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
10 changed files with 115 additions and 76 deletions
Showing only changes of commit 0f67623da7 - Show all commits
+6 -8
View File
@@ -8,6 +8,7 @@ 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'
@@ -15,7 +16,6 @@ import type { EditorGarden, EditorObject } from './types'
const GRID_CM = 100 // 1 m grid
const GRID_MIN_CELL_PX = 6
const GRID_MAX_OPACITY = 0.18
const DIMMED_OPACITY = 0.35
/** The world-space bounds rect of an object (center + size → top-left rect). */
function objectRect(o: EditorObject): Rect {
@@ -33,12 +33,12 @@ export function GardenCanvas({
garden,
objects,
plantings,
plants,
plantsById,
}: {
garden: EditorGarden
objects: EditorObject[]
plantings: EditorPlanting[]
plants: Plant[]
plantsById: Map<number, Plant>
}) {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
@@ -65,8 +65,6 @@ export function GardenCanvas({
[garden.widthCm, garden.heightCm],
)
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
useEffect(() => {
const el = containerRef.current
if (!el) return
@@ -136,7 +134,7 @@ export function GardenCanvas({
// armed for repeat-placement until Escape / Done).
function onPlace(e: ReactPointerEvent) {
e.stopPropagation()
if (!focusedObject || !armedPlant) return
if (!focusedObject || !armedPlant || !focusedObject.plantable) return
const rect = svgRef.current?.getBoundingClientRect()
if (!rect) return
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
1
@@ -197,8 +195,8 @@ export function GardenCanvas({
{/* 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 && (
<g transform={`translate(${focusedObject.xCm} ${focusedObject.yCm}) rotate(${focusedObject.rotationDeg})`}>
{focusedObject && armedPlant && focusedObject.plantable && (
<g transform={objectTransform(focusedObject)}>
<rect
x={-halfFW}
y={-halfFH}
+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}
+41 -23
View File
@@ -1,19 +1,22 @@
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 { PlantIcon } from '@/components/plants/PlantIcon'
const MIN_RADIUS_CM = 1
import { MIN_RADIUS_CM } from './shared'
import { useEditorStore } from './store'
/**
* 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.
* 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,
@@ -32,44 +35,59 @@ export function PlopInspector({
}) {
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)
const [radius, setRadius] = useState(String(spacingFromCm(plop.radiusCm, unit)))
const [count, setCount] = useState(plop.count != null ? String(plop.count) : '')
const [label, setLabel] = useState(plop.label ?? '')
const [planted, setPlanted] = useState(plop.plantedAt ?? '')
// 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(plop.radiusCm, unit)))
setCount(plop.count != null ? String(plop.count) : '')
setLabel(plop.label ?? '')
setPlanted(plop.plantedAt ?? '')
}, [plop.version, plop.radiusCm, plop.count, plop.label, plop.plantedAt, unit])
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(plop.radiusCm, plant.spacingCm) : plop.derivedCount
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 = parseFloat(radius)
if (!Number.isFinite(v)) return
const v = Number(radius)
if (radius.trim() === '' || !Number.isFinite(v)) {
setRadius(String(spacingFromCm(p.radiusCm, unit))) // reset stale/invalid text
return
}
const cm = Math.max(MIN_RADIUS_CM, cmFromSpacing(v, unit))
if (cm !== plop.radiusCm) patch({ radiusCm: cm })
if (cm !== p.radiusCm) patch({ radiusCm: cm })
}
function commitCount() {
if (count.trim() === '') {
if (plop.count != null) patch({ count: null }) // restore derived
if (p.count != null) patch({ count: null }) // restore derived
return
}
const n = Number(count)
if (!Number.isInteger(n) || n < 1) return
if (n !== plop.count) patch({ count: n })
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 (
@@ -121,7 +139,7 @@ export function PlopInspector({
value={count}
onChange={(e) => setCount(e.target.value)}
onBlur={commitCount}
hint={plop.count != null ? 'Override — clear for auto' : 'Auto from area ÷ spacing²'}
hint={p.count != null ? 'Override — clear for auto' : 'Auto from area ÷ spacing²'}
/>
</div>
@@ -130,7 +148,7 @@ export function PlopInspector({
name="label"
value={label}
onChange={(e) => setLabel(e.target.value)}
onBlur={() => label !== (plop.label ?? '') && patch({ label: label.trim() || null })}
onBlur={() => label !== (p.label ?? '') && patch({ label: label.trim() || null })}
/>
<TextField
@@ -139,7 +157,7 @@ export function PlopInspector({
type="date"
value={planted}
onChange={(e) => setPlanted(e.target.value)}
onBlur={() => planted !== (plop.plantedAt ?? '') && planted !== '' && patch({ plantedAt: planted })}
onBlur={commitPlanted}
/>
<Button
+13 -13
View File
@@ -1,7 +1,8 @@
import { memo } from 'react'
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). */
@@ -45,12 +46,16 @@ export const PlopLayer = memo(function PlopLayer({
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>
const byObject = new Map<number, EditorPlanting[]>()
for (const p of plantings) {
const arr = byObject.get(p.objectId)
if (arr) arr.push(p)
else byObject.set(p.objectId, [p])
}
// 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 (
1
@@ -63,11 +68,7 @@ export const PlopLayer = memo(function PlopLayer({
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={`translate(${o.xCm} ${o.yCm}) rotate(${o.rotationDeg})`}
opacity={dimmed ? 0.4 : 1}
>
<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" />
@@ -81,7 +82,6 @@ export const PlopLayer = memo(function PlopLayer({
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>
dimmed={false}
onSelect={onSelectPlop}
/>
))}
+8 -13
View File
@@ -1,6 +1,7 @@
import { memo, type PointerEvent } from 'react'
import type { Plant } from '@/lib/plants'
import { effectiveCount, type EditorPlanting } from '@/lib/plantings'
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)
@@ -9,34 +10,33 @@ import { effectiveCount, type EditorPlanting } from '@/lib/plantings'
export const SEMANTIC_FAR = 0.75
export const SEMANTIC_NEAR = 3
const SELECT_COLOR = '#2f7a3e'
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. memo'd so a pan/zoom
* that only changes the world transform doesn't re-render every plop.
* 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,
dimmed,
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
dimmed: 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()
@@ -44,12 +44,7 @@ export const PlopMarker = memo(function PlopMarker({
}
return (
<g
transform={`translate(${plop.xCm} ${plop.yCm})`}
opacity={dimmed ? 0.3 : 1}
onPointerDown={down}
style={{ cursor: 'pointer' }}
>
<g transform={`translate(${plop.xCm} ${plop.yCm})`} onPointerDown={down} style={{ cursor: 'pointer' }}>
<circle
cx={0}
cy={0}
@@ -85,7 +80,7 @@ export const PlopMarker = memo(function PlopMarker({
paintOrder="stroke"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{plant!.name} · {effectiveCount(plop)}
{plant!.name} · {count}
</text>
)}
</g>
1
+2 -4
View File
@@ -2,13 +2,11 @@ import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObje
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 HANDLE_PX = 12
const MIN_RADIUS_CM = 1
const MAX_RADIUS_CM = 10_000
const SELECT_COLOR = '#2f7a3e'
/**
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
3
@@ -127,7 +125,7 @@ export function PlopOverlay({
const r = plop.radiusCm
return (
<g transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`}>
<g transform={objectTransform(object)}>
{/* Transparent body: drag to move (at least handle-sized so tiny plops stay grabbable). */}
<circle
cx={plop.xCm}
+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})`
}
+13 -1
View File
2
@@ -280,7 +280,18 @@ export function useRemovePlanting(gardenId: number) {
},
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)
toast.error(objectErrorMessage(err, 'Could not remove that plant.'))
// 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.'))
}
},
})
}
@@ -307,6 +318,7 @@ function applyPlantingPatch(p: ServerPlanting, patch: PlantingPatch): ServerPlan
...(patch.count !== undefined ? { count: patch.count } : {}),
...(patch.label !== undefined ? { label: patch.label } : {}),
...(patch.plantedAt !== undefined ? { plantedAt: patch.plantedAt } : {}),
...(patch.removedAt !== undefined ? { removedAt: patch.removedAt } : {}),
}
}
+14 -6
View File
@@ -32,7 +32,6 @@ export function GardenEditorPage() {
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
const setArmedKind = useEditorStore((s) => s.setArmedKind)
const setLiveObject = useEditorStore((s) => s.setLiveObject)
const livePlanting = useEditorStore((s) => s.livePlanting)
const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
const updatePlanting = useUpdatePlanting(gid)
1
@@ -75,6 +74,14 @@ export function GardenEditorPage() {
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)
@@ -116,9 +123,8 @@ export function GardenEditorPage() {
const selectedObject = objects.find((o) => o.id === selectedId) ?? null
const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null
const rawSelectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
const selectedPlop =
rawSelectedPlop && livePlanting?.id === rawSelectedPlop.id ? livePlanting : rawSelectedPlop
// 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)
@@ -149,7 +155,9 @@ export function GardenEditorPage() {
<span className="max-w-[8rem] truncate text-muted">
{focusedObject.name || kindDef(focusedObject.kind)?.label || 'Object'}
</span>
{armedPlant ? (
{!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>
@@ -160,7 +168,7 @@ export function GardenEditorPage() {
)}
</div>
)}
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plants={plants} />
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} />
</div>
{(selectedObject || selectedPlop) && (