Files
pansy/web/src/editor/SelectionOverlay.tsx
T
steve 48ba08e8f2
Build image / build-and-push (push) Successful in 14s
Plops editor: focus mode, place/move/resize, semantic zoom (#15)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 03:22:51 +00:00

202 lines
7.8 KiB
TypeScript

import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
import { localToWorld, screenToWorld, 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,
}: {
object: EditorObject
gardenId: number
svgRef: RefObject<SVGSVGElement | null>
}) {
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<typeof update.mutate>[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)
return { ...base, xCm: base.xCm + (world.x - start.x), yCm: base.yCm + (world.y - start.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)
const newW = Math.max(MIN_OBJ_CM, Math.abs(p.x - oppositeLocal.x))
const newH = Math.max(MIN_OBJ_CM, Math.abs(p.y - oppositeLocal.y))
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 (
<g transform={objectTransform(object)}>
{/* Transparent body: drag to move. */}
{object.shape === 'circle' ? (
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill="transparent" pointerEvents="all" style={{ cursor: 'move' }} onPointerDown={startMove} />
) : (
<rect x={-halfW} y={-halfH} width={object.widthCm} height={object.heightCm} fill="transparent" pointerEvents="all" style={{ cursor: 'move' }} onPointerDown={startMove} />
)}
{/* Selection outline. */}
{object.shape === 'circle' ? (
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill="none" stroke={SELECT_COLOR} strokeWidth={1.5} vectorEffect="non-scaling-stroke" pointerEvents="none" />
) : (
<rect x={-halfW} y={-halfH} width={object.widthCm} height={object.heightCm} fill="none" stroke={SELECT_COLOR} strokeWidth={1.5} vectorEffect="non-scaling-stroke" pointerEvents="none" />
)}
{/* Rotate knob. */}
<line x1={0} y1={-halfH} x2={0} y2={-halfH - rotateOffsetCm} stroke={SELECT_COLOR} strokeWidth={1} vectorEffect="non-scaling-stroke" pointerEvents="none" />
<circle cx={0} cy={-halfH - rotateOffsetCm} r={handleCm * 0.7} fill={SELECT_COLOR} style={{ cursor: 'grab' }} onPointerDown={startRotate} />
{/* Resize corners. */}
{corners.map(([sx, sy]) => (
<rect
key={`${sx},${sy}`}
x={sx * halfW - handleCm / 2}
y={sy * halfH - handleCm / 2}
width={handleCm}
height={handleCm}
fill="#ffffff"
stroke={SELECT_COLOR}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
style={{ cursor: 'nwse-resize' }}
onPointerDown={(e) => startResize(e, sx, sy)}
/>
))}
</g>
)
}