ultisuite-client/components/drive/richtext/docs-toolbar.tsx
R3D347HR4Y 770669424e
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(drive): refine select trigger styles in rich text editor
- Updated select trigger components for paragraph style and font family to include a ghost variant for improved visual consistency.
- Adjusted CSS styles to standardize height and padding, enhancing the overall appearance of select elements.
- Improved hover and focus states for better user interaction feedback across different themes.
2026-06-15 18:03:02 +02:00

902 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,
List,
ListOrdered,
Minus,
MoreHorizontal,
Plus,
Printer,
Redo,
Underline as UnderlineIcon,
Undo,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
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 {
DOCS_FONT_FAMILIES,
} from "@/lib/drive/docs-font-family"
import {
DocsGraphicInsertMenu,
} from "@/components/drive/richtext/docs-graphic-toolbar-menu"
import { DocsLinkPopover } from "@/components/drive/richtext/docs-link-popover"
import { DocsParagraphStyleSelect } from "@/components/drive/richtext/docs-paragraph-style-ui"
import { useDocsParagraphStylesContext } from "@/lib/drive/docs-paragraph-styles-context"
import { docsFontStackByName, useDocsFonts } from "@/lib/drive/use-docs-fonts"
import { useDocsToolbarState } from "@/lib/drive/use-docs-toolbar-state"
import { DOCS_LINE_HEIGHT_PRESETS } from "@/lib/drive/docs-line-spacing"
import { readDocsCustomSpacingDraft } from "@/lib/drive/docs-line-spacing-actions"
import { DocsLineSpacingDialog } from "@/components/drive/richtext/docs-line-spacing-dialog"
import { cn } from "@/lib/utils"
const ZOOM_OPTIONS = [50, 75, 90, 100, 125, 150, 200] 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
function DocsToolbarInner({
editor,
disabled,
zoom,
onZoomChange,
spellcheck,
onToggleSpellcheck,
showChromeToggle,
chromeCollapsed,
onToggleChromeCollapsed,
onPrint,
embedded,
}: {
editor: Editor | null
disabled?: boolean
zoom: number
onZoomChange: (zoom: number) => void
spellcheck: boolean
onToggleSpellcheck: () => void
showChromeToggle?: boolean
chromeCollapsed?: boolean
onToggleChromeCollapsed?: () => void
onPrint?: () => void
/** Rendered inside DocsEditorWorkspace shell (no outer docs-toolbar-shell). */
embedded?: boolean
}) {
const toolbarState = useDocsToolbarState(editor)
const paragraphStylesCtx = useDocsParagraphStylesContext()
const fontsQuery = useDocsFonts()
const fonts = fontsQuery.data ?? DOCS_FONT_FAMILIES.map((f) => ({ name: f.name, stack: f.stack }))
const [customSpacingOpen, setCustomSpacingOpen] = useState(false)
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,
isTaskList,
canIncreaseIndent,
canDecreaseIndent,
lineHeightPresetId,
} = 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={() => {
if (onPrint) onPrint()
else toast.error("Impossible d'imprimer le document")
}}
>
<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: paragraphStylesCtx ? (
<DocsParagraphStyleSelect
value={styleId}
disabled={disabled}
documentStyles={paragraphStylesCtx.state.documentStyles}
userStyles={paragraphStylesCtx.state.userStyles}
onValueChange={(value) => paragraphStylesCtx.applyStyle(value)}
/>
) : (
<Select value={styleId} disabled>
<SelectTrigger className="docs-toolbar-select h-7 w-[120px] shrink-0 border-0 bg-transparent px-1 shadow-none">
<SelectValue />
</SelectTrigger>
</Select>
),
},
{
id: "font-family",
sepAfter: true,
node: (
<Select
disabled={disabled}
value={fontFamilyState.kind === "single" ? fontFamilyState.name : undefined}
onValueChange={(value) => {
editor.chain().focus().setFontFamily(docsFontStackByName(fonts, value)).run()
}}
>
<SelectTrigger
variant="ghost"
className="docs-toolbar-select docs-toolbar-select--font-family h-7 w-[108px] shrink-0 border-0 bg-transparent px-1 py-0 shadow-none"
style={
fontFamilyState.kind === "single"
? {
fontFamily: docsFontStackByName(fonts, fontFamilyState.name),
}
: undefined
}
>
<SelectValue placeholder="Police" />
</SelectTrigger>
<SelectContent className="docs-toolbar-select-content docs-toolbar-select-content--font">
{fonts.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: (
<>
<DocsLinkPopover editor={editor} disabled={disabled} active={isLink} />
<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} />,
},
{
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" className="min-w-[220px]">
{DOCS_LINE_HEIGHT_PRESETS.map((option) => (
<DropdownMenuItem
key={option.id}
disabled={disabled}
onClick={() => editor.chain().focus().setDocsLineHeight(option.value).run()}
>
<span className="flex w-full items-center justify-between gap-3">
{option.label}
{lineHeightPresetId === option.id ? (
<Icon icon="material-symbols:check" className="size-4 opacity-70" />
) : null}
</span>
</DropdownMenuItem>
))}
<DropdownMenuItem disabled={disabled} onClick={() => setCustomSpacingOpen(true)}>
Espacement personnalisé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
),
},
{
id: "lists",
sepAfter: false,
node: (
<>
<ToolbarIconBtn
disabled={disabled}
active={isBulletList}
onClick={() => {
if (isBulletList) editor.chain().focus().toggleBulletList().run()
else editor.chain().focus().applyDocsBulletStyle("disc").run()
}}
label="Liste à puces"
>
<List className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn
disabled={disabled}
active={isOrderedList}
onClick={() => {
if (isOrderedList) editor.chain().focus().toggleOrderedList().run()
else editor.chain().focus().applyDocsOrderedStyle("decimal").run()
}}
label="Liste numérotée"
>
<ListOrdered className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn
disabled={disabled}
active={isTaskList}
onClick={() => {
if (isTaskList) editor.chain().focus().toggleTaskList().run()
else editor.chain().focus().applyDocsChecklistStyle("simple").run()
}}
label="Liste de contrôle"
>
<Icon icon="material-symbols:checklist" className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn
disabled={disabled || !canDecreaseIndent}
onClick={() => editor.chain().focus().decreaseDocsIndent().run()}
label="Diminuer le retrait"
>
<Icon icon="material-symbols:format-indent-decrease" className="size-4" />
</ToolbarIconBtn>
<ToolbarIconBtn
disabled={disabled || !canIncreaseIndent}
onClick={() => editor.chain().focus().increaseDocsIndent().run()}
label="Augmenter le retrait"
>
<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,
paragraphStylesCtx,
fonts,
])
const reservedTrailingPx = showChromeToggle ? 44 : 0
const { containerRef, measureRef, visibleCount, hasOverflow } = useToolbarOverflow(
segments.length,
reservedTrailingPx
)
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 min-w-0 w-full max-w-full 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}
{editor ? (
<DocsLineSpacingDialog
open={customSpacingOpen}
onOpenChange={setCustomSpacingOpen}
initial={readDocsCustomSpacingDraft(editor)}
onApply={(input) => editor.chain().focus().setDocsCustomSpacing(input).run()}
/>
) : null}
</>
)
}
return (
<div
className={cn(
"docs-toolbar-shell shrink-0",
chromeCollapsed && "docs-toolbar-shell--collapsed"
)}
>
{toolbarRow}
{editor ? (
<DocsLineSpacingDialog
open={customSpacingOpen}
onOpenChange={setCustomSpacingOpen}
initial={readDocsCustomSpacingDraft(editor)}
onApply={(input) => editor.chain().focus().setDocsCustomSpacing(input).run()}
/>
) : null}
</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>
)
}