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()
1
@@ -342,6 +354,18 @@ export const Canvas = forwardRef<
} }
// Dimmed siblings stay inert inside a focused bed. // Dimmed siblings stay inert inside a focused bed.
Outdated
Review

🟠 Double-click detection accepts non-primary pointer buttons, unlike native dblclick

correctness, error-handling · flagged by 2 models

  • web/src/editor/Canvas.tsx:357lastPress is updated for every pointer button, so non-primary clicks (right-click, middle-click) can participate in or complete a double-click. Verified by reading objDown: the ref is written unconditionally before the e.button check, and the if (prev && …) block that fires focusObject does not inspect e.button. Native dblclick fires only for the primary button; right-clicking a bed twice within 400 ms should not focus it. Fix: gate the `last…

🪰 Gadfly · advisory

🟠 **Double-click detection accepts non-primary pointer buttons, unlike native dblclick** _correctness, error-handling · flagged by 2 models_ - **`web/src/editor/Canvas.tsx:357` — `lastPress` is updated for every pointer button, so non-primary clicks (right-click, middle-click) can participate in or complete a double-click.** Verified by reading `objDown`: the ref is written unconditionally before the `e.button` check, and the `if (prev && …)` block that fires `focusObject` does not inspect `e.button`. Native `dblclick` fires only for the primary button; right-clicking a bed twice within 400 ms should not focus it. Fix: gate the `last… <sub>🪰 Gadfly · advisory</sub>
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} />