diff --git a/README.md b/README.md index e2d3641..5399c65 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,11 @@ nobody hosts it yet: run it yourself, or hand over the `.flow` file or the brief and Eraser (`7`/`E`, click a shape or connection to delete it). Escape goes back to Select. - **New diagram and samples.** Start empty, or from a web app architecture or support flow sample. Undo brings back whatever was there. +- **Phones and tablets.** Below 768px the tool bar docks at the bottom and panels and dialogs rise + as sheets. Pinch to zoom and pan with two fingers anywhere, even over shapes; a long press (or a + right click) opens a shape's or connection's menu. A stylus draws with its pressure, and a palm + resting on the screen draws nothing. A link opened on a phone shows the diagram fitted and + read-only, with Copy for AI and Edit. - **Shape library.** The Shapes tool opens it over the canvas. Drag a shape onto the canvas to drop it there, or click it (or press Enter) to add it in a clear spot near the middle. - **Resize.** Select a shape and drag its handles; hold Shift to keep its proportions. diff --git a/backlog/tasks/fl-81 - Phones-and-tablets.md b/backlog/tasks/fl-81 - Phones-and-tablets.md index b60579a..9667e58 100644 --- a/backlog/tasks/fl-81 - Phones-and-tablets.md +++ b/backlog/tasks/fl-81 - Phones-and-tablets.md @@ -1,9 +1,11 @@ --- id: FL-81 title: Phones and tablets -status: To Do -assignee: [] +status: Done +assignee: + - '@raj-khan' created_date: '2026-09-25 17:39' +updated_date: '2026-09-27 13:08' labels: - frontend - responsive @@ -26,10 +28,44 @@ There are no responsive styles in src/ and no touch gestures. Anyone opening a s -- [ ] #1 Below 768px the tool bar docks at the bottom, the menu collapses to one button, panels and dialogs become bottom sheets, and nothing scrolls sideways at 360px -- [ ] #2 Pinch to zoom, two-finger pan, long press for the context menu, touch targets at least 44px -- [ ] #3 The pen supports stylus pressure and palm rejection -- [ ] #4 A shared or hosted link on a phone opens a fitted read-only view with Copy for AI and Edit -- [ ] #5 The e2e suite also runs at a phone and a tablet viewport +- [x] #1 Below 768px the tool bar docks at the bottom, the menu collapses to one button, panels and dialogs become bottom sheets, and nothing scrolls sideways at 360px +- [x] #2 Pinch to zoom, two-finger pan, long press for the context menu, touch targets at least 44px +- [x] #3 The pen supports stylus pressure and palm rejection +- [x] #4 A shared or hosted link on a phone opens a fitted read-only view with Copy for AI and Edit +- [x] #5 The e2e suite also runs at a phone and a tablet viewport + +## Implementation Plan + + + +1. Responsive shell below 768px (Tailwind max-md): tool bar docks bottom centre; the menu island is one button; undo/redo move to the top bar; view controls shrink to fit and zoom level; minimap hidden; library, text pane, details drawer and dialogs become bottom sheets; toasts sit above the tool bar; no sideways scroll at 360px. +2. Touch: touch-action none on the canvas (Vue Flow already pinch-zooms and pans with d3-zoom); 44px targets under pointer: coarse; a canvas context menu (Open, Rename, Duplicate, Delete; Remove for a connection) on right click and on a 500ms long press. +3. Pen: record pointer pressure per point as x,y,p; draw pressure strokes as a variable-width outline (perfect-freehand) in the app and the SVG renderer; once a stylus is seen, touch pointers do not draw (palm rejection). +4. Viewer: a diagram opened from a link on a phone-sized screen opens read-only and fitted, with Copy for AI and Edit. +5. Playwright phone (Pixel 7) and tablet (Galaxy Tab S4) projects running e2e/mobile/*.spec.js. + + + +## Implementation Notes + + + +Vue Flow cannot pinch when a finger lands on a shape: d3-drag on the node stops the touch propagating to d3-zoom. useTwoFingers handles two-finger pinch and pan in the capture phase, keeping the point between the fingers fixed, so it works over shapes and leaves no Move in history. + +Long press is a 500ms timer on touch/pen pointers (iOS sends no contextmenu); the ending tap is swallowed. The same menu opens on right click: Open details, Rename, Duplicate, Delete; Edit label and Remove for a connection. + +Pressure is stored as x,y,p per point only when a stylus drew; perfect-freehand turns it into a filled outline in the app and renderSvg. Plain strokes are unchanged. Palm rejection: once a stylus has drawn, touch never draws; a second pointer never joins a stroke. + +On a phone, undo/redo join the top bar and the view controls shrink to fit and zoom; the minimap hides. The viewer refits after the shared diagram is measured, because Vue Flow was already mounted with the previous one. + +Verified: e2e/mobile/touch.spec.js on phone (Pixel 7) and tablet (Galaxy Tab S4 landscape), 12 runs: no sideways scroll at 360px, tool bar position, 44px targets, CDP two-finger pinch and pan, long press menu and undo, sheets, pen pressure and palm, viewer fitted with Copy for AI and Edit; right-click menu in tools.spec.js; full e2e 119, vitest 247, lint and typecheck; screenshots at 360px, Pixel 7 and tablet. + + +## Final Summary + + + +Phones and tablets: a thumb-first layout below 768px with bottom sheets, 44px targets, pinch and two-finger pan anywhere, a long-press and right-click context menu, stylus pressure with palm rejection, and a fitted read-only viewer for links opened on a phone. Verified by phone and tablet Playwright projects and screenshots. + diff --git a/e2e/mobile/touch.spec.js b/e2e/mobile/touch.spec.js new file mode 100644 index 0000000..0f3d529 --- /dev/null +++ b/e2e/mobile/touch.spec.js @@ -0,0 +1,216 @@ +import { expect, test } from '@playwright/test' + +/** + * Touch, on a phone (Pixel 7) and a tablet (Galaxy Tab S4, landscape). The + * phone gets the phone layout; the tablet keeps the desktop one, by touch. + */ +const shapes = (page) => page.locator('.vue-flow__node') +const node = (page, id) => page.locator(`.vue-flow__node[data-id="${id}"]`) +const tools = (page) => page.getByRole('toolbar', { name: 'Tools' }) +const isPhone = (page) => page.viewportSize().width < 768 +const transform = (page) => + page.locator('.vue-flow__transformationpane').evaluate((element) => element.style.transform) +const zoomOf = async (page) => Number((await transform(page)).match(/scale\(([\d.]+)\)/)[1]) + +/** Two fingers, from one pair of points to another, in steps. */ +async function twoFingers(page, from, to) { + const cdp = await page.context().newCDPSession(page) + const points = (pair) => pair.map(([x, y], id) => ({ x, y, id })) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: points(from) }) + for (let step = 1; step <= 10; step += 1) { + const at = from.map(([x, y], index) => [ + x + ((to[index][0] - x) * step) / 10, + y + ((to[index][1] - y) * step) / 10, + ]) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: points(at) }) + } + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) +} + +/** A finger resting on a point for longer than a long press. */ +async function longPress(page, locator) { + const box = await locator.boundingBox() + const cdp = await page.context().newCDPSession(page) + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2, id: 0 } + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] }) + await page.waitForTimeout(700) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) +} + +test.beforeEach(async ({ page }) => { + await page.goto('/new') + await expect(shapes(page)).toHaveCount(5) +}) + +test('fits the screen: nothing scrolls sideways, and the tool bar sits where the thumb is', async ({ + page, +}) => { + if (isPhone(page)) await page.setViewportSize({ width: 360, height: 780 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe( + page.viewportSize().width, + ) + + const bar = await tools(page).boundingBox() + const height = page.viewportSize().height + if (isPhone(page)) expect(bar.y + bar.height).toBeGreaterThan(height - 24) + else expect(bar.y).toBeLessThan(24) + + for (const button of await page.locator('.island button:visible').all()) { + const box = await button.boundingBox() + expect( + Math.min(box.width, box.height), + await button.getAttribute('aria-label'), + ).toBeGreaterThanOrEqual(44) + } +}) + +test('pinching zooms, and two fingers pan', async ({ page }) => { + const { width, height } = page.viewportSize() + const [cx, cy] = [width / 2, height / 2] + const before = await zoomOf(page) + + await twoFingers( + page, + [ + [cx - 30, cy], + [cx + 30, cy], + ], + [ + [cx - 120, cy], + [cx + 120, cy], + ], + ) + await expect.poll(() => zoomOf(page)).toBeGreaterThan(before) + + const panned = await transform(page) + await twoFingers( + page, + [ + [cx - 40, cy], + [cx + 40, cy], + ], + [ + [cx - 40, cy + 120], + [cx + 40, cy + 120], + ], + ) + await expect.poll(() => transform(page)).not.toBe(panned) + + // A gesture over shapes moves the view, never a shape. + await expect( + page.getByRole('toolbar', { name: 'History' }).getByRole('button', { name: 'Undo' }), + ).toBeDisabled() +}) + +test('a long press opens the context menu, and its Delete can be undone', async ({ page }) => { + await longPress(page, node(page, 'e879e4')) + const menu = page.getByRole('menu', { name: 'Shape' }) + await expect(menu).toBeVisible() + await expect(menu.getByRole('menuitem')).toHaveText([ + 'Open details', + 'Rename', + 'Duplicate', + 'Delete', + ]) + + await menu.getByRole('menuitem', { name: 'Delete' }).tap() + await expect(shapes(page)).toHaveCount(4) + await page.getByRole('toolbar', { name: 'History' }).getByRole('button', { name: 'Undo' }).tap() + await expect(shapes(page)).toHaveCount(5) +}) + +test('panels rise as sheets on a phone, and float on a tablet', async ({ page }) => { + await tools(page).getByRole('button', { name: 'Shapes' }).tap() + const library = await page.getByRole('complementary', { name: 'Shapes' }).boundingBox() + const { width, height } = page.viewportSize() + if (isPhone(page)) { + expect(library.width).toBeCloseTo(width, 0) + expect(library.y + library.height).toBeCloseTo(height, 0) + } else { + expect(library.width).toBeLessThan(width / 2) + } +}) + +test('the pen takes a stylus’s pressure, and ignores a resting palm', async ({ page }) => { + await tools(page).getByRole('button', { name: 'Pen' }).tap() + const layer = page.getByTestId('pen-layer') + const box = await layer.boundingBox() + const at = (dx, dy) => ({ clientX: box.x + 100 + dx, clientY: box.y + 200 + dy }) + const send = (type, init) => + layer.dispatchEvent(type, { bubbles: true, isPrimary: true, button: 0, ...init }) + + await send('pointerdown', { pointerId: 7, pointerType: 'pen', pressure: 0.2, ...at(0, 0) }) + for (let step = 1; step <= 8; step += 1) { + await send('pointermove', { + pointerId: 7, + pointerType: 'pen', + pressure: 0.2 + step * 0.08, + ...at(step * 15, (step % 2) * 20), + }) + } + await send('pointerup', { pointerId: 7, pointerType: 'pen', ...at(120, 0) }) + await expect(page.getByTestId('ink-stroke')).toHaveCount(1) + + const ink = await page.evaluate(() => + JSON.parse(localStorage.getItem('flow:document')).nodes.find((n) => n.type === 'ink'), + ) + expect(ink.data.points.split(' ').every((point) => point.split(',').length === 3)).toBe(true) + + // A palm, after the stylus: it draws nothing. + await send('pointerdown', { pointerId: 9, pointerType: 'touch', ...at(0, 60) }) + await send('pointermove', { pointerId: 9, pointerType: 'touch', ...at(80, 90) }) + await send('pointerup', { pointerId: 9, pointerType: 'touch', ...at(80, 90) }) + await expect(page.getByTestId('ink-stroke')).toHaveCount(1) +}) + +test('a shared link opens read-only and fitted on a phone, with Copy for AI and Edit', async ({ + page, + context, +}) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + const hash = + '#flow=t' + + Buffer.from( + // Fanned out wider than a phone, so it has to be fitted to be seen. + [ + 'title: Checkout', + 'pay = process "Pay"', + ...['ship', 'mail', 'bill', 'log'].map((id) => `${id} = process "${id}"\npay -> ${id}`), + ].join('\n'), + 'utf8', + ).toString('base64url') + await page.goto(`/new${hash}`) + await expect(shapes(page)).toHaveCount(5) + + const viewer = page.getByRole('toolbar', { name: 'Shared diagram' }) + if (!isPhone(page)) { + await expect(viewer).toHaveCount(0) + await expect(tools(page)).toBeVisible() + return + } + + await expect(page.getByRole('heading', { name: 'Checkout' })).toBeVisible() + // Fitted: every shape is on screen. + const { width, height } = page.viewportSize() + await expect + .poll(async () => { + const boxes = await Promise.all( + (await shapes(page).all()).map((shape) => shape.boundingBox()), + ) + return boxes.every( + (box) => box.x >= 0 && box.x + box.width <= width && box.y + box.height <= height, + ) + }) + .toBe(true) + await expect(tools(page)).toHaveCount(0) + await node(page, 'pay').tap() + await expect(page).toHaveURL(/\/new$/) + + await viewer.getByRole('button', { name: 'Copy for AI' }).tap() + expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('Pay') + + await viewer.getByRole('button', { name: 'Edit' }).tap() + await expect(tools(page)).toBeVisible() + await node(page, 'pay').tap() + await expect(page).toHaveURL(/\/new\/node\/pay$/) +}) diff --git a/e2e/tools.spec.js b/e2e/tools.spec.js index 576cbdb..c0a7897 100644 --- a/e2e/tools.spec.js +++ b/e2e/tools.spec.js @@ -97,3 +97,20 @@ test('the text pane and the details drawer float and close', async ({ page }) => await page.getByRole('button', { name: 'Close details' }).click() await expect(page).toHaveURL(/\/new$/) }) + +test('a right click opens the context menu for a shape or a connection', async ({ page }) => { + await node(page, 'e879e4').click({ button: 'right' }) + const menu = page.getByRole('menu', { name: 'Shape' }) + await expect(menu.getByRole('menuitem').first()).toBeFocused() + await menu.getByRole('menuitem', { name: 'Duplicate' }).click() + await expect(shapes(page)).toHaveCount(6) + + await page.keyboard.press('Escape') + // A step line's box has no line at its centre, so the menu is asked for on the line itself. + await page + .locator('.vue-flow__edge[data-id="e-d09c08-b6a0c1"] path.cursor-pointer') + .dispatchEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10 }) + await expect(page.getByRole('menu', { name: 'Connection' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByRole('menu')).toHaveCount(0) +}) diff --git a/package-lock.json b/package-lock.json index 1ff0634..2b5addc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@vue-flow/minimap": "^1.5.4", "@vue-flow/node-resizer": "^1.5.1", "fflate": "^0.8.3", + "perfect-freehand": "^1.2.3", "pinia": "^4.0.3", "roughjs": "^4.6.6", "vue": "^3.5.42", @@ -3616,6 +3617,12 @@ "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "license": "MIT" }, + "node_modules/perfect-freehand": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.3.tgz", + "integrity": "sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 43279e9..60ae198 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@vue-flow/minimap": "^1.5.4", "@vue-flow/node-resizer": "^1.5.1", "fflate": "^0.8.3", + "perfect-freehand": "^1.2.3", "pinia": "^4.0.3", "roughjs": "^4.6.6", "vue": "^3.5.42", diff --git a/playwright.config.js b/playwright.config.js index 267f5ba..769955a 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -16,7 +16,16 @@ export default defineConfig({ trace: 'on-first-retry', }, - projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + // Desktop runs everything but the touch specs; a phone and a tablet run those. + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] }, testIgnore: /mobile\// }, + { name: 'phone', use: { ...devices['Pixel 7'] }, testMatch: /mobile\/.*\.spec\.js/ }, + { + name: 'tablet', + use: { ...devices['Galaxy Tab S4 landscape'] }, + testMatch: /mobile\/.*\.spec\.js/, + }, + ], // Against the production build, so E2E exercises what actually ships. webServer: { diff --git a/src/components/canvas/CanvasContextMenu.vue b/src/components/canvas/CanvasContextMenu.vue new file mode 100644 index 0000000..4356933 --- /dev/null +++ b/src/components/canvas/CanvasContextMenu.vue @@ -0,0 +1,113 @@ + + + diff --git a/src/components/canvas/CanvasControls.vue b/src/components/canvas/CanvasControls.vue index e973758..a64f30e 100644 --- a/src/components/canvas/CanvasControls.vue +++ b/src/components/canvas/CanvasControls.vue @@ -76,6 +76,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))