From 1be9f3b47bef82089cbace0aceb1283a4ab62aa3 Mon Sep 17 00:00:00 2001 From: ycsahara Date: Fri, 11 Jul 2025 17:49:07 +0000 Subject: [PATCH] Add frontend React app and server components - Add React frontend with Excalidraw integration (App.jsx, main.jsx) - Add Express server with MCP protocol support (server.js) - Update CLI with new functionality (cli.js) - Add Vite configuration for frontend build - Update package.json with new dependencies - Add public assets and build files - Update .gitignore to exclude build artifacts --- .gitignore | 1 + frontend/index.html | 240 ++++++++++++ frontend/src/App.jsx | 523 +++++++++++++++++++++++++++ frontend/src/main.jsx | 9 + package.json | 22 +- public/dist/frontend/index.html | 241 +++++++++++++ public/dist/index.html | 241 +++++++++++++ public/index.html | 621 ++++++++++++++++++++++++++++++++ src/cli.js | 0 src/server.js | 382 ++++++++++++++++++++ vite.config.js | 27 ++ 11 files changed, 2303 insertions(+), 4 deletions(-) create mode 100644 frontend/index.html create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/main.jsx create mode 100644 public/dist/frontend/index.html create mode 100644 public/dist/index.html create mode 100644 public/index.html mode change 100644 => 100755 src/cli.js create mode 100644 src/server.js create mode 100644 vite.config.js diff --git a/.gitignore b/.gitignore index 4e17dba..be5020e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules package-lock.json .cursor *.excalidraw +public/dist/assets/ \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..00593be --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,240 @@ + + + + + + Excalidraw POC - Backend API Integration + + + +
+ + + \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..30003db --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,523 @@ +import React, { useState, useEffect, useRef } from 'react' +import { Excalidraw, convertToExcalidrawElements } from '@excalidraw/excalidraw' +import '@excalidraw/excalidraw/index.css' + +function App() { + const [excalidrawAPI, setExcalidrawAPI] = useState(null) + const [isConnected, setIsConnected] = useState(false) + const [elementCount, setElementCount] = useState(0) + const [apiPanelVisible, setApiPanelVisible] = useState(true) + const [notifications, setNotifications] = useState([]) + const websocketRef = useRef(null) + + // Form state + const [formData, setFormData] = useState({ + type: 'rectangle', + x: 100, + y: 100, + width: 100, + height: 100, + text: '', + backgroundColor: '#ffffff', + strokeColor: '#000000', + strokeWidth: 2 + }) + + // WebSocket connection + useEffect(() => { + connectWebSocket() + return () => { + if (websocketRef.current) { + websocketRef.current.close() + } + } + }, []) + + // Load existing elements when Excalidraw API becomes available + useEffect(() => { + if (excalidrawAPI) { + console.log('ExcalidrawAPI ready, setting up real-time sync') + + // Load existing elements immediately + loadExistingElements() + + // Ensure WebSocket is connected for real-time updates + if (!isConnected) { + console.log('ExcalidrawAPI ready but WebSocket not connected, connecting...') + connectWebSocket() + } else { + console.log('Both ExcalidrawAPI and WebSocket are ready for real-time sync') + } + } + }, [excalidrawAPI, isConnected]) + + const loadExistingElements = async () => { + try { + const response = await fetch('/api/elements') + const result = await response.json() + + if (result.success && result.elements && result.elements.length > 0) { + console.log('Loading existing elements from API:', result.elements.length) + const convertedElements = convertToExcalidrawElements(result.elements) + console.log('Converted existing elements:', convertedElements) + excalidrawAPI.updateScene({ elements: convertedElements }) + } + } catch (error) { + console.error('Error loading existing elements:', error) + } + } + + const connectWebSocket = () => { + if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN) { + console.log('WebSocket already connected') + return + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const wsUrl = `${protocol}//${window.location.host}` + + console.log('Connecting to WebSocket:', wsUrl) + websocketRef.current = new WebSocket(wsUrl) + + websocketRef.current.onopen = () => { + console.log('WebSocket connected successfully') + setIsConnected(true) + + // Request existing elements when WebSocket connects and API is ready + if (excalidrawAPI) { + console.log('WebSocket connected and ExcalidrawAPI ready - requesting refresh') + setTimeout(loadExistingElements, 100) // Small delay to ensure connection is stable + } + } + + websocketRef.current.onmessage = (event) => { + try { + const data = JSON.parse(event.data) + handleWebSocketMessage(data) + } catch (error) { + console.error('Error parsing WebSocket message:', error, event.data) + } + } + + websocketRef.current.onclose = (event) => { + console.log('WebSocket disconnected:', event.code, event.reason) + setIsConnected(false) + + // Reconnect after 3 seconds if not a clean close + if (event.code !== 1000) { + console.log('Attempting to reconnect in 3 seconds...') + setTimeout(connectWebSocket, 3000) + } + } + + websocketRef.current.onerror = (error) => { + console.error('WebSocket error:', error) + setIsConnected(false) + } + } + + const handleWebSocketMessage = (data) => { + console.log('WebSocket message received:', data.type, data) + + if (!excalidrawAPI) { + console.log('ExcalidrawAPI not ready, skipping message:', data.type) + return + } + + try { + const currentElements = excalidrawAPI.getSceneElements() + console.log('Current elements count:', currentElements.length) + + switch (data.type) { + case 'initial_elements': + if (data.elements && data.elements.length > 0) { + console.log('Loading initial elements:', data.elements.length) + const convertedElements = convertToExcalidrawElements(data.elements) + console.log('Converted initial elements:', convertedElements.length) + excalidrawAPI.updateScene({ elements: convertedElements }) + } + break + + case 'element_created': + console.log('Processing element_created:', data.element) + const newElement = convertToExcalidrawElements([data.element]) + console.log('Converted new element:', newElement) + const updatedElementsAfterCreate = [...currentElements, ...newElement] + console.log('Total elements after create:', updatedElementsAfterCreate.length) + excalidrawAPI.updateScene({ elements: updatedElementsAfterCreate }) + showNotification('Element created successfully!', 'success') + break + + case 'element_updated': + console.log('Processing element_updated:', data.element.id) + const convertedUpdatedElement = convertToExcalidrawElements([data.element])[0] + const updatedElements = currentElements.map(el => + el.id === data.element.id ? convertedUpdatedElement : el + ) + excalidrawAPI.updateScene({ elements: updatedElements }) + showNotification('Element updated successfully!', 'success') + break + + case 'element_deleted': + console.log('Processing element_deleted:', data.elementId) + const filteredElements = currentElements.filter(el => el.id !== data.elementId) + excalidrawAPI.updateScene({ elements: filteredElements }) + showNotification('Element deleted successfully!', 'success') + break + + case 'elements_batch_created': + console.log('Processing elements_batch_created:', data.elements.length) + const batchElements = convertToExcalidrawElements(data.elements) + console.log('Converted batch elements:', batchElements.length) + const updatedElementsAfterBatch = [...currentElements, ...batchElements] + console.log('Total elements after batch:', updatedElementsAfterBatch.length) + excalidrawAPI.updateScene({ elements: updatedElementsAfterBatch }) + showNotification(`${data.elements.length} elements created!`, 'success') + break + + default: + console.log('Unknown WebSocket message type:', data.type) + } + } catch (error) { + console.error('Error processing WebSocket message:', error, data) + showNotification('Error processing real-time update', 'error') + } + } + + const showNotification = (message, type = 'success') => { + const id = Date.now() + const notification = { id, message, type } + setNotifications(prev => [...prev, notification]) + + setTimeout(() => { + setNotifications(prev => prev.filter(n => n.id !== id)) + }, 3000) + } + + const handleExcalidrawChange = (elements, appState, files) => { + setElementCount(elements.length) + } + + const handleFormSubmit = async (e) => { + e.preventDefault() + + const elementData = { + type: formData.type, + x: parseInt(formData.x), + y: parseInt(formData.y), + width: parseInt(formData.width) || undefined, + height: parseInt(formData.height) || undefined, + backgroundColor: formData.backgroundColor, + strokeColor: formData.strokeColor, + strokeWidth: parseInt(formData.strokeWidth) + } + + if (formData.text) { + elementData.text = formData.text + } + + // Remove undefined values + Object.keys(elementData).forEach(key => { + if (elementData[key] === undefined) { + delete elementData[key] + } + }) + + try { + const response = await fetch('/api/elements', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(elementData) + }) + + const result = await response.json() + + if (result.success) { + showNotification('Element created via API!', 'success') + // Reset form positions + setFormData(prev => ({ + ...prev, + x: Math.floor(Math.random() * 400) + 50, + y: Math.floor(Math.random() * 300) + 50 + })) + } else { + showNotification(`Error: ${result.error}`, 'error') + } + } catch (error) { + console.error('Error creating element:', error) + showNotification('Failed to create element', 'error') + } + } + + const createSampleElements = async () => { + const sampleElements = [ + { + type: 'rectangle', + x: 50, + y: 50, + width: 150, + height: 100, + backgroundColor: '#ffeaa7', + strokeColor: '#2d3436' + }, + { + type: 'ellipse', + x: 250, + y: 50, + width: 120, + height: 120, + backgroundColor: '#74b9ff', + strokeColor: '#0984e3' + }, + { + type: 'diamond', + x: 50, + y: 200, + width: 100, + height: 100, + backgroundColor: '#fd79a8', + strokeColor: '#e84393' + }, + { + type: 'text', + x: 250, + y: 220, + text: 'Hello from API!', + fontSize: 20, + strokeColor: '#2d3436' + } + ] + + try { + const response = await fetch('/api/elements/batch', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ elements: sampleElements }) + }) + + const result = await response.json() + + if (result.success) { + showNotification(`${result.count} sample elements created!`, 'success') + } else { + showNotification(`Error: ${result.error}`, 'error') + } + } catch (error) { + console.error('Error creating sample elements:', error) + showNotification('Failed to create sample elements', 'error') + } + } + + const clearCanvas = () => { + if (excalidrawAPI) { + excalidrawAPI.updateScene({ elements: [] }) + showNotification('Canvas cleared!', 'success') + } + } + + const refreshElements = async () => { + console.log('Manual refresh requested') + if (excalidrawAPI) { + await loadExistingElements() + showNotification('Elements refreshed!', 'success') + } else { + showNotification('Canvas not ready yet', 'error') + } + } + + const forceReconnectWebSocket = () => { + console.log('Force reconnecting WebSocket') + if (websocketRef.current) { + websocketRef.current.close() + } + setTimeout(connectWebSocket, 100) + } + + const toggleApiPanel = () => { + setApiPanelVisible(!apiPanelVisible) + } + + const handleInputChange = (e) => { + const { name, value } = e.target + setFormData(prev => ({ + ...prev, + [name]: value + })) + } + + return ( +
+ {/* Header */} +
+

Excalidraw POC - Backend API Integration

+
+
+
+ {isConnected ? 'Connected' : 'Disconnected'} +
+
+ Elements: {elementCount} +
+ + + + {!isConnected && ( + + )} +
+
+ + {/* API Panel Toggle */} + + + {/* API Panel */} + {apiPanelVisible && ( +
+

Create Element via API

+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+
+ )} + + {/* Canvas Container */} +
+ setExcalidrawAPI(api)} + onChange={handleExcalidrawChange} + initialData={{ + elements: [], + appState: { + theme: 'light', + viewBackgroundColor: '#ffffff' + } + }} + /> +
+ + {/* Notifications */} + {notifications.map(notification => ( +
+ {notification.message} +
+ ))} +
+ ) +} + +export default App \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..aad322f --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) \ No newline at end of file diff --git a/package.json b/package.json index 0afd752..222d603 100644 --- a/package.json +++ b/package.json @@ -10,25 +10,39 @@ "scripts": { "start": "node src/index.js", "dev": "nodemon src/index.js", + "server": "node src/server.js", + "server:dev": "nodemon src/server.js", + "frontend:dev": "vite", + "frontend:build": "vite build", + "frontend:preview": "vite preview", + "build": "npm run frontend:build", + "poc": "concurrently \"npm run server\" \"npm run frontend:dev\"", "test": "jest", "lint": "eslint src/**/*.js", - "build": "tsc", "docker:build": "docker build -t mcp/excalidraw .", "docker:run": "docker run -p 3000:3000 mcp/excalidraw", "prepare": "node -e \"try { require('fs').chmodSync('./src/cli.js', '755') } catch(e) { console.log(e) }\"" }, "dependencies": { + "@excalidraw/excalidraw": "^0.18.0", "@modelcontextprotocol/sdk": "latest", "cors": "^2.8.5", "dotenv": "^16.3.1", + "express": "^4.18.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", "winston": "^3.11.0", + "ws": "^8.14.2", "zod": "^3.22.4", "zod-to-json-schema": "^3.22.3" }, "devDependencies": { - "nodemon": "^3.0.2", + "@vitejs/plugin-react": "^4.6.0", + "concurrently": "^9.2.0", + "eslint": "^8.56.0", "jest": "^29.7.0", - "eslint": "^8.56.0" + "nodemon": "^3.0.2", + "vite": "^6.3.5" }, "keywords": [ "mcp", @@ -50,4 +64,4 @@ "README.md", "LICENSE" ] -} \ No newline at end of file +} diff --git a/public/dist/frontend/index.html b/public/dist/frontend/index.html new file mode 100644 index 0000000..709f2ae --- /dev/null +++ b/public/dist/frontend/index.html @@ -0,0 +1,241 @@ + + + + + + Excalidraw POC - Backend API Integration + + + + + +
+ + \ No newline at end of file diff --git a/public/dist/index.html b/public/dist/index.html new file mode 100644 index 0000000..709f2ae --- /dev/null +++ b/public/dist/index.html @@ -0,0 +1,241 @@ + + + + + + Excalidraw POC - Backend API Integration + + + + + +
+ + \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..b99c9c9 --- /dev/null +++ b/public/index.html @@ -0,0 +1,621 @@ + + + + + + Excalidraw POC - Backend API Integration + + + +
+

