213 lines
6.7 KiB
TypeScript
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>
|
|
)
|
|
}
|