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]