Canvas: make double-click-to-plant work on desktop #135

Merged
steve merged 3 commits from fix/double-click-focus into main 2026-08-23 07:26:50 +00:00
2 changed files with 30 additions and 1 deletions
+6
View File
@@ -104,6 +104,12 @@ Conventions that follow from it:
- **Tap-to-place makes a one-plant plop** (radius = spacing/2, per the handoff); - **Tap-to-place makes a one-plant plop** (radius = spacing/2, per the handoff);
"Fill the bed" (rows / clumps) is the bulk tool. Fill geometry still follows "Fill the bed" (rows / clumps) is the bulk tool. Fill geometry still follows
the clump rules below — different tools, not a conflict. the clump rules below — different tools, not a conflict.
- **The canvas captures the pointer, so `onDoubleClick` on a bed never fires.**
`track()` calls `setPointerCapture` on the SVG root for every press, and
capture retargets the compatibility `click`/`dblclick` to the root. Double
presses are detected in `objDown` (same object, < 400 ms, < 12 px) instead;
a `dblclick` handler on an object is dead code — it was, silently, from the
Organic rebuild until 2026-08-23.
- **Undo in the header re-reads history before reverting.** The cached list - **Undo in the header re-reads history before reverting.** The cached list
trails the canvas right after a placement, and undoing the step *before* the trails the canvas right after a placement, and undoing the step *before* the
one you meant is the worst thing an undo button can do. Keep it that way. one you meant is the worst thing an undo button can do. Keep it that way.
+24 -1
View File
@@ -35,6 +35,10 @@ import {
import { useEditorStore, type Viewport } from './store' import { useEditorStore, type Viewport } from './store'
import type { EditorGarden, EditorObject } from './types' import type { EditorGarden, EditorObject } from './types'
// A double press: same object, this close in time and space.
const DOUBLE_PRESS_MS = 400
const DOUBLE_PRESS_PX = 12
const WHEEL_SENSITIVITY = 0.0016 const WHEEL_SENSITIVITY = 0.0016
const ANIM_MS = 520 const ANIM_MS = 520
const REFIT_THRESHOLD_PX = 60 const REFIT_THRESHOLD_PX = 60
@@ -95,6 +99,13 @@ export const Canvas = forwardRef<
const drag = useRef<Drag | null>(null) const drag = useRef<Drag | null>(null)
const pinch = useRef<Pinch | null>(null) const pinch = useRef<Pinch | null>(null)
const dropPlant = useRef<{ plant: Plant; lotId: number | null } | null>(null) const dropPlant = useRef<{ plant: Plant; lotId: number | null } | null>(null)
// The last primary-button press on an object, for double-click detection.
// Detected in objDown rather than with onDoubleClick on the object's <g>:
// track() captures the pointer on the SVG root, and capture retargets the
// compatibility click and dblclick events to the root, so a dblclick
// handler on the object never fires — which is why "double-click a bed to
// plant it" did nothing from the Organic rebuild until 2026-08-23.
const lastPress = useRef<{ id: number; at: number; x: number; y: number } | null>(null)
const vp = useEditorStore((s) => s.vp) const vp = useEditorStore((s) => s.vp)
const anim = useEditorStore((s) => s.anim) const anim = useEditorStore((s) => s.anim)
@@ -316,6 +327,7 @@ export const Canvas = forwardRef<
// ── pointer handlers ──────────────────────────────────────────────────── // ── pointer handlers ────────────────────────────────────────────────────
const onCanvasDown = (e: ReactPointerEvent) => { const onCanvasDown = (e: ReactPointerEvent) => {
lastPress.current = null // a press on empty ground is not half of a double-click on a bed
track(e) track(e)
if (pts.current.size === 2) return pinchStart() if (pts.current.size === 2) return pinchStart()
const st = useEditorStore.getState() const st = useEditorStore.getState()
@@ -342,6 +354,18 @@ export const Canvas = forwardRef<
} }
// Dimmed siblings stay inert inside a focused bed. // Dimmed siblings stay inert inside a focused bed.
if (st.focusId != null && o.id !== st.focusId) return if (st.focusId != null && o.id !== st.focusId) return
const prev = lastPress.current
const now = performance.now()
// Only the primary button (or a finger) counts, like a native dblclick.
lastPress.current = e.button === 0 && e.isPrimary ? { id: o.id, at: now, x: e.clientX, y: e.clientY } : null
if (prev && lastPress.current && prev.id === o.id && now - prev.at < DOUBLE_PRESS_MS && Math.hypot(e.clientX - prev.x, e.clientY - prev.y) < DOUBLE_PRESS_PX) {
// Second press of a double-click: focus the bed to plant it. No drag
// starts, so the matching pointerup has nothing to select into Plot.
lastPress.current = null
drag.current = null
if (o.plantable) focusObject(o)
return
}
if (!latest.current.canEdit) { if (!latest.current.canEdit) {
st.setSel({ type: 'object', id: o.id }) st.setSel({ type: 'object', id: o.id })
st.setTab('plot') st.setTab('plot')
@@ -562,7 +586,6 @@ export const Canvas = forwardRef<
opacity={dim ? DIM_OBJECT : 1} opacity={dim ? DIM_OBJECT : 1}
style={{ cursor: dim ? 'default' : canEdit ? 'grab' : 'pointer' }} style={{ cursor: dim ? 'default' : canEdit ? 'grab' : 'pointer' }}
onPointerDown={objDown(o)} onPointerDown={objDown(o)}
onDoubleClick={o.plantable && !dim ? () => focusObject(o) : undefined}
> >
{o.shape === 'circle' ? ( {o.shape === 'circle' ? (
<ellipse rx={o.widthCm / 2} ry={o.heightCm / 2} {...common} /> <ellipse rx={o.widthCm / 2} ry={o.heightCm / 2} {...common} />