Build image / build-and-push (push) Successful in 9s
Correctness / stuck-state: - PlopInspector: the planted date can now be cleared (blur commits null instead of being blocked); radius/count reset to the current value on invalid input. - applyPlantingPatch now applies removedAt too (completes the optimistic patch). - PlopMarker computes the count live from the current radius, so the on-canvas near-zoom label updates while resizing (not just the inspector). - GardenEditorPage clears focus when the focused object vanishes — deleted, or a stale ?focus id on load — so the canvas can't get stuck dimmed with no exit. - useRemovePlanting adopts the 409 current row, so a stale-version soft-remove can be retried instead of looping. - Placement is gated on plantable, so a deep-linked non-plantable focus can't try to POST a plop the server would 400. Dedup / perf (introduced by this PR): - editor/shared.ts: SELECT_COLOR, HANDLE_PX, MIN_RADIUS_CM, DIMMED_OPACITY, and an objectTransform() helper, now used by ObjectShape/SelectionOverlay/PlopLayer/ PlopOverlay/PlopMarker/GardenCanvas (unified the transform string + the two different dim opacities). - Dropped PlopMarker's always-false `dimmed` prop (dimming is at the group level). - Build plantsById once in the page and pass it to GardenCanvas. - Moved the livePlanting subscription into PlopInspector so a plop drag re-renders only the panel, not the whole page (matches the liveObject pattern). - Memoized PlopLayer's plop grouping so it doesn't rebuild every pan/zoom frame. Skipped: gesture-scaffolding hook extraction (risky churn in #11 code), elliptical clamp for circle objects (consistent with the server's rect bounds), optimistic create (matches useCreateObject). tsc --noEmit clean; 24/24 vitest; production build green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
165 lines
5.1 KiB
TypeScript
165 lines
5.1 KiB
TypeScript
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
|
|
import { screenToWorld, worldToLocal, type Point } from '@/lib/geometry'
|
|
import { useUpdatePlanting } from '@/lib/objects'
|
|
import type { EditorPlanting } from '@/lib/plantings'
|
|
import { HANDLE_PX, MIN_RADIUS_CM, SELECT_COLOR, objectTransform } from './shared'
|
|
import { useEditorStore } from './store'
|
|
import type { EditorObject } from './types'
|
|
|
|
const MAX_RADIUS_CM = 10_000
|
|
|
|
/**
|
|
* Move/resize handles for the selected plop, rendered inside its parent object's
|
|
* translate+rotate group so the plop stays in the object's local frame. Drag the
|
|
* body to move (clamped to the object's bounds); drag the edge handle to resize
|
|
* the radius. Each gesture updates livePlanting for instant feedback and fires
|
|
* one PATCH on release — the same contract as SelectionOverlay (#11).
|
|
*/
|
|
export function PlopOverlay({
|
|
plop,
|
|
object,
|
|
gardenId,
|
|
svgRef,
|
|
}: {
|
|
plop: EditorPlanting
|
|
object: EditorObject
|
|
gardenId: number
|
|
svgRef: RefObject<SVGSVGElement | null>
|
|
}) {
|
|
const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
|
|
const setObjectDragging = useEditorStore((s) => s.setObjectDragging)
|
|
const scale = useEditorStore((s) => s.viewport.scale)
|
|
const update = useUpdatePlanting(gardenId)
|
|
|
|
const cleanupRef = useRef<(() => void) | null>(null)
|
|
useEffect(
|
|
() => () => {
|
|
cleanupRef.current?.()
|
|
cleanupRef.current = null
|
|
},
|
|
[],
|
|
)
|
|
|
|
const handleCm = HANDLE_PX / scale
|
|
const halfW = object.widthCm / 2
|
|
const halfH = object.heightCm / 2
|
|
const center: Point = { x: object.xCm, y: object.yCm }
|
|
const rot = object.rotationDeg
|
|
|
|
// Pointer (client) → the object's local frame, snapshotting the svg rect once
|
|
// per gesture (the object doesn't move during a plop drag).
|
|
const makePointerLocal = () => {
|
|
const rect = svgRef.current?.getBoundingClientRect()
|
|
return (e: { clientX: number; clientY: number }): Point => {
|
|
const vp = useEditorStore.getState().viewport
|
|
const canvas = rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : { x: e.clientX, y: e.clientY }
|
|
return worldToLocal(screenToWorld(canvas, vp), center, rot)
|
|
}
|
|
}
|
|
|
|
const clampLocal = (p: Point): Point => ({
|
|
x: Math.max(-halfW, Math.min(halfW, p.x)),
|
|
y: Math.max(-halfH, Math.min(halfH, p.y)),
|
|
})
|
|
|
|
const begin = (
|
|
e: ReactPointerEvent,
|
|
onMove: (e: PointerEvent) => EditorPlanting,
|
|
fields: (final: EditorPlanting) => Parameters<typeof update.mutate>[0],
|
|
) => {
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
setObjectDragging(true)
|
|
const move = (ev: PointerEvent) => setLivePlanting(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().livePlanting
|
|
setObjectDragging(false)
|
|
setLivePlanting(null)
|
|
if (final) update.mutate(fields(final))
|
|
}
|
|
cleanupRef.current = () => {
|
|
detach()
|
|
setObjectDragging(false)
|
|
setLivePlanting(null)
|
|
}
|
|
window.addEventListener('pointermove', move)
|
|
window.addEventListener('pointerup', finish)
|
|
window.addEventListener('pointercancel', finish)
|
|
}
|
|
|
|
const base = { ...plop }
|
|
|
|
const startMove = (e: ReactPointerEvent) => {
|
|
const ptr = makePointerLocal()
|
|
const start = ptr(e.nativeEvent)
|
|
begin(
|
|
e,
|
|
(ev) => {
|
|
const p = ptr(ev)
|
|
const next = clampLocal({ x: base.xCm + (p.x - start.x), y: base.yCm + (p.y - start.y) })
|
|
return { ...base, xCm: next.x, yCm: next.y }
|
|
},
|
|
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
|
|
)
|
|
}
|
|
|
|
const startResize = (e: ReactPointerEvent) => {
|
|
const ptr = makePointerLocal()
|
|
begin(
|
|
e,
|
|
(ev) => {
|
|
const p = ptr(ev)
|
|
const r = Math.max(MIN_RADIUS_CM, Math.min(MAX_RADIUS_CM, Math.hypot(p.x - base.xCm, p.y - base.yCm)))
|
|
return { ...base, radiusCm: r }
|
|
},
|
|
(f) => ({ id: base.id, version: base.version, radiusCm: f.radiusCm }),
|
|
)
|
|
}
|
|
|
|
const r = plop.radiusCm
|
|
return (
|
|
<g transform={objectTransform(object)}>
|
|
{/* Transparent body: drag to move (at least handle-sized so tiny plops stay grabbable). */}
|
|
<circle
|
|
cx={plop.xCm}
|
|
cy={plop.yCm}
|
|
r={Math.max(r, handleCm)}
|
|
fill="transparent"
|
|
pointerEvents="all"
|
|
style={{ cursor: 'move' }}
|
|
onPointerDown={startMove}
|
|
/>
|
|
{/* Selection ring. */}
|
|
<circle
|
|
cx={plop.xCm}
|
|
cy={plop.yCm}
|
|
r={r}
|
|
fill="none"
|
|
stroke={SELECT_COLOR}
|
|
strokeWidth={1.5}
|
|
vectorEffect="non-scaling-stroke"
|
|
pointerEvents="none"
|
|
/>
|
|
{/* Radius handle at the local +x edge. */}
|
|
<circle
|
|
cx={plop.xCm + r}
|
|
cy={plop.yCm}
|
|
r={handleCm * 0.6}
|
|
fill="#ffffff"
|
|
stroke={SELECT_COLOR}
|
|
strokeWidth={1}
|
|
vectorEffect="non-scaling-stroke"
|
|
style={{ cursor: 'ew-resize' }}
|
|
onPointerDown={startResize}
|
|
/>
|
|
</g>
|
|
)
|
|
}
|