From 353d3e55b92197657ec6fdc6efd5449cf685378c Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sun, 27 Sep 2026 21:41:58 +0800 Subject: [PATCH] Add frames: named regions that group and carry their shapes A shape belongs to the frame its centre is in. Dragging a frame moves its contents in one step, and a frame copies for AI, exports, briefs and draws in Mermaid and draw.io on its own. --- README.md | 3 + .../tasks/fl-83 - Excalidraw-feel-extras.md | 17 ++- docs/format.template.html | 5 + docs/llms-full.template.txt | 3 + e2e/frames.spec.js | 123 ++++++++++++++++++ public/docs/format/index.html | 5 + public/llms-full.txt | 3 + src/api/analytics.js | 2 +- src/components/canvas/FlowCanvas.vue | 100 +++++++++++++- src/components/canvas/FlowNodeCard.vue | 16 ++- src/components/export/ExportDialog.vue | 32 ++++- src/domain/__tests__/frames.spec.js | 101 ++++++++++++++ src/domain/brief.js | 21 ++- src/domain/constants.js | 17 ++- src/domain/drawio.js | 11 +- src/domain/frames.js | 77 +++++++++++ src/domain/graph.js | 4 +- src/domain/mermaid.js | 24 +++- src/domain/nodeMeta.js | 5 + src/domain/renderSvg.js | 42 ++++-- src/domain/shapes.js | 2 + src/stores/canvas.js | 14 ++ src/style.css | 6 + src/views/FlowView.vue | 6 + 24 files changed, 598 insertions(+), 41 deletions(-) create mode 100644 e2e/frames.spec.js create mode 100644 src/domain/__tests__/frames.spec.js create mode 100644 src/domain/frames.js diff --git a/README.md b/README.md index a51289f..1607f4d 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,9 @@ nobody hosts it yet: run it yourself, or hand over the `.flow` file or the brief (`4`/`C`, click one shape then another), Text (`5`/`T`, click the canvas to write), Pen (`6`/`P`), Eraser (`7`/`E`, click or drag over shapes and connections to delete them, in one undo step) and Laser (`8`/`K`, a fading pointer trail for presenting). Escape goes back to Select. +- **Frames.** A frame (from the library) is a named region behind the shapes inside it: dragging + it carries them along, in one undo step. From its menu, copy just that frame for AI or export it + on its own. The brief groups shapes by frame, and Mermaid draws frames as subgraphs. - **Quick to type.** On a blank diagram, just start typing: the first shape takes the words. Double-click empty canvas for a shape there. `Tab` adds the next step below the selected shape, connected and ready to name, as in Whimsical. `Ctrl+K` finds any action or shape by name. diff --git a/backlog/tasks/fl-83 - Excalidraw-feel-extras.md b/backlog/tasks/fl-83 - Excalidraw-feel-extras.md index bcb9608..9839264 100644 --- a/backlog/tasks/fl-83 - Excalidraw-feel-extras.md +++ b/backlog/tasks/fl-83 - Excalidraw-feel-extras.md @@ -1,11 +1,11 @@ --- id: FL-83 title: Excalidraw-feel extras -status: In Progress +status: Done assignee: - '@raj-khan' created_date: '2026-09-25 17:39' -updated_date: '2026-09-27 13:30' +updated_date: '2026-09-27 13:41' labels: - frontend - editing @@ -31,7 +31,7 @@ The quick, keyboard-first touches that make Excalidraw, tldraw and Whimsical fee - [x] #1 An eraser tool and a laser pointer - [x] #2 Typing on an empty canvas creates a shape; Tab adds a connected shape next to the selected one - [x] #3 A command palette (Ctrl+K) reaches every action and shape -- [ ] #4 Frames: a named region grouping shapes, exported and briefed on its own +- [x] #4 Frames: a named region grouping shapes, exported and briefed on its own @@ -49,4 +49,15 @@ The quick, keyboard-first touches that make Excalidraw, tldraw and Whimsical fee Part 1 verified: e2e/quick.spec.js (6: drag erase and one undo, laser trail fades with the document unchanged, typing on a blank diagram names the shape and picks no tool, double click adds, Tab adds a connected named shape, Ctrl+K adds a shape, opens Export, says when nothing matches, and closes); unit tests for withConnectedShape, withErased and filterCommands. e2e 131, vitest 261, lint and typecheck. The laser is left off a phone tool bar, which has room for seven 44px targets. + +Part 2, frames: a frame shape (560x360 by default) whose members are the shapes whose centres it holds (smallest frame wins, frames nest), so there is no membership list to keep in step. Drawn behind everything (zIndex -2000 survives selection elevation) with a dashed region and its name at the top left; not opened by a click. Dragging a frame carries its members and saves them in one moveNodes change. Brief lists frames in their own section; Mermaid draws subgraphs, nested; draw.io exports frames first and round-trips them (no container=1, which the importer flattens); the SVG renderer draws them under the connections. Frame menu: Rename, Copy frame for AI (a brief of frameDocument), Export frame (Export gains a what-to-export select), Delete frame. + +Part 2 verified: e2e/frames.spec.js (5: behind and labelled with members clickable, drag carries members with one undo, copy frame brief has only its members, frame SVG export has only its members, library adds a large frame); src/domain/**tests**/frames.spec.js (9: membership, nesting, frameDocument, brief, Mermaid subgraphs, draw.io round trip and order, SVG order, .flow round trip). e2e 136, vitest 270, lint and typecheck; screenshot checked. + +## Final Summary + + + +Excalidraw-feel extras in two PRs: drag erase and a fading laser; typing on a blank diagram, double click and Tab to add shapes; a Ctrl+K command palette over every action and shape; and frames, named regions that carry their contents and copy, export and brief on their own. Verified with quick.spec, frames.spec and unit tests. + diff --git a/docs/format.template.html b/docs/format.template.html index d9650ce..fce7a46 100644 --- a/docs/format.template.html +++ b/docs/format.template.html @@ -191,6 +191,11 @@

