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
+78 -49
View File
@@ -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 <g> 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<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState<Size>({ 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=<id> 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 (
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
@@ -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}
>
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
{gridOpacity > 0.005 && (
<>
<defs>
<pattern id={gridId} width={GRID_CM} height={GRID_CM} patternUnits="userSpaceOnUse">
<path
d={`M ${GRID_CM} 0 L 0 0 0 ${GRID_CM}`}
fill="none"
stroke="#808080"
strokeOpacity={gridOpacity}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
<path d={`M ${GRID_CM} 0 L 0 0 0 ${GRID_CM}`} fill="none" stroke="#808080" strokeOpacity={gridOpacity} strokeWidth={1} vectorEffect="non-scaling-stroke" />
</pattern>
</defs>
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill={`url(#${gridId})`} />
</>
)}
{/* Garden boundary. */}
<rect
x={0}
y={0}
width={garden.widthCm}
height={garden.heightCm}
fill="none"
stroke="#3f8f4f"
strokeOpacity={0.7}
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill="none" stroke="#3f8f4f" strokeOpacity={0.7} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
{ordered.map((o) => (
{rendered.map((o) => (
<ObjectShape key={o.id} object={o} selected={o.id === selectedId} onSelect={select} />
))}
{selected && <SelectionOverlay object={selected} gardenId={garden.id} svgRef={svgRef} />}
</g>
</svg>
{/* Overlay: zoom readout + fit control. */}
<div className="pointer-events-none absolute left-3 top-3 rounded-md bg-surface/80 px-2 py-1 text-xs text-muted backdrop-blur">
{viewport.scale.toFixed(2)} px/cm
</div>
<Button
variant="ghost"
<button
type="button"
onClick={() => size.w > 0 && fitToRect(gardenRect, size)}
className="absolute bottom-3 right-3 bg-surface/90 py-1.5 shadow-sm backdrop-blur"
className="absolute bottom-3 right-3 rounded-md border border-border bg-surface/90 px-3 py-1.5 text-sm font-medium text-fg shadow-sm outline-none backdrop-blur transition-colors hover:bg-border/50 focus-visible:ring-2 focus-visible:ring-accent/40"
>
Fit
</Button>
</button>
</div>
)
}