From 767106e2d7ec6a8e9c87c486f326de8703318f33 Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sun, 27 Sep 2026 21:30:11 +0800 Subject: [PATCH] Add drag erase, a laser, type-to-add, Tab and a command palette The eraser deletes what it is dragged over in one step, the laser leaves a fading trail, typing on a blank diagram starts a shape, Tab adds the next connected step, and Ctrl+K finds any action or shape. --- README.md | 8 +- .../tasks/fl-83 - Excalidraw-feel-extras.md | 28 ++- e2e/quick.spec.js | 111 ++++++++++ e2e/tools.spec.js | 3 +- src/components/canvas/CanvasControls.vue | 14 +- src/components/canvas/EraserLayer.vue | 68 +++++++ src/components/canvas/FlowCanvas.vue | 152 ++++++++++++-- src/components/canvas/FlowNodeCard.vue | 2 + src/components/canvas/InlineText.vue | 29 ++- src/components/canvas/LaserLayer.vue | 71 +++++++ src/components/canvas/editKey.js | 2 + src/components/shell/CommandPalette.vue | 110 ++++++++++ src/components/shell/MainMenu.vue | 7 +- src/components/shell/ToolBar.vue | 8 +- src/composables/useCommands.js | 191 ++++++++++++++++++ src/domain/__tests__/commands.spec.js | 30 +++ src/domain/__tests__/quickShapes.spec.js | 50 +++++ src/domain/__tests__/tools.spec.js | 6 +- src/domain/commands.js | 25 +++ src/domain/quickShapes.js | 65 ++++++ src/domain/shortcuts.js | 6 +- src/domain/tools.js | 11 +- src/stores/canvas.js | 19 ++ src/style.css | 6 + src/views/FlowView.vue | 31 +++ 25 files changed, 1018 insertions(+), 35 deletions(-) create mode 100644 e2e/quick.spec.js create mode 100644 src/components/canvas/EraserLayer.vue create mode 100644 src/components/canvas/LaserLayer.vue create mode 100644 src/components/shell/CommandPalette.vue create mode 100644 src/composables/useCommands.js create mode 100644 src/domain/__tests__/commands.spec.js create mode 100644 src/domain/__tests__/quickShapes.spec.js create mode 100644 src/domain/commands.js create mode 100644 src/domain/quickShapes.js diff --git a/README.md b/README.md index 8360058..a51289f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backlog/tasks/fl-83 - Excalidraw-feel-extras.md b/backlog/tasks/fl-83 - Excalidraw-feel-extras.md index 0bec9c2..bcb9608 100644 --- a/backlog/tasks/fl-83 - Excalidraw-feel-extras.md +++ b/backlog/tasks/fl-83 - Excalidraw-feel-extras.md @@ -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 @@ -26,9 +28,25 @@ The quick, keyboard-first touches that make Excalidraw, tldraw and Whimsical fee -- [ ] #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 + +## Implementation Plan + + + +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. + + + +## Implementation Notes + + + +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. + diff --git a/e2e/quick.spec.js b/e2e/quick.spec.js new file mode 100644 index 0000000..f71f21c --- /dev/null +++ b/e2e/quick.spec.js @@ -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) +}) diff --git a/e2e/tools.spec.js b/e2e/tools.spec.js index c0a7897..39cd78d 100644 --- a/e2e/tools.spec.js +++ b/e2e/tools.spec.js @@ -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() diff --git a/src/components/canvas/CanvasControls.vue b/src/components/canvas/CanvasControls.vue index a64f30e..6719aa5 100644 --- a/src/components/canvas/CanvasControls.vue +++ b/src/components/canvas/CanvasControls.vue @@ -1,5 +1,5 @@ diff --git a/src/components/canvas/EraserLayer.vue b/src/components/canvas/EraserLayer.vue new file mode 100644 index 0000000..ce2af9d --- /dev/null +++ b/src/components/canvas/EraserLayer.vue @@ -0,0 +1,68 @@ + + + diff --git a/src/components/canvas/FlowCanvas.vue b/src/components/canvas/FlowCanvas.vue index 24ef19c..a33c1ea 100644 --- a/src/components/canvas/FlowCanvas.vue +++ b/src/components/canvas/FlowCanvas.vue @@ -27,6 +27,7 @@ import { useUpdateEdge, useStyleEdge, useUpdateNode, + useReplaceDocument, } from '@/composables/useNodeMutations.js' import { useCanvasClipboard } from '@/composables/useCanvasClipboard.js' import { useFlowHistory } from '@/composables/useFlowHistory.js' @@ -40,8 +41,12 @@ import { isInView, panDuration } from '@/domain/motion.js' import { isSketch } from '@/domain/sketch.js' import { GRID } from '@/domain/arrange.js' import { TOOL } from '@/domain/tools.js' +import { withConnectedShape, withErased } from '@/domain/quickShapes.js' +import { generateNodeId } from '@/api/flowApi.js' import SelectionToolbar from './SelectionToolbar.vue' import PenLayer from './PenLayer.vue' +import EraserLayer from './EraserLayer.vue' +import LaserLayer from './LaserLayer.vue' import { useToastStore } from '@/stores/toasts.js' import { ROUTE } from '@/router/index.js' import { nodeComponents } from './nodeComponents.js' @@ -74,6 +79,8 @@ const updateNode = useUpdateNode() const updateEdge = useUpdateEdge() const styleEdge = useStyleEdge() const resizeNode = useResizeNode() +const eraseShapes = useReplaceDocument('Erase') +const addConnected = useReplaceDocument('Add a connected shape') /** No room for a minimap on a phone, and pinching does its job. */ const isPhone = useMediaQuery(PHONE) /** Shapes are picked up and changed with Select, and never in the viewer. */ @@ -99,13 +106,34 @@ provide( /** The shape or edge whose text is being edited in place, or empty. */ const editingId = ref('') +/** Whether that text was begun by typing, so the caret goes on from it. */ +const caretAtEnd = ref(false) + +/** + * Keys typed on a blank diagram before its first shape's title field can take + * them: the shape is created asynchronously, and a fast typist is quicker. + */ +let typedAhead = { active: false, text: '', enter: false } + +function takeTyped() { + const { text, enter } = typedAhead + typedAhead = { active: false, text: '', enter: false } + return { text, enter } +} provide(EDIT_TEXT, { editingId, + caretAtEnd, + takeTyped, start: (/** @type {string} */ id) => { - if (!canvas.isViewing) editingId.value = id + if (canvas.isViewing) return + caretAtEnd.value = false + editingId.value = id + }, + stop: () => { + editingId.value = '' + caretAtEnd.value = false }, - stop: () => (editingId.value = ''), renameNode: (/** @type {string} */ id, /** @type {string} */ name) => updateNode.mutate({ id, patch: { name } }), relabelEdge: (/** @type {string} */ id, /** @type {string} */ label) => @@ -306,10 +334,6 @@ function drawFromData(list) { /** @param {{ node: import('@vue-flow/core').GraphNode, event: MouseEvent | TouchEvent }} event */ function onNodeClick({ node, event }) { if (canvas.tool === TOOL.HAND || canvas.isViewing) return - if (canvas.tool === TOOL.ERASER) { - removeShapes([node.id]) - return - } if (canvas.tool === TOOL.CONNECTOR) { connectByClicks(node.id) return @@ -364,11 +388,6 @@ watch( () => (connectingFrom.value = ''), ) -/** @param {{ edge: { id: string } }} event */ -function onEdgeClick({ edge }) { - if (canvas.tool === TOOL.ERASER) detach(edge.id) -} - /** * The text tool writes where the canvas is clicked, then hands back to Select; * a click on empty canvas also drops a connection half made by clicks. @@ -522,6 +541,17 @@ function onSelectionKeys(event) { return } + // Tab adds the next step to the one selected shape, or the focused one. + if (event.key === 'Tab' && !event.shiftKey && isEditing.value) { + const selected = getSelectedNodes.value + const from = selected.length === 1 ? selected[0].id : !selected.length ? focusedId.value : '' + if (from) { + event.preventDefault() + addConnectedShape(from) + return + } + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { event.preventDefault() addSelectedNodes(getNodes.value) @@ -623,7 +653,7 @@ function centreOn(node) { * * @param {string} shape * @param {{ x: number, y: number } | null} at - * @param {{ exact?: boolean }} [options] + * @param {{ exact?: boolean, typed?: boolean }} [options] typed: named by what was typed */ function addShape(shape, at, options = {}) { const wanted = at @@ -636,7 +666,7 @@ function addShape(shape, at, options = {}) { const position = options.exact ? wanted : freeSpotNear(wanted, nodes.value) createNode.mutate( - { title: metaFor(shape).label, description: '', shape, position }, + { title: options.typed ? '' : metaFor(shape).label, description: '', shape, position }, { onSuccess(node) { const id = toNodeId(node.id) @@ -644,10 +674,14 @@ function addShape(shape, at, options = {}) { // drawer sliding in, and the canvas moves only if the shape is off it. router.push({ name: ROUTE.FLOW }) canvas.requestFocus(id) + caretAtEnd.value = Boolean(options.typed) editingId.value = id waitForNode(id).then((added) => added && addSelectedNodes([added])) }, - onError: () => toasts.push('The shape could not be added.', { tone: 'danger' }), + onError: () => { + takeTyped() + toasts.push('The shape could not be added.', { tone: 'danger' }) + }, }, ) } @@ -691,6 +725,92 @@ watch( }, ) +/** + * What the eraser went over, gone as one change. + * @param {{ nodes: string[], edges: string[] }} erased + */ +function erase({ nodes: nodeIds, edges: edgeIds }) { + if (!diagram.value) return + if (nodeIds.includes(String(route.params.id ?? ''))) router.push({ name: ROUTE.FLOW }) + const count = nodeIds.length + edgeIds.length + eraseShapes.mutate(withErased(diagram.value, nodeIds, edgeIds), { + onSuccess: () => + toasts.push(count === 1 ? 'Erased one thing' : `Erased ${count} things`, { + action: { label: 'Undo', run: undo }, + }), + }) +} + +/** + * Tab, as in Whimsical: the next step, below the selected shape, connected + * and named in place. + * @param {string} fromId + */ +function addConnectedShape(fromId) { + const from = findNode(fromId) + if (!from || !diagram.value) return + const id = generateNodeId() + const next = withConnectedShape( + diagram.value, + { id: fromId, position: { x: from.position.x, y: from.position.y } }, + getNodes.value.map((node) => ({ position: node.position })), + id, + ) + addConnected.mutate(next, { + async onSuccess() { + removeSelectedNodes(getSelectedNodes.value) + canvas.requestFocus(id) + caretAtEnd.value = false + editingId.value = id + const added = await waitForNode(id) + if (added) addSelectedNodes([added]) + }, + }) +} + +/** + * On a blank diagram, typing starts a shape named with what is typed, ahead + * of the one-letter tool keys. + * @param {KeyboardEvent} event + */ +function onBlankTyping(event) { + if (event.ctrlKey || event.metaKey || event.altKey || isBlocked(event)) return + + // Until the title field has the keys, they are kept for it. + if (typedAhead.active) { + if (event.key === 'Enter') typedAhead.enter = true + else if (event.key === 'Backspace') typedAhead.text = typedAhead.text.slice(0, -1) + else if (event.key.length === 1) typedAhead.text += event.key + else return + event.preventDefault() + event.stopImmediatePropagation() + return + } + + if (nodes.value.length || isLoading.value || !isEditing.value) return + if (event.key.length !== 1 || !event.key.trim()) return + event.preventDefault() + event.stopImmediatePropagation() + typedAhead = { active: true, text: event.key, enter: false } + addShape('process', null, { typed: true }) +} + +/** + * A double click on empty canvas puts a shape there, ready to name. + * @param {MouseEvent} event + */ +function onDoubleClick(event) { + const target = /** @type {Element} */ (event.target) + if (!isEditing.value || !target.closest?.('.vue-flow__pane')) return + if (target.closest('.vue-flow__node, .vue-flow__edge')) return + addShape('process', screenToFlowCoordinate({ x: event.clientX, y: event.clientY }), { + exact: true, + }) +} + +onMounted(() => window.addEventListener('keydown', onBlankTyping, true)) +onBeforeUnmount(() => window.removeEventListener('keydown', onBlankTyping, true)) + /** * The context menu: a right click, or a long press on a touch screen. * @type {import('vue').Ref<{ x: number, y: number, kind: 'node' | 'edge', id: string } | null>} @@ -787,6 +907,7 @@ watch( class="h-full w-full" :class="[connectingFrom ? 'is-connecting' : '', `tool-${canvas.tool}`]" @dragover="onDragOver" + @dblclick="onDoubleClick" @drop="onDrop" > + + diff --git a/src/components/canvas/FlowNodeCard.vue b/src/components/canvas/FlowNodeCard.vue index 14452c8..45ca6f1 100644 --- a/src/components/canvas/FlowNodeCard.vue +++ b/src/components/canvas/FlowNodeCard.vue @@ -214,6 +214,8 @@ const strokeWidth = computed(() => (props.selected || isKeyboardFocused.value ? :value="node.name" label="Shape title" :maxlength="FIELD_LIMIT.TITLE_MAX" + :caret-at-end="edit?.caretAtEnd.value" + :take-typed="edit?.caretAtEnd.value ? edit.takeTyped : undefined" @save="rename" @cancel="edit?.stop()" /> diff --git a/src/components/canvas/InlineText.vue b/src/components/canvas/InlineText.vue index 0144c18..79a623c 100644 --- a/src/components/canvas/InlineText.vue +++ b/src/components/canvas/InlineText.vue @@ -1,5 +1,5 @@ + + diff --git a/src/components/canvas/editKey.js b/src/components/canvas/editKey.js index f6f0e54..ea94049 100644 --- a/src/components/canvas/editKey.js +++ b/src/components/canvas/editKey.js @@ -4,6 +4,8 @@ * * @typedef {{ * editingId: import('vue').Ref, + * caretAtEnd: import('vue').Ref, + * takeTyped: () => { text: string, enter: boolean }, * start: (id: string) => void, * stop: () => void, * renameNode: (id: string, name: string) => void, diff --git a/src/components/shell/CommandPalette.vue b/src/components/shell/CommandPalette.vue new file mode 100644 index 0000000..58374e2 --- /dev/null +++ b/src/components/shell/CommandPalette.vue @@ -0,0 +1,110 @@ + + + diff --git a/src/components/shell/MainMenu.vue b/src/components/shell/MainMenu.vue index 8a5b6ff..85b6e46 100644 --- a/src/components/shell/MainMenu.vue +++ b/src/components/shell/MainMenu.vue @@ -17,7 +17,7 @@ import { useToastStore } from '@/stores/toasts.js' * The top-left island: the product, the open file, and a menu of everything * that is not drawing. It owns Ctrl+O and Ctrl+S, bound once here. */ -const emit = defineEmits(['help', 'import', 'compare', 'export']) +const emit = defineEmits(['help', 'import', 'compare', 'export', 'commands']) const canvas = useCanvasStore() const file = useFileStore() @@ -75,6 +75,11 @@ const groups = computed( { label: themeLabel.value, run: cycleTheme }, ], [ + { + label: 'Find a command', + run: () => emit('commands'), + hint: comboLabel(COMBO.COMMANDS, isMac.value), + }, { label: 'Keyboard shortcuts', run: () => emit('help'), diff --git a/src/components/shell/ToolBar.vue b/src/components/shell/ToolBar.vue index e5c6c2e..389ca6c 100644 --- a/src/components/shell/ToolBar.vue +++ b/src/components/shell/ToolBar.vue @@ -6,7 +6,8 @@ import { TOOL, TOOLS, toolForKey } from '@/domain/tools.js' import { useCanvasStore } from '@/stores/canvas.js' /** - * The top-centre island. Number keys and letters pick a tool, as in + * The top-centre island; the laser, for presenting, is left off a phone's. + * Number keys and letters pick a tool, as in * Excalidraw; Escape goes back to Select and closes the library. */ const canvas = useCanvasStore() @@ -41,6 +42,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) v-for="tool in TOOLS" :key="tool.id" class="inline-flex" + :class="{ 'max-md:hidden': tool.id === 'laser' }" :title="`${tool.hint} (${tool.keys.join(' or ')})`" >