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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(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<Omit<EditorObject, 'id' | 'version'>>) =>
|
||||
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 (
|
||||
<div ref={rootRef} className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-fg">{kindDef(object.kind)?.label ?? object.kind}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(null)}
|
||||
className="rounded px-1.5 text-sm text-muted hover:text-fg"
|
||||
aria-label="Close inspector"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => name !== object.name && patch({ name })}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
label={`Width (${u})`}
|
||||
name="width"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }), true)}
|
||||
/>
|
||||
<TextField
|
||||
label={`Height (${u})`}
|
||||
name="height"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)}
|
||||
/>
|
||||
<TextField
|
||||
label={`X (${u})`}
|
||||
name="x"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
value={x}
|
||||
onChange={(e) => setX(e.target.value)}
|
||||
onBlur={() => commitDim(x, object.xCm, (cm) => patch({ xCm: cm }))}
|
||||
/>
|
||||
<TextField
|
||||
label={`Y (${u})`}
|
||||
name="y"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
value={y}
|
||||
onChange={(e) => setY(e.target.value)}
|
||||
onBlur={() => commitDim(y, object.yCm, (cm) => patch({ yCm: cm }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label="Rotation (°)"
|
||||
name="rotation"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
value={rotation}
|
||||
onChange={(e) => setRotation(e.target.value)}
|
||||
onBlur={() => {
|
||||
const v = parseFloat(rotation)
|
||||
if (Number.isFinite(v) && v !== object.rotationDeg) patch({ rotationDeg: v })
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="obj-color" className="text-sm font-medium text-fg">
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
id="obj-color"
|
||||
type="color"
|
||||
value={color}
|
||||
// onChange fires continuously while dragging in the native picker;
|
||||
// track it locally for the live swatch and commit one PATCH on blur.
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => 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"
|
||||
/>
|
||||
</div>
|
||||
{object.color && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1.5 text-xs"
|
||||
onClick={() => {
|
||||
setColor(DEFAULT_COLOR)
|
||||
patch({ color: null })
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-fg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={object.plantable}
|
||||
onChange={(e) => patch({ plantable: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
Plantable
|
||||
</label>
|
||||
|
||||
<TextArea
|
||||
label="Notes"
|
||||
name="notes"
|
||||
rows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
onBlur={() => notes !== object.notes && patch({ notes })}
|
||||
/>
|
||||
|
||||
{confirmingDelete ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="danger"
|
||||
className="flex-1"
|
||||
disabled={del.isPending}
|
||||
onClick={() => {
|
||||
select(null)
|
||||
del.mutate(object.id)
|
||||
}}
|
||||
>
|
||||
Confirm delete
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmingDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="ghost" className="text-red-600 dark:text-red-400" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete object
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-1.5 md:flex-col">
|
||||
{OBJECT_KINDS.map((k) => {
|
||||
const active = armedKind === k.kind
|
||||
return (
|
||||
<button
|
||||
key={k.kind}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
select(null)
|
||||
setArmedKind(active ? null : k.kind)
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm font-medium outline-none transition-colors',
|
||||
'focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||
active
|
||||
? 'border-accent bg-accent/10 text-accent-strong'
|
||||
: 'border-border bg-surface text-fg hover:bg-border/50',
|
||||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span aria-hidden className="text-base leading-none">
|
||||
{k.icon}
|
||||
</span>
|
||||
<span>{k.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{armedKind && (
|
||||
<p className="text-xs text-muted">Tap the canvas to place a {kindDef(armedKind)?.label ?? 'object'}.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
|
||||
import { localToWorld, screenToWorld, worldToLocal, type Point } from '@/lib/geometry'
|
||||
import { useUpdateObject } from '@/lib/objects'
|
||||
import { useEditorStore } from './store'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
const HANDLE_PX = 12 // on-screen size of the resize handles
|
||||
const ROTATE_OFFSET_PX = 28 // distance of the rotate knob above the object
|
||||
const MIN_OBJ_CM = 1 // smallest allowed dimension
|
||||
const ROTATE_SNAP_DEG = 15
|
||||
const SELECT_COLOR = '#2f7a3e'
|
||||
|
||||
const corners: [number, number][] = [
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
]
|
||||
|
||||
/**
|
||||
* Handles for the selected object: a transparent body to move, four corner
|
||||
* handles to resize (opposite corner stays put, honoring rotation via the
|
||||
* object-local frame), and a knob to rotate (snaps to 15°, free with Shift).
|
||||
* Each gesture updates liveObject for instant feedback and fires exactly one
|
||||
* PATCH on release.
|
||||
*/
|
||||
export function SelectionOverlay({
|
||||
object,
|
||||
gardenId,
|
||||
svgRef,
|
||||
}: {
|
||||
object: EditorObject
|
||||
gardenId: number
|
||||
svgRef: RefObject<SVGSVGElement | null>
|
||||
}) {
|
||||
const setLiveObject = useEditorStore((s) => s.setLiveObject)
|
||||
const setObjectDragging = useEditorStore((s) => s.setObjectDragging)
|
||||
const scale = useEditorStore((s) => s.viewport.scale)
|
||||
const update = useUpdateObject(gardenId)
|
||||
|
||||
// Detach for an in-flight gesture. If the overlay unmounts mid-drag (the
|
||||
// object is deleted or deselected), this runs on unmount so window listeners
|
||||
// don't leak and objectDragging can't stay stuck true (which would freeze pan).
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
useEffect(
|
||||
() => () => {
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = null
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const halfW = object.widthCm / 2
|
||||
const halfH = object.heightCm / 2
|
||||
const handleCm = HANDLE_PX / scale
|
||||
const rotateOffsetCm = ROTATE_OFFSET_PX / scale
|
||||
|
||||
// The svg's screen rect doesn't move during a drag, so snapshot it once at
|
||||
// gesture start rather than calling getBoundingClientRect (a layout reflow) on
|
||||
// every pointermove.
|
||||
const makePointerWorld = () => {
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
return (e: { clientX: number; clientY: number }): Point => {
|
||||
const vp = useEditorStore.getState().viewport
|
||||
const local = rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : { x: e.clientX, y: e.clientY }
|
||||
return screenToWorld(local, vp)
|
||||
}
|
||||
}
|
||||
|
||||
// Common gesture scaffolding: mark dragging, track the pointer on window, and
|
||||
// on release fire one PATCH built from the final liveObject. onMove receives
|
||||
// the live pointer event so modifier keys (Shift) reflect their current state.
|
||||
const begin = (
|
||||
e: ReactPointerEvent,
|
||||
onMove: (e: PointerEvent) => EditorObject,
|
||||
fields: (final: EditorObject) => Parameters<typeof update.mutate>[0],
|
||||
) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
setObjectDragging(true)
|
||||
const move = (ev: PointerEvent) => setLiveObject(onMove(ev))
|
||||
const detach = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', finish)
|
||||
window.removeEventListener('pointercancel', finish)
|
||||
}
|
||||
const finish = () => {
|
||||
detach()
|
||||
cleanupRef.current = null
|
||||
const final = useEditorStore.getState().liveObject
|
||||
setObjectDragging(false)
|
||||
setLiveObject(null)
|
||||
if (final) update.mutate(fields(final))
|
||||
}
|
||||
// On unmount mid-gesture: detach and reset, but don't fire a PATCH.
|
||||
cleanupRef.current = () => {
|
||||
detach()
|
||||
setObjectDragging(false)
|
||||
setLiveObject(null)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', finish)
|
||||
window.addEventListener('pointercancel', finish)
|
||||
}
|
||||
|
||||
const base = { ...object }
|
||||
const center0: Point = { x: base.xCm, y: base.yCm }
|
||||
|
||||
const startMove = (e: ReactPointerEvent) => {
|
||||
const pointerWorld = makePointerWorld()
|
||||
const start = pointerWorld(e.nativeEvent)
|
||||
begin(
|
||||
e,
|
||||
(ev) => {
|
||||
const world = pointerWorld(ev)
|
||||
return { ...base, xCm: base.xCm + (world.x - start.x), yCm: base.yCm + (world.y - start.y) }
|
||||
},
|
||||
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
|
||||
)
|
||||
}
|
||||
|
||||
const startResize = (e: ReactPointerEvent, sx: number, sy: number) => {
|
||||
// The corner opposite the dragged one stays fixed (in the object's local
|
||||
// frame, which is anchored at the original center).
|
||||
const oppositeLocal: Point = { x: -sx * halfW, y: -sy * halfH }
|
||||
const pointerWorld = makePointerWorld()
|
||||
begin(
|
||||
e,
|
||||
(ev) => {
|
||||
const world = pointerWorld(ev)
|
||||
const p = worldToLocal(world, center0, base.rotationDeg)
|
||||
const newW = Math.max(MIN_OBJ_CM, Math.abs(p.x - oppositeLocal.x))
|
||||
const newH = Math.max(MIN_OBJ_CM, Math.abs(p.y - oppositeLocal.y))
|
||||
const draggedLocal: Point = { x: oppositeLocal.x + sx * newW, y: oppositeLocal.y + sy * newH }
|
||||
const newCenterLocal: Point = {
|
||||
x: (oppositeLocal.x + draggedLocal.x) / 2,
|
||||
y: (oppositeLocal.y + draggedLocal.y) / 2,
|
||||
}
|
||||
const c = localToWorld(newCenterLocal, center0, base.rotationDeg)
|
||||
return { ...base, xCm: c.x, yCm: c.y, widthCm: newW, heightCm: newH }
|
||||
},
|
||||
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm, widthCm: f.widthCm, heightCm: f.heightCm }),
|
||||
)
|
||||
}
|
||||
|
||||
const startRotate = (e: ReactPointerEvent) => {
|
||||
const pointerWorld = makePointerWorld()
|
||||
begin(
|
||||
e,
|
||||
(ev) => {
|
||||
const world = pointerWorld(ev)
|
||||
// +90° because the knob points up (local -y) at rotation 0.
|
||||
let deg = (Math.atan2(world.y - center0.y, world.x - center0.x) * 180) / Math.PI + 90
|
||||
// Read Shift live off the move event so toggling mid-drag switches
|
||||
// between snapped and free rotation.
|
||||
if (!ev.shiftKey) deg = Math.round(deg / ROTATE_SNAP_DEG) * ROTATE_SNAP_DEG
|
||||
deg = ((deg % 360) + 360) % 360
|
||||
return { ...base, rotationDeg: deg }
|
||||
},
|
||||
(f) => ({ id: base.id, version: base.version, rotationDeg: f.rotationDeg }),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<g transform={`translate(${object.xCm} ${object.yCm}) rotate(${object.rotationDeg})`}>
|
||||
{/* Transparent body: drag to move. */}
|
||||
{object.shape === 'circle' ? (
|
||||
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill="transparent" pointerEvents="all" style={{ cursor: 'move' }} onPointerDown={startMove} />
|
||||
) : (
|
||||
<rect x={-halfW} y={-halfH} width={object.widthCm} height={object.heightCm} fill="transparent" pointerEvents="all" style={{ cursor: 'move' }} onPointerDown={startMove} />
|
||||
)}
|
||||
|
||||
{/* Selection outline. */}
|
||||
{object.shape === 'circle' ? (
|
||||
<ellipse cx={0} cy={0} rx={halfW} ry={halfH} fill="none" stroke={SELECT_COLOR} strokeWidth={1.5} vectorEffect="non-scaling-stroke" pointerEvents="none" />
|
||||
) : (
|
||||
<rect x={-halfW} y={-halfH} width={object.widthCm} height={object.heightCm} fill="none" stroke={SELECT_COLOR} strokeWidth={1.5} vectorEffect="non-scaling-stroke" pointerEvents="none" />
|
||||
)}
|
||||
|
||||
{/* Rotate knob. */}
|
||||
<line x1={0} y1={-halfH} x2={0} y2={-halfH - rotateOffsetCm} stroke={SELECT_COLOR} strokeWidth={1} vectorEffect="non-scaling-stroke" pointerEvents="none" />
|
||||
<circle cx={0} cy={-halfH - rotateOffsetCm} r={handleCm * 0.7} fill={SELECT_COLOR} style={{ cursor: 'grab' }} onPointerDown={startRotate} />
|
||||
|
||||
{/* Resize corners. */}
|
||||
{corners.map(([sx, sy]) => (
|
||||
<rect
|
||||
key={`${sx},${sy}`}
|
||||
x={sx * halfW - handleCm / 2}
|
||||
y={sy * halfH - handleCm / 2}
|
||||
width={handleCm}
|
||||
height={handleCm}
|
||||
fill="#ffffff"
|
||||
stroke={SELECT_COLOR}
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ cursor: 'nwse-resize' }}
|
||||
onPointerDown={(e) => startResize(e, sx, sy)}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ObjectShapeKind } from './types'
|
||||
|
||||
// The seven placeable object kinds with their palette presentation and sensible
|
||||
// default sizes (cm), per the #11 brief.
|
||||
export interface KindDef {
|
||||
kind: string
|
||||
label: string
|
||||
icon: string
|
||||
shape: ObjectShapeKind
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
// Default z-index at placement. Paths/structures/in-ground sit under beds (0);
|
||||
// beds and containers sit at 1; trees float above (2). Explicit z still wins.
|
||||
defaultZ: number
|
||||
}
|
||||
|
||||
export const OBJECT_KINDS: KindDef[] = [
|
||||
{ kind: 'bed', label: 'Bed', icon: '▭', shape: 'rect', widthCm: 120, heightCm: 240, defaultZ: 1 },
|
||||
{ kind: 'grow_bag', label: 'Grow bag', icon: '🛍️', shape: 'circle', widthCm: 40, heightCm: 40, defaultZ: 1 },
|
||||
{ kind: 'container', label: 'Container', icon: '🪴', shape: 'circle', widthCm: 60, heightCm: 60, defaultZ: 1 },
|
||||
{ kind: 'in_ground', label: 'In-ground', icon: '🟫', shape: 'rect', widthCm: 200, heightCm: 200, defaultZ: 0 },
|
||||
{ kind: 'tree', label: 'Tree', icon: '🌳', shape: 'circle', widthCm: 300, heightCm: 300, defaultZ: 2 },
|
||||
{ kind: 'path', label: 'Path', icon: '🧱', shape: 'rect', widthCm: 100, heightCm: 300, defaultZ: 0 },
|
||||
{ kind: 'structure', label: 'Structure', icon: '🏠', shape: 'rect', widthCm: 200, heightCm: 200, defaultZ: 0 },
|
||||
]
|
||||
|
||||
export function kindDef(kind: string): KindDef | undefined {
|
||||
return OBJECT_KINDS.find((k) => k.kind === kind)
|
||||
}
|
||||
+28
-4
@@ -1,9 +1,10 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Viewport } from '@/lib/geometry'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
// Ephemeral editor state only (per DESIGN § State): the viewport, the current
|
||||
// selection, and which object is "focused" (zoomed into for plops, #15). All
|
||||
// server state stays in react-query. Selection interactions grow in #11.
|
||||
// selection, the object being live-edited mid-gesture, and the palette kind
|
||||
// armed for tap-to-place. All server state stays in react-query.
|
||||
|
||||
export const MIN_SCALE = 0.05 // px per cm — fully zoomed out
|
||||
export const MAX_SCALE = 20 // px per cm — fully zoomed in
|
||||
@@ -17,16 +18,39 @@ interface EditorState {
|
||||
|
||||
focusedObjectId: number | null
|
||||
setFocusedObject: (id: number | null) => void
|
||||
|
||||
// During a move/resize/rotate, the object's live geometry is held here so the
|
||||
// canvas renders it instantly; the PATCH fires only on gesture end.
|
||||
liveObject: EditorObject | null
|
||||
setLiveObject: (o: EditorObject | null) => void
|
||||
|
||||
// The palette kind armed for tap-to-place (mobile-friendly; also set while
|
||||
// dragging a kind from the palette). null when nothing is armed.
|
||||
armedKind: string | null
|
||||
setArmedKind: (kind: string | null) => void
|
||||
|
||||
// True while an object move/resize/rotate is in progress, so the viewport's
|
||||
// pan gesture stands down (both listen on the same svg).
|
||||
objectDragging: boolean
|
||||
setObjectDragging: (v: boolean) => void
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set) => ({
|
||||
viewport: { tx: 0, ty: 0, scale: 1 },
|
||||
setViewport: (next) =>
|
||||
set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
|
||||
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
|
||||
|
||||
selectedId: null,
|
||||
select: (id) => set({ selectedId: id }),
|
||||
|
||||
focusedObjectId: null,
|
||||
setFocusedObject: (id) => set({ focusedObjectId: id }),
|
||||
|
||||
liveObject: null,
|
||||
setLiveObject: (o) => set({ liveObject: o }),
|
||||
|
||||
armedKind: null,
|
||||
setArmedKind: (kind) => set({ armedKind: kind }),
|
||||
|
||||
objectDragging: false,
|
||||
setObjectDragging: (v) => set({ objectDragging: v }),
|
||||
}))
|
||||
|
||||
@@ -17,7 +17,9 @@ export interface EditorObject {
|
||||
rotationDeg: number
|
||||
zIndex: number
|
||||
color?: string | null
|
||||
notes: string
|
||||
plantable: boolean
|
||||
version: number
|
||||
}
|
||||
|
||||
/** The garden being edited: its bounds define the canvas extent. */
|
||||
|
||||
@@ -46,6 +46,8 @@ export function useViewport(svgRef: RefObject<SVGSVGElement | null>) {
|
||||
{
|
||||
onDragStart: cancelAnim,
|
||||
onDrag: ({ delta: [dx, dy], pinching, cancel }) => {
|
||||
// An object move/resize/rotate owns this drag; don't also pan.
|
||||
if (useEditorStore.getState().objectDragging) return
|
||||
if (pinching) {
|
||||
cancel()
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user