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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 14 additions & 3 deletions backlog/tasks/fl-83 - Excalidraw-feel-extras.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

<!-- AC:END -->

Expand All @@ -49,4 +49,15 @@ The quick, keyboard-first touches that make Excalidraw, tldraw and Whimsical fee
<!-- SECTION:NOTES:BEGIN -->

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.
<!-- SECTION:NOTES:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->

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.
<!-- SECTION:FINAL_SUMMARY:END -->
5 changes: 5 additions & 0 deletions docs/format.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ <h2>Every rule</h2>
<code>button</code>, <code>input</code>, <code>card</code>, <code>list</code>,
<code>image</code>.
</li>
<li>
<code>id = frame "Name"</code> is a frame: a named region, with its size under
<code>@layout</code>. Every shape whose centre is inside it belongs to it, so the brief
groups them and Mermaid draws it as a subgraph.
</li>
<li>
<code>note: ...</code> under the title is a note for the whole diagram, and
<code>id note: ...</code> a note for one shape, one line each: instructions for whoever
Expand Down
3 changes: 3 additions & 0 deletions docs/llms-full.template.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions e2e/frames.spec.js
Original file line number Diff line number Diff line change
@@ -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</text>')
expect(text).not.toContain('>Mail</text>')
})

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)
})
5 changes: 5 additions & 0 deletions public/docs/format/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,11 @@ <h2>Every rule</h2>
<code>button</code>, <code>input</code>, <code>card</code>, <code>list</code>,
<code>image</code>.
</li>
<li>
<code>id = frame "Name"</code> is a frame: a named region, with its size under
<code>@layout</code>. Every shape whose centre is inside it belongs to it, so the brief
groups them and Mermaid draws it as a subgraph.
</li>
<li>
<code>note: ...</code> under the title is a note for the whole diagram, and
<code>id note: ...</code> a note for one shape, one line each: instructions for whoever
Expand Down
3 changes: 3 additions & 0 deletions public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/api/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>} [params]
* @param {Record<string, string>} [params] such as the scope of a brief, or an export's format
*/
export function track(name, params) {
const page =
Expand Down
100 changes: 93 additions & 7 deletions src/components/canvas/FlowCanvas.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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) },
Expand All @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading