Files
pansy/web/src/editor/PlopOverlay.tsx
T
steveandClaude Opus 4.8 0f67623da7
Build image / build-and-push (push) Successful in 9s
Address Gadfly review on #15: real bugs + shared editor constants
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
2026-07-18 23:21:17 -04:00

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>
)
}