144 lines
4.4 KiB
TypeScript
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>
|
|
)
|
|
}
|