Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 161 additions & 2 deletions e2e/floorplan.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => requestAnimationFrame(resolve)));

await page.mouse.click(firstBox.centerX, firstBox.centerY);
await expect(page.getByTestId('properties-panel')).toBeVisible();
Expand Down Expand Up @@ -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<number> {
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
width: 100%;
height: 100%;
position: relative;
touch-action: none;
}

.dimInput {
Expand Down
100 changes: 100 additions & 0 deletions src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ export function DrawingCanvas() {
const [lastPlacedWallId, setLastPlacedWallId] = useState<string | null>(null);
const dimInputRef = useRef<HTMLInputElement>(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 {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -281,6 +374,7 @@ export function DrawingCanvas() {
// ── Pointer events ──────────────────────────────────────────────

function handlePointerDown(e: Konva.KonvaEventObject<PointerEvent>) {
if (isTwoFingerActiveRef.current) return;
const world = getPointerWorld();
if (!world) return;

Expand All @@ -297,6 +391,7 @@ export function DrawingCanvas() {
}

function handlePointerMove(e: Konva.KonvaEventObject<PointerEvent>) {
if (isTwoFingerActiveRef.current) return;
const world = getPointerWorld();
if (!world) return;

Expand Down Expand Up @@ -327,6 +422,7 @@ export function DrawingCanvas() {
}

function handlePointerUp(e: Konva.KonvaEventObject<PointerEvent>) {
if (isTwoFingerActiveRef.current) return;
const world = getPointerWorld();
if (!world || !pointerDown) {
setPointerDown(null);
Expand Down Expand Up @@ -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}
>
<Stage
ref={stageRef}
Expand Down
Loading