From 1f76c4c0e1572211c81935048a3b9e12d7f8e0e3 Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sun, 27 Sep 2026 21:08:33 +0800 Subject: [PATCH 1/2] Draw stylus strokes with their pressure, and ignore a resting palm Points keep a stylus's pressure as x,y,p, drawn as a filled outline with perfect-freehand in the app and in SVG. Once a stylus has drawn, touch never draws. --- package-lock.json | 7 +++ package.json | 1 + src/components/canvas/FlowNodeCard.vue | 14 +++++- src/components/canvas/PenLayer.vue | 33 ++++++++++--- src/domain/__tests__/ink.spec.js | 29 ++++++++++- src/domain/__tests__/renderSvg.spec.js | 20 ++++++++ src/domain/ink.js | 66 ++++++++++++++++++++++---- src/domain/renderSvg.js | 6 ++- 8 files changed, 156 insertions(+), 20 deletions(-) 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/src/components/canvas/FlowNodeCard.vue b/src/components/canvas/FlowNodeCard.vue index 7a1ae4e..14452c8 100644 --- a/src/components/canvas/FlowNodeCard.vue +++ b/src/components/canvas/FlowNodeCard.vue @@ -9,7 +9,7 @@ import { useShiftKey } from '@/composables/useShiftKey.js' import { RESIZE_NODE } from './resizeKey.js' import { SKETCH } from './sketchKey.js' import { sketchPath } from '@/domain/sketch.js' -import { inkPath } from '@/domain/ink.js' +import { inkOutline, inkPath } from '@/domain/ink.js' import { shapePath, textInset } from '@/domain/shapes.js' import { accentClasses } from './accents.js' import { FOCUSED_NODE_ID } from './focusKey.js' @@ -88,6 +88,10 @@ const isDecision = computed(() => node.value.type === SHAPE.DECISION) const isTable = computed(() => node.value.type === SHAPE.TABLE) /** A pen stroke: just its line, with no text, outline or connections. */ const isInk = computed(() => node.value.type === SHAPE.INK) +/** A stylus stroke is a filled outline that swells with the pressure. */ +const inkFill = computed(() => + isInk.value ? inkOutline(node.value.data?.points, size.value.width, size.value.height) : '', +) const inkD = computed(() => isInk.value ? inkPath(node.value.data?.points, size.value.width, size.value.height) : '', ) @@ -141,6 +145,14 @@ const strokeWidth = computed(() => (props.selected || isKeyboardFocused.value ? data-testid="ink-stroke" > + } */ const live = ref([]) -/** The same points on the page, for the diagram's coordinates at the end. */ -/** @type {{ x: number, y: number }[]} */ +/** The same points on the page, with a stylus's pressure, for the diagram at the end. */ +/** @type {{ x: number, y: number, p?: number }[]} */ let onPage = [] +/** The pointer drawing now; a second one, such as a resting palm, is ignored. */ +let drawing = -1 +/** Once a stylus has drawn, fingers are palms: they never draw. */ +let stylusSeen = false const layer = ref(/** @type {HTMLElement | null} */ (null)) const preview = computed(() => live.value.map(({ x, y }) => `${x},${y}`).join(' ')) @@ -31,9 +35,16 @@ const preview = computed(() => live.value.map(({ x, y }) => `${x},${y}`).join(' /** @param {PointerEvent} event */ function start(event) { if (event.button !== 0) return + if (event.pointerType === 'pen') stylusSeen = true + if ((stylusSeen && event.pointerType === 'touch') || drawing !== -1) return + drawing = event.pointerId event.preventDefault() const surface = /** @type {HTMLElement} */ (event.currentTarget) - surface.setPointerCapture(event.pointerId) + try { + surface.setPointerCapture(event.pointerId) + } catch { + // A pointer the browser no longer tracks cannot be captured; the stroke still draws. + } onPage = [] live.value = [] add(event) @@ -41,14 +52,22 @@ function start(event) { /** @param {PointerEvent} event */ function add(event) { - if (!layer.value || (event.type === 'pointermove' && !onPage.length)) return + if (!layer.value || event.pointerId !== drawing) return + if (event.type === 'pointermove' && !onPage.length) return const box = layer.value.getBoundingClientRect() - onPage.push({ x: event.clientX, y: event.clientY }) + const pressure = event.pointerType === 'pen' && event.pressure > 0 ? { p: event.pressure } : {} + onPage.push({ x: event.clientX, y: event.clientY, ...pressure }) live.value = [...live.value, { x: event.clientX - box.left, y: event.clientY - box.top }] } -function finish() { - const drawn = onPage.map((point) => screenToFlowCoordinate(point)) +/** @param {PointerEvent} event */ +function finish(event) { + if (event.pointerId !== drawing) return + drawing = -1 + const drawn = onPage.map(({ p, ...point }) => ({ + ...screenToFlowCoordinate(point), + ...(p === undefined ? {} : { p }), + })) onPage = [] live.value = [] const ink = strokeToInk(drawn) diff --git a/src/domain/__tests__/ink.spec.js b/src/domain/__tests__/ink.spec.js index afefdb8..151d9f6 100644 --- a/src/domain/__tests__/ink.spec.js +++ b/src/domain/__tests__/ink.spec.js @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { inkPath, simplify, strokeToInk } from '../ink.js' +import { inkOutline, inkPath, simplify, strokeToInk } from '../ink.js' describe('strokeToInk', () => { it('boxes a stroke with room for its line, and keeps its points across the box', () => { @@ -49,3 +49,30 @@ describe('inkPath', () => { expect(inkPath('nonsense', 10, 10)).toBe('') }) }) + +describe('stylus pressure', () => { + const pressed = [ + { x: 0, y: 0, p: 0.2 }, + { x: 50, y: 20, p: 0.9 }, + { x: 100, y: 0, p: 0.4 }, + ] + + it('keeps each point’s pressure, as x,y,p', () => { + const ink = strokeToInk(pressed) + expect(ink.points.split(' ').every((point) => point.split(',').length === 3)).toBe(true) + expect(ink.points).toMatch(/,0\.9\b/) + }) + + it('leaves a stroke without pressure as x,y pairs', () => { + const ink = strokeToInk(pressed.map(({ x, y }) => ({ x, y }))) + expect(ink.points.split(' ').every((point) => point.split(',').length === 2)).toBe(true) + }) + + it('draws pressure as a filled outline, and plain strokes as a line', () => { + const ink = strokeToInk(pressed) + expect(inkOutline(ink.points, ink.size.width, ink.size.height)).toMatch(/^M[\d.,-]+ L.* Z$/) + const plain = strokeToInk(pressed.map(({ x, y }) => ({ x, y }))) + expect(inkOutline(plain.points, 100, 40)).toBe('') + expect(inkPath(ink.points, ink.size.width, ink.size.height)).toMatch(/^M/) + }) +}) diff --git a/src/domain/__tests__/renderSvg.spec.js b/src/domain/__tests__/renderSvg.spec.js index 39a7509..cfcc491 100644 --- a/src/domain/__tests__/renderSvg.spec.js +++ b/src/domain/__tests__/renderSvg.spec.js @@ -123,6 +123,26 @@ describe('sizes', () => { expect(svg).not.toContain(' { + const svg = renderSvg({ + version: 3, + title: 'T', + nodes: [ + { + id: 'ink-1', + type: 'ink', + name: '', + data: { points: '0,0,0.2 50,60,0.9 100,100,0.4' }, + position: { x: 0, y: 0 }, + size: { width: 100, height: 40 }, + }, + ], + edges: [], + }) + + expect(svg).toMatch(//) + }) + it('draws a resized node at its size, and frames it', () => { const svg = renderSvg( { diff --git a/src/domain/ink.js b/src/domain/ink.js index f9fdde7..4f0b36a 100644 --- a/src/domain/ink.js +++ b/src/domain/ink.js @@ -1,7 +1,10 @@ +import { getStroke } from 'perfect-freehand' + /** * Pen strokes: marks drawn by hand over the diagram. A stroke is stored as * points from 0 to 100 across its own box, so it moves and resizes like any - * shape, and is drawn by scaling them to whatever size the box has. + * shape, and is drawn by scaling them to whatever size the box has. A stylus + * adds its pressure to each point (`x,y,p`), and the line swells with it. */ /** The box's padding around a stroke, so its line is never clipped. */ @@ -10,7 +13,7 @@ const PAD = 6 const TOLERANCE = 1.5 /** - * @typedef {{ x: number, y: number }} Point + * @typedef {{ x: number, y: number, p?: number }} Point */ /** @@ -32,8 +35,12 @@ export function strokeToInk(drawn) { const height = Math.max(...ys) - top + PAD if (width <= 2 * PAD + 2 && height <= 2 * PAD + 2) return null + const pressed = points.some((point) => point.p !== undefined && point.p > 0) const scaled = points - .map(({ x, y }) => `${tenth(((x - left) / width) * 100)},${tenth(((y - top) / height) * 100)}`) + .map(({ x, y, p }) => { + const at = `${tenth(((x - left) / width) * 100)},${tenth(((y - top) / height) * 100)}` + return pressed ? `${at},${hundredth(p ?? 0.5)}` : at + }) .join(' ') return { position: { x: Math.round(left), y: Math.round(top) }, @@ -42,6 +49,48 @@ export function strokeToInk(drawn) { } } +/** + * @param {string | undefined} points + * @param {number} width + * @param {number} height + * @returns {{ x: number, y: number, p?: number }[]} + */ +function scale(points, width, height) { + return String(points ?? '') + .trim() + .split(/\s+/) + .filter(Boolean) + .map((triple) => triple.split(',').map(Number)) + .filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y)) + .map(([x, y, p]) => ({ + x: tenth((x / 100) * width), + y: tenth((y / 100) * height), + ...(Number.isFinite(p) ? { p } : {}), + })) +} + +/** + * A stroke drawn with a stylus, as the outline of a line that swells and thins + * with the pressure, to be filled. Empty for a stroke without pressure, which + * inkPath draws instead. + * + * @param {string | undefined} points + * @param {number} width + * @param {number} height + * @returns {string} + */ +export function inkOutline(points, width, height) { + const at = scale(points, width, height) + if (at.length < 2 || !at.every((point) => point.p !== undefined)) return '' + const outline = getStroke( + at.map(({ x, y, p }) => [x, y, p ?? 0.5]), + { size: 5, thinning: 0.6, smoothing: 0.5, streamline: 0.3, simulatePressure: false }, + ) + if (outline.length < 3) return '' + const [first, ...rest] = outline + return `M${tenth(first[0])},${tenth(first[1])} ${rest.map(([x, y]) => `L${tenth(x)},${tenth(y)}`).join(' ')} Z` +} + /** * Path data for a stroke in a `width` by `height` box, smoothed through the * midpoints so a few points still read as a hand-drawn curve. @@ -52,13 +101,7 @@ export function strokeToInk(drawn) { * @returns {string} */ export function inkPath(points, width, height) { - const at = String(points ?? '') - .trim() - .split(/\s+/) - .filter(Boolean) - .map((pair) => pair.split(',').map(Number)) - .filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y)) - .map(([x, y]) => ({ x: tenth((x / 100) * width), y: tenth((y / 100) * height) })) + const at = scale(points, width, height) if (at.length < 2) return '' if (at.length === 2) return `M${at[0].x},${at[0].y} L${at[1].x},${at[1].y}` @@ -114,3 +157,6 @@ function fromLine(point, a, b) { /** @param {number} value */ const tenth = (value) => Math.round(value * 10) / 10 + +/** @param {number} value */ +const hundredth = (value) => Math.round(Math.min(Math.max(value, 0), 1) * 100) / 100 diff --git a/src/domain/renderSvg.js b/src/domain/renderSvg.js index 4eee80a..7018db9 100644 --- a/src/domain/renderSvg.js +++ b/src/domain/renderSvg.js @@ -5,7 +5,7 @@ import { metaFor } from './nodeMeta.js' import { shapePath, textInset } from './shapes.js' import { isSketch, SKETCH_FONT, sketchPath } from './sketch.js' import { LINE, routeEdge } from './routes.js' -import { inkPath } from './ink.js' +import { inkOutline, inkPath } from './ink.js' /** * The app's colour tokens, copied from `style.css` so the renderer runs where @@ -143,6 +143,10 @@ export function renderSvg( */ function renderInk(node, position, colours, change) { const { width, height } = sizeOf(node) + const colour = change ? colours.changes[change] : colours.ink + const g = `` + const outline = inkOutline(node.data?.points, width, height) + if (outline) return `${g}` const d = inkPath(node.data?.points, width, height) if (!d) return '' return `` From 8a3e902488e6f3b1bd1c7c0923147a3060b6c457 Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sun, 27 Sep 2026 21:08:35 +0800 Subject: [PATCH 2/2] Fit isketch to phones and tablets Below 768px the tool bar docks at the bottom and panels become sheets. Two fingers pinch and pan anywhere, a long press or right click opens a context menu, and a link opened on a phone shows a fitted read-only view. Phone and tablet Playwright projects run the touch specs. --- README.md | 5 + backlog/tasks/fl-81 - Phones-and-tablets.md | 50 ++++- e2e/mobile/touch.spec.js | 216 ++++++++++++++++++++ e2e/tools.spec.js | 17 ++ playwright.config.js | 11 +- src/components/canvas/CanvasContextMenu.vue | 113 ++++++++++ src/components/canvas/CanvasControls.vue | 6 + src/components/canvas/FlowCanvas.vue | 116 ++++++++++- src/components/drawer/NodeDetailsDrawer.vue | 2 +- src/components/shell/MainMenu.vue | 4 +- src/components/ui/BaseModal.vue | 6 +- src/components/ui/ToastHost.vue | 2 +- src/composables/useCanvasClipboard.js | 8 + src/composables/useLongPress.js | 70 +++++++ src/composables/useMediaQuery.js | 17 ++ src/composables/useShareLink.js | 5 + src/composables/useTwoFingers.js | 78 +++++++ src/stores/canvas.js | 13 ++ src/style.css | 44 ++++ src/views/FlowView.vue | 135 ++++++++---- 20 files changed, 852 insertions(+), 66 deletions(-) create mode 100644 e2e/mobile/touch.spec.js create mode 100644 src/components/canvas/CanvasContextMenu.vue create mode 100644 src/composables/useLongPress.js create mode 100644 src/composables/useMediaQuery.js create mode 100644 src/composables/useTwoFingers.js 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/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))