Two independent grids, each with a size and a snap toggle: - Garden grid (owner-set, in the garden settings form): drives the editor's visual grid and, when snapping is on, snaps objects on place/move and snaps their dimensions to whole grid steps on resize. - Bed grid (per plantable object, in the bed Inspector): when snapping is on, plants snap to the bed's grid on place/move, and the grid is drawn inside the focused bed. Plant radius stays free. Snapping defaults off on both, so existing gardens keep free placement. Grid spacing shares the [1cm, 100m] range and defaults to 1m (garden) / 30cm (bed); 0 is treated as unset and re-defaulted. Backend: migration 0005 adds grid_size_cm/snap_to_grid to gardens and garden_objects, threaded through domain/store/service/api with service + api round-trip tests. Frontend: new geometry snap helpers (unit-tested), schema/type plumbing, canvas + overlay snapping, and the two forms. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
217 lines
8.5 KiB
TypeScript
217 lines
8.5 KiB
TypeScript
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<SVGSVGElement | null>
|
|
// 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<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)
|
|
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 (
|
|
<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>
|
|
)
|
|
}
|