Feat/canvas toolkit v2 (#41)

* feat: enhance Excalidraw MCP with advanced canvas toolkit features

- Rename skill to `excalidraw-skill` with expanded playbook and cheatsheet.
- Add new MCP tools for iterative refinement: `describe_scene` and `get_canvas_screenshot`.
- Implement layout tools (`align_elements`, `distribute_elements`) and `duplicate_elements`.
- Add file I/O support for `.excalidraw` JSON and image export (PNG/SVG).
- Introduce named snapshots for canvas state management.
- Add server-side element CRUD and WebSocket handlers for real-time sync.
- Normalize `points` format for arrows and lines.

* docs: update README with v2.0 features and official MCP comparison

* feat: implement arrow binding and edge-to-edge routing

* fix: enhance security with path sanitization and improve export error handling

* feat: add viewport control, design guide, and excalidraw.com URL export

* feat: enhance excalidraw.com export with proper scene formatting and labels
This commit is contained in:
yctimlin
2026-02-12 18:09:56 +08:00
committed by GitHub
parent 1359720f66
commit 4a17c47b54
16 changed files with 2300 additions and 258 deletions
+56 -29
View File
@@ -22,6 +22,7 @@ Keywords: Excalidraw agent skill, Excalidraw MCP server, AI diagramming, Claude
- [Demo](#demo)
- [What It Is](#what-it-is)
- [How We Differ from the Official Excalidraw MCP](#how-we-differ-from-the-official-excalidraw-mcp)
- [What's New](#whats-new)
- [Quick Start (Local)](#quick-start-local)
- [Quick Start (Docker)](#quick-start-docker)
@@ -33,7 +34,7 @@ Keywords: Excalidraw agent skill, Excalidraw MCP server, AI diagramming, Claude
- [OpenCode](#opencode)
- [Antigravity (Google)](#antigravity-google)
- [Agent Skill (Optional)](#agent-skill-optional)
- [MCP Tools (High Level)](#mcp-tools-high-level)
- [MCP Tools (23 Total)](#mcp-tools-23-total)
- [Testing](#testing)
- [Troubleshooting](#troubleshooting)
- [Known Issues / TODO](#known-issues--todo)
@@ -46,9 +47,39 @@ This repo contains two separate processes:
- Canvas server: web UI + REST API + WebSocket updates (default `http://localhost:3000`)
- MCP server: exposes MCP tools over stdio; syncs to the canvas via `EXPRESS_SERVER_URL`
## How We Differ from the Official Excalidraw MCP
Excalidraw now has an [official MCP](https://github.com/excalidraw/excalidraw-mcp) — it's great for quick, prompt-to-diagram generation rendered inline in chat. We solve a different problem.
| | Official Excalidraw MCP | This Project |
|---|---|---|
| **Approach** | Prompt in, diagram out (one-shot) | Programmatic element-level control (23 tools) |
| **State** | Stateless — each call is independent | Persistent live canvas with real-time sync |
| **Element CRUD** | No | Full create / read / update / delete per element |
| **AI sees the canvas** | No | `describe_scene` (structured text) + `get_canvas_screenshot` (image) |
| **Iterative refinement** | No — regenerate the whole diagram | Draw → look → adjust → look again, element by element |
| **Layout tools** | No | `align_elements`, `distribute_elements`, `group / ungroup` |
| **File I/O** | No | `export_scene` / `import_scene` (.excalidraw JSON) |
| **Snapshot & rollback** | No | `snapshot_scene` / `restore_snapshot` |
| **Mermaid conversion** | No | `create_from_mermaid` |
| **Live canvas UI** | Rendered inline in chat | Standalone Excalidraw app synced via WebSocket |
| **Multi-agent** | Single user | Multiple agents can draw on the same canvas concurrently |
**TL;DR** — The official MCP generates diagrams. We give AI agents a full canvas toolkit to build, inspect, and iteratively refine diagrams — including the ability to see what they drew.
## What's New
- Agent skill: `skills/excalidraw-mcp/` (portable instructions + helper scripts for export/import and repeatable CRUD)
### v2.0 — Canvas Toolkit
- 10 new MCP tools: `get_element`, `clear_canvas`, `export_scene`, `import_scene`, `export_to_image`, `duplicate_elements`, `snapshot_scene`, `restore_snapshot`, `describe_scene`, `get_canvas_screenshot` (23 tools total)
- **Closed feedback loop**: AI can now inspect the canvas (`describe_scene`) and see it (`get_canvas_screenshot` returns an image) — enabling iterative refinement
- **File I/O**: export/import full `.excalidraw` JSON files
- **Snapshots**: save and restore named canvas states
- Fixed all previously known issues: `align_elements` / `distribute_elements` fully implemented, points type normalization, removed invalid `label` type, removed HTTP transport dead code, `ungroup_elements` now errors on failure
### v1.x
- Agent skill: `skills/excalidraw-skill/` (portable instructions + helper scripts for export/import and repeatable CRUD)
- Better testing loop: MCP Inspector CLI examples + browser screenshot checks (`agent-browser`)
- Bugfixes: batch create now preserves element ids (fixes update/delete after batch); frontend entrypoint fixed (`main.tsx`)
@@ -328,7 +359,7 @@ Config location: `~/.gemini/antigravity/mcp_config.json`
## Agent Skill (Optional)
This repo includes a skill at `skills/excalidraw-mcp/` that provides:
This repo includes a skill at `skills/excalidraw-skill/` that provides:
- **Workflow playbook** (`SKILL.md`): step-by-step guidance for drawing, refining, and exporting diagrams
- **Cheatsheet** (`references/cheatsheet.md`): MCP tool and REST API reference
@@ -340,26 +371,26 @@ The skill complements the MCP server by giving your AI agent structured workflow
```bash
mkdir -p ~/.codex/skills
cp -R skills/excalidraw-mcp ~/.codex/skills/excalidraw-mcp
cp -R skills/excalidraw-skill ~/.codex/skills/excalidraw-skill
```
To update an existing installation, remove the old folder first (`rm -rf ~/.codex/skills/excalidraw-mcp`) then re-copy.
To update an existing installation, remove the old folder first (`rm -rf ~/.codex/skills/excalidraw-skill`) then re-copy.
### Install The Skill (Claude Code)
**User-level** (available across all your projects):
```bash
mkdir -p ~/.claude/skills
cp -R skills/excalidraw-mcp ~/.claude/skills/excalidraw-mcp
cp -R skills/excalidraw-skill ~/.claude/skills/excalidraw-skill
```
**Project-level** (scoped to a specific project, can be committed to the repo):
```bash
mkdir -p /path/to/your/project/.claude/skills
cp -R skills/excalidraw-mcp /path/to/your/project/.claude/skills/excalidraw-mcp
cp -R skills/excalidraw-skill /path/to/your/project/.claude/skills/excalidraw-skill
```
Then invoke the skill in Claude Code with `/excalidraw-mcp`.
Then invoke the skill in Claude Code with `/excalidraw-skill`.
To update an existing installation, remove the old folder first then re-copy.
@@ -368,9 +399,9 @@ To update an existing installation, remove the old folder first then re-copy.
All scripts respect `EXPRESS_SERVER_URL` (default `http://localhost:3000`) or accept `--url`.
```bash
EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-mcp/scripts/healthcheck.cjs
EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-mcp/scripts/export-elements.cjs --out diagram.elements.json
EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-mcp/scripts/import-elements.cjs --in diagram.elements.json --mode batch
EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-skill/scripts/healthcheck.cjs
EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-skill/scripts/export-elements.cjs --out diagram.elements.json
EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-skill/scripts/import-elements.cjs --in diagram.elements.json --mode batch
```
### When The Skill Is Useful
@@ -380,21 +411,20 @@ EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-mcp/scripts/impo
- Automated smoke tests: create/update/delete a known element to validate a deployment.
- Repeatable diagrams: keep a library of element JSON snippets and import them.
See `skills/excalidraw-mcp/SKILL.md` and `skills/excalidraw-mcp/references/cheatsheet.md`.
See `skills/excalidraw-skill/SKILL.md` and `skills/excalidraw-skill/references/cheatsheet.md`.
## MCP Tools (High Level)
## MCP Tools (23 Total)
The MCP server exposes tools such as:
| Category | Tools |
|---|---|
| **Element CRUD** | `create_element`, `get_element`, `update_element`, `delete_element`, `query_elements`, `batch_create_elements`, `duplicate_elements` |
| **Layout** | `align_elements`, `distribute_elements`, `group_elements`, `ungroup_elements`, `lock_elements`, `unlock_elements` |
| **Scene Awareness** | `describe_scene`, `get_canvas_screenshot` |
| **File I/O** | `export_scene`, `import_scene`, `export_to_image`, `create_from_mermaid` |
| **State Management** | `clear_canvas`, `snapshot_scene`, `restore_snapshot` |
| **Resources** | `get_resource` |
- `create_element`, `update_element`, `delete_element`
- `query_elements`, `get_resource`
- `batch_create_elements`
- `align_elements`, `distribute_elements`
- `group_elements`, `ungroup_elements`
- `lock_elements`, `unlock_elements`
- `create_from_mermaid` (frontend converts Mermaid to Excalidraw elements)
The full tool list and schemas are discoverable via MCP Inspector (`tools/list`) or by reading `src/index.ts`.
Full schemas are discoverable via `tools/list` or in `skills/excalidraw-skill/references/cheatsheet.md`.
## Testing
@@ -441,13 +471,10 @@ agent-browser screenshot /tmp/canvas.png
## Known Issues / TODO
The following issues are known and tracked for future improvement:
All previously listed bugs have been fixed in v2.0. Remaining items:
- [ ] **`align_elements` / `distribute_elements` are stubs**: These tools log and return success but do not actually move elements. Implementation needed in `src/index.ts:782-806`.
- [ ] **`points` type mismatch**: The Zod schema expects `[{x, y}]` objects but Excalidraw expects `[[x, y]]` tuples. This may cause issues when creating arrows/lines via MCP. See `src/index.ts:187` vs `src/types.ts:64`.
- [ ] **`label` element type incomplete**: The `label` type is defined in `EXCALIDRAW_ELEMENT_TYPES` but has no corresponding interface in the type union. See `src/types.ts:109,118`.
- [ ] **HTTP transport mode placeholder**: `MCP_TRANSPORT_MODE=http` is accepted but falls back to stdio. See `src/index.ts:989-996`.
- [ ] **`ungroup_elements` silent success**: Returns success even when no elements are ungrouped (e.g., elements not found). See `src/index.ts:765-769`.
- [ ] **Persistent storage**: Elements are stored in-memory — restarting the server clears everything. Use `export_scene` / snapshots as a workaround.
- [ ] **Image export requires a browser**: `export_to_image` and `get_canvas_screenshot` rely on the frontend doing the actual rendering. The canvas UI must be open in a browser.
Contributions welcome!
+204 -15
View File
@@ -3,7 +3,9 @@ import {
Excalidraw,
convertToExcalidrawElements,
CaptureUpdateAction,
ExcalidrawImperativeAPI
ExcalidrawImperativeAPI,
exportToBlob,
exportToSvg
} from '@excalidraw/excalidraw'
import type { ExcalidrawElement, NonDeleted, NonDeletedExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'
import { convertMermaidToExcalidraw, DEFAULT_MERMAID_CONFIG } from './utils/mermaidConverter'
@@ -39,6 +41,12 @@ interface ServerElement {
boundElements?: any[] | null;
containerId?: string | null;
locked?: boolean;
// Arrow element binding
start?: { id: string };
end?: { id: string };
strokeStyle?: string;
endArrowhead?: string;
startArrowhead?: string;
}
interface WebSocketMessage {
@@ -240,13 +248,24 @@ function App(): JSX.Element {
case 'element_created':
if (data.element) {
const cleanedNewElement = cleanElementForExcalidraw(data.element)
// Preserve server IDs so later update/delete websocket events can match by id.
const newElement = convertToExcalidrawElements([cleanedNewElement], { regenerateIds: false })
const updatedElementsAfterCreate = [...currentElements, ...newElement]
excalidrawAPI.updateScene({
elements: updatedElementsAfterCreate,
captureUpdate: CaptureUpdateAction.NEVER
})
const hasBindings = (cleanedNewElement as any).start || (cleanedNewElement as any).end
if (hasBindings) {
// Bound arrow: re-convert all elements together so bindings resolve
const allElements = [...currentElements, cleanedNewElement] as any[]
const convertedAll = convertToExcalidrawElements(allElements, { regenerateIds: false })
excalidrawAPI.updateScene({
elements: convertedAll,
captureUpdate: CaptureUpdateAction.NEVER
})
} else {
// Preserve server IDs so later update/delete websocket events can match by id.
const newElement = convertToExcalidrawElements([cleanedNewElement], { regenerateIds: false })
const updatedElementsAfterCreate = [...currentElements, ...newElement]
excalidrawAPI.updateScene({
elements: updatedElementsAfterCreate,
captureUpdate: CaptureUpdateAction.NEVER
})
}
}
break
@@ -278,13 +297,24 @@ function App(): JSX.Element {
case 'elements_batch_created':
if (data.elements) {
const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw)
// Preserve server IDs so later update/delete websocket events can match by id.
const batchElements = convertToExcalidrawElements(cleanedBatchElements, { regenerateIds: false })
const updatedElementsAfterBatch = [...currentElements, ...batchElements]
excalidrawAPI.updateScene({
elements: updatedElementsAfterBatch,
captureUpdate: CaptureUpdateAction.NEVER
})
const hasBoundArrows = cleanedBatchElements.some((el: any) => el.start || el.end)
if (hasBoundArrows) {
// Convert ALL elements together so arrow bindings resolve to target shapes
const allElements = [...currentElements, ...cleanedBatchElements] as any[]
const convertedAll = convertToExcalidrawElements(allElements, { regenerateIds: false })
excalidrawAPI.updateScene({
elements: convertedAll,
captureUpdate: CaptureUpdateAction.NEVER
})
} else {
// Preserve server IDs so later update/delete websocket events can match by id.
const batchElements = convertToExcalidrawElements(cleanedBatchElements, { regenerateIds: false })
const updatedElementsAfterBatch = [...currentElements, ...batchElements]
excalidrawAPI.updateScene({
elements: updatedElementsAfterBatch,
captureUpdate: CaptureUpdateAction.NEVER
})
}
}
break
@@ -297,6 +327,165 @@ function App(): JSX.Element {
console.log(`Server sync status: ${data.count} elements`)
break
case 'canvas_cleared':
console.log('Canvas cleared by server')
excalidrawAPI.updateScene({
elements: [],
captureUpdate: CaptureUpdateAction.NEVER
})
break
case 'export_image_request':
console.log('Received image export request', data)
if (data.requestId) {
try {
const elements = excalidrawAPI.getSceneElements()
const appState = excalidrawAPI.getAppState()
const files = excalidrawAPI.getFiles()
if (data.format === 'svg') {
const svg = await exportToSvg({
elements,
appState: {
...appState,
exportBackground: data.background !== false
},
files
})
const svgString = new XMLSerializer().serializeToString(svg)
await fetch('/api/export/image/result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requestId: data.requestId,
format: 'svg',
data: svgString
})
})
} else {
const blob = await exportToBlob({
elements,
appState: {
...appState,
exportBackground: data.background !== false
},
files,
mimeType: 'image/png'
})
const reader = new FileReader()
reader.onload = async () => {
try {
const resultString = reader.result as string
const base64 = resultString?.split(',')[1]
if (!base64) {
throw new Error('Could not extract base64 data from result')
}
await fetch('/api/export/image/result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requestId: data.requestId,
format: 'png',
data: base64
})
})
} catch (readerError) {
console.error('Image export (FileReader) failed:', readerError)
await fetch('/api/export/image/result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requestId: data.requestId,
error: (readerError as Error).message
})
}).catch(() => {})
}
}
reader.onerror = async () => {
console.error('FileReader error:', reader.error)
await fetch('/api/export/image/result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requestId: data.requestId,
error: reader.error?.message || 'FileReader failed'
})
}).catch(() => {})
}
reader.readAsDataURL(blob)
}
console.log('Image export completed for request', data.requestId)
} catch (exportError) {
console.error('Image export failed:', exportError)
await fetch('/api/export/image/result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requestId: data.requestId,
error: (exportError as Error).message
})
})
}
}
break
case 'set_viewport':
console.log('Received viewport control request', data)
if (data.requestId) {
try {
if (data.scrollToContent) {
const allElements = excalidrawAPI.getSceneElements()
if (allElements.length > 0) {
excalidrawAPI.scrollToContent(allElements, { fitToViewport: true, animate: true })
}
} else if (data.scrollToElementId) {
const allElements = excalidrawAPI.getSceneElements()
const targetElement = allElements.find(el => el.id === data.scrollToElementId)
if (targetElement) {
excalidrawAPI.scrollToContent([targetElement], { fitToViewport: false, animate: true })
} else {
throw new Error(`Element ${data.scrollToElementId} not found`)
}
} else {
// Direct zoom/scroll control
const appState: any = {}
if (data.zoom !== undefined) {
appState.zoom = { value: data.zoom }
}
if (data.offsetX !== undefined) {
appState.scrollX = data.offsetX
}
if (data.offsetY !== undefined) {
appState.scrollY = data.offsetY
}
if (Object.keys(appState).length > 0) {
excalidrawAPI.updateScene({ appState })
}
}
await fetch('/api/viewport/result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requestId: data.requestId,
success: true,
message: 'Viewport updated'
})
})
} catch (viewportError) {
console.error('Viewport control failed:', viewportError)
await fetch('/api/viewport/result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requestId: data.requestId,
error: (viewportError as Error).message
})
}).catch(() => {})
}
}
break
case 'mermaid_convert':
console.log('Received Mermaid conversion request from MCP')
if (data.mermaidDiagram) {
-66
View File
@@ -1,66 +0,0 @@
---
name: excalidraw-mcp
description: Create, edit, and export live Excalidraw diagrams using mcp-excalidraw-server (MCP tools + canvas REST API). Use when an agent needs to draw/lay out diagrams, convert Mermaid to Excalidraw, query/update/delete elements, or export/import elements from a running canvas server (EXPRESS_SERVER_URL, default http://localhost:3000).
---
# Excalidraw MCP
## Overview
Create and refine diagrams on a live Excalidraw canvas via MCP tools, with helper scripts for export/import workflows.
## Quick Start
- Ensure the canvas server is reachable at `EXPRESS_SERVER_URL` (default `http://localhost:3000`).
- Use MCP tools for interactive diagram edits; use `scripts/*.cjs` for file-ish workflows (export/import/clear/health).
- For detailed endpoint/tool reference, read `references/cheatsheet.md`.
## Workflow: Draw A Diagram (From Empty Canvas)
1. Confirm canvas is up:
- Run `node scripts/healthcheck.cjs` (or GET `/health`).
2. Optional: clear the canvas:
- Run `node scripts/clear-canvas.cjs`.
3. Create shapes first (rectangles/diamonds/ellipses), using `create_element`.
4. Put text on shapes by setting the shapes `text` field (do not create a separate text element unless you need standalone text).
5. Create arrows/lines after both endpoints exist.
6. Use `align_elements` / `distribute_elements` after rough placement; group only after layout stabilizes.
## Workflow: Refine An Existing Diagram
1. Discover whats already there:
- Prefer `get_resource` with `resource: "elements"` or `query_elements`.
2. Identify targets by stable signals (id, type, label text), not by exact x/y.
3. Update with `update_element` (move/resize/colors/text) or delete with `delete_element`.
4. If deletes/updates “dont work”, check:
- Youre pointing to the right `EXPRESS_SERVER_URL`.
- The element id exists on the canvas (use `get_resource` / `GET /api/elements/:id`).
- The element isnt locked (use `unlock_elements` first).
## Workflow: Export / Import (Repository-Friendly)
- Export current elements to a JSON file:
- `node scripts/export-elements.cjs --out diagram.elements.json`
- Import elements (append) using batch create:
- `node scripts/import-elements.cjs --in diagram.elements.json --mode batch`
- Import elements (overwrite canvas) using sync:
- `node scripts/import-elements.cjs --in diagram.elements.json --mode sync`
Notes:
- `--mode sync` clears the canvas and then writes the provided elements (good for “make canvas match this file”).
- If you want stable ids across updates, keep ids in the exported JSON; if you want fresh ids, regenerate before importing.
## Workflow: CRUD Smoke Test (Create → Update → Delete)
1. Clear:
- `node scripts/clear-canvas.cjs`
2. Create a large visible rectangle + label:
- Use `node scripts/create-element.cjs` twice (rectangle + text).
3. Update:
- Move the rectangle with `node scripts/update-element.cjs`.
4. Delete:
- Remove both with `node scripts/delete-element.cjs`.
## References
- `references/cheatsheet.md`: MCP tool list + REST API endpoints + payload shapes
@@ -1,51 +0,0 @@
# Excalidraw MCP Cheatsheet
## Defaults
- Canvas base URL: `EXPRESS_SERVER_URL` (default `http://localhost:3000`)
- Canvas health: `GET /health`
## MCP Tools (Server-Side)
Tool names are defined in `src/index.ts`.
- `create_element`: create a shape/text/arrow/line
- `update_element`: update an element by `id`
- `delete_element`: delete an element by `id`
- `query_elements`: query by `type` and/or exact-match filters
- `get_resource`: `scene` | `library` | `theme` | `elements`
- `group_elements` / `ungroup_elements`
- `align_elements` / `distribute_elements`
- `lock_elements` / `unlock_elements`
- `create_from_mermaid`: send Mermaid diagram to canvas for frontend conversion
- `batch_create_elements`: create many elements in one call
Notes:
- For shapes, set the `text` field to place text inside the shape (backend converts to `label.text`).
- Prefer creating shapes first, then arrows, then alignment/grouping.
## Canvas REST API (HTTP)
Read/write primitives (used by the MCP server and helper scripts):
- `GET /api/elements` -> `{ success, elements, count }`
- `GET /api/elements/:id` -> `{ success, element }`
- `POST /api/elements` -> `{ success, element }`
- `PUT /api/elements/:id` -> `{ success, element }`
- `DELETE /api/elements/:id` -> `{ success, message }`
- `GET /api/elements/search?type=...&key=value` -> `{ success, elements, count }`
- `POST /api/elements/batch` -> `{ success, elements, count }`
- `POST /api/elements/from-mermaid` -> triggers websocket conversion on the frontend
- `POST /api/elements/sync` -> clears stored elements, then writes provided ones (overwrite import)
## Skill Scripts
All scripts accept `--url <canvasUrl>` (defaults to `EXPRESS_SERVER_URL`).
- `node scripts/healthcheck.cjs`
- `node scripts/clear-canvas.cjs`
- `node scripts/export-elements.cjs --out diagram.elements.json`
- `node scripts/import-elements.cjs --in diagram.elements.json --mode batch|sync`
- `node scripts/create-element.cjs --data '{...}'`
- `node scripts/update-element.cjs --id <id> --data '{...}'`
- `node scripts/delete-element.cjs --id <id>`
+106
View File
@@ -0,0 +1,106 @@
---
name: excalidraw-skill
description: Programmatic canvas toolkit for creating, editing, and refining Excalidraw diagrams via MCP tools with real-time canvas sync. Use when an agent needs to (1) draw or lay out diagrams on a live canvas, (2) iteratively refine diagrams using describe_scene and get_canvas_screenshot to see its own work, (3) export/import .excalidraw files or PNG/SVG images, (4) save/restore canvas snapshots, (5) convert Mermaid to Excalidraw, or (6) perform element-level CRUD, alignment, distribution, grouping, duplication, and locking. Requires a running canvas server (EXPRESS_SERVER_URL, default http://localhost:3000).
---
# Excalidraw Skill
## Quick Start
1. Ensure canvas server is reachable at `EXPRESS_SERVER_URL` (default `http://localhost:3000`).
2. Open the canvas URL in a browser (required for image export/screenshot).
3. Use MCP tools for all diagram operations; use `scripts/*.cjs` for CLI workflows.
4. For full tool/endpoint reference, read `references/cheatsheet.md`.
## Workflow: Draw A Diagram
1. **Call `read_diagram_guide`** first to load design best practices (colors, sizing, layout, anti-patterns).
2. Confirm canvas: `node scripts/healthcheck.cjs` or `GET /health`.
3. Optional: `clear_canvas` to start fresh.
4. Use `batch_create_elements` with all shapes AND arrows in one call.
5. **Assign custom `id` to shapes** (e.g. `"id": "auth-svc"`). Set `text` field to label shapes.
6. **Bind arrows to shapes** using `startElementId` / `endElementId` — arrows auto-route to element edges.
7. `align_elements` / `distribute_elements` after rough placement.
8. `set_viewport` with `scrollToContent: true` to auto-fit the diagram in view.
9. `describe_scene` to verify layout. `get_canvas_screenshot` to visually check.
### Arrow Binding (Recommended)
Use `startElementId` and `endElementId` on arrows to bind them to shapes. The server automatically calculates edge-to-edge routing with proper gaps. Example:
```json
{"elements": [
{"id": "svc-a", "type": "rectangle", "x": 0, "y": 0, "width": 120, "height": 60, "text": "Service A"},
{"id": "svc-b", "type": "rectangle", "x": 0, "y": 200, "width": 120, "height": 60, "text": "Service B"},
{"type": "arrow", "x": 0, "y": 0, "startElementId": "svc-a", "endElementId": "svc-b", "text": "calls"}
]}
```
Arrows without `startElementId`/`endElementId` use manual `x`, `y`, `points` coordinates.
## Workflow: Iterative Refinement (Key Differentiator)
The feedback loop that makes this skill unique:
1. `describe_scene` -- read what's on the canvas (types, positions, labels, connections).
2. Decide what to change based on the description.
3. Apply changes (`update_element`, `align_elements`, `create_element`, etc.).
4. `get_canvas_screenshot` -- visually verify the result (returns PNG to multimodal AI).
5. Repeat until satisfied.
Example flow:
```
create_element (rectangles, arrows) → describe_scene → "layout is cramped"
→ distribute_elements → get_canvas_screenshot → "arrow misaligned"
→ update_element → get_canvas_screenshot → "looks good"
→ export_scene --filePath architecture.excalidraw
```
## Workflow: Refine An Existing Diagram
1. `describe_scene` to understand current state.
2. Identify targets by id, type, or label text (not x/y coordinates).
3. `update_element` to move/resize/recolor, `delete_element` to remove.
4. `get_canvas_screenshot` to verify changes visually.
5. If updates fail: check element id exists (`get_element`), element isn't locked (`unlock_elements`).
## Workflow: File I/O (Diagrams-as-Code)
- Export to .excalidraw format: `export_scene` with optional `filePath`.
- Import from .excalidraw: `import_scene` with `mode: "replace"` or `"merge"`.
- Export to image: `export_to_image` with `format: "png"` or `"svg"` (requires browser open).
- CLI export: `node scripts/export-elements.cjs --out diagram.elements.json`
- CLI import: `node scripts/import-elements.cjs --in diagram.elements.json --mode batch|sync`
## Workflow: Snapshots (Save/Restore Canvas State)
1. `snapshot_scene` with a name before risky changes.
2. Make changes, `describe_scene` / `get_canvas_screenshot` to evaluate.
3. `restore_snapshot` to rollback if needed.
## Workflow: Duplication
- `duplicate_elements` with `elementIds` and optional `offsetX`/`offsetY` (default 20,20).
- Useful for creating repeated patterns or copying existing layouts.
## Points Format for Arrows/Lines
The `points` field accepts both formats:
- Tuple: `[[0, 0], [100, 50]]`
- Object: `[{"x": 0, "y": 0}, {"x": 100, "y": 50}]`
Both are normalized to tuples automatically.
## Workflow: Share Diagram (excalidraw.com URL)
1. Create your diagram using any of the above workflows.
2. `export_to_excalidraw_url` — uploads encrypted scene, returns a shareable URL.
3. Share the URL — anyone can open it in excalidraw.com to view and edit.
## Workflow: Viewport Control
- `set_viewport` with `scrollToContent: true` — auto-fit all elements (zoom-to-fit).
- `set_viewport` with `scrollToElementId: "my-element"` — center view on a specific element.
- `set_viewport` with `zoom: 1.5, offsetX: 100, offsetY: 200` — manual camera control.
## References
- `references/cheatsheet.md`: Complete MCP tool list (26 tools) + REST API endpoints + payload shapes.
@@ -0,0 +1,139 @@
# Excalidraw Skill Cheatsheet
## Defaults
- Canvas base URL: `EXPRESS_SERVER_URL` (default `http://localhost:3000`)
- Canvas health: `GET /health`
## MCP Tools (26 total)
### Element CRUD
| Tool | Description | Required params |
|------|-------------|-----------------|
| `create_element` | Create shape/text/arrow/line | `type`, `x`, `y` |
| `get_element` | Get single element by ID | `id` |
| `update_element` | Update element properties | `id` |
| `delete_element` | Delete element | `id` |
| `query_elements` | Query by type/filters | (optional) `type`, `filter` |
| `batch_create_elements` | Create many at once | `elements[]` |
| `duplicate_elements` | Clone with offset | `elementIds[]`, (optional) `offsetX`, `offsetY` |
### Layout & Organization
| Tool | Description | Required params |
|------|-------------|-----------------|
| `align_elements` | Align to left/center/right/top/middle/bottom | `elementIds[]`, `alignment` |
| `distribute_elements` | Even spacing horizontal/vertical | `elementIds[]`, `direction` |
| `group_elements` | Group elements | `elementIds[]` |
| `ungroup_elements` | Ungroup | `groupId` |
| `lock_elements` | Lock elements | `elementIds[]` |
| `unlock_elements` | Unlock elements | `elementIds[]` |
### Scene Awareness (Iterative Refinement)
| Tool | Description | Required params |
|------|-------------|-----------------|
| `describe_scene` | AI-readable scene description (types, positions, labels, connections, bounding box) | (none) |
| `get_canvas_screenshot` | Returns PNG image of canvas for visual verification | (optional) `background` |
| `get_resource` | Get scene/library/theme/elements | `resource` |
### File I/O & Export
| Tool | Description | Required params |
|------|-------------|-----------------|
| `export_scene` | Export to .excalidraw JSON | (optional) `filePath` |
| `import_scene` | Import from .excalidraw JSON | `mode` ("replace"\|"merge"), `filePath` or `data` |
| `export_to_image` | Export to PNG/SVG (needs browser) | `format` ("png"\|"svg"), (optional) `filePath`, `background` |
| `export_to_excalidraw_url` | Upload & get shareable excalidraw.com URL | (none) |
### State Management
| Tool | Description | Required params |
|------|-------------|-----------------|
| `clear_canvas` | Remove all elements | (none) |
| `snapshot_scene` | Save named snapshot | `name` |
| `restore_snapshot` | Restore from snapshot | `name` |
### Viewport & Camera
| Tool | Description | Required params |
|------|-------------|-----------------|
| `set_viewport` | Control camera: zoom-to-fit, center on element, manual zoom/scroll (needs browser) | (optional) `scrollToContent`, `scrollToElementId`, `zoom`, `offsetX`, `offsetY` |
### Design Guide
| Tool | Description | Required params |
|------|-------------|-----------------|
| `read_diagram_guide` | Get design best practices (colors, sizing, layout, anti-patterns) | (none) |
### Conversion
| Tool | Description | Required params |
|------|-------------|-----------------|
| `create_from_mermaid` | Mermaid diagram to Excalidraw | `mermaidDiagram` |
Notes:
- For shapes, set `text` field to place text inside (backend converts to `label.text`).
- `points` accepts both `[[x,y]]` tuples and `[{x,y}]` objects.
- Prefer creating shapes first, then arrows, then alignment/grouping.
## Canvas REST API (HTTP)
### Elements
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/elements` | List all elements |
| `GET` | `/api/elements/:id` | Get element by ID |
| `POST` | `/api/elements` | Create element |
| `PUT` | `/api/elements/:id` | Update element |
| `DELETE` | `/api/elements/:id` | Delete element |
| `DELETE` | `/api/elements/clear` | Clear all elements |
| `GET` | `/api/elements/search?type=...` | Search with filters |
| `POST` | `/api/elements/batch` | Batch create |
| `POST` | `/api/elements/sync` | Overwrite import (clear + write) |
| `POST` | `/api/elements/from-mermaid` | Mermaid conversion via frontend |
### Export
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/export/image` | Request image export (needs frontend) |
| `POST` | `/api/export/image/result` | Frontend posts export result back |
### Viewport
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/viewport` | Set viewport/camera (needs frontend) |
| `POST` | `/api/viewport/result` | Frontend posts viewport result back |
### Snapshots
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/snapshots` | Save snapshot `{name}` |
| `GET` | `/api/snapshots` | List snapshots |
| `GET` | `/api/snapshots/:name` | Get snapshot by name |
### System
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/health` | Health check |
| `GET` | `/api/sync/status` | Memory/WebSocket stats |
## Skill Scripts
All scripts accept `--url <canvasUrl>` (defaults to `EXPRESS_SERVER_URL`).
```bash
node scripts/healthcheck.cjs
node scripts/clear-canvas.cjs
node scripts/export-elements.cjs --out diagram.elements.json
node scripts/import-elements.cjs --in diagram.elements.json --mode batch|sync
node scripts/create-element.cjs --data '{...}'
node scripts/update-element.cjs --id <id> --data '{...}'
node scripts/delete-element.cjs --id <id>
```
@@ -20,11 +20,8 @@ async function main() {
const { url } = parseArgs(process.argv.slice(2));
const baseUrl = url.replace(/\/$/, "");
// Use the sync endpoint as a fast "clear" primitive (clears server storage).
const res = await fetch(`${baseUrl}/api/elements/sync`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ elements: [], timestamp: new Date().toISOString() }),
const res = await fetch(`${baseUrl}/api/elements/clear`, {
method: "DELETE",
});
const json = await res.json().catch(() => null);
@@ -32,7 +29,7 @@ async function main() {
throw new Error(`Failed to clear canvas: ${res.status} ${res.statusText} ${json?.error ? `- ${json.error}` : ""}`);
}
console.log("Cleared canvas");
console.log(`Cleared canvas (${json.count} elements removed)`);
}
main().catch((err) => {
+1265 -75
View File
File diff suppressed because it is too large Load Diff
+482 -12
View File
@@ -6,9 +6,10 @@ import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import logger from './utils/logger.js';
import {
import {
elements,
generateId,
snapshots,
generateId,
EXCALIDRAW_ELEMENT_TYPES,
ServerElement,
ExcalidrawElementType,
@@ -18,7 +19,8 @@ import {
ElementDeletedMessage,
BatchCreatedMessage,
SyncStatusMessage,
InitialElementsMessage
InitialElementsMessage,
Snapshot
} from './types.js';
import { z } from 'zod';
import WebSocket from 'ws';
@@ -35,7 +37,7 @@ const wss = new WebSocketServer({ server });
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.json({ limit: '10mb' }));
// Serve static files from the build directory
const staticDir = path.join(__dirname, '../dist');
@@ -98,6 +100,7 @@ const CreateElementSchema = z.object({
backgroundColor: z.string().optional(),
strokeColor: z.string().optional(),
strokeWidth: z.number().optional(),
strokeStyle: z.string().optional(),
roughness: z.number().optional(),
opacity: z.number().optional(),
text: z.string().optional(),
@@ -107,7 +110,13 @@ const CreateElementSchema = z.object({
fontSize: z.number().optional(),
fontFamily: z.string().optional(),
groupIds: z.array(z.string()).optional(),
locked: z.boolean().optional()
locked: z.boolean().optional(),
// Arrow-specific properties
points: z.any().optional(),
start: z.object({ id: z.string() }).optional(),
end: z.object({ id: z.string() }).optional(),
startArrowhead: z.string().nullable().optional(),
endArrowhead: z.string().nullable().optional(),
});
const UpdateElementSchema = z.object({
@@ -120,6 +129,7 @@ const UpdateElementSchema = z.object({
backgroundColor: z.string().optional(),
strokeColor: z.string().optional(),
strokeWidth: z.number().optional(),
strokeStyle: z.string().optional(),
roughness: z.number().optional(),
opacity: z.number().optional(),
text: z.string().optional(),
@@ -129,7 +139,15 @@ const UpdateElementSchema = z.object({
fontSize: z.number().optional(),
fontFamily: z.string().optional(),
groupIds: z.array(z.string()).optional(),
locked: z.boolean().optional()
locked: z.boolean().optional(),
points: z.array(z.union([
z.tuple([z.number(), z.number()]),
z.object({ x: z.number(), y: z.number() })
])).optional(),
start: z.object({ id: z.string() }).optional(),
end: z.object({ id: z.string() }).optional(),
startArrowhead: z.string().nullable().optional(),
endArrowhead: z.string().nullable().optional(),
});
// API Routes
@@ -240,6 +258,33 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
}
});
// Clear all elements (must be before /:id route)
app.delete('/api/elements/clear', (req: Request, res: Response) => {
try {
const count = elements.size;
elements.clear();
broadcast({
type: 'canvas_cleared',
timestamp: new Date().toISOString()
});
logger.info(`Canvas cleared: ${count} elements removed`);
res.json({
success: true,
message: `Cleared ${count} elements`,
count
});
} catch (error) {
logger.error('Error clearing canvas:', error);
res.status(500).json({
success: false,
error: (error as Error).message
});
}
});
// Delete element
app.delete('/api/elements/:id', (req: Request, res: Response) => {
try {
@@ -349,20 +394,152 @@ app.get('/api/elements/:id', (req: Request, res: Response) => {
}
});
// Helper: compute edge point for an element given a direction toward a target
function computeEdgePoint(
el: ServerElement,
targetCenterX: number,
targetCenterY: number
): { x: number; y: number } {
const cx = el.x + (el.width || 0) / 2;
const cy = el.y + (el.height || 0) / 2;
const dx = targetCenterX - cx;
const dy = targetCenterY - cy;
if (el.type === 'diamond') {
// Diamond edge: use diamond geometry (rotated square)
const hw = (el.width || 0) / 2;
const hh = (el.height || 0) / 2;
if (dx === 0 && dy === 0) return { x: cx, y: cy + hh };
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
// Scale factor to reach diamond edge
const scale = (absDx / hw + absDy / hh) > 0
? 1 / (absDx / hw + absDy / hh)
: 1;
return { x: cx + dx * scale, y: cy + dy * scale };
}
if (el.type === 'ellipse') {
// Ellipse edge: parametric intersection
const a = (el.width || 0) / 2;
const b = (el.height || 0) / 2;
if (dx === 0 && dy === 0) return { x: cx, y: cy + b };
const angle = Math.atan2(dy, dx);
return { x: cx + a * Math.cos(angle), y: cy + b * Math.sin(angle) };
}
// Rectangle: find intersection with edges
const hw = (el.width || 0) / 2;
const hh = (el.height || 0) / 2;
if (dx === 0 && dy === 0) return { x: cx, y: cy + hh };
const angle = Math.atan2(dy, dx);
const tanA = Math.tan(angle);
// Check if ray intersects top/bottom edge or left/right edge
if (Math.abs(tanA * hw) <= hh) {
// Intersects left or right edge
const signX = dx >= 0 ? 1 : -1;
return { x: cx + signX * hw, y: cy + signX * hw * tanA };
} else {
// Intersects top or bottom edge
const signY = dy >= 0 ? 1 : -1;
return { x: cx + signY * hh / tanA, y: cy + signY * hh };
}
}
// Helper: resolve arrow bindings in a batch
function resolveArrowBindings(batchElements: ServerElement[]): void {
const elementMap = new Map<string, ServerElement>();
batchElements.forEach(el => elementMap.set(el.id, el));
// Also check existing elements for cross-batch references
elements.forEach((el, id) => {
if (!elementMap.has(id)) elementMap.set(id, el);
});
for (const el of batchElements) {
if (el.type !== 'arrow' && el.type !== 'line') continue;
const startRef = (el as any).start as { id: string } | undefined;
const endRef = (el as any).end as { id: string } | undefined;
if (!startRef && !endRef) continue;
const startEl = startRef ? elementMap.get(startRef.id) : undefined;
const endEl = endRef ? elementMap.get(endRef.id) : undefined;
// Calculate arrow path from edge to edge
const startCenter = startEl
? { x: startEl.x + (startEl.width || 0) / 2, y: startEl.y + (startEl.height || 0) / 2 }
: { x: el.x, y: el.y };
const endCenter = endEl
? { x: endEl.x + (endEl.width || 0) / 2, y: endEl.y + (endEl.height || 0) / 2 }
: { x: el.x + 100, y: el.y };
const GAP = 8;
const startPt = startEl
? computeEdgePoint(startEl, endCenter.x, endCenter.y)
: startCenter;
const endPt = endEl
? computeEdgePoint(endEl, startCenter.x, startCenter.y)
: endCenter;
// Apply gap: move start point slightly away from source, end point slightly away from target
const startDx = endPt.x - startPt.x;
const startDy = endPt.y - startPt.y;
const startDist = Math.sqrt(startDx * startDx + startDy * startDy) || 1;
const endDx = startPt.x - endPt.x;
const endDy = startPt.y - endPt.y;
const endDist = Math.sqrt(endDx * endDx + endDy * endDy) || 1;
const finalStart = {
x: startPt.x + (startDx / startDist) * GAP,
y: startPt.y + (startDy / startDist) * GAP
};
const finalEnd = {
x: endPt.x + (endDx / endDist) * GAP,
y: endPt.y + (endDy / endDist) * GAP
};
// Set arrow position and points
el.x = finalStart.x;
el.y = finalStart.y;
el.points = [[0, 0], [finalEnd.x - finalStart.x, finalEnd.y - finalStart.y]];
// Remove start/end refs (they were used for computation only)
delete (el as any).start;
delete (el as any).end;
// Set binding metadata for Excalidraw
if (startEl) {
(el as any).startBinding = {
elementId: startEl.id,
focus: 0,
gap: GAP
};
}
if (endEl) {
(el as any).endBinding = {
elementId: endEl.id,
focus: 0,
gap: GAP
};
}
}
}
// Batch create elements
app.post('/api/elements/batch', (req: Request, res: Response) => {
try {
const { elements: elementsToCreate } = req.body;
if (!Array.isArray(elementsToCreate)) {
return res.status(400).json({
success: false,
error: 'Expected an array of elements'
});
}
const createdElements: ServerElement[] = [];
elementsToCreate.forEach(elementData => {
const params = CreateElementSchema.parse(elementData);
// Prioritize passed ID (for MCP sync), otherwise generate new ID
@@ -375,17 +552,22 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
version: 1
};
elements.set(id, element);
createdElements.push(element);
});
// Resolve arrow bindings (computes positions, startBinding, endBinding, boundElements)
resolveArrowBindings(createdElements);
// Store all elements after binding resolution
createdElements.forEach(el => elements.set(el.id, el));
// Broadcast to all connected clients
const message: BatchCreatedMessage = {
type: 'elements_batch_created',
elements: createdElements
};
broadcast(message);
res.json({
success: true,
elements: createdElements,
@@ -525,6 +707,294 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
}
});
// Image export: request (MCP -> Express -> WebSocket -> Frontend)
interface PendingExport {
resolve: (data: { format: string; data: string }) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
const pendingExports = new Map<string, PendingExport>();
app.post('/api/export/image', (req: Request, res: Response) => {
try {
const { format, background } = req.body;
if (!format || !['png', 'svg'].includes(format)) {
return res.status(400).json({
success: false,
error: 'format must be "png" or "svg"'
});
}
if (clients.size === 0) {
return res.status(503).json({
success: false,
error: 'No frontend client connected. Open the canvas in a browser first.'
});
}
const requestId = generateId();
const exportPromise = new Promise<{ format: string; data: string }>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingExports.delete(requestId);
reject(new Error('Export timed out after 30 seconds'));
}, 30000);
pendingExports.set(requestId, { resolve, reject, timeout });
});
broadcast({
type: 'export_image_request',
requestId,
format,
background: background ?? true
});
exportPromise
.then(result => {
res.json({
success: true,
format: result.format,
data: result.data
});
})
.catch(error => {
res.status(500).json({
success: false,
error: (error as Error).message
});
});
} catch (error) {
logger.error('Error initiating image export:', error);
res.status(500).json({
success: false,
error: (error as Error).message
});
}
});
// Image export: result (Frontend -> Express -> MCP)
app.post('/api/export/image/result', (req: Request, res: Response) => {
try {
const { requestId, format, data, error } = req.body;
if (!requestId) {
return res.status(400).json({
success: false,
error: 'requestId is required'
});
}
const pending = pendingExports.get(requestId);
if (!pending) {
// Already resolved by another client, or expired — ignore silently
return res.json({ success: true });
}
if (error) {
// Don't reject on error — another WebSocket client may still succeed.
// The timeout will handle the case where ALL clients fail.
logger.warn(`Export error from one client (requestId=${requestId}): ${error}`);
return res.json({ success: true });
}
clearTimeout(pending.timeout);
pendingExports.delete(requestId);
pending.resolve({ format, data });
res.json({ success: true });
} catch (error) {
logger.error('Error processing export result:', error);
res.status(500).json({
success: false,
error: (error as Error).message
});
}
});
// Viewport control: request (MCP -> Express -> WebSocket -> Frontend)
interface PendingViewport {
resolve: (data: { success: boolean; message: string }) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
const pendingViewports = new Map<string, PendingViewport>();
app.post('/api/viewport', (req: Request, res: Response) => {
try {
const { scrollToContent, scrollToElementId, zoom, offsetX, offsetY } = req.body;
if (clients.size === 0) {
return res.status(503).json({
success: false,
error: 'No frontend client connected. Open the canvas in a browser first.'
});
}
const requestId = generateId();
const viewportPromise = new Promise<{ success: boolean; message: string }>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingViewports.delete(requestId);
reject(new Error('Viewport request timed out after 10 seconds'));
}, 10000);
pendingViewports.set(requestId, { resolve, reject, timeout });
});
broadcast({
type: 'set_viewport',
requestId,
scrollToContent,
scrollToElementId,
zoom,
offsetX,
offsetY
});
viewportPromise
.then(result => {
res.json(result);
})
.catch(error => {
res.status(500).json({
success: false,
error: (error as Error).message
});
});
} catch (error) {
logger.error('Error initiating viewport change:', error);
res.status(500).json({
success: false,
error: (error as Error).message
});
}
});
// Viewport control: result (Frontend -> Express -> MCP)
app.post('/api/viewport/result', (req: Request, res: Response) => {
try {
const { requestId, success, message, error } = req.body;
if (!requestId) {
return res.status(400).json({
success: false,
error: 'requestId is required'
});
}
const pending = pendingViewports.get(requestId);
if (!pending) {
return res.json({ success: true });
}
if (error) {
clearTimeout(pending.timeout);
pendingViewports.delete(requestId);
pending.resolve({ success: false, message: error });
return res.json({ success: true });
}
clearTimeout(pending.timeout);
pendingViewports.delete(requestId);
pending.resolve({ success: true, message: message || 'Viewport updated' });
res.json({ success: true });
} catch (error) {
logger.error('Error processing viewport result:', error);
res.status(500).json({
success: false,
error: (error as Error).message
});
}
});
// Snapshots: save
app.post('/api/snapshots', (req: Request, res: Response) => {
try {
const { name } = req.body;
if (!name || typeof name !== 'string') {
return res.status(400).json({
success: false,
error: 'Snapshot name is required'
});
}
const snapshot: Snapshot = {
name,
elements: Array.from(elements.values()),
createdAt: new Date().toISOString()
};
snapshots.set(name, snapshot);
logger.info(`Snapshot saved: "${name}" with ${snapshot.elements.length} elements`);
res.json({
success: true,
name,
elementCount: snapshot.elements.length,
createdAt: snapshot.createdAt
});
} catch (error) {
logger.error('Error saving snapshot:', error);
res.status(500).json({
success: false,
error: (error as Error).message
});
}
});
// Snapshots: list
app.get('/api/snapshots', (req: Request, res: Response) => {
try {
const list = Array.from(snapshots.values()).map(s => ({
name: s.name,
elementCount: s.elements.length,
createdAt: s.createdAt
}));
res.json({
success: true,
snapshots: list,
count: list.length
});
} catch (error) {
logger.error('Error listing snapshots:', error);
res.status(500).json({
success: false,
error: (error as Error).message
});
}
});
// Snapshots: get by name
app.get('/api/snapshots/:name', (req: Request, res: Response) => {
try {
const { name } = req.params;
const snapshot = snapshots.get(name!);
if (!snapshot) {
return res.status(404).json({
success: false,
error: `Snapshot "${name}" not found`
});
}
res.json({
success: true,
snapshot
});
} catch (error) {
logger.error('Error fetching snapshot:', error);
res.status(500).json({
success: false,
error: (error as Error).message
});
}
});
// Serve the frontend
app.get('/', (req: Request, res: Response) => {
const htmlFile = path.join(__dirname, '../dist/frontend/index.html');
+45 -4
View File
@@ -106,7 +106,7 @@ export interface ExcalidrawBinding {
fixedPoint?: readonly [number, number] | null;
}
export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw' | 'label';
export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw';
// Excalidraw element types
export const EXCALIDRAW_ELEMENT_TYPES: Record<string, ExcalidrawElementType> = {
@@ -115,7 +115,6 @@ export const EXCALIDRAW_ELEMENT_TYPES: Record<string, ExcalidrawElementType> = {
DIAMOND: 'diamond',
ARROW: 'arrow',
TEXT: 'text',
LABEL: 'label',
FREEDRAW: 'freedraw',
LINE: 'line'
} as const;
@@ -136,6 +135,10 @@ export interface ServerElement extends Omit<ExcalidrawElementBase, 'id'> {
label?: {
text: string;
};
points?: any;
// Arrow element binding: connect arrows to shapes by element ID
start?: { id: string };
end?: { id: string };
}
// API Response types
@@ -168,7 +171,7 @@ export interface WebSocketMessage {
[key: string]: any;
}
export type WebSocketMessageType =
export type WebSocketMessageType =
| 'initial_elements'
| 'element_created'
| 'element_updated'
@@ -176,7 +179,10 @@ export type WebSocketMessageType =
| 'elements_batch_created'
| 'elements_synced'
| 'sync_status'
| 'mermaid_convert';
| 'mermaid_convert'
| 'canvas_cleared'
| 'export_image_request'
| 'set_viewport';
export interface InitialElementsMessage extends WebSocketMessage {
type: 'initial_elements';
@@ -240,9 +246,44 @@ export interface MermaidConversionResponse extends ApiResponse {
count: number;
}
// Canvas cleared message
export interface CanvasClearedMessage extends WebSocketMessage {
type: 'canvas_cleared';
timestamp: string;
}
// Image export types
export interface ExportImageRequestMessage extends WebSocketMessage {
type: 'export_image_request';
requestId: string;
format: 'png' | 'svg';
background?: boolean;
}
// Viewport control types
export interface SetViewportMessage extends WebSocketMessage {
type: 'set_viewport';
requestId: string;
scrollToContent?: boolean;
scrollToElementId?: string;
zoom?: number;
offsetX?: number;
offsetY?: number;
}
// Snapshot types
export interface Snapshot {
name: string;
elements: ServerElement[];
createdAt: string;
}
// In-memory storage for Excalidraw elements
export const elements = new Map<string, ServerElement>();
// In-memory storage for snapshots
export const snapshots = new Map<string, Snapshot>();
// Validation function for Excalidraw elements
export function validateElement(element: Partial<ServerElement>): element is ServerElement {
const requiredFields: (keyof ServerElement)[] = ['type', 'x', 'y'];