"use client" import { memo, useCallback, useMemo, useRef, useState } from "react" import { Icon } from "@iconify/react" import type { Editor } from "@tiptap/react" import { AlignCenter, AlignJustify, AlignLeft, AlignRight, ChevronDown, ChevronUp, Bold, Image as ImageIcon, Italic, Link2, List, ListOrdered, Minus, MoreHorizontal, Plus, Printer, Redo, Underline as UnderlineIcon, Undo, X, } from "lucide-react" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { useToolbarOverflow } from "@/lib/drive/use-toolbar-overflow" import { applyFontSizePx, DOCS_FONT_SIZES, stepFontSizePx, } from "@/lib/drive/docs-font-size" import { applyFontFamily, DOCS_FONT_FAMILIES, type DocsFontFamilyName, } from "@/lib/drive/docs-font-family" import { useDocsToolbarState } from "@/lib/drive/use-docs-toolbar-state" import { cn } from "@/lib/utils" const TEXT_STYLES = [ { id: "paragraph", label: "Texte normal" }, { id: "heading1", label: "Titre 1" }, { id: "heading2", label: "Titre 2" }, { id: "heading3", label: "Titre 3" }, { id: "heading4", label: "Titre 4" }, ] as const const TEXT_COLORS = [ "#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#efefef", "#f3f3f3", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", ] as const /** Classic highlighter + pastel palette (Google Docs / marker style). */ const HIGHLIGHT_COLORS = [ "#ffff00", "#fff475", "#fce8b2", "#f4cccc", "#ffc8dd", "#d9ead3", "#b6d7a8", "#cfe2f3", "#a4c2f4", "#d9d2e9", "#e6e6e6", ] as const const ZOOM_OPTIONS = [50, 75, 90, 100, 125, 150, 200] as const function applyTextStyle(editor: Editor, styleId: string) { if (styleId === "paragraph") { editor.chain().focus().setParagraph().run() return } const level = Number(styleId.replace("heading", "")) as 1 | 2 | 3 | 4 editor.chain().focus().setHeading({ level }).run() } function DocsToolbarInner({ editor, disabled, zoom, onZoomChange, spellcheck, onToggleSpellcheck, showChromeToggle, chromeCollapsed, onToggleChromeCollapsed, }: { editor: Editor | null disabled?: boolean zoom: number onZoomChange: (zoom: number) => void spellcheck: boolean onToggleSpellcheck: () => void showChromeToggle?: boolean chromeCollapsed?: boolean onToggleChromeCollapsed?: () => void }) { const imageInputRef = useRef(null) const [linkOpen, setLinkOpen] = useState(false) const [linkUrl, setLinkUrl] = useState("") const toolbarState = useDocsToolbarState(editor) const insertImage = useCallback( (file: File) => { if (!editor) return const reader = new FileReader() reader.onload = () => { const src = reader.result as string editor.chain().focus().setImage({ src }).run() } reader.readAsDataURL(file) }, [editor] ) const applyLink = useCallback(() => { if (!editor) return const url = linkUrl.trim() if (!url) { editor.chain().focus().unsetLink().run() } else { editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run() } setLinkOpen(false) setLinkUrl("") }, [editor, linkUrl]) const segments = useMemo(() => { if (!editor || !toolbarState) return [] const { canUndo, canRedo, styleId, fontFamilyState, fontSizeState, canStepFontSizeDown, canStepFontSizeUp, textColor, highlightColor, isBold, isItalic, isUnderline, isLink, alignLeft, alignCenter, alignRight, alignJustify, isBulletList, isOrderedList, } = toolbarState return [ { id: "history", sepAfter: false, node: ( <> editor.chain().focus().undo().run()} label="Annuler" > editor.chain().focus().redo().run()} label="Rétablir" > ), }, { id: "print", sepAfter: false, node: ( <> window.print()}> ), }, { id: "zoom", sepAfter: true, node: ( ), }, { id: "style", sepAfter: true, node: ( ), }, { id: "font-family", sepAfter: true, node: ( ), }, { id: "font-size", sepAfter: true, node: (
stepFontSizePx(editor, -1)} > stepFontSizePx(editor, 1)} >
), }, { id: "marks-basic", sepAfter: false, node: ( <> editor.chain().focus().toggleMark("bold").run()} label="Gras" > editor.chain().focus().toggleMark("italic").run()} label="Italique" > editor.chain().focus().toggleUnderline().run()} label="Souligné" > editor.chain().focus().setColor(color).run()} icon="material-symbols:format-color-text" /> ), }, { id: "highlight", sepAfter: true, node: ( { editor.chain().focus().setHighlight({ color }).run() }} onClear={() => editor.chain().focus().unsetHighlight().run()} /> ), }, { id: "insert-link", sepAfter: false, node: ( <> { const prev = editor.getAttributes("link").href as string | undefined setLinkUrl(prev ?? "") }} >
setLinkUrl(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") applyLink() }} />
), }, { id: "insert-image", sepAfter: true, node: ( imageInputRef.current?.click()} > ), }, { id: "align", sepAfter: false, node: ( <> editor.chain().focus().setTextAlign("left").run()} label="Aligner à gauche" > editor.chain().focus().setTextAlign("center").run()} label="Centrer" > editor.chain().focus().setTextAlign("right").run()} label="Aligner à droite" > editor.chain().focus().setTextAlign("justify").run()} label="Justifier" > Bientôt disponible ), }, { id: "lists", sepAfter: false, node: ( <> editor.chain().focus().toggleBulletList().run()} label="Liste à puces" > editor.chain().focus().toggleOrderedList().run()} label="Liste numérotée" > ), }, { id: "clear", sepAfter: false, node: ( editor.chain().focus().unsetAllMarks().clearNodes().run()} > ), }, ] }, [ editor, toolbarState, disabled, zoom, onZoomChange, spellcheck, onToggleSpellcheck, linkOpen, linkUrl, applyLink, ]) const { containerRef, measureRef, visibleCount, hasOverflow } = useToolbarOverflow( segments.length ) if (!editor) return null const visibleSegments = segments.slice(0, visibleCount) const overflowSegments = segments.slice(visibleCount) return (
{segments.map((segment) => (
{segment.node}
{segment.sepAfter ? : null}
))}
{visibleSegments.map((segment) => (
{segment.node}
{segment.sepAfter ? : null}
))}
{hasOverflow ? (
{overflowSegments.map((segment, index) => (
{index > 0 ? null : null} {segment.node}
))}
) : null} {showChromeToggle ? ( <> {chromeCollapsed ? ( ) : ( )} ) : null} { const file = e.target.files?.[0] if (file) insertImage(file) e.target.value = "" }} />
) } export const DocsToolbar = memo(DocsToolbarInner) function ToolbarSep() { return } function ToolbarIconBtn({ ref, children, onClick, active, disabled, label, className, ...rest }: { children: React.ReactNode onClick?: React.MouseEventHandler active?: boolean disabled?: boolean label: string className?: string } & React.ComponentPropsWithoutRef<"button"> & { ref?: React.Ref }) { return ( ) } function colorSwatchOutlineClass(hex: string): string { const luminance = colorRelativeLuminance(hex) if (luminance > 0.72) { return "border border-black/50 ring-1 ring-black/30" } if (luminance > 0.45) { return "border border-black/40 ring-1 ring-black/20" } return "border border-white/60 ring-1 ring-black/35" } function colorRelativeLuminance(hex: string): number { const raw = hex.replace("#", "") if (raw.length !== 6) return 0 const channel = (index: number) => { const value = parseInt(raw.slice(index, index + 2), 16) / 255 return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 } return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4) } function ColorToolbarGlyph({ icon, color, highlight = false, }: { icon: string color: string highlight?: boolean }) { return ( ) } function ColorPicker({ disabled, label, colors, currentColor, onPick, icon, }: { disabled?: boolean label: string colors: readonly string[] currentColor: string onPick: (color: string) => void icon: string }) { return (
{colors.map((color) => (
) } function HighlightColorPicker({ disabled, colors, currentColor, isActive, onPick, onClear, }: { disabled?: boolean colors: readonly string[] currentColor: string isActive: boolean onPick: (color: string) => void onClear: () => void }) { return (
{colors.map((color) => (
) }