Field editor: palette, select/move/resize/rotate, inspector, optimistic sync (#11) (#30)
Build image / build-and-push (push) Successful in 13s
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user