Every rule

button, input, card, list, image. +
  • + id = frame "Name" is a frame: a named region, with its size under + @layout. Every shape whose centre is inside it belongs to it, so the brief + groups them and Mermaid draws it as a subgraph. +
  • note: ... under the title is a note for the whole diagram, and id note: ... a note for one shape, one line each: instructions for whoever diff --git a/docs/llms-full.template.txt b/docs/llms-full.template.txt index 23dcab5..242ecb7 100644 --- a/docs/llms-full.template.txt +++ b/docs/llms-full.template.txt @@ -52,6 +52,9 @@ db 276,352 - `@layout` starts the positions, one `id x,y` per line, with ` WxH` after it for a resized shape. A node with no position is laid out automatically, so a hand-written diagram needs no layout block at all. +- `id = frame "Name"` is a frame: a named region, with its size under `@layout`. + Every shape whose centre is inside it belongs to it, so the brief groups them + and Mermaid draws it as a subgraph. - Pen strokes are `id = ink` shapes, with their points in an `@ink` block after the layout, so they never clutter the lines that say what the system is. - `style: sketch` under the title draws the diagram by hand. Leave it out for diff --git a/e2e/frames.spec.js b/e2e/frames.spec.js new file mode 100644 index 0000000..8fd155e --- /dev/null +++ b/e2e/frames.spec.js @@ -0,0 +1,123 @@ +import { expect, test } from '@playwright/test' + +import { history, openLibrary } from './helpers.js' + +const shapes = (page) => page.locator('.vue-flow__node') +const node = (page, id) => page.locator(`.vue-flow__node[data-id="${id}"]`) +const saved = (page) => page.evaluate(() => JSON.parse(localStorage.getItem('flow:document'))) +const positionOf = async (page, id) => + (await saved(page)).nodes.find((each) => each.id === id).position + +const at = (x, y) => ({ position: { x, y } }) +const SHOP = { + version: 3, + title: 'Shop', + nodes: [ + { + id: 'checkout', + type: 'frame', + name: 'Checkout', + data: {}, + ...at(0, 0), + size: { width: 640, height: 420 }, + }, + { id: 'pay', type: 'process', name: 'Pay', data: {}, ...at(40, 80) }, + { id: 'ship', type: 'process', name: 'Ship', data: {}, ...at(340, 240) }, + { id: 'mail', type: 'process', name: 'Mail', data: {}, ...at(820, 80) }, + ], + edges: [ + { id: 'e-pay-ship', source: 'pay', target: 'ship' }, + { id: 'e-ship-mail', source: 'ship', target: 'mail' }, + ], +} + +/** The frame's own surface, clear of the shapes inside it: near its name. */ +const frameLabel = (page) => node(page, 'checkout').getByRole('heading', { name: 'Checkout' }) + +test.beforeEach(async ({ page }) => { + await page.goto('/new') + await page.evaluate( + (document) => localStorage.setItem('flow:document', JSON.stringify(document)), + SHOP, + ) + await page.reload() + await expect(shapes(page)).toHaveCount(4) +}) + +test('a frame sits behind the shapes it holds, named at its top left', async ({ page }) => { + const frame = await node(page, 'checkout').boundingBox() + const label = await frameLabel(page).boundingBox() + expect(label.x - frame.x).toBeLessThan(30) + expect(label.y - frame.y).toBeLessThan(30) + + // Pay is inside the frame and still takes its own clicks. + await node(page, 'pay').click() + await expect(page).toHaveURL(/\/new\/node\/pay$/) +}) + +test('dragging a frame carries what is inside it, and one undo puts it all back', async ({ + page, +}) => { + const before = { pay: await positionOf(page, 'pay'), mail: await positionOf(page, 'mail') } + const box = await frameLabel(page).boundingBox() + await page.mouse.move(box.x + 10, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + 60, box.y + 40, { steps: 5 }) + await page.mouse.move(box.x + 130, box.y + 90, { steps: 10 }) + await page.mouse.up() + + await expect.poll(async () => (await positionOf(page, 'pay')).x).not.toBe(before.pay.x) + const frame = await positionOf(page, 'checkout') + const pay = await positionOf(page, 'pay') + expect(Math.round(pay.x - before.pay.x)).toBe(Math.round(frame.x)) + expect(Math.round(pay.y - before.pay.y)).toBe(Math.round(frame.y)) + expect(await positionOf(page, 'mail')).toEqual(before.mail) + + await history(page).getByRole('button', { name: 'Undo' }).click() + await expect.poll(async () => (await positionOf(page, 'pay')).x).toBe(before.pay.x) + expect(await positionOf(page, 'checkout')).toEqual({ x: 0, y: 0 }) +}) + +test('a frame copies for AI on its own, with only what it holds', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await frameLabel(page).click({ button: 'right' }) + await page + .getByRole('menu', { name: 'Shape' }) + .getByRole('menuitem', { name: 'Copy frame for AI' }) + .click() + + await expect(page.getByText(/Copied the Checkout frame/)).toBeVisible() + const brief = await page.evaluate(() => navigator.clipboard.readText()) + expect(brief).toMatch(/^# Checkout\n/) + expect(brief).toContain('**Pay** → **Ship**') + expect(brief).not.toContain('Mail') +}) + +test('a frame exports on its own', async ({ page }) => { + await frameLabel(page).click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Export frame' }).click() + + const dialog = page.getByRole('dialog', { name: 'Export' }) + await expect(dialog.getByRole('combobox')).toHaveValue('checkout') + await dialog.getByText('SVG', { exact: true }).click() + const [download] = await Promise.all([ + page.waitForEvent('download'), + dialog.getByRole('button', { name: /Download/ }).click(), + ]) + expect(download.suggestedFilename()).toBe('checkout.svg') + const svg = await (await download.createReadStream()).toArray() + const text = Buffer.concat(svg).toString('utf8') + expect(text).toContain('>Pay') + expect(text).not.toContain('>Mail') +}) + +test('the library adds a frame, large enough to hold a few shapes', async ({ page }) => { + const library = await openLibrary(page) + await library.getByRole('button', { name: 'Frame', exact: true }).click() + await expect(shapes(page)).toHaveCount(5) + const added = (await saved(page)).nodes.at(-1) + expect(added.type).toBe('frame') + const box = await page.locator(`.vue-flow__node[data-id="${added.id}"]`).boundingBox() + const pay = await node(page, 'pay').boundingBox() + expect(box.width).toBeGreaterThan(pay.width * 2) +}) diff --git a/public/docs/format/index.html b/public/docs/format/index.html index da53b32..522b7c2 100644 --- a/public/docs/format/index.html +++ b/public/docs/format/index.html @@ -222,6 +222,11 @@

    Every rule

    button, input, card, list, image.
  • +
  • + id = frame "Name" is a frame: a named region, with its size under + @layout. Every shape whose centre is inside it belongs to it, so the brief + groups them and Mermaid draws it as a subgraph. +
  • note: ... under the title is a note for the whole diagram, and id note: ... a note for one shape, one line each: instructions for whoever diff --git a/public/llms-full.txt b/public/llms-full.txt index 23dcab5..242ecb7 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -52,6 +52,9 @@ db 276,352 - `@layout` starts the positions, one `id x,y` per line, with ` WxH` after it for a resized shape. A node with no position is laid out automatically, so a hand-written diagram needs no layout block at all. +- `id = frame "Name"` is a frame: a named region, with its size under `@layout`. + Every shape whose centre is inside it belongs to it, so the brief groups them + and Mermaid draws it as a subgraph. - Pen strokes are `id = ink` shapes, with their points in an `@ink` block after the layout, so they never clutter the lines that say what the system is. - `style: sketch` under the title draws the diagram by hand. Leave it out for diff --git a/src/api/analytics.js b/src/api/analytics.js index 92e4858..08c9095 100644 --- a/src/api/analytics.js +++ b/src/api/analytics.js @@ -59,7 +59,7 @@ export function analyticsScript(id) { /** * Count an event, when analytics is on; otherwise nothing happens. * @param {'brief_copied' | 'exported' | 'published' | 'opened_from_link' | 'mcp_setup_copied'} name - * @param {Record} [params] + * @param {Record} [params] such as the scope of a brief, or an export's format */ export function track(name, params) { const page = diff --git a/src/components/canvas/FlowCanvas.vue b/src/components/canvas/FlowCanvas.vue index a33c1ea..a3b656a 100644 --- a/src/components/canvas/FlowCanvas.vue +++ b/src/components/canvas/FlowCanvas.vue @@ -56,7 +56,11 @@ import { EDIT_TEXT } from './editKey.js' import { RESIZE_NODE } from './resizeKey.js' import { LINE_STYLE, SKETCH } from './sketchKey.js' import { SHAPE_DRAG_TYPE } from '@/components/palette/dragType.js' -import { NODE_SIZE } from '@/domain/constants.js' +import { SHAPE, sizeOf } from '@/domain/constants.js' +import { frameDocument, frameMembers } from '@/domain/frames.js' +import { withPositions } from '@/domain/graph.js' +import { toBrief } from '@/domain/brief.js' +import { track } from '@/api/analytics.js' import { freeSpotNear } from '@/domain/layout.js' import FlowEdge from './FlowEdge.vue' import CanvasControls from './CanvasControls.vue' @@ -468,9 +472,63 @@ provide( */ const isDragging = ref(false) +/** + * A frame carries what is inside it: the shapes whose centres it holds when + * the drag starts, moved by as much as it is. + * @type {{ frame: string, from: { x: number, y: number }, members: { id: string, from: { x: number, y: number } }[] } | null} + */ +let carrying = null + +/** + * @param {import('@/domain/types.js').FlowDocument} document + * @param {string} frameId + */ +const frameMembersOf = (document, frameId) => frameMembers(document).get(frameId) ?? [] + +/** @param {{ node: import('@vue-flow/core').GraphNode, nodes: import('@vue-flow/core').GraphNode[] }} event */ +function onNodeDragStart({ node, nodes: dragged }) { + isDragging.value = true + carrying = null + if (node.data.node.type !== SHAPE.FRAME || (dragged?.length ?? 1) > 1 || !diagram.value) return + const onScreen = withPositions( + diagram.value, + Object.fromEntries(getNodes.value.map((each) => [each.id, { ...each.position }])), + ) + const members = frameMembersOf(onScreen, node.id) + carrying = { + frame: node.id, + from: { ...node.position }, + members: members.map((id) => ({ id, from: { ...(findNode(id)?.position ?? { x: 0, y: 0 }) } })), + } +} + +/** @param {{ node: import('@vue-flow/core').GraphNode }} event */ +function onNodeDrag({ node }) { + if (!carrying || node.id !== carrying.frame) return + const dx = node.position.x - carrying.from.x + const dy = node.position.y - carrying.from.y + carrying.members.forEach(({ id, from }) => + updateFlowNode(id, { position: { x: from.x + dx, y: from.y + dy } }), + ) +} + /** @param {{ node: import('@vue-flow/core').GraphNode, nodes: import('@vue-flow/core').GraphNode[] }} event */ function onNodeDragStop({ node, nodes: dragged }) { isDragging.value = false + // A frame and what it carried move as one change. + if (carrying && node.id === carrying.frame) { + const group = [node.id, ...carrying.members.map((member) => member.id)] + carrying = null + moveNodes.mutate({ + positions: Object.fromEntries( + group.map((id) => { + const at = findNode(id)?.position ?? { x: 0, y: 0 } + return [id, { x: at.x, y: at.y }] + }), + ), + }) + return + } // Plain objects, not Vue Flow's reactive positions. if (dragged?.length > 1) { moveNodes.mutate({ @@ -656,14 +714,14 @@ function centreOn(node) { * @param {{ exact?: boolean, typed?: boolean }} [options] typed: named by what was typed */ function addShape(shape, at, options = {}) { + const box = sizeOf({ type: shape }) const wanted = at - ? { - x: Math.round(at.x - NODE_SIZE.WIDTH / 2), - y: Math.round(at.y - NODE_SIZE.HEIGHT / 2), - } + ? { x: Math.round(at.x - box.width / 2), y: Math.round(at.y - box.height / 2) } : { x: 0, y: 0 } // A drop lands exactly where it was let go; a click finds room near the middle. - const position = options.exact ? wanted : freeSpotNear(wanted, nodes.value) + // A frame goes where it is asked for: its point is to go around things. + const position = + options.exact || shape === SHAPE.FRAME ? wanted : freeSpotNear(wanted, nodes.value) createNode.mutate( { title: options.typed ? '' : metaFor(shape).label, description: '', shape, position }, @@ -827,6 +885,14 @@ const menuItems = computed(() => { ] } const node = nodes.value.find((candidate) => candidate.id === at.id)?.data.node + if (node?.type === SHAPE.FRAME) { + return [ + { label: 'Rename', run: () => (editingId.value = at.id) }, + { label: 'Copy frame for AI', run: () => copyFrame(at.id) }, + { label: 'Export frame', run: () => canvas.requestExport(at.id) }, + { label: 'Delete frame', run: () => removeShapes([at.id]), danger: true }, + ] + } return [ ...(node && isOpenable(node) ? [{ label: 'Open details', run: () => openDetails(at.id) }] : []), { label: 'Rename', run: () => (editingId.value = at.id) }, @@ -835,6 +901,25 @@ const menuItems = computed(() => { ] }) +/** + * One frame as a brief of its own: what it holds, and how those connect. + * @param {string} id + */ +async function copyFrame(id) { + const part = diagram.value && frameDocument(diagram.value, id) + if (!part) return + try { + await navigator.clipboard.writeText(toBrief(part)) + } catch { + toasts.push('The brief could not be copied. Your browser refused the clipboard.', { + tone: 'danger', + }) + return + } + track('brief_copied', { scope: 'frame' }) + toasts.push(`Copied the ${part.title} frame. Paste it into Claude, Copilot or any coding agent.`) +} + /** @param {string} id */ function openDetails(id) { focus(id) @@ -946,7 +1031,8 @@ watch( @node-context-menu="onNodeMenu" @edge-context-menu="onEdgeMenu" @pane-click="onPaneClick" - @node-drag-start="isDragging = true" + @node-drag-start="onNodeDragStart" + @node-drag="onNodeDrag" @node-drag-stop="onNodeDragStop" @connect="onConnect" @connect-start="onConnectStart" diff --git a/src/components/canvas/FlowNodeCard.vue b/src/components/canvas/FlowNodeCard.vue index 45ca6f1..ba27b95 100644 --- a/src/components/canvas/FlowNodeCard.vue +++ b/src/components/canvas/FlowNodeCard.vue @@ -84,6 +84,8 @@ function onResizeEnd({ params }) { resize(props.id, params) } const isText = computed(() => node.value.type === SHAPE.TEXT) +/** A frame: a region behind the shapes it holds, named at its top left. */ +const isFrame = computed(() => node.value.type === SHAPE.FRAME) 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. */ @@ -108,7 +110,11 @@ const strokeWidth = computed(() => (props.selected || isKeyboardFocused.value ? :class="[ sketch ? 'font-sketch' : '', // A table reads top down: its name in the band, its columns below. - isTable ? 'justify-start pt-1.5 text-left' : 'justify-center text-center', + isFrame + ? 'items-start justify-start text-left' + : isTable + ? 'justify-start pt-1.5 text-left' + : 'justify-center text-center', isDropTarget && !acceptsDrop ? 'opacity-40' : '', isKeyboardFocused ? 'outline-2 outline-offset-4 outline-focus' : '', meta.openable ? 'cursor-pointer' : 'cursor-default', @@ -119,7 +125,7 @@ const strokeWidth = computed(() => (props.selected || isKeyboardFocused.value ? width: '100%', height: '100%', // A diamond's inset already leaves room; padding on top would leave none for text. - padding: isTable ? undefined : `${inset.y + 8}px ${inset.x || 12}px`, + padding: isFrame ? '10px 14px' : isTable ? undefined : `${inset.y + 8}px ${inset.x || 12}px`, paddingInline: isTable ? '12px' : undefined, }" :aria-label="`${meta.label}: ${node.name}${notes ? ', has notes for the builder' : ''}`" @@ -182,9 +188,11 @@ const strokeWidth = computed(() => (props.selected || isKeyboardFocused.value ? > (props.selected || isKeyboardFocused.value ?

    {{ node.name }}

    diff --git a/src/components/export/ExportDialog.vue b/src/components/export/ExportDialog.vue index 489229c..730c210 100644 --- a/src/components/export/ExportDialog.vue +++ b/src/components/export/ExportDialog.vue @@ -1,5 +1,5 @@