ultisuite-client/components/drive/richtext/docs-graphic-crop-overlay.tsx
R3D347HR4Y 2a7c153748
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wrap page
2026-06-10 12:48:27 +02:00

144 lines
4.4 KiB
TypeScript

"use client"
import { useCallback, useEffect, useRef } from "react"
import type { DocsGraphicAttrs } from "@/lib/drive/docs-graphic-types"
import { cn } from "@/lib/utils"
const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"] as const
type Handle = (typeof HANDLES)[number]
const HANDLE_CLASS: Record<Handle, string> = {
nw: "left-0 top-0 cursor-nwse-resize",
n: "left-1/2 top-0 -translate-x-1/2 cursor-ns-resize",
ne: "right-0 top-0 cursor-nesw-resize",
e: "right-0 top-1/2 -translate-y-1/2 cursor-ew-resize",
se: "right-0 bottom-0 cursor-nwse-resize",
s: "left-1/2 bottom-0 -translate-x-1/2 cursor-ns-resize",
sw: "left-0 bottom-0 cursor-nesw-resize",
w: "left-0 top-1/2 -translate-y-1/2 cursor-ew-resize",
}
function clamp01(v: number): number {
return Math.min(1, Math.max(0, v))
}
function resizeCrop(
handle: Handle,
crop: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">,
dxNorm: number,
dyNorm: number
): Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> {
let { cropX, cropY, cropWidth, cropHeight } = crop
if (handle.includes("e")) cropWidth = clamp01(cropWidth + dxNorm)
if (handle.includes("w")) {
cropWidth = clamp01(cropWidth - dxNorm)
cropX = clamp01(cropX + dxNorm)
}
if (handle.includes("s")) cropHeight = clamp01(cropHeight + dyNorm)
if (handle.includes("n")) {
cropHeight = clamp01(cropHeight - dyNorm)
cropY = clamp01(cropY + dyNorm)
}
cropWidth = Math.max(0.05, cropWidth)
cropHeight = Math.max(0.05, cropHeight)
return { cropX, cropY, cropWidth, cropHeight }
}
export function DocsGraphicCropOverlay({
attrs,
frameWidth,
frameHeight,
onChange,
onDone,
}: {
attrs: DocsGraphicAttrs
frameWidth: number
frameHeight: number
onChange: (patch: Partial<DocsGraphicAttrs>) => void
onDone: () => void
}) {
const dragRef = useRef<{
handle: Handle
startX: number
startY: number
origin: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">
} | null>(null)
const onHandleDown = useCallback(
(handle: Handle, event: React.PointerEvent) => {
event.preventDefault()
event.stopPropagation()
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
dragRef.current = {
handle,
startX: event.clientX,
startY: event.clientY,
origin: {
cropX: attrs.cropX,
cropY: attrs.cropY,
cropWidth: attrs.cropWidth,
cropHeight: attrs.cropHeight,
},
}
},
[attrs.cropHeight, attrs.cropWidth, attrs.cropX, attrs.cropY]
)
useEffect(() => {
const onMove = (event: PointerEvent) => {
if (!dragRef.current) return
const { handle, startX, startY, origin } = dragRef.current
const dxNorm = (event.clientX - startX) / Math.max(frameWidth, 1)
const dyNorm = (event.clientY - startY) / Math.max(frameHeight, 1)
onChange(resizeCrop(handle, origin, dxNorm, dyNorm))
}
const onUp = () => {
dragRef.current = null
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
return () => {
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
}
}, [frameHeight, frameWidth, onChange])
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onDone()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [onDone])
const left = `${attrs.cropX * 100}%`
const top = `${attrs.cropY * 100}%`
const width = `${attrs.cropWidth * 100}%`
const height = `${attrs.cropHeight * 100}%`
return (
<div className="docs-graphic-crop absolute inset-0 z-30" aria-label="Recadrage">
<div className="absolute inset-0 bg-black/40" />
<div
className={cn(
"absolute border-2 border-white shadow-[0_0_0_1px_#1a73e8]",
attrs.cropShape === "ellipse" && "rounded-full"
)}
style={{ left, top, width, height }}
>
{HANDLES.map((handle) => (
<span
key={handle}
role="presentation"
className={cn(
"absolute z-40 size-2.5 rounded-full border border-white bg-[#1a73e8] shadow",
HANDLE_CLASS[handle]
)}
onPointerDown={(event) => onHandleDown(handle, event)}
/>
))}
</div>
</div>
)
}