Excalidraw POC - Backend API Integration

+
+
+
+ Connecting... +
+
+ Elements: 0 +
+ + +
+
+ + + +
+

Create Element via API

+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+
+ +
+
+
+
+
+
Loading Excalidraw...
+
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/src/cli.js b/src/cli.js old mode 100644 new mode 100755 diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..a3d379b --- /dev/null +++ b/src/server.js @@ -0,0 +1,382 @@ +import express from 'express'; +import cors from 'cors'; +import { WebSocketServer } from 'ws'; +import { createServer } from 'http'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; +import logger from './utils/logger.js'; +import { + elements, + generateId, + EXCALIDRAW_ELEMENT_TYPES +} from './types.js'; +import { z } from 'zod'; + +// Load environment variables +dotenv.config(); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); +const server = createServer(app); +const wss = new WebSocketServer({ server }); + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Serve static files from the build directory for production +// or from the old public directory for development +const staticDir = process.env.NODE_ENV === 'production' + ? path.join(__dirname, '../public/dist') + : path.join(__dirname, '../public'); +app.use(express.static(staticDir)); + +// WebSocket connections +const clients = new Set(); + +// Broadcast to all connected clients +function broadcast(message) { + const data = JSON.stringify(message); + clients.forEach(client => { + if (client.readyState === client.OPEN) { + client.send(data); + } + }); +} + +// WebSocket connection handling +wss.on('connection', (ws) => { + clients.add(ws); + logger.info('New WebSocket connection established'); + + // Send current elements to new client + ws.send(JSON.stringify({ + type: 'initial_elements', + elements: Array.from(elements.values()) + })); + + ws.on('close', () => { + clients.delete(ws); + logger.info('WebSocket connection closed'); + }); + + ws.on('error', (error) => { + logger.error('WebSocket error:', error); + clients.delete(ws); + }); +}); + +// Schema validation +const CreateElementSchema = z.object({ + type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)), + x: z.number(), + y: z.number(), + width: z.number().optional(), + height: z.number().optional(), + backgroundColor: z.string().optional(), + strokeColor: z.string().optional(), + strokeWidth: z.number().optional(), + roughness: z.number().optional(), + opacity: z.number().optional(), + text: z.string().optional(), + fontSize: z.number().optional(), + fontFamily: z.string().optional() +}); + +const UpdateElementSchema = z.object({ + id: z.string(), + type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)).optional(), + x: z.number().optional(), + y: z.number().optional(), + width: z.number().optional(), + height: z.number().optional(), + backgroundColor: z.string().optional(), + strokeColor: z.string().optional(), + strokeWidth: z.number().optional(), + roughness: z.number().optional(), + opacity: z.number().optional(), + text: z.string().optional(), + fontSize: z.number().optional(), + fontFamily: z.string().optional() +}); + +// API Routes + +// Get all elements +app.get('/api/elements', (req, res) => { + try { + const elementsArray = Array.from(elements.values()); + res.json({ + success: true, + elements: elementsArray, + count: elementsArray.length + }); + } catch (error) { + logger.error('Error fetching elements:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +// Create new element +app.post('/api/elements', (req, res) => { + try { + const params = CreateElementSchema.parse(req.body); + logger.info('Creating element via API', { type: params.type }); + + const id = generateId(); + const element = { + id, + ...params, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + version: 1 + }; + + elements.set(id, element); + + // Broadcast to all connected clients + broadcast({ + type: 'element_created', + element: element + }); + + res.json({ + success: true, + element: element + }); + } catch (error) { + logger.error('Error creating element:', error); + res.status(400).json({ + success: false, + error: error.message + }); + } +}); + +// Update element +app.put('/api/elements/:id', (req, res) => { + try { + const { id } = req.params; + const updates = UpdateElementSchema.parse({ id, ...req.body }); + + const existingElement = elements.get(id); + if (!existingElement) { + return res.status(404).json({ + success: false, + error: `Element with ID ${id} not found` + }); + } + + const updatedElement = { + ...existingElement, + ...updates, + updatedAt: new Date().toISOString(), + version: existingElement.version + 1 + }; + + elements.set(id, updatedElement); + + // Broadcast to all connected clients + broadcast({ + type: 'element_updated', + element: updatedElement + }); + + res.json({ + success: true, + element: updatedElement + }); + } catch (error) { + logger.error('Error updating element:', error); + res.status(400).json({ + success: false, + error: error.message + }); + } +}); + +// Delete element +app.delete('/api/elements/:id', (req, res) => { + try { + const { id } = req.params; + + if (!elements.has(id)) { + return res.status(404).json({ + success: false, + error: `Element with ID ${id} not found` + }); + } + + elements.delete(id); + + // Broadcast to all connected clients + broadcast({ + type: 'element_deleted', + elementId: id + }); + + res.json({ + success: true, + message: `Element ${id} deleted successfully` + }); + } catch (error) { + logger.error('Error deleting element:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +// Get element by ID +app.get('/api/elements/:id', (req, res) => { + try { + const { id } = req.params; + const element = elements.get(id); + + if (!element) { + return res.status(404).json({ + success: false, + error: `Element with ID ${id} not found` + }); + } + + res.json({ + success: true, + element: element + }); + } catch (error) { + logger.error('Error fetching element:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +// Query elements with filters +app.get('/api/elements/search', (req, res) => { + try { + const { type, ...filters } = req.query; + let results = Array.from(elements.values()); + + // Filter by type if specified + if (type) { + results = results.filter(element => element.type === type); + } + + // Apply additional filters + if (Object.keys(filters).length > 0) { + results = results.filter(element => { + return Object.entries(filters).every(([key, value]) => { + return element[key] === value; + }); + }); + } + + res.json({ + success: true, + elements: results, + count: results.length + }); + } catch (error) { + logger.error('Error querying elements:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +// Batch create elements +app.post('/api/elements/batch', (req, res) => { + try { + const { elements: elementsToCreate } = req.body; + + if (!Array.isArray(elementsToCreate)) { + return res.status(400).json({ + success: false, + error: 'Expected an array of elements' + }); + } + + const createdElements = []; + + elementsToCreate.forEach(elementData => { + const params = CreateElementSchema.parse(elementData); + const id = generateId(); + const element = { + id, + ...params, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + version: 1 + }; + + elements.set(id, element); + createdElements.push(element); + }); + + // Broadcast to all connected clients + broadcast({ + type: 'elements_batch_created', + elements: createdElements + }); + + res.json({ + success: true, + elements: createdElements, + count: createdElements.length + }); + } catch (error) { + logger.error('Error batch creating elements:', error); + res.status(400).json({ + success: false, + error: error.message + }); + } +}); + +// Serve the frontend +app.get('/', (req, res) => { + const htmlFile = process.env.NODE_ENV === 'production' + ? path.join(__dirname, '../public/dist/index.html') + : path.join(__dirname, '../public/index.html'); + res.sendFile(htmlFile); +}); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + elements_count: elements.size, + websocket_clients: clients.size + }); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + logger.error('Unhandled error:', err); + res.status(500).json({ + success: false, + error: 'Internal server error' + }); +}); + +// Start server +const PORT = process.env.PORT || 3000; +const HOST = process.env.HOST || 'localhost'; + +server.listen(PORT, HOST, () => { + logger.info(`POC server running on http://${HOST}:${PORT}`); + logger.info(`WebSocket server running on ws://${HOST}:${PORT}`); +}); + +export default app; \ No newline at end of file diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..14eeede --- /dev/null +++ b/vite.config.js @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'public/dist', + rollupOptions: { + input: { + main: './frontend/index.html', + }, + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + '/health': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, +}) \ No newline at end of file