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:
@@ -1,12 +1,17 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { GardenCanvas } from '@/editor/GardenCanvas'
|
||||
import { Inspector } from '@/editor/Inspector'
|
||||
import { PlopInspector } from '@/editor/PlopInspector'
|
||||
import { PlantPicker } from '@/editor/PlantPicker'
|
||||
import { Palette } from '@/editor/Palette'
|
||||
import { kindDef } from '@/editor/kinds'
|
||||
import { useEditorStore } from '@/editor/store'
|
||||
import type { EditorGarden } from '@/editor/types'
|
||||
import { toEditorObject, useGardenFull } from '@/lib/objects'
|
||||
import { toEditorObject, useGardenFull, useUpdatePlanting } from '@/lib/objects'
|
||||
import { toEditorPlanting } from '@/lib/plantings'
|
||||
|
||||
const routeApi = getRouteApi('/gardens/$gardenId')
|
||||
|
||||
@@ -14,29 +19,90 @@ export function GardenEditorPage() {
|
||||
const { gardenId } = routeApi.useParams()
|
||||
const gid = Number(gardenId)
|
||||
const { focus } = routeApi.useSearch()
|
||||
const navigate = routeApi.useNavigate()
|
||||
const full = useGardenFull(gid)
|
||||
|
||||
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 setArmedPlant = useEditorStore((s) => s.setArmedPlant)
|
||||
const setArmedKind = useEditorStore((s) => s.setArmedKind)
|
||||
const setLiveObject = useEditorStore((s) => s.setLiveObject)
|
||||
const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
|
||||
|
||||
const updatePlanting = useUpdatePlanting(gid)
|
||||
|
||||
// Which plant-picker flow is open: 'place' arms a plant for repeat placement;
|
||||
// 'change' swaps the selected plop's plant.
|
||||
const [picker, setPicker] = useState<'place' | 'change' | null>(null)
|
||||
|
||||
// Map the server rows to EditorObjects once per cache change. Rebuilding this
|
||||
// every render (e.g. on each viewport/selection update) would hand ObjectShape
|
||||
// fresh identities and defeat its memo, re-rendering every object.
|
||||
const serverObjects = full.data?.objects
|
||||
const objects = useMemo(() => serverObjects?.map(toEditorObject) ?? [], [serverObjects])
|
||||
const serverPlantings = full.data?.plantings
|
||||
const plantings = useMemo(() => serverPlantings?.map(toEditorPlanting) ?? [], [serverPlantings])
|
||||
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
|
||||
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
|
||||
|
||||
// Clear transient editor state on entering/switching gardens and on leaving.
|
||||
// Adopt the URL's focus and clear transient editor state on entering/switching
|
||||
// gardens (and on leaving). `focus` is intentionally read once here, not a dep,
|
||||
// so URL syncs below don't retrigger a full reset.
|
||||
useEffect(() => {
|
||||
const reset = () => {
|
||||
const clear = () => {
|
||||
select(null)
|
||||
selectPlanting(null)
|
||||
setArmedKind(null)
|
||||
setArmedPlant(null)
|
||||
setLiveObject(null)
|
||||
setLivePlanting(null)
|
||||
}
|
||||
reset()
|
||||
return reset
|
||||
}, [gid, select, setArmedKind, setLiveObject])
|
||||
clear()
|
||||
setFocusedObject(focus ?? null)
|
||||
return () => {
|
||||
clear()
|
||||
setFocusedObject(null)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gid])
|
||||
|
||||
// Mirror focusedObjectId → ?focus so focus mode is deep-linkable and survives
|
||||
// reload. Declared after the reset effect so the initial adopt wins.
|
||||
useEffect(() => {
|
||||
navigate({ search: (prev) => ({ ...prev, focus: focusedObjectId ?? undefined }), replace: true })
|
||||
}, [focusedObjectId, navigate])
|
||||
|
||||
// If the focused object vanished — deleted, or a stale ?focus id on load — leave
|
||||
// focus mode so the canvas isn't stuck dimmed with no way out.
|
||||
useEffect(() => {
|
||||
if (focusedObjectId != null && full.data && !objects.some((o) => o.id === focusedObjectId)) {
|
||||
setFocusedObject(null)
|
||||
}
|
||||
}, [focusedObjectId, objects, full.data, setFocusedObject])
|
||||
|
||||
const exitFocus = () => {
|
||||
setFocusedObject(null)
|
||||
setArmedPlant(null)
|
||||
select(null)
|
||||
selectPlanting(null)
|
||||
}
|
||||
|
||||
// Escape peels back one layer: stop placing → deselect plop → exit focus → deselect.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return
|
||||
const s = useEditorStore.getState()
|
||||
if (s.armedPlant) s.setArmedPlant(null)
|
||||
else if (s.selectedPlantingId != null) s.selectPlanting(null)
|
||||
else if (s.focusedObjectId != null) exitFocus()
|
||||
else if (s.selectedId != null) s.select(null)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
if (full.isPending) return <p className="p-6 text-sm text-muted">Loading garden…</p>
|
||||
if (full.isError)
|
||||
@@ -54,7 +120,22 @@ export function GardenEditorPage() {
|
||||
heightCm: g.heightCm,
|
||||
unitPref: g.unitPref,
|
||||
}
|
||||
const selected = objects.find((o) => o.id === selectedId) ?? null
|
||||
|
||||
const selectedObject = objects.find((o) => o.id === selectedId) ?? null
|
||||
const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null
|
||||
// Committed selected plop; PlopInspector applies live drag geometry itself.
|
||||
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
|
||||
|
||||
function onPickPlant(plantId: number) {
|
||||
const plant = plantsById.get(plantId)
|
||||
if (!plant) return
|
||||
if (picker === 'change' && selectedPlop) {
|
||||
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
|
||||
} else {
|
||||
setArmedPlant(plant) // 'place' mode: arm for repeat placement
|
||||
}
|
||||
setPicker(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
|
||||
@@ -62,18 +143,69 @@ export function GardenEditorPage() {
|
||||
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||
{garden.name}
|
||||
</h1>
|
||||
<Palette />
|
||||
{focusedObjectId == null && <Palette />}
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<GardenCanvas garden={garden} objects={objects} focusId={focus} />
|
||||
{focusedObject && (
|
||||
<div className="absolute left-2 top-2 z-20 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur">
|
||||
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
|
||||
← {garden.name}
|
||||
</button>
|
||||
<span className="max-w-[8rem] truncate text-muted">
|
||||
{focusedObject.name || kindDef(focusedObject.kind)?.label || 'Object'}
|
||||
</span>
|
||||
{!focusedObject.plantable ? (
|
||||
<span className="text-xs text-muted">Not plantable</span>
|
||||
) : armedPlant ? (
|
||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setArmedPlant(null)}>
|
||||
Placing {armedPlant.icon} — Done
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="px-2 py-1 text-xs" onClick={() => setPicker('place')}>
|
||||
+ Add plant
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} />
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
{(selectedObject || selectedPlop) && (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 max-h-[65vh] overflow-y-auto rounded-t-xl border-t border-border bg-surface p-4 shadow-lg md:static md:max-h-none md:w-72 md:shrink-0 md:rounded-xl md:border md:p-4 md:shadow-sm">
|
||||
<Inspector key={selected.id} object={selected} gardenId={gid} unit={garden.unitPref} />
|
||||
{selectedObject && (
|
||||
<Inspector
|
||||
key={`obj-${selectedObject.id}`}
|
||||
object={selectedObject}
|
||||
gardenId={gid}
|
||||
unit={garden.unitPref}
|
||||
onFocus={() => {
|
||||
setFocusedObject(selectedObject.id)
|
||||
select(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selectedPlop && (
|
||||
<PlopInspector
|
||||
key={`plop-${selectedPlop.id}`}
|
||||
plop={selectedPlop}
|
||||
plant={plantsById.get(selectedPlop.plantId)}
|
||||
gardenId={gid}
|
||||
unit={garden.unitPref}
|
||||
onChangePlant={() => setPicker('change')}
|
||||
onClose={() => selectPlanting(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{picker && (
|
||||
<PlantPicker
|
||||
unit={garden.unitPref}
|
||||
onClose={() => setPicker(null)}
|
||||
onSelect={(p) => onPickPlant(p.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user