diff --git a/e2e/floorplan.spec.ts b/e2e/floorplan.spec.ts index a8eb00b..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(); @@ -962,3 +971,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..c5a8c0e 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,10 @@ 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} + data-element-count={elements.length} >