diff --git a/README.md b/README.md index 1607f4d..ee6de7f 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ isketch aims at that gap: itself, and notes on the diagram and its shapes travel as instructions to follow. - **Git native.** Files diff cleanly, a CLI renders SVG with no browser, and pull requests get a visual diff, so the design and the code stop drifting apart. -- **Start from real files.** Your existing `.drawio` diagrams, `docker-compose.yml`, OpenAPI and - SQL DDL, with re-import that keeps your layout. And a `.drawio` back out whenever you want one. +- **Start from real files.** Your existing `.drawio` and `.excalidraw` diagrams, + `docker-compose.yml`, OpenAPI and SQL DDL, with re-import that keeps your layout. And a `.drawio` + or `.excalidraw` back out whenever you want one. - **Local first.** No account, no server, works offline, shareable as a link. - **Written for AI readers.** [/llms.txt](https://isketch.online/llms.txt) and [/docs/format](https://isketch.online/docs/format) describe the format, the tools and the CLI @@ -134,11 +135,16 @@ nobody hosts it yet: run it yourself, or hand over the `.flow` file or the brief Diagram: shapes map to the nearest one here, entity tables keep their rows, and lanes are flattened with their shapes kept. _Export_ writes a file draw.io opens as it is, sketch style included, and that comes back into isketch unchanged. +- **Excalidraw, both ways.** Import a `.excalidraw` file, or copy shapes in Excalidraw and paste + them onto the canvas: rectangles, ellipses, diamonds, text and frames become shapes named by + their text, arrows bound at both ends become connections, and free drawing becomes pen strokes. + What cannot come in is listed. _Export_ writes a `.excalidraw` file whose shapes carry their + isketch kind, description and notes, so it comes back unchanged. - **Copy for AI.** One button copies a Markdown brief for Claude, Copilot or any coding agent: each shape with its id and what it means ("a data store", "a branch the code must handle"), each connection in words, and the `.flow` source at the end so the agent can change the diagram and hand it back. `isketch brief` prints the same from the command line. -- **Export** as PNG (at twice the size, for slides and chat), SVG or a draw.io file, light or +- **Export** as PNG (at twice the size, for slides and chat), SVG, a draw.io or an Excalidraw file, light or dark, with a preview. Sketches carry their handwriting font inside the file. - **Copy as Mermaid** from the text pane, for a README. - **Open and save `.flow` files** (`Ctrl+O`, `Ctrl+S`). In Chrome and Edge, Save writes back to diff --git a/backlog/tasks/fl-84 - Import-and-export-Excalidraw.md b/backlog/tasks/fl-84 - Import-and-export-Excalidraw.md index 450d9ff..01146eb 100644 --- a/backlog/tasks/fl-84 - Import-and-export-Excalidraw.md +++ b/backlog/tasks/fl-84 - Import-and-export-Excalidraw.md @@ -1,9 +1,11 @@ --- id: FL-84 title: Import and export Excalidraw -status: To Do -assignee: [] +status: Done +assignee: + - '@raj-khan' created_date: '2026-09-25 17:39' +updated_date: '2026-09-27 13:50' labels: - import milestone: m-3 @@ -24,9 +26,36 @@ With draw.io done (FL-70), Excalidraw import lets anyone bring existing diagrams -- [ ] #1 .excalidraw files and pasted Excalidraw clipboard data import -- [ ] #2 Rectangles, ellipses, diamonds and text become shapes; bound arrows become connections; free drawing becomes ink -- [ ] #3 Skipped elements are listed, as the draw.io import does -- [ ] #4 Export to .excalidraw; unit tests cover a round trip +- [x] #1 .excalidraw files and pasted Excalidraw clipboard data import +- [x] #2 Rectangles, ellipses, diamonds and text become shapes; bound arrows become connections; free drawing becomes ink +- [x] #3 Skipped elements are listed, as the draw.io import does +- [x] #4 Export to .excalidraw; unit tests cover a round trip + +## Implementation Plan + + + +1. src/domain/excalidraw.js: fromExcalidraw reads files and clipboard data (type excalidraw or excalidraw/clipboard); rectangles, ellipses, diamonds, loose text and frames become shapes named by their bound text; arrows bound at both ends become connections (label, dashed, two-way, reversed heads); freedraw becomes ink with pressure; skipped elements are counted into warnings; roughness sets sketch style. +2. toExcalidraw: frames first, shapes with bound text labels, bound arrows listed on both shapes, freedraw for ink; kind, description and notes in customData so a round trip is exact; seeded so output is stable. +3. Import dialog format; Export dialog format; Excalidraw clipboard pastes onto the canvas. +4. Unit tests (import, export, round trip) and e2e (import with skipped list, export, paste). + + + +## Implementation Notes + + + +Round trip is exact through customData (isketch kind, description, notes): Excalidraw keeps unknown customData on elements. A standalone text shape keeps its own box; a bound label is centred in what holds it. An arrow with its head at the start only is read the way it points. Frames map both ways (Excalidraw frame and magicframe). The import dialog lists warnings without a line number when a format has none. + +Verified: src/domain/**tests**/excalidraw.spec.js (9: shapes, bound labels, arrows with label and dashes, freedraw with pressure, skipped list, sketch style, clipboard form, refusal, export shape, round trip, stable bytes); e2e/excalidraw.spec.js (2: import with the skipped list, export file with bound arrow, clipboard paste as one undo). e2e 138, vitest 279, lint and typecheck. + + +## Final Summary + + + +Excalidraw both ways: .excalidraw files and Excalidraw clipboard data import (in the Import dialog and by pasting on the canvas) with skipped elements listed, and Export writes a .excalidraw file that round-trips exactly. Verified with unit and e2e tests. + diff --git a/e2e/excalidraw.spec.js b/e2e/excalidraw.spec.js new file mode 100644 index 0000000..096c85d --- /dev/null +++ b/e2e/excalidraw.spec.js @@ -0,0 +1,139 @@ +import { readFile } from 'node:fs/promises' + +import { expect, test } from '@playwright/test' + +import { fromMenu, history } from './helpers.js' + +const shapes = (page) => page.locator('.vue-flow__node') +const modifier = process.platform === 'darwin' ? 'Meta' : 'Control' + +const base = { angle: 0, isDeleted: false, roughness: 1, strokeStyle: 'solid' } +const ELEMENTS = [ + { ...base, id: 'cart', type: 'ellipse', x: 0, y: 0, width: 160, height: 70 }, + { + ...base, + id: 't1', + type: 'text', + x: 0, + y: 0, + width: 40, + height: 20, + text: 'Cart', + originalText: 'Cart', + containerId: 'cart', + }, + { ...base, id: 'paid', type: 'diamond', x: 0, y: 200, width: 160, height: 90 }, + { + ...base, + id: 't2', + type: 'text', + x: 0, + y: 0, + width: 40, + height: 20, + text: 'Paid?', + originalText: 'Paid?', + containerId: 'paid', + }, + { + ...base, + id: 'go', + type: 'arrow', + x: 80, + y: 70, + width: 0, + height: 130, + points: [ + [0, 0], + [0, 130], + ], + startBinding: { elementId: 'cart' }, + endBinding: { elementId: 'paid' }, + endArrowhead: 'arrow', + }, + { + ...base, + id: 't3', + type: 'text', + x: 0, + y: 0, + width: 40, + height: 20, + text: 'pay', + originalText: 'pay', + containerId: 'go', + }, + { + ...base, + id: 'stray', + type: 'line', + x: 0, + y: 0, + width: 10, + height: 10, + points: [ + [0, 0], + [10, 10], + ], + }, +] +const FILE = JSON.stringify({ + type: 'excalidraw', + version: 2, + elements: ELEMENTS, + appState: {}, + files: {}, +}) + +test.beforeEach(async ({ page }) => { + await page.goto('/new') + await expect(shapes(page)).toHaveCount(5) +}) + +test('imports an Excalidraw file, says what it skipped, and exports one back', async ({ page }) => { + await fromMenu(page, 'Import') + const dialog = page.getByRole('dialog', { name: 'Import' }) + await dialog.getByRole('radio', { name: 'Excalidraw' }).click() + await dialog.getByLabel('Excalidraw to import').fill(FILE) + await expect(dialog.getByRole('status')).toContainText('2 shapes and 1 connection') + await expect(dialog.getByRole('list', { name: 'What will be skipped' })).toHaveText( + 'Skipped a line (only arrows between shapes connect them).', + ) + await dialog.getByRole('button', { name: 'Import' }).click() + + await expect(shapes(page)).toHaveCount(2) + await expect(page.locator('.vue-flow__node[data-id="paid"] [data-shape]')).toHaveAttribute( + 'data-shape', + 'decision', + ) + await expect(page.getByTestId('edge-label').filter({ hasText: 'pay' })).toBeVisible() + + await fromMenu(page, 'Export') + const exporting = page.getByRole('dialog', { name: 'Export' }) + await exporting.getByText('Excalidraw', { exact: true }).click() + const download = page.waitForEvent('download') + await exporting.getByRole('button', { name: /Download .*\.excalidraw/ }).click() + const file = JSON.parse(await readFile(await (await download).path(), 'utf8')) + expect(file.type).toBe('excalidraw') + expect(file.elements.find((element) => element.id === 'paid')).toMatchObject({ type: 'diamond' }) + expect(file.elements.find((element) => element.type === 'arrow')).toMatchObject({ + startBinding: { elementId: 'cart' }, + endBinding: { elementId: 'paid' }, + }) +}) + +test('shapes copied in Excalidraw paste onto the canvas, as one undoable change', async ({ + page, + context, +}) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + const clipboard = JSON.stringify({ type: 'excalidraw/clipboard', elements: ELEMENTS, files: {} }) + await page.evaluate((text) => navigator.clipboard.writeText(text), clipboard) + await page.locator('.vue-flow__pane').click({ position: { x: 20, y: 300 } }) + await page.keyboard.press(`${modifier}+v`) + + await expect(shapes(page)).toHaveCount(7) + await expect(page.locator('.vue-flow__node.selected')).toHaveCount(2) + await history(page).getByRole('button', { name: 'Undo' }).click() + await expect(shapes(page)).toHaveCount(5) +}) diff --git a/e2e/mermaid.spec.js b/e2e/mermaid.spec.js index 0bc194d..54d0a6c 100644 --- a/e2e/mermaid.spec.js +++ b/e2e/mermaid.spec.js @@ -29,9 +29,7 @@ test('imports a pasted flowchart, says what it skipped, and undo brings the old ].join('\n'), ) await expect(dialog.getByRole('status')).toContainText('4 shapes and 3 connections') - await expect(dialog.getByRole('list', { name: 'Lines that will be skipped' })).toContainText( - 'Line 5', - ) + await expect(dialog.getByRole('list', { name: 'What will be skipped' })).toContainText('Line 5') await dialog.getByRole('button', { name: 'Import' }).click() diff --git a/src/components/export/ExportDialog.vue b/src/components/export/ExportDialog.vue index 730c210..597e96c 100644 --- a/src/components/export/ExportDialog.vue +++ b/src/components/export/ExportDialog.vue @@ -7,6 +7,7 @@ import { downloadBlob, downloadText } from '@/composables/download.js' import { sketchFontData, svgToPng } from '@/composables/exportImage.js' import { useFlowQuery } from '@/composables/useFlowQuery.js' import { toDrawio } from '@/domain/drawio.js' +import { toExcalidraw } from '@/domain/excalidraw.js' import { flowFileName } from '@/domain/flowText.js' import { frameDocument, isFrame } from '@/domain/frames.js' import { renderSvg } from '@/domain/renderSvg.js' @@ -37,7 +38,10 @@ const FORMATS = [ { id: 'png', label: 'PNG', hint: 'An image for slides, docs and chat' }, { id: 'svg', label: 'SVG', hint: 'Sharp at any size, for docs and the web' }, { id: 'drawio', label: 'draw.io', hint: 'To carry on in draw.io or diagrams.net' }, + { id: 'excalidraw', label: 'Excalidraw', hint: 'To carry on sketching in Excalidraw' }, ] +/** Files, not pictures: no colours to pick and no preview to show. */ +const isFile = computed(() => format.value === 'drawio' || format.value === 'excalidraw') const format = ref('png') const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false @@ -71,6 +75,8 @@ async function download() { if (format.value === 'svg') downloadText(name, svg.value, 'image/svg+xml') else if (format.value === 'drawio') { downloadText(name, toDrawio(document.value), 'application/xml') + } else if (format.value === 'excalidraw') { + downloadText(name, toExcalidraw(document.value), 'application/json') } else downloadBlob(name, await svgToPng(svg.value)) track('exported', { format: format.value }) toasts.push(`Downloaded ${name}`) @@ -88,7 +94,7 @@ async function download() {
Format -
+
-
+
Colours
What the export will look like -

+

Opens in draw.io as it is, positions, sizes and sketch style included, and comes back into isketch unchanged through Import.

+

+ Opens in Excalidraw with its shapes, labels and bound arrows, and comes back into isketch + unchanged through Import: each shape keeps its kind, description and notes. +