Plops editor: focus mode, place/move/resize, semantic zoom (#15) #34

Merged
steve merged 2 commits from phase-5-plops-editor into main 2026-07-19 03:22:51 +00:00
14 changed files with 1084 additions and 73 deletions
+121 -47
View File
@@ -1,9 +1,14 @@
import { useEffect, useId, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { screenToWorld, type Rect, type Size } from '@/lib/geometry'
import { useCreateObject } from '@/lib/objects'
import { screenToWorld, worldToLocal, type Rect, type Size } from '@/lib/geometry'
import { useCreateObject, useCreatePlanting } from '@/lib/objects'
import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
import { ObjectShape } from './ObjectShape'
import { PlopLayer } from './PlopLayer'
import { PlopOverlay } from './PlopOverlay'
import { SelectionOverlay } from './SelectionOverlay'
import { kindDef } from './kinds'
import { DIMMED_OPACITY, objectTransform } from './shared'
import { useEditorStore } from './store'
import { useViewport } from './useViewport'
import type { EditorGarden, EditorObject } from './types'
@@ -12,33 +17,48 @@ const GRID_CM = 100 // 1 m grid
const GRID_MIN_CELL_PX = 6
const GRID_MAX_OPACITY = 0.18
Outdated
Review

🟡 Focus dimming opacity inconsistent across components

maintainability · flagged by 2 models

🪰 Gadfly · advisory

🟡 **Focus dimming opacity inconsistent across components** _maintainability · flagged by 2 models_ <sub>🪰 Gadfly · advisory</sub>
/** The world-space bounds rect of an object (center + size → top-left rect). */
function objectRect(o: EditorObject): Rect {
return { x: o.xCm - o.widthCm / 2, y: o.yCm - o.heightCm / 2, w: o.widthCm, h: o.heightCm }
}
/**
* The editor canvas. Pan/zoom/pinch (from useViewport), tap-to-place from the
* armed palette kind, click to select, and — for the selected object —
* move/resize/rotate via SelectionOverlay. The object being dragged is rendered
* from liveObject for instant feedback; the PATCH fires on release.
* The editor canvas. Pan/zoom/pinch, tap-to-place objects, select+move/resize/
* rotate an object, and — the #15 core — focus into a plantable object to place,
* move, resize and remove plops with semantic-zoom rendering. The object/plop
* being dragged renders from liveObject/livePlanting for instant feedback; the
* PATCH fires on release.
*/
export function GardenCanvas({
garden,
objects,
focusId,
plantings,
plantsById,
}: {
garden: EditorGarden
objects: EditorObject[]
focusId?: number
plantings: EditorPlanting[]
plantsById: Map<number, Plant>
}) {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState<Size>({ w: 0, h: 0 })
const fittedForRef = useRef<number | null>(null)
const fitKeyRef = useRef<string | null>(null)
const gridId = 'garden-grid-' + useId().replace(/:/g, '')
const viewport = useEditorStore((s) => s.viewport)
const selectedId = useEditorStore((s) => s.selectedId)
const select = useEditorStore((s) => s.select)
const selectedPlantingId = useEditorStore((s) => s.selectedPlantingId)
const selectPlanting = useEditorStore((s) => s.selectPlanting)
const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
const armedPlant = useEditorStore((s) => s.armedPlant)
const liveObject = useEditorStore((s) => s.liveObject)
const livePlanting = useEditorStore((s) => s.livePlanting)
const { fitToRect } = useViewport(svgRef)
const create = useCreateObject(garden.id)
const createObject = useCreateObject(garden.id)
const createPlanting = useCreatePlanting(garden.id)
const gardenRect: Rect = useMemo(
() => ({ x: 0, y: 0, w: garden.widthCm, h: garden.heightCm }),
1
@@ -53,61 +73,83 @@ export function GardenCanvas({
return () => ro.disconnect()
}, [])
// Single fit mechanism: frame the focused object, else the whole garden, and
// animate whenever that target (or the garden) changes. The key guard stops a
// refit on unrelated re-renders (a plop add, a bed drag).
useEffect(() => {
const usable = size.w > 0 && size.h > 0 && garden.widthCm > 0 && garden.heightCm > 0
if (usable && fittedForRef.current !== garden.id) {
fittedForRef.current = garden.id
// ?focus=<id> frames that object (groundwork for #15's focus mode);
// otherwise frame the whole garden.
const focus = focusId != null ? objects.find((o) => o.id === focusId) : undefined
const target: Rect = focus
? { x: focus.xCm - focus.widthCm / 2, y: focus.yCm - focus.heightCm / 2, w: focus.widthCm, h: focus.heightCm }
: gardenRect
fitToRect(target, size)
}
}, [size, garden.id, garden.widthCm, garden.heightCm, gardenRect, fitToRect, focusId, objects])
if (size.w === 0 || size.h === 0 || garden.widthCm === 0 || garden.heightCm === 0) return
const key = `${garden.id}:${focusedObjectId}`
if (fitKeyRef.current === key) return
const target = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) : undefined
if (focusedObjectId != null && !target) return // object not loaded yet; try again when it is
fitKeyRef.current = key
fitToRect(target ? objectRect(target) : gardenRect, size)
}, [size, garden.id, garden.widthCm, garden.heightCm, focusedObjectId, objects, gardenRect, fitToRect])
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))
// 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
// Merge the in-flight live plop over the active list, same as liveObject.
const renderedPlops = useMemo(
() => (livePlanting ? plantings.map((p) => (p.id === livePlanting.id ? livePlanting : p)) : plantings),
[plantings, livePlanting],
)
const selectedObject = rendered.find((o) => o.id === selectedId) ?? null
const selectedPlop = renderedPlops.find((p) => p.id === selectedPlantingId) ?? null
const selectedPlopObject = selectedPlop ? rendered.find((o) => o.id === selectedPlop.objectId) ?? null : null
const focusedObject = focusedObjectId != null ? rendered.find((o) => o.id === focusedObjectId) ?? null : null
// A pointerdown reaching the svg is empty space: place the armed kind there, or
// deselect. (Objects/handles stopPropagation.)
// A pointerdown reaching the svg is empty space: place the armed object kind,
// or exit focus mode, or just deselect.
function onCanvasPointerDown(e: ReactPointerEvent) {
const armed = useEditorStore.getState().armedKind
if (!armed) {
select(null)
if (armed) {
useEditorStore.getState().setArmedKind(null)
const def = kindDef(armed)
const rect = svgRef.current?.getBoundingClientRect()
if (!def || !rect) return
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
createObject.mutate(
{ kind: def.kind, shape: def.shape, xCm: world.x, yCm: world.y, widthCm: def.widthCm, heightCm: def.heightCm, zIndex: def.defaultZ },
{ onSuccess: (o) => select(o.id) },
)
return
}
const def = kindDef(armed)
useEditorStore.getState().setArmedKind(null)
if (!def) return
// Empty-space tap while focused exits focus (and any placement); otherwise
// just clears the selection.
if (focusedObjectId != null) {
setFocusedObject(null)
useEditorStore.getState().setArmedPlant(null)
}
select(null)
selectPlanting(null)
}
// Drop a plop where the user taps inside the focused object (placement stays
// armed for repeat-placement until Escape / Done).
function onPlace(e: ReactPointerEvent) {
e.stopPropagation()
if (!focusedObject || !armedPlant || !focusedObject.plantable) return
const rect = svgRef.current?.getBoundingClientRect()
if (!rect) return
Outdated
Review

🟡 onPlace allows planting on non-plantable objects when deep-linked

correctness · flagged by 1 model

  • web/src/editor/GardenCanvas.tsx:139onPlace guards with if (!focusedObject || !armedPlant) return but never checks focusedObject.plantable. A deep-link (?focus=<id>) to a non-plantable object still lets the user arm and place a plant; the server will likely reject it, but the client should prevent the attempt. Fix: add || !focusedObject.plantable to the guard.

🪰 Gadfly · advisory

🟡 **onPlace allows planting on non-plantable objects when deep-linked** _correctness · flagged by 1 model_ - **`web/src/editor/GardenCanvas.tsx:139`** — `onPlace` guards with `if (!focusedObject || !armedPlant) return` but never checks `focusedObject.plantable`. A deep-link (`?focus=<id>`) to a non-plantable object still lets the user arm and place a plant; the server will likely reject it, but the client should prevent the attempt. **Fix:** add `|| !focusedObject.plantable` to the guard. <sub>🪰 Gadfly · advisory</sub>
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
create.mutate(
{
kind: def.kind,
shape: def.shape,
xCm: world.x,
yCm: world.y,
widthCm: def.widthCm,
heightCm: def.heightCm,
zIndex: def.defaultZ,
},
{ onSuccess: (o) => select(o.id) },
)
const local = worldToLocal(world, { x: focusedObject.xCm, y: focusedObject.yCm }, focusedObject.rotationDeg)
const x = Math.max(-focusedObject.widthCm / 2, Math.min(focusedObject.widthCm / 2, local.x))
const y = Math.max(-focusedObject.heightCm / 2, Math.min(focusedObject.heightCm / 2, local.y))
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
Review

🟡 Clamp-to-object-bounds duplicated between GardenCanvas.onPlace and PlopOverlay.clampLocal

maintainability · flagged by 1 model

🪰 Gadfly · advisory

🟡 **Clamp-to-object-bounds duplicated between GardenCanvas.onPlace and PlopOverlay.clampLocal** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
// Stay armed for repeat-placement; don't select (the placement sheet covers
// the object, so a selection would be hidden until placement ends anyway).
createPlanting.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, xCm: x, yCm: y, radiusCm })
}
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
const halfFH = focusedObject ? focusedObject.heightCm / 2 : 0
return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
<svg
@@ -131,10 +173,42 @@ export function GardenCanvas({
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill="none" stroke="#3f8f4f" strokeOpacity={0.7} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
{rendered.map((o) => (
<ObjectShape key={o.id} object={o} selected={o.id === selectedId} onSelect={select} />
<g key={o.id} opacity={focusedObjectId != null && focusedObjectId !== o.id ? DIMMED_OPACITY : 1}>
<ObjectShape object={o} selected={o.id === selectedId} onSelect={select} />
</g>
))}
{selected && <SelectionOverlay object={selected} gardenId={garden.id} svgRef={svgRef} />}
<PlopLayer
objects={rendered}
plantings={renderedPlops}
plantsById={plantsById}
scale={viewport.scale}
focusedObjectId={focusedObjectId}
selectedPlantingId={selectedPlantingId}
onSelectPlop={selectPlanting}
/>
{selectedObject && <SelectionOverlay object={selectedObject} gardenId={garden.id} svgRef={svgRef} />}
{selectedPlop && selectedPlopObject && (
<PlopOverlay plop={selectedPlop} object={selectedPlopObject} gardenId={garden.id} svgRef={svgRef} />
)}
{/* Placement capture: a transparent sheet over the focused object while a
plant is armed, so taps drop plops instead of selecting the object. */}
{focusedObject && armedPlant && focusedObject.plantable && (
<g transform={objectTransform(focusedObject)}>
<rect
x={-halfFW}
y={-halfFH}
width={halfFW * 2}
height={halfFH * 2}
fill="transparent"
pointerEvents="all"
style={{ cursor: 'crosshair' }}
onPointerDown={onPlace}
/>
</g>
)}
</g>
</svg>
+8
View File
@@ -26,10 +26,12 @@ export function Inspector({
object,
gardenId,
unit,
onFocus,
}: {
object: EditorObject
gardenId: number
unit: UnitPref
onFocus?: () => void
}) {
const update = useUpdateObject(gardenId)
const del = useDeleteObject(gardenId)
@@ -94,6 +96,12 @@ export function Inspector({
</button>
</div>
{object.plantable && onFocus && (
<Button onClick={onFocus} className="w-full">
🌱 Plant here
</Button>
)}
<TextField
label="Name"
name="name"
+2 -5
View File
@@ -1,4 +1,5 @@
import { memo, type PointerEvent } from 'react'
import { objectTransform } from './shared'
import type { EditorObject } from './types'
const DEFAULT_FILL = '#8a8a8a'
@@ -60,11 +61,7 @@ export const ObjectShape = memo(function ObjectShape({
const strokeWidth = selected ? 2 : 1
return (
<g
transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`}
onPointerDown={handleDown}
style={{ cursor: 'pointer' }}
>
<g transform={objectTransform(object)} onPointerDown={handleDown} style={{ cursor: 'pointer' }}>
{object.shape === 'circle' ? (
<ellipse
cx={0}
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/Button'
import { TextField } from '@/components/ui/TextField'
import { PlantIcon } from '@/components/plants/PlantIcon'
import { useRemovePlanting, useUpdatePlanting } from '@/lib/objects'
import type { Plant } from '@/lib/plants'
import { computeDerivedCount, type EditorPlanting } from '@/lib/plantings'
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
import { MIN_RADIUS_CM } from './shared'
import { useEditorStore } from './store'
Outdated
Review

🟡 MIN_RADIUS_CM duplicated verbatim between PlopInspector.tsx and PlopOverlay.tsx, both new in this PR

maintainability · flagged by 2 models

  • web/src/editor/PlopOverlay.tsx:11 / web/src/editor/PlopMarker.tsx:12const SELECT_COLOR = '#2f7a3e' is now defined identically in three places: the pre-existing SelectionOverlay.tsx:11, and the two new plop files added by this PR. A future "change the selection color" edit will silently miss two of three spots. Suggest hoisting to a small shared constants module (e.g. editor/colors.ts) or exporting it from SelectionOverlay.tsx. - **web/src/editor/PlopInspector.tsx:10 and `w…

🪰 Gadfly · advisory

🟡 **MIN_RADIUS_CM duplicated verbatim between PlopInspector.tsx and PlopOverlay.tsx, both new in this PR** _maintainability · flagged by 2 models_ - **`web/src/editor/PlopOverlay.tsx:11` / `web/src/editor/PlopMarker.tsx:12`** — `const SELECT_COLOR = '#2f7a3e'` is now defined identically in three places: the pre-existing `SelectionOverlay.tsx:11`, and the two new plop files added by this PR. A future "change the selection color" edit will silently miss two of three spots. Suggest hoisting to a small shared constants module (e.g. `editor/colors.ts`) or exporting it from `SelectionOverlay.tsx`. - **`web/src/editor/PlopInspector.tsx:10` and `w… <sub>🪰 Gadfly · advisory</sub>
/**
* Property panel for the selected plop. Radius/label/date/count commit a PATCH on
* blur (carrying the version); the count field shows the live derived value as a
* placeholder and takes an override when typed. "Remove" soft-removes (keeps the
* row with removed_at); "Change plant" opens the picker via onChangePlant. While
* the plop is dragged/resized on the canvas, it reads livePlanting for live
* feedback (subscribed here, not at the page level, so a drag only re-renders
* this panel).
*/
export function PlopInspector({
plop,
plant,
gardenId,
unit,
onChangePlant,
onClose,
}: {
plop: EditorPlanting
plant?: Plant
gardenId: number
unit: UnitPref
onChangePlant: () => void
onClose: () => void
}) {
const update = useUpdatePlanting(gardenId)
const remove = useRemovePlanting(gardenId)
Review

🟡 Four useState initializers duplicated verbatim in the resync effect; extract a sync helper

maintainability · flagged by 1 model

  • web/src/editor/PlopInspector.tsx:37-40 + 46-49 — The four useState initializers are duplicated verbatim in the resync useEffect. Extract a syncFromPlop(plop, unit) helper (or a small useSyncedFields hook) to keep the two sites from drifting.

🪰 Gadfly · advisory

🟡 **Four useState initializers duplicated verbatim in the resync effect; extract a sync helper** _maintainability · flagged by 1 model_ - **`web/src/editor/PlopInspector.tsx:37-40`** + **`46-49`** — The four `useState` initializers are duplicated verbatim in the resync `useEffect`. Extract a `syncFromPlop(plop, unit)` helper (or a small `useSyncedFields` hook) to keep the two sites from drifting. <sub>🪰 Gadfly · advisory</sub>
const livePlanting = useEditorStore((s) => s.livePlanting)
const rootRef = useRef<HTMLDivElement>(null)
// The plop with any in-flight drag/resize geometry applied.
const p = livePlanting && livePlanting.id === plop.id ? livePlanting : plop
const [radius, setRadius] = useState(String(spacingFromCm(p.radiusCm, unit)))
const [count, setCount] = useState(p.count != null ? String(p.count) : '')
const [label, setLabel] = useState(p.label ?? '')
const [planted, setPlanted] = useState(p.plantedAt ?? '')
// Re-sync when the plop changes underneath us (a drag/resize or a server row),
// unless the user is editing a field here.
useEffect(() => {
if (rootRef.current?.contains(document.activeElement)) return
setRadius(String(spacingFromCm(p.radiusCm, unit)))
setCount(p.count != null ? String(p.count) : '')
setLabel(p.label ?? '')
setPlanted(p.plantedAt ?? '')
}, [p.version, p.radiusCm, p.count, p.label, p.plantedAt, unit])
const patch = (fields: Omit<Parameters<typeof update.mutate>[0], 'id' | 'version'>) =>
Review

🟠 commitRadius uses parseFloat which accepts partial parses like '1.2.3' without user feedback

error-handling · flagged by 1 model

🪰 Gadfly · advisory

🟠 **commitRadius uses parseFloat which accepts partial parses like '1.2.3' without user feedback** _error-handling · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
update.mutate({ id: plop.id, version: plop.version, ...fields })
const u = spacingUnitLabel(unit)
const derived = plant ? computeDerivedCount(p.radiusCm, plant.spacingCm) : p.derivedCount
function commitRadius() {
Review

🟡 commitCount silently drops invalid input with no reset or feedback, unlike commitRadius which clamps

error-handling · flagged by 1 model

  • web/src/editor/Inspector.tsx:227 + web/src/pages/GardenEditorPage.tsx:78-99,118,140 + web/src/editor/GardenCanvas.tsx:81-89,178 — Deleting the currently-focused object leaves the editor stuck in a broken focus state. Confirmed: ObjectShape.tsx has no focus-mode gating on onPointerDown — the focused object itself renders at full opacity (GardenCanvas.tsx:178, focusedObjectId !== o.id dimming only applies to other objects) and is fully selectable via the plain select action…

🪰 Gadfly · advisory

🟡 **commitCount silently drops invalid input with no reset or feedback, unlike commitRadius which clamps** _error-handling · flagged by 1 model_ - **`web/src/editor/Inspector.tsx:227` + `web/src/pages/GardenEditorPage.tsx:78-99,118,140` + `web/src/editor/GardenCanvas.tsx:81-89,178`** — Deleting the currently-focused object leaves the editor stuck in a broken focus state. Confirmed: `ObjectShape.tsx` has no focus-mode gating on `onPointerDown` — the focused object itself renders at full opacity (`GardenCanvas.tsx:178`, `focusedObjectId !== o.id` dimming only applies to *other* objects) and is fully selectable via the plain `select` action… <sub>🪰 Gadfly · advisory</sub>
const v = Number(radius)
if (radius.trim() === '' || !Number.isFinite(v)) {
setRadius(String(spacingFromCm(p.radiusCm, unit))) // reset stale/invalid text
return
}
Outdated
Review

🟡 commitCount silently rejects invalid/float input without user feedback

error-handling · flagged by 1 model

🪰 Gadfly · advisory

🟡 **commitCount silently rejects invalid/float input without user feedback** _error-handling · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
const cm = Math.max(MIN_RADIUS_CM, cmFromSpacing(v, unit))
if (cm !== p.radiusCm) patch({ radiusCm: cm })
}
function commitCount() {
if (count.trim() === '') {
if (p.count != null) patch({ count: null }) // restore derived
return
}
const n = Number(count)
if (!Number.isInteger(n) || n < 1) {
setCount(p.count != null ? String(p.count) : '') // reject invalid, restore
return
}
if (n !== p.count) patch({ count: n })
}
function commitPlanted() {
const next = planted === '' ? null : planted
if (next !== (p.plantedAt ?? null)) patch({ plantedAt: next })
}
return (
<div ref={rootRef} className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-fg">Plant</h2>
<button
type="button"
onClick={onClose}
className="rounded px-1.5 text-sm text-muted hover:text-fg"
aria-label="Close inspector"
>
</button>
</div>
<div className="flex items-center gap-2 rounded-lg border border-border p-2">
{plant ? (
<PlantIcon color={plant.color} icon={plant.icon} className="h-9 w-9 rounded-md text-xl" />
) : (
<span className="grid h-9 w-9 place-items-center rounded-md bg-border/50 text-muted">?</span>
)}
<span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">{plant?.name ?? 'Unknown plant'}</span>
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={onChangePlant}>
Change
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<TextField
label={`Radius (${u})`}
name="radius"
type="number"
inputMode="decimal"
step="any"
min="0"
value={radius}
onChange={(e) => setRadius(e.target.value)}
onBlur={commitRadius}
/>
<TextField
label="Count"
name="count"
type="number"
inputMode="numeric"
step="1"
min="1"
placeholder={`${derived} (auto)`}
value={count}
onChange={(e) => setCount(e.target.value)}
onBlur={commitCount}
hint={p.count != null ? 'Override — clear for auto' : 'Auto from area ÷ spacing²'}
Outdated
Review

🔴 Cannot clear plantedAt date: blur guard prevents null patch and re-sync snaps value back

correctness, error-handling · flagged by 4 models

  • web/src/editor/PlopInspector.tsx:142Cannot clear the "Planted" date once set. The onBlur handler guards with planted !== '', so when the user clears the field the patch is silently skipped. Worse, the re-sync effect at line 44 snaps the cleared value back to the old date once focus leaves the inspector (because the field is no longer active). The handler should send plantedAt: planted || null and remove the empty-string guard.

🪰 Gadfly · advisory

🔴 **Cannot clear plantedAt date: blur guard prevents null patch and re-sync snaps value back** _correctness, error-handling · flagged by 4 models_ * **`web/src/editor/PlopInspector.tsx:142`** — **Cannot clear the "Planted" date once set.** The `onBlur` handler guards with `planted !== ''`, so when the user clears the field the patch is silently skipped. Worse, the re-sync effect at line 44 snaps the cleared value back to the old date once focus leaves the inspector (because the field is no longer active). The handler should send `plantedAt: planted || null` and remove the empty-string guard. <sub>🪰 Gadfly · advisory</sub>
/>
</div>
<TextField
label="Label (optional)"
name="label"
value={label}
onChange={(e) => setLabel(e.target.value)}
onBlur={() => label !== (p.label ?? '') && patch({ label: label.trim() || null })}
/>
<TextField
label="Planted"
name="planted"
type="date"
value={planted}
onChange={(e) => setPlanted(e.target.value)}
onBlur={commitPlanted}
/>
<Button
variant="ghost"
className="text-red-600 dark:text-red-400"
disabled={remove.isPending}
onClick={() => {
onClose()
remove.mutate({ id: plop.id, version: plop.version })
}}
>
Remove plant
</Button>
</div>
)
}
+93
View File
@@ -0,0 +1,93 @@
import { memo, useMemo } from 'react'
import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
import { PlopMarker, SEMANTIC_FAR } from './PlopMarker'
import { DIMMED_OPACITY, objectTransform } from './shared'
import type { EditorObject } from './types'
/** The most common plant color among an object's plops (for the far-zoom tint). */
function dominantColor(plops: EditorPlanting[], plantsById: Map<number, Plant>): string | null {
const freq = new Map<string, number>()
for (const p of plops) {
const c = plantsById.get(p.plantId)?.color
if (c) freq.set(c, (freq.get(c) ?? 0) + 1)
}
let best: string | null = null
let bestN = 0
for (const [c, n] of freq) {
if (n > bestN) {
best = c
bestN = n
}
}
return best
}
/**
* Renders every active plop, grouped under its parent object's translate+rotate
* transform so plops track the bed when it's moved/rotated. Far zoom adds a
* dominant-plant tint over each planted object so beds read at a glance. Focus
* mode dims plops on non-focused objects.
*/
export const PlopLayer = memo(function PlopLayer({
objects,
plantings,
plantsById,
scale,
focusedObjectId,
selectedPlantingId,
onSelectPlop,
}: {
objects: EditorObject[]
plantings: EditorPlanting[]
plantsById: Map<number, Plant>
scale: number
focusedObjectId: number | null
selectedPlantingId: number | null
onSelectPlop: (id: number) => void
}) {
Review

🟠 PlopLayer recomputes full plop grouping + dominant-color scan every frame during any object drag or zoom, not just plop changes

performance · flagged by 2 models

  • web/src/editor/PlopLayer.tsx:48-53 (byObject grouping) and :64 (dominantColor call) — PlopLayer is wrapped in memo(), but its objects and scale props are not stable across common editor gestures, so the memo rarely pays off: - Any object drag/resize/rotate: SelectionOverlay.tsx:81 calls setLiveObject(onMove(ev)) on every pointermove, and GardenCanvas.tsx:95-98's rendered (passed to PlopLayer as objects, GardenCanvas.tsx:184) is a useMemo keyed on `live…

🪰 Gadfly · advisory

🟠 **PlopLayer recomputes full plop grouping + dominant-color scan every frame during any object drag or zoom, not just plop changes** _performance · flagged by 2 models_ - **`web/src/editor/PlopLayer.tsx:48-53`** (byObject grouping) and **`:64`** (`dominantColor` call) — `PlopLayer` is wrapped in `memo()`, but its `objects` and `scale` props are not stable across common editor gestures, so the memo rarely pays off: - **Any object drag/resize/rotate**: `SelectionOverlay.tsx:81` calls `setLiveObject(onMove(ev))` on every pointermove, and `GardenCanvas.tsx:95-98`'s `rendered` (passed to `PlopLayer` as `objects`, `GardenCanvas.tsx:184`) is a `useMemo` keyed on `live… <sub>🪰 Gadfly · advisory</sub>
// Group plops by object once per plops change (not on every pan/zoom frame).
const byObject = useMemo(() => {
const m = new Map<number, EditorPlanting[]>()
for (const p of plantings) {
const arr = m.get(p.objectId)
if (arr) arr.push(p)
else m.set(p.objectId, [p])
}
return m
}, [plantings])
const far = scale < SEMANTIC_FAR
return (
<>
{objects.map((o) => {
const plops = byObject.get(o.id)
Review

🟠 dominantColor recomputed inline on every scale change

maintainability · flagged by 1 model

  • dominantColor is recomputed inline on every scale change (web/src/editor/PlopLayer.tsx:64). Because scale changes during zoom and PlopLayer re-renders with it, dominantColor iterates over all plops for every object even though its true dependencies are only the plop list and plant map. Moving this derivation into a memoized structure (or precomputing per object) keeps data logic out of the render hot path and makes the component easier to reason about.

🪰 Gadfly · advisory

🟠 **dominantColor recomputed inline on every scale change** _maintainability · flagged by 1 model_ - **`dominantColor` is recomputed inline on every scale change** (`web/src/editor/PlopLayer.tsx:64`). Because `scale` changes during zoom and `PlopLayer` re-renders with it, `dominantColor` iterates over all plops for every object even though its true dependencies are only the plop list and plant map. Moving this derivation into a memoized structure (or precomputing per object) keeps data logic out of the render hot path and makes the component easier to reason about. <sub>🪰 Gadfly · advisory</sub>
if (!plops || plops.length === 0) return null
const dimmed = focusedObjectId != null && focusedObjectId !== o.id
const halfW = o.widthCm / 2
const halfH = o.heightCm / 2
Review

🟡 Object-local transform string duplicated across 5 files; extract a shared helper

maintainability · flagged by 2 models

  • web/src/editor/PlopLayer.tsx:69, web/src/editor/PlopMarker.tsx:49, web/src/editor/GardenCanvas.tsx:18 — Three separate dim-opacity constants (0.4, 0.3, 0.35) for the same visual concept. The PR introduced DIMMED_OPACITY in GardenCanvas but PlopLayer hardcoded 0.4 instead of reusing it (and PlopMarker's 0.3 is dead anyway). Consolidate to one exported constant.

🪰 Gadfly · advisory

🟡 **Object-local transform string duplicated across 5 files; extract a shared helper** _maintainability · flagged by 2 models_ - **`web/src/editor/PlopLayer.tsx:69`, `web/src/editor/PlopMarker.tsx:49`, `web/src/editor/GardenCanvas.tsx:18`** — Three separate dim-opacity constants (`0.4`, `0.3`, `0.35`) for the same visual concept. The PR introduced `DIMMED_OPACITY` in GardenCanvas but PlopLayer hardcoded `0.4` instead of reusing it (and PlopMarker's `0.3` is dead anyway). Consolidate to one exported constant. <sub>🪰 Gadfly · advisory</sub>
const tint = far ? dominantColor(plops, plantsById) : null
return (
<g key={o.id} transform={objectTransform(o)} opacity={dimmed ? DIMMED_OPACITY : 1}>
{tint &&
(o.shape === 'circle' ? (
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill={tint} fillOpacity={0.5} pointerEvents="none" />
) : (
<rect x={-halfW} y={-halfH} width={halfW * 2} height={halfH * 2} fill={tint} fillOpacity={0.5} pointerEvents="none" />
))}
{plops.map((p) => (
<PlopMarker
key={p.id}
plop={p}
plant={plantsById.get(p.plantId)}
scale={scale}
selected={p.id === selectedPlantingId}
Review

🟠 PlopMarker dimmed prop is always false and redundant with parent group opacity

maintainability · flagged by 2 models

  • web/src/editor/PlopLayer.tsx:84dimmed={false} is hardcoded for every PlopMarker, but PlopMarker declares dimmed: boolean as a required prop and branches on it (PlopMarker.tsx:49). Dead parameter: the layer already applies dim opacity at the group level (PlopLayer.tsx:69, opacity={dimmed ? 0.4 : 1}), so the marker-level dimmed can never be true. Grep confirms no caller ever passes true. Either drop the prop from PlopMarker or pass the real per-plop dim state.

🪰 Gadfly · advisory

🟠 **PlopMarker dimmed prop is always false and redundant with parent group opacity** _maintainability · flagged by 2 models_ - **`web/src/editor/PlopLayer.tsx:84`** — `dimmed={false}` is hardcoded for every `PlopMarker`, but `PlopMarker` declares `dimmed: boolean` as a required prop and branches on it (`PlopMarker.tsx:49`). Dead parameter: the layer already applies dim opacity at the group level (`PlopLayer.tsx:69`, `opacity={dimmed ? 0.4 : 1}`), so the marker-level `dimmed` can never be `true`. Grep confirms no caller ever passes `true`. Either drop the prop from `PlopMarker` or pass the real per-plop dim state. <sub>🪰 Gadfly · advisory</sub>
onSelect={onSelectPlop}
/>
))}
</g>
)
})}
</>
)
})
+88
View File
@@ -0,0 +1,88 @@
import { memo, type PointerEvent } from 'react'
import type { Plant } from '@/lib/plants'
import { computeDerivedCount, type EditorPlanting } from '@/lib/plantings'
import { SELECT_COLOR } from './shared'
// Semantic-zoom thresholds in px/cm (DESIGN § Editor / rendering), tuned by feel:
// < FAR — flat color patch, no text ("what's planted where" at a glance)
// FAR..NEAR — colored circle + plant emoji
// ≥ NEAR — circle + emoji + plant name + count
export const SEMANTIC_FAR = 0.75
export const SEMANTIC_NEAR = 3
const DEFAULT_COLOR = '#6aa84f'
/**
* One plop, rendered in its parent object's local frame (the caller wraps it in
* the object's translate+rotate group, so the plop tracks the bed). A circle in
* the plant's color; emoji and name/count fade in with zoom. The count is
* computed live from the current radius so it updates while resizing. memo'd so a
* pan/zoom that only changes the world transform doesn't re-render every plop.
*/
export const PlopMarker = memo(function PlopMarker({
plop,
plant,
scale,
selected,
onSelect,
Review

🟡 Dead dimmed prop in PlopMarker

maintainability · flagged by 1 model

  • PlopMarker carries a dead dimmed prop (web/src/editor/PlopMarker.tsx:27). The only caller (PlopLayer) always passes dimmed={false} and handles object-level dimming via the parent <g> opacity. The prop is redundant boilerplate that should be removed to avoid misleading future callers.

🪰 Gadfly · advisory

🟡 **Dead dimmed prop in PlopMarker** _maintainability · flagged by 1 model_ - **`PlopMarker` carries a dead `dimmed` prop** (`web/src/editor/PlopMarker.tsx:27`). The only caller (`PlopLayer`) always passes `dimmed={false}` and handles object-level dimming via the parent `<g>` opacity. The prop is redundant boilerplate that should be removed to avoid misleading future callers. <sub>🪰 Gadfly · advisory</sub>
}: {
plop: EditorPlanting
plant?: Plant
scale: number
selected: boolean
onSelect: (id: number) => void
}) {
const color = plant?.color ?? DEFAULT_COLOR
const r = Math.max(0, plop.radiusCm)
const showIcon = scale >= SEMANTIC_FAR && !!plant?.icon
const showText = scale >= SEMANTIC_NEAR && !!plant
const count = plop.count ?? (plant ? computeDerivedCount(plop.radiusCm, plant.spacingCm) : plop.derivedCount)
function down(e: PointerEvent) {
e.stopPropagation()
onSelect(plop.id)
}
return (
<g transform={`translate(${plop.xCm} ${plop.yCm})`} onPointerDown={down} style={{ cursor: 'pointer' }}>
<circle
cx={0}
cy={0}
r={r}
fill={color}
fillOpacity={0.82}
stroke={selected ? SELECT_COLOR : '#00000033'}
strokeWidth={selected ? 2.5 : 1}
vectorEffect="non-scaling-stroke"
/>
{showIcon && (
<text
x={0}
y={0}
fontSize={r * 1.2}
textAnchor="middle"
dominantBaseline="central"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{plant!.icon}
</text>
)}
{showText && (
<text
x={0}
y={r + r * 0.3}
fontSize={Math.max(r * 0.5, 6)}
textAnchor="middle"
dominantBaseline="hanging"
fill="#1f2937"
stroke="#ffffff"
strokeWidth={0.5}
paintOrder="stroke"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{plant!.name} · {count}
</text>
)}
</g>
)
})
Review

🟠 On-canvas plop label shows stale derivedCount during live resize instead of recomputing from live radius

correctness · flagged by 2 models

  • web/src/editor/PlopMarker.tsx:88 — The near-zoom label always calls effectiveCount(plop) (plop.count ?? plop.derivedCount, web/src/lib/plantings.ts:59-60), the last server-computed value. During a live resize drag, PlopOverlay.startResize (web/src/editor/PlopOverlay.tsx:115-126) only updates radiusCm on the live plop ({...base, radiusCm: r}), leaving derivedCount/count untouched, and GardenCanvas's renderedPlops merges that live plop into what PlopLayer/`PlopMarke…

🪰 Gadfly · advisory

🟠 **On-canvas plop label shows stale derivedCount during live resize instead of recomputing from live radius** _correctness · flagged by 2 models_ - **`web/src/editor/PlopMarker.tsx:88`** — The near-zoom label always calls `effectiveCount(plop)` (`plop.count ?? plop.derivedCount`, `web/src/lib/plantings.ts:59-60`), the last server-computed value. During a live resize drag, `PlopOverlay.startResize` (`web/src/editor/PlopOverlay.tsx:115-126`) only updates `radiusCm` on the live plop (`{...base, radiusCm: r}`), leaving `derivedCount`/`count` untouched, and `GardenCanvas`'s `renderedPlops` merges that live plop into what `PlopLayer`/`PlopMarke… <sub>🪰 Gadfly · advisory</sub>
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
import { screenToWorld, worldToLocal, type Point } from '@/lib/geometry'
import { useUpdatePlanting } from '@/lib/objects'
import type { EditorPlanting } from '@/lib/plantings'
import { HANDLE_PX, MIN_RADIUS_CM, SELECT_COLOR, objectTransform } from './shared'
import { useEditorStore } from './store'
import type { EditorObject } from './types'
const MAX_RADIUS_CM = 10_000
/**
Review

🟡 SELECT_COLOR hex constant duplicated across SelectionOverlay/PlopMarker/PlopOverlay instead of shared

maintainability · flagged by 3 models

  • web/src/editor/PlopOverlay.tsx:11 / web/src/editor/PlopMarker.tsx:12const SELECT_COLOR = '#2f7a3e' is now defined identically in three places: the pre-existing SelectionOverlay.tsx:11, and the two new plop files added by this PR. A future "change the selection color" edit will silently miss two of three spots. Suggest hoisting to a small shared constants module (e.g. editor/colors.ts) or exporting it from SelectionOverlay.tsx. - **web/src/editor/PlopInspector.tsx:10 and `w…

🪰 Gadfly · advisory

🟡 **SELECT_COLOR hex constant duplicated across SelectionOverlay/PlopMarker/PlopOverlay instead of shared** _maintainability · flagged by 3 models_ - **`web/src/editor/PlopOverlay.tsx:11` / `web/src/editor/PlopMarker.tsx:12`** — `const SELECT_COLOR = '#2f7a3e'` is now defined identically in three places: the pre-existing `SelectionOverlay.tsx:11`, and the two new plop files added by this PR. A future "change the selection color" edit will silently miss two of three spots. Suggest hoisting to a small shared constants module (e.g. `editor/colors.ts`) or exporting it from `SelectionOverlay.tsx`. - **`web/src/editor/PlopInspector.tsx:10` and `w… <sub>🪰 Gadfly · advisory</sub>
* Move/resize handles for the selected plop, rendered inside its parent object's
* translate+rotate group so the plop stays in the object's local frame. Drag the
* body to move (clamped to the object's bounds); drag the edge handle to resize
* the radius. Each gesture updates livePlanting for instant feedback and fires
* one PATCH on release — the same contract as SelectionOverlay (#11).
*/
export function PlopOverlay({
plop,
object,
gardenId,
svgRef,
}: {
plop: EditorPlanting
object: EditorObject
gardenId: number
svgRef: RefObject<SVGSVGElement | null>
}) {
const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
const setObjectDragging = useEditorStore((s) => s.setObjectDragging)
const scale = useEditorStore((s) => s.viewport.scale)
const update = useUpdatePlanting(gardenId)
const cleanupRef = useRef<(() => void) | null>(null)
useEffect(
() => () => {
Review

🟠 Gesture scaffolding duplicated from SelectionOverlay

maintainability · flagged by 2 models

  • web/src/editor/PlopOverlay.tsx:36-97 vs web/src/editor/SelectionOverlay.tsx:44-104 — ~60 lines of near-identical gesture scaffolding (cleanupRef + useEffect cleanup + begin() + makePointerLocal/makePointerWorld + detach/finish). The PR description itself notes "the same contract as SelectionOverlay (#11)". This is a clear shared-hook candidate (useDragGesture). Not blocking for this PR, but worth a follow-up — every new overlay will copy this again.

🪰 Gadfly · advisory

🟠 **Gesture scaffolding duplicated from SelectionOverlay** _maintainability · flagged by 2 models_ - **`web/src/editor/PlopOverlay.tsx:36-97`** vs **`web/src/editor/SelectionOverlay.tsx:44-104`** — ~60 lines of near-identical gesture scaffolding (`cleanupRef` + `useEffect` cleanup + `begin()` + `makePointerLocal`/`makePointerWorld` + `detach`/`finish`). The PR description itself notes "the same contract as SelectionOverlay (#11)". This is a clear shared-hook candidate (`useDragGesture`). Not blocking for this PR, but worth a follow-up — every new overlay will copy this again. <sub>🪰 Gadfly · advisory</sub>
cleanupRef.current?.()
cleanupRef.current = null
},
[],
)
const handleCm = HANDLE_PX / scale
const halfW = object.widthCm / 2
const halfH = object.heightCm / 2
Review

🟠 Plop move/placement clamps to the object's rectangular bounds even for circle-shaped objects (grow_bag/container), letting plops sit outside the visible circular boundary

correctness · flagged by 1 model

🪰 Gadfly · advisory

🟠 **Plop move/placement clamps to the object's rectangular bounds even for circle-shaped objects (grow_bag/container), letting plops sit outside the visible circular boundary** _correctness · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
const center: Point = { x: object.xCm, y: object.yCm }
const rot = object.rotationDeg
// Pointer (client) → the object's local frame, snapshotting the svg rect once
// per gesture (the object doesn't move during a plop drag).
const makePointerLocal = () => {
const rect = svgRef.current?.getBoundingClientRect()
return (e: { clientX: number; clientY: number }): Point => {
const vp = useEditorStore.getState().viewport
const canvas = rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : { x: e.clientX, y: e.clientY }
return worldToLocal(screenToWorld(canvas, vp), center, rot)
}
}
const clampLocal = (p: Point): Point => ({
x: Math.max(-halfW, Math.min(halfW, p.x)),
y: Math.max(-halfH, Math.min(halfH, p.y)),
})
const begin = (
e: ReactPointerEvent,
onMove: (e: PointerEvent) => EditorPlanting,
fields: (final: EditorPlanting) => Parameters<typeof update.mutate>[0],
) => {
e.stopPropagation()
e.preventDefault()
setObjectDragging(true)
const move = (ev: PointerEvent) => setLivePlanting(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().livePlanting
setObjectDragging(false)
setLivePlanting(null)
if (final) update.mutate(fields(final))
}
cleanupRef.current = () => {
detach()
setObjectDragging(false)
setLivePlanting(null)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', finish)
window.addEventListener('pointercancel', finish)
}
const base = { ...plop }
const startMove = (e: ReactPointerEvent) => {
const ptr = makePointerLocal()
const start = ptr(e.nativeEvent)
begin(
e,
(ev) => {
const p = ptr(ev)
const next = clampLocal({ x: base.xCm + (p.x - start.x), y: base.yCm + (p.y - start.y) })
return { ...base, xCm: next.x, yCm: next.y }
},
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
)
}
const startResize = (e: ReactPointerEvent) => {
const ptr = makePointerLocal()
begin(
e,
(ev) => {
const p = ptr(ev)
const r = Math.max(MIN_RADIUS_CM, Math.min(MAX_RADIUS_CM, Math.hypot(p.x - base.xCm, p.y - base.yCm)))
return { ...base, radiusCm: r }
},
Review

🔴 livePlanting resize omits derivedCount update, so near-zoom marker count stays stale during drag

correctness, error-handling · flagged by 2 models

  • web/src/editor/PlopOverlay.tsx:121startResize sets the optimistic livePlanting to { ...base, radiusCm: r } but leaves derivedCount unchanged. Because PlopMarker (near-zoom text) reads effectiveCount(plop) which uses the stale derivedCount, the count label stays at its old value for the entire drag and only updates after the server PATCH returns. The acceptance criterion says “Resizing a plop updates its displayed derived count live”; the inspector panel does recompute…

🪰 Gadfly · advisory

🔴 **livePlanting resize omits derivedCount update, so near-zoom marker count stays stale during drag** _correctness, error-handling · flagged by 2 models_ - **`web/src/editor/PlopOverlay.tsx:121`** — `startResize` sets the optimistic `livePlanting` to `{ ...base, radiusCm: r }` but leaves `derivedCount` unchanged. Because `PlopMarker` (near-zoom text) reads `effectiveCount(plop)` which uses the stale `derivedCount`, the count label stays at its old value for the entire drag and only updates after the server PATCH returns. The acceptance criterion says *“Resizing a plop updates its displayed derived count live”*; the inspector panel does recompute… <sub>🪰 Gadfly · advisory</sub>
(f) => ({ id: base.id, version: base.version, radiusCm: f.radiusCm }),
)
}
const r = plop.radiusCm
return (
<g transform={objectTransform(object)}>
{/* Transparent body: drag to move (at least handle-sized so tiny plops stay grabbable). */}
<circle
cx={plop.xCm}
cy={plop.yCm}
r={Math.max(r, handleCm)}
fill="transparent"
pointerEvents="all"
style={{ cursor: 'move' }}
onPointerDown={startMove}
/>
{/* Selection ring. */}
<circle
cx={plop.xCm}
cy={plop.yCm}
r={r}
fill="none"
stroke={SELECT_COLOR}
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
pointerEvents="none"
/>
{/* Radius handle at the local +x edge. */}
<circle
cx={plop.xCm + r}
cy={plop.yCm}
r={handleCm * 0.6}
fill="#ffffff"
stroke={SELECT_COLOR}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
style={{ cursor: 'ew-resize' }}
onPointerDown={startResize}
/>
</g>
)
}
+2 -3
View File
@@ -1,14 +1,13 @@
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 HANDLE_PX = 12 // on-screen size of the resize handles
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 SELECT_COLOR = '#2f7a3e'
const corners: [number, number][] = [
[-1, -1],
@@ -162,7 +161,7 @@ export function SelectionOverlay({
}
return (
<g transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`}>
<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} />
+14
View File
@@ -0,0 +1,14 @@
// Shared editor UI constants + tiny helpers used across the object/plop markers
// and overlays, so colors, handle sizes, and the object-local transform stay in
// one place instead of drifting between files.
export const SELECT_COLOR = '#2f7a3e' // selection stroke/handles
export const HANDLE_PX = 12 // on-screen size of a drag/resize handle
export const MIN_RADIUS_CM = 1 // smallest plop radius
export const DIMMED_OPACITY = 0.4 // non-focused objects/plops in focus mode
/** SVG transform placing content in an object's local frame: its center point
* then its clockwise rotation. Plops and overlays render inside this. */
export function objectTransform(o: { xCm: number; yCm: number; rotationDeg: number }): string {
return `translate(${o.xCm} ${o.yCm}) rotate(${o.rotationDeg})`
}
+26 -1
View File
@@ -1,5 +1,7 @@
import { create } from 'zustand'
import type { Viewport } from '@/lib/geometry'
import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
import type { EditorObject } from './types'
// Ephemeral editor state only (per DESIGN § State): the viewport, the current
@@ -13,17 +15,31 @@ interface EditorState {
viewport: Viewport
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
// The selected object OR plop (mutually exclusive; selecting one clears the
// other). selectedId is a garden object; selectedPlantingId is a plop.
selectedId: number | null
select: (id: number | null) => void
selectedPlantingId: number | null
selectPlanting: (id: number | null) => void
focusedObjectId: number | null
setFocusedObject: (id: number | null) => void
// The plant armed for placing plops (set after the PlantPicker choice); stays
// armed for repeat-placement until cleared (Escape / done). null = not placing.
armedPlant: Plant | null
setArmedPlant: (p: Plant | null) => void
// During a move/resize/rotate, the object's live geometry is held here so the
// canvas renders it instantly; the PATCH fires only on gesture end.
liveObject: EditorObject | null
setLiveObject: (o: EditorObject | null) => void
// The same, for a plop being moved/resized.
livePlanting: EditorPlanting | null
setLivePlanting: (p: EditorPlanting | null) => void
// The palette kind armed for tap-to-place (mobile-friendly; also set while
// dragging a kind from the palette). null when nothing is armed.
armedKind: string | null
@@ -40,14 +56,23 @@ export const useEditorStore = create<EditorState>((set) => ({
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
selectedId: null,
select: (id) => set({ selectedId: id }),
select: (id) => set({ selectedId: id, selectedPlantingId: null }),
selectedPlantingId: null,
selectPlanting: (id) => set({ selectedPlantingId: id, selectedId: null }),
focusedObjectId: null,
setFocusedObject: (id) => set({ focusedObjectId: id }),
armedPlant: null,
setArmedPlant: (p) => set({ armedPlant: p }),
liveObject: null,
setLiveObject: (o) => set({ liveObject: o }),
livePlanting: null,
setLivePlanting: (p) => set({ livePlanting: p }),
armedKind: null,
setArmedKind: (kind) => set({ armedKind: kind }),
+146 -2
View File
@@ -7,6 +7,8 @@ import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient }
import { z } from 'zod'
import { ApiError, api } from './api'
import { gardenSchema } from './gardens'
import { plantSchema } from './plants'
import { serverPlantingSchema, type ServerPlanting } from './plantings'
import { toast } from '@/components/ui/toast'
import type { EditorObject, ObjectShapeKind } from '@/editor/types'
@@ -35,8 +37,8 @@ export type ServerObject = z.infer<typeof serverObjectSchema>
export const fullGardenSchema = z.object({
garden: gardenSchema,
objects: z.array(serverObjectSchema),
plantings: z.array(z.unknown()), // typed in #14
plants: z.array(z.unknown()), // typed in #12
plantings: z.array(serverPlantingSchema),
plants: z.array(plantSchema),
})
export type FullGarden = z.infer<typeof fullGardenSchema>
@@ -181,12 +183,154 @@ export function useDeleteObject(gardenId: number) {
})
}
// --- planting (plop) mutations ---------------------------------------------
// Plops are part of the FullGarden cache, so these patch the same query as the
// object mutations, with the same optimistic + 409-rollback contract.
export interface PlantingCreate {
objectId: number
plantId: number
xCm: number
yCm: number
radiusCm: number
count?: number | null
label?: string | null
}
export function useCreatePlanting(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ objectId, ...body }: PlantingCreate): Promise<ServerPlanting> =>
serverPlantingSchema.parse(await api.post(`/objects/${objectId}/plantings`, body)),
onSuccess: (created) => {
patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: [...full.plantings, created] }))
},
onError: (err) => toast.error(objectErrorMessage(err, 'Could not place that plant.')),
})
}
/** Fields the plop editor patches. version is required for the optimistic guard. */
export interface PlantingPatch {
id: number
version: number
plantId?: number
xCm?: number
yCm?: number
radiusCm?: number
count?: number | null
label?: string | null
plantedAt?: string | null
removedAt?: string | null
Review

🟠 PlantingPatch.removedAt ignored by applyPlantingPatch causing optimistic cache inconsistency

maintainability · flagged by 1 model

🪰 Gadfly · advisory

🟠 **PlantingPatch.removedAt ignored by applyPlantingPatch causing optimistic cache inconsistency** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
}
export function useUpdatePlanting(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...body }: PlantingPatch): Promise<ServerPlanting> =>
serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, body)),
onMutate: async (patch) => {
await qc.cancelQueries({ queryKey: fullKey(gardenId) })
const prev = qc.getQueryData<FullGarden>(fullKey(gardenId))
patchFullCache(qc, gardenId, (full) => ({
...full,
plantings: full.plantings.map((p) => (p.id === patch.id ? applyPlantingPatch(p, patch) : p)),
}))
return { prev }
},
onSuccess: (updated) => {
patchFullCache(qc, gardenId, (full) => ({
...full,
// A soft-remove (removedAt set) drops the plop from the active list;
// otherwise write the authoritative row (carries the fresh derivedCount).
plantings: updated.removedAt
? full.plantings.filter((p) => p.id !== updated.id)
: full.plantings.map((p) => (p.id === updated.id ? updated : p)),
}))
},
onError: (err, _patch, ctx) => {
const current = conflictPlanting(err)
if (current) {
patchFullCache(qc, gardenId, (full) => ({
...full,
plantings: full.plantings.map((p) => (p.id === current.id ? current : p)),
}))
toast.error('That plant was updated elsewhere — your change was rolled back.')
} else if (ctx?.prev) {
qc.setQueryData(fullKey(gardenId), ctx.prev)
toast.error(objectErrorMessage(err, 'Could not save that change.'))
}
},
})
}
/** Soft-remove ("Remove"): PATCH removed_at to today; the row is kept but leaves
* the active list. Distinct from a hard delete. */
export function useRemovePlanting(gardenId: number) {
Review

🟠 useRemovePlanting has no 409-conflict adoption and no onSettled refetch, so a version-conflict on soft-remove leaves the plop with a stale version and the user stuck until reload

error-handling · flagged by 1 model

  • web/src/lib/objects.ts:268-286 (useRemovePlanting) — A 409 version-conflict on soft-remove leaves the user stuck. onError (line 281) only restores ctx.prev (the pre-removal snapshot, which carries the stale version) and toasts a generic "Could not remove that plant." It neither adopts the 409's current row (the way useUpdatePlanting does at line 250-257 via conflictPlanting) nor refetches (the way useDeleteObject does at line 182 via onSettled). So after a failed remove…

🪰 Gadfly · advisory

🟠 **useRemovePlanting has no 409-conflict adoption and no onSettled refetch, so a version-conflict on soft-remove leaves the plop with a stale version and the user stuck until reload** _error-handling · flagged by 1 model_ - **`web/src/lib/objects.ts:268-286`** (`useRemovePlanting`) — A 409 version-conflict on soft-remove leaves the user stuck. `onError` (line 281) only restores `ctx.prev` (the pre-removal snapshot, which carries the *stale* version) and toasts a generic "Could not remove that plant." It neither adopts the 409's `current` row (the way `useUpdatePlanting` does at line 250-257 via `conflictPlanting`) nor refetches (the way `useDeleteObject` does at line 182 via `onSettled`). So after a failed remove… <sub>🪰 Gadfly · advisory</sub>
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ id, version }: { id: number; version: number }): Promise<ServerPlanting> => {
const today = new Date().toISOString().slice(0, 10)
return serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, { removedAt: today, version }))
},
onMutate: async ({ id }) => {
await qc.cancelQueries({ queryKey: fullKey(gardenId) })
const prev = qc.getQueryData<FullGarden>(fullKey(gardenId))
patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: full.plantings.filter((p) => p.id !== id) }))
return { prev }
},
onError: (err, _vars, ctx) => {
Review

🟠 useRemovePlanting onError doesn't adopt the 409 conflict's current row, so a stale-version remove loops on 409 and the plop can't be removed until an unrelated refetch

error-handling · flagged by 1 model

  • useRemovePlanting doesn't reconcile a 409 conflict, leaving the user stuck. web/src/lib/objects.ts:281-284: the onError only restores ctx.prev and shows a generic toast. useUpdatePlanting (lines 250-257) instead calls conflictPlanting(err) and, on a conflict, writes the server's current row into the cache so the next edit carries a fresh version. useRemovePlanting lacks that branch: on a stale-version 409 it rolls the plop back into the cache with the original (now stale)…

🪰 Gadfly · advisory

🟠 **useRemovePlanting onError doesn't adopt the 409 conflict's current row, so a stale-version remove loops on 409 and the plop can't be removed until an unrelated refetch** _error-handling · flagged by 1 model_ - **`useRemovePlanting` doesn't reconcile a 409 conflict, leaving the user stuck.** `web/src/lib/objects.ts:281-284`: the `onError` only restores `ctx.prev` and shows a generic toast. `useUpdatePlanting` (lines 250-257) instead calls `conflictPlanting(err)` and, on a conflict, writes the server's `current` row into the cache so the next edit carries a fresh `version`. `useRemovePlanting` lacks that branch: on a stale-version 409 it rolls the plop back into the cache with the original (now stale)… <sub>🪰 Gadfly · advisory</sub>
if (ctx?.prev) qc.setQueryData(fullKey(gardenId), ctx.prev)
// On a 409, adopt the server's current row so the plop reappears with a
// fresh version — otherwise a retry keeps 409-ing on the stale version.
const current = conflictPlanting(err)
if (current) {
patchFullCache(qc, gardenId, (full) => ({
...full,
plantings: full.plantings.map((p) => (p.id === current.id ? current : p)),
}))
toast.error('That plant was updated elsewhere — try removing it again.')
} else {
toast.error(objectErrorMessage(err, 'Could not remove that plant.'))
}
},
})
}
// --- helpers ---------------------------------------------------------------
Outdated
Review

🟠 applyPlantingPatch omits removedAt, causing latent optimistic-update mismatch

correctness, error-handling, maintainability · flagged by 4 models

  • web/src/lib/objects.ts:299applyPlantingPatch silently ignores removedAt despite it being in PlantingPatch. The optimistic-update function copies every other patch field but omits removedAt. Today this is latent because soft-removes use useRemovePlanting, but if anyone later calls useUpdatePlanting with removedAt, the plop will stay visible during the request and only disappear on onSuccess, producing a jarring flash.

🪰 Gadfly · advisory

🟠 **applyPlantingPatch omits removedAt, causing latent optimistic-update mismatch** _correctness, error-handling, maintainability · flagged by 4 models_ * **`web/src/lib/objects.ts:299`** — **`applyPlantingPatch` silently ignores `removedAt` despite it being in `PlantingPatch`.** The optimistic-update function copies every other patch field but omits `removedAt`. Today this is latent because soft-removes use `useRemovePlanting`, but if anyone later calls `useUpdatePlanting` with `removedAt`, the plop will stay visible during the request and only disappear on `onSuccess`, producing a jarring flash. <sub>🪰 Gadfly · advisory</sub>
function patchFullCache(qc: QueryClient, gardenId: number, fn: (full: FullGarden) => FullGarden) {
qc.setQueryData<FullGarden>(fullKey(gardenId), (full) => (full ? fn(full) : full))
}
/** Apply a plop patch onto a cached row for the optimistic pass. version is
* bumped to mirror the server's post-commit increment (as applyServerPatch does
* for objects), so a fast follow-up edit doesn't self-conflict. derivedCount is
* left as-is here; the editor recomputes it live from the plant's spacing and
* onSuccess writes the authoritative value. */
function applyPlantingPatch(p: ServerPlanting, patch: PlantingPatch): ServerPlanting {
return {
...p,
version: p.version + 1,
...(patch.plantId !== undefined ? { plantId: patch.plantId } : {}),
...(patch.xCm !== undefined ? { xCm: patch.xCm } : {}),
...(patch.yCm !== undefined ? { yCm: patch.yCm } : {}),
...(patch.radiusCm !== undefined ? { radiusCm: patch.radiusCm } : {}),
...(patch.count !== undefined ? { count: patch.count } : {}),
...(patch.label !== undefined ? { label: patch.label } : {}),
...(patch.plantedAt !== undefined ? { plantedAt: patch.plantedAt } : {}),
...(patch.removedAt !== undefined ? { removedAt: patch.removedAt } : {}),
}
}
/** The current server plop from a 409 conflict body, or null. */
function conflictPlanting(err: unknown): ServerPlanting | null {
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
const parsed = serverPlantingSchema.safeParse((err.body as { current?: unknown }).current)
if (parsed.success) return parsed.data
}
return null
}
/** 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
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { computeDerivedCount, effectiveCount } from './plantings'
describe('computeDerivedCount', () => {
it('mirrors the server formula max(1, round(π·r²/spacing²))', () => {
expect(computeDerivedCount(10, 10)).toBe(3) // π·100/100 = 3.14 → 3
expect(computeDerivedCount(50, 10)).toBe(79) // π·2500/100 = 78.5 → 79
expect(computeDerivedCount(30, 15)).toBe(13) // π·900/225 = 12.57 → 13
})
it('floors at 1 for tiny / non-positive inputs', () => {
expect(computeDerivedCount(0.1, 10)).toBe(1)
expect(computeDerivedCount(0, 10)).toBe(1)
expect(computeDerivedCount(10, 0)).toBe(1)
})
it('caps like the server', () => {
expect(computeDerivedCount(10_000, 0.1)).toBe(1_000_000)
})
})
describe('effectiveCount', () => {
it('prefers the override, else the derived value', () => {
expect(effectiveCount({ count: 12, derivedCount: 79 })).toBe(12)
expect(effectiveCount({ count: null, derivedCount: 79 })).toBe(79)
})
})
+70
View File
@@ -0,0 +1,70 @@
// Plantings ("plops") data layer: the zod shape for a /full planting plus the
// EditorPlanting the canvas renders. Optimistic create/update/remove mutations
// live in objects.ts alongside the FullGarden cache they patch (a plop is part
// of the one-shot editor payload).
import { z } from 'zod'
export const serverPlantingSchema = z.object({
id: z.number(),
objectId: z.number(),
plantId: z.number(),
xCm: z.number(),
yCm: z.number(),
radiusCm: z.number(),
count: z.number().nullable().optional(), // null/absent = derived
label: z.string().nullable().optional(),
plantedAt: z.string().nullable().optional(),
removedAt: z.string().nullable().optional(),
derivedCount: z.number(), // computed server-side from area / spacing²
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type ServerPlanting = z.infer<typeof serverPlantingSchema>
/** The plop shape the canvas renders. x/y/radius are in the parent object's
* local frame (origin at object center), so a plop tracks its object. */
export interface EditorPlanting {
id: number
objectId: number
plantId: number
xCm: number
yCm: number
radiusCm: number
count: number | null
derivedCount: number
label: string | null
plantedAt: string | null
version: number
}
export function toEditorPlanting(p: ServerPlanting): EditorPlanting {
return {
id: p.id,
objectId: p.objectId,
plantId: p.plantId,
xCm: p.xCm,
yCm: p.yCm,
radiusCm: p.radiusCm,
count: p.count ?? null,
derivedCount: p.derivedCount,
label: p.label ?? null,
plantedAt: p.plantedAt ?? null,
version: p.version,
}
}
/** The count a plop shows: its explicit override, else the derived value. */
export function effectiveCount(p: { count: number | null; derivedCount: number }): number {
return p.count ?? p.derivedCount
}
/** Client-side mirror of the server's derived-count formula, for live display
* while resizing a plop (before the PATCH round-trips). max(1, round(π·r² /
* spacing²)), capped like the server. */
export function computeDerivedCount(radiusCm: number, spacingCm: number): number {
if (radiusCm <= 0 || spacingCm <= 0) return 1
const n = Math.round((Math.PI * radiusCm * radiusCm) / (spacingCm * spacingCm))
return Math.max(1, Math.min(1_000_000, n))
}
+147 -15
View File
@@ -1,12 +1,17 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { getRouteApi } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { GardenCanvas } from '@/editor/GardenCanvas'
import { Inspector } from '@/editor/Inspector'
import { PlopInspector } from '@/editor/PlopInspector'
import { PlantPicker } from '@/editor/PlantPicker'
import { Palette } from '@/editor/Palette'
import { kindDef } from '@/editor/kinds'
import { useEditorStore } from '@/editor/store'
import type { EditorGarden } from '@/editor/types'
import { toEditorObject, useGardenFull } from '@/lib/objects'
import { toEditorObject, useGardenFull, useUpdatePlanting } from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings'
const routeApi = getRouteApi('/gardens/$gardenId')
@@ -14,29 +19,90 @@ export function GardenEditorPage() {
const { gardenId } = routeApi.useParams()
const gid = Number(gardenId)
const { focus } = routeApi.useSearch()
const navigate = routeApi.useNavigate()
const full = useGardenFull(gid)
const selectedId = useEditorStore((s) => s.selectedId)
const select = useEditorStore((s) => s.select)
const selectedPlantingId = useEditorStore((s) => s.selectedPlantingId)
const selectPlanting = useEditorStore((s) => s.selectPlanting)
const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
const armedPlant = useEditorStore((s) => s.armedPlant)
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
const setArmedKind = useEditorStore((s) => s.setArmedKind)
const setLiveObject = useEditorStore((s) => s.setLiveObject)
const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
const updatePlanting = useUpdatePlanting(gid)
// Which plant-picker flow is open: 'place' arms a plant for repeat placement;
// 'change' swaps the selected plop's plant.
const [picker, setPicker] = useState<'place' | 'change' | null>(null)
// 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])
const serverPlantings = full.data?.plantings
const plantings = useMemo(() => serverPlantings?.map(toEditorPlanting) ?? [], [serverPlantings])
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
// Clear transient editor state on entering/switching gardens and on leaving.
// Adopt the URL's focus and clear transient editor state on entering/switching
// gardens (and on leaving). `focus` is intentionally read once here, not a dep,
// so URL syncs below don't retrigger a full reset.
useEffect(() => {
const reset = () => {
const clear = () => {
select(null)
selectPlanting(null)
setArmedKind(null)
setArmedPlant(null)
setLiveObject(null)
setLivePlanting(null)
}
reset()
return reset
}, [gid, select, setArmedKind, setLiveObject])
clear()
setFocusedObject(focus ?? null)
return () => {
Review

🟠 Stale ?focus IDs not validated after load: canvas stays dimmed with no visible exit UI

error-handling · flagged by 1 model

  • web/src/pages/GardenEditorPage.tsx:64 / web/src/editor/GardenCanvas.tsx:178Stale ?focus IDs leave the canvas permanently dimmed with no visible exit affordance. setFocusedObject(focus ?? null) is adopted from the URL without validating that the object exists after the garden loads. GardenCanvas and PlopLayer dim every object when focusedObjectId != null, but GardenEditorPage hides the breadcrumb (← garden) when the object is missing. On mobile there is no Esc…

🪰 Gadfly · advisory

🟠 **Stale ?focus IDs not validated after load: canvas stays dimmed with no visible exit UI** _error-handling · flagged by 1 model_ * **`web/src/pages/GardenEditorPage.tsx:64`** / **`web/src/editor/GardenCanvas.tsx:178`** — **Stale `?focus` IDs leave the canvas permanently dimmed with no visible exit affordance.** `setFocusedObject(focus ?? null)` is adopted from the URL without validating that the object exists after the garden loads. `GardenCanvas` and `PlopLayer` dim every object when `focusedObjectId != null`, but `GardenEditorPage` hides the breadcrumb (`← garden`) when the object is missing. On mobile there is no Esc… <sub>🪰 Gadfly · advisory</sub>
clear()
setFocusedObject(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gid])
// Mirror focusedObjectId → ?focus so focus mode is deep-linkable and survives
// reload. Declared after the reset effect so the initial adopt wins.
useEffect(() => {
navigate({ search: (prev) => ({ ...prev, focus: focusedObjectId ?? undefined }), replace: true })
}, [focusedObjectId, navigate])
// If the focused object vanished — deleted, or a stale ?focus id on load — leave
// focus mode so the canvas isn't stuck dimmed with no way out.
useEffect(() => {
if (focusedObjectId != null && full.data && !objects.some((o) => o.id === focusedObjectId)) {
setFocusedObject(null)
}
}, [focusedObjectId, objects, full.data, setFocusedObject])
const exitFocus = () => {
setFocusedObject(null)
setArmedPlant(null)
select(null)
selectPlanting(null)
}
// Escape peels back one layer: stop placing → deselect plop → exit focus → deselect.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key !== 'Escape') return
const s = useEditorStore.getState()
if (s.armedPlant) s.setArmedPlant(null)
else if (s.selectedPlantingId != null) s.selectPlanting(null)
else if (s.focusedObjectId != null) exitFocus()
else if (s.selectedId != null) s.select(null)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (full.isPending) return <p className="p-6 text-sm text-muted">Loading garden</p>
if (full.isError)
@@ -54,7 +120,22 @@ export function GardenEditorPage() {
heightCm: g.heightCm,
unitPref: g.unitPref,
}
const selected = objects.find((o) => o.id === selectedId) ?? null
const selectedObject = objects.find((o) => o.id === selectedId) ?? null
const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null
// Committed selected plop; PlopInspector applies live drag geometry itself.
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
function onPickPlant(plantId: number) {
const plant = plantsById.get(plantId)
if (!plant) return
if (picker === 'change' && selectedPlop) {
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
} else {
setArmedPlant(plant) // 'place' mode: arm for repeat placement
}
setPicker(null)
}
return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
@@ -62,18 +143,69 @@ export function GardenEditorPage() {
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
{garden.name}
</h1>
<Palette />
{focusedObjectId == null && <Palette />}
</div>
<div className="relative min-h-0 flex-1">
<GardenCanvas garden={garden} objects={objects} focusId={focus} />
{focusedObject && (
<div className="absolute left-2 top-2 z-20 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur">
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
{garden.name}
</button>
<span className="max-w-[8rem] truncate text-muted">
{focusedObject.name || kindDef(focusedObject.kind)?.label || 'Object'}
</span>
{!focusedObject.plantable ? (
<span className="text-xs text-muted">Not plantable</span>
) : armedPlant ? (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
Placing {armedPlant.icon} Done
</Button>
) : (
<Button className="px-2 py-1 text-xs" onClick={() => setPicker('place')}>
+ Add plant
</Button>
)}
</div>
)}
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} />
</div>
{selected && (
{(selectedObject || selectedPlop) && (
<div className="fixed inset-x-0 bottom-0 z-30 max-h-[65vh] overflow-y-auto rounded-t-xl border-t border-border bg-surface p-4 shadow-lg md:static md:max-h-none md:w-72 md:shrink-0 md:rounded-xl md:border md:p-4 md:shadow-sm">
<Inspector key={selected.id} object={selected} gardenId={gid} unit={garden.unitPref} />
{selectedObject && (
<Inspector
key={`obj-${selectedObject.id}`}
object={selectedObject}
gardenId={gid}
unit={garden.unitPref}
onFocus={() => {
setFocusedObject(selectedObject.id)
select(null)
}}
/>
)}
{selectedPlop && (
<PlopInspector
key={`plop-${selectedPlop.id}`}
plop={selectedPlop}
plant={plantsById.get(selectedPlop.plantId)}
gardenId={gid}
unit={garden.unitPref}
onChangePlant={() => setPicker('change')}
onClose={() => selectPlanting(null)}
/>
)}
</div>
)}
{picker && (
<PlantPicker
unit={garden.unitPref}
onClose={() => setPicker(null)}
onSelect={(p) => onPickPlant(p.id)}
/>
)}
</div>
)
}