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
+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) {