import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react' import { localToWorld, screenToWorld, snapPoint, snapValue, worldToLocal, type Point } from '@/lib/geometry' import { useUpdateObject } from '@/lib/objects' import { HANDLE_PX, SELECT_COLOR, objectTransform } from './shared' import { useEditorStore } from './store' import type { EditorObject } from './types' const ROTATE_OFFSET_PX = 28 // distance of the rotate knob above the object const MIN_OBJ_CM = 1 // smallest allowed dimension const ROTATE_SNAP_DEG = 15 const corners: [number, number][] = [ [-1, -1], [1, -1], [1, 1], [-1, 1], ] /** * Handles for the selected object: a transparent body to move, four corner * handles to resize (opposite corner stays put, honoring rotation via the * object-local frame), and a knob to rotate (snaps to 15°, free with Shift). * Each gesture updates liveObject for instant feedback and fires exactly one * PATCH on release. */ export function SelectionOverlay({ object, gardenId, svgRef, snap, gridCm, }: { object: EditorObject gardenId: number svgRef: RefObject // The garden grid: when snap is true, a move snaps the object's center to it and // a resize snaps width/height to whole grid steps (the opposite corner stays put). snap: boolean gridCm: number }) { const setLiveObject = useEditorStore((s) => s.setLiveObject) const setObjectDragging = useEditorStore((s) => s.setObjectDragging) const scale = useEditorStore((s) => s.viewport.scale) const update = useUpdateObject(gardenId) // Detach for an in-flight gesture. If the overlay unmounts mid-drag (the // object is deleted or deselected), this runs on unmount so window listeners // don't leak and objectDragging can't stay stuck true (which would freeze pan). const cleanupRef = useRef<(() => void) | null>(null) useEffect( () => () => { cleanupRef.current?.() cleanupRef.current = null }, [], ) const halfW = object.widthCm / 2 const halfH = object.heightCm / 2 const handleCm = HANDLE_PX / scale const rotateOffsetCm = ROTATE_OFFSET_PX / scale // The svg's screen rect doesn't move during a drag, so snapshot it once at // gesture start rather than calling getBoundingClientRect (a layout reflow) on // every pointermove. const makePointerWorld = () => { const rect = svgRef.current?.getBoundingClientRect() return (e: { clientX: number; clientY: number }): Point => { const vp = useEditorStore.getState().viewport const local = rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : { x: e.clientX, y: e.clientY } return screenToWorld(local, vp) } } // Common gesture scaffolding: mark dragging, track the pointer on window, and // on release fire one PATCH built from the final liveObject. onMove receives // the live pointer event so modifier keys (Shift) reflect their current state. const begin = ( e: ReactPointerEvent, onMove: (e: PointerEvent) => EditorObject, fields: (final: EditorObject) => Parameters[0], ) => { e.stopPropagation() e.preventDefault() setObjectDragging(true) const move = (ev: PointerEvent) => setLiveObject(onMove(ev)) const detach = () => { window.removeEventListener('pointermove', move) window.removeEventListener('pointerup', finish) window.removeEventListener('pointercancel', finish) } const finish = () => { detach() cleanupRef.current = null const final = useEditorStore.getState().liveObject setObjectDragging(false) setLiveObject(null) if (final) update.mutate(fields(final)) } // On unmount mid-gesture: detach and reset, but don't fire a PATCH. cleanupRef.current = () => { detach() setObjectDragging(false) setLiveObject(null) } window.addEventListener('pointermove', move) window.addEventListener('pointerup', finish) window.addEventListener('pointercancel', finish) } const base = { ...object } const center0: Point = { x: base.xCm, y: base.yCm } const startMove = (e: ReactPointerEvent) => { const pointerWorld = makePointerWorld() const start = pointerWorld(e.nativeEvent) begin( e, (ev) => { const world = pointerWorld(ev) const next: Point = { x: base.xCm + (world.x - start.x), y: base.yCm + (world.y - start.y) } const c = snap ? snapPoint(next, gridCm) : next return { ...base, xCm: c.x, yCm: c.y } }, (f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }), ) } const startResize = (e: ReactPointerEvent, sx: number, sy: number) => { // The corner opposite the dragged one stays fixed (in the object's local // frame, which is anchored at the original center). const oppositeLocal: Point = { x: -sx * halfW, y: -sy * halfH } const pointerWorld = makePointerWorld() begin( e, (ev) => { const world = pointerWorld(ev) const p = worldToLocal(world, center0, base.rotationDeg) let newW = Math.max(MIN_OBJ_CM, Math.abs(p.x - oppositeLocal.x)) let newH = Math.max(MIN_OBJ_CM, Math.abs(p.y - oppositeLocal.y)) // Snap dimensions to whole grid steps (at least one cell). The opposite // corner is the anchor, so it stays fixed while the dragged corner lands // on a grid-multiple size. if (snap) { newW = Math.max(gridCm, snapValue(newW, gridCm)) newH = Math.max(gridCm, snapValue(newH, gridCm)) } const draggedLocal: Point = { x: oppositeLocal.x + sx * newW, y: oppositeLocal.y + sy * newH } const newCenterLocal: Point = { x: (oppositeLocal.x + draggedLocal.x) / 2, y: (oppositeLocal.y + draggedLocal.y) / 2, } const c = localToWorld(newCenterLocal, center0, base.rotationDeg) return { ...base, xCm: c.x, yCm: c.y, widthCm: newW, heightCm: newH } }, (f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm, widthCm: f.widthCm, heightCm: f.heightCm }), ) } const startRotate = (e: ReactPointerEvent) => { const pointerWorld = makePointerWorld() begin( e, (ev) => { const world = pointerWorld(ev) // +90° because the knob points up (local -y) at rotation 0. let deg = (Math.atan2(world.y - center0.y, world.x - center0.x) * 180) / Math.PI + 90 // Read Shift live off the move event so toggling mid-drag switches // between snapped and free rotation. if (!ev.shiftKey) deg = Math.round(deg / ROTATE_SNAP_DEG) * ROTATE_SNAP_DEG deg = ((deg % 360) + 360) % 360 return { ...base, rotationDeg: deg } }, (f) => ({ id: base.id, version: base.version, rotationDeg: f.rotationDeg }), ) } return ( {/* Transparent body: drag to move. */} {object.shape === 'circle' ? ( ) : ( )} {/* Selection outline. */} {object.shape === 'circle' ? ( ) : ( )} {/* Rotate knob. */} {/* Resize corners. */} {corners.map(([sx, sy]) => ( startResize(e, sx, sy)} /> ))} ) }