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]