+3
-1
@@ -17,4 +17,6 @@ public/dist/
|
||||
.claude/
|
||||
|
||||
# Development artifacts
|
||||
*.excalidraw
|
||||
*.excalidraw
|
||||
|
||||
docs/
|
||||
@@ -1,6 +1,6 @@
|
||||
# MCP Excalidraw Server: Advanced Live Visual Diagramming with AI Integration
|
||||
|
||||
A comprehensive system that combines **Excalidraw's powerful drawing capabilities** with **Model Context Protocol (MCP)** integration, enabling AI agents to create and manipulate diagrams in real-time on a live canvas.
|
||||
A comprehensive **TypeScript-based** system that combines **Excalidraw's powerful drawing capabilities** with **Model Context Protocol (MCP)** integration, enabling AI agents to create and manipulate diagrams in real-time on a live canvas.
|
||||
|
||||
## 🚦 Current Status & Version Information
|
||||
|
||||
@@ -54,6 +54,12 @@ For the most stable experience, we recommend using the local development setup.
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
### **Modern TypeScript Architecture**
|
||||
- **Full TypeScript Migration**: Complete type safety for backend and frontend
|
||||
- **Comprehensive Type Definitions**: Excalidraw elements, API responses, WebSocket messages
|
||||
- **Strict Type Checking**: Enhanced development experience and compile-time error detection
|
||||
- **Type-Safe React Components**: TSX components with proper props typing
|
||||
|
||||
### **Real-time Canvas Integration**
|
||||
- Elements created via MCP appear instantly on the live canvas
|
||||
- WebSocket-based real-time synchronization
|
||||
@@ -71,8 +77,8 @@ For the most stable experience, we recommend using the local development setup.
|
||||
- **Advanced Features**: grouping, alignment, distribution, locking
|
||||
|
||||
### **Robust Architecture**
|
||||
- Express.js backend with REST API + WebSocket
|
||||
- React frontend with official Excalidraw package
|
||||
- TypeScript-based Express.js backend with REST API + WebSocket
|
||||
- React frontend with official Excalidraw package and TypeScript
|
||||
- Dual-path element loading for reliability
|
||||
- Auto-reconnection and error handling
|
||||
|
||||
@@ -133,10 +139,13 @@ docker run -p 3000:3000 mcp-excalidraw-server
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `npm start` | Start MCP server (`src/index.js`) |
|
||||
| `npm run canvas` | Start canvas server (`src/server.js`) |
|
||||
| `npm run build` | Build frontend for production |
|
||||
| `npm run dev` | Start canvas + Vite dev server |
|
||||
| `npm start` | Build and start MCP server (`dist/index.js`) |
|
||||
| `npm run canvas` | Build and start canvas server (`dist/server.js`) |
|
||||
| `npm run build` | Build both frontend and TypeScript backend |
|
||||
| `npm run build:frontend` | Build React frontend only |
|
||||
| `npm run build:server` | Compile TypeScript backend to JavaScript |
|
||||
| `npm run dev` | Start TypeScript watch mode + Vite dev server |
|
||||
| `npm run type-check` | Run TypeScript type checking without compilation |
|
||||
| `npm run production` | Build + start in production mode |
|
||||
|
||||
## 🎯 Usage Guide
|
||||
@@ -225,13 +234,13 @@ For the **local development version** (most stable), add this configuration to y
|
||||
"mcpServers": {
|
||||
"excalidraw": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/mcp_excalidraw/src/index.js"]
|
||||
"args": ["/absolute/path/to/mcp_excalidraw/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: Replace `/absolute/path/to/mcp_excalidraw` with the actual absolute path to your cloned repository.
|
||||
**Important**: Replace `/absolute/path/to/mcp_excalidraw` with the actual absolute path to your cloned repository. Note that the path now points to `dist/index.js` (the compiled TypeScript output).
|
||||
|
||||
### **🔧 Alternative Configurations (Beta)**
|
||||
|
||||
@@ -272,7 +281,7 @@ Add to your `.cursor/mcp.json`:
|
||||
"mcpServers": {
|
||||
"excalidraw": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/mcp_excalidraw/src/index.js"]
|
||||
"args": ["/absolute/path/to/mcp_excalidraw/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,7 +297,7 @@ For VS Code MCP extension, add to your settings:
|
||||
"servers": {
|
||||
"excalidraw": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/mcp_excalidraw/src/index.js"]
|
||||
"args": ["/absolute/path/to/mcp_excalidraw/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,22 +351,29 @@ The canvas server provides these REST endpoints:
|
||||
## 🏗️ Development Architecture
|
||||
|
||||
### **Frontend** (`frontend/src/`)
|
||||
- **React + Vite**: Modern build system
|
||||
- **Official Excalidraw**: `@excalidraw/excalidraw` package
|
||||
- **WebSocket Client**: Real-time element sync
|
||||
- **Clean UI**: Production-ready interface
|
||||
- **React + TypeScript**: Modern TSX components with full type safety
|
||||
- **Vite Build System**: Fast development and optimized production builds
|
||||
- **Official Excalidraw**: `@excalidraw/excalidraw` package with TypeScript types
|
||||
- **WebSocket Client**: Type-safe real-time element synchronization
|
||||
- **Clean UI**: Production-ready interface with proper TypeScript typing
|
||||
|
||||
### **Canvas Server** (`src/server.js`)
|
||||
- **Express.js**: REST API + static file serving
|
||||
- **WebSocket**: Real-time client communication
|
||||
- **Element Storage**: In-memory with persistence options
|
||||
- **CORS**: Cross-origin support
|
||||
### **Canvas Server** (`src/server.ts` → `dist/server.js`)
|
||||
- **TypeScript + Express.js**: Fully typed REST API + static file serving
|
||||
- **WebSocket**: Type-safe real-time client communication
|
||||
- **Element Storage**: In-memory with comprehensive type definitions
|
||||
- **CORS**: Cross-origin support with proper typing
|
||||
|
||||
### **MCP Server** (`src/index.js`)
|
||||
- **MCP Protocol**: Standard Model Context Protocol
|
||||
- **Canvas Sync**: HTTP requests to canvas server
|
||||
- **Element Management**: Full CRUD operations
|
||||
- **Batch Support**: Complex diagram creation
|
||||
### **MCP Server** (`src/index.ts` → `dist/index.js`)
|
||||
- **TypeScript MCP Protocol**: Type-safe Model Context Protocol implementation
|
||||
- **Canvas Sync**: Strongly typed HTTP requests to canvas server
|
||||
- **Element Management**: Full CRUD operations with comprehensive type checking
|
||||
- **Batch Support**: Type-safe complex diagram creation
|
||||
|
||||
### **Type System** (`src/types.ts`)
|
||||
- **Excalidraw Element Types**: Complete type definitions for all element types
|
||||
- **API Response Types**: Strongly typed REST API interfaces
|
||||
- **WebSocket Message Types**: Type-safe real-time communication
|
||||
- **Server Element Types**: Enhanced element types with metadata
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
@@ -390,6 +406,8 @@ The canvas server provides these REST endpoints:
|
||||
- Delete `node_modules` and run `npm install`
|
||||
- Check Node.js version (requires 16+)
|
||||
- Ensure all dependencies are installed
|
||||
- Run `npm run type-check` to identify TypeScript issues
|
||||
- Verify `dist/` directory is created after `npm run build:server`
|
||||
|
||||
## 📋 Project Structure
|
||||
|
||||
@@ -397,16 +415,23 @@ The canvas server provides these REST endpoints:
|
||||
mcp_excalidraw/
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── App.jsx # Main React component
|
||||
│ │ └── main.jsx # React entry point
|
||||
│ │ ├── App.tsx # Main React component (TypeScript)
|
||||
│ │ └── main.tsx # React entry point (TypeScript)
|
||||
│ └── index.html # HTML template
|
||||
├── src/
|
||||
│ ├── index.js # MCP server
|
||||
│ ├── server.js # Canvas server (Express + WebSocket)
|
||||
│ ├── types.js # Shared types and utilities
|
||||
├── src/ (TypeScript Source)
|
||||
│ ├── index.ts # MCP server (TypeScript)
|
||||
│ ├── server.ts # Canvas server (Express + WebSocket, TypeScript)
|
||||
│ ├── types.ts # Comprehensive type definitions
|
||||
│ └── utils/
|
||||
│ └── logger.js # Logging utility
|
||||
├── dist/ # Built frontend (generated)
|
||||
│ └── logger.ts # Logging utility (TypeScript)
|
||||
├── dist/ (Compiled Output)
|
||||
│ ├── index.js # Compiled MCP server
|
||||
│ ├── server.js # Compiled Canvas server
|
||||
│ ├── types.js # Compiled type definitions
|
||||
│ ├── utils/
|
||||
│ │ └── logger.js # Compiled logging utility
|
||||
│ └── frontend/ # Built React frontend
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
├── vite.config.js # Vite build configuration
|
||||
├── package.json # Dependencies and scripts
|
||||
└── README.md # This file
|
||||
@@ -414,10 +439,12 @@ mcp_excalidraw/
|
||||
|
||||
## 🔮 Development Roadmap
|
||||
|
||||
- ✅ **TypeScript Migration**: Complete type safety for enhanced development experience
|
||||
- **NPM Package**: Resolving MCP tool registration issues
|
||||
- **Docker Deployment**: Improving canvas synchronization
|
||||
- **Enhanced Features**: Additional MCP tools and capabilities
|
||||
- **Performance Optimization**: Real-time sync improvements
|
||||
- **Advanced TypeScript Features**: Stricter type checking and advanced type utilities
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
|
||||
@@ -1,21 +1,86 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Excalidraw, convertToExcalidrawElements, CaptureUpdateAction } from '@excalidraw/excalidraw'
|
||||
import {
|
||||
Excalidraw,
|
||||
convertToExcalidrawElements,
|
||||
CaptureUpdateAction,
|
||||
ExcalidrawAPIRefValue,
|
||||
ExcalidrawElement
|
||||
} from '@excalidraw/excalidraw'
|
||||
import '@excalidraw/excalidraw/index.css'
|
||||
|
||||
// Type definitions
|
||||
interface ServerElement {
|
||||
id: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
backgroundColor?: string;
|
||||
strokeColor?: string;
|
||||
strokeWidth?: number;
|
||||
roughness?: number;
|
||||
opacity?: number;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: string | number;
|
||||
label?: {
|
||||
text: string;
|
||||
};
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
version?: number;
|
||||
syncedAt?: string;
|
||||
source?: string;
|
||||
syncTimestamp?: string;
|
||||
boundElements?: any[] | null;
|
||||
containerId?: string | null;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
element?: ServerElement;
|
||||
elements?: ServerElement[];
|
||||
elementId?: string;
|
||||
count?: number;
|
||||
timestamp?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
success: boolean;
|
||||
elements?: ServerElement[];
|
||||
element?: ServerElement;
|
||||
count?: number;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface ElementBinding {
|
||||
id: string;
|
||||
type: 'text' | 'arrow';
|
||||
}
|
||||
|
||||
type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';
|
||||
|
||||
// Helper function to clean elements for Excalidraw
|
||||
const cleanElementForExcalidraw = (element) => {
|
||||
const cleanElementForExcalidraw = (element: ServerElement): Partial<ExcalidrawElement> => {
|
||||
const {
|
||||
createdAt,
|
||||
updatedAt,
|
||||
version,
|
||||
syncedAt,
|
||||
source,
|
||||
syncTimestamp,
|
||||
...cleanElement
|
||||
} = element;
|
||||
return cleanElement;
|
||||
}
|
||||
|
||||
// Helper function to validate and fix element binding data
|
||||
const validateAndFixBindings = (elements) => {
|
||||
const elementMap = new Map(elements.map(el => [el.id, el]));
|
||||
const validateAndFixBindings = (elements: Partial<ExcalidrawElement>[]): Partial<ExcalidrawElement>[] => {
|
||||
const elementMap = new Map(elements.map(el => [el.id!, el]));
|
||||
|
||||
return elements.map(element => {
|
||||
const fixedElement = { ...element };
|
||||
@@ -23,7 +88,7 @@ const validateAndFixBindings = (elements) => {
|
||||
// Validate and fix boundElements
|
||||
if (fixedElement.boundElements) {
|
||||
if (Array.isArray(fixedElement.boundElements)) {
|
||||
fixedElement.boundElements = fixedElement.boundElements.filter(binding => {
|
||||
fixedElement.boundElements = fixedElement.boundElements.filter((binding: any) => {
|
||||
// Ensure binding has required properties
|
||||
if (!binding || typeof binding !== 'object') return false;
|
||||
if (!binding.id || !binding.type) return false;
|
||||
@@ -61,14 +126,14 @@ const validateAndFixBindings = (elements) => {
|
||||
});
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [excalidrawAPI, setExcalidrawAPI] = useState(null)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const websocketRef = useRef(null)
|
||||
function App(): JSX.Element {
|
||||
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawAPIRefValue | null>(null)
|
||||
const [isConnected, setIsConnected] = useState<boolean>(false)
|
||||
const websocketRef = useRef<WebSocket | null>(null)
|
||||
|
||||
// Sync state management
|
||||
const [syncStatus, setSyncStatus] = useState('idle') // idle, syncing, success, error
|
||||
const [lastSyncTime, setLastSyncTime] = useState(null)
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle')
|
||||
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null)
|
||||
|
||||
// WebSocket connection
|
||||
useEffect(() => {
|
||||
@@ -92,22 +157,22 @@ function App() {
|
||||
}
|
||||
}, [excalidrawAPI, isConnected])
|
||||
|
||||
const loadExistingElements = async () => {
|
||||
const loadExistingElements = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch('/api/elements')
|
||||
const result = await response.json()
|
||||
const result: ApiResponse = await response.json()
|
||||
|
||||
if (result.success && result.elements && result.elements.length > 0) {
|
||||
const cleanedElements = result.elements.map(cleanElementForExcalidraw)
|
||||
const convertedElements = convertToExcalidrawElements(cleanedElements, { regenerateIds: false })
|
||||
excalidrawAPI.updateScene({ elements: convertedElements })
|
||||
excalidrawAPI?.updateScene({ elements: convertedElements })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading existing elements:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const connectWebSocket = () => {
|
||||
const connectWebSocket = (): void => {
|
||||
if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
@@ -125,16 +190,16 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
websocketRef.current.onmessage = (event) => {
|
||||
websocketRef.current.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
const data: WebSocketMessage = JSON.parse(event.data)
|
||||
handleWebSocketMessage(data)
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error, event.data)
|
||||
}
|
||||
}
|
||||
|
||||
websocketRef.current.onclose = (event) => {
|
||||
websocketRef.current.onclose = (event: CloseEvent) => {
|
||||
setIsConnected(false)
|
||||
|
||||
// Reconnect after 3 seconds if not a clean close
|
||||
@@ -143,14 +208,13 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
websocketRef.current.onerror = (error) => {
|
||||
websocketRef.current.onerror = (error: Event) => {
|
||||
console.error('WebSocket error:', error)
|
||||
setIsConnected(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleWebSocketMessage = (data) => {
|
||||
const handleWebSocketMessage = (data: WebSocketMessage): void => {
|
||||
if (!excalidrawAPI) {
|
||||
return
|
||||
}
|
||||
@@ -173,43 +237,51 @@ function App() {
|
||||
break
|
||||
|
||||
case 'element_created':
|
||||
const cleanedNewElement = cleanElementForExcalidraw(data.element)
|
||||
const newElement = convertToExcalidrawElements([cleanedNewElement])
|
||||
const updatedElementsAfterCreate = [...currentElements, ...newElement]
|
||||
excalidrawAPI.updateScene({
|
||||
elements: updatedElementsAfterCreate,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
if (data.element) {
|
||||
const cleanedNewElement = cleanElementForExcalidraw(data.element)
|
||||
const newElement = convertToExcalidrawElements([cleanedNewElement])
|
||||
const updatedElementsAfterCreate = [...currentElements, ...newElement]
|
||||
excalidrawAPI.updateScene({
|
||||
elements: updatedElementsAfterCreate,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'element_updated':
|
||||
const cleanedUpdatedElement = cleanElementForExcalidraw(data.element)
|
||||
const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement])[0]
|
||||
const updatedElements = currentElements.map(el =>
|
||||
el.id === data.element.id ? convertedUpdatedElement : el
|
||||
)
|
||||
excalidrawAPI.updateScene({
|
||||
elements: updatedElements,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
if (data.element) {
|
||||
const cleanedUpdatedElement = cleanElementForExcalidraw(data.element)
|
||||
const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement])[0]
|
||||
const updatedElements = currentElements.map(el =>
|
||||
el.id === data.element!.id ? convertedUpdatedElement : el
|
||||
)
|
||||
excalidrawAPI.updateScene({
|
||||
elements: updatedElements,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'element_deleted':
|
||||
const filteredElements = currentElements.filter(el => el.id !== data.elementId)
|
||||
excalidrawAPI.updateScene({
|
||||
elements: filteredElements,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
if (data.elementId) {
|
||||
const filteredElements = currentElements.filter(el => el.id !== data.elementId)
|
||||
excalidrawAPI.updateScene({
|
||||
elements: filteredElements,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'elements_batch_created':
|
||||
const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw)
|
||||
const batchElements = convertToExcalidrawElements(cleanedBatchElements)
|
||||
const updatedElementsAfterBatch = [...currentElements, ...batchElements]
|
||||
excalidrawAPI.updateScene({
|
||||
elements: updatedElementsAfterBatch,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
if (data.elements) {
|
||||
const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw)
|
||||
const batchElements = convertToExcalidrawElements(cleanedBatchElements)
|
||||
const updatedElementsAfterBatch = [...currentElements, ...batchElements]
|
||||
excalidrawAPI.updateScene({
|
||||
elements: updatedElementsAfterBatch,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'elements_synced':
|
||||
@@ -218,7 +290,7 @@ function App() {
|
||||
break
|
||||
|
||||
case 'sync_status':
|
||||
console.log(`Server sync status: ${data.elementCount} elements`)
|
||||
console.log(`Server sync status: ${data.count} elements`)
|
||||
break
|
||||
|
||||
default:
|
||||
@@ -230,14 +302,14 @@ function App() {
|
||||
}
|
||||
|
||||
// Data format conversion for backend
|
||||
const convertToBackendFormat = (element) => {
|
||||
const convertToBackendFormat = (element: ExcalidrawElement): ServerElement => {
|
||||
return {
|
||||
...element
|
||||
}
|
||||
} as ServerElement
|
||||
}
|
||||
|
||||
// Format sync time display
|
||||
const formatSyncTime = (time) => {
|
||||
const formatSyncTime = (time: Date | null): string => {
|
||||
if (!time) return ''
|
||||
return time.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
@@ -247,7 +319,7 @@ function App() {
|
||||
}
|
||||
|
||||
// Main sync function
|
||||
const syncToBackend = async () => {
|
||||
const syncToBackend = async (): Promise<void> => {
|
||||
if (!excalidrawAPI) {
|
||||
console.warn('Excalidraw API not available')
|
||||
return
|
||||
@@ -279,7 +351,7 @@ function App() {
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
const result: ApiResponse = await response.json()
|
||||
setSyncStatus('success')
|
||||
setLastSyncTime(new Date())
|
||||
console.log(`Sync successful: ${result.count} elements synced`)
|
||||
@@ -287,7 +359,7 @@ function App() {
|
||||
// Reset status after 2 seconds
|
||||
setTimeout(() => setSyncStatus('idle'), 2000)
|
||||
} else {
|
||||
const error = await response.json()
|
||||
const error: ApiResponse = await response.json()
|
||||
setSyncStatus('error')
|
||||
console.error('Sync failed:', error.error)
|
||||
}
|
||||
@@ -297,13 +369,12 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const clearCanvas = async () => {
|
||||
const clearCanvas = async (): Promise<void> => {
|
||||
if (excalidrawAPI) {
|
||||
try {
|
||||
// Get all current elements and delete them from backend
|
||||
const response = await fetch('/api/elements')
|
||||
const result = await response.json()
|
||||
const result: ApiResponse = await response.json()
|
||||
|
||||
if (result.success && result.elements) {
|
||||
const deletePromises = result.elements.map(element =>
|
||||
@@ -373,7 +444,7 @@ function App() {
|
||||
{/* Canvas Container */}
|
||||
<div className="canvas-container">
|
||||
<Excalidraw
|
||||
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||
excalidrawAPI={(api: ExcalidrawAPIRefValue) => setExcalidrawAPI(api)}
|
||||
initialData={{
|
||||
elements: [],
|
||||
appState: {
|
||||
@@ -387,4 +458,4 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
+15
-8
@@ -2,21 +2,23 @@
|
||||
"name": "mcp-excalidraw-server",
|
||||
"version": "1.0.2",
|
||||
"description": "Advanced MCP server for Excalidraw with real-time canvas, WebSocket sync, and comprehensive diagram management",
|
||||
"main": "src/index.js",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"mcp-excalidraw-server": "src/index.js"
|
||||
"mcp-excalidraw-server": "dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"canvas": "node src/server.js",
|
||||
"build": "npm run build:frontend && npm run build:types",
|
||||
"start": "npm run build:server && node dist/index.js",
|
||||
"canvas": "npm run build:server && node dist/server.js",
|
||||
"build": "npm run build:frontend && npm run build:server",
|
||||
"build:frontend": "vite build",
|
||||
"build:types": "npx tsc --emitDeclarationOnly",
|
||||
"build:server": "npx tsc",
|
||||
"dev": "concurrently \"npm run canvas\" \"vite\"",
|
||||
"build:types": "npx tsc --emitDeclarationOnly",
|
||||
"dev": "concurrently \"npm run dev:server\" \"vite\"",
|
||||
"dev:server": "npx tsc --watch",
|
||||
"production": "npm run build && npm run canvas",
|
||||
"prepublishOnly": "npm run build"
|
||||
"prepublishOnly": "npm run build",
|
||||
"type-check": "npx tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "^0.18.0",
|
||||
@@ -33,7 +35,12 @@
|
||||
"zod-to-json-schema": "^3.22.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.19.7",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"concurrently": "^9.2.0",
|
||||
"typescript": "^5.8.3",
|
||||
|
||||
Executable → Regular
+277
-456
@@ -5,19 +5,23 @@ process.env.NODE_DISABLE_COLORS = '1';
|
||||
process.env.NO_COLOR = '1';
|
||||
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequest,
|
||||
Tool
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { z } from 'zod';
|
||||
import dotenv from 'dotenv';
|
||||
import logger from './utils/logger.js';
|
||||
import {
|
||||
generateId,
|
||||
EXCALIDRAW_ELEMENT_TYPES
|
||||
EXCALIDRAW_ELEMENT_TYPES,
|
||||
ServerElement,
|
||||
ExcalidrawElementType,
|
||||
validateElement
|
||||
} from './types.js';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
@@ -28,15 +32,30 @@ dotenv.config();
|
||||
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || 'http://localhost:3000';
|
||||
const ENABLE_CANVAS_SYNC = process.env.ENABLE_CANVAS_SYNC !== 'false'; // Default to true
|
||||
|
||||
// API Response types
|
||||
interface ApiResponse {
|
||||
success: boolean;
|
||||
element?: ServerElement;
|
||||
elements?: ServerElement[];
|
||||
message?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface SyncResponse {
|
||||
element?: ServerElement;
|
||||
elements?: ServerElement[];
|
||||
}
|
||||
|
||||
// Helper functions to sync with Express server (canvas)
|
||||
async function syncToCanvas(operation, data) {
|
||||
async function syncToCanvas(operation: string, data: any): Promise<SyncResponse | null> {
|
||||
if (!ENABLE_CANVAS_SYNC) {
|
||||
logger.debug('Canvas sync disabled, skipping');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let url, options;
|
||||
let url: string;
|
||||
let options: any;
|
||||
|
||||
switch (operation) {
|
||||
case 'create':
|
||||
@@ -83,43 +102,50 @@ async function syncToCanvas(operation, data) {
|
||||
throw new Error(`Canvas sync failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const result = await response.json() as ApiResponse;
|
||||
logger.debug(`Canvas sync successful: ${operation}`, result);
|
||||
return result;
|
||||
return result as SyncResponse;
|
||||
|
||||
} catch (error) {
|
||||
logger.warn(`Canvas sync failed for ${operation}:`, error.message);
|
||||
logger.warn(`Canvas sync failed for ${operation}:`, (error as Error).message);
|
||||
// Don't throw - we want MCP operations to work even if canvas is unavailable
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to sync element creation to canvas
|
||||
async function createElementOnCanvas(elementData) {
|
||||
async function createElementOnCanvas(elementData: ServerElement): Promise<ServerElement | null> {
|
||||
const result = await syncToCanvas('create', elementData);
|
||||
return result?.element || elementData;
|
||||
}
|
||||
|
||||
// Helper to sync element update to canvas
|
||||
async function updateElementOnCanvas(elementData) {
|
||||
async function updateElementOnCanvas(elementData: Partial<ServerElement> & { id: string }): Promise<ServerElement | null> {
|
||||
const result = await syncToCanvas('update', elementData);
|
||||
return result?.element || elementData;
|
||||
return result?.element || null;
|
||||
}
|
||||
|
||||
// Helper to sync element deletion to canvas
|
||||
async function deleteElementOnCanvas(elementId) {
|
||||
async function deleteElementOnCanvas(elementId: string): Promise<any> {
|
||||
const result = await syncToCanvas('delete', { id: elementId });
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper to sync batch creation to canvas
|
||||
async function batchCreateElementsOnCanvas(elementsData) {
|
||||
async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise<ServerElement[] | null> {
|
||||
const result = await syncToCanvas('batch_create', elementsData);
|
||||
return result?.elements || elementsData;
|
||||
}
|
||||
|
||||
// In-memory storage for scene state
|
||||
const sceneState = {
|
||||
interface SceneState {
|
||||
theme: string;
|
||||
viewport: { x: number; y: number; zoom: number };
|
||||
selectedElements: Set<string>;
|
||||
groups: Map<string, string[]>;
|
||||
}
|
||||
|
||||
const sceneState: SceneState = {
|
||||
theme: 'light',
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
selectedElements: new Set(),
|
||||
@@ -128,7 +154,7 @@ const sceneState = {
|
||||
|
||||
// Schema definitions using zod
|
||||
const ElementSchema = z.object({
|
||||
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)),
|
||||
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
width: z.number().optional(),
|
||||
@@ -167,7 +193,7 @@ const DistributeElementsSchema = z.object({
|
||||
});
|
||||
|
||||
const QuerySchema = z.object({
|
||||
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)).optional(),
|
||||
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
|
||||
filter: z.record(z.any()).optional()
|
||||
});
|
||||
|
||||
@@ -175,19 +201,201 @@ const ResourceSchema = z.object({
|
||||
resource: z.enum(['scene', 'library', 'theme', 'elements'])
|
||||
});
|
||||
|
||||
// Initialize MCP server
|
||||
const server = new Server(
|
||||
// Tool definitions
|
||||
const tools: Tool[] = [
|
||||
{
|
||||
name: "mcp-excalidraw-server",
|
||||
version: "1.0.2",
|
||||
description: "Advanced MCP server for Excalidraw with real-time canvas"
|
||||
name: 'create_element',
|
||||
description: 'Create a new Excalidraw element',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
|
||||
},
|
||||
x: { type: 'number' },
|
||||
y: { type: 'number' },
|
||||
width: { type: 'number' },
|
||||
height: { type: 'number' },
|
||||
backgroundColor: { type: 'string' },
|
||||
strokeColor: { type: 'string' },
|
||||
strokeWidth: { type: 'number' },
|
||||
roughness: { type: 'number' },
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: 'string' }
|
||||
},
|
||||
required: ['type', 'x', 'y']
|
||||
}
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {
|
||||
create_element: {
|
||||
description: 'Create a new Excalidraw element',
|
||||
inputSchema: {
|
||||
name: 'update_element',
|
||||
description: 'Update an existing Excalidraw element',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
|
||||
},
|
||||
x: { type: 'number' },
|
||||
y: { type: 'number' },
|
||||
width: { type: 'number' },
|
||||
height: { type: 'number' },
|
||||
backgroundColor: { type: 'string' },
|
||||
strokeColor: { type: 'string' },
|
||||
strokeWidth: { type: 'number' },
|
||||
roughness: { type: 'number' },
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: 'string' }
|
||||
},
|
||||
required: ['id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'delete_element',
|
||||
description: 'Delete an Excalidraw element',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' }
|
||||
},
|
||||
required: ['id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'query_elements',
|
||||
description: 'Query Excalidraw elements with optional filters',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
|
||||
},
|
||||
filter: {
|
||||
type: 'object',
|
||||
additionalProperties: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_resource',
|
||||
description: 'Get an Excalidraw resource',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
resource: {
|
||||
type: 'string',
|
||||
enum: ['scene', 'library', 'theme', 'elements']
|
||||
}
|
||||
},
|
||||
required: ['resource']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'group_elements',
|
||||
description: 'Group multiple elements together',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: ['elementIds']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ungroup_elements',
|
||||
description: 'Ungroup a group of elements',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
groupId: { type: 'string' }
|
||||
},
|
||||
required: ['groupId']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'align_elements',
|
||||
description: 'Align elements to a specific position',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
},
|
||||
alignment: {
|
||||
type: 'string',
|
||||
enum: ['left', 'center', 'right', 'top', 'middle', 'bottom']
|
||||
}
|
||||
},
|
||||
required: ['elementIds', 'alignment']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'distribute_elements',
|
||||
description: 'Distribute elements evenly',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
},
|
||||
direction: {
|
||||
type: 'string',
|
||||
enum: ['horizontal', 'vertical']
|
||||
}
|
||||
},
|
||||
required: ['elementIds', 'direction']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'lock_elements',
|
||||
description: 'Lock elements to prevent modification',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: ['elementIds']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'unlock_elements',
|
||||
description: 'Unlock elements to allow modification',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: ['elementIds']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'batch_create_elements',
|
||||
description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elements: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
@@ -209,196 +417,32 @@ const server = new Server(
|
||||
},
|
||||
required: ['type', 'x', 'y']
|
||||
}
|
||||
},
|
||||
update_element: {
|
||||
description: 'Update an existing Excalidraw element',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
|
||||
},
|
||||
x: { type: 'number' },
|
||||
y: { type: 'number' },
|
||||
width: { type: 'number' },
|
||||
height: { type: 'number' },
|
||||
backgroundColor: { type: 'string' },
|
||||
strokeColor: { type: 'string' },
|
||||
strokeWidth: { type: 'number' },
|
||||
roughness: { type: 'number' },
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: 'string' }
|
||||
},
|
||||
required: ['id']
|
||||
}
|
||||
},
|
||||
delete_element: {
|
||||
description: 'Delete an Excalidraw element',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' }
|
||||
},
|
||||
required: ['id']
|
||||
}
|
||||
},
|
||||
query_elements: {
|
||||
description: 'Query Excalidraw elements with optional filters',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
|
||||
},
|
||||
filter: {
|
||||
type: 'object',
|
||||
additionalProperties: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
get_resource: {
|
||||
description: 'Get an Excalidraw resource',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
resource: {
|
||||
type: 'string',
|
||||
enum: ['scene', 'library', 'theme', 'elements']
|
||||
}
|
||||
},
|
||||
required: ['resource']
|
||||
}
|
||||
},
|
||||
group_elements: {
|
||||
description: 'Group multiple elements together',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: ['elementIds']
|
||||
}
|
||||
},
|
||||
ungroup_elements: {
|
||||
description: 'Ungroup a group of elements',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
groupId: { type: 'string' }
|
||||
},
|
||||
required: ['groupId']
|
||||
}
|
||||
},
|
||||
align_elements: {
|
||||
description: 'Align elements to a specific position',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
},
|
||||
alignment: {
|
||||
type: 'string',
|
||||
enum: ['left', 'center', 'right', 'top', 'middle', 'bottom']
|
||||
}
|
||||
},
|
||||
required: ['elementIds', 'alignment']
|
||||
}
|
||||
},
|
||||
distribute_elements: {
|
||||
description: 'Distribute elements evenly',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
},
|
||||
direction: {
|
||||
type: 'string',
|
||||
enum: ['horizontal', 'vertical']
|
||||
}
|
||||
},
|
||||
required: ['elementIds', 'direction']
|
||||
}
|
||||
},
|
||||
lock_elements: {
|
||||
description: 'Lock elements to prevent modification',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: ['elementIds']
|
||||
}
|
||||
},
|
||||
unlock_elements: {
|
||||
description: 'Unlock elements to allow modification',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: ['elementIds']
|
||||
}
|
||||
},
|
||||
batch_create_elements: {
|
||||
description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elements: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
|
||||
},
|
||||
x: { type: 'number' },
|
||||
y: { type: 'number' },
|
||||
width: { type: 'number' },
|
||||
height: { type: 'number' },
|
||||
backgroundColor: { type: 'string' },
|
||||
strokeColor: { type: 'string' },
|
||||
strokeWidth: { type: 'number' },
|
||||
roughness: { type: 'number' },
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: 'string' }
|
||||
},
|
||||
required: ['type', 'x', 'y']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['elements']
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['elements']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Initialize MCP server
|
||||
const server = new Server(
|
||||
{
|
||||
name: "mcp-excalidraw-server",
|
||||
version: "1.0.2",
|
||||
description: "Advanced MCP server for Excalidraw with real-time canvas"
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: Object.fromEntries(tools.map(tool => [tool.name, {
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema
|
||||
}]))
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Helper function to convert text property to label format for Excalidraw
|
||||
function convertTextToLabel(element) {
|
||||
function convertTextToLabel(element: ServerElement): ServerElement {
|
||||
const { text, ...rest } = element;
|
||||
if (text) {
|
||||
// For standalone text elements, keep text as direct property
|
||||
@@ -409,13 +453,13 @@ function convertTextToLabel(element) {
|
||||
return {
|
||||
...rest,
|
||||
label: { text }
|
||||
};
|
||||
} as ServerElement;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
// Set up request handler for tool calls
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => {
|
||||
try {
|
||||
const { name, arguments: args } = request.params;
|
||||
logger.info(`Handling tool call: ${name}`);
|
||||
@@ -426,7 +470,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
logger.info('Creating element via MCP', { type: params.type });
|
||||
|
||||
const id = generateId();
|
||||
const element = {
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
...params,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -465,14 +509,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (!id) throw new Error('Element ID is required');
|
||||
|
||||
// Build update payload with timestamp and version increment
|
||||
const updatePayload = {
|
||||
const updatePayload: Partial<ServerElement> & { id: string } = {
|
||||
id,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Convert text to label format for Excalidraw
|
||||
const excalidrawElement = convertTextToLabel(updatePayload);
|
||||
const excalidrawElement = convertTextToLabel(updatePayload as ServerElement);
|
||||
|
||||
// Update element directly on HTTP server (no local storage)
|
||||
const canvasElement = await updateElementOnCanvas(excalidrawElement);
|
||||
@@ -526,7 +570,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (type) queryParams.set('type', type);
|
||||
if (filter) {
|
||||
Object.entries(filter).forEach(([key, value]) => {
|
||||
queryParams.set(key, value);
|
||||
queryParams.set(key, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -538,14 +582,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json() as ApiResponse;
|
||||
const results = data.elements || [];
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(results, null, 2) }]
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to query elements: ${error.message}`);
|
||||
throw new Error(`Failed to query elements: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,7 +598,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { resource } = params;
|
||||
logger.info('Getting resource', { resource });
|
||||
|
||||
let result;
|
||||
let result: any;
|
||||
switch (resource) {
|
||||
case 'scene':
|
||||
result = {
|
||||
@@ -571,12 +615,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP server error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const data = await response.json() as ApiResponse;
|
||||
result = {
|
||||
elements: data.elements || []
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get elements: ${error.message}`);
|
||||
throw new Error(`Failed to get elements: ${(error as Error).message}`);
|
||||
}
|
||||
break;
|
||||
case 'theme':
|
||||
@@ -671,7 +715,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to lock elements: ${error.message}`);
|
||||
throw new Error(`Failed to lock elements: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,7 +741,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to unlock elements: ${error.message}`);
|
||||
throw new Error(`Failed to unlock elements: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,12 +749,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const params = z.object({ elements: z.array(ElementSchema) }).parse(args);
|
||||
logger.info('Batch creating elements via MCP', { count: params.elements.length });
|
||||
|
||||
const createdElements = [];
|
||||
const createdElements: ServerElement[] = [];
|
||||
|
||||
// Create each element with unique ID
|
||||
for (const elementData of params.elements) {
|
||||
const id = generateId();
|
||||
const element = {
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
...elementData,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -754,9 +798,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error handling tool call: ${error.message}`, { error });
|
||||
logger.error(`Error handling tool call: ${(error as Error).message}`, { error });
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
||||
content: [{ type: 'text', text: `Error: ${(error as Error).message}` }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
@@ -765,234 +809,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
// Set up request handler for listing available tools
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
logger.info('Listing available tools');
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: 'create_element',
|
||||
description: 'Create a new Excalidraw element',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
|
||||
},
|
||||
x: { type: 'number' },
|
||||
y: { type: 'number' },
|
||||
width: { type: 'number' },
|
||||
height: { type: 'number' },
|
||||
backgroundColor: { type: 'string' },
|
||||
strokeColor: { type: 'string' },
|
||||
strokeWidth: { type: 'number' },
|
||||
roughness: { type: 'number' },
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: 'string' }
|
||||
},
|
||||
required: ['type', 'x', 'y']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'update_element',
|
||||
description: 'Update an existing Excalidraw element',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
|
||||
},
|
||||
x: { type: 'number' },
|
||||
y: { type: 'number' },
|
||||
width: { type: 'number' },
|
||||
height: { type: 'number' },
|
||||
backgroundColor: { type: 'string' },
|
||||
strokeColor: { type: 'string' },
|
||||
strokeWidth: { type: 'number' },
|
||||
roughness: { type: 'number' },
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: 'string' }
|
||||
},
|
||||
required: ['id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'delete_element',
|
||||
description: 'Delete an Excalidraw element',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' }
|
||||
},
|
||||
required: ['id']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'query_elements',
|
||||
description: 'Query Excalidraw elements with optional filters',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
|
||||
},
|
||||
filter: {
|
||||
type: 'object',
|
||||
additionalProperties: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_resource',
|
||||
description: 'Get an Excalidraw resource',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
resource: {
|
||||
type: 'string',
|
||||
enum: ['scene', 'library', 'theme', 'elements']
|
||||
}
|
||||
},
|
||||
required: ['resource']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'group_elements',
|
||||
description: 'Group multiple elements together',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: ['elementIds']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ungroup_elements',
|
||||
description: 'Ungroup a group of elements',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
groupId: { type: 'string' }
|
||||
},
|
||||
required: ['groupId']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'align_elements',
|
||||
description: 'Align elements to a specific position',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
},
|
||||
alignment: {
|
||||
type: 'string',
|
||||
enum: ['left', 'center', 'right', 'top', 'middle', 'bottom']
|
||||
}
|
||||
},
|
||||
required: ['elementIds', 'alignment']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'distribute_elements',
|
||||
description: 'Distribute elements evenly',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
},
|
||||
direction: {
|
||||
type: 'string',
|
||||
enum: ['horizontal', 'vertical']
|
||||
}
|
||||
},
|
||||
required: ['elementIds', 'direction']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'lock_elements',
|
||||
description: 'Lock elements to prevent modification',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: ['elementIds']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'unlock_elements',
|
||||
description: 'Unlock elements to allow modification',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elementIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: ['elementIds']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'batch_create_elements',
|
||||
description: 'Create multiple Excalidraw elements at once - ideal for complex diagrams',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
elements: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: Object.values(EXCALIDRAW_ELEMENT_TYPES)
|
||||
},
|
||||
x: { type: 'number' },
|
||||
y: { type: 'number' },
|
||||
width: { type: 'number' },
|
||||
height: { type: 'number' },
|
||||
backgroundColor: { type: 'string' },
|
||||
strokeColor: { type: 'string' },
|
||||
strokeWidth: { type: 'number' },
|
||||
roughness: { type: 'number' },
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: 'string' }
|
||||
},
|
||||
required: ['type', 'x', 'y']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['elements']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return { tools };
|
||||
});
|
||||
|
||||
// Start server with transport based on mode
|
||||
async function runServer() {
|
||||
async function runServer(): Promise<void> {
|
||||
try {
|
||||
logger.info('Starting Excalidraw MCP server...');
|
||||
|
||||
@@ -1022,19 +843,19 @@ async function runServer() {
|
||||
process.stdin.resume();
|
||||
} catch (error) {
|
||||
logger.error('Error starting server:', error);
|
||||
process.stderr.write(`Failed to start MCP server: ${error.message}\n${error.stack}\n`);
|
||||
process.stderr.write(`Failed to start MCP server: ${(error as Error).message}\n${(error as Error).stack}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Add global error handlers
|
||||
process.on('uncaughtException', (error) => {
|
||||
process.on('uncaughtException', (error: Error) => {
|
||||
logger.error('Uncaught exception:', error);
|
||||
process.stderr.write(`UNCAUGHT EXCEPTION: ${error.message}\n${error.stack}\n`);
|
||||
setTimeout(() => process.exit(1), 1000);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
|
||||
logger.error('Unhandled promise rejection:', reason);
|
||||
process.stderr.write(`UNHANDLED REJECTION: ${reason}\n`);
|
||||
setTimeout(() => process.exit(1), 1000);
|
||||
@@ -1053,4 +874,4 @@ if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
});
|
||||
}
|
||||
|
||||
export default runServer;
|
||||
export default runServer;
|
||||
@@ -1,4 +1,4 @@
|
||||
import express from 'express';
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
@@ -9,9 +9,19 @@ import logger from './utils/logger.js';
|
||||
import {
|
||||
elements,
|
||||
generateId,
|
||||
EXCALIDRAW_ELEMENT_TYPES
|
||||
EXCALIDRAW_ELEMENT_TYPES,
|
||||
ServerElement,
|
||||
ExcalidrawElementType,
|
||||
WebSocketMessage,
|
||||
ElementCreatedMessage,
|
||||
ElementUpdatedMessage,
|
||||
ElementDeletedMessage,
|
||||
BatchCreatedMessage,
|
||||
SyncStatusMessage,
|
||||
InitialElementsMessage
|
||||
} from './types.js';
|
||||
import { z } from 'zod';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -34,35 +44,37 @@ app.use(express.static(staticDir));
|
||||
app.use(express.static(path.join(__dirname, '../dist/frontend')));
|
||||
|
||||
// WebSocket connections
|
||||
const clients = new Set();
|
||||
const clients = new Set<WebSocket>();
|
||||
|
||||
// Broadcast to all connected clients
|
||||
function broadcast(message) {
|
||||
function broadcast(message: WebSocketMessage): void {
|
||||
const data = JSON.stringify(message);
|
||||
clients.forEach(client => {
|
||||
if (client.readyState === client.OPEN) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// WebSocket connection handling
|
||||
wss.on('connection', (ws) => {
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
clients.add(ws);
|
||||
logger.info('New WebSocket connection established');
|
||||
|
||||
// Send current elements to new client
|
||||
ws.send(JSON.stringify({
|
||||
const initialMessage: InitialElementsMessage = {
|
||||
type: 'initial_elements',
|
||||
elements: Array.from(elements.values())
|
||||
}));
|
||||
};
|
||||
ws.send(JSON.stringify(initialMessage));
|
||||
|
||||
// Send sync status to new client
|
||||
ws.send(JSON.stringify({
|
||||
const syncMessage: SyncStatusMessage = {
|
||||
type: 'sync_status',
|
||||
elementCount: elements.size,
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
};
|
||||
ws.send(JSON.stringify(syncMessage));
|
||||
|
||||
ws.on('close', () => {
|
||||
clients.delete(ws);
|
||||
@@ -78,7 +90,7 @@ wss.on('connection', (ws) => {
|
||||
// Schema validation
|
||||
const CreateElementSchema = z.object({
|
||||
id: z.string().optional(), // Allow passing ID for MCP sync
|
||||
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)),
|
||||
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
width: z.number().optional(),
|
||||
@@ -98,7 +110,7 @@ const CreateElementSchema = z.object({
|
||||
|
||||
const UpdateElementSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)).optional(),
|
||||
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
|
||||
x: z.number().optional(),
|
||||
y: z.number().optional(),
|
||||
width: z.number().optional(),
|
||||
@@ -119,7 +131,7 @@ const UpdateElementSchema = z.object({
|
||||
// API Routes
|
||||
|
||||
// Get all elements
|
||||
app.get('/api/elements', (req, res) => {
|
||||
app.get('/api/elements', (req: Request, res: Response) => {
|
||||
try {
|
||||
const elementsArray = Array.from(elements.values());
|
||||
res.json({
|
||||
@@ -131,20 +143,20 @@ app.get('/api/elements', (req, res) => {
|
||||
logger.error('Error fetching elements:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Create new element
|
||||
app.post('/api/elements', (req, res) => {
|
||||
app.post('/api/elements', (req: Request, res: Response) => {
|
||||
try {
|
||||
const params = CreateElementSchema.parse(req.body);
|
||||
logger.info('Creating element via API', { type: params.type });
|
||||
|
||||
// Prioritize passed ID (for MCP sync), otherwise generate new ID
|
||||
const id = params.id || generateId();
|
||||
const element = {
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
...params,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -155,10 +167,11 @@ app.post('/api/elements', (req, res) => {
|
||||
elements.set(id, element);
|
||||
|
||||
// Broadcast to all connected clients
|
||||
broadcast({
|
||||
const message: ElementCreatedMessage = {
|
||||
type: 'element_created',
|
||||
element: element
|
||||
});
|
||||
};
|
||||
broadcast(message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -168,17 +181,24 @@ app.post('/api/elements', (req, res) => {
|
||||
logger.error('Error creating element:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update element
|
||||
app.put('/api/elements/:id', (req, res) => {
|
||||
app.put('/api/elements/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = UpdateElementSchema.parse({ id, ...req.body });
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Element ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
const existingElement = elements.get(id);
|
||||
if (!existingElement) {
|
||||
return res.status(404).json({
|
||||
@@ -187,20 +207,21 @@ app.put('/api/elements/:id', (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const updatedElement = {
|
||||
const updatedElement: ServerElement = {
|
||||
...existingElement,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
version: existingElement.version + 1
|
||||
version: (existingElement.version || 0) + 1
|
||||
};
|
||||
|
||||
elements.set(id, updatedElement);
|
||||
|
||||
// Broadcast to all connected clients
|
||||
broadcast({
|
||||
const message: ElementUpdatedMessage = {
|
||||
type: 'element_updated',
|
||||
element: updatedElement
|
||||
});
|
||||
};
|
||||
broadcast(message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -210,16 +231,23 @@ app.put('/api/elements/:id', (req, res) => {
|
||||
logger.error('Error updating element:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Delete element
|
||||
app.delete('/api/elements/:id', (req, res) => {
|
||||
app.delete('/api/elements/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Element ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
if (!elements.has(id)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
@@ -230,10 +258,11 @@ app.delete('/api/elements/:id', (req, res) => {
|
||||
elements.delete(id);
|
||||
|
||||
// Broadcast to all connected clients
|
||||
broadcast({
|
||||
const message: ElementDeletedMessage = {
|
||||
type: 'element_deleted',
|
||||
elementId: id
|
||||
});
|
||||
elementId: id!
|
||||
};
|
||||
broadcast(message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -243,19 +272,19 @@ app.delete('/api/elements/:id', (req, res) => {
|
||||
logger.error('Error deleting element:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Query elements with filters
|
||||
app.get('/api/elements/search', (req, res) => {
|
||||
app.get('/api/elements/search', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, ...filters } = req.query;
|
||||
let results = Array.from(elements.values());
|
||||
|
||||
// Filter by type if specified
|
||||
if (type) {
|
||||
if (type && typeof type === 'string') {
|
||||
results = results.filter(element => element.type === type);
|
||||
}
|
||||
|
||||
@@ -263,7 +292,7 @@ app.get('/api/elements/search', (req, res) => {
|
||||
if (Object.keys(filters).length > 0) {
|
||||
results = results.filter(element => {
|
||||
return Object.entries(filters).every(([key, value]) => {
|
||||
return element[key] === value;
|
||||
return (element as any)[key] === value;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -277,15 +306,23 @@ app.get('/api/elements/search', (req, res) => {
|
||||
logger.error('Error querying elements:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get element by ID
|
||||
app.get('/api/elements/:id', (req, res) => {
|
||||
app.get('/api/elements/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Element ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
const element = elements.get(id);
|
||||
|
||||
if (!element) {
|
||||
@@ -303,13 +340,13 @@ app.get('/api/elements/:id', (req, res) => {
|
||||
logger.error('Error fetching element:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Batch create elements
|
||||
app.post('/api/elements/batch', (req, res) => {
|
||||
app.post('/api/elements/batch', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { elements: elementsToCreate } = req.body;
|
||||
|
||||
@@ -320,12 +357,12 @@ app.post('/api/elements/batch', (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const createdElements = [];
|
||||
const createdElements: ServerElement[] = [];
|
||||
|
||||
elementsToCreate.forEach(elementData => {
|
||||
const params = CreateElementSchema.parse(elementData);
|
||||
const id = generateId();
|
||||
const element = {
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
...params,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -338,10 +375,11 @@ app.post('/api/elements/batch', (req, res) => {
|
||||
});
|
||||
|
||||
// Broadcast to all connected clients
|
||||
broadcast({
|
||||
const message: BatchCreatedMessage = {
|
||||
type: 'elements_batch_created',
|
||||
elements: createdElements
|
||||
});
|
||||
};
|
||||
broadcast(message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -352,13 +390,13 @@ app.post('/api/elements/batch', (req, res) => {
|
||||
logger.error('Error batch creating elements:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sync elements from frontend (overwrite sync)
|
||||
app.post('/api/elements/sync', (req, res) => {
|
||||
app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { elements: frontendElements, timestamp } = req.body;
|
||||
|
||||
@@ -384,15 +422,15 @@ app.post('/api/elements/sync', (req, res) => {
|
||||
|
||||
// 2. Batch write new data
|
||||
let successCount = 0;
|
||||
const processedElements = [];
|
||||
const processedElements: ServerElement[] = [];
|
||||
|
||||
frontendElements.forEach((element, index) => {
|
||||
frontendElements.forEach((element: any, index: number) => {
|
||||
try {
|
||||
// Ensure element has ID, generate one if missing
|
||||
const elementId = element.id || generateId();
|
||||
|
||||
// Add server metadata
|
||||
const processedElement = {
|
||||
const processedElement: ServerElement = {
|
||||
...element,
|
||||
id: elementId,
|
||||
syncedAt: new Date().toISOString(),
|
||||
@@ -435,14 +473,14 @@ app.post('/api/elements/sync', (req, res) => {
|
||||
logger.error('Sync error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
error: (error as Error).message,
|
||||
details: 'Internal server error during sync operation'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Serve the frontend
|
||||
app.get('/', (req, res) => {
|
||||
app.get('/', (req: Request, res: Response) => {
|
||||
const htmlFile = path.join(__dirname, '../dist/frontend/index.html');
|
||||
res.sendFile(htmlFile, (err) => {
|
||||
if (err) {
|
||||
@@ -453,7 +491,7 @@ app.get('/', (req, res) => {
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
app.get('/health', (req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -463,7 +501,7 @@ app.get('/health', (req, res) => {
|
||||
});
|
||||
|
||||
// Sync status endpoint
|
||||
app.get('/api/sync/status', (req, res) => {
|
||||
app.get('/api/sync/status', (req: Request, res: Response) => {
|
||||
res.json({
|
||||
success: true,
|
||||
elementCount: elements.size,
|
||||
@@ -477,7 +515,7 @@ app.get('/api/sync/status', (req, res) => {
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
@@ -486,7 +524,7 @@ app.use((err, req, res, next) => {
|
||||
});
|
||||
|
||||
// Start server
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
const HOST = process.env.HOST || 'localhost';
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
@@ -494,4 +532,4 @@ server.listen(PORT, HOST, () => {
|
||||
logger.info(`WebSocket server running on ws://${HOST}:${PORT}`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
export default app;
|
||||
@@ -1,35 +0,0 @@
|
||||
// Excalidraw element types
|
||||
export const EXCALIDRAW_ELEMENT_TYPES = {
|
||||
RECTANGLE: 'rectangle',
|
||||
ELLIPSE: 'ellipse',
|
||||
DIAMOND: 'diamond',
|
||||
ARROW: 'arrow',
|
||||
TEXT: 'text',
|
||||
LABEL: 'label',
|
||||
FREEDRAW: 'freedraw',
|
||||
LINE: 'line'
|
||||
};
|
||||
|
||||
// In-memory storage for Excalidraw elements
|
||||
export const elements = new Map();
|
||||
|
||||
// Validation function for Excalidraw elements
|
||||
export function validateElement(element) {
|
||||
const requiredFields = ['type', 'x', 'y'];
|
||||
const hasRequiredFields = requiredFields.every(field => field in element);
|
||||
|
||||
if (!hasRequiredFields) {
|
||||
throw new Error(`Missing required fields: ${requiredFields.join(', ')}`);
|
||||
}
|
||||
|
||||
if (!Object.values(EXCALIDRAW_ELEMENT_TYPES).includes(element.type)) {
|
||||
throw new Error(`Invalid element type: ${element.type}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper function to generate unique IDs
|
||||
export function generateId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
export interface ExcalidrawElementBase {
|
||||
id: string;
|
||||
type: ExcalidrawElementType;
|
||||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
angle?: number;
|
||||
strokeColor?: string;
|
||||
backgroundColor?: string;
|
||||
fillStyle?: string;
|
||||
strokeWidth?: number;
|
||||
strokeStyle?: string;
|
||||
roughness?: number;
|
||||
opacity?: number;
|
||||
groupIds?: string[];
|
||||
frameId?: string | null;
|
||||
roundness?: {
|
||||
type: number;
|
||||
value?: number;
|
||||
} | null;
|
||||
seed?: number;
|
||||
versionNonce?: number;
|
||||
isDeleted?: boolean;
|
||||
locked?: boolean;
|
||||
link?: string | null;
|
||||
customData?: Record<string, any> | null;
|
||||
boundElements?: readonly ExcalidrawBoundElement[] | null;
|
||||
updated?: number;
|
||||
containerId?: string | null;
|
||||
}
|
||||
|
||||
export interface ExcalidrawTextElement extends ExcalidrawElementBase {
|
||||
type: 'text';
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: number;
|
||||
textAlign?: string;
|
||||
verticalAlign?: string;
|
||||
baseline?: number;
|
||||
lineHeight?: number;
|
||||
}
|
||||
|
||||
export interface ExcalidrawRectangleElement extends ExcalidrawElementBase {
|
||||
type: 'rectangle';
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ExcalidrawEllipseElement extends ExcalidrawElementBase {
|
||||
type: 'ellipse';
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ExcalidrawDiamondElement extends ExcalidrawElementBase {
|
||||
type: 'diamond';
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ExcalidrawArrowElement extends ExcalidrawElementBase {
|
||||
type: 'arrow';
|
||||
points: readonly [number, number][];
|
||||
lastCommittedPoint?: readonly [number, number] | null;
|
||||
startBinding?: ExcalidrawBinding | null;
|
||||
endBinding?: ExcalidrawBinding | null;
|
||||
startArrowhead?: string | null;
|
||||
endArrowhead?: string | null;
|
||||
}
|
||||
|
||||
export interface ExcalidrawLineElement extends ExcalidrawElementBase {
|
||||
type: 'line';
|
||||
points: readonly [number, number][];
|
||||
lastCommittedPoint?: readonly [number, number] | null;
|
||||
startBinding?: ExcalidrawBinding | null;
|
||||
endBinding?: ExcalidrawBinding | null;
|
||||
}
|
||||
|
||||
export interface ExcalidrawFreedrawElement extends ExcalidrawElementBase {
|
||||
type: 'freedraw';
|
||||
points: readonly [number, number][];
|
||||
pressures?: readonly number[];
|
||||
simulatePressure?: boolean;
|
||||
lastCommittedPoint?: readonly [number, number] | null;
|
||||
}
|
||||
|
||||
export type ExcalidrawElement =
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawArrowElement
|
||||
| ExcalidrawLineElement
|
||||
| ExcalidrawFreedrawElement;
|
||||
|
||||
export interface ExcalidrawBoundElement {
|
||||
id: string;
|
||||
type: 'text' | 'arrow';
|
||||
}
|
||||
|
||||
export interface ExcalidrawBinding {
|
||||
elementId: string;
|
||||
focus: number;
|
||||
gap: number;
|
||||
fixedPoint?: readonly [number, number] | null;
|
||||
}
|
||||
|
||||
export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw' | 'label';
|
||||
|
||||
// Excalidraw element types
|
||||
export const EXCALIDRAW_ELEMENT_TYPES: Record<string, ExcalidrawElementType> = {
|
||||
RECTANGLE: 'rectangle',
|
||||
ELLIPSE: 'ellipse',
|
||||
DIAMOND: 'diamond',
|
||||
ARROW: 'arrow',
|
||||
TEXT: 'text',
|
||||
LABEL: 'label',
|
||||
FREEDRAW: 'freedraw',
|
||||
LINE: 'line'
|
||||
} as const;
|
||||
|
||||
// Server-side element with metadata
|
||||
export interface ServerElement extends Omit<ExcalidrawElementBase, 'id'> {
|
||||
id: string;
|
||||
type: ExcalidrawElementType;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
version?: number;
|
||||
syncedAt?: string;
|
||||
source?: string;
|
||||
syncTimestamp?: string;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: string | number;
|
||||
label?: {
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
// API Response types
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ElementsResponse extends ApiResponse {
|
||||
elements: ServerElement[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ElementResponse extends ApiResponse {
|
||||
element: ServerElement;
|
||||
}
|
||||
|
||||
export interface SyncResponse extends ApiResponse {
|
||||
count: number;
|
||||
syncedAt: string;
|
||||
beforeCount: number;
|
||||
afterCount: number;
|
||||
}
|
||||
|
||||
// WebSocket message types
|
||||
export interface WebSocketMessage {
|
||||
type: WebSocketMessageType;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type WebSocketMessageType =
|
||||
| 'initial_elements'
|
||||
| 'element_created'
|
||||
| 'element_updated'
|
||||
| 'element_deleted'
|
||||
| 'elements_batch_created'
|
||||
| 'elements_synced'
|
||||
| 'sync_status';
|
||||
|
||||
export interface InitialElementsMessage extends WebSocketMessage {
|
||||
type: 'initial_elements';
|
||||
elements: ServerElement[];
|
||||
}
|
||||
|
||||
export interface ElementCreatedMessage extends WebSocketMessage {
|
||||
type: 'element_created';
|
||||
element: ServerElement;
|
||||
}
|
||||
|
||||
export interface ElementUpdatedMessage extends WebSocketMessage {
|
||||
type: 'element_updated';
|
||||
element: ServerElement;
|
||||
}
|
||||
|
||||
export interface ElementDeletedMessage extends WebSocketMessage {
|
||||
type: 'element_deleted';
|
||||
elementId: string;
|
||||
}
|
||||
|
||||
export interface BatchCreatedMessage extends WebSocketMessage {
|
||||
type: 'elements_batch_created';
|
||||
elements: ServerElement[];
|
||||
}
|
||||
|
||||
export interface SyncStatusMessage extends WebSocketMessage {
|
||||
type: 'sync_status';
|
||||
elementCount: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// In-memory storage for Excalidraw elements
|
||||
export const elements = new Map<string, ServerElement>();
|
||||
|
||||
// Validation function for Excalidraw elements
|
||||
export function validateElement(element: Partial<ServerElement>): element is ServerElement {
|
||||
const requiredFields: (keyof ServerElement)[] = ['type', 'x', 'y'];
|
||||
const hasRequiredFields = requiredFields.every(field => field in element);
|
||||
|
||||
if (!hasRequiredFields) {
|
||||
throw new Error(`Missing required fields: ${requiredFields.join(', ')}`);
|
||||
}
|
||||
|
||||
if (!Object.values(EXCALIDRAW_ELEMENT_TYPES).includes(element.type as ExcalidrawElementType)) {
|
||||
throw new Error(`Invalid element type: ${element.type}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper function to generate unique IDs
|
||||
export function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import winston from 'winston';
|
||||
|
||||
const logger = winston.createLogger({
|
||||
const logger: winston.Logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
|
||||
format: winston.format.combine(
|
||||
@@ -24,4 +24,4 @@ const logger = winston.createLogger({
|
||||
]
|
||||
});
|
||||
|
||||
export default logger;
|
||||
export default logger;
|
||||
+17
-5
@@ -3,14 +3,24 @@
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"allowJs": true,
|
||||
"allowJs": false,
|
||||
"checkJs": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": false,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitReturns": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
@@ -23,14 +33,16 @@
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"frontend",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.js"
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.js",
|
||||
"**/*.jsx"
|
||||
],
|
||||
"ts-node": {
|
||||
"esm": true
|
||||
|
||||
Reference in New Issue
Block a user