ultisuite-client/components/drive/richtext/docs-graphic-floating-toolbar.tsx
R3D347HR4Y 303b2b1074
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wow
2026-06-11 01:22:40 +02:00

489 lines
16 KiB
TypeScript

"use client"
import { memo, useCallback, useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import type { Editor } from "@tiptap/react"
import { Icon } from "@iconify/react"
import {
ArrowDown,
ArrowUp,
Check,
ChevronDown,
Crop,
MoreHorizontal,
Settings2,
Shapes,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DOCS_GRAPHIC_WRAP_MARGIN_PRESETS_MM } from "@/lib/drive/docs-graphic-layout"
import { usesPageLayer } from "@/lib/drive/docs-graphic-position"
import {
DOCS_GRAPHIC_WRAP_LABELS,
parseGraphicAttrs,
type DocsGraphicFloatSide,
type DocsGraphicPositionMode,
type DocsGraphicWrap,
} from "@/lib/drive/docs-graphic-types"
import {
DOCS_GRAPHIC_ADVANCED_WRAP_ACTIONS,
DOCS_GRAPHIC_FLOATING_WRAP_ACTIONS,
DOCS_GRAPHIC_FLOAT_SIDE_ICONS,
DOCS_GRAPHIC_FLOAT_SIDE_LABELS,
DOCS_GRAPHIC_LAYER_WRAP_ACTIONS,
DOCS_GRAPHIC_POSITION_MODE_SHORT_LABELS,
DOCS_GRAPHIC_WRAP_ICONS,
} from "@/lib/drive/docs-graphic-ui"
import { openDocsGraphicDrawEditor } from "@/lib/drive/docs-graphic-draw-bridge"
import { readGraphicToolbarActive } from "@/components/drive/richtext/docs-graphic-toolbar-menu"
import { openDocsGraphicOptionsSidebar } from "@/components/drive/richtext/docs-graphic-options-sidebar"
import {
applyDocsGraphicCrop,
DOCS_GRAPHIC_CROP_CHANGED_EVENT,
readDocsGraphicCropActive,
startDocsGraphicCrop,
} from "@/lib/drive/docs-graphic-crop-bridge"
import { cn } from "@/lib/utils"
function readSelectedGraphic(editor: Editor) {
const name = editor.isActive("docsInlineGraphic")
? "docsInlineGraphic"
: editor.isActive("docsGraphic")
? "docsGraphic"
: null
if (!name) return null
return {
name,
attrs: parseGraphicAttrs(editor.getAttributes(name) as Record<string, unknown>),
}
}
function ToolbarDivider() {
return <span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
}
function keepEditorSelectionOnMenuClose(event: Event) {
event.preventDefault()
}
function IconToolbarButton({
label,
active,
onClick,
children,
}: {
label: string
active?: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"size-8 shrink-0 rounded-full text-popover-foreground hover:bg-accent hover:text-accent-foreground",
active && "bg-accent text-primary hover:bg-accent hover:text-primary"
)}
aria-label={label}
title={label}
onClick={onClick}
>
{children}
</Button>
)
}
function DocsGraphicFloatingToolbarInner({
editor,
canvasRef,
disabled,
}: {
editor: Editor | null
canvasRef: React.RefObject<HTMLElement | null>
disabled?: boolean
}) {
const [rect, setRect] = useState<DOMRect | null>(null)
const [cropActive, setCropActive] = useState(false)
const [, setTick] = useState(0)
const replaceInputRef = useRef<HTMLInputElement>(null)
const refresh = useCallback(() => {
setTick((t) => t + 1)
if (!editor || !readGraphicToolbarActive(editor)) {
setRect(null)
return
}
const selected = document.querySelector(
".docs-graphic--selected, .ProseMirror-selectednode .docs-graphic"
) as HTMLElement | null
if (!selected) {
setRect(null)
return
}
setRect(selected.getBoundingClientRect())
setCropActive(readDocsGraphicCropActive())
}, [editor])
useEffect(() => {
if (!editor) return
refresh()
editor.on("selectionUpdate", refresh)
editor.on("transaction", refresh)
const canvas = canvasRef.current
canvas?.addEventListener("scroll", refresh, { passive: true })
window.addEventListener("resize", refresh)
window.addEventListener(DOCS_GRAPHIC_CROP_CHANGED_EVENT, refresh)
return () => {
editor.off("selectionUpdate", refresh)
editor.off("transaction", refresh)
canvas?.removeEventListener("scroll", refresh)
window.removeEventListener("resize", refresh)
window.removeEventListener(DOCS_GRAPHIC_CROP_CHANGED_EVENT, refresh)
}
}, [canvasRef, editor, refresh])
if (!editor || disabled || !readGraphicToolbarActive(editor) || !rect) return null
const graphic = readSelectedGraphic(editor)
if (!graphic) return null
const { attrs } = graphic
const isImage = attrs.graphicType === "image"
const isGradient = attrs.graphicType === "gradient"
const isDraw = attrs.graphicType === "draw" || attrs.graphicType === "shape"
const moveWithText = attrs.positionMode === "move-with-text"
const showInFlowWrap = moveWithText
const showPageLayerActions = usesPageLayer(attrs)
const showAlign = moveWithText
const run = (fn: () => void) => fn()
const applyWrap = (wrap: DocsGraphicWrap) => {
run(() => editor.chain().setDocsGraphicWrap(wrap).run())
}
const applyPositionMode = (positionMode: DocsGraphicPositionMode) => {
run(() => editor.chain().setDocsGraphicPositionMode(positionMode).run())
}
const applyMargin = (wrapMarginMm: number) => {
run(() => editor.chain().setDocsGraphicWrapMargin(wrapMarginMm).run())
}
const applyFloatSide = (floatSide: DocsGraphicFloatSide) => {
run(() => editor.chain().setDocsGraphicFloatSide(floatSide).run())
}
const downloadImage = () => {
if (!attrs.src) return
const link = document.createElement("a")
link.href = attrs.src
link.download = "image"
link.click()
}
const toolbar = (
<div
className="docs-graphic-floating-toolbar pointer-events-auto fixed z-200 -translate-x-1/2"
style={{ left: rect.left + rect.width / 2, top: rect.bottom + 8 }}
role="toolbar"
aria-label="Options graphique"
>
<div className="flex items-center gap-0.5 rounded-full border border-border bg-popover px-1 py-0.5 text-popover-foreground shadow-md">
{isImage || isDraw ? (
<Button
type="button"
variant={isImage && cropActive ? "default" : "ghost"}
size="sm"
className={cn(
"h-8 shrink-0 gap-1.5 rounded-full px-2.5 text-xs",
isImage && cropActive
? "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
: "hover:bg-accent hover:text-accent-foreground"
)}
onClick={() => {
if (isImage) {
if (cropActive) applyDocsGraphicCrop()
else startDocsGraphicCrop()
} else {
openDocsGraphicDrawEditor(editor)
}
}}
>
{isImage ? (
cropActive ? (
<Check className="size-3.5" />
) : (
<Crop className="size-3.5" />
)
) : (
<Shapes className="size-3.5" />
)}
{isImage ? (cropActive ? "Appliquer" : "Ajuster") : "Modifier le dessin"}
</Button>
) : null}
{showAlign ? (
<>
<ToolbarDivider />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 gap-1 rounded-full px-2 text-xs hover:bg-accent hover:text-accent-foreground"
>
<Icon
icon={DOCS_GRAPHIC_FLOAT_SIDE_ICONS[attrs.floatSide]}
className="size-[18px]"
/>
<ChevronDown className="size-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-40"
onCloseAutoFocus={keepEditorSelectionOnMenuClose}
>
{(Object.keys(DOCS_GRAPHIC_FLOAT_SIDE_LABELS) as DocsGraphicFloatSide[]).map(
(side) => (
<DropdownMenuItem key={side} onClick={() => applyFloatSide(side)}>
<Icon icon={DOCS_GRAPHIC_FLOAT_SIDE_ICONS[side]} className="mr-2 size-4" />
{DOCS_GRAPHIC_FLOAT_SIDE_LABELS[side]}
{attrs.floatSide === side ? " ✓" : ""}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
</>
) : null}
{showInFlowWrap ? (
<>
<ToolbarDivider />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 max-w-[140px] shrink-0 gap-1 truncate rounded-full px-2 text-xs hover:bg-accent hover:text-accent-foreground"
>
<Icon icon="material-symbols:wrap-text" className="size-[18px] shrink-0" />
<span className="truncate">
{DOCS_GRAPHIC_WRAP_LABELS[attrs.wrap] ?? "Habillage"}
</span>
<ChevronDown className="size-3 shrink-0 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-48"
onCloseAutoFocus={keepEditorSelectionOnMenuClose}
>
{DOCS_GRAPHIC_FLOATING_WRAP_ACTIONS.map(({ wrap, label }) => (
<DropdownMenuItem key={wrap} onClick={() => applyWrap(wrap)}>
<Icon icon={DOCS_GRAPHIC_WRAP_ICONS[wrap]} className="mr-2 size-4" />
{label}
{attrs.wrap === wrap ? " ✓" : ""}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{DOCS_GRAPHIC_ADVANCED_WRAP_ACTIONS.map(({ wrap, label }) => (
<DropdownMenuItem key={wrap} onClick={() => applyWrap(wrap)}>
<Icon icon={DOCS_GRAPHIC_WRAP_ICONS[wrap]} className="mr-2 size-4" />
{label}
{attrs.wrap === wrap ? " ✓" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</>
) : null}
{showPageLayerActions ? (
<>
<ToolbarDivider />
{DOCS_GRAPHIC_LAYER_WRAP_ACTIONS.map(({ wrap, label }) => (
<IconToolbarButton
key={wrap}
label={label}
active={attrs.wrap === wrap}
onClick={() => applyWrap(wrap)}
>
<Icon icon={DOCS_GRAPHIC_WRAP_ICONS[wrap]} className="size-[18px]" />
</IconToolbarButton>
))}
</>
) : null}
<ToolbarDivider />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 gap-1 rounded-full px-2 text-xs hover:bg-accent hover:text-accent-foreground"
>
{attrs.wrapMarginMm}mm
<ChevronDown className="size-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="min-w-40"
onCloseAutoFocus={keepEditorSelectionOnMenuClose}
>
{DOCS_GRAPHIC_WRAP_MARGIN_PRESETS_MM.map((mm) => (
<DropdownMenuItem key={mm} onClick={() => applyMargin(mm)}>
Marge de {mm}mm{attrs.wrapMarginMm === mm ? " ✓" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 gap-1 rounded-full px-2 text-xs hover:bg-accent hover:text-accent-foreground"
>
<Icon icon="material-symbols:pin" className="size-[18px]" />
{DOCS_GRAPHIC_POSITION_MODE_SHORT_LABELS[attrs.positionMode]}
<ChevronDown className="size-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-48"
onCloseAutoFocus={keepEditorSelectionOnMenuClose}
>
{(["move-with-text", "fixed-on-page"] as DocsGraphicPositionMode[]).map((mode) => (
<DropdownMenuItem key={mode} onClick={() => applyPositionMode(mode)}>
{DOCS_GRAPHIC_POSITION_MODE_SHORT_LABELS[mode]}
{attrs.positionMode === mode ? " ✓" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{showPageLayerActions ? (
<>
<IconToolbarButton
label="Avancer"
onClick={() => run(() => editor.chain().bringDocsGraphicForward().run())}
>
<ArrowUp className="size-4" />
</IconToolbarButton>
<IconToolbarButton
label="Reculer"
onClick={() => run(() => editor.chain().sendDocsGraphicBackward().run())}
>
<ArrowDown className="size-4" />
</IconToolbarButton>
<ToolbarDivider />
</>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 gap-1.5 rounded-full px-2.5 text-xs hover:bg-accent hover:text-accent-foreground"
onClick={() =>
openDocsGraphicOptionsSidebar(
isImage ? undefined : isGradient ? "gradient" : undefined
)
}
>
<Settings2 className="size-3.5" />
{isImage
? "Options de l'image"
: isGradient
? "Options du dégradé"
: "Options"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 rounded-full hover:bg-accent hover:text-accent-foreground"
aria-label="Plus d'options"
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-48"
onCloseAutoFocus={keepEditorSelectionOnMenuClose}
>
{isImage ? (
<>
<DropdownMenuItem
onClick={() => {
const alt = window.prompt("Texte alternatif", attrs.alt)
if (alt != null) run(() => editor.chain().updateDocsGraphic({ alt }).run())
}}
>
Texte alternatif
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => replaceInputRef.current?.click()}>
Remplacer l&apos;image
</DropdownMenuItem>
<DropdownMenuItem onClick={downloadImage} disabled={!attrs.src}>
Télécharger l&apos;image
</DropdownMenuItem>
</>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => run(() => editor.chain().focus().deleteSelection().run())}
>
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<input
ref={replaceInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
run(() =>
editor.chain().focus().updateDocsGraphic({ src: reader.result as string }).run()
)
}
reader.readAsDataURL(file)
event.target.value = ""
}}
/>
</div>
</div>
)
return createPortal(toolbar, document.body)
}
export const DocsGraphicFloatingToolbar = memo(DocsGraphicFloatingToolbarInner)