Address Gadfly review on #11 field editor
Build image / build-and-push (push) Successful in 8s

Fix the real bugs surfaced by the PR #30 adversarial review (duplicates
across models collapsed):

- Optimistic PATCH now bumps the cached row version so a second edit
  started before the first PATCH resolves sends the next version instead
  of re-sending a stale one and self-conflicting (spurious 409 +
  "updated elsewhere" toast + lost edit). Drop the per-PATCH onSettled
  refetch: onSuccess already writes the authoritative row and onError
  always rolls back, so the invalidate was a request storm during drags
  and a refetch race, with no drift benefit.
- Color picker committed a PATCH on every onChange (continuous during a
  native picker drag). Track the value locally and commit one PATCH on
  blur.
- Rotate read shiftKey from the pointerdown event, so toggling Shift
  mid-drag didn't switch snap/free. Pass the live pointermove event into
  the gesture callback and read shiftKey from it.
- Window pointer listeners had no unmount cleanup: deleting/deselecting
  an object mid-drag leaked listeners and could leave objectDragging
  stuck true (freezing pan). Track the active gesture's cleanup and run
  it on unmount.
- commitDim accepted 0/negative width/height (invalid geometry flashed
  into the optimistic cache before rollback) and fired a spurious 1cm
  PATCH on blur-without-edit from cm<->ft rounding drift. Guard
  width/height against the server minimum and compare at display
  precision first.

Perf:
- Split the z-order sort from the live-merge so a pointermove is an O(n)
  map, not an O(n log n) re-sort.
- Memoize the toEditorObject map so object identities are stable across
  renders and ObjectShape's memo holds.
- Snapshot the svg rect once per gesture instead of calling
  getBoundingClientRect (a layout reflow) on every pointermove.

Maintainability:
- Move per-kind default z-index into kinds.ts (single source of truth).
- Rename the ToastItem `toast` param so it stops shadowing the module's
  `toast` export.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-18 21:01:00 -04:00
co-authored by Claude Opus 4.8
parent 451a4b00cc
commit 2cde8a24f5
7 changed files with 140 additions and 75 deletions
+8 -7
View File
@@ -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 (
<div
role={toast.tone === 'error' ? 'alert' : 'status'}
role={item.tone === 'error' ? 'alert' : 'status'}
className={cn(
'pointer-events-auto rounded-md border px-3 py-2 text-sm shadow-md',
toast.tone === 'error'
item.tone === 'error'
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
: 'border-border bg-surface text-fg',
)}
>
{toast.message}
{item.message}
</div>
)
}
@@ -57,7 +58,7 @@ export function Toaster() {
return (
<div className="pointer-events-none fixed bottom-4 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2">
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} />
<ToastItem key={t.id} item={t} />
))}
</div>
)
+9 -14
View File
@@ -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) },
)
+34 -8
View File
@@ -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<HTMLDivElement>(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<Omit<EditorObject, 'id' | 'version'>>) =>
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)}
/>
<TextField
label={`Height (${u})`}
@@ -105,7 +121,7 @@ export function Inspector({
step="any"
value={height}
onChange={(e) => setHeight(e.target.value)}
onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }))}
onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)}
/>
<TextField
label={`X (${u})`}
@@ -151,13 +167,23 @@ export function Inspector({
<input
id="obj-color"
type="color"
value={object.color ?? '#8a8a8a'}
onChange={(e: ChangeEvent<HTMLInputElement>) => 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<HTMLInputElement>) => 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"
/>
</div>
{object.color && (
<Button variant="ghost" className="px-2 py-1.5 text-xs" onClick={() => patch({ color: null })}>
<Button
variant="ghost"
className="px-2 py-1.5 text-xs"
onClick={() => {
setColor(DEFAULT_COLOR)
patch({ color: null })
}}
>
Clear
</Button>
)}
+56 -28
View File
@@ -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()
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<typeof update.mutate>[0],
) => {
e.stopPropagation()
e.preventDefault()
setObjectDragging(true)
trackPointer(
(ev) => setLiveObject(onMove(pointerWorld(ev))),
() => {
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 }
},
+10 -7
View File
@@ -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 {
+9 -2
View File
@@ -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<FullGarden>(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 } : {}),
+7 -2
View File
@@ -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 (