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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,12 @@ nobody hosts it yet: run it yourself, or hand over the `.flow` file or the brief
the menu (file, import, export, view, help) at the top left, the tool bar at the top centre,
Share and Copy for AI at the top right, undo and the view controls at the bottom left.
- **Tools** on number keys and letters: Select (`1`/`V`), Hand (`2`/`H`), Shapes (`3`), Connector
(`4`/`C`, click one shape then another), Text (`5`/`T`, click the canvas to write), Pen (`6`/`P`)
and Eraser (`7`/`E`, click a shape or connection to delete it). Escape goes back to Select.
(`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.
- **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.
- **New diagram and samples.** Start empty, or from a web app architecture or support flow
sample. Undo brings back whatever was there.
- **Installable.** Install isketch from the browser as an app that opens with no connection. The
Expand Down
28 changes: 23 additions & 5 deletions backlog/tasks/fl-83 - Excalidraw-feel-extras.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
---
id: FL-83
title: Excalidraw-feel extras
status: To Do
assignee: []
status: In Progress
assignee:
- '@raj-khan'
created_date: '2026-09-25 17:39'
updated_date: '2026-09-27 13:30'
labels:
- frontend
- editing
Expand All @@ -26,9 +28,25 @@ The quick, keyboard-first touches that make Excalidraw, tldraw and Whimsical fee

<!-- AC:BEGIN -->

- [ ] #1 An eraser tool and a laser pointer
- [ ] #2 Typing on an empty canvas creates a shape; Tab adds a connected shape next to the selected one
- [ ] #3 A command palette (Ctrl+K) reaches every action and shape
- [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

<!-- AC:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->

1. Part 1 (one PR): an eraser layer that deletes everything it is dragged over as one change; a laser pointer tool (8/K) whose trail fades and saves nothing; typing on a blank diagram starts a shape named with the keys (buffered until its title field has focus, ahead of the one-letter tool keys); a double click on empty canvas adds a shape there; Tab adds a connected shape below the selected one (withConnectedShape); a Ctrl+K command palette over every menu action, tool, view action and shape.
2. Part 2 (second PR): frames, a named region grouping the shapes inside it, moved together, and exported and briefed on its own.

<!-- SECTION:PLAN:END -->

## Implementation Notes

<!-- 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.
<!-- SECTION:NOTES:END -->
111 changes: 111 additions & 0 deletions e2e/quick.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { expect, test } from '@playwright/test'

import { fromMenu, history } from './helpers.js'

const shapes = (page) => page.locator('.vue-flow__node')
const node = (page, id) => page.locator(`.vue-flow__node[data-id="${id}"]`)
const tool = (page, name) =>
page.getByRole('toolbar', { name: 'Tools' }).getByRole('button', { name, exact: true })
const titleField = (page) => page.getByRole('textbox', { name: 'Shape title' })
const saved = (page) => page.evaluate(() => JSON.parse(localStorage.getItem('flow:document')))

test.beforeEach(async ({ page }) => {
await page.goto('/new')
await expect(shapes(page)).toHaveCount(5)
})

test('the eraser deletes everything it is dragged over, as one undoable change', async ({
page,
}) => {
await tool(page, 'Eraser').click()
const away = await node(page, 'b6a0c1').boundingBox()
const welcome = await node(page, 'b0653a').boundingBox()
await page.mouse.move(away.x + 20, away.y + away.height / 2)
await page.mouse.down()
await page.mouse.move(welcome.x + welcome.width - 20, welcome.y + welcome.height / 2, {
steps: 25,
})
await page.mouse.up()

await expect(shapes(page)).toHaveCount(3)
await history(page).getByRole('button', { name: 'Undo' }).click()
await expect(shapes(page)).toHaveCount(5)
})

test('the laser leaves a fading trail and changes nothing', async ({ page }) => {
const before = JSON.stringify(await saved(page))
await page.keyboard.press('8')
await expect(tool(page, 'Laser')).toHaveAttribute('aria-pressed', 'true')

await page.mouse.move(300, 400)
await page.mouse.down()
await page.mouse.move(600, 450, { steps: 10 })
await expect(page.getByTestId('laser-trail')).toBeVisible()
await page.mouse.up()
await expect(page.getByTestId('laser-trail')).toHaveCount(0, { timeout: 3000 })
expect(JSON.stringify(await saved(page))).toBe(before)
})

test('typing on a blank diagram starts a shape named with what is typed', async ({ page }) => {
await fromMenu(page, 'New diagram')
await expect(shapes(page)).toHaveCount(0)

await page.keyboard.type('Checkout')
await page.keyboard.press('Enter')
await expect(shapes(page)).toHaveCount(1)
await expect(shapes(page).first()).toContainText('Checkout')
// The letters named the shape; none of them picked a tool.
await expect(tool(page, 'Select')).toHaveAttribute('aria-pressed', 'true')
})

test('a double click on empty canvas puts a shape there, ready to name', async ({ page }) => {
await page.locator('.vue-flow__pane').dblclick({ position: { x: 200, y: 620 } })
await expect(shapes(page)).toHaveCount(6)
await expect(titleField(page)).toBeFocused()
})

test('Tab adds the next step below the selected shape, connected, to name', async ({ page }) => {
await node(page, 'e879e4').click({ modifiers: ['Shift'] })
await page.keyboard.press('Tab')

await expect(shapes(page)).toHaveCount(6)
await expect(titleField(page)).toBeFocused()
await page.keyboard.type('Notify the team')
await page.keyboard.press('Enter')

const document = await saved(page)
const added = document.nodes.find((each) => each.name === 'Notify the team')
expect(added).toBeTruthy()
expect(document.edges).toContainEqual(
expect.objectContaining({ source: 'e879e4', target: added.id }),
)

await history(page).getByRole('button', { name: 'Undo' }).click()
await history(page).getByRole('button', { name: 'Undo' }).click()
await expect(shapes(page)).toHaveCount(5)
})

test('Ctrl+K finds any action or shape by name', async ({ page }) => {
const palette = page.getByRole('dialog', { name: 'Commands' })

await page.keyboard.press('Control+k')
await expect(palette.getByRole('combobox', { name: 'Search commands' })).toBeFocused()
await page.keyboard.type('add decision')
await expect(palette.getByRole('option').first()).toHaveText(/Add Decision/)
await page.keyboard.press('Enter')
await expect(palette).toHaveCount(0)
await expect(shapes(page)).toHaveCount(6)

await page.keyboard.press('Escape')
await page.keyboard.press('Control+k')
await page.keyboard.type('png')
await page.keyboard.press('Enter')
await expect(page.getByRole('dialog', { name: 'Export' })).toBeVisible()

await page.keyboard.press('Escape')
await page.keyboard.press('Control+k')
await page.keyboard.type('nothing like this')
await expect(palette.getByText('Nothing matches')).toBeVisible()
await page.keyboard.press('Control+k')
await expect(palette).toHaveCount(0)
})
3 changes: 2 additions & 1 deletion e2e/tools.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ test('the text tool writes where the canvas is clicked, then hands back to Selec

test('the eraser deletes a shape with a click, and undo brings it back', async ({ page }) => {
await tool(page, 'Eraser').click()
await node(page, 'e879e4').click()
// Through the eraser's layer, which takes every press while it is on.
await node(page, 'e879e4').click({ force: true })
await expect(shapes(page)).toHaveCount(4)

await history(page).getByRole('button', { name: 'Undo' }).click()
Expand Down
14 changes: 13 additions & 1 deletion src/components/canvas/CanvasControls.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup>
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { useVueFlow } from '@vue-flow/core'

import IconButton from '@/components/ui/IconButton.vue'
Expand Down Expand Up @@ -66,6 +66,18 @@ function onKeydown(event) {
}
}

// Asked for from outside Vue Flow, such as by the command palette.
watch(
() => canvas.viewRequest,
(kind) => {
if (!kind) return
canvas.clearViewRequest()
if (kind === 'fit') fitView(FIT)
else if (kind === 'selection') fitSelection()
else tidy()
},
)

onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
Expand Down
68 changes: 68 additions & 0 deletions src/components/canvas/EraserLayer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<script setup>
/**
* The eraser: a click, or a drag across the canvas, marks every shape and
* connection it touches, and lifting the pointer deletes them as one change.
*/
const emit = defineEmits(['erase'])

/** @type {Map<Element, { kind: 'node' | 'edge', id: string }>} */
const marked = new Map()
let isDown = false

/**
* What is under the pointer, beneath this layer.
* @param {PointerEvent} event
*/
function hit(event) {
for (const element of document.elementsFromPoint(event.clientX, event.clientY)) {
const node = element.closest('.vue-flow__node')
const edge = node ? null : element.closest('.vue-flow__edge')
const target = node ?? edge
const id = target?.getAttribute('data-id')
if (!target || !id) continue
if (!marked.has(target)) {
marked.set(target, { kind: node ? 'node' : 'edge', id })
target.classList.add('is-erasing')
}
return
}
}

/** @param {PointerEvent} event */
function down(event) {
if (event.button !== 0) return
event.preventDefault()
isDown = true
hit(event)
}

/** @param {PointerEvent} event */
function move(event) {
if (isDown) hit(event)
}

function up() {
if (!isDown) return
isDown = false
const all = [...marked.entries()]
marked.clear()
all.forEach(([element]) => element.classList.remove('is-erasing'))
if (!all.length) return
emit('erase', {
nodes: all.filter(([, item]) => item.kind === 'node').map(([, item]) => item.id),
edges: all.filter(([, item]) => item.kind === 'edge').map(([, item]) => item.id),
})
}
</script>

<template>
<div
class="nodrag nopan absolute inset-0 z-[5] cursor-crosshair touch-none"
data-testid="eraser-layer"
@pointerdown="down"
@pointermove="move"
@pointerup="up"
@pointercancel="up"
@pointerleave="up"
/>
</template>
Loading
Loading