Address Gadfly review on #11 field editor
Build image / build-and-push (push) Successful in 8s
Build image / build-and-push (push) Successful in 8s
Fix the real bugs surfaced by the PR #30 adversarial review (duplicates across models collapsed): - Optimistic PATCH now bumps the cached row version so a second edit started before the first PATCH resolves sends the next version instead of re-sending a stale one and self-conflicting (spurious 409 + "updated elsewhere" toast + lost edit). Drop the per-PATCH onSettled refetch: onSuccess already writes the authoritative row and onError always rolls back, so the invalidate was a request storm during drags and a refetch race, with no drift benefit. - Color picker committed a PATCH on every onChange (continuous during a native picker drag). Track the value locally and commit one PATCH on blur. - Rotate read shiftKey from the pointerdown event, so toggling Shift mid-drag didn't switch snap/free. Pass the live pointermove event into the gesture callback and read shiftKey from it. - Window pointer listeners had no unmount cleanup: deleting/deselecting an object mid-drag leaked listeners and could leave objectDragging stuck true (freezing pan). Track the active gesture's cleanup and run it on unmount. - commitDim accepted 0/negative width/height (invalid geometry flashed into the optimistic cache before rollback) and fired a spurious 1cm PATCH on blur-without-edit from cm<->ft rounding drift. Guard width/height against the server minimum and compare at display precision first. Perf: - Split the z-order sort from the live-merge so a pointermove is an O(n) map, not an O(n log n) re-sort. - Memoize the toEditorObject map so object identities are stable across renders and ObjectShape's memo holds. - Snapshot the svg rect once per gesture instead of calling getBoundingClientRect (a layout reflow) on every pointermove. Maintainability: - Move per-kind default z-index into kinds.ts (single source of truth). - Rename the ToastItem `toast` param so it stops shadowing the module's `toast` export. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -29,23 +29,24 @@ export const toast = {
|
|||||||
error: (m: string) => useToastStore.getState().push(m, 'error'),
|
error: (m: string) => useToastStore.getState().push(m, 'error'),
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToastItem({ toast }: { toast: Toast }) {
|
// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
|
||||||
|
function ToastItem({ item }: { item: Toast }) {
|
||||||
const dismiss = useToastStore((s) => s.dismiss)
|
const dismiss = useToastStore((s) => s.dismiss)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => dismiss(toast.id), 4000)
|
const t = setTimeout(() => dismiss(item.id), 4000)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [toast.id, dismiss])
|
}, [item.id, dismiss])
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role={toast.tone === 'error' ? 'alert' : 'status'}
|
role={item.tone === 'error' ? 'alert' : 'status'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-auto rounded-md border px-3 py-2 text-sm shadow-md',
|
'pointer-events-auto rounded-md border px-3 py-2 text-sm shadow-md',
|
||||||
toast.tone === 'error'
|
item.tone === 'error'
|
||||||
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
|
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||||
: 'border-border bg-surface text-fg',
|
: 'border-border bg-surface text-fg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{toast.message}
|
{item.message}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -57,7 +58,7 @@ export function Toaster() {
|
|||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed bottom-4 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2">
|
<div className="pointer-events-none fixed bottom-4 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2">
|
||||||
{toasts.map((t) => (
|
{toasts.map((t) => (
|
||||||
<ToastItem key={t.id} toast={t} />
|
<ToastItem key={t.id} item={t} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,14 +12,6 @@ const GRID_CM = 100 // 1 m grid
|
|||||||
const GRID_MIN_CELL_PX = 6
|
const GRID_MIN_CELL_PX = 6
|
||||||
const GRID_MAX_OPACITY = 0.18
|
const GRID_MAX_OPACITY = 0.18
|
||||||
|
|
||||||
// Kinds that render beneath beds by default; explicit z_index still wins.
|
|
||||||
const UNDER_KINDS = new Set(['path', 'structure', 'in_ground'])
|
|
||||||
function defaultZForKind(kind: string): number {
|
|
||||||
if (UNDER_KINDS.has(kind)) return 0
|
|
||||||
if (kind === 'tree') return 2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The editor canvas. Pan/zoom/pinch (from useViewport), tap-to-place from the
|
* The editor canvas. Pan/zoom/pinch (from useViewport), tap-to-place from the
|
||||||
* armed palette kind, click to select, and — for the selected object —
|
* armed palette kind, click to select, and — for the selected object —
|
||||||
@@ -78,11 +70,14 @@ export function GardenCanvas({
|
|||||||
const cellPx = GRID_CM * viewport.scale
|
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 gridOpacity = Math.max(0, Math.min(GRID_MAX_OPACITY, ((cellPx - GRID_MIN_CELL_PX) / GRID_MIN_CELL_PX) * GRID_MAX_OPACITY))
|
||||||
|
|
||||||
// Merge the in-flight live geometry over the server objects, then z-order.
|
// Sort once by z-order; only re-sorts when the server objects change.
|
||||||
const rendered = useMemo(() => {
|
const sorted = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
|
||||||
const merged = liveObject ? objects.map((o) => (o.id === liveObject.id ? liveObject : o)) : objects
|
// Merge the in-flight live geometry over the sorted list. A gesture never
|
||||||
return [...merged].sort((a, b) => a.zIndex - b.zIndex)
|
// changes z, so this stays a cheap O(n) map on every pointermove — no re-sort.
|
||||||
}, [objects, liveObject])
|
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
|
const selected = rendered.find((o) => o.id === selectedId) ?? null
|
||||||
|
|
||||||
// A pointerdown reaching the svg is empty space: place the armed kind there, or
|
// A pointerdown reaching the svg is empty space: place the armed kind there, or
|
||||||
@@ -107,7 +102,7 @@ export function GardenCanvas({
|
|||||||
yCm: world.y,
|
yCm: world.y,
|
||||||
widthCm: def.widthCm,
|
widthCm: def.widthCm,
|
||||||
heightCm: def.heightCm,
|
heightCm: def.heightCm,
|
||||||
zIndex: defaultZForKind(def.kind),
|
zIndex: def.defaultZ,
|
||||||
},
|
},
|
||||||
{ onSuccess: (o) => select(o.id) },
|
{ onSuccess: (o) => select(o.id) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,11 +3,19 @@ import { Button } from '@/components/ui/Button'
|
|||||||
import { TextField } from '@/components/ui/TextField'
|
import { TextField } from '@/components/ui/TextField'
|
||||||
import { TextArea } from '@/components/ui/TextArea'
|
import { TextArea } from '@/components/ui/TextArea'
|
||||||
import { useDeleteObject, useUpdateObject } from '@/lib/objects'
|
import { useDeleteObject, useUpdateObject } from '@/lib/objects'
|
||||||
import { cmFromDisplay, dimensionUnitLabel, displayFromCm, type UnitPref } from '@/lib/units'
|
import {
|
||||||
|
cmFromDisplay,
|
||||||
|
dimensionUnitLabel,
|
||||||
|
displayFromCm,
|
||||||
|
MIN_DIMENSION_CM,
|
||||||
|
type UnitPref,
|
||||||
|
} from '@/lib/units'
|
||||||
import { kindDef } from './kinds'
|
import { kindDef } from './kinds'
|
||||||
import { useEditorStore } from './store'
|
import { useEditorStore } from './store'
|
||||||
import type { EditorObject } from './types'
|
import type { EditorObject } from './types'
|
||||||
|
|
||||||
|
const DEFAULT_COLOR = '#8a8a8a'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Property panel for the selected object. Each field commits a PATCH on
|
* Property panel for the selected object. Each field commits a PATCH on
|
||||||
* blur/change (carrying the object's version); dimensions and position are shown
|
* blur/change (carrying the object's version); dimensions and position are shown
|
||||||
@@ -35,6 +43,7 @@ export function Inspector({
|
|||||||
const [x, setX] = useState(String(displayFromCm(object.xCm, unit)))
|
const [x, setX] = useState(String(displayFromCm(object.xCm, unit)))
|
||||||
const [y, setY] = useState(String(displayFromCm(object.yCm, unit)))
|
const [y, setY] = useState(String(displayFromCm(object.yCm, unit)))
|
||||||
const [rotation, setRotation] = useState(String(Math.round(object.rotationDeg)))
|
const [rotation, setRotation] = useState(String(Math.round(object.rotationDeg)))
|
||||||
|
const [color, setColor] = useState(object.color ?? DEFAULT_COLOR)
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -50,15 +59,22 @@ export function Inspector({
|
|||||||
setX(String(displayFromCm(object.xCm, unit)))
|
setX(String(displayFromCm(object.xCm, unit)))
|
||||||
setY(String(displayFromCm(object.yCm, unit)))
|
setY(String(displayFromCm(object.yCm, unit)))
|
||||||
setRotation(String(Math.round(object.rotationDeg)))
|
setRotation(String(Math.round(object.rotationDeg)))
|
||||||
}, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, unit])
|
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'>>) =>
|
const patch = (fields: Partial<Omit<EditorObject, 'id' | 'version'>>) =>
|
||||||
update.mutate({ id: object.id, version: object.version, ...fields })
|
update.mutate({ id: object.id, version: object.version, ...fields })
|
||||||
|
|
||||||
const commitDim = (raw: string, current: number, apply: (cm: number) => void) => {
|
// 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)
|
const v = parseFloat(raw)
|
||||||
if (!Number.isFinite(v)) return
|
if (!Number.isFinite(v)) return
|
||||||
|
if (v === displayFromCm(current, unit)) return
|
||||||
const cm = cmFromDisplay(v, unit)
|
const cm = cmFromDisplay(v, unit)
|
||||||
|
if (positive && cm < MIN_DIMENSION_CM) return
|
||||||
if (cm !== current) apply(cm)
|
if (cm !== current) apply(cm)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +111,7 @@ export function Inspector({
|
|||||||
step="any"
|
step="any"
|
||||||
value={width}
|
value={width}
|
||||||
onChange={(e) => setWidth(e.target.value)}
|
onChange={(e) => setWidth(e.target.value)}
|
||||||
onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }))}
|
onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }), true)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={`Height (${u})`}
|
label={`Height (${u})`}
|
||||||
@@ -105,7 +121,7 @@ export function Inspector({
|
|||||||
step="any"
|
step="any"
|
||||||
value={height}
|
value={height}
|
||||||
onChange={(e) => setHeight(e.target.value)}
|
onChange={(e) => setHeight(e.target.value)}
|
||||||
onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }))}
|
onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={`X (${u})`}
|
label={`X (${u})`}
|
||||||
@@ -151,13 +167,23 @@ export function Inspector({
|
|||||||
<input
|
<input
|
||||||
id="obj-color"
|
id="obj-color"
|
||||||
type="color"
|
type="color"
|
||||||
value={object.color ?? '#8a8a8a'}
|
value={color}
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => patch({ color: e.target.value })}
|
// 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"
|
className="h-9 w-14 cursor-pointer rounded-md border border-border bg-surface"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{object.color && (
|
{object.color && (
|
||||||
<Button variant="ghost" className="px-2 py-1.5 text-xs" onClick={() => patch({ color: null })}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1.5 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setColor(DEFAULT_COLOR)
|
||||||
|
patch({ color: null })
|
||||||
|
}}
|
||||||
|
>
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'
|
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
|
||||||
import { localToWorld, screenToWorld, worldToLocal, type Point } from '@/lib/geometry'
|
import { localToWorld, screenToWorld, worldToLocal, type Point } from '@/lib/geometry'
|
||||||
import { useUpdateObject } from '@/lib/objects'
|
import { useUpdateObject } from '@/lib/objects'
|
||||||
import { useEditorStore } from './store'
|
import { useEditorStore } from './store'
|
||||||
@@ -17,20 +17,6 @@ const corners: [number, number][] = [
|
|||||||
[-1, 1],
|
[-1, 1],
|
||||||
]
|
]
|
||||||
|
|
||||||
// Register window listeners for a pointer drag; cleaned up on up/cancel.
|
|
||||||
function trackPointer(onMove: (e: PointerEvent) => void, onEnd: () => void) {
|
|
||||||
const move = (e: PointerEvent) => onMove(e)
|
|
||||||
const end = () => {
|
|
||||||
window.removeEventListener('pointermove', move)
|
|
||||||
window.removeEventListener('pointerup', end)
|
|
||||||
window.removeEventListener('pointercancel', end)
|
|
||||||
onEnd()
|
|
||||||
}
|
|
||||||
window.addEventListener('pointermove', move)
|
|
||||||
window.addEventListener('pointerup', end)
|
|
||||||
window.addEventListener('pointercancel', end)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles for the selected object: a transparent body to move, four corner
|
* Handles for the selected object: a transparent body to move, four corner
|
||||||
* handles to resize (opposite corner stays put, honoring rotation via the
|
* handles to resize (opposite corner stays put, honoring rotation via the
|
||||||
@@ -52,47 +38,83 @@ export function SelectionOverlay({
|
|||||||
const scale = useEditorStore((s) => s.viewport.scale)
|
const scale = useEditorStore((s) => s.viewport.scale)
|
||||||
const update = useUpdateObject(gardenId)
|
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 halfW = object.widthCm / 2
|
||||||
const halfH = object.heightCm / 2
|
const halfH = object.heightCm / 2
|
||||||
const handleCm = HANDLE_PX / scale
|
const handleCm = HANDLE_PX / scale
|
||||||
const rotateOffsetCm = ROTATE_OFFSET_PX / scale
|
const rotateOffsetCm = ROTATE_OFFSET_PX / scale
|
||||||
|
|
||||||
const pointerWorld = (e: PointerEvent): Point => {
|
// 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()
|
const rect = svgRef.current?.getBoundingClientRect()
|
||||||
|
return (e: { clientX: number; clientY: number }): Point => {
|
||||||
const vp = useEditorStore.getState().viewport
|
const vp = useEditorStore.getState().viewport
|
||||||
const local = rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : { x: e.clientX, y: e.clientY }
|
const local = rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : { x: e.clientX, y: e.clientY }
|
||||||
return screenToWorld(local, vp)
|
return screenToWorld(local, vp)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Common gesture scaffolding: mark dragging, track the pointer, and on release
|
// Common gesture scaffolding: mark dragging, track the pointer on window, and
|
||||||
// fire one PATCH built from the final liveObject.
|
// 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 = (
|
const begin = (
|
||||||
e: ReactPointerEvent,
|
e: ReactPointerEvent,
|
||||||
onMove: (world: Point) => EditorObject,
|
onMove: (e: PointerEvent) => EditorObject,
|
||||||
fields: (final: EditorObject) => Parameters<typeof update.mutate>[0],
|
fields: (final: EditorObject) => Parameters<typeof update.mutate>[0],
|
||||||
) => {
|
) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setObjectDragging(true)
|
setObjectDragging(true)
|
||||||
trackPointer(
|
const move = (ev: PointerEvent) => setLiveObject(onMove(ev))
|
||||||
(ev) => setLiveObject(onMove(pointerWorld(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
|
const final = useEditorStore.getState().liveObject
|
||||||
setObjectDragging(false)
|
setObjectDragging(false)
|
||||||
setLiveObject(null)
|
setLiveObject(null)
|
||||||
if (final) update.mutate(fields(final))
|
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 base = { ...object }
|
||||||
const center0: Point = { x: base.xCm, y: base.yCm }
|
const center0: Point = { x: base.xCm, y: base.yCm }
|
||||||
|
|
||||||
const startMove = (e: ReactPointerEvent) => {
|
const startMove = (e: ReactPointerEvent) => {
|
||||||
|
const pointerWorld = makePointerWorld()
|
||||||
const start = pointerWorld(e.nativeEvent)
|
const start = pointerWorld(e.nativeEvent)
|
||||||
begin(
|
begin(
|
||||||
e,
|
e,
|
||||||
(world) => ({ ...base, xCm: base.xCm + (world.x - start.x), yCm: base.yCm + (world.y - start.y) }),
|
(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 }),
|
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -101,9 +123,11 @@ export function SelectionOverlay({
|
|||||||
// The corner opposite the dragged one stays fixed (in the object's local
|
// The corner opposite the dragged one stays fixed (in the object's local
|
||||||
// frame, which is anchored at the original center).
|
// frame, which is anchored at the original center).
|
||||||
const oppositeLocal: Point = { x: -sx * halfW, y: -sy * halfH }
|
const oppositeLocal: Point = { x: -sx * halfW, y: -sy * halfH }
|
||||||
|
const pointerWorld = makePointerWorld()
|
||||||
begin(
|
begin(
|
||||||
e,
|
e,
|
||||||
(world) => {
|
(ev) => {
|
||||||
|
const world = pointerWorld(ev)
|
||||||
const p = worldToLocal(world, center0, base.rotationDeg)
|
const p = worldToLocal(world, center0, base.rotationDeg)
|
||||||
const newW = Math.max(MIN_OBJ_CM, Math.abs(p.x - oppositeLocal.x))
|
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 newH = Math.max(MIN_OBJ_CM, Math.abs(p.y - oppositeLocal.y))
|
||||||
@@ -120,12 +144,16 @@ export function SelectionOverlay({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startRotate = (e: ReactPointerEvent) => {
|
const startRotate = (e: ReactPointerEvent) => {
|
||||||
|
const pointerWorld = makePointerWorld()
|
||||||
begin(
|
begin(
|
||||||
e,
|
e,
|
||||||
(world) => {
|
(ev) => {
|
||||||
|
const world = pointerWorld(ev)
|
||||||
// +90° because the knob points up (local -y) at rotation 0.
|
// +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
|
let deg = (Math.atan2(world.y - center0.y, world.x - center0.x) * 180) / Math.PI + 90
|
||||||
if (!e.shiftKey) deg = Math.round(deg / ROTATE_SNAP_DEG) * ROTATE_SNAP_DEG
|
// 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
|
deg = ((deg % 360) + 360) % 360
|
||||||
return { ...base, rotationDeg: deg }
|
return { ...base, rotationDeg: deg }
|
||||||
},
|
},
|
||||||
|
|||||||
+10
-7
@@ -9,16 +9,19 @@ export interface KindDef {
|
|||||||
shape: ObjectShapeKind
|
shape: ObjectShapeKind
|
||||||
widthCm: number
|
widthCm: number
|
||||||
heightCm: 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[] = [
|
export const OBJECT_KINDS: KindDef[] = [
|
||||||
{ kind: 'bed', label: 'Bed', icon: '▭', shape: 'rect', widthCm: 120, heightCm: 240 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ kind: 'tree', label: 'Tree', icon: '🌳', shape: 'circle', widthCm: 300, heightCm: 300, defaultZ: 2 },
|
||||||
{ kind: 'path', label: 'Path', icon: '🧱', shape: 'rect', widthCm: 100, heightCm: 300 },
|
{ kind: 'path', label: 'Path', icon: '🧱', shape: 'rect', widthCm: 100, heightCm: 300, defaultZ: 0 },
|
||||||
{ kind: 'structure', label: 'Structure', icon: '🏠', shape: 'rect', widthCm: 200, heightCm: 200 },
|
{ kind: 'structure', label: 'Structure', icon: '🏠', shape: 'rect', widthCm: 200, heightCm: 200, defaultZ: 0 },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function kindDef(kind: string): KindDef | undefined {
|
export function kindDef(kind: string): KindDef | undefined {
|
||||||
|
|||||||
@@ -156,7 +156,10 @@ export function useUpdateObject(gardenId: number) {
|
|||||||
toast.error(objectErrorMessage(err, 'Could not save that change.'))
|
toast.error(objectErrorMessage(err, 'Could not save that change.'))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
|
// No onSettled refetch: onSuccess already writes the authoritative server
|
||||||
|
// row and onError reconciles. Invalidating here would fire a full-garden GET
|
||||||
|
// after every PATCH — a request storm during drags, and a refetch race that
|
||||||
|
// can clobber a fast follow-up edit.
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,10 +187,14 @@ function patchFullCache(qc: QueryClient, gardenId: number, fn: (full: FullGarden
|
|||||||
qc.setQueryData<FullGarden>(fullKey(gardenId), (full) => (full ? fn(full) : full))
|
qc.setQueryData<FullGarden>(fullKey(gardenId), (full) => (full ? fn(full) : full))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply a patch onto a cached server object for the optimistic pass. */
|
/** Apply a patch onto a cached server object for the optimistic pass. The
|
||||||
|
* version is bumped to mirror the server's post-commit increment, so a second
|
||||||
|
* edit started before the first PATCH resolves reads the next version instead
|
||||||
|
* of re-sending the stale one and self-conflicting (a spurious 409). */
|
||||||
function applyServerPatch(o: ServerObject, patch: ObjectPatch): ServerObject {
|
function applyServerPatch(o: ServerObject, patch: ObjectPatch): ServerObject {
|
||||||
return {
|
return {
|
||||||
...o,
|
...o,
|
||||||
|
version: o.version + 1,
|
||||||
...(patch.name !== undefined ? { name: patch.name } : {}),
|
...(patch.name !== undefined ? { name: patch.name } : {}),
|
||||||
...(patch.xCm !== undefined ? { xCm: patch.xCm } : {}),
|
...(patch.xCm !== undefined ? { xCm: patch.xCm } : {}),
|
||||||
...(patch.yCm !== undefined ? { yCm: patch.yCm } : {}),
|
...(patch.yCm !== undefined ? { yCm: patch.yCm } : {}),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { getRouteApi } from '@tanstack/react-router'
|
import { getRouteApi } from '@tanstack/react-router'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
import { GardenCanvas } from '@/editor/GardenCanvas'
|
import { GardenCanvas } from '@/editor/GardenCanvas'
|
||||||
@@ -21,6 +21,12 @@ export function GardenEditorPage() {
|
|||||||
const setArmedKind = useEditorStore((s) => s.setArmedKind)
|
const setArmedKind = useEditorStore((s) => s.setArmedKind)
|
||||||
const setLiveObject = useEditorStore((s) => s.setLiveObject)
|
const setLiveObject = useEditorStore((s) => s.setLiveObject)
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
|
||||||
// Clear transient editor state on entering/switching gardens and on leaving.
|
// Clear transient editor state on entering/switching gardens and on leaving.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
@@ -48,7 +54,6 @@ export function GardenEditorPage() {
|
|||||||
heightCm: g.heightCm,
|
heightCm: g.heightCm,
|
||||||
unitPref: g.unitPref,
|
unitPref: g.unitPref,
|
||||||
}
|
}
|
||||||
const objects = full.data.objects.map(toEditorObject)
|
|
||||||
const selected = objects.find((o) => o.id === selectedId) ?? null
|
const selected = objects.find((o) => o.id === selectedId) ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user