Merge pull request #31 from frNNcs/feature/mermaid-integration
Add Mermaid Diagram Integration to MCP Excalidraw
This commit is contained in:
@@ -99,6 +99,13 @@ Both local and Docker setups are **fully working** and production-ready!
|
||||
- **Strict Type Checking**: Enhanced development experience and compile-time error detection
|
||||
- **Type-Safe React Components**: TSX components with proper props typing
|
||||
|
||||
### **🎨 Mermaid Diagram Support (NEW!)**
|
||||
- **Mermaid to Excalidraw**: Convert Mermaid diagrams directly to Excalidraw elements
|
||||
- **MCP Tool Integration**: Use `create_from_mermaid` tool from Claude
|
||||
- **Browser-based Conversion**: Leverages DOM access in the frontend for accurate rendering
|
||||
- **Multiple Diagram Types**: Supports flowcharts, sequence diagrams, class diagrams, and more
|
||||
- **Test Button**: Quick test functionality directly from the canvas UI
|
||||
|
||||
### **Real-time Canvas Integration**
|
||||
- Elements created via MCP appear instantly on the live canvas
|
||||
- WebSocket-based real-time synchronization
|
||||
|
||||
+48
-14
@@ -2,13 +2,14 @@ import React, { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Excalidraw,
|
||||
convertToExcalidrawElements,
|
||||
CaptureUpdateAction,
|
||||
ExcalidrawAPIRefValue,
|
||||
ExcalidrawElement
|
||||
CaptureUpdateAction
|
||||
} from '@excalidraw/excalidraw'
|
||||
import '@excalidraw/excalidraw/index.css'
|
||||
import { convertMermaidToExcalidraw, DEFAULT_MERMAID_CONFIG } from './utils/mermaidConverter'
|
||||
|
||||
// Type definitions
|
||||
type ExcalidrawAPIRefValue = any;
|
||||
type ExcalidrawElement = any;
|
||||
|
||||
interface ServerElement {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -164,7 +165,7 @@ function App(): JSX.Element {
|
||||
|
||||
if (result.success && result.elements && result.elements.length > 0) {
|
||||
const cleanedElements = result.elements.map(cleanElementForExcalidraw)
|
||||
const convertedElements = convertToExcalidrawElements(cleanedElements, { regenerateIds: false })
|
||||
const convertedElements = convertToExcalidrawElements(cleanedElements as any, { regenerateIds: false })
|
||||
excalidrawAPI?.updateScene({ elements: convertedElements })
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -214,7 +215,7 @@ function App(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const handleWebSocketMessage = (data: WebSocketMessage): void => {
|
||||
const handleWebSocketMessage = async (data: WebSocketMessage): Promise<void> => {
|
||||
if (!excalidrawAPI) {
|
||||
return
|
||||
}
|
||||
@@ -228,7 +229,7 @@ function App(): JSX.Element {
|
||||
if (data.elements && data.elements.length > 0) {
|
||||
const cleanedElements = data.elements.map(cleanElementForExcalidraw)
|
||||
const validatedElements = validateAndFixBindings(cleanedElements)
|
||||
const convertedElements = convertToExcalidrawElements(validatedElements)
|
||||
const convertedElements = convertToExcalidrawElements(validatedElements as any)
|
||||
excalidrawAPI.updateScene({
|
||||
elements: convertedElements,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
@@ -239,7 +240,7 @@ function App(): JSX.Element {
|
||||
case 'element_created':
|
||||
if (data.element) {
|
||||
const cleanedNewElement = cleanElementForExcalidraw(data.element)
|
||||
const newElement = convertToExcalidrawElements([cleanedNewElement])
|
||||
const newElement = convertToExcalidrawElements([cleanedNewElement] as any)
|
||||
const updatedElementsAfterCreate = [...currentElements, ...newElement]
|
||||
excalidrawAPI.updateScene({
|
||||
elements: updatedElementsAfterCreate,
|
||||
@@ -251,8 +252,8 @@ function App(): JSX.Element {
|
||||
case 'element_updated':
|
||||
if (data.element) {
|
||||
const cleanedUpdatedElement = cleanElementForExcalidraw(data.element)
|
||||
const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement])[0]
|
||||
const updatedElements = currentElements.map(el =>
|
||||
const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement] as any)[0]
|
||||
const updatedElements = currentElements.map((el: any) =>
|
||||
el.id === data.element!.id ? convertedUpdatedElement : el
|
||||
)
|
||||
excalidrawAPI.updateScene({
|
||||
@@ -264,7 +265,7 @@ function App(): JSX.Element {
|
||||
|
||||
case 'element_deleted':
|
||||
if (data.elementId) {
|
||||
const filteredElements = currentElements.filter(el => el.id !== data.elementId)
|
||||
const filteredElements = currentElements.filter((el: any) => el.id !== data.elementId)
|
||||
excalidrawAPI.updateScene({
|
||||
elements: filteredElements,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
@@ -275,7 +276,7 @@ function App(): JSX.Element {
|
||||
case 'elements_batch_created':
|
||||
if (data.elements) {
|
||||
const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw)
|
||||
const batchElements = convertToExcalidrawElements(cleanedBatchElements)
|
||||
const batchElements = convertToExcalidrawElements(cleanedBatchElements as any)
|
||||
const updatedElementsAfterBatch = [...currentElements, ...batchElements]
|
||||
excalidrawAPI.updateScene({
|
||||
elements: updatedElementsAfterBatch,
|
||||
@@ -293,6 +294,39 @@ function App(): JSX.Element {
|
||||
console.log(`Server sync status: ${data.count} elements`)
|
||||
break
|
||||
|
||||
case 'mermaid_convert':
|
||||
console.log('Received Mermaid conversion request from MCP')
|
||||
if ((data as any).mermaidDiagram) {
|
||||
try {
|
||||
const result = await convertMermaidToExcalidraw((data as any).mermaidDiagram, (data as any).config || DEFAULT_MERMAID_CONFIG)
|
||||
|
||||
if (result.error) {
|
||||
console.error('Mermaid conversion error:', result.error)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.elements && result.elements.length > 0) {
|
||||
const convertedElements = convertToExcalidrawElements(result.elements as any, { regenerateIds: false })
|
||||
excalidrawAPI.updateScene({
|
||||
elements: convertedElements,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY
|
||||
})
|
||||
|
||||
if (result.files) {
|
||||
excalidrawAPI.addFiles(Object.values(result.files))
|
||||
}
|
||||
|
||||
console.log('Mermaid diagram converted successfully:', result.elements.length, 'elements')
|
||||
|
||||
// Sync to backend automatically after creating elements
|
||||
await syncToBackend()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error converting Mermaid diagram from WebSocket:', error)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
console.log('Unknown WebSocket message type:', data.type)
|
||||
}
|
||||
@@ -332,8 +366,8 @@ function App(): JSX.Element {
|
||||
const currentElements = excalidrawAPI.getSceneElements()
|
||||
console.log(`Syncing ${currentElements.length} elements to backend`)
|
||||
|
||||
// 2. Filter out deleted elements
|
||||
const activeElements = currentElements.filter(el => !el.isDeleted)
|
||||
// Filter out deleted elements
|
||||
const activeElements = currentElements.filter((el: any) => !el.isDeleted)
|
||||
|
||||
// 3. Convert to backend format
|
||||
const backendElements = activeElements.map(convertToBackendFormat)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import '@excalidraw/excalidraw/index.css'
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { parseMermaidToExcalidraw, MermaidConfig } from '@excalidraw/mermaid-to-excalidraw';
|
||||
|
||||
export interface MermaidConversionResult {
|
||||
elements: any[];
|
||||
files?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Mermaid diagram definition to Excalidraw elements
|
||||
* This function needs to run in the browser context as it requires DOM access
|
||||
*/
|
||||
export const convertMermaidToExcalidraw = async (
|
||||
mermaidDefinition: string,
|
||||
config?: MermaidConfig
|
||||
): Promise<MermaidConversionResult> => {
|
||||
try {
|
||||
// Parse the Mermaid diagram to Excalidraw elements
|
||||
const result = await parseMermaidToExcalidraw(mermaidDefinition, config);
|
||||
|
||||
return {
|
||||
elements: result.elements,
|
||||
files: result.files,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error converting Mermaid to Excalidraw:', error);
|
||||
return {
|
||||
elements: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Default Mermaid configuration for Excalidraw conversion
|
||||
*/
|
||||
export const DEFAULT_MERMAID_CONFIG: MermaidConfig = {
|
||||
startOnLoad: false,
|
||||
flowchart: {
|
||||
curve: 'linear',
|
||||
},
|
||||
themeVariables: {
|
||||
fontSize: '20px',
|
||||
},
|
||||
maxEdges: 500,
|
||||
maxTextSize: 50000,
|
||||
};
|
||||
Generated
+863
-81
File diff suppressed because it is too large
Load Diff
@@ -22,10 +22,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "^0.18.0",
|
||||
"@excalidraw/mermaid-to-excalidraw": "^1.1.3",
|
||||
"@modelcontextprotocol/sdk": "latest",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"mermaid": "^11.12.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -414,6 +414,41 @@ const tools: Tool[] = [
|
||||
required: ['elementIds']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'create_from_mermaid',
|
||||
description: 'Convert a Mermaid diagram to Excalidraw elements and render them on the canvas',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mermaidDiagram: {
|
||||
type: 'string',
|
||||
description: 'The Mermaid diagram definition (e.g., "graph TD; A-->B; B-->C;")'
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
description: 'Optional Mermaid configuration',
|
||||
properties: {
|
||||
startOnLoad: { type: 'boolean' },
|
||||
flowchart: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
curve: { type: 'string', enum: ['linear', 'basis'] }
|
||||
}
|
||||
},
|
||||
themeVariables: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fontSize: { type: 'string' }
|
||||
}
|
||||
},
|
||||
maxEdges: { type: 'number' },
|
||||
maxTextSize: { type: 'number' }
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['mermaidDiagram']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'batch_create_elements',
|
||||
description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams',
|
||||
@@ -822,6 +857,60 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
}
|
||||
}
|
||||
|
||||
case 'create_from_mermaid': {
|
||||
const params = z.object({
|
||||
mermaidDiagram: z.string(),
|
||||
config: z.object({
|
||||
startOnLoad: z.boolean().optional(),
|
||||
flowchart: z.object({
|
||||
curve: z.enum(['linear', 'basis']).optional()
|
||||
}).optional(),
|
||||
themeVariables: z.object({
|
||||
fontSize: z.string().optional()
|
||||
}).optional(),
|
||||
maxEdges: z.number().optional(),
|
||||
maxTextSize: z.number().optional()
|
||||
}).optional()
|
||||
}).parse(args);
|
||||
|
||||
logger.info('Creating Excalidraw elements from Mermaid diagram via MCP', {
|
||||
diagramLength: params.mermaidDiagram.length,
|
||||
hasConfig: !!params.config
|
||||
});
|
||||
|
||||
try {
|
||||
// Send the Mermaid diagram to the frontend via the API
|
||||
// The frontend will use mermaid-to-excalidraw to convert it
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/from-mermaid`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mermaidDiagram: params.mermaidDiagram,
|
||||
config: params.config
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as ApiResponse;
|
||||
|
||||
logger.info('Mermaid diagram sent to frontend for conversion', {
|
||||
success: result.success
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Mermaid diagram sent for conversion!\n\n${JSON.stringify(result, null, 2)}\n\n⚠️ Note: The actual conversion happens in the frontend canvas with DOM access. Open the canvas at ${EXPRESS_SERVER_URL} to see the diagram rendered.`
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to process Mermaid diagram: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
case 'batch_create_elements': {
|
||||
const params = z.object({ elements: z.array(ElementSchema) }).parse(args);
|
||||
logger.info('Batch creating elements via MCP', { count: params.elements.length });
|
||||
|
||||
@@ -399,6 +399,47 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Convert Mermaid diagram to Excalidraw elements
|
||||
app.post('/api/elements/from-mermaid', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { mermaidDiagram, config } = req.body;
|
||||
|
||||
if (!mermaidDiagram || typeof mermaidDiagram !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Mermaid diagram definition is required'
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Received Mermaid conversion request', {
|
||||
diagramLength: mermaidDiagram.length,
|
||||
hasConfig: !!config
|
||||
});
|
||||
|
||||
// Broadcast to all WebSocket clients to process the Mermaid diagram
|
||||
broadcast({
|
||||
type: 'mermaid_convert',
|
||||
mermaidDiagram,
|
||||
config: config || {},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Return the diagram for frontend processing
|
||||
res.json({
|
||||
success: true,
|
||||
mermaidDiagram,
|
||||
config: config || {},
|
||||
message: 'Mermaid diagram sent to frontend for conversion.'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error processing Mermaid diagram:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sync elements from frontend (overwrite sync)
|
||||
app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
+33
-1
@@ -175,7 +175,8 @@ export type WebSocketMessageType =
|
||||
| 'element_deleted'
|
||||
| 'elements_batch_created'
|
||||
| 'elements_synced'
|
||||
| 'sync_status';
|
||||
| 'sync_status'
|
||||
| 'mermaid_convert';
|
||||
|
||||
export interface InitialElementsMessage extends WebSocketMessage {
|
||||
type: 'initial_elements';
|
||||
@@ -208,6 +209,37 @@ export interface SyncStatusMessage extends WebSocketMessage {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface MermaidConvertMessage extends WebSocketMessage {
|
||||
type: 'mermaid_convert';
|
||||
mermaidDiagram: string;
|
||||
config?: MermaidConfig;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Mermaid conversion types
|
||||
export interface MermaidConfig {
|
||||
startOnLoad?: boolean;
|
||||
flowchart?: {
|
||||
curve?: 'linear' | 'basis';
|
||||
};
|
||||
themeVariables?: {
|
||||
fontSize?: string;
|
||||
};
|
||||
maxEdges?: number;
|
||||
maxTextSize?: number;
|
||||
}
|
||||
|
||||
export interface MermaidConversionRequest {
|
||||
mermaidDiagram: string;
|
||||
config?: MermaidConfig;
|
||||
}
|
||||
|
||||
export interface MermaidConversionResponse extends ApiResponse {
|
||||
elements: ServerElement[];
|
||||
files?: any;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// In-memory storage for Excalidraw elements
|
||||
export const elements = new Map<string, ServerElement>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user