Address Gadfly review on #11 field editor
Build image / build-and-push (push) Successful in 8s
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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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<typeof update.mutate>[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 }
|
||||
},
|
||||
|
||||
+10
-7
@@ -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 {
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user