"use client" import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import type { Editor } from "@tiptap/react" import { NodeSelection } from "@tiptap/pm/state" import { Minus, Plus, X } from "lucide-react" import { Icon } from "@iconify/react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Checkbox } from "@/components/ui/checkbox" import { Slider } from "@/components/ui/slider" import { Textarea } from "@/components/ui/textarea" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion" import type { DocPageLayout } from "@/lib/drive/doc-page-setup" import { mmToPx, pxToMm } from "@/lib/drive/doc-page-setup" import { DOCS_GRAPHIC_WRAP_MARGIN_PRESETS_MM } from "@/lib/drive/docs-graphic-layout" import { DOCS_GRAPHIC_RECOLOR_PRESETS, DOCS_GRAPHIC_WRAP_LABELS, buildGradientCss, parseGraphicAttrs, type DocsGraphicAttrs, type DocsGraphicPositionMode, type DocsGraphicWrap, type DocsGradientType, } from "@/lib/drive/docs-graphic-types" import { DOCS_GRAPHIC_POSITION_MODE_DESCRIPTIONS, DOCS_GRAPHIC_POSITION_MODE_SHORT_LABELS, DOCS_GRAPHIC_WRAP_DESCRIPTIONS, DOCS_GRAPHIC_WRAP_SHORT_LABELS, type DocsGraphicOptionsSection, } from "@/lib/drive/docs-graphic-ui" import { DocsGraphicMarginPreview, DocsGraphicPageAnchorPreview, DocsGraphicPositionModePreview, DocsGraphicWrapPreview, } from "@/components/drive/richtext/docs-graphic-layout-previews" import { startDocsGraphicCrop } from "@/lib/drive/docs-graphic-crop-bridge" import { cn } from "@/lib/utils" export const DOCS_GRAPHIC_OPTIONS_EVENT = "ultidocs:graphic-options-open" export const DOCS_GRAPHIC_OPTIONS_SIDEBAR_WIDTH_PX = 320 /** Opens the graphic options sidebar (listened to by the docs workspace). */ export function openDocsGraphicOptionsSidebar(section?: DocsGraphicOptionsSection) { window.dispatchEvent( new CustomEvent(DOCS_GRAPHIC_OPTIONS_EVENT, { detail: { section } }) ) } const IN_FLOW_WRAP_CHOICES: DocsGraphicWrap[] = [ "inline", "square", "tight", "through", "top-bottom", ] const PAGE_LAYER_WRAP_CHOICES: DocsGraphicWrap[] = ["behind", "in-front"] function wrapChoicesForPositionMode( positionMode: DocsGraphicPositionMode ): DocsGraphicWrap[] { if (positionMode === "move-with-text") return IN_FLOW_WRAP_CHOICES return PAGE_LAYER_WRAP_CHOICES } const QUICK_LAYOUTS: { id: string; label: string; h: 0 | 0.5 | 1; v: 0 | 0.5 | 1 }[] = [ { id: "tl", label: "En haut à gauche", h: 0, v: 0 }, { id: "tc", label: "En haut au centre", h: 0.5, v: 0 }, { id: "tr", label: "En haut à droite", h: 1, v: 0 }, { id: "ml", label: "Au milieu à gauche", h: 0, v: 0.5 }, { id: "mc", label: "Au centre", h: 0.5, v: 0.5 }, { id: "mr", label: "Au milieu à droite", h: 1, v: 0.5 }, { id: "bl", label: "En bas à gauche", h: 0, v: 1 }, { id: "bc", label: "En bas au centre", h: 0.5, v: 1 }, { id: "br", label: "En bas à droite", h: 1, v: 1 }, ] const GRADIENT_ANGLE_PRESETS: { angle: number; label: string; title: string }[] = [ { angle: 0, label: "→", title: "Gauche à droite" }, { angle: 90, label: "↓", title: "Haut vers le bas" }, { angle: 180, label: "←", title: "Droite à gauche" }, { angle: 270, label: "↑", title: "Bas vers le haut" }, ] function pxToCm(px: number): number { return pxToMm(px) / 10 } function cmToPx(cm: number): number { return mmToPx(cm * 10) } function roundTo(value: number, decimals: number): number { const f = 10 ** decimals return Math.round(value * f) / f } function readSelectedGraphic(editor: Editor | null) { if (!editor) return null const name = editor.isActive("docsInlineGraphic") ? "docsInlineGraphic" : editor.isActive("docsGraphic") ? "docsGraphic" : null if (!name) return null return parseGraphicAttrs(editor.getAttributes(name) as Record) } function readNaturalSize(): { width: number; height: number } | null { const img = document.querySelector( ".docs-graphic--selected img, .ProseMirror-selectednode .docs-graphic img" ) as HTMLImageElement | null if (!img || !img.naturalWidth || !img.naturalHeight) return null return { width: img.naturalWidth, height: img.naturalHeight } } /** * Numeric field with commit-on-blur/Enter so typing isn't interrupted by * editor transactions re-feeding the value. */ function NumberField({ label, value, unit, step = 0.01, min, max, decimals = 2, onCommit, disabled, showSteppers = true, }: { label: string value: number unit: string step?: number min?: number max?: number decimals?: number onCommit: (value: number) => void disabled?: boolean showSteppers?: boolean }) { const display = String(roundTo(value, decimals)) const [draft, setDraft] = useState(display) const [focused, setFocused] = useState(false) useEffect(() => { if (!focused) setDraft(display) }, [display, focused]) const clamp = (parsed: number) => { let next = parsed if (min != null) next = Math.max(min, next) if (max != null) next = Math.min(max, next) return next } const commit = () => { const parsed = Number.parseFloat(draft.replace(",", ".")) if (!Number.isFinite(parsed)) { setDraft(display) return } const next = clamp(parsed) onCommit(next) setDraft(String(roundTo(next, decimals))) } const stepBy = (delta: number) => { const next = clamp(value + delta) onCommit(next) setDraft(String(roundTo(next, decimals))) } const handleStepperPointerDown = (event: React.PointerEvent) => { event.preventDefault() } return (
{label} ({unit})
{showSteppers ? ( ) : null} setFocused(true)} onChange={(event) => setDraft(event.target.value)} onBlur={() => { setFocused(false) commit() }} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault() commit() return } if (event.key === "ArrowUp") { event.preventDefault() stepBy(event.shiftKey ? step * 10 : step) return } if (event.key === "ArrowDown") { event.preventDefault() stepBy(event.shiftKey ? -step * 10 : -step) } }} /> {showSteppers ? ( ) : null}
) } function AdjustmentSlider({ label, value, min, max, onChange, }: { label: string value: number min: number max: number onChange: (value: number) => void }) { return (
{label} {value}
onChange(values[0] ?? value)} />
) } function DocsGraphicOptionsSidebarInner({ editor, pageLayout, open, focusSection, onClose, }: { editor: Editor | null pageLayout: DocPageLayout open: boolean focusSection?: DocsGraphicOptionsSection | null onClose: () => void }) { const [, setTick] = useState(0) const graphicSelectionPos = useRef(null) const [expandedSections, setExpandedSections] = useState([ "size", "position", "wrap", "margin", ]) const refresh = useCallback(() => setTick((t) => t + 1), []) useEffect(() => { if (!open || !focusSection) return setExpandedSections((prev) => prev.includes(focusSection) ? prev : [...prev, focusSection] ) requestAnimationFrame(() => { document .querySelector(`[data-graphic-section="${focusSection}"]`) ?.scrollIntoView({ block: "nearest", behavior: "smooth" }) }) }, [focusSection, open]) useEffect(() => { if (!editor) return editor.on("selectionUpdate", refresh) editor.on("transaction", refresh) return () => { editor.off("selectionUpdate", refresh) editor.off("transaction", refresh) } }, [editor, refresh]) const attrs = readSelectedGraphic(editor) const isImage = attrs?.graphicType === "image" const isGradient = attrs?.graphicType === "gradient" useEffect(() => { if (!open || !isGradient) return setExpandedSections((prev) => (prev.includes("gradient") ? prev : [...prev, "gradient"])) }, [open, isGradient]) useEffect(() => { if (!editor) return const { selection } = editor.state if (!(selection instanceof NodeSelection)) return const node = selection.node if (node.type.name !== "docsGraphic" && node.type.name !== "docsInlineGraphic") return graphicSelectionPos.current = selection.from }, [editor, attrs]) const natural = useMemo( () => (open && isImage ? readNaturalSize() : null), // eslint-disable-next-line react-hooks/exhaustive-deps [open, isImage, attrs?.src] ) if (!open) return null const apply = (command: () => boolean | void) => { if (!editor) return command() } const update = (patch: Partial) => { if (!editor) return const chain = editor.chain() const pos = graphicSelectionPos.current if (pos != null) chain.setNodeSelection(pos) chain.updateDocsGraphic(patch).run() } const updateGradient = ( patch: Partial< Pick< DocsGraphicAttrs, "gradientType" | "gradientAngle" | "gradientColor1" | "gradientColor2" > > ) => { if (!attrs) return const gradientType = patch.gradientType ?? attrs.gradientType const gradientAngle = patch.gradientAngle ?? attrs.gradientAngle const gradientColor1 = patch.gradientColor1 ?? attrs.gradientColor1 const gradientColor2 = patch.gradientColor2 ?? attrs.gradientColor2 update({ ...patch, gradientCss: buildGradientCss( gradientAngle, gradientColor1, gradientColor2, gradientType ), }) } const lockAspect = attrs?.lockAspectRatio !== false const setWidth = (cm: number) => { if (!attrs) return const width = Math.max(8, cmToPx(cm)) const patch: Partial = { width } if (lockAspect && attrs.width > 0) { patch.height = Math.max(8, attrs.height * (width / attrs.width)) } update(patch) } const setHeight = (cm: number) => { if (!attrs) return const height = Math.max(8, cmToPx(cm)) const patch: Partial = { height } if (lockAspect && attrs.height > 0) { patch.width = Math.max(8, attrs.width * (height / attrs.height)) } update(patch) } const setScaleWidth = (pct: number) => { if (!attrs || !natural) return const width = Math.max(8, (natural.width * pct) / 100) const patch: Partial = { width } if (lockAspect) patch.height = Math.max(8, (natural.height * pct) / 100) update(patch) } const setScaleHeight = (pct: number) => { if (!attrs || !natural) return const height = Math.max(8, (natural.height * pct) / 100) const patch: Partial = { height } if (lockAspect) patch.width = Math.max(8, (natural.width * pct) / 100) update(patch) } const applyQuickLayout = (h: number, v: number) => { if (!attrs) return const margins = pageLayout.marginsPx const innerW = pageLayout.widthPx - margins.left - margins.right const innerH = pageLayout.heightPx - margins.top - margins.bottom update({ positionMode: "fixed-on-page", pageX: margins.left + (innerW - attrs.width) * h, pageY: margins.top + (innerH - attrs.height) * v, }) } const sidebar = (