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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
41 changes: 35 additions & 6 deletions backlog/tasks/fl-84 - Import-and-export-Excalidraw.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,9 +26,36 @@ With draw.io done (FL-70), Excalidraw import lets anyone bring existing diagrams

<!-- AC:BEGIN -->

- [ ] #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

<!-- AC:END -->

## Implementation Plan

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

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).

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

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->

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

## Final Summary

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

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.
<!-- SECTION:FINAL_SUMMARY:END -->
139 changes: 139 additions & 0 deletions e2e/excalidraw.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
4 changes: 1 addition & 3 deletions e2e/mermaid.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
18 changes: 14 additions & 4 deletions src/components/export/ExportDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`)
Expand All @@ -88,7 +94,7 @@ async function download() {
<div class="space-y-4 px-5 py-4">
<fieldset>
<legend class="mb-1.5 text-xs font-medium text-muted">Format</legend>
<div class="grid grid-cols-3 gap-2">
<div class="grid grid-cols-2 gap-2 md:grid-cols-4">
<label
v-for="option in FORMATS"
:key="option.id"
Expand Down Expand Up @@ -116,7 +122,7 @@ async function download() {
</select>
</label>

<fieldset v-if="format !== 'drawio'" class="flex items-center gap-4 text-sm">
<fieldset v-if="!isFile" class="flex items-center gap-4 text-sm">
<legend class="sr-only">Colours</legend>
<label class="flex items-center gap-1.5">
<input v-model="look" type="radio" name="look" value="light" />
Expand All @@ -129,15 +135,19 @@ async function download() {
</fieldset>

<img
v-if="format !== 'drawio'"
v-if="!isFile"
:src="preview"
alt="What the export will look like"
class="max-h-[45vh] w-full rounded-lg border border-line object-contain"
/>
<p v-else class="text-xs text-muted">
<p v-else-if="format === 'drawio'" class="text-xs text-muted">
Opens in draw.io as it is, positions, sizes and sketch style included, and comes back into
isketch unchanged through Import.
</p>
<p v-else class="text-xs text-muted">
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.
</p>

<div class="flex justify-end">
<button
Expand Down
4 changes: 2 additions & 2 deletions src/components/import/ImportDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,10 @@ function submit() {
<ul
v-if="result?.warnings.length"
class="scroll-panel max-h-32 space-y-0.5 text-xs text-muted"
aria-label="Lines that will be skipped"
aria-label="What will be skipped"
>
<li v-for="warning in result.warnings" :key="`${warning.line}-${warning.message}`">
Line {{ warning.line }}: {{ warning.message }}
{{ warning.line ? `Line ${warning.line}: ` : '' }}{{ warning.message }}
</li>
</ul>

Expand Down
6 changes: 4 additions & 2 deletions src/composables/useCanvasClipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { onBeforeUnmount, onMounted } from 'vue'
import { useFlowQuery } from '@/composables/useFlowQuery.js'
import { useReplaceDocument } from '@/composables/useNodeMutations.js'
import { copySelection, pasteInto } from '@/domain/clipboard.js'
import { fromExcalidraw } from '@/domain/excalidraw.js'
import { parseFlow, serialiseFlow } from '@/domain/flowText.js'
import { useToastStore } from '@/stores/toasts.js'

/**
* Copy, cut, paste and duplicate on the canvas. The clipboard carries `.flow`
* text, so shapes paste between tabs, and into and out of a text editor or an
* agent. Each paste is one undoable change.
* agent; shapes copied in Excalidraw paste too. Each paste is one undoable change.
*
* @param {{
* selectedIds: () => string[],
Expand Down Expand Up @@ -58,7 +59,8 @@ export function useCanvasClipboard(canvas) {
function onPaste(event) {
if (canvas.isBlocked(event) || !document.value) return
const text = event.clipboardData?.getData('text/plain') ?? ''
const { document: copy } = parseFlow(text)
const flow = parseFlow(text).document
const copy = flow?.nodes.length ? flow : fromExcalidraw(text).document
// Anything else on the clipboard is not ours to take.
if (!copy?.nodes.length) return
event.preventDefault()
Expand Down
Loading
Loading