From 451a4b00cc5e78d03f34082f17f402a0c529883e Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 18 Jul 2026 20:37:24 -0400 Subject: [PATCH 1/2] Add field editor: palette, select/move/resize/rotate, inspector, sync (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the canvas foundation into the real editor, loading a garden from the server and editing it with optimistic sync. Completes Phase 3. - lib/objects.ts: react-query over GET /gardens/:id/full + optimistic create/update/delete. Update applies to the cache up front and PATCHes with the row version; a 409 adopts the server's `current` row and toasts "updated elsewhere". One PATCH per gesture. - editor/store.ts: adds liveObject (in-gesture geometry, rendered instantly), armedKind (palette tap-to-place), and objectDragging (so the pan gesture stands down while an object drag owns the pointer). - editor/Palette.tsx + kinds.ts: the seven kinds with icons + default sizes; tap a kind, tap the canvas to place (works desktop + touch). - editor/SelectionOverlay.tsx: move (drag body), resize (corner handles, opposite corner fixed via the object-local frame so rotation is honored), rotate (knob, snaps to 15°, free with Shift). Each updates liveObject and fires one PATCH on release. - editor/Inspector.tsx: name, dimensions/position (unit-aware), rotation, color override (+ clear), plantable, notes, two-step delete. Commits per field; re-syncs from the object unless a field is focused. - GardenCanvas: placement, live-drag merge, z-ordered render, selection overlay, and ?focus= frames an object on load. GardenEditorPage loads /full and lays out palette | canvas | inspector (bottom sheet on mobile). - components/ui/toast.tsx + Toaster in AppShell. Verified in a real browser against the embedded binary: place a bed → move, resize, rotate (rotate snapped to 60°) → set its name in the inspector → reload, all persisted; each gesture was exactly one PATCH (version 1→2→3→4→5); an out-of-band edit made the next drag 409 → rollback to the server's value + toast. tsc + 17 vitest tests green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi --- web/src/components/layout/AppShell.tsx | 3 + web/src/components/ui/toast.tsx | 64 ++++++++ web/src/editor/GardenCanvas.tsx | 132 +++++++++------ web/src/editor/Inspector.tsx | 209 ++++++++++++++++++++++++ web/src/editor/Palette.tsx | 49 ++++++ web/src/editor/SelectionOverlay.tsx | 174 ++++++++++++++++++++ web/src/editor/kinds.ts | 26 +++ web/src/editor/store.ts | 32 +++- web/src/editor/types.ts | 2 + web/src/editor/useViewport.ts | 2 + web/src/lib/objects.ts | 215 +++++++++++++++++++++++++ web/src/pages/GardenEditorPage.tsx | 85 +++++++--- web/src/router.tsx | 5 + 13 files changed, 924 insertions(+), 74 deletions(-) create mode 100644 web/src/components/ui/toast.tsx create mode 100644 web/src/editor/Inspector.tsx create mode 100644 web/src/editor/Palette.tsx create mode 100644 web/src/editor/SelectionOverlay.tsx create mode 100644 web/src/editor/kinds.ts create mode 100644 web/src/lib/objects.ts diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 5cf7770..f0554d8 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -1,4 +1,5 @@ import { Link, Outlet, useNavigate } from '@tanstack/react-router' +import { Toaster } from '@/components/ui/toast' import { useLogout, useMe } from '@/lib/auth' const navLinks = [ @@ -81,6 +82,8 @@ export function AppShell() {
+ + ) } diff --git a/web/src/components/ui/toast.tsx b/web/src/components/ui/toast.tsx new file mode 100644 index 0000000..a1810b2 --- /dev/null +++ b/web/src/components/ui/toast.tsx @@ -0,0 +1,64 @@ +import { useEffect } from 'react' +import { create } from 'zustand' +import { cn } from '@/lib/cn' + +type ToastTone = 'info' | 'error' +interface Toast { + id: number + message: string + tone: ToastTone +} + +interface ToastState { + toasts: Toast[] + push: (message: string, tone?: ToastTone) => void + dismiss: (id: number) => void +} + +let nextId = 1 + +export const useToastStore = create((set) => ({ + toasts: [], + push: (message, tone = 'info') => set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }] })), + dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), +})) + +/** Fire a toast from anywhere (including non-component code). */ +export const toast = { + info: (m: string) => useToastStore.getState().push(m, 'info'), + error: (m: string) => useToastStore.getState().push(m, 'error'), +} + +function ToastItem({ toast }: { toast: Toast }) { + const dismiss = useToastStore((s) => s.dismiss) + useEffect(() => { + const t = setTimeout(() => dismiss(toast.id), 4000) + return () => clearTimeout(t) + }, [toast.id, dismiss]) + return ( +
+ {toast.message} +
+ ) +} + +/** Fixed stack of active toasts. Mount once near the app root. */ +export function Toaster() { + const toasts = useToastStore((s) => s.toasts) + if (toasts.length === 0) return null + return ( +
+ {toasts.map((t) => ( + + ))} +
+ ) +} diff --git a/web/src/editor/GardenCanvas.tsx b/web/src/editor/GardenCanvas.tsx index 204e160..0d0595d 100644 --- a/web/src/editor/GardenCanvas.tsx +++ b/web/src/editor/GardenCanvas.tsx @@ -1,22 +1,40 @@ -import { useEffect, useId, useMemo, useRef, useState } from 'react' -import { Button } from '@/components/ui/Button' -import type { Size } from '@/lib/geometry' +import { useEffect, useId, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' +import { screenToWorld, type Rect, type Size } from '@/lib/geometry' +import { useCreateObject } from '@/lib/objects' import { ObjectShape } from './ObjectShape' +import { SelectionOverlay } from './SelectionOverlay' +import { kindDef } from './kinds' import { useEditorStore } from './store' import { useViewport } from './useViewport' import type { EditorGarden, EditorObject } from './types' const GRID_CM = 100 // 1 m grid -const GRID_MIN_CELL_PX = 6 // grid is invisible below this cell size (zoomed out) +const GRID_MIN_CELL_PX = 6 const GRID_MAX_OPACITY = 0.18 +// Kinds that render beneath beds by default; explicit z_index still wins. +const UNDER_KINDS = new Set(['path', 'structure', 'in_ground']) +function defaultZForKind(kind: string): number { + if (UNDER_KINDS.has(kind)) return 0 + if (kind === 'tree') return 2 + return 1 +} + /** - * The editor canvas: a full-size SVG with a single world that pan/zoom/pinch - * transforms. Renders a 1 m grid that fades in as you zoom, the garden boundary, - * and its objects. Interactions beyond viewport + basic select (move/resize/ - * rotate, palette) land in #11; this is the foundation, driven by mock data. + * The editor canvas. Pan/zoom/pinch (from useViewport), tap-to-place from the + * armed palette kind, click to select, and — for the selected object — + * move/resize/rotate via SelectionOverlay. The object being dragged is rendered + * from liveObject for instant feedback; the PATCH fires on release. */ -export function GardenCanvas({ garden, objects }: { garden: EditorGarden; objects: EditorObject[] }) { +export function GardenCanvas({ + garden, + objects, + focusId, +}: { + garden: EditorGarden + objects: EditorObject[] + focusId?: number +}) { const svgRef = useRef(null) const containerRef = useRef(null) const [size, setSize] = useState({ w: 0, h: 0 }) @@ -26,39 +44,74 @@ export function GardenCanvas({ garden, objects }: { garden: EditorGarden; object const viewport = useEditorStore((s) => s.viewport) const selectedId = useEditorStore((s) => s.selectedId) const select = useEditorStore((s) => s.select) + const liveObject = useEditorStore((s) => s.liveObject) const { fitToRect } = useViewport(svgRef) + const create = useCreateObject(garden.id) - const gardenRect = useMemo( + const gardenRect: Rect = useMemo( () => ({ x: 0, y: 0, w: garden.widthCm, h: garden.heightCm }), [garden.widthCm, garden.heightCm], ) - // Track the container's pixel size for fit math. useEffect(() => { const el = containerRef.current if (!el) return - const ro = new ResizeObserver(([entry]) => { - setSize({ w: entry.contentRect.width, h: entry.contentRect.height }) - }) + const ro = new ResizeObserver(([entry]) => setSize({ w: entry.contentRect.width, h: entry.contentRect.height })) ro.observe(el) return () => ro.disconnect() }, []) - // Frame the garden once the canvas size is known, and again if we switch to a - // different garden. useEffect(() => { const usable = size.w > 0 && size.h > 0 && garden.widthCm > 0 && garden.heightCm > 0 if (usable && fittedForRef.current !== garden.id) { fittedForRef.current = garden.id - fitToRect(gardenRect, size) + // ?focus= frames that object (groundwork for #15's focus mode); + // otherwise frame the whole garden. + const focus = focusId != null ? objects.find((o) => o.id === focusId) : undefined + const target: Rect = focus + ? { x: focus.xCm - focus.widthCm / 2, y: focus.yCm - focus.heightCm / 2, w: focus.widthCm, h: focus.heightCm } + : gardenRect + fitToRect(target, size) } - }, [size, garden.id, garden.widthCm, garden.heightCm, gardenRect, fitToRect]) + }, [size, garden.id, garden.widthCm, garden.heightCm, gardenRect, fitToRect, focusId, objects]) - // Grid fades in as cells grow from GRID_MIN_CELL_PX to 2× that. 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 ordered = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects]) + // Merge the in-flight live geometry over the server objects, then z-order. + const rendered = useMemo(() => { + const merged = liveObject ? objects.map((o) => (o.id === liveObject.id ? liveObject : o)) : objects + return [...merged].sort((a, b) => a.zIndex - b.zIndex) + }, [objects, liveObject]) + const selected = rendered.find((o) => o.id === selectedId) ?? null + + // A pointerdown reaching the svg is empty space: place the armed kind there, or + // deselect. (Objects/handles stopPropagation.) + function onCanvasPointerDown(e: ReactPointerEvent) { + const armed = useEditorStore.getState().armedKind + if (!armed) { + select(null) + return + } + const def = kindDef(armed) + useEditorStore.getState().setArmedKind(null) + if (!def) return + const rect = svgRef.current?.getBoundingClientRect() + if (!rect) return + const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport) + create.mutate( + { + kind: def.kind, + shape: def.shape, + xCm: world.x, + yCm: world.y, + widthCm: def.widthCm, + heightCm: def.heightCm, + zIndex: defaultZForKind(def.kind), + }, + { onSuccess: (o) => select(o.id) }, + ) + } return (
@@ -66,59 +119,40 @@ export function GardenCanvas({ garden, objects }: { garden: EditorGarden; object ref={svgRef} className="h-full w-full select-none" style={{ touchAction: 'none' }} - // Any pointerdown reaching the svg is empty space (objects stopPropagation), - // so it deselects. - onPointerDown={() => select(null)} + onPointerDown={onCanvasPointerDown} > {gridOpacity > 0.005 && ( <> - + )} - {/* Garden boundary. */} - + - {ordered.map((o) => ( + {rendered.map((o) => ( ))} + + {selected && } - {/* Overlay: zoom readout + fit control. */}
{viewport.scale.toFixed(2)} px/cm
- +
) } diff --git a/web/src/editor/Inspector.tsx b/web/src/editor/Inspector.tsx new file mode 100644 index 0000000..f1bf99b --- /dev/null +++ b/web/src/editor/Inspector.tsx @@ -0,0 +1,209 @@ +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, type UnitPref } from '@/lib/units' +import { kindDef } from './kinds' +import { useEditorStore } from './store' +import type { EditorObject } from './types' + +/** + * 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 [confirmingDelete, setConfirmingDelete] = useState(false) + const rootRef = useRef(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))) + }, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, unit]) + + const patch = (fields: Partial>) => + update.mutate({ id: object.id, version: object.version, ...fields }) + + const commitDim = (raw: string, current: number, apply: (cm: number) => void) => { + const v = parseFloat(raw) + if (!Number.isFinite(v)) return + const cm = cmFromDisplay(v, unit) + if (cm !== current) apply(cm) + } + + const u = dimensionUnitLabel(unit) + + return ( +
+
+

{kindDef(object.kind)?.label ?? object.kind}

+ +
+ + setName(e.target.value)} + onBlur={() => name !== object.name && patch({ name })} + /> + +
+ setWidth(e.target.value)} + onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }))} + /> + setHeight(e.target.value)} + onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }))} + /> + setX(e.target.value)} + onBlur={() => commitDim(x, object.xCm, (cm) => patch({ xCm: cm }))} + /> + setY(e.target.value)} + onBlur={() => commitDim(y, object.yCm, (cm) => patch({ yCm: cm }))} + /> +
+ + setRotation(e.target.value)} + onBlur={() => { + const v = parseFloat(rotation) + if (Number.isFinite(v) && v !== object.rotationDeg) patch({ rotationDeg: v }) + }} + /> + +
+
+ + ) => patch({ color: e.target.value })} + className="h-9 w-14 cursor-pointer rounded-md border border-border bg-surface" + /> +
+ {object.color && ( + + )} +
+ + + +