Configurable grid + snap-to-grid in the layout system (#45)
Build image / build-and-push (push) Successful in 7s

Closes #44. Two independent grids (garden + per-bed), each with a size and a snap toggle; snapping defaults off. See PR #45 for details.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #45.
This commit is contained in:
2026-07-19 07:07:14 +00:00
committed by steve
parent 9968c06243
commit e74fb308c1
23 changed files with 581 additions and 88 deletions
+54 -9
View File
@@ -5,9 +5,12 @@ import { TextArea } from '@/components/ui/TextArea'
import { useDeleteObject, useUpdateObject } from '@/lib/objects'
import {
cmFromDisplay,
cmFromSpacing,
dimensionUnitLabel,
displayFromCm,
MIN_DIMENSION_CM,
spacingFromCm,
spacingUnitLabel,
type UnitPref,
} from '@/lib/units'
import { kindDef } from './kinds'
@@ -48,6 +51,7 @@ export function Inspector({
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 [gridSize, setGridSize] = useState(String(spacingFromCm(object.gridSizeCm, unit)))
const [confirmingDelete, setConfirmingDelete] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
@@ -64,7 +68,8 @@ export function Inspector({
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])
setGridSize(String(spacingFromCm(object.gridSizeCm, unit)))
}, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, object.color, object.gridSizeCm, unit])
const patch = (fields: Partial<Omit<EditorObject, 'id' | 'version'>>) => {
if (readOnly) return // a blur mustn't fire a mutation if the role changed mid-edit
@@ -84,6 +89,17 @@ export function Inspector({
if (cm !== current) apply(cm)
}
// Commit the bed grid size (entered at spacing scale, cm/in). Compare at display
// precision so a blur without an edit doesn't fire a spurious PATCH; ignore a
// sub-1cm value the server would reject.
const commitGrid = () => {
const v = parseFloat(gridSize)
if (!Number.isFinite(v)) return
if (v === spacingFromCm(object.gridSizeCm, unit)) return
const cm = cmFromSpacing(v, unit)
if (cm >= MIN_DIMENSION_CM && cm !== object.gridSizeCm) patch({ gridSizeCm: cm })
}
const u = dimensionUnitLabel(unit)
return (
@@ -218,14 +234,43 @@ export function Inspector({
Plantable
</label>
<TextArea
label="Notes"
name="notes"
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
onBlur={() => notes !== object.notes && patch({ notes })}
/>
{/* Bed grid: only plantable beds place plants, so the plant-snapping grid
is shown just for them. */}
{object.plantable && (
<div className="flex items-end gap-2">
<div className="flex-1">
<TextField
label={`Bed grid (${spacingUnitLabel(unit)})`}
name="gridSize"
type="number"
inputMode="decimal"
step="any"
min="0"
value={gridSize}
onChange={(e) => setGridSize(e.target.value)}
onBlur={commitGrid}
/>
</div>
<label className="flex h-9 items-center gap-2 whitespace-nowrap text-sm text-fg">
<input
type="checkbox"
checked={object.snapToGrid}
onChange={(e) => patch({ snapToGrid: e.target.checked })}
className="h-4 w-4 rounded border-border"
/>
Snap plants
</label>
</div>
)}
<TextArea
label="Notes"
name="notes"
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
onBlur={() => notes !== object.notes && patch({ notes })}
/>
</fieldset>
{!readOnly &&