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

1009 lines
37 KiB
TypeScript

"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<string, unknown>)
}
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<HTMLButtonElement>) => {
event.preventDefault()
}
return (
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="text-xs text-muted-foreground">
{label} <span className="text-[10px]">({unit})</span>
</span>
<div className="flex items-center gap-1">
{showSteppers ? (
<Button
type="button"
variant="outline"
size="icon"
className="docs-graphic-options-stepper size-8 shrink-0"
disabled={disabled}
aria-label={`Diminuer ${label}`}
onPointerDown={handleStepperPointerDown}
onClick={() => stepBy(-step)}
>
<Minus className="size-3.5" />
</Button>
) : null}
<Input
type="text"
inputMode="decimal"
value={draft}
disabled={disabled}
className="docs-graphic-options-number-input h-8 min-w-0 flex-1 px-1 text-center text-sm tabular-nums"
onFocus={() => 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 ? (
<Button
type="button"
variant="outline"
size="icon"
className="docs-graphic-options-stepper size-8 shrink-0"
disabled={disabled}
aria-label={`Augmenter ${label}`}
onPointerDown={handleStepperPointerDown}
onClick={() => stepBy(step)}
>
<Plus className="size-3.5" />
</Button>
) : null}
</div>
</div>
)
}
function AdjustmentSlider({
label,
value,
min,
max,
onChange,
}: {
label: string
value: number
min: number
max: number
onChange: (value: number) => void
}) {
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-xs tabular-nums text-muted-foreground">{value}</span>
</div>
<Slider
value={[value]}
min={min}
max={max}
step={1}
onValueChange={(values) => onChange(values[0] ?? value)}
/>
</div>
)
}
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<number | null>(null)
const [expandedSections, setExpandedSections] = useState<string[]>([
"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<DocsGraphicAttrs>) => {
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<DocsGraphicAttrs> = { 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<DocsGraphicAttrs> = { 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<DocsGraphicAttrs> = { 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<DocsGraphicAttrs> = { 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 = (
<aside
className="docs-graphic-options-sidebar flex h-full shrink-0 flex-col border-l border-border bg-background"
style={{ width: DOCS_GRAPHIC_OPTIONS_SIDEBAR_WIDTH_PX }}
aria-label="Options du graphique"
onMouseDownCapture={(event) => {
const target = event.target as HTMLElement
if (target.closest("input, textarea, button, [role=slider]")) return
event.preventDefault()
}}
>
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<h2 className="text-sm font-medium">
{isImage
? "Options de l'image"
: isGradient
? "Options du dégradé"
: "Options du graphique"}
</h2>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label="Fermer"
onClick={onClose}
>
<X className="size-4" />
</Button>
</div>
{!attrs ? (
<p className="px-4 py-6 text-sm text-muted-foreground">
Sélectionnez une image ou un graphisme pour afficher ses options.
</p>
) : (
<div className="docs-graphic-options-sidebar__scroll min-h-0 flex-1 overflow-y-auto">
<Accordion
type="multiple"
value={expandedSections}
onValueChange={setExpandedSections}
className="px-1"
>
<AccordionItem value="size" data-graphic-section="size">
<AccordionTrigger className="px-3 py-3 text-sm">
Taille et rotation
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-3 px-3">
<div className="flex gap-2">
<NumberField
label="Largeur"
unit="cm"
value={pxToCm(attrs.width)}
min={0.2}
step={0.1}
onCommit={setWidth}
/>
<NumberField
label="Hauteur"
unit="cm"
value={pxToCm(attrs.height)}
min={0.2}
step={0.1}
onCommit={setHeight}
/>
</div>
{natural ? (
<div className="flex gap-2">
<NumberField
label="Échelle de largeur"
unit="%"
value={(attrs.width / natural.width) * 100}
min={1}
decimals={0}
step={1}
onCommit={setScaleWidth}
/>
<NumberField
label="Échelle de hauteur"
unit="%"
value={(attrs.height / natural.height) * 100}
min={1}
decimals={0}
step={1}
onCommit={setScaleHeight}
/>
</div>
) : null}
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={lockAspect}
onCheckedChange={(checked) =>
update({ lockAspectRatio: checked === true })
}
/>
Verrouiller le format
</label>
{isImage ? (
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground">Ajustement dans le cadre</span>
<div className="grid grid-cols-3 gap-2">
{(
[
{ fit: "contain" as const, label: "Contenir" },
{ fit: "cover" as const, label: "Remplir" },
{ fit: "crop" as const, label: "Recadrer" },
] as const
).map(({ fit, label }) => (
<button
key={fit}
type="button"
className={cn(
"rounded-md border px-2 py-1.5 text-xs font-medium",
attrs.imageFit === fit
? "border-primary bg-primary/5 text-primary"
: "border-border hover:border-muted-foreground/40 hover:bg-accent/30"
)}
onClick={() => {
if (fit === "crop") {
apply(() => {
editor.chain().updateDocsGraphic({ imageFit: "crop" }).run()
startDocsGraphicCrop()
})
return
}
update({ imageFit: fit })
}}
>
{label}
</button>
))}
</div>
{attrs.imageFit === "crop" ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 w-full text-xs"
onClick={() => apply(() => startDocsGraphicCrop())}
>
Ajuster le recadrage
</Button>
) : null}
{attrs.imageFit === "cover" ? (
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Point focal</span>
<div className="grid w-fit grid-cols-3 gap-1.5">
{QUICK_LAYOUTS.map((layout) => (
<button
key={layout.id}
type="button"
className="rounded-md"
aria-label={layout.label}
title={layout.label}
onClick={() =>
update({
imageFitAnchorH: layout.h,
imageFitAnchorV: layout.v,
})
}
>
<DocsGraphicPageAnchorPreview
h={layout.h}
v={layout.v}
active={
attrs.imageFitAnchorH === layout.h &&
attrs.imageFitAnchorV === layout.v
}
/>
</button>
))}
</div>
</div>
) : null}
</div>
) : null}
<div className="flex items-end gap-2">
<NumberField
label="Angle de rotation"
unit="°"
value={attrs.rotationDeg}
decimals={0}
step={1}
onCommit={(deg) => update({ rotationDeg: ((deg % 360) + 360) % 360 })}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1.5"
onClick={() =>
update({ rotationDeg: (attrs.rotationDeg + 90) % 360 })
}
>
<Icon icon="material-symbols:rotate-90-degrees-cw-outline" className="size-4" />
90°
</Button>
</div>
</AccordionContent>
</AccordionItem>
{isGradient ? (
<AccordionItem value="gradient" data-graphic-section="gradient">
<AccordionTrigger className="px-3 py-3 text-sm">Dégradé</AccordionTrigger>
<AccordionContent className="flex flex-col gap-3 px-3">
<div
className="h-16 w-full rounded-md border border-border"
style={{ background: attrs.gradientCss }}
aria-hidden
/>
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Type</span>
<div className="grid grid-cols-2 gap-2">
{(
[
{ type: "linear" as const, label: "Linéaire" },
{ type: "radial" as const, label: "Radial" },
] satisfies { type: DocsGradientType; label: string }[]
).map(({ type, label }) => (
<button
key={type}
type="button"
className={cn(
"rounded-md border px-2 py-1.5 text-xs font-medium",
attrs.gradientType === type
? "border-primary bg-primary/5 text-primary"
: "border-border hover:border-muted-foreground/40 hover:bg-accent/30"
)}
onClick={() => updateGradient({ gradientType: type })}
>
{label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Couleur 1</span>
<Input
type="color"
className="h-9 cursor-pointer p-1"
value={attrs.gradientColor1}
onChange={(event) =>
updateGradient({ gradientColor1: event.target.value })
}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Couleur 2</span>
<Input
type="color"
className="h-9 cursor-pointer p-1"
value={attrs.gradientColor2}
onChange={(event) =>
updateGradient({ gradientColor2: event.target.value })
}
/>
</label>
</div>
{attrs.gradientType === "linear" ? (
<>
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Orientation</span>
<div className="grid grid-cols-4 gap-1.5">
{GRADIENT_ANGLE_PRESETS.map(({ angle, label, title }) => (
<button
key={angle}
type="button"
className={cn(
"rounded-md border px-2 py-1.5 text-sm font-medium",
attrs.gradientAngle === angle
? "border-primary bg-primary/5 text-primary"
: "border-border hover:border-muted-foreground/40 hover:bg-accent/30"
)}
aria-label={title}
title={title}
onClick={() => updateGradient({ gradientAngle: angle })}
>
{label}
</button>
))}
</div>
</div>
<NumberField
label="Angle"
unit="°"
value={attrs.gradientAngle}
decimals={0}
step={1}
min={0}
max={360}
onCommit={(deg) =>
updateGradient({ gradientAngle: ((deg % 360) + 360) % 360 })
}
/>
</>
) : (
<p className="text-xs text-muted-foreground">
Le dégradé radial part du centre vers les bords.
</p>
)}
</AccordionContent>
</AccordionItem>
) : null}
<AccordionItem value="position" data-graphic-section="position">
<AccordionTrigger className="px-3 py-3 text-sm">Position</AccordionTrigger>
<AccordionContent className="flex flex-col gap-3 px-3">
<div className="flex flex-col gap-2">
{(["move-with-text", "fixed-on-page"] as DocsGraphicPositionMode[]).map(
(mode) => (
<button
key={mode}
type="button"
className={cn(
"docs-graphic-option-card flex w-full flex-col gap-2 rounded-lg border p-2 text-left",
attrs.positionMode === mode
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-accent/30"
)}
onClick={() =>
apply(() => editor.chain().setDocsGraphicPositionMode(mode).run())
}
>
<DocsGraphicPositionModePreview mode={mode} />
<span className="text-sm font-medium">
{DOCS_GRAPHIC_POSITION_MODE_SHORT_LABELS[mode]}
</span>
<span className="text-xs leading-snug text-muted-foreground">
{DOCS_GRAPHIC_POSITION_MODE_DESCRIPTIONS[mode]}
</span>
</button>
)
)}
</div>
{attrs.positionMode === "fixed-on-page" ? (
<>
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground">
Placer l&apos;image sur la page
</span>
<div className="grid w-fit grid-cols-3 gap-1.5">
{QUICK_LAYOUTS.map((layout) => (
<button
key={layout.id}
type="button"
className="rounded-md"
aria-label={layout.label}
title={layout.label}
onClick={() => applyQuickLayout(layout.h, layout.v)}
>
<DocsGraphicPageAnchorPreview h={layout.h} v={layout.v} />
</button>
))}
</div>
</div>
<div className="flex gap-2">
<NumberField
label="X"
unit="cm"
value={pxToCm(attrs.pageX)}
step={0.1}
onCommit={(cm) => update({ pageX: cmToPx(cm) })}
/>
<NumberField
label="Y"
unit="cm"
value={pxToCm(attrs.pageY)}
step={0.1}
onCommit={(cm) => update({ pageY: cmToPx(cm) })}
/>
</div>
<p className="text-xs text-muted-foreground">
Distance depuis l&apos;angle supérieur gauche de la page.
</p>
</>
) : null}
</AccordionContent>
</AccordionItem>
{wrapChoicesForPositionMode(attrs.positionMode).length > 0 ? (
<AccordionItem value="wrap" data-graphic-section="wrap">
<AccordionTrigger className="px-3 py-3 text-sm">
{attrs.positionMode === "move-with-text"
? "Habillage du texte"
: "Calque par rapport au texte"}
</AccordionTrigger>
<AccordionContent className="px-3">
<div className="grid grid-cols-2 gap-2">
{wrapChoicesForPositionMode(attrs.positionMode).map((wrap) => (
<button
key={wrap}
type="button"
className={cn(
"docs-graphic-option-card flex flex-col gap-1.5 rounded-lg border p-2 text-left",
attrs.wrap === wrap
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-accent/30"
)}
aria-label={DOCS_GRAPHIC_WRAP_LABELS[wrap]}
title={DOCS_GRAPHIC_WRAP_LABELS[wrap]}
onClick={() =>
apply(() => editor.chain().setDocsGraphicWrap(wrap).run())
}
>
<DocsGraphicWrapPreview wrap={wrap} />
<span className="text-xs font-medium leading-tight">
{DOCS_GRAPHIC_WRAP_SHORT_LABELS[wrap]}
</span>
</button>
))}
</div>
</AccordionContent>
</AccordionItem>
) : null}
<AccordionItem value="margin" data-graphic-section="margin">
<AccordionTrigger className="px-3 py-3 text-sm">
Marges par rapport au texte
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2 px-3">
<p className="text-xs text-muted-foreground">
Espace entre l&apos;image et le texte qui l&apos;entoure.
</p>
<div className="grid grid-cols-2 gap-2">
{DOCS_GRAPHIC_WRAP_MARGIN_PRESETS_MM.map((mm) => (
<button
key={mm}
type="button"
className={cn(
"docs-graphic-option-card flex flex-col gap-2 rounded-lg border p-2 text-left",
attrs.wrapMarginMm === mm
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-accent/30"
)}
onClick={() =>
apply(() => editor.chain().setDocsGraphicWrapMargin(mm).run())
}
>
<DocsGraphicMarginPreview mm={mm} />
<span className="text-sm font-medium">Marge de {mm} mm</span>
</button>
))}
</div>
</AccordionContent>
</AccordionItem>
{isImage ? (
<AccordionItem value="recolor" data-graphic-section="recolor">
<AccordionTrigger className="px-3 py-3 text-sm">
Recolorier
</AccordionTrigger>
<AccordionContent className="px-3">
<div className="grid grid-cols-4 gap-1.5">
{DOCS_GRAPHIC_RECOLOR_PRESETS.map((preset) => (
<button
key={preset.id || "none"}
type="button"
className={cn(
"h-12 overflow-hidden rounded-md border",
attrs.recolor === preset.id
? "border-primary ring-2 ring-primary/40"
: "border-border hover:border-muted-foreground/60"
)}
aria-label={preset.label}
title={preset.label}
onClick={() => update({ recolor: preset.id })}
>
{attrs.src ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={attrs.src}
alt=""
className="h-full w-full object-cover"
style={{ filter: preset.filter || undefined }}
/>
) : (
<span className="block h-full w-full bg-muted" />
)}
</button>
))}
</div>
</AccordionContent>
</AccordionItem>
) : null}
<AccordionItem value="adjust" data-graphic-section="adjust">
<AccordionTrigger className="px-3 py-3 text-sm">
Ajustements
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 px-3">
<AdjustmentSlider
label="Transparence"
value={Math.round((1 - attrs.opacity) * 100)}
min={0}
max={100}
onChange={(value) => update({ opacity: 1 - value / 100 })}
/>
{isImage ? (
<>
<AdjustmentSlider
label="Luminosité"
value={Math.round(attrs.brightness * 100)}
min={-100}
max={100}
onChange={(value) => update({ brightness: value / 100 })}
/>
<AdjustmentSlider
label="Contraste"
value={Math.round(attrs.contrast * 100)}
min={-100}
max={100}
onChange={(value) => update({ contrast: value / 100 })}
/>
</>
) : null}
<Button
type="button"
variant="outline"
size="sm"
className="h-8 w-fit"
onClick={() =>
update({ opacity: 1, brightness: 0, contrast: 0, recolor: "" })
}
>
Rétablir
</Button>
</AccordionContent>
</AccordionItem>
<AccordionItem value="alt" data-graphic-section="alt" className="border-b-0">
<AccordionTrigger className="px-3 py-3 text-sm">
Texte alternatif
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-3 px-3">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Titre</span>
<Input
value={attrs.altTitle}
className="h-8 text-sm"
onChange={(event) => update({ altTitle: event.target.value })}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Description</span>
<Textarea
value={attrs.alt}
rows={3}
className="text-sm"
onChange={(event) => update({ alt: event.target.value })}
/>
</label>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)}
</aside>
)
return sidebar
}
export const DocsGraphicOptionsSidebar = memo(DocsGraphicOptionsSidebarInner)