+ )
+}
+
+/** 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 (
+ )
+}
diff --git a/web/src/editor/Palette.tsx b/web/src/editor/Palette.tsx
new file mode 100644
index 0000000..5183b26
--- /dev/null
+++ b/web/src/editor/Palette.tsx
@@ -0,0 +1,49 @@
+import { cn } from '@/lib/cn'
+import { OBJECT_KINDS, kindDef } from './kinds'
+import { useEditorStore } from './store'
+
+/**
+ * The object-kind palette. Tap a kind to arm it, then tap the canvas to place
+ * (works on desktop and touch). Tapping the armed kind again disarms it.
+ */
+export function Palette() {
+ const armedKind = useEditorStore((s) => s.armedKind)
+ const setArmedKind = useEditorStore((s) => s.setArmedKind)
+ const select = useEditorStore((s) => s.select)
+
+ return (
+