Plops editor: focus mode, place/move/resize, semantic zoom (#15) #34
@@ -1,9 +1,14 @@
|
|||||||
import { useEffect, useId, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
import { useEffect, useId, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
||||||
import { screenToWorld, type Rect, type Size } from '@/lib/geometry'
|
import { screenToWorld, worldToLocal, type Rect, type Size } from '@/lib/geometry'
|
||||||
import { useCreateObject } from '@/lib/objects'
|
import { useCreateObject, useCreatePlanting } from '@/lib/objects'
|
||||||
|
import type { Plant } from '@/lib/plants'
|
||||||
|
import type { EditorPlanting } from '@/lib/plantings'
|
||||||
import { ObjectShape } from './ObjectShape'
|
import { ObjectShape } from './ObjectShape'
|
||||||
|
import { PlopLayer } from './PlopLayer'
|
||||||
|
import { PlopOverlay } from './PlopOverlay'
|
||||||
import { SelectionOverlay } from './SelectionOverlay'
|
import { SelectionOverlay } from './SelectionOverlay'
|
||||||
import { kindDef } from './kinds'
|
import { kindDef } from './kinds'
|
||||||
|
import { DIMMED_OPACITY, objectTransform } from './shared'
|
||||||
import { useEditorStore } from './store'
|
import { useEditorStore } from './store'
|
||||||
import { useViewport } from './useViewport'
|
import { useViewport } from './useViewport'
|
||||||
import type { EditorGarden, EditorObject } from './types'
|
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_MIN_CELL_PX = 6
|
||||||
const GRID_MAX_OPACITY = 0.18
|
const GRID_MAX_OPACITY = 0.18
|
||||||
|
|
|||||||
|
|
||||||
|
/** 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
|
* The editor canvas. Pan/zoom/pinch, tap-to-place objects, select+move/resize/
|
||||||
* armed palette kind, click to select, and — for the selected object —
|
* rotate an object, and — the #15 core — focus into a plantable object to place,
|
||||||
* move/resize/rotate via SelectionOverlay. The object being dragged is rendered
|
* move, resize and remove plops with semantic-zoom rendering. The object/plop
|
||||||
* from liveObject for instant feedback; the PATCH fires on release.
|
* being dragged renders from liveObject/livePlanting for instant feedback; the
|
||||||
|
* PATCH fires on release.
|
||||||
*/
|
*/
|
||||||
export function GardenCanvas({
|
export function GardenCanvas({
|
||||||
garden,
|
garden,
|
||||||
objects,
|
objects,
|
||||||
focusId,
|
plantings,
|
||||||
|
plantsById,
|
||||||
}: {
|
}: {
|
||||||
garden: EditorGarden
|
garden: EditorGarden
|
||||||
objects: EditorObject[]
|
objects: EditorObject[]
|
||||||
focusId?: number
|
plantings: EditorPlanting[]
|
||||||
|
plantsById: Map<number, Plant>
|
||||||
}) {
|
}) {
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [size, setSize] = useState<Size>({ w: 0, h: 0 })
|
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 gridId = 'garden-grid-' + useId().replace(/:/g, '')
|
||||||
|
|
||||||
const viewport = useEditorStore((s) => s.viewport)
|
const viewport = useEditorStore((s) => s.viewport)
|
||||||
const selectedId = useEditorStore((s) => s.selectedId)
|
const selectedId = useEditorStore((s) => s.selectedId)
|
||||||
const select = useEditorStore((s) => s.select)
|
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 liveObject = useEditorStore((s) => s.liveObject)
|
||||||
|
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||||
const { fitToRect } = useViewport(svgRef)
|
const { fitToRect } = useViewport(svgRef)
|
||||||
const create = useCreateObject(garden.id)
|
const createObject = useCreateObject(garden.id)
|
||||||
|
const createPlanting = useCreatePlanting(garden.id)
|
||||||
|
|
||||||
const gardenRect: Rect = useMemo(
|
const gardenRect: Rect = useMemo(
|
||||||
() => ({ x: 0, y: 0, w: garden.widthCm, h: garden.heightCm }),
|
() => ({ x: 0, y: 0, w: garden.widthCm, h: garden.heightCm }),
|
||||||
@@ -53,61 +73,83 @@ export function GardenCanvas({
|
|||||||
return () => ro.disconnect()
|
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(() => {
|
useEffect(() => {
|
||||||
const usable = size.w > 0 && size.h > 0 && garden.widthCm > 0 && garden.heightCm > 0
|
if (size.w === 0 || size.h === 0 || garden.widthCm === 0 || garden.heightCm === 0) return
|
||||||
if (usable && fittedForRef.current !== garden.id) {
|
const key = `${garden.id}:${focusedObjectId}`
|
||||||
fittedForRef.current = garden.id
|
if (fitKeyRef.current === key) return
|
||||||
// ?focus=<id> frames that object (groundwork for #15's focus mode);
|
const target = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) : undefined
|
||||||
// otherwise frame the whole garden.
|
if (focusedObjectId != null && !target) return // object not loaded yet; try again when it is
|
||||||
const focus = focusId != null ? objects.find((o) => o.id === focusId) : undefined
|
fitKeyRef.current = key
|
||||||
const target: Rect = focus
|
fitToRect(target ? objectRect(target) : gardenRect, size)
|
||||||
? { x: focus.xCm - focus.widthCm / 2, y: focus.yCm - focus.heightCm / 2, w: focus.widthCm, h: focus.heightCm }
|
}, [size, garden.id, garden.widthCm, garden.heightCm, focusedObjectId, objects, gardenRect, fitToRect])
|
||||||
: gardenRect
|
|
||||||
fitToRect(target, size)
|
|
||||||
}
|
|
||||||
}, [size, garden.id, garden.widthCm, garden.heightCm, gardenRect, fitToRect, focusId, objects])
|
|
||||||
|
|
||||||
const cellPx = GRID_CM * viewport.scale
|
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))
|
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])
|
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(
|
const rendered = useMemo(
|
||||||
() => (liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted),
|
() => (liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted),
|
||||||
[sorted, liveObject],
|
[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
|
// A pointerdown reaching the svg is empty space: place the armed object kind,
|
||||||
// deselect. (Objects/handles stopPropagation.)
|
// or exit focus mode, or just deselect.
|
||||||
function onCanvasPointerDown(e: ReactPointerEvent) {
|
function onCanvasPointerDown(e: ReactPointerEvent) {
|
||||||
const armed = useEditorStore.getState().armedKind
|
const armed = useEditorStore.getState().armedKind
|
||||||
if (!armed) {
|
if (armed) {
|
||||||
select(null)
|
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
|
return
|
||||||
}
|
}
|
||||||
const def = kindDef(armed)
|
// Empty-space tap while focused exits focus (and any placement); otherwise
|
||||||
useEditorStore.getState().setArmedKind(null)
|
// just clears the selection.
|
||||||
if (!def) return
|
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()
|
const rect = svgRef.current?.getBoundingClientRect()
|
||||||
if (!rect) return
|
if (!rect) return
|
||||||
|
gitea-actions
commented
🟡 onPlace allows planting on non-plantable objects when deep-linked correctness · flagged by 1 model
🪰 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)
|
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
|
||||||
create.mutate(
|
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))
|
||||||
kind: def.kind,
|
const y = Math.max(-focusedObject.heightCm / 2, Math.min(focusedObject.heightCm / 2, local.y))
|
||||||
shape: def.shape,
|
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
|
||||||
|
gitea-actions
commented
🟡 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>
|
|||||||
xCm: world.x,
|
// Stay armed for repeat-placement; don't select (the placement sheet covers
|
||||||
yCm: world.y,
|
// the object, so a selection would be hidden until placement ends anyway).
|
||||||
widthCm: def.widthCm,
|
createPlanting.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, xCm: x, yCm: y, radiusCm })
|
||||||
heightCm: def.heightCm,
|
|
||||||
zIndex: def.defaultZ,
|
|
||||||
},
|
|
||||||
{ onSuccess: (o) => select(o.id) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
|
||||||
|
const halfFH = focusedObject ? focusedObject.heightCm / 2 : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
|
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
|
||||||
<svg
|
<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" />
|
<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) => (
|
{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>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ export function Inspector({
|
|||||||
object,
|
object,
|
||||||
gardenId,
|
gardenId,
|
||||||
unit,
|
unit,
|
||||||
|
onFocus,
|
||||||
}: {
|
}: {
|
||||||
object: EditorObject
|
object: EditorObject
|
||||||
gardenId: number
|
gardenId: number
|
||||||
unit: UnitPref
|
unit: UnitPref
|
||||||
|
onFocus?: () => void
|
||||||
}) {
|
}) {
|
||||||
const update = useUpdateObject(gardenId)
|
const update = useUpdateObject(gardenId)
|
||||||
const del = useDeleteObject(gardenId)
|
const del = useDeleteObject(gardenId)
|
||||||
@@ -94,6 +96,12 @@ export function Inspector({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{object.plantable && onFocus && (
|
||||||
|
<Button onClick={onFocus} className="w-full">
|
||||||
|
🌱 Plant here
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Name"
|
label="Name"
|
||||||
name="name"
|
name="name"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo, type PointerEvent } from 'react'
|
import { memo, type PointerEvent } from 'react'
|
||||||
|
import { objectTransform } from './shared'
|
||||||
import type { EditorObject } from './types'
|
import type { EditorObject } from './types'
|
||||||
|
|
||||||
const DEFAULT_FILL = '#8a8a8a'
|
const DEFAULT_FILL = '#8a8a8a'
|
||||||
@@ -60,11 +61,7 @@ export const ObjectShape = memo(function ObjectShape({
|
|||||||
const strokeWidth = selected ? 2 : 1
|
const strokeWidth = selected ? 2 : 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g transform={objectTransform(object)} onPointerDown={handleDown} style={{ cursor: 'pointer' }}>
|
||||||
transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`}
|
|
||||||
onPointerDown={handleDown}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
{object.shape === 'circle' ? (
|
{object.shape === 'circle' ? (
|
||||||
<ellipse
|
<ellipse
|
||||||
cx={0}
|
cx={0}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
gitea-actions
commented
🟡 MIN_RADIUS_CM duplicated verbatim between PlopInspector.tsx and PlopOverlay.tsx, both new in this PR maintainability · flagged by 2 models
🪰 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)
|
||||||
|
gitea-actions
commented
🟡 Four useState initializers duplicated verbatim in the resync effect; extract a sync helper maintainability · flagged by 1 model
🪰 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'>) =>
|
||||||
|
gitea-actions
commented
🟠 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() {
|
||||||
|
gitea-actions
commented
🟡 commitCount silently drops invalid input with no reset or feedback, unlike commitRadius which clamps error-handling · flagged by 1 model
🪰 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
|
||||||
|
}
|
||||||
|
gitea-actions
commented
🟡 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²'}
|
||||||
|
gitea-actions
commented
🔴 Cannot clear plantedAt date: blur guard prevents null patch and re-sync snaps value back correctness, error-handling · flagged by 4 models
🪰 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}) {
|
||||||
|
gitea-actions
commented
🟠 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
🪰 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)
|
||||||
|
gitea-actions
commented
🟠 dominantColor recomputed inline on every scale change maintainability · flagged by 1 model
🪰 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
|
||||||
|
gitea-actions
commented
🟡 Object-local transform string duplicated across 5 files; extract a shared helper maintainability · flagged by 2 models
🪰 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}
|
||||||
|
gitea-actions
commented
🟠 PlopMarker dimmed prop is always false and redundant with parent group opacity maintainability · flagged by 2 models
🪰 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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
gitea-actions
commented
🟡 Dead dimmed prop in PlopMarker maintainability · flagged by 1 model
🪰 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
gitea-actions
commented
🟠 On-canvas plop label shows stale derivedCount during live resize instead of recomputing from live radius correctness · flagged by 2 models
🪰 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>
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
/**
|
||||||
|
gitea-actions
commented
🟡 SELECT_COLOR hex constant duplicated across SelectionOverlay/PlopMarker/PlopOverlay instead of shared maintainability · flagged by 3 models
🪰 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(
|
||||||
|
() => () => {
|
||||||
|
gitea-actions
commented
🟠 Gesture scaffolding duplicated from SelectionOverlay maintainability · flagged by 2 models
🪰 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
|
||||||
|
gitea-actions
commented
🟠 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 }
|
||||||
|
},
|
||||||
|
gitea-actions
commented
🔴 livePlanting resize omits derivedCount update, so near-zoom marker count stays stale during drag correctness, error-handling · flagged by 2 models
🪰 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
|
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
|
||||||
import { localToWorld, screenToWorld, worldToLocal, type Point } from '@/lib/geometry'
|
import { localToWorld, screenToWorld, worldToLocal, type Point } from '@/lib/geometry'
|
||||||
import { useUpdateObject } from '@/lib/objects'
|
import { useUpdateObject } from '@/lib/objects'
|
||||||
|
import { HANDLE_PX, SELECT_COLOR, objectTransform } from './shared'
|
||||||
import { useEditorStore } from './store'
|
import { useEditorStore } from './store'
|
||||||
import type { EditorObject } from './types'
|
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 ROTATE_OFFSET_PX = 28 // distance of the rotate knob above the object
|
||||||
const MIN_OBJ_CM = 1 // smallest allowed dimension
|
const MIN_OBJ_CM = 1 // smallest allowed dimension
|
||||||
const ROTATE_SNAP_DEG = 15
|
const ROTATE_SNAP_DEG = 15
|
||||||
const SELECT_COLOR = '#2f7a3e'
|
|
||||||
|
|
||||||
const corners: [number, number][] = [
|
const corners: [number, number][] = [
|
||||||
[-1, -1],
|
[-1, -1],
|
||||||
@@ -162,7 +161,7 @@ export function SelectionOverlay({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`}>
|
<g transform={objectTransform(object)}>
|
||||||
{/* Transparent body: drag to move. */}
|
{/* Transparent body: drag to move. */}
|
||||||
{object.shape === 'circle' ? (
|
{object.shape === 'circle' ? (
|
||||||
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill="transparent" pointerEvents="all" style={{ cursor: 'move' }} onPointerDown={startMove} />
|
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill="transparent" pointerEvents="all" style={{ cursor: 'move' }} onPointerDown={startMove} />
|
||||||
|
|||||||
@@ -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})`
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { Viewport } from '@/lib/geometry'
|
import type { Viewport } from '@/lib/geometry'
|
||||||
|
import type { Plant } from '@/lib/plants'
|
||||||
|
import type { EditorPlanting } from '@/lib/plantings'
|
||||||
import type { EditorObject } from './types'
|
import type { EditorObject } from './types'
|
||||||
|
|
||||||
// Ephemeral editor state only (per DESIGN § State): the viewport, the current
|
// Ephemeral editor state only (per DESIGN § State): the viewport, the current
|
||||||
@@ -13,17 +15,31 @@ interface EditorState {
|
|||||||
viewport: Viewport
|
viewport: Viewport
|
||||||
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
|
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
|
selectedId: number | null
|
||||||
select: (id: number | null) => void
|
select: (id: number | null) => void
|
||||||
|
|
||||||
|
selectedPlantingId: number | null
|
||||||
|
selectPlanting: (id: number | null) => void
|
||||||
|
|
||||||
focusedObjectId: number | null
|
focusedObjectId: number | null
|
||||||
setFocusedObject: (id: number | null) => void
|
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
|
// 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.
|
// canvas renders it instantly; the PATCH fires only on gesture end.
|
||||||
liveObject: EditorObject | null
|
liveObject: EditorObject | null
|
||||||
setLiveObject: (o: EditorObject | null) => void
|
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
|
// The palette kind armed for tap-to-place (mobile-friendly; also set while
|
||||||
// dragging a kind from the palette). null when nothing is armed.
|
// dragging a kind from the palette). null when nothing is armed.
|
||||||
armedKind: string | null
|
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 })),
|
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
|
||||||
|
|
||||||
selectedId: null,
|
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,
|
focusedObjectId: null,
|
||||||
setFocusedObject: (id) => set({ focusedObjectId: id }),
|
setFocusedObject: (id) => set({ focusedObjectId: id }),
|
||||||
|
|
||||||
|
armedPlant: null,
|
||||||
|
setArmedPlant: (p) => set({ armedPlant: p }),
|
||||||
|
|
||||||
liveObject: null,
|
liveObject: null,
|
||||||
setLiveObject: (o) => set({ liveObject: o }),
|
setLiveObject: (o) => set({ liveObject: o }),
|
||||||
|
|
||||||
|
livePlanting: null,
|
||||||
|
setLivePlanting: (p) => set({ livePlanting: p }),
|
||||||
|
|
||||||
armedKind: null,
|
armedKind: null,
|
||||||
setArmedKind: (kind) => set({ armedKind: kind }),
|
setArmedKind: (kind) => set({ armedKind: kind }),
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient }
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { ApiError, api } from './api'
|
import { ApiError, api } from './api'
|
||||||
import { gardenSchema } from './gardens'
|
import { gardenSchema } from './gardens'
|
||||||
|
import { plantSchema } from './plants'
|
||||||
|
import { serverPlantingSchema, type ServerPlanting } from './plantings'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import type { EditorObject, ObjectShapeKind } from '@/editor/types'
|
import type { EditorObject, ObjectShapeKind } from '@/editor/types'
|
||||||
|
|
||||||
@@ -35,8 +37,8 @@ export type ServerObject = z.infer<typeof serverObjectSchema>
|
|||||||
export const fullGardenSchema = z.object({
|
export const fullGardenSchema = z.object({
|
||||||
garden: gardenSchema,
|
garden: gardenSchema,
|
||||||
objects: z.array(serverObjectSchema),
|
objects: z.array(serverObjectSchema),
|
||||||
plantings: z.array(z.unknown()), // typed in #14
|
plantings: z.array(serverPlantingSchema),
|
||||||
plants: z.array(z.unknown()), // typed in #12
|
plants: z.array(plantSchema),
|
||||||
})
|
})
|
||||||
export type FullGarden = z.infer<typeof fullGardenSchema>
|
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
|
||||||
|
gitea-actions
commented
🟠 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) {
|
||||||
|
gitea-actions
commented
🟠 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
🪰 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) => {
|
||||||
|
gitea-actions
commented
🟠 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
🪰 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 ---------------------------------------------------------------
|
// --- helpers ---------------------------------------------------------------
|
||||||
|
gitea-actions
commented
🟠 applyPlantingPatch omits removedAt, causing latent optimistic-update mismatch correctness, error-handling, maintainability · flagged by 4 models
🪰 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) {
|
function patchFullCache(qc: QueryClient, gardenId: number, fn: (full: FullGarden) => FullGarden) {
|
||||||
qc.setQueryData<FullGarden>(fullKey(gardenId), (full) => (full ? fn(full) : full))
|
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
|
/** 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
|
* 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
|
* edit started before the first PATCH resolves reads the next version instead
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { getRouteApi } from '@tanstack/react-router'
|
import { getRouteApi } from '@tanstack/react-router'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
import { GardenCanvas } from '@/editor/GardenCanvas'
|
import { GardenCanvas } from '@/editor/GardenCanvas'
|
||||||
import { Inspector } from '@/editor/Inspector'
|
import { Inspector } from '@/editor/Inspector'
|
||||||
|
import { PlopInspector } from '@/editor/PlopInspector'
|
||||||
|
import { PlantPicker } from '@/editor/PlantPicker'
|
||||||
import { Palette } from '@/editor/Palette'
|
import { Palette } from '@/editor/Palette'
|
||||||
|
import { kindDef } from '@/editor/kinds'
|
||||||
import { useEditorStore } from '@/editor/store'
|
import { useEditorStore } from '@/editor/store'
|
||||||
import type { EditorGarden } from '@/editor/types'
|
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')
|
const routeApi = getRouteApi('/gardens/$gardenId')
|
||||||
|
|
||||||
@@ -14,29 +19,90 @@ export function GardenEditorPage() {
|
|||||||
const { gardenId } = routeApi.useParams()
|
const { gardenId } = routeApi.useParams()
|
||||||
const gid = Number(gardenId)
|
const gid = Number(gardenId)
|
||||||
const { focus } = routeApi.useSearch()
|
const { focus } = routeApi.useSearch()
|
||||||
|
const navigate = routeApi.useNavigate()
|
||||||
const full = useGardenFull(gid)
|
const full = useGardenFull(gid)
|
||||||
|
|
||||||
const selectedId = useEditorStore((s) => s.selectedId)
|
const selectedId = useEditorStore((s) => s.selectedId)
|
||||||
const select = useEditorStore((s) => s.select)
|
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 setArmedKind = useEditorStore((s) => s.setArmedKind)
|
||||||
const setLiveObject = useEditorStore((s) => s.setLiveObject)
|
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 serverObjects = full.data?.objects
|
||||||
const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects])
|
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(() => {
|
useEffect(() => {
|
||||||
const reset = () => {
|
const clear = () => {
|
||||||
select(null)
|
select(null)
|
||||||
|
selectPlanting(null)
|
||||||
setArmedKind(null)
|
setArmedKind(null)
|
||||||
|
setArmedPlant(null)
|
||||||
setLiveObject(null)
|
setLiveObject(null)
|
||||||
|
setLivePlanting(null)
|
||||||
}
|
}
|
||||||
reset()
|
clear()
|
||||||
return reset
|
setFocusedObject(focus ?? null)
|
||||||
}, [gid, select, setArmedKind, setLiveObject])
|
return () => {
|
||||||
|
gitea-actions
commented
🟠 Stale ?focus IDs not validated after load: canvas stays dimmed with no visible exit UI error-handling · flagged by 1 model
🪰 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.isPending) return <p className="p-6 text-sm text-muted">Loading garden…</p>
|
||||||
if (full.isError)
|
if (full.isError)
|
||||||
@@ -54,7 +120,22 @@ export function GardenEditorPage() {
|
|||||||
heightCm: g.heightCm,
|
heightCm: g.heightCm,
|
||||||
unitPref: g.unitPref,
|
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 (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
|
<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}>
|
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||||
{garden.name}
|
{garden.name}
|
||||||
</h1>
|
</h1>
|
||||||
<Palette />
|
{focusedObjectId == null && <Palette />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative min-h-0 flex-1">
|
<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>
|
</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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{picker && (
|
||||||
|
<PlantPicker
|
||||||
|
unit={garden.unitPref}
|
||||||
|
onClose={() => setPicker(null)}
|
||||||
|
onSelect={(p) => onPickPlant(p.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
🟡 Focus dimming opacity inconsistent across components
maintainability · flagged by 2 models
🪰 Gadfly · advisory