From 44f593c3d0a43bcb7ab33213584af8ee4cbf0c65 Mon Sep 17 00:00:00 2001 From: Rohit Rajendran Date: Sun, 22 Mar 2026 11:24:17 -0400 Subject: [PATCH 1/2] fix: two-finger pinch zoom and pan on touch screens (iPad) Handle native TouchEvents for two-finger gestures so pinch-to-zoom and two-finger pan work on actual touch screens. The previous wheel-based handler only covered trackpad gestures. Guards on pointer handlers prevent accidental drawing actions during touch navigation. - Add touch event listeners (touchstart/move/end) with pinch-zoom math that mirrors the existing trackpad zoom (midpoint anchored in world space) - Add touch-action:none on the canvas div to suppress browser native gestures - Expose data-zoom/pan-x/pan-y attributes for test observability - Add E2E tests covering pinch-in, pinch-out, pan, and no accidental wall placement --- e2e/floorplan.spec.ts | 150 ++++++++++++++++++ .../DrawingCanvas/DrawingCanvas.module.css | 1 + .../Canvas/DrawingCanvas/DrawingCanvas.tsx | 99 ++++++++++++ 3 files changed, 250 insertions(+) diff --git a/e2e/floorplan.spec.ts b/e2e/floorplan.spec.ts index a8eb00b..2ae4202 100644 --- a/e2e/floorplan.spec.ts +++ b/e2e/floorplan.spec.ts @@ -962,3 +962,153 @@ test.describe('Import / Export / QR', () => { await expect(page.getByTestId('share-modal')).not.toBeVisible(); }); }); + +// ─── Touch gestures ─────────────────────────────────────────────────────────── + +/** Dispatch a synthetic two-finger touch gesture on an element. + * Fires touchstart → N touchmove steps → touchend. + * Coordinates are relative to the page (clientX/Y). */ +async function simulatePinch( + page: Page, + opts: { + /** Starting midpoint (page coords) */ + midX: number; + midY: number; + /** Half-distance between fingers at start */ + startRadius: number; + /** Half-distance between fingers at end */ + endRadius: number; + /** How much the midpoint translates during the gesture */ + deltaX?: number; + deltaY?: number; + steps?: number; + }, +) { + const { midX, midY, startRadius, endRadius, deltaX = 0, deltaY = 0, steps = 8 } = opts; + + await page.evaluate( + ({ midX, midY, startRadius, endRadius, deltaX, deltaY, steps }) => { + const target = document.querySelector('[data-testid="drawing-canvas"]') as HTMLElement; + if (!target) throw new Error('canvas not found'); + + function makeTouch(id: number, x: number, y: number): Touch { + return new Touch({ identifier: id, target, clientX: x, clientY: y, pageX: x, pageY: y }); + } + + function fire(type: string, t1: Touch, t2: Touch) { + const evt = new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches: type === 'touchend' ? [] : [t1, t2], + changedTouches: [t1, t2], + targetTouches: type === 'touchend' ? [] : [t1, t2], + }); + target.dispatchEvent(evt); + } + + // touchstart + fire( + 'touchstart', + makeTouch(1, midX - startRadius, midY), + makeTouch(2, midX + startRadius, midY), + ); + + // touchmove steps + for (let i = 1; i <= steps; i++) { + const t = i / steps; + const r = startRadius + (endRadius - startRadius) * t; + const mx = midX + deltaX * t; + const my = midY + deltaY * t; + fire('touchmove', makeTouch(1, mx - r, my), makeTouch(2, mx + r, my)); + } + + // touchend + const finalR = endRadius; + const finalMx = midX + deltaX; + const finalMy = midY + deltaY; + fire( + 'touchend', + makeTouch(1, finalMx - finalR, finalMy), + makeTouch(2, finalMx + finalR, finalMy), + ); + }, + { midX, midY, startRadius, endRadius, deltaX, deltaY, steps }, + ); +} + +async function getCanvasZoom(page: Page): Promise { + const val = await page.getByTestId('drawing-canvas').getAttribute('data-zoom'); + return parseFloat(val ?? '1'); +} + +async function getCanvasPan(page: Page): Promise<{ x: number; y: number }> { + const [px, py] = await Promise.all([ + page.getByTestId('drawing-canvas').getAttribute('data-pan-x'), + page.getByTestId('drawing-canvas').getAttribute('data-pan-y'), + ]); + return { x: parseFloat(px ?? '0'), y: parseFloat(py ?? '0') }; +} + +test.describe('Touch gestures', () => { + test.beforeEach(({ page }) => setup(page)); + + test('canvas has touch-action none to prevent browser interference', async ({ page }) => { + const touchAction = await page + .getByTestId('drawing-canvas') + .evaluate((el) => window.getComputedStyle(el).touchAction); + expect(touchAction).toBe('none'); + }); + + test('two-finger pinch-out zooms in', async ({ page }) => { + const { cx, cy } = await canvasCenter(page); + const before = await getCanvasZoom(page); + + await simulatePinch(page, { midX: cx, midY: cy, startRadius: 40, endRadius: 120 }); + + const after = await getCanvasZoom(page); + expect(after).toBeGreaterThan(before); + }); + + test('two-finger pinch-in zooms out', async ({ page }) => { + const { cx, cy } = await canvasCenter(page); + // Start zoomed in so there is room to zoom out + await simulatePinch(page, { midX: cx, midY: cy, startRadius: 40, endRadius: 160 }); + const before = await getCanvasZoom(page); + + await simulatePinch(page, { midX: cx, midY: cy, startRadius: 160, endRadius: 40 }); + + const after = await getCanvasZoom(page); + expect(after).toBeLessThan(before); + }); + + test('two-finger translate pans the canvas', async ({ page }) => { + const { cx, cy } = await canvasCenter(page); + const before = await getCanvasPan(page); + + // Move both fingers 80px to the right without changing pinch distance + await simulatePinch(page, { + midX: cx, + midY: cy, + startRadius: 60, + endRadius: 60, + deltaX: 80, + deltaY: 0, + }); + + const after = await getCanvasPan(page); + expect(after.x).toBeGreaterThan(before.x); + }); + + test('two-finger gesture does not place a wall segment', async ({ page }) => { + // Wall tool is active by default + const { cx, cy } = await canvasCenter(page); + const elementsBefore = await getActivePlanElements(page); + + await simulatePinch(page, { midX: cx, midY: cy, startRadius: 40, endRadius: 120 }); + // Wait a tick for any deferred state updates + await page.waitForTimeout(100); + + const elementsAfter = await getActivePlanElements(page); + expect(elementsAfter.length).toBe(elementsBefore.length); + }); +}); diff --git a/src/components/Canvas/DrawingCanvas/DrawingCanvas.module.css b/src/components/Canvas/DrawingCanvas/DrawingCanvas.module.css index e9baf36..edd7bd7 100644 --- a/src/components/Canvas/DrawingCanvas/DrawingCanvas.module.css +++ b/src/components/Canvas/DrawingCanvas/DrawingCanvas.module.css @@ -2,6 +2,7 @@ width: 100%; height: 100%; position: relative; + touch-action: none; } .dimInput { diff --git a/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx b/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx index 2a0eace..f072bfc 100644 --- a/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx +++ b/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx @@ -71,6 +71,13 @@ export function DrawingCanvas() { const [lastPlacedWallId, setLastPlacedWallId] = useState(null); const dimInputRef = useRef(null); const lastWallTapRef = useRef<{ time: number; point: Point | null }>({ time: 0, point: null }); + const touchGestureRef = useRef<{ + initialDist: number; + initialZoom: number; + initialPan: { x: number; y: number }; + initialMidpoint: { x: number; y: number }; + } | null>(null); + const isTwoFingerActiveRef = useRef(false); const { activePlan, addElement, updateElements, deleteElements } = useFloorplanStore(); const { @@ -164,6 +171,92 @@ export function DrawingCanvas() { return () => ro.disconnect(); }, []); + // ── Two-finger touch: pinch → zoom, translate → pan ───────────── + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + function getTouchDist(t1: Touch, t2: Touch) { + const dx = t1.clientX - t2.clientX; + const dy = t1.clientY - t2.clientY; + return Math.sqrt(dx * dx + dy * dy); + } + + function getTouchMidpoint(t1: Touch, t2: Touch) { + return { + x: (t1.clientX + t2.clientX) / 2, + y: (t1.clientY + t2.clientY) / 2, + }; + } + + function handleTouchStart(e: TouchEvent) { + if (e.touches.length === 2) { + isTwoFingerActiveRef.current = true; + const t1 = e.touches[0]; + const t2 = e.touches[1]; + const { zoom: currentZoom, pan: currentPan } = useToolStore.getState(); + touchGestureRef.current = { + initialDist: getTouchDist(t1, t2), + initialZoom: currentZoom, + initialPan: { ...currentPan }, + initialMidpoint: getTouchMidpoint(t1, t2), + }; + // Cancel any in-progress single-finger action + setPointerDown(null); + setMarquee(null); + e.preventDefault(); + } + } + + function handleTouchMove(e: TouchEvent) { + if (e.touches.length === 2 && touchGestureRef.current) { + e.preventDefault(); + const t1 = e.touches[0]; + const t2 = e.touches[1]; + const { initialDist, initialZoom, initialPan, initialMidpoint } = touchGestureRef.current; + + const currentDist = getTouchDist(t1, t2); + const currentMidpoint = getTouchMidpoint(t1, t2); + + const scaleRatio = currentDist / initialDist; + const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, initialZoom * scaleRatio)); + + // Keep the initial pinch midpoint anchored in world space + const worldUnderMidpoint = { + x: (initialMidpoint.x - initialPan.x) / initialZoom, + y: (initialMidpoint.y - initialPan.y) / initialZoom, + }; + + useToolStore.setState({ + zoom: newZoom, + pan: { + x: currentMidpoint.x - worldUnderMidpoint.x * newZoom, + y: currentMidpoint.y - worldUnderMidpoint.y * newZoom, + }, + }); + } + } + + function handleTouchEnd(e: TouchEvent) { + if (e.touches.length < 2) { + touchGestureRef.current = null; + // Brief delay so the finger-lift pointer event doesn't trigger a tap + setTimeout(() => { + isTwoFingerActiveRef.current = false; + }, 50); + } + } + + container.addEventListener('touchstart', handleTouchStart, { passive: false }); + container.addEventListener('touchmove', handleTouchMove, { passive: false }); + container.addEventListener('touchend', handleTouchEnd); + return () => { + container.removeEventListener('touchstart', handleTouchStart); + container.removeEventListener('touchmove', handleTouchMove); + container.removeEventListener('touchend', handleTouchEnd); + }; + }, []); + // On mount: if the active plan has content, fit it to screen and switch to select tool. // Otherwise leave the default wall tool so the user can start drawing immediately. useEffect(() => { @@ -281,6 +374,7 @@ export function DrawingCanvas() { // ── Pointer events ────────────────────────────────────────────── function handlePointerDown(e: Konva.KonvaEventObject) { + if (isTwoFingerActiveRef.current) return; const world = getPointerWorld(); if (!world) return; @@ -297,6 +391,7 @@ export function DrawingCanvas() { } function handlePointerMove(e: Konva.KonvaEventObject) { + if (isTwoFingerActiveRef.current) return; const world = getPointerWorld(); if (!world) return; @@ -327,6 +422,7 @@ export function DrawingCanvas() { } function handlePointerUp(e: Konva.KonvaEventObject) { + if (isTwoFingerActiveRef.current) return; const world = getPointerWorld(); if (!world || !pointerDown) { setPointerDown(null); @@ -673,6 +769,9 @@ export function DrawingCanvas() { aria-label="Floor plan canvas. Use toolbar to select drawing tools." tabIndex={0} data-testid="drawing-canvas" + data-zoom={zoom} + data-pan-x={pan.x} + data-pan-y={pan.y} > Date: Sun, 22 Mar 2026 16:59:39 -0400 Subject: [PATCH 2/2] fix: stabilize flaky multi-select undo E2E test Root cause: Konva repaints its hit canvas asynchronously via requestAnimationFrame after React commits. Clicking within that frame window caused the hit test to read the old hit canvas (no elements), landing on the stage background instead of the restored box. The fix adds a data-element-count attribute to the canvas div (set during React's render phase, synchronized with Konva node creation) and waits for both: the attribute to reflect the restored count, then one requestAnimationFrame to let Konva finish painting the hit canvas. This was a pre-existing failure on main (present since commit 64df94f). --- e2e/floorplan.spec.ts | 13 +++++++++++-- .../Canvas/DrawingCanvas/DrawingCanvas.tsx | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/e2e/floorplan.spec.ts b/e2e/floorplan.spec.ts index 2ae4202..0d9806c 100644 --- a/e2e/floorplan.spec.ts +++ b/e2e/floorplan.spec.ts @@ -565,8 +565,17 @@ test.describe('Multi-select', () => { await expect(page.getByTestId('multi-select-bar')).not.toBeVisible(); await page.keyboard.press('Meta+z'); - // Wait for React to commit the undo state change before clicking - await expect(page.getByTestId('tool-redo')).not.toBeDisabled(); + // Wait for React to commit the restored elements (data-element-count reflects elements.length + // from the render, which is in the same commit as Konva node creation). + await page.waitForFunction( + (count) => + document.querySelector('[data-testid="drawing-canvas"]')?.getAttribute('data-element-count') === + String(count), + 2, + ); + // Konva repaints its hit canvas via requestAnimationFrame after React commits. + // Clicking before that frame fires would miss the new nodes. Wait past it. + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve))); await page.mouse.click(firstBox.centerX, firstBox.centerY); await expect(page.getByTestId('properties-panel')).toBeVisible(); diff --git a/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx b/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx index f072bfc..c5a8c0e 100644 --- a/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx +++ b/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx @@ -772,6 +772,7 @@ export function DrawingCanvas() { data-zoom={zoom} data-pan-x={pan.x} data-pan-y={pan.y} + data-element-count={elements.length} >