489 lines
16 KiB
TypeScript
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'image…
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={downloadImage} disabled={!attrs.src}>
|
|
Télécharger l'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)
|