diff --git a/web/src/components/ui/toast.tsx b/web/src/components/ui/toast.tsx index a1810b2..3d41450 100644 --- a/web/src/components/ui/toast.tsx +++ b/web/src/components/ui/toast.tsx @@ -29,23 +29,24 @@ export const toast = { error: (m: string) => useToastStore.getState().push(m, 'error'), } -function ToastItem({ toast }: { toast: Toast }) { +// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export. +function ToastItem({ item }: { item: Toast }) { const dismiss = useToastStore((s) => s.dismiss) useEffect(() => { - const t = setTimeout(() => dismiss(toast.id), 4000) + const t = setTimeout(() => dismiss(item.id), 4000) return () => clearTimeout(t) - }, [toast.id, dismiss]) + }, [item.id, dismiss]) return (
- {toast.message} + {item.message}
) } @@ -57,7 +58,7 @@ export function Toaster() { return (
{toasts.map((t) => ( - + ))}
) diff --git a/web/src/editor/GardenCanvas.tsx b/web/src/editor/GardenCanvas.tsx index 0d0595d..93f1a4d 100644 --- a/web/src/editor/GardenCanvas.tsx +++ b/web/src/editor/GardenCanvas.tsx @@ -12,14 +12,6 @@ const GRID_CM = 100 // 1 m grid const GRID_MIN_CELL_PX = 6 const GRID_MAX_OPACITY = 0.18 -// Kinds that render beneath beds by default; explicit z_index still wins. -const UNDER_KINDS = new Set(['path', 'structure', 'in_ground']) -function defaultZForKind(kind: string): number { - if (UNDER_KINDS.has(kind)) return 0 - if (kind === 'tree') return 2 - return 1 -} - /** * The editor canvas. Pan/zoom/pinch (from useViewport), tap-to-place from the * armed palette kind, click to select, and — for the selected object — @@ -78,11 +70,14 @@ export function GardenCanvas({ const cellPx = GRID_CM * viewport.scale const gridOpacity = Math.max(0, Math.min(GRID_MAX_OPACITY, ((cellPx - GRID_MIN_CELL_PX) / GRID_MIN_CELL_PX) * GRID_MAX_OPACITY)) - // Merge the in-flight live geometry over the server objects, then z-order. - const rendered = useMemo(() => { - const merged = liveObject ? objects.map((o) => (o.id === liveObject.id ? liveObject : o)) : objects - return [...merged].sort((a, b) => a.zIndex - b.zIndex) - }, [objects, liveObject]) + // Sort once by z-order; only re-sorts when the server objects change. + const sorted = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects]) + // Merge the in-flight live geometry over the sorted list. A gesture never + // changes z, so this stays a cheap O(n) map on every pointermove — no re-sort. + const rendered = useMemo( + () => (liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted), + [sorted, liveObject], + ) const selected = rendered.find((o) => o.id === selectedId) ?? null // A pointerdown reaching the svg is empty space: place the armed kind there, or @@ -107,7 +102,7 @@ export function GardenCanvas({ yCm: world.y, widthCm: def.widthCm, heightCm: def.heightCm, - zIndex: defaultZForKind(def.kind), + zIndex: def.defaultZ, }, { onSuccess: (o) => select(o.id) }, ) diff --git a/web/src/editor/Inspector.tsx b/web/src/editor/Inspector.tsx index f1bf99b..7060e69 100644 --- a/web/src/editor/Inspector.tsx +++ b/web/src/editor/Inspector.tsx @@ -3,11 +3,19 @@ import { Button } from '@/components/ui/Button' import { TextField } from '@/components/ui/TextField' import { TextArea } from '@/components/ui/TextArea' import { useDeleteObject, useUpdateObject } from '@/lib/objects' -import { cmFromDisplay, dimensionUnitLabel, displayFromCm, type UnitPref } from '@/lib/units' +import { + cmFromDisplay, + dimensionUnitLabel, + displayFromCm, + MIN_DIMENSION_CM, + type UnitPref, +} from '@/lib/units' import { kindDef } from './kinds' import { useEditorStore } from './store' import type { EditorObject } from './types' +const DEFAULT_COLOR = '#8a8a8a' + /** * Property panel for the selected object. Each field commits a PATCH on * blur/change (carrying the object's version); dimensions and position are shown @@ -35,6 +43,7 @@ export function Inspector({ const [x, setX] = useState(String(displayFromCm(object.xCm, unit))) const [y, setY] = useState(String(displayFromCm(object.yCm, unit))) const [rotation, setRotation] = useState(String(Math.round(object.rotationDeg))) + const [color, setColor] = useState(object.color ?? DEFAULT_COLOR) const [confirmingDelete, setConfirmingDelete] = useState(false) const rootRef = useRef(null) @@ -50,15 +59,22 @@ export function Inspector({ setX(String(displayFromCm(object.xCm, unit))) setY(String(displayFromCm(object.yCm, unit))) setRotation(String(Math.round(object.rotationDeg))) - }, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, unit]) + setColor(object.color ?? DEFAULT_COLOR) + }, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, object.color, unit]) const patch = (fields: Partial>) => update.mutate({ id: object.id, version: object.version, ...fields }) - const commitDim = (raw: string, current: number, apply: (cm: number) => void) => { + // Commit a dimension/position field. `positive` gates width/height (must be + // ≥ the server minimum) but not x/y, which may be zero or negative. Compare at + // display precision first so a blur without an edit doesn't fire a spurious + // PATCH from cm↔ft rounding drift (e.g. 240cm shows 7.9ft → 241cm). + const commitDim = (raw: string, current: number, apply: (cm: number) => void, positive = false) => { const v = parseFloat(raw) if (!Number.isFinite(v)) return + if (v === displayFromCm(current, unit)) return const cm = cmFromDisplay(v, unit) + if (positive && cm < MIN_DIMENSION_CM) return if (cm !== current) apply(cm) } @@ -95,7 +111,7 @@ export function Inspector({ step="any" value={width} onChange={(e) => setWidth(e.target.value)} - onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }))} + onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }), true)} /> setHeight(e.target.value)} - onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }))} + onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)} /> ) => patch({ color: e.target.value })} + value={color} + // onChange fires continuously while dragging in the native picker; + // track it locally for the live swatch and commit one PATCH on blur. + onChange={(e: ChangeEvent) => setColor(e.target.value)} + onBlur={() => color !== (object.color ?? DEFAULT_COLOR) && patch({ color })} className="h-9 w-14 cursor-pointer rounded-md border border-border bg-surface" /> {object.color && ( - )} diff --git a/web/src/editor/SelectionOverlay.tsx b/web/src/editor/SelectionOverlay.tsx index ce4069a..b72b0df 100644 --- a/web/src/editor/SelectionOverlay.tsx +++ b/web/src/editor/SelectionOverlay.tsx @@ -1,4 +1,4 @@ -import type { PointerEvent as ReactPointerEvent, RefObject } from 'react' +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 { useEditorStore } from './store' @@ -17,20 +17,6 @@ const corners: [number, number][] = [ [-1, 1], ] -// Register window listeners for a pointer drag; cleaned up on up/cancel. -function trackPointer(onMove: (e: PointerEvent) => void, onEnd: () => void) { - const move = (e: PointerEvent) => onMove(e) - const end = () => { - window.removeEventListener('pointermove', move) - window.removeEventListener('pointerup', end) - window.removeEventListener('pointercancel', end) - onEnd() - } - window.addEventListener('pointermove', move) - window.addEventListener('pointerup', end) - window.addEventListener('pointercancel', end) -} - /** * Handles for the selected object: a transparent body to move, four corner * handles to resize (opposite corner stays put, honoring rotation via the @@ -52,47 +38,83 @@ export function SelectionOverlay({ 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 - const pointerWorld = (e: PointerEvent): Point => { + // 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() - 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) + 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, and on release - // fire one PATCH built from the final liveObject. + // 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: (world: Point) => EditorObject, + onMove: (e: PointerEvent) => EditorObject, fields: (final: EditorObject) => Parameters[0], ) => { e.stopPropagation() e.preventDefault() setObjectDragging(true) - trackPointer( - (ev) => setLiveObject(onMove(pointerWorld(ev))), - () => { - const final = useEditorStore.getState().liveObject - setObjectDragging(false) - setLiveObject(null) - if (final) update.mutate(fields(final)) - }, - ) + 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, - (world) => ({ ...base, xCm: base.xCm + (world.x - start.x), yCm: base.yCm + (world.y - start.y) }), + (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 }), ) } @@ -101,9 +123,11 @@ export function SelectionOverlay({ // 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, - (world) => { + (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)) @@ -120,12 +144,16 @@ export function SelectionOverlay({ } const startRotate = (e: ReactPointerEvent) => { + const pointerWorld = makePointerWorld() begin( e, - (world) => { + (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 - if (!e.shiftKey) deg = Math.round(deg / ROTATE_SNAP_DEG) * ROTATE_SNAP_DEG + // 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 } }, diff --git a/web/src/editor/kinds.ts b/web/src/editor/kinds.ts index 0809ab4..a8567ed 100644 --- a/web/src/editor/kinds.ts +++ b/web/src/editor/kinds.ts @@ -9,16 +9,19 @@ export interface KindDef { shape: ObjectShapeKind widthCm: number heightCm: number + // Default z-index at placement. Paths/structures/in-ground sit under beds (0); + // beds and containers sit at 1; trees float above (2). Explicit z still wins. + defaultZ: number } export const OBJECT_KINDS: KindDef[] = [ - { kind: 'bed', label: 'Bed', icon: '▭', shape: 'rect', widthCm: 120, heightCm: 240 }, - { kind: 'grow_bag', label: 'Grow bag', icon: '🛍️', shape: 'circle', widthCm: 40, heightCm: 40 }, - { kind: 'container', label: 'Container', icon: '🪴', shape: 'circle', widthCm: 60, heightCm: 60 }, - { kind: 'in_ground', label: 'In-ground', icon: '🟫', shape: 'rect', widthCm: 200, heightCm: 200 }, - { kind: 'tree', label: 'Tree', icon: '🌳', shape: 'circle', widthCm: 300, heightCm: 300 }, - { kind: 'path', label: 'Path', icon: '🧱', shape: 'rect', widthCm: 100, heightCm: 300 }, - { kind: 'structure', label: 'Structure', icon: '🏠', shape: 'rect', widthCm: 200, heightCm: 200 }, + { kind: 'bed', label: 'Bed', icon: '▭', shape: 'rect', widthCm: 120, heightCm: 240, defaultZ: 1 }, + { kind: 'grow_bag', label: 'Grow bag', icon: '🛍️', shape: 'circle', widthCm: 40, heightCm: 40, defaultZ: 1 }, + { kind: 'container', label: 'Container', icon: '🪴', shape: 'circle', widthCm: 60, heightCm: 60, defaultZ: 1 }, + { kind: 'in_ground', label: 'In-ground', icon: '🟫', shape: 'rect', widthCm: 200, heightCm: 200, defaultZ: 0 }, + { kind: 'tree', label: 'Tree', icon: '🌳', shape: 'circle', widthCm: 300, heightCm: 300, defaultZ: 2 }, + { kind: 'path', label: 'Path', icon: '🧱', shape: 'rect', widthCm: 100, heightCm: 300, defaultZ: 0 }, + { kind: 'structure', label: 'Structure', icon: '🏠', shape: 'rect', widthCm: 200, heightCm: 200, defaultZ: 0 }, ] export function kindDef(kind: string): KindDef | undefined { diff --git a/web/src/lib/objects.ts b/web/src/lib/objects.ts index f6b9ba4..f671e1d 100644 --- a/web/src/lib/objects.ts +++ b/web/src/lib/objects.ts @@ -156,7 +156,10 @@ export function useUpdateObject(gardenId: number) { toast.error(objectErrorMessage(err, 'Could not save that change.')) } }, - onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }), + // No onSettled refetch: onSuccess already writes the authoritative server + // row and onError reconciles. Invalidating here would fire a full-garden GET + // after every PATCH — a request storm during drags, and a refetch race that + // can clobber a fast follow-up edit. }) } @@ -184,10 +187,14 @@ function patchFullCache(qc: QueryClient, gardenId: number, fn: (full: FullGarden qc.setQueryData(fullKey(gardenId), (full) => (full ? fn(full) : full)) } -/** Apply a patch onto a cached server object for the optimistic pass. */ +/** Apply a patch onto a cached server object for the optimistic pass. The + * version is bumped to mirror the server's post-commit increment, so a second + * edit started before the first PATCH resolves reads the next version instead + * of re-sending the stale one and self-conflicting (a spurious 409). */ function applyServerPatch(o: ServerObject, patch: ObjectPatch): ServerObject { return { ...o, + version: o.version + 1, ...(patch.name !== undefined ? { name: patch.name } : {}), ...(patch.xCm !== undefined ? { xCm: patch.xCm } : {}), ...(patch.yCm !== undefined ? { yCm: patch.yCm } : {}), diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx index e445a4d..ce138c0 100644 --- a/web/src/pages/GardenEditorPage.tsx +++ b/web/src/pages/GardenEditorPage.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { getRouteApi } from '@tanstack/react-router' import { Alert } from '@/components/ui/Alert' import { GardenCanvas } from '@/editor/GardenCanvas' @@ -21,6 +21,12 @@ export function GardenEditorPage() { const setArmedKind = useEditorStore((s) => s.setArmedKind) const setLiveObject = useEditorStore((s) => s.setLiveObject) + // Map the server rows to EditorObjects once per cache change. Rebuilding this + // every render (e.g. on each viewport/selection update) would hand ObjectShape + // fresh identities and defeat its memo, re-rendering every object. + const serverObjects = full.data?.objects + const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects]) + // Clear transient editor state on entering/switching gardens and on leaving. useEffect(() => { const reset = () => { @@ -48,7 +54,6 @@ export function GardenEditorPage() { heightCm: g.heightCm, unitPref: g.unitPref, } - const objects = full.data.objects.map(toEditorObject) const selected = objects.find((o) => o.id === selectedId) ?? null return (