"use client"
import { memo, useCallback, useMemo, useState } from "react"
import { Icon } from "@iconify/react"
import type { Editor } from "@tiptap/react"
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
ChevronDown,
ChevronUp,
Bold,
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 {
DocsGraphicInsertMenu,
DocsGraphicLayoutMenu,
readGraphicToolbarActive,
} from "@/components/drive/richtext/docs-graphic-toolbar-menu"
import { DocsGraphicOptionsPanel } from "@/components/drive/richtext/docs-graphic-options-panel"
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,
embedded,
}: {
editor: Editor | null
disabled?: boolean
zoom: number
onZoomChange: (zoom: number) => void
spellcheck: boolean
onToggleSpellcheck: () => void
showChromeToggle?: boolean
chromeCollapsed?: boolean
onToggleChromeCollapsed?: () => void
/** Rendered inside DocsEditorWorkspace shell (no outer docs-toolbar-shell). */
embedded?: boolean
}) {
const [linkOpen, setLinkOpen] = useState(false)
const [linkUrl, setLinkUrl] = useState("")
const toolbarState = useDocsToolbarState(editor)
const graphicSelected = readGraphicToolbarActive(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-graphic",
sepAfter: true,
node: (
<>
{graphicSelected ? (
<>
>
) : null}
>
),
},
{
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,
graphicSelected,
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)
const toolbarRow = (
{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}
)
if (embedded) return toolbarRow
return (
{toolbarRow}
)
}
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) => (
)
}