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

213 lines
6.7 KiB
TypeScript

"use client"
import { useCallback, useEffect, useRef } from "react"
import {
computeCropDisplayGeometry,
resizeCropRegion,
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: "docs-crop-handle--corner docs-crop-handle--nw cursor-nwse-resize",
n: "docs-crop-handle--edge docs-crop-handle--n cursor-ns-resize",
ne: "docs-crop-handle--corner docs-crop-handle--ne cursor-nesw-resize",
e: "docs-crop-handle--edge docs-crop-handle--e cursor-ew-resize",
se: "docs-crop-handle--corner docs-crop-handle--se cursor-nwse-resize",
s: "docs-crop-handle--edge docs-crop-handle--s cursor-ns-resize",
sw: "docs-crop-handle--corner docs-crop-handle--sw cursor-nesw-resize",
w: "docs-crop-handle--edge docs-crop-handle--w cursor-ew-resize",
}
type CropRegion = Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight">
function pct(value: number, total: number): string {
return `${(value / Math.max(total, 1)) * 100}%`
}
export function DocsGraphicCropOverlay({
attrs,
cropEditBase,
frameWidth,
frameHeight,
imageNaturalWidth,
imageNaturalHeight,
onChange,
onDone,
}: {
attrs: DocsGraphicAttrs
cropEditBase?: Pick<DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight"> | null
frameWidth: number
frameHeight: number
imageNaturalWidth: number
imageNaturalHeight: number
onChange: (patch: Partial<DocsGraphicAttrs>) => void
onDone: () => void
}) {
const rootRef = useRef<HTMLDivElement>(null)
const { windowRect, cropRect } = computeCropDisplayGeometry(
attrs,
frameWidth,
frameHeight,
imageNaturalWidth,
imageNaturalHeight,
cropEditBase
)
const dragRef = useRef<{
handle: Handle
startX: number
startY: number
origin: CropRegion
} | null>(null)
const readScale = useCallback(() => {
const rootRect = rootRef.current?.getBoundingClientRect()
return rootRect && windowRect.width > 0 ? rootRect.width / windowRect.width : 1
}, [windowRect.width])
const beginResize = 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 scale = readScale()
const dxPx = (event.clientX - startX) / scale
const dyPx = (event.clientY - startY) / scale
const dxNorm = dxPx / Math.max(windowRect.width, 1)
const dyNorm = dyPx / Math.max(windowRect.height, 1)
onChange(resizeCropRegion(handle, origin, dxNorm, dyNorm))
}
const onUp = () => {
dragRef.current = null
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
window.addEventListener("pointercancel", onUp)
return () => {
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
window.removeEventListener("pointercancel", onUp)
}
}, [onChange, readScale, windowRect.height, windowRect.width])
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" || event.key === "Enter") {
event.preventDefault()
onDone()
}
}
window.addEventListener("keydown", onKey, true)
return () => window.removeEventListener("keydown", onKey, true)
}, [onDone])
const localCropLeft = cropRect.left - windowRect.left
const localCropTop = cropRect.top - windowRect.top
const cropWidth = pct(cropRect.width, windowRect.width)
const cropHeight = pct(cropRect.height, windowRect.height)
const cropLeft = pct(localCropLeft, windowRect.width)
const cropTop = pct(localCropTop, windowRect.height)
const shadeTop = localCropTop
const shadeLeft = localCropLeft
const shadeRight = windowRect.width - (localCropLeft + cropRect.width)
const shadeBottom = windowRect.height - (localCropTop + cropRect.height)
return (
<div
ref={rootRef}
className="docs-graphic-crop pointer-events-none absolute z-30"
aria-label="Recadrage"
style={{
left: windowRect.left,
top: windowRect.top,
width: windowRect.width,
height: windowRect.height,
}}
onDoubleClick={(event) => {
event.preventDefault()
event.stopPropagation()
onDone()
}}
>
{shadeTop > 0 ? (
<div
className="pointer-events-none absolute left-0 top-0 bg-black/40"
style={{ width: "100%", height: pct(shadeTop, windowRect.height) }}
/>
) : null}
{shadeBottom > 0 ? (
<div
className="pointer-events-none absolute bottom-0 left-0 bg-black/40"
style={{ width: "100%", height: pct(shadeBottom, windowRect.height) }}
/>
) : null}
{shadeLeft > 0 ? (
<div
className="pointer-events-none absolute bg-black/40"
style={{
left: 0,
top: cropTop,
width: pct(shadeLeft, windowRect.width),
height: cropHeight,
}}
/>
) : null}
{shadeRight > 0 ? (
<div
className="pointer-events-none absolute bg-black/40"
style={{
right: 0,
top: cropTop,
width: pct(shadeRight, windowRect.width),
height: cropHeight,
}}
/>
) : null}
<div
className={cn(
"docs-crop-region pointer-events-none absolute",
attrs.cropShape === "ellipse" && "rounded-full"
)}
style={{ left: cropLeft, top: cropTop, width: cropWidth, height: cropHeight }}
>
<div
className="docs-crop-region__frame pointer-events-none absolute inset-0 border border-dashed border-white/95 shadow-[0_0_0_1px_rgba(0,0,0,0.45)]"
aria-hidden
/>
{HANDLES.map((handle) => (
<span
key={handle}
role="presentation"
className={cn("docs-crop-handle pointer-events-auto absolute z-40", HANDLE_CLASS[handle])}
onPointerDown={(event) => beginResize(handle, event)}
/>
))}
</div>
</div>
)
}