From 916b2989f5558f704f2f6e79de9f2b04876b8377 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 23 Aug 2026 03:12:13 -0400 Subject: [PATCH 1/3] Canvas: make double-click-to-plant work on desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Double-click a bed to plant it" has done nothing since the Organic rebuild: track() captures the pointer on the SVG root, and pointer capture retargets the compatibility click/dblclick events to the root, so the onDoubleClick handler on each object's never fired. A double-click only selected. The double press is now detected in objDown itself — two presses on the same object within 400 ms and 12 px — which capture cannot retarget. The second press focuses the bed and starts no drag, so its pointerup has nothing to select into Plot; a plop in an unfocused bed already delegates to objDown, so double-clicking a plant focuses its bed too. Co-Authored-By: Claude Fable 5 --- web/src/editor/Canvas.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/web/src/editor/Canvas.tsx b/web/src/editor/Canvas.tsx index 1525af0..e420272 100644 --- a/web/src/editor/Canvas.tsx +++ b/web/src/editor/Canvas.tsx @@ -329,6 +329,16 @@ export const Canvas = forwardRef< if (!drag.current) drag.current = { t: 'pan', sx: e.clientX, sy: e.clientY, tx: st.vp.tx, ty: st.vp.ty, moved: false, th: thresh(e) } } + // The last press on an object, for double-click detection. Detected here + // rather than with onDoubleClick on the object's : 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 this was noticed (2026-08-23). + const lastPress = useRef<{ id: number; at: number; x: number; y: number } | null>(null) + const DOUBLE_PRESS_MS = 400 + const DOUBLE_PRESS_PX = 12 + const objDown = (o: EditorObject) => (e: ReactPointerEvent) => { e.stopPropagation() track(e) @@ -342,6 +352,17 @@ export const Canvas = forwardRef< } // Dimmed siblings stay inert inside a focused bed. if (st.focusId != null && o.id !== st.focusId) return + const prev = lastPress.current + const now = performance.now() + lastPress.current = { id: o.id, at: now, x: e.clientX, y: e.clientY } + if (prev && 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) { st.setSel({ type: 'object', id: o.id }) st.setTab('plot') @@ -562,7 +583,6 @@ export const Canvas = forwardRef< opacity={dim ? DIM_OBJECT : 1} style={{ cursor: dim ? 'default' : canEdit ? 'grab' : 'pointer' }} onPointerDown={objDown(o)} - onDoubleClick={o.plantable && !dim ? () => focusObject(o) : undefined} > {o.shape === 'circle' ? ( -- 2.54.0 From a19fc2e7fcd3bc4fead0878c38808e313ae4bbde Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 23 Aug 2026 03:13:35 -0400 Subject: [PATCH 2/3] CLAUDE.md: the pointer-capture / dblclick gotcha Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index b103382..f115fec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,12 @@ Conventions that follow from it: - **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 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 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. -- 2.54.0 From b5e97d41440d7cd9e3585de7493405c9412930ef Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 23 Aug 2026 03:25:54 -0400 Subject: [PATCH 3/3] Address #135 review: ref with the refs, primary button only, reset on ground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lastPress sits with the component's other refs and its thresholds at module scope with the other tuning constants; only a primary-button (or finger) press counts, like a native dblclick; a press on empty ground clears the pending half, so bed → ground → bed within 400 ms is not a double-click. Co-Authored-By: Claude Fable 5 --- web/src/editor/Canvas.tsx | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/web/src/editor/Canvas.tsx b/web/src/editor/Canvas.tsx index e420272..92fb6c9 100644 --- a/web/src/editor/Canvas.tsx +++ b/web/src/editor/Canvas.tsx @@ -35,6 +35,10 @@ import { import { useEditorStore, type Viewport } from './store' 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 ANIM_MS = 520 const REFIT_THRESHOLD_PX = 60 @@ -95,6 +99,13 @@ export const Canvas = forwardRef< const drag = useRef(null) const pinch = useRef(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 : + // 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 anim = useEditorStore((s) => s.anim) @@ -316,6 +327,7 @@ export const Canvas = forwardRef< // ── pointer handlers ──────────────────────────────────────────────────── const onCanvasDown = (e: ReactPointerEvent) => { + lastPress.current = null // a press on empty ground is not half of a double-click on a bed track(e) if (pts.current.size === 2) return pinchStart() const st = useEditorStore.getState() @@ -329,16 +341,6 @@ export const Canvas = forwardRef< if (!drag.current) drag.current = { t: 'pan', sx: e.clientX, sy: e.clientY, tx: st.vp.tx, ty: st.vp.ty, moved: false, th: thresh(e) } } - // The last press on an object, for double-click detection. Detected here - // rather than with onDoubleClick on the object's : 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 this was noticed (2026-08-23). - const lastPress = useRef<{ id: number; at: number; x: number; y: number } | null>(null) - const DOUBLE_PRESS_MS = 400 - const DOUBLE_PRESS_PX = 12 - const objDown = (o: EditorObject) => (e: ReactPointerEvent) => { e.stopPropagation() track(e) @@ -354,8 +356,9 @@ export const Canvas = forwardRef< if (st.focusId != null && o.id !== st.focusId) return const prev = lastPress.current const now = performance.now() - lastPress.current = { id: o.id, at: now, x: e.clientX, y: e.clientY } - if (prev && prev.id === o.id && now - prev.at < DOUBLE_PRESS_MS && Math.hypot(e.clientX - prev.x, e.clientY - prev.y) < DOUBLE_PRESS_PX) { + // 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 -- 2.54.0