Field editor: palette, select/move/resize/rotate, inspector, optimistic sync (#11) (#30)
Build image / build-and-push (push) Successful in 13s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #30.
This commit is contained in:
2026-07-19 01:01:38 +00:00
committed by steve
parent 2119f1a376
commit b79bfcf7a9
13 changed files with 989 additions and 74 deletions
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
import { Button } from '@/components/ui/Button'
import { TextField } from '@/components/ui/TextField'
import { TextArea } from '@/components/ui/TextArea'
import { useDeleteObject, useUpdateObject } from '@/lib/objects'
import {
cmFromDisplay,
dimensionUnitLabel,
displayFromCm,
MIN_DIMENSION_CM,
type UnitPref,
} from '@/lib/units'
import { kindDef } from './kinds'
import { useEditorStore } from './store'
import type { EditorObject } from './types'
const DEFAULT_COLOR = '#8a8a8a'
/**
* Property panel for the selected object. Each field commits a PATCH on
* blur/change (carrying the object's version); dimensions and position are shown
* in the garden's unit. Keyed by object id in the parent so it re-inits cleanly
* on selection change.
*/
export function Inspector({
object,
gardenId,
unit,
}: {
object: EditorObject
gardenId: number
unit: UnitPref
}) {
const update = useUpdateObject(gardenId)
const del = useDeleteObject(gardenId)
const select = useEditorStore((s) => s.select)
// Local field state (initialized once; committed on blur/change).
const [name, setName] = useState(object.name)
const [notes, setNotes] = useState(object.notes)
const [width, setWidth] = useState(String(displayFromCm(object.widthCm, unit)))
const [height, setHeight] = useState(String(displayFromCm(object.heightCm, unit)))
const [x, setX] = useState(String(displayFromCm(object.xCm, unit)))
const [y, setY] = useState(String(displayFromCm(object.yCm, unit)))
const [rotation, setRotation] = useState(String(Math.round(object.rotationDeg)))
const [color, setColor] = useState(object.color ?? DEFAULT_COLOR)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
// When the object changes underneath us (e.g. a canvas move/resize/rotate, or
// an optimistic PATCH result), re-sync the fields — unless the user is
// actively editing one here, so we don't clobber their typing.
useEffect(() => {
if (rootRef.current?.contains(document.activeElement)) return
setName(object.name)
setNotes(object.notes)
setWidth(String(displayFromCm(object.widthCm, unit)))
setHeight(String(displayFromCm(object.heightCm, unit)))
setX(String(displayFromCm(object.xCm, unit)))
setY(String(displayFromCm(object.yCm, unit)))
setRotation(String(Math.round(object.rotationDeg)))
setColor(object.color ?? DEFAULT_COLOR)
}, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, object.color, unit])
const patch = (fields: Partial<Omit<EditorObject, 'id' | 'version'>>) =>
update.mutate({ id: object.id, version: object.version, ...fields })
// Commit a dimension/position field. `positive` gates width/height (must be
// ≥ the server minimum) but not x/y, which may be zero or negative. Compare at
// display precision first so a blur without an edit doesn't fire a spurious
// PATCH from cm↔ft rounding drift (e.g. 240cm shows 7.9ft → 241cm).
const commitDim = (raw: string, current: number, apply: (cm: number) => void, positive = false) => {
const v = parseFloat(raw)
if (!Number.isFinite(v)) return
if (v === displayFromCm(current, unit)) return
const cm = cmFromDisplay(v, unit)
if (positive && cm < MIN_DIMENSION_CM) return
if (cm !== current) apply(cm)
}
const u = dimensionUnitLabel(unit)
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">{kindDef(object.kind)?.label ?? object.kind}</h2>
<button
type="button"
onClick={() => select(null)}
className="rounded px-1.5 text-sm text-muted hover:text-fg"
aria-label="Close inspector"
>
</button>
</div>
<TextField
label="Name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => name !== object.name && patch({ name })}
/>
<div className="grid grid-cols-2 gap-2">
<TextField
label={`Width (${u})`}
name="width"
type="number"
inputMode="decimal"
step="any"
value={width}
onChange={(e) => setWidth(e.target.value)}
onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }), true)}
/>
<TextField
label={`Height (${u})`}
name="height"
type="number"
inputMode="decimal"
step="any"
value={height}
onChange={(e) => setHeight(e.target.value)}
onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)}
/>
<TextField
label={`X (${u})`}
name="x"
type="number"
inputMode="decimal"
step="any"
value={x}
onChange={(e) => setX(e.target.value)}
onBlur={() => commitDim(x, object.xCm, (cm) => patch({ xCm: cm }))}
/>
<TextField
label={`Y (${u})`}
name="y"
type="number"
inputMode="decimal"
step="any"
value={y}
onChange={(e) => setY(e.target.value)}
onBlur={() => commitDim(y, object.yCm, (cm) => patch({ yCm: cm }))}
/>
</div>
<TextField
label="Rotation (°)"
name="rotation"
type="number"
inputMode="numeric"
step="1"
value={rotation}
onChange={(e) => setRotation(e.target.value)}
onBlur={() => {
const v = parseFloat(rotation)
if (Number.isFinite(v) && v !== object.rotationDeg) patch({ rotationDeg: v })
}}
/>
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1.5">
<label htmlFor="obj-color" className="text-sm font-medium text-fg">
Color
</label>
<input
id="obj-color"
type="color"
value={color}
// onChange fires continuously while dragging in the native picker;
// track it locally for the live swatch and commit one PATCH on blur.
onChange={(e: ChangeEvent<HTMLInputElement>) => setColor(e.target.value)}
onBlur={() => color !== (object.color ?? DEFAULT_COLOR) && patch({ color })}
className="h-9 w-14 cursor-pointer rounded-md border border-border bg-surface"
/>
</div>
{object.color && (
<Button
variant="ghost"
className="px-2 py-1.5 text-xs"
onClick={() => {
setColor(DEFAULT_COLOR)
patch({ color: null })
}}
>
Clear
</Button>
)}
</div>
<label className="flex items-center gap-2 text-sm text-fg">
<input
type="checkbox"
checked={object.plantable}
onChange={(e) => patch({ plantable: e.target.checked })}
className="h-4 w-4 rounded border-border"
/>
Plantable
</label>
<TextArea
label="Notes"
name="notes"
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
onBlur={() => notes !== object.notes && patch({ notes })}
/>
{confirmingDelete ? (
<div className="flex items-center gap-2">
<Button
variant="danger"
className="flex-1"
disabled={del.isPending}
onClick={() => {
select(null)
del.mutate(object.id)
}}
>
Confirm delete
</Button>
<Button variant="ghost" onClick={() => setConfirmingDelete(false)}>
Cancel
</Button>
</div>
) : (
<Button variant="ghost" className="text-red-600 dark:text-red-400" onClick={() => setConfirmingDelete(true)}>
Delete object
</Button>
)}
</div>
)
}