1009 lines
37 KiB
TypeScript
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'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'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'image et le texte qui l'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)
|