diff --git a/frontend/index.html b/frontend/index.html index 00593be..44e68ef 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -231,6 +231,50 @@ .loading-content div:first-child { margin-bottom: 10px; } + + /* Sync Controls Styles */ + .sync-controls { + display: flex; + align-items: center; + gap: 10px; + } + + .btn-loading { + position: relative; + } + + .spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid #ffffff40; + border-top: 2px solid #ffffff; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-right: 5px; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + .sync-status { + font-size: 12px; + min-width: 100px; + } + + .sync-success { + color: #4caf50; + } + + .sync-error { + color: #f44336; + } + + .sync-time { + color: #666; + } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 648d0d4..ee42fbe 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react' -import { Excalidraw, convertToExcalidrawElements } from '@excalidraw/excalidraw' +import { Excalidraw, convertToExcalidrawElements, CaptureUpdateAction } from '@excalidraw/excalidraw' import '@excalidraw/excalidraw/index.css' // Helper function to clean elements for Excalidraw @@ -13,10 +13,62 @@ const cleanElementForExcalidraw = (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])); + + return elements.map(element => { + const fixedElement = { ...element }; + + // Validate and fix boundElements + if (fixedElement.boundElements) { + if (Array.isArray(fixedElement.boundElements)) { + fixedElement.boundElements = fixedElement.boundElements.filter(binding => { + // Ensure binding has required properties + if (!binding || typeof binding !== 'object') return false; + if (!binding.id || !binding.type) return false; + + // Ensure the referenced element exists + const referencedElement = elementMap.get(binding.id); + if (!referencedElement) return false; + + // Validate binding type + if (!['text', 'arrow'].includes(binding.type)) return false; + + return true; + }); + + // Remove boundElements if empty + if (fixedElement.boundElements.length === 0) { + fixedElement.boundElements = null; + } + } else { + // Invalid boundElements format, set to null + fixedElement.boundElements = null; + } + } + + // Validate and fix containerId + if (fixedElement.containerId) { + const containerElement = elementMap.get(fixedElement.containerId); + if (!containerElement) { + // Container doesn't exist, remove containerId + fixedElement.containerId = null; + } + } + + return fixedElement; + }); +} + function App() { const [excalidrawAPI, setExcalidrawAPI] = useState(null) const [isConnected, setIsConnected] = useState(false) const websocketRef = useRef(null) + + // Sync state management + const [syncStatus, setSyncStatus] = useState('idle') // idle, syncing, success, error + const [lastSyncTime, setLastSyncTime] = useState(null) // WebSocket connection useEffect(() => { @@ -97,6 +149,38 @@ function App() { } } + // 调试函数:记录删除操作详情 + const debugDeleteOperation = (operation, data, additionalInfo = {}) => { + const timestamp = new Date().toISOString() + const debugInfo = { + operation, + timestamp, + data, + elementCount: excalidrawAPI?.getSceneElements()?.length || 0, + ...additionalInfo + } + + console.group(`🔍 DELETE DEBUG: ${operation}`) + console.log('详细信息:', debugInfo) + + if (operation === 'websocket_received' && data.type === 'element_deleted') { + const currentElements = excalidrawAPI?.getSceneElements() || [] + const targetElement = currentElements.find(el => el.id === data.elementId) + + console.log('目标元素存在:', !!targetElement) + console.log('当前元素ID列表:', currentElements.map(el => el.id)) + console.log('要删除的ID:', data.elementId) + console.log('ID类型匹配检查:', currentElements.map(el => ({ + id: el.id, + type: typeof el.id, + matches: el.id === data.elementId, + strictEquals: el.id === data.elementId + }))) + } + + console.groupEnd() + } + const handleWebSocketMessage = (data) => { if (!excalidrawAPI) { return @@ -104,13 +188,20 @@ function App() { try { const currentElements = excalidrawAPI.getSceneElements() + console.log('当前元素:', currentElements); switch (data.type) { case 'initial_elements': if (data.elements && data.elements.length > 0) { const cleanedElements = data.elements.map(cleanElementForExcalidraw) - const convertedElements = convertToExcalidrawElements(cleanedElements) - excalidrawAPI.updateScene({ elements: convertedElements }) + const validatedElements = validateAndFixBindings(cleanedElements) + const convertedElements = convertToExcalidrawElements(validatedElements) + excalidrawAPI.updateScene({ + elements: convertedElements, + captureUpdate: CaptureUpdateAction.NEVER + }) + console.log('Loaded initial elements with validated bindings:', convertedElements) + debugElementBindings(convertedElements) } break @@ -118,7 +209,10 @@ function App() { const cleanedNewElement = cleanElementForExcalidraw(data.element) const newElement = convertToExcalidrawElements([cleanedNewElement]) const updatedElementsAfterCreate = [...currentElements, ...newElement] - excalidrawAPI.updateScene({ elements: updatedElementsAfterCreate }) + excalidrawAPI.updateScene({ + elements: updatedElementsAfterCreate, + captureUpdate: CaptureUpdateAction.NEVER + }) break case 'element_updated': @@ -127,19 +221,46 @@ function App() { const updatedElements = currentElements.map(el => el.id === data.element.id ? convertedUpdatedElement : el ) - excalidrawAPI.updateScene({ elements: updatedElements }) + excalidrawAPI.updateScene({ + elements: updatedElements, + captureUpdate: CaptureUpdateAction.NEVER + }) break case 'element_deleted': + debugDeleteOperation('websocket_received', data) const filteredElements = currentElements.filter(el => el.id !== data.elementId) - excalidrawAPI.updateScene({ elements: filteredElements }) + debugDeleteOperation('after_filter', data, { + originalCount: currentElements.length, + filteredCount: filteredElements.length, + actuallyRemoved: currentElements.length - filteredElements.length + }) + excalidrawAPI.updateScene({ + elements: filteredElements, + captureUpdate: CaptureUpdateAction.NEVER + }) + debugDeleteOperation('after_update_scene', data, { + finalElementCount: excalidrawAPI.getSceneElements().length + }) break case 'elements_batch_created': const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw) const batchElements = convertToExcalidrawElements(cleanedBatchElements) const updatedElementsAfterBatch = [...currentElements, ...batchElements] - excalidrawAPI.updateScene({ elements: updatedElementsAfterBatch }) + excalidrawAPI.updateScene({ + elements: updatedElementsAfterBatch, + captureUpdate: CaptureUpdateAction.NEVER + }) + break + + case 'elements_synced': + console.log(`Sync confirmed by server: ${data.count} elements`) + // Sync confirmation already handled by HTTP response + break + + case 'sync_status': + console.log(`Server sync status: ${data.elementCount} elements`) break default: @@ -150,6 +271,89 @@ function App() { } } + // Data format conversion for backend + const convertToBackendFormat = (element) => { + return { + ...element + } + } + + // Format sync time display + const formatSyncTime = (time) => { + if (!time) return '' + return time.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + } + + // Main sync function + const syncToBackend = async () => { + if (!excalidrawAPI) { + console.warn('Excalidraw API not available') + return + } + + setSyncStatus('syncing') + + try { + // 1. Get current elements + const currentElements = excalidrawAPI.getSceneElements() + console.log(`Syncing ${currentElements.length} elements to backend`) + + // 2. Filter out deleted elements + const activeElements = currentElements.filter(el => !el.isDeleted) + + // 3. Convert to backend format + const backendElements = activeElements.map(convertToBackendFormat) + + // 4. Send to backend + const response = await fetch('/api/elements/sync', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + elements: backendElements, + timestamp: new Date().toISOString() + }) + }) + + if (response.ok) { + const result = await response.json() + setSyncStatus('success') + setLastSyncTime(new Date()) + console.log(`Sync successful: ${result.count} elements synced`) + + // Reset status after 2 seconds + setTimeout(() => setSyncStatus('idle'), 2000) + } else { + const error = await response.json() + setSyncStatus('error') + console.error('Sync failed:', error.error) + } + } catch (error) { + setSyncStatus('error') + console.error('Sync error:', error) + } + } + + // Debug function to check element bindings + const debugElementBindings = (elements) => { + console.group('🔍 Element Binding Debug'); + elements.forEach(element => { + if (element.boundElements || element.containerId) { + console.log(`Element ${element.id} (${element.type}):`, { + boundElements: element.boundElements, + containerId: element.containerId, + text: element.text || 'N/A' + }); + } + }); + console.groupEnd(); + } + const clearCanvas = async () => { if (excalidrawAPI) { try { @@ -165,11 +369,17 @@ function App() { } // Clear the frontend canvas - excalidrawAPI.updateScene({ elements: [] }) + excalidrawAPI.updateScene({ + elements: [], + captureUpdate: CaptureUpdateAction.IMMEDIATELY + }) } catch (error) { console.error('Error clearing canvas:', error) // Still clear frontend even if backend fails - excalidrawAPI.updateScene({ elements: [] }) + excalidrawAPI.updateScene({ + elements: [], + captureUpdate: CaptureUpdateAction.IMMEDIATELY + }) } } } @@ -184,6 +394,34 @@ function App() {
{isConnected ? 'Connected' : 'Disconnected'} + + {/* Sync Controls */} +
+ + + {/* Sync Status */} +
+ {syncStatus === 'success' && ( + ✅ Synced + )} + {syncStatus === 'error' && ( + ❌ Sync Failed + )} + {lastSyncTime && syncStatus === 'idle' && ( + + Last sync: {formatSyncTime(lastSyncTime)} + + )} +
+
+ diff --git a/src/index.js b/src/index.js index 53249a3..aea23ee 100755 --- a/src/index.js +++ b/src/index.js @@ -460,7 +460,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } case 'update_element': { - const params = ElementSchema.partial().extend(ElementIdSchema).parse(args); + const params = ElementIdSchema.merge(ElementSchema.partial()).parse(args); const { id, ...updates } = params; if (!id) throw new Error('Element ID is required'); diff --git a/src/server.js b/src/server.js index abd188e..11f6c8b 100644 --- a/src/server.js +++ b/src/server.js @@ -57,6 +57,13 @@ wss.on('connection', (ws) => { elements: Array.from(elements.values()) })); + // Send sync status to new client + ws.send(JSON.stringify({ + type: 'sync_status', + elementCount: elements.size, + timestamp: new Date().toISOString() + })); + ws.on('close', () => { clients.delete(ws); logger.info('WebSocket connection closed'); @@ -70,6 +77,7 @@ wss.on('connection', (ws) => { // Schema validation const CreateElementSchema = z.object({ + id: z.string().optional(), // 允许传入ID,用于MCP同步 type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)), x: z.number(), y: z.number(), @@ -134,7 +142,8 @@ app.post('/api/elements', (req, res) => { const params = CreateElementSchema.parse(req.body); logger.info('Creating element via API', { type: params.type }); - const id = generateId(); + // 优先使用传入的ID(用于MCP同步),否则生成新ID + const id = params.id || generateId(); const element = { id, ...params, @@ -348,6 +357,90 @@ app.post('/api/elements/batch', (req, res) => { } }); +// Sync elements from frontend (覆盖式同步) +app.post('/api/elements/sync', (req, res) => { + try { + const { elements: frontendElements, timestamp } = req.body; + + logger.info(`Sync request received: ${frontendElements.length} elements`, { + timestamp, + elementCount: frontendElements.length + }); + + // 验证输入数据 + if (!Array.isArray(frontendElements)) { + return res.status(400).json({ + success: false, + error: 'Expected elements to be an array' + }); + } + + // 记录同步前的元素数量 + const beforeCount = elements.size; + + // 1. 清空现有内存存储 + elements.clear(); + logger.info(`Cleared existing elements: ${beforeCount} elements removed`); + + // 2. 批量写入新数据 + let successCount = 0; + const processedElements = []; + + frontendElements.forEach((element, index) => { + try { + // 确保元素有ID,如果没有则生成一个 + const elementId = element.id || generateId(); + + // 添加服务端元数据 + const processedElement = { + ...element, + id: elementId, + syncedAt: new Date().toISOString(), + source: 'frontend_sync', + syncTimestamp: timestamp, + version: 1 + }; + + // 存储到内存 + elements.set(elementId, processedElement); + processedElements.push(processedElement); + successCount++; + + } catch (elementError) { + logger.warn(`Failed to process element ${index}:`, elementError); + } + }); + + logger.info(`Sync completed: ${successCount}/${frontendElements.length} elements synced`); + + // 3. 广播同步事件给所有WebSocket客户端 + broadcast({ + type: 'elements_synced', + count: successCount, + timestamp: new Date().toISOString(), + source: 'manual_sync' + }); + + // 4. 返回同步结果 + res.json({ + success: true, + message: `Successfully synced ${successCount} elements`, + count: successCount, + syncedAt: new Date().toISOString(), + beforeCount, + afterCount: elements.size + }); + + } catch (error) { + logger.error('Sync error:', error); + res.status(500).json({ + success: false, + error: error.message, + details: 'Internal server error during sync operation' + }); + } +}); + // Serve the frontend app.get('/', (req, res) => { const htmlFile = path.join(__dirname, '../dist/frontend/index.html'); @@ -369,6 +462,20 @@ app.get('/health', (req, res) => { }); }); +// Sync status endpoint +app.get('/api/sync/status', (req, res) => { + res.json({ + success: true, + elementCount: elements.size, + timestamp: new Date().toISOString(), + memoryUsage: { + heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB + heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), // MB + }, + websocketClients: clients.size + }); +}); + // Error handling middleware app.use((err, req, res, next) => { logger.error('Unhandled error:', err);