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..3d41450 --- /dev/null +++ b/web/src/components/ui/toast.tsx @@ -0,0 +1,65 @@ +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'), +} + +// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export. +function ToastItem({ item }: { item: Toast }) { + const dismiss = useToastStore((s) => s.dismiss) + useEffect(() => { + const t = setTimeout(() => dismiss(item.id), 4000) + return () => clearTimeout(t) + }, [item.id, dismiss]) + return ( +
+ {item.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..93f1a4d 100644 --- a/web/src/editor/GardenCanvas.tsx +++ b/web/src/editor/GardenCanvas.tsx @@ -1,22 +1,32 @@ -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 /** - * 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 +36,77 @@ 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]) + // 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]) + // 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( + () => (liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted), + [sorted, 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: def.defaultZ, + }, + { onSuccess: (o) => select(o.id) }, + ) + } return (
@@ -66,59 +114,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..7060e69 --- /dev/null +++ b/web/src/editor/Inspector.tsx @@ -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(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>) => + 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 ( +
+
+

{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 }), true)} + /> + setHeight(e.target.value)} + onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)} + /> + 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 }) + }} + /> + +
+
+ + ) => 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" + /> +
+ {object.color && ( + + )} +
+ + + +