913 lines
26 KiB
TypeScript
913 lines
26 KiB
TypeScript
"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: (
|
|
<>
|
|
<ToolbarIconBtn
|
|
disabled={disabled || !canUndo}
|
|
onClick={() => editor.chain().focus().undo().run()}
|
|
label="Annuler"
|
|
>
|
|
<Undo className="size-4" />
|
|
</ToolbarIconBtn>
|
|
<ToolbarIconBtn
|
|
disabled={disabled || !canRedo}
|
|
onClick={() => editor.chain().focus().redo().run()}
|
|
label="Rétablir"
|
|
>
|
|
<Redo className="size-4" />
|
|
</ToolbarIconBtn>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
id: "print",
|
|
sepAfter: false,
|
|
node: (
|
|
<>
|
|
<ToolbarIconBtn label="Imprimer" onClick={() => window.print()}>
|
|
<Printer className="size-4" />
|
|
</ToolbarIconBtn>
|
|
<ToolbarIconBtn
|
|
disabled={disabled}
|
|
active={spellcheck}
|
|
label={
|
|
spellcheck
|
|
? "Désactiver la vérification orthographique"
|
|
: "Activer la vérification orthographique"
|
|
}
|
|
onClick={onToggleSpellcheck}
|
|
>
|
|
<Icon icon="material-symbols:spellcheck" className="size-4" />
|
|
</ToolbarIconBtn>
|
|
<ToolbarIconBtn disabled label="Reproduire la mise en forme (bientôt)">
|
|
<Icon icon="material-symbols:format-paint-outline" className="size-4" />
|
|
</ToolbarIconBtn>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
id: "zoom",
|
|
sepAfter: true,
|
|
node: (
|
|
<Select
|
|
value={String(zoom)}
|
|
onValueChange={(v) => onZoomChange(Number(v))}
|
|
disabled={disabled}
|
|
>
|
|
<SelectTrigger className="docs-toolbar-select h-7 w-[72px] shrink-0 border-0 bg-transparent px-1 shadow-none">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{ZOOM_OPTIONS.map((z) => (
|
|
<SelectItem key={z} value={String(z)}>
|
|
{z}%
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
),
|
|
},
|
|
{
|
|
id: "style",
|
|
sepAfter: true,
|
|
node: (
|
|
<Select
|
|
value={styleId}
|
|
onValueChange={(v) => applyTextStyle(editor, v)}
|
|
disabled={disabled}
|
|
>
|
|
<SelectTrigger className="docs-toolbar-select h-7 w-[120px] shrink-0 border-0 bg-transparent px-1 shadow-none">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="docs-toolbar-select-content docs-toolbar-select-content--style">
|
|
{TEXT_STYLES.map((s) => (
|
|
<SelectItem
|
|
key={s.id}
|
|
value={s.id}
|
|
className="docs-toolbar-style-item"
|
|
>
|
|
<span
|
|
className={cn(
|
|
"docs-toolbar-style-preview",
|
|
`docs-toolbar-style-preview--${s.id}`
|
|
)}
|
|
>
|
|
{s.label}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
),
|
|
},
|
|
{
|
|
id: "font-family",
|
|
sepAfter: true,
|
|
node: (
|
|
<Select
|
|
disabled={disabled}
|
|
value={fontFamilyState.kind === "single" ? fontFamilyState.name : undefined}
|
|
onValueChange={(value) => applyFontFamily(editor, value as DocsFontFamilyName)}
|
|
>
|
|
<SelectTrigger
|
|
className="docs-toolbar-select h-7 w-[108px] shrink-0 border-0 bg-transparent px-1 shadow-none"
|
|
style={
|
|
fontFamilyState.kind === "single"
|
|
? {
|
|
fontFamily: DOCS_FONT_FAMILIES.find(
|
|
(f) => f.name === fontFamilyState.name
|
|
)?.stack,
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
<SelectValue placeholder="Police" />
|
|
</SelectTrigger>
|
|
<SelectContent className="docs-toolbar-select-content docs-toolbar-select-content--font">
|
|
{DOCS_FONT_FAMILIES.map((f) => (
|
|
<SelectItem key={f.name} value={f.name}>
|
|
<span
|
|
className="docs-toolbar-font-preview"
|
|
style={{ fontFamily: f.stack }}
|
|
>
|
|
{f.name}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
),
|
|
},
|
|
{
|
|
id: "font-size",
|
|
sepAfter: true,
|
|
node: (
|
|
<div className="docs-toolbar-font-size flex shrink-0 items-center">
|
|
<ToolbarIconBtn
|
|
disabled={disabled || !canStepFontSizeDown}
|
|
label="Diminuer la taille"
|
|
className="docs-toolbar-btn--size-step"
|
|
onClick={() => stepFontSizePx(editor, -1)}
|
|
>
|
|
<Minus className="size-3.5" />
|
|
</ToolbarIconBtn>
|
|
<Select
|
|
disabled={disabled}
|
|
value={fontSizeState.kind === "single" ? String(fontSizeState.size) : undefined}
|
|
onValueChange={(value) => applyFontSizePx(editor, Number(value))}
|
|
>
|
|
<SelectTrigger className="docs-toolbar-select docs-toolbar-select--size shrink-0 bg-transparent shadow-none">
|
|
<SelectValue placeholder="" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DOCS_FONT_SIZES.map((size) => (
|
|
<SelectItem key={size} value={String(size)}>
|
|
{size}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<ToolbarIconBtn
|
|
disabled={disabled || !canStepFontSizeUp}
|
|
label="Augmenter la taille"
|
|
className="docs-toolbar-btn--size-step"
|
|
onClick={() => stepFontSizePx(editor, 1)}
|
|
>
|
|
<Plus className="size-3.5" />
|
|
</ToolbarIconBtn>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
id: "marks-basic",
|
|
sepAfter: false,
|
|
node: (
|
|
<>
|
|
<ToolbarIconBtn
|
|
disabled={disabled}
|
|
active={isBold}
|
|
onClick={() => editor.chain().focus().toggleMark("bold").run()}
|
|
label="Gras"
|
|
>
|
|
<Bold className="size-4" />
|
|
</ToolbarIconBtn>
|
|
<ToolbarIconBtn
|
|
disabled={disabled}
|
|
active={isItalic}
|
|
onClick={() => editor.chain().focus().toggleMark("italic").run()}
|
|
label="Italique"
|
|
>
|
|
<Italic className="size-4" />
|
|
</ToolbarIconBtn>
|
|
<ToolbarIconBtn
|
|
disabled={disabled}
|
|
active={isUnderline}
|
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
label="Souligné"
|
|
>
|
|
<UnderlineIcon className="size-4" />
|
|
</ToolbarIconBtn>
|
|
<ColorPicker
|
|
disabled={disabled}
|
|
label="Couleur du texte"
|
|
colors={TEXT_COLORS}
|
|
currentColor={textColor}
|
|
onPick={(color) => editor.chain().focus().setColor(color).run()}
|
|
icon="material-symbols:format-color-text"
|
|
/>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
id: "highlight",
|
|
sepAfter: true,
|
|
node: (
|
|
<HighlightColorPicker
|
|
disabled={disabled}
|
|
colors={HIGHLIGHT_COLORS}
|
|
currentColor={highlightColor ?? "transparent"}
|
|
isActive={highlightColor != null}
|
|
onPick={(color) => {
|
|
editor.chain().focus().setHighlight({ color }).run()
|
|
}}
|
|
onClear={() => editor.chain().focus().unsetHighlight().run()}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
id: "insert-link",
|
|
sepAfter: false,
|
|
node: (
|
|
<>
|
|
<Popover open={linkOpen} onOpenChange={setLinkOpen}>
|
|
<PopoverTrigger asChild>
|
|
<ToolbarIconBtn
|
|
disabled={disabled}
|
|
active={isLink}
|
|
label="Lien"
|
|
onClick={() => {
|
|
const prev = editor.getAttributes("link").href as string | undefined
|
|
setLinkUrl(prev ?? "")
|
|
}}
|
|
>
|
|
<Link2 className="size-4" />
|
|
</ToolbarIconBtn>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-72 p-2" align="start">
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="url"
|
|
value={linkUrl}
|
|
placeholder="https://"
|
|
className="h-8 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
onChange={(e) => setLinkUrl(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") applyLink()
|
|
}}
|
|
/>
|
|
<Button type="button" size="sm" onClick={applyLink}>
|
|
OK
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<ToolbarIconBtn disabled label="Commentaire (bientôt)">
|
|
<Icon icon="material-symbols:add-comment-outline" className="size-4" />
|
|
</ToolbarIconBtn>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
id: "insert-graphic",
|
|
sepAfter: true,
|
|
node: (
|
|
<>
|
|
<DocsGraphicInsertMenu editor={editor} disabled={disabled} />
|
|
{graphicSelected ? (
|
|
<>
|
|
<DocsGraphicLayoutMenu editor={editor} disabled={disabled} />
|
|
<DocsGraphicOptionsPanel editor={editor} disabled={disabled} />
|
|
</>
|
|
) : null}
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
id: "align",
|
|
sepAfter: false,
|
|
node: (
|
|
<>
|
|
<ToolbarIconBtn
|
|
disabled={disabled}
|
|
active={alignLeft}
|
|
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
|
label="Aligner à gauche"
|
|
>
|
|
<AlignLeft className="size-4" />
|
|
</ToolbarIconBtn>
|
|
<ToolbarIconBtn
|
|
disabled={disabled}
|
|
active={alignCenter}
|
|
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
|
label="Centrer"
|
|
>
|
|
<AlignCenter className="size-4" />
|
|
</ToolbarIconBtn>
|
|
<ToolbarIconBtn
|
|
disabled={disabled}
|
|
active={alignRight}
|
|
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
|
label="Aligner à droite"
|
|
>
|
|
<AlignRight className="size-4" />
|
|
</ToolbarIconBtn>
|
|
<ToolbarIconBtn
|
|
disabled={disabled}
|
|
active={alignJustify}
|
|
onClick={() => editor.chain().focus().setTextAlign("justify").run()}
|
|
label="Justifier"
|
|
>
|
|
<AlignJustify className="size-4" />
|
|
</ToolbarIconBtn>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="docs-toolbar-btn size-7 shrink-0"
|
|
disabled={disabled}
|
|
aria-label="Interligne et espacement"
|
|
>
|
|
<Icon icon="material-symbols:format-line-spacing" className="size-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<DropdownMenuItem disabled>Bientôt disponible</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
id: "lists",
|
|
sepAfter: false,
|
|
node: (
|
|
<>
|
|
<ToolbarIconBtn
|
|
disabled={disabled}
|
|
active={isBulletList}
|
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
label="Liste à puces"
|
|
>
|
|
<List className="size-4" />
|
|
</ToolbarIconBtn>
|
|
<ToolbarIconBtn
|
|
disabled={disabled}
|
|
active={isOrderedList}
|
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
label="Liste numérotée"
|
|
>
|
|
<ListOrdered className="size-4" />
|
|
</ToolbarIconBtn>
|
|
<ToolbarIconBtn disabled label="Liste de contrôle (bientôt)">
|
|
<Icon icon="material-symbols:checklist" className="size-4" />
|
|
</ToolbarIconBtn>
|
|
<ToolbarIconBtn disabled label="Diminuer le retrait (bientôt)">
|
|
<Icon icon="material-symbols:format-indent-decrease" className="size-4" />
|
|
</ToolbarIconBtn>
|
|
<ToolbarIconBtn disabled label="Augmenter le retrait (bientôt)">
|
|
<Icon icon="material-symbols:format-indent-increase" className="size-4" />
|
|
</ToolbarIconBtn>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
id: "clear",
|
|
sepAfter: false,
|
|
node: (
|
|
<ToolbarIconBtn
|
|
disabled={disabled}
|
|
label="Effacer la mise en forme"
|
|
onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
|
|
>
|
|
<Icon icon="material-symbols:format-clear" className="size-4" />
|
|
</ToolbarIconBtn>
|
|
),
|
|
},
|
|
]
|
|
}, [
|
|
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 = (
|
|
<div
|
|
ref={containerRef}
|
|
className="docs-toolbar relative flex items-center gap-0 overflow-hidden px-1.5 py-0.5"
|
|
>
|
|
<div
|
|
ref={measureRef}
|
|
className="pointer-events-none invisible absolute left-0 top-0 flex h-0 overflow-hidden whitespace-nowrap"
|
|
aria-hidden
|
|
>
|
|
{segments.map((segment) => (
|
|
<div key={segment.id} className="flex shrink-0 items-center">
|
|
<div className="flex shrink-0 items-center gap-0">{segment.node}</div>
|
|
{segment.sepAfter ? <ToolbarSep /> : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex min-w-0 flex-1 items-center overflow-hidden">
|
|
{visibleSegments.map((segment) => (
|
|
<div key={segment.id} className="flex shrink-0 items-center">
|
|
<div className="flex shrink-0 items-center gap-0">{segment.node}</div>
|
|
{segment.sepAfter ? <ToolbarSep /> : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{hasOverflow ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="docs-toolbar-btn size-7 shrink-0"
|
|
aria-label="Plus d'actions"
|
|
>
|
|
<MoreHorizontal className="size-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="max-h-[min(70vh,480px)] w-auto overflow-y-auto p-2">
|
|
<div className="flex flex-col gap-1">
|
|
{overflowSegments.map((segment, index) => (
|
|
<div
|
|
key={segment.id}
|
|
className="flex flex-wrap items-center gap-0.5 border-t border-border pt-1 first:border-t-0 first:pt-0"
|
|
>
|
|
{index > 0 ? null : null}
|
|
{segment.node}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
|
|
{showChromeToggle ? (
|
|
<>
|
|
<ToolbarSep />
|
|
<ToolbarIconBtn
|
|
active={chromeCollapsed}
|
|
label={
|
|
chromeCollapsed
|
|
? "Afficher l'en-tête du document"
|
|
: "Masquer l'en-tête du document"
|
|
}
|
|
onClick={onToggleChromeCollapsed}
|
|
>
|
|
{chromeCollapsed ? (
|
|
<ChevronDown className="size-4" />
|
|
) : (
|
|
<ChevronUp className="size-4" />
|
|
)}
|
|
</ToolbarIconBtn>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
)
|
|
|
|
if (embedded) return toolbarRow
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"docs-toolbar-shell shrink-0",
|
|
chromeCollapsed && "docs-toolbar-shell--collapsed"
|
|
)}
|
|
>
|
|
{toolbarRow}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const DocsToolbar = memo(DocsToolbarInner)
|
|
|
|
function ToolbarSep() {
|
|
return <span aria-hidden className="docs-toolbar-sep" />
|
|
}
|
|
|
|
function ToolbarIconBtn({
|
|
ref,
|
|
children,
|
|
onClick,
|
|
active,
|
|
disabled,
|
|
label,
|
|
className,
|
|
...rest
|
|
}: {
|
|
children: React.ReactNode
|
|
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
|
active?: boolean
|
|
disabled?: boolean
|
|
label: string
|
|
className?: string
|
|
} & React.ComponentPropsWithoutRef<"button"> & {
|
|
ref?: React.Ref<HTMLButtonElement>
|
|
}) {
|
|
return (
|
|
<Button
|
|
ref={ref}
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(
|
|
"docs-toolbar-btn size-7 shrink-0",
|
|
active && "docs-toolbar-btn--active",
|
|
className
|
|
)}
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
aria-label={label}
|
|
title={label}
|
|
aria-pressed={active}
|
|
{...rest}
|
|
>
|
|
{children}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<span
|
|
className={cn("docs-toolbar-color-glyph", highlight && "docs-toolbar-color-glyph--highlight")}
|
|
aria-hidden
|
|
>
|
|
<Icon icon={icon} className="docs-toolbar-color-glyph__icon docs-toolbar-icon" />
|
|
<span className="docs-toolbar-color-glyph__swatch" style={{ backgroundColor: color }} />
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function ColorPicker({
|
|
disabled,
|
|
label,
|
|
colors,
|
|
currentColor,
|
|
onPick,
|
|
icon,
|
|
}: {
|
|
disabled?: boolean
|
|
label: string
|
|
colors: readonly string[]
|
|
currentColor: string
|
|
onPick: (color: string) => void
|
|
icon: string
|
|
}) {
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="docs-toolbar-btn size-7 shrink-0 px-1"
|
|
disabled={disabled}
|
|
aria-label={label}
|
|
title={label}
|
|
>
|
|
<ColorToolbarGlyph icon={icon} color={currentColor} />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-2" align="start">
|
|
<div className="grid grid-cols-5 gap-1">
|
|
{colors.map((color) => (
|
|
<button
|
|
key={color}
|
|
type="button"
|
|
className={cn(
|
|
"size-6 rounded-sm border border-border",
|
|
currentColor.toLowerCase() === color.toLowerCase() && "ring-2 ring-[#1967d2]"
|
|
)}
|
|
style={{ backgroundColor: color }}
|
|
aria-label={color}
|
|
onClick={() => onPick(color)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
function HighlightColorPicker({
|
|
disabled,
|
|
colors,
|
|
currentColor,
|
|
isActive,
|
|
onPick,
|
|
onClear,
|
|
}: {
|
|
disabled?: boolean
|
|
colors: readonly string[]
|
|
currentColor: string
|
|
isActive: boolean
|
|
onPick: (color: string) => void
|
|
onClear: () => void
|
|
}) {
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="docs-toolbar-btn size-7 shrink-0 px-1"
|
|
disabled={disabled}
|
|
aria-label="Couleur de surlignage"
|
|
title="Couleur de surlignage"
|
|
>
|
|
<ColorToolbarGlyph
|
|
icon="material-symbols:format-ink-highlighter"
|
|
color={currentColor}
|
|
highlight
|
|
/>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-2" align="start">
|
|
<div className="grid grid-cols-6 gap-1">
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex size-6 items-center justify-center rounded-sm border border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
|
!isActive && "ring-2 ring-[#1967d2]"
|
|
)}
|
|
aria-label="Retirer le surlignage"
|
|
title="Retirer le surlignage"
|
|
onClick={onClear}
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
{colors.map((color) => (
|
|
<button
|
|
key={color}
|
|
type="button"
|
|
className={cn(
|
|
"size-6 rounded-sm border border-border",
|
|
isActive &&
|
|
currentColor.toLowerCase() === color.toLowerCase() &&
|
|
"ring-2 ring-[#1967d2]"
|
|
)}
|
|
style={{ backgroundColor: color }}
|
|
aria-label={color}
|
|
onClick={() => onPick(color)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|