From 8dd8dd2eafdb52b7bcd94765cf5c52a58aabd3f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:36:50 +0000 Subject: [PATCH] fix(seating): a cancelled drag no longer strands a half-drawn zone or calibration line Zone drawing and room calibration listened for pointermove and pointerup only. When the pointer was cancelled instead (a system gesture, an interrupted touch), the draft stayed on the canvas and its listeners stayed on window, so the next release anywhere committed a zone nobody drew. A cancel now drops the draft and commits nothing; a release behaves as before. Also deletes the pan/zoom TODO whose fix already sat beneath it. Closes #55 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VhFp1PtVBwgmxST9ZrRTEs --- .../components/canvas/RoomCanvas.test.tsx | 64 +++++++++++++++++++ .../tableaux/components/canvas/RoomCanvas.tsx | 28 +++++--- suite/apps/tableaux/hooks/useCanvasPanZoom.ts | 5 -- 3 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 suite/apps/tableaux/components/canvas/RoomCanvas.test.tsx diff --git a/suite/apps/tableaux/components/canvas/RoomCanvas.test.tsx b/suite/apps/tableaux/components/canvas/RoomCanvas.test.tsx new file mode 100644 index 00000000..2f02914e --- /dev/null +++ b/suite/apps/tableaux/components/canvas/RoomCanvas.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, fireEvent, act } from '@testing-library/react' +import RoomCanvas from './RoomCanvas' +import { useStore } from '../../store/useStore' +import { openPlan } from '../../test/openPlan' + +const pointer = (type: string, x: number, y: number) => + act(() => { + window.dispatchEvent(new MouseEvent(type, { clientX: x, clientY: y })) + }) + +// A cancelled pointer (a system gesture, an interrupted touch) is not a +// release: the draft must go, and nothing it would have committed may happen. +describe('RoomCanvas drawing, when the pointer is cancelled', () => { + beforeEach(() => { + openPlan({}) + }) + + it('drops a half-drawn zone and adds nothing', () => { + useStore.setState({ activeTool: 'zone' }) + const { container } = render() + const canvas = container.querySelector('[data-tour="seating.canvas"]')! + + fireEvent.pointerDown(canvas, { button: 0, clientX: 10, clientY: 10 }) + pointer('pointermove', 200, 200) + expect(container.querySelector('[class*="draftZone"]')).not.toBeNull() + + pointer('pointercancel', 200, 200) + expect(container.querySelector('[class*="draftZone"]')).toBeNull() + + // A release after the cancel belongs to no drag. + pointer('pointerup', 200, 200) + expect(Object.keys(useStore.getState().zones)).toHaveLength(0) + expect(useStore.getState().activeTool).toBe('select') + }) + + it('drops a half-drawn calibration line and opens no dialog', () => { + useStore.setState({ activeTool: 'calibrate' }) + const { container } = render() + const canvas = container.querySelector('[data-tour="seating.canvas"]')! + + fireEvent.pointerDown(canvas, { button: 0, clientX: 10, clientY: 10 }) + pointer('pointermove', 200, 200) + expect(container.querySelector('line[stroke-dasharray]')).not.toBeNull() + + pointer('pointercancel', 200, 200) + expect(container.querySelector('line[stroke-dasharray]')).toBeNull() + + pointer('pointerup', 200, 200) + expect(useStore.getState().modal).toBeNull() + expect(useStore.getState().activeTool).toBe('select') + }) + + it('still adds a zone on an ordinary release', () => { + useStore.setState({ activeTool: 'zone' }) + const { container } = render() + const canvas = container.querySelector('[data-tour="seating.canvas"]')! + + fireEvent.pointerDown(canvas, { button: 0, clientX: 10, clientY: 10 }) + pointer('pointermove', 200, 200) + pointer('pointerup', 200, 200) + expect(Object.keys(useStore.getState().zones)).toHaveLength(1) + }) +}) diff --git a/suite/apps/tableaux/components/canvas/RoomCanvas.tsx b/suite/apps/tableaux/components/canvas/RoomCanvas.tsx index 8cf5317e..c88437cc 100644 --- a/suite/apps/tableaux/components/canvas/RoomCanvas.tsx +++ b/suite/apps/tableaux/components/canvas/RoomCanvas.tsx @@ -141,11 +141,9 @@ export default function RoomCanvas() { [canvas.panX, canvas.panY, canvas.zoom] ) - // TODO(ux-audit): startZoneDraw and startCalibrate below both register only - // pointermove/pointerup on window, no pointercancel (same bug class fixed - // in TableHandles.jsx, see tmp/ux-audit.md #C13) — a pointer release/ - // cancel outside the window can strand a draft zone or calibration line - // mid-draw. See tmp/ux-audit.md #C14. + // A cancelled pointer (a system gesture, an interrupted touch) is not a + // release: it drops the draft and commits nothing. Without listening for it + // the draft stayed on the canvas, still following the pointer. const startZoneDraw = (e: ReactPointerEvent) => { const start = screenToCanvas(e.clientX, e.clientY) const onMove = (ev: PointerEvent) => { @@ -153,9 +151,15 @@ export default function RoomCanvas() { draftRef.current = rect setDraftZone(rect) } - const onUp = () => { + const stop = () => { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', stop) + draftRef.current = null + setDraftZone(null) + setActiveTool('select') + } + const onUp = () => { const r = draftRef.current if (r && r.width > 16 && r.height > 16) { addZone({ @@ -167,12 +171,11 @@ export default function RoomCanvas() { shape: 'rect', }) } - draftRef.current = null - setDraftZone(null) - setActiveTool('select') + stop() } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', stop) } const startCalibrate = (e: ReactPointerEvent) => { @@ -183,11 +186,15 @@ export default function RoomCanvas() { line = { x1: start.x, y1: start.y, x2: end.x, y2: end.y } setDraftLine(line) } - const onUp = () => { + const stop = () => { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', stop) setDraftLine(null) setActiveTool('select') + } + const onUp = () => { + stop() if (line) { const dist = Math.hypot(line.x2 - line.x1, line.y2 - line.y1) if (dist > 8) openModal('calibrate', { pixelDistance: dist }) @@ -195,6 +202,7 @@ export default function RoomCanvas() { } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', stop) } // ── polygon room drawing: click to drop vertices, Enter/click-start to close ── diff --git a/suite/apps/tableaux/hooks/useCanvasPanZoom.ts b/suite/apps/tableaux/hooks/useCanvasPanZoom.ts index f4186b35..0c37e188 100644 --- a/suite/apps/tableaux/hooks/useCanvasPanZoom.ts +++ b/suite/apps/tableaux/hooks/useCanvasPanZoom.ts @@ -65,11 +65,6 @@ export function useCanvasPanZoom() { } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) - // TODO(ux-audit): no pointercancel listener (same bug class fixed in - // TableHandles.jsx — see tmp/ux-audit.md #C13). A pointer release/ - // cancel outside the window here leaves isPanning stuck true (cursor - // stuck "grabbing"). RoomCanvas.jsx's zone-draw/calibration-line - // dragging has the same gap. See tmp/ux-audit.md #C14. window.addEventListener('pointercancel', onUp) }, [setCanvas]