Address Gadfly review on #15: real bugs + shared editor constants
Build image / build-and-push (push) Successful in 9s
Build image / build-and-push (push) Successful in 9s
Correctness / stuck-state: - PlopInspector: the planted date can now be cleared (blur commits null instead of being blocked); radius/count reset to the current value on invalid input. - applyPlantingPatch now applies removedAt too (completes the optimistic patch). - PlopMarker computes the count live from the current radius, so the on-canvas near-zoom label updates while resizing (not just the inspector). - GardenEditorPage clears focus when the focused object vanishes — deleted, or a stale ?focus id on load — so the canvas can't get stuck dimmed with no exit. - useRemovePlanting adopts the 409 current row, so a stale-version soft-remove can be retried instead of looping. - Placement is gated on plantable, so a deep-linked non-plantable focus can't try to POST a plop the server would 400. Dedup / perf (introduced by this PR): - editor/shared.ts: SELECT_COLOR, HANDLE_PX, MIN_RADIUS_CM, DIMMED_OPACITY, and an objectTransform() helper, now used by ObjectShape/SelectionOverlay/PlopLayer/ PlopOverlay/PlopMarker/GardenCanvas (unified the transform string + the two different dim opacities). - Dropped PlopMarker's always-false `dimmed` prop (dimming is at the group level). - Build plantsById once in the page and pass it to GardenCanvas. - Moved the livePlanting subscription into PlopInspector so a plop drag re-renders only the panel, not the whole page (matches the liveObject pattern). - Memoized PlopLayer's plop grouping so it doesn't rebuild every pan/zoom frame. Skipped: gesture-scaffolding hook extraction (risky churn in #11 code), elliptical clamp for circle objects (consistent with the server's rect bounds), optimistic create (matches useCreateObject). tsc --noEmit clean; 24/24 vitest; production build green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -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)
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
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'>) =>
|
||||
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() {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}) {
|
||||
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 (
|
||||
@@ -63,11 +68,7 @@ export const PlopLayer = memo(function PlopLayer({
|
||||
const halfH = o.heightCm / 2
|
||||
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}
|
||||
dimmed={false}
|
||||
onSelect={onSelectPlop}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
* Move/resize handles for the selected plop, rendered inside its parent object's
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -280,7 +280,18 @@ export function useRemovePlanting(gardenId: number) {
|
||||
},
|
||||
onError: (err, _vars, ctx) => {
|
||||
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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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) && (
|
||||
|
||||
Reference in New Issue
Block a user