Plops editor: focus mode, place/move/resize, semantic zoom (#15)
Build image / build-and-push (push) Successful in 14s
Build image / build-and-push (push) Successful in 14s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #34.
This commit is contained in:
+121
-47
@@ -1,9 +1,14 @@
|
||||
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 { screenToWorld, worldToLocal, type Rect, type Size } from '@/lib/geometry'
|
||||
import { useCreateObject, useCreatePlanting } from '@/lib/objects'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import { ObjectShape } from './ObjectShape'
|
||||
import { PlopLayer } from './PlopLayer'
|
||||
import { PlopOverlay } from './PlopOverlay'
|
||||
import { SelectionOverlay } from './SelectionOverlay'
|
||||
import { kindDef } from './kinds'
|
||||
import { DIMMED_OPACITY, objectTransform } from './shared'
|
||||
import { useEditorStore } from './store'
|
||||
import { useViewport } from './useViewport'
|
||||
import type { EditorGarden, EditorObject } from './types'
|
||||
@@ -12,33 +17,48 @@ const GRID_CM = 100 // 1 m grid
|
||||
const GRID_MIN_CELL_PX = 6
|
||||
const GRID_MAX_OPACITY = 0.18
|
||||
|
||||
/** The world-space bounds rect of an object (center + size → top-left rect). */
|
||||
function objectRect(o: EditorObject): Rect {
|
||||
return { x: o.xCm - o.widthCm / 2, y: o.yCm - o.heightCm / 2, w: o.widthCm, h: o.heightCm }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The editor canvas. Pan/zoom/pinch, tap-to-place objects, select+move/resize/
|
||||
* rotate an object, and — the #15 core — focus into a plantable object to place,
|
||||
* move, resize and remove plops with semantic-zoom rendering. The object/plop
|
||||
* being dragged renders from liveObject/livePlanting for instant feedback; the
|
||||
* PATCH fires on release.
|
||||
*/
|
||||
export function GardenCanvas({
|
||||
garden,
|
||||
objects,
|
||||
focusId,
|
||||
plantings,
|
||||
plantsById,
|
||||
}: {
|
||||
garden: EditorGarden
|
||||
objects: EditorObject[]
|
||||
focusId?: number
|
||||
plantings: EditorPlanting[]
|
||||
plantsById: Map<number, Plant>
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [size, setSize] = useState<Size>({ w: 0, h: 0 })
|
||||
const fittedForRef = useRef<number | null>(null)
|
||||
const fitKeyRef = useRef<string | null>(null)
|
||||
const gridId = 'garden-grid-' + useId().replace(/:/g, '')
|
||||
|
||||
const viewport = useEditorStore((s) => s.viewport)
|
||||
const selectedId = useEditorStore((s) => s.selectedId)
|
||||
const select = useEditorStore((s) => s.select)
|
||||
const selectedPlantingId = useEditorStore((s) => s.selectedPlantingId)
|
||||
const selectPlanting = useEditorStore((s) => s.selectPlanting)
|
||||
const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
|
||||
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||
const liveObject = useEditorStore((s) => s.liveObject)
|
||||
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||
const { fitToRect } = useViewport(svgRef)
|
||||
const create = useCreateObject(garden.id)
|
||||
const createObject = useCreateObject(garden.id)
|
||||
const createPlanting = useCreatePlanting(garden.id)
|
||||
|
||||
const gardenRect: Rect = useMemo(
|
||||
() => ({ x: 0, y: 0, w: garden.widthCm, h: garden.heightCm }),
|
||||
@@ -53,61 +73,83 @@ export function GardenCanvas({
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// Single fit mechanism: frame the focused object, else the whole garden, and
|
||||
// animate whenever that target (or the garden) changes. The key guard stops a
|
||||
// refit on unrelated re-renders (a plop add, a bed drag).
|
||||
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
|
||||
// ?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, focusId, objects])
|
||||
if (size.w === 0 || size.h === 0 || garden.widthCm === 0 || garden.heightCm === 0) return
|
||||
const key = `${garden.id}:${focusedObjectId}`
|
||||
if (fitKeyRef.current === key) return
|
||||
const target = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) : undefined
|
||||
if (focusedObjectId != null && !target) return // object not loaded yet; try again when it is
|
||||
fitKeyRef.current = key
|
||||
fitToRect(target ? objectRect(target) : gardenRect, size)
|
||||
}, [size, garden.id, garden.widthCm, garden.heightCm, focusedObjectId, objects, gardenRect, fitToRect])
|
||||
|
||||
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))
|
||||
|
||||
// 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
|
||||
// Merge the in-flight live plop over the active list, same as liveObject.
|
||||
const renderedPlops = useMemo(
|
||||
() => (livePlanting ? plantings.map((p) => (p.id === livePlanting.id ? livePlanting : p)) : plantings),
|
||||
[plantings, livePlanting],
|
||||
)
|
||||
const selectedObject = rendered.find((o) => o.id === selectedId) ?? null
|
||||
const selectedPlop = renderedPlops.find((p) => p.id === selectedPlantingId) ?? null
|
||||
const selectedPlopObject = selectedPlop ? rendered.find((o) => o.id === selectedPlop.objectId) ?? null : null
|
||||
const focusedObject = focusedObjectId != null ? rendered.find((o) => o.id === focusedObjectId) ?? null : null
|
||||
|
||||
// A pointerdown reaching the svg is empty space: place the armed kind there, or
|
||||
// deselect. (Objects/handles stopPropagation.)
|
||||
// A pointerdown reaching the svg is empty space: place the armed object kind,
|
||||
// or exit focus mode, or just deselect.
|
||||
function onCanvasPointerDown(e: ReactPointerEvent) {
|
||||
const armed = useEditorStore.getState().armedKind
|
||||
if (!armed) {
|
||||
select(null)
|
||||
if (armed) {
|
||||
useEditorStore.getState().setArmedKind(null)
|
||||
const def = kindDef(armed)
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
if (!def || !rect) return
|
||||
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
|
||||
createObject.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
|
||||
}
|
||||
const def = kindDef(armed)
|
||||
useEditorStore.getState().setArmedKind(null)
|
||||
if (!def) return
|
||||
// Empty-space tap while focused exits focus (and any placement); otherwise
|
||||
// just clears the selection.
|
||||
if (focusedObjectId != null) {
|
||||
setFocusedObject(null)
|
||||
useEditorStore.getState().setArmedPlant(null)
|
||||
}
|
||||
select(null)
|
||||
selectPlanting(null)
|
||||
}
|
||||
|
||||
// Drop a plop where the user taps inside the focused object (placement stays
|
||||
// armed for repeat-placement until Escape / Done).
|
||||
function onPlace(e: ReactPointerEvent) {
|
||||
e.stopPropagation()
|
||||
if (!focusedObject || !armedPlant || !focusedObject.plantable) 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) },
|
||||
)
|
||||
const local = worldToLocal(world, { x: focusedObject.xCm, y: focusedObject.yCm }, focusedObject.rotationDeg)
|
||||
const x = Math.max(-focusedObject.widthCm / 2, Math.min(focusedObject.widthCm / 2, local.x))
|
||||
const y = Math.max(-focusedObject.heightCm / 2, Math.min(focusedObject.heightCm / 2, local.y))
|
||||
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
|
||||
// Stay armed for repeat-placement; don't select (the placement sheet covers
|
||||
// the object, so a selection would be hidden until placement ends anyway).
|
||||
createPlanting.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, xCm: x, yCm: y, radiusCm })
|
||||
}
|
||||
|
||||
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
|
||||
const halfFH = focusedObject ? focusedObject.heightCm / 2 : 0
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
|
||||
<svg
|
||||
@@ -131,10 +173,42 @@ export function GardenCanvas({
|
||||
<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" />
|
||||
|
||||
{rendered.map((o) => (
|
||||
<ObjectShape key={o.id} object={o} selected={o.id === selectedId} onSelect={select} />
|
||||
<g key={o.id} opacity={focusedObjectId != null && focusedObjectId !== o.id ? DIMMED_OPACITY : 1}>
|
||||
<ObjectShape object={o} selected={o.id === selectedId} onSelect={select} />
|
||||
</g>
|
||||
))}
|
||||
|
||||
{selected && <SelectionOverlay object={selected} gardenId={garden.id} svgRef={svgRef} />}
|
||||
<PlopLayer
|
||||
objects={rendered}
|
||||
plantings={renderedPlops}
|
||||
plantsById={plantsById}
|
||||
scale={viewport.scale}
|
||||
focusedObjectId={focusedObjectId}
|
||||
selectedPlantingId={selectedPlantingId}
|
||||
onSelectPlop={selectPlanting}
|
||||
/>
|
||||
|
||||
{selectedObject && <SelectionOverlay object={selectedObject} gardenId={garden.id} svgRef={svgRef} />}
|
||||
{selectedPlop && selectedPlopObject && (
|
||||
<PlopOverlay plop={selectedPlop} object={selectedPlopObject} gardenId={garden.id} svgRef={svgRef} />
|
||||
)}
|
||||
|
||||
{/* Placement capture: a transparent sheet over the focused object while a
|
||||
plant is armed, so taps drop plops instead of selecting the object. */}
|
||||
{focusedObject && armedPlant && focusedObject.plantable && (
|
||||
<g transform={objectTransform(focusedObject)}>
|
||||
<rect
|
||||
x={-halfFW}
|
||||
y={-halfFH}
|
||||
width={halfFW * 2}
|
||||
height={halfFH * 2}
|
||||
fill="transparent"
|
||||
pointerEvents="all"
|
||||
style={{ cursor: 'crosshair' }}
|
||||
onPointerDown={onPlace}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user