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:
@@ -0,0 +1,164 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user