import { useEffect, useRef, useCallback, useState } from 'react'; import * as Blockly from 'blockly'; import { javascriptGenerator } from 'blockly/javascript'; import { getBlocksForFramework, getBlock } from '../../blocks/registry'; import { initializeBlocks } from '../../blocks/init'; import { BlockDefinition, BlockShape, CATEGORY_COLORS, CATEGORY_ICONS } from '../../types/blocks'; import { useProjectStore } from '../../store/projectStore'; import { useEditorStore } from '../../store/editorStore'; import { useCollaborationStore } from '../../store/collaborationStore'; import { useProjectSocket } from '../../hooks/useWebSocket'; import { getColor } from '../Collaboration/CollaboratorAvatars'; import { Maximize2, Trash2, MousePointer2 } from 'lucide-react'; // Bridge for Blockly callbacks to set React modal state let setEditModalData: ((data: EditModalData | null) => void) | null = null; interface EditModalData { blockType: string; block: any; values: Record; } // Variable type options const VAR_TYPES = [ { label: 'Any', value: 'any' }, { label: 'Text', value: 'string' }, { label: 'Number', value: 'number' }, { label: 'True/False', value: 'boolean' }, { label: 'List', value: 'array' }, ]; // Inject Blockly CSS fixes for flyout/toolbox function injectBlocklyCSS() { const id = 'rb-blockly-css-fixes'; if (document.getElementById(id)) return; const style = document.createElement('style'); style.id = id; style.textContent = ` .blocklyFlyout { min-width: 220px !important; background: #1e293b !important; border-right: 1px solid #334155 !important; } .blocklyFlyout .blocklyBlockCanvas { padding: 12px 8px !important; } .blocklyFlyout .blocklyBlockCanvas .blocklyDraggable { margin-bottom: 8px !important; } .blocklyToolboxDiv { background: #0f172a !important; border-right: 1px solid #1e293b !important; } .blocklyToolboxDiv .blocklyTreeRow { padding: 6px 8px !important; margin: 0 !important; border-bottom: 1px solid #1e293b !important; height: auto !important; min-height: 36px !important; } .blocklyToolboxDiv .blocklyTreeRow:hover { background: rgba(99, 102, 241, 0.15) !important; } .blocklyToolboxDiv .blocklyTreeRow.blocklyTreeSelected { background: rgba(99, 102, 241, 0.25) !important; } .blocklyTreeIcon { display: none !important; } .blocklyTreeLabel { font-size: 13px !important; font-weight: 500 !important; padding-left: 4px !important; } .blocklyTreeRow .blocklyTreeIcon + .blocklyTreeLabel { padding-left: 28px !important; } .blocklyScrollbarHandle { fill: rgba(148, 163, 184, 0.3) !important; } .blocklyScrollbarHandle:hover { fill: rgba(148, 163, 184, 0.5) !important; } .blocklyFlyoutButton { fill: #334155 !important; } .blocklyFlyoutButton:hover { fill: #475569 !important; } `; document.head.appendChild(style); } // Get all variable names from variable definition blocks on the workspace function getWorkspaceVariableNames(): string[] { const ws = Blockly.getMainWorkspace(); if (!ws) return []; const vars = new Set(); const topBlocks = ws.getTopBlocks(false); const queue = [...topBlocks]; while (queue.length > 0) { const block = queue.shift()!; if (block.type === 'rb_js_var') { try { const name = block.getFieldValue('name'); if (name) vars.add(name); } catch {} } (block.getChildren(false) || []).forEach((c: any) => queue.push(c)); const next = block.getNextBlock(); if (next) queue.push(next); } return Array.from(vars).sort(); } // Create a dynamic variable dropdown for Blockly function dynamicVariableOptions(): any[][] { const names = getWorkspaceVariableNames(); if (names.length === 0) return [['myVar', 'myVar']]; return names.map(n => [n, n]); } // Create a dynamic element selector dropdown for Blockly. // Shows elements from: // 1. The active file's own visual elements (when editing HTML) // 2. Elements from HTML files linked to the active JS/CSS file // 3. All HTML files' elements as a fallback function dynamicElementOptions(): any[][] { try { const state = useEditorStore.getState(); const { elementRegistry, activeFileId, fileTree } = state; const selectors = new Set(); const walk = (els: any[]) => { for (const el of els) { if (el.props?.id) selectors.add(`#${el.props.id}`); if (el.tagName) selectors.add(el.tagName); el.classes?.forEach((c: string) => selectors.add(`.${c}`)); if (el.children) walk(el.children); } }; // Walk elements from a given file, return whether any were found const walkFile = (fileId: string): boolean => { const els = elementRegistry[fileId] || []; if (els.length > 0) { walk(els); return true; } return false; }; // 1. Active file's own elements (if it's an HTML file) if (activeFileId) { walkFile(activeFileId); } // 2. Elements from HTML files that LINK TO the active file // (e.g., index.html links to app.js -> when editing app.js, show index.html's elements) if (activeFileId) { const linkingHtmlFiles = fileTree.filter( f => f.type === 'html' && (f.linkedFiles || []).includes(activeFileId) ); for (const hf of linkingHtmlFiles) { walkFile(hf.id); } } // 3. Elements from ALL HTML files (broad fallback so blocks always have options) const allHtmlFiles = fileTree.filter(f => f.type === 'html'); for (const hf of allHtmlFiles) { walkFile(hf.id); } const sorted = Array.from(selectors).sort(); if (sorted.length === 0) return [['body', 'body'], ['#myId', '#myId'], ['.myClass', '.myClass']]; return sorted.map(s => [s, s]); } catch { return [['body', 'body'], ['#myId', '#myId']]; } } // Find a file in the file tree by id function findFileInTree(files: any[], id: string): any { for (const f of files) { if (f.id === id) return f; if (f.children) { const found = findFileInTree(f.children, id); if (found) return found; } } return null; } initializeBlocks(); // Category label mapping (child-friendly names) const CATEGORY_LABELS: Record = { events: 'Events', variables: 'Variables', logic: 'Logic', loops: 'Loops', math: 'Math', text: 'Text', lists: 'Lists', date: 'Date & Time', functions: 'Functions', dom: 'Elements', css: 'Styles', data: 'Data', html: 'HTML', types: 'Types', electron: 'Electron', ipc: 'IPC', dialog: 'Dialogs', clipboard: 'Clipboard', screen: 'Screen', preload: 'Preload', xaml: 'XAML', csharp: 'C#', maui: 'Commands', node_fs: 'File System', node_http: 'HTTP', node_express: 'Express', node_db: 'Database', node_modules: 'Modules', node_utils: 'Utilities', node_async: 'Async', node_websocket: 'WebSocket', node_jwt: 'JWT Auth', browser: 'Browser', }; // Map our shape names to Blockly's connection types function getBlocklyConnectionType(shape: BlockShape): { prev: string | null; next: string | null; output?: string; } { switch (shape) { case 'hat': return { prev: null, next: null }; // no top, has bottom (handled separately) case 'cap': return { prev: 'stack', next: null as string | null }; case 'boolean': return { prev: null as string | null, next: null as string | null, output: 'Boolean' }; case 'reporter': return { prev: null as string | null, next: null as string | null, output: undefined }; // any output case 'c-block': return { prev: 'stack', next: 'stack' }; case 'stack': default: return { prev: 'stack', next: 'stack' }; } } // Map our field types to Blockly field types function buildBlocklyFields(config: any[], blockId: string): any { const args: any[] = []; if (!config) return args; for (const field of config) { const fieldId = field.id; switch (field.type) { case 'text': args.push({ type: 'field_input', name: fieldId, text: String(field.default ?? ''), }); break; case 'number': args.push({ type: 'field_number', name: fieldId, value: Number(field.default ?? 0), }); break; case 'select': args.push({ type: 'field_dropdown', name: fieldId, options: (field.options || []).map((o: any) => [ o.label, String(o.value), ]), }); break; case 'variable': args.push({ type: 'field_dropdown', name: fieldId, options: dynamicVariableOptions, }); break; case 'element': args.push({ type: 'field_dropdown', name: fieldId, options: dynamicElementOptions, }); break; case 'boolean': args.push({ type: 'field_checkbox', name: fieldId, checked: Boolean(field.default), }); break; case 'color': args.push({ type: 'field_colour', name: fieldId, colour: String(field.default ?? '#000000'), }); break; case 'textarea': case 'code': args.push({ type: 'field_input', name: fieldId, text: String(field.default ?? '').slice(0, 30), }); break; default: args.push({ type: 'field_input', name: fieldId, text: String(field.default ?? ''), }); } } return args; } // Escape special regex characters in a string function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // Register a single block with Blockly function registerBlock(block: BlockDefinition, framework: string): void { const connType = getBlocklyConnectionType(block.shape); // Build all config fields into Blockly field descriptors const allFields: any[] = []; for (const field of block.config || []) { const built = buildBlocklyFields([field], block.id); allFields.push(built[0]); } // Build message: always append config fields and value inputs after the label // We NEVER try to replace words inline to avoid regex collision bugs. let message = block.label || ''; const args0: any[] = []; // Append all config fields at the end of the message for (let i = 0; i < allFields.length; i++) { message += ` %${i + 1}`; args0.push(allFields[i]); } // Separate inputs into value inputs (inline) and statement inputs (separate messages) const valueInputs: any[] = []; const statementInputs: any[] = []; for (const input of block.inputs || []) { if (input.type === 'code') { statementInputs.push(input); } else { valueInputs.push(input); } } // Append value inputs inline to message0 (continue from config fields) let nextIdx = allFields.length + 1; for (const vi of valueInputs) { message += ` %${nextIdx}`; args0.push({ type: 'input_value', name: vi.id, check: vi.type === 'boolean' ? 'Boolean' : null, }); nextIdx++; } const blockJson: any = { type: `rb_${block.id}`, message0: message, args0: args0, colour: hexToBlocklyColor(block.color || CATEGORY_COLORS[block.category] || '#6366f1'), tooltip: block.description, helpUrl: '', }; if (block.shape === 'hat') { // Hat blocks have a "next" connection (they start stacks) but no "previous" blockJson.nextStatement = 'stack'; } else if (block.shape === 'c-block') { // C-block: has prev, next, and a statement input for child blocks blockJson.previousStatement = 'stack'; blockJson.nextStatement = 'stack'; } else if (block.shape === 'boolean' || block.shape === 'reporter') { // Value blocks have output blockJson.output = block.shape === 'boolean' ? 'Boolean' : null; // For reporter, allow any connection if (block.shape === 'reporter') { blockJson.output = null; // Blockly allows any type } } else { // stack or cap if (connType.prev) blockJson.previousStatement = connType.prev; if (connType.next) blockJson.nextStatement = connType.next; } // Add custom context menu for variable and function definition blocks if (block.id === 'js_var' || block.id === 'js_function') { blockJson.customContextMenu = function (options: any[]) { options.push({ text: block.id.startsWith('js_') ? 'Edit...' : 'Edit variable...', enabled: true, callback: function () { if (!setEditModalData) return; const vals: Record = {}; (block.config || []).forEach((f: any) => { try { vals[f.id] = this.getFieldValue(f.id) || f.default || ''; } catch { vals[f.id] = f.default || ''; } }); setEditModalData({ blockType: `rb_${block.id}`, block: this, values: vals }); }, }); }; } // Statement inputs (code type) get their own message blocks for C-shape rendering if (statementInputs.length > 0) { statementInputs.forEach((input, idx) => { blockJson[`message${idx + 1}`] = `${input.label || ''} %1`; blockJson[`args${idx + 1}`] = [{ type: 'input_statement', name: input.id, }]; }); } try { Blockly.Blocks[`rb_${block.id}`] = { init: function () { this.jsonInit(blockJson); }, }; } catch (e) { // Block already registered - skip } // Register the code generator const blockDef = block; javascriptGenerator.forBlock[`rb_${block.id}`] = function (runtimeBlock: any) { const configValues: Record = {}; if (blockDef.config) { blockDef.config.forEach((field: any) => { try { const valueBlock = runtimeBlock.getFieldValue(field.id); configValues[field.id] = valueBlock; } catch (e) { configValues[field.id] = field.default; } }); } // Get input values (from connected blocks) const inputValues: Record = {}; if (blockDef.inputs) { blockDef.inputs.forEach((input: any) => { try { const valueCode = javascriptGenerator.valueToCode( runtimeBlock, input.id, javascriptGenerator.ORDER_ATOMIC ); inputValues[input.id] = valueCode || ''; } catch (e) { inputValues[input.id] = ''; } }); } // Call our existing compile function const code = blockDef.compile ? blockDef.compile(configValues, inputValues) : ''; return [code, javascriptGenerator.ORDER_ATOMIC]; }; } // Convert hex color to Blockly's expected color number (HSV hue) function hexToBlocklyColor(hex: string): number { // Blockly uses hue values 0-360 // We'll just return the hex as a string in modern Blockly return hex as any; } // Generate code from a single Blockly block by using our own compile functions // This bypasses javascriptGenerator.workspaceToCode() to avoid compatibility issues function generateCodeFromBlock(block: any, visited: Set): string { const type: string = block.type || ''; const id = type.startsWith('rb_') ? type.slice(3) : ''; if (!id) return ''; const def = getBlock(id); if (!def) return ''; const fieldValues: Record = {}; if (def.config) { for (const field of def.config) { try { const val = block.getFieldValue(field.id); fieldValues[field.id] = val != null ? val : field.default; } catch { fieldValues[field.id] = field.default; } } } const inputValues: Record = {}; if (def.inputs) { for (const input of def.inputs) { try { const targetBlock = block.getInputTargetBlock(input.id); if (targetBlock && !visited.has(targetBlock.id)) { visited.add(targetBlock.id); if (input.type === 'code') { // Statement input: walk the connected block stack via nextConnection const stmts: string[] = []; let current: any = targetBlock; while (current) { stmts.push(generateCodeFromBlock(current, visited)); current = current.getNextBlock ? current.getNextBlock() : null; if (current && visited.has(current.id)) break; if (current) visited.add(current.id); } inputValues[input.id] = stmts.join('\n'); } else { // Value input: single expression block inputValues[input.id] = generateCodeFromBlock(targetBlock, visited); } } else { inputValues[input.id] = ''; } } catch { inputValues[input.id] = ''; } } } return def.compile ? def.compile(fieldValues, inputValues) : ''; } function generateCodeFromWorkspace(ws: any): string { const topBlocks = ws.getTopBlocks(false); const visited = new Set(); const code: string[] = []; for (const block of topBlocks) { if (!visited.has(block.id)) { visited.add(block.id); code.push(generateCodeFromBlock(block, visited)); } } return code.filter(Boolean).join('\n'); } export default function BlockEditor() { const blocklyDiv = useRef(null); const workspaceRef = useRef(null); const { currentProject } = useProjectStore(); const framework = currentProject?.framework || 'web'; const activeFileId = useEditorStore((s) => s.activeFileId); const getBlocksXml = useEditorStore((s) => s.getBlocksXml); const setBlocksXml = useEditorStore((s) => s.setBlocksXml); const setBlockCode = useEditorStore((s) => s.setBlockCode); const saveGeneratedCodeToFile = useEditorStore((s) => s.saveGeneratedCodeToFile); const { emitBlockStateSync, emitBlockDragStart, emitBlockDragMove, emitBlockDragEnd, emitBlockConnect, emitBlockChange, emitBlockCreateEvent, emitPointerMove } = useProjectSocket(currentProject?.id); const remotePointers = useCollaborationStore((s) => s.remotePointers); const remoteBlockDrags = useCollaborationStore((s) => s.remoteBlockDrags); const pendingBlockConnects = useCollaborationStore((s) => s.pendingBlockConnects); const pendingBlockChanges = useCollaborationStore((s) => s.pendingBlockChanges); const pendingBlockCreates = useCollaborationStore((s) => s.pendingBlockCreates); const remoteUpdateRef = useRef(false); const isDraggingRef = useRef(false); const pendingSyncXmlRef = useRef(null); const blocksCreatedDuringDragRef = useRef(false); // Modal state for editing variable/function blocks const [editingModal, setEditingModal] = useState(null); const [editFormValues, setEditFormValues] = useState>({}); // Expose setter to module-level variable for Blockly callbacks useEffect(() => { setEditModalData = (data) => { setEditingModal(data); if (data) { setEditFormValues({ ...data.values }); } }; }, []); // Initialize Blockly workspace useEffect(() => { if (!blocklyDiv.current) return; if (!activeFileId) return; // Inject Blockly CSS fixes for toolbox/flyout spacing injectBlocklyCSS(); // Register all blocks for the current framework const blocks = getBlocksForFramework(framework as any); blocks.forEach((block) => registerBlock(block, framework)); // Build the toolbox const blocksByCategory = new Map(); blocks.forEach((b) => { if (!blocksByCategory.has(b.category)) { blocksByCategory.set(b.category, []); } blocksByCategory.get(b.category)!.push(b); }); const toolbox = buildToolbox(blocksByCategory); // Create the workspace const ws = Blockly.inject(blocklyDiv.current, { toolbox, grid: { spacing: 20, length: 3, colour: '#1e293b', snap: false, }, move: { scrollbars: true, drag: true, wheel: true, }, zoom: { controls: true, wheel: true, startScale: 0.9, maxScale: 2, minScale: 0.4, scaleSpeed: 1.1, }, trashcan: true, renderer: 'zelos', theme: Blockly.Themes.Zelos, sounds: false, }); workspaceRef.current = ws; // Register toolbar button callbacks for custom buttons in the toolbox ws.registerButtonCallback('ADD_VARIABLE', () => { const btn = document.querySelector('[data-rb-add-var]'); if (btn) (btn as HTMLButtonElement).click(); }); ws.registerButtonCallback('ADD_FUNCTION', () => { const btn = document.querySelector('[data-rb-add-func]'); if (btn) (btn as HTMLButtonElement).click(); }); // Hide flyout scrollbar when flyout closes, show when it opens ws.addChangeListener((e: any) => { if (e.type === 'flyout_open') { if (e.isOpen === false) { setTimeout(() => { Blockly.svgResize(ws); try { ws.scroll(0, 0); } catch {} // After resize/scroll, force-hide the flyout's scrollbar. // Blockly appends flyout scrollbar SVG elements directly to the main // workspace SVG (not inside the flyout's SVG group), so they remain // visible even after the flyout is hidden. const flyout = ws.getFlyout(); if (flyout) { const flyoutWs = flyout.getWorkspace(); if (flyoutWs && flyoutWs.scrollbar) { flyoutWs.scrollbar.setVisible(false); } // DOM fallback: directly hide any scrollbar handles on the left side // (flyout area) that Blockly's API may leave visible. const svgEl = ws.getParentSvg(); if (svgEl) { const svgWidth = svgEl.clientWidth; svgEl.querySelectorAll('.blocklyScrollbarHandle').forEach((h: Element) => { const scrollbarEl = h.closest('.blocklyScrollbar') as HTMLElement; if (scrollbarEl && scrollbarEl.style.display !== 'none') { const rect = scrollbarEl.getBoundingClientRect(); const svgRect = svgEl.getBoundingClientRect(); const relX = rect.left - svgRect.left; if (relX < svgWidth * 0.4) { scrollbarEl.style.display = 'none'; } } }); } } }, 200); } } }); // Track block selection for collaboration ws.addChangeListener((e: any) => { if (e.type === Blockly.Events.SELECTED) { const wsHook = (window as any).__wsBlockSelection; if (wsHook) wsHook(e.newElementId || null); } }); // Track pointer position in workspace coordinates (viewport → ws, delivers absolute coords) let pointerEmitTime = 0; const svg = ws.getParentSvg(); if (svg) { const handleMouseMove = (e: MouseEvent) => { const now = Date.now(); if (now - pointerEmitTime < 50) return; pointerEmitTime = now; const containerRect = blocklyDiv.current!.getBoundingClientRect(); const relX = e.clientX - containerRect.left; const relY = e.clientY - containerRect.top; const wsCoord = { x: (relX - ws.scrollX) / ws.scale, y: (relY - ws.scrollY) / ws.scale, }; emitPointerMove(wsCoord.x, wsCoord.y); }; svg.addEventListener('mousemove', handleMouseMove); // Store for cleanup blocklyDiv.current!.dataset.pointerHandler = 'true'; (blocklyDiv.current as any).__pointerHandler = handleMouseMove; (blocklyDiv.current as any).__pointerSvg = svg; } // Track block drag events + poll position during drag let draggedBlockId: string | null = null; let dragPoll: ReturnType | null = null; let dragEndId = 0; ws.addChangeListener((e: any) => { if (e.type === Blockly.Events.BLOCK_DRAG) { const block = ws.getBlockById(e.blockId); if (block) { const xy = block.getRelativeToSurfaceXY(); if (e.isStart) { dragEndId++; isDraggingRef.current = true; blocksCreatedDuringDragRef.current = false; draggedBlockId = e.blockId; emitBlockDragStart(e.blockId, xy.x, xy.y); // Poll block position during drag (BLOCK_MOVE may not fire) dragPoll = setInterval(() => { if (remoteUpdateRef.current || !isDraggingRef.current) { if (dragPoll) { clearInterval(dragPoll); dragPoll = null; } return; } const b = ws.getBlockById(draggedBlockId!); if (b) { const pos = b.getRelativeToSurfaceXY(); emitBlockDragMove(draggedBlockId!, pos.x, pos.y); } }, 100); } else if (e.isEnd) { const endedBlockId = e.blockId; draggedBlockId = null; if (dragPoll) { clearInterval(dragPoll); dragPoll = null; } emitBlockDragEnd(endedBlockId); // Defer isDraggingRef=false + saveBlocks until after Blockly // applies the connection (which fires BLOCK_MOVE next) const thisId = ++dragEndId; requestAnimationFrame(() => { if (thisId !== dragEndId) return; isDraggingRef.current = false; saveBlocks(); // Apply any deferred XML sync that arrived during drag const pendingXml = pendingSyncXmlRef.current; if (pendingXml) { pendingSyncXmlRef.current = null; const ws2 = workspaceRef.current; if (ws2) { // If the user created any blocks locally during drag, skip the // deferred XML entirely — local changes take priority over the // collaborator's XML that was captured mid-drag. if (blocksCreatedDuringDragRef.current) { console.log('[WS Reload] drag-end SKIPPING deferred XML: user created blocks locally during drag'); return; } // Staleness check: if the workspace has >= blocks than the incoming // XML, the XML is stale (user added/rearranged blocks during drag). try { const xmlEl = Blockly.utils.xml.textToDom(pendingXml); let incomingTopBlockCount = 0; if (xmlEl) { for (const child of xmlEl.children) { if (child.nodeName === 'block') incomingTopBlockCount++; } } const currentTopBlockCount = ws2.getTopBlocks(false).length; console.log('[WS Reload] drag-end: incoming top blocks:', incomingTopBlockCount, 'current workspace top blocks:', currentTopBlockCount); if (currentTopBlockCount >= incomingTopBlockCount) { console.log('[WS Reload] drag-end SKIPPING deferred XML: workspace has >= blocks than incoming XML (stale)'); return; } } catch (e) { console.warn('[WS Reload] drag-end staleness check failed:', e); } console.log('[WS Reload] applying deferred XML after drag end'); remoteUpdateRef.current = true; try { ws2.clear(); const dom = Blockly.utils.xml.textToDom(pendingXml); Blockly.Xml.domToWorkspace(dom, ws2); try { (ws2 as any).clearSelection(); } catch {} try { ws2.zoomToFit(); } catch {} } catch (e) { console.error('[WS Reload] FAILED to apply deferred XML:', e); } requestAnimationFrame(() => { setTimeout(() => { remoteUpdateRef.current = false; }, 0); }); } } }); } } } // When a block snaps to a parent during drag end, Connection.connect() // fires BLOCK_MOVE synchronously with newParentId/newInputName. // isDraggingRef.current ensures we only emit for user-initiated drags. if (e.type === Blockly.Events.BLOCK_MOVE && e.newParentId && isDraggingRef.current) { emitBlockConnect(e.blockId, e.newParentId, e.newInputName || undefined); } // Broadcast field value changes (dropdowns, text inputs, etc.) // Only emit for user-initiated changes, not during remote updates or workspace load. if (e.type === Blockly.Events.BLOCK_CHANGE && e.element === 'field' && !remoteUpdateRef.current) { emitBlockChange(e.blockId, e.name, e.newValue, e.element); } // Broadcast block creation (toolbox drag-out, paste, duplicate) so collaborator // screens can create the block immediately instead of waiting for XML sync. if (e.type === Blockly.Events.BLOCK_CREATE && !remoteUpdateRef.current) { if (isDraggingRef.current) { blocksCreatedDuringDragRef.current = true; } const block = ws.getBlockById(e.blockId); console.log('[BLOCK_CREATE] received:', { blockId: e.blockId, hasBlock: !!block, blockType: block?.type, isShadow: block?.isShadow?.(), hasParent: !!block?.getParent?.(), isDragging: isDraggingRef.current }); if (block && !block.getParent()) { try { const dom = Blockly.Xml.blockToDom(block, { xy: true } as any); const xml = Blockly.Xml.domToText(dom); console.log('[BLOCK_CREATE] emitting block_create_event:', { blockId: e.blockId, activeFileId, xmlLen: xml.length }); if (activeFileId) emitBlockCreateEvent(e.blockId, activeFileId, xml); } catch (err) { console.warn('[BLOCK_CREATE] failed to emit block_create_event:', err); } } else if (block && block.getParent()) { console.log('[BLOCK_CREATE] skipping: block has parent'); } } }); // Restore saved blocks for this specific file const savedXml = getBlocksXml(activeFileId); if (savedXml) { try { remoteUpdateRef.current = true; const dom = Blockly.utils.xml.textToDom(savedXml); Blockly.Xml.domToWorkspace(dom, ws); requestAnimationFrame(() => { setTimeout(() => { remoteUpdateRef.current = false; }, 0); }); } catch (e) { requestAnimationFrame(() => { setTimeout(() => { remoteUpdateRef.current = false; }, 0); }); // saved XML is invalid, ignore } } // Auto-save blocks on change - per-file const saveBlocks = () => { if (remoteUpdateRef.current || isDraggingRef.current) { console.log('[saveBlocks] SKIPPING (remote:', remoteUpdateRef.current, 'dragging:', isDraggingRef.current, ') topBlocks:', ws.getTopBlocks(false).length); return; } const dom = Blockly.Xml.workspaceToDom(ws); const xml = Blockly.Xml.domToText(dom); const currentFileId = useEditorStore.getState().activeFileId; if (!currentFileId) return; const savedXml = getBlocksXml(currentFileId); if (savedXml === xml) { console.log('[saveBlocks] SKIPPING: XML unchanged for file:', currentFileId); return; } console.log('[saveBlocks] SAVING blocks for file:', currentFileId, 'xml length:', xml.length, 'topBlocks:', ws.getTopBlocks(false).length, '(old:', savedXml?.length, ')'); setBlocksXml(currentFileId, xml); emitBlockStateSync(currentFileId, xml); let finalCode = ''; try { // Try manual code generation first (uses our compile functions directly) finalCode = generateCodeFromWorkspace(ws); // Fall back to Blockly's generator if manual output is empty but blocks exist if (!finalCode && ws.getTopBlocks(false).length > 0) { finalCode = javascriptGenerator.workspaceToCode(ws) || ''; } } catch (e) { // code generation failed - file content stays empty so compileWeb defaults } setBlockCode(currentFileId, finalCode); saveGeneratedCodeToFile(currentFileId, finalCode); }; ws.addChangeListener(saveBlocks); return () => { if (dragPoll) { clearInterval(dragPoll); dragPoll = null; } // Save current blocks for the file before workspace is disposed if (activeFileId) { try { const dom = Blockly.Xml.workspaceToDom(ws); const xml = Blockly.Xml.domToText(dom); setBlocksXml(activeFileId, xml); } catch (e) {} } // Cleanup pointer move listener const div = blocklyDiv.current; if (div && (div as any).__pointerHandler && (div as any).__pointerSvg) { (div as any).__pointerSvg.removeEventListener('mousemove', (div as any).__pointerHandler); delete (div as any).__pointerHandler; delete (div as any).__pointerSvg; } isDraggingRef.current = false; ws.removeChangeListener(saveBlocks); ws.dispose(); workspaceRef.current = null; }; }, [framework, activeFileId]); // Apply remote block state changes (skip if workspace already matches) const blocksXmlForFile = useEditorStore((s) => s.blocksXml[activeFileId || '']); useEffect(() => { if (!activeFileId || !workspaceRef.current || remoteUpdateRef.current) return; const ws = workspaceRef.current; if (!blocksXmlForFile) return; // If the workspace already matches this XML, skip reload (avoid disrupting drag/edit) try { const currentDom = Blockly.Xml.workspaceToDom(ws); const currentXml = Blockly.Xml.domToText(currentDom); if (currentXml === blocksXmlForFile) return; } catch {} // If the user is currently dragging, defer the reload to avoid disrupting the drag. // Store the pending XML so the drag-end handler can apply it. if (isDraggingRef.current) { console.log('[WS Reload] DEFERRING: user is dragging'); pendingSyncXmlRef.current = blocksXmlForFile; return; } // Staleness check: if the current workspace has more top-level blocks than the // incoming XML, the XML is stale (blocks were added via individual events like // block_create_event before the XML sync arrived). Don't clear the workspace // to avoid losing blocks that aren't yet reflected in the XML. try { const xmlEl = Blockly.utils.xml.textToDom(blocksXmlForFile); let incomingTopBlockCount = 0; if (xmlEl) { for (const child of xmlEl.children) { if (child.nodeName === 'block') incomingTopBlockCount++; } } const currentTopBlockCount = ws.getTopBlocks(false).length; console.log('[WS Reload] incoming top blocks:', incomingTopBlockCount, 'current workspace top blocks:', currentTopBlockCount); if (currentTopBlockCount > incomingTopBlockCount) { console.log('[WS Reload] SKIPPING: workspace has more blocks than incoming XML (stale)'); return; } } catch (e) { console.warn('[WS Reload] staleness check failed:', e); } console.log('[WS Reload] reloading workspace from XML (top blocks:', ws.getTopBlocks(false).length, ')'); pendingSyncXmlRef.current = null; remoteUpdateRef.current = true; try { ws.clear(); const dom = Blockly.utils.xml.textToDom(blocksXmlForFile); Blockly.Xml.domToWorkspace(dom, ws); try { (ws as any).clearSelection(); } catch {} try { ws.zoomToFit(); } catch {} } catch (e) { console.error('[WS Reload] FAILED to load blocks from XML:', e); } // Blockly may fire events asynchronously (microtasks) after domToWorkspace. // Defer resetting the flag so those events see remoteUpdateRef=true. requestAnimationFrame(() => { setTimeout(() => { remoteUpdateRef.current = false; }, 0); }); }, [activeFileId, blocksXmlForFile]); // Move remote collaborator blocks to absolute positions during drag useEffect(() => { const ws = workspaceRef.current; if (!ws || !activeFileId || remoteUpdateRef.current) return; remoteUpdateRef.current = true; for (const [userId, drag] of Object.entries(remoteBlockDrags)) { const block = ws.getBlockById(drag.blockId); if (!block) continue; // Remote user already unplugged this block from its parent; match on our end if (block.getParent()) { try { block.unplug(false); } catch {} } const currentPos = block.getRelativeToSurfaceXY(); const dx = drag.x - currentPos.x; const dy = drag.y - currentPos.y; if (dx !== 0 || dy !== 0) { block.moveBy(dx, dy); } } remoteUpdateRef.current = false; }, [remoteBlockDrags, activeFileId]); // Connect blocks on receiver when remote user snaps a block into a parent useEffect(() => { if (!pendingBlockConnects.length) return; remoteUpdateRef.current = true; const ws = workspaceRef.current; if (ws) { const unprocessed: Array<{ childId: string; parentId: string; inputName?: string }> = []; for (const connect of pendingBlockConnects) { const child = ws.getBlockById(connect.childId); const parent = ws.getBlockById(connect.parentId); if (!child || !parent) { // Blocks not yet loaded (connect arrived before XML sync); keep in queue unprocessed.push(connect); continue; } if (connect.inputName) { // Statement or value connection: child connects to a named input on the parent const input = parent.getInput(connect.inputName); if (input?.connection && !input.connection.isConnected()) { try { if (child.previousConnection && !child.previousConnection.isConnected()) { input.connection.connect(child.previousConnection); } else if (child.outputConnection && !child.outputConnection.isConnected()) { input.connection.connect(child.outputConnection); } try { (parent as any).render(); } catch {} try { (child as any).render(); } catch {} } catch {} } } else { // Stack connection: child below parent (child.previous → parent.next) if (child.previousConnection && parent.nextConnection && !child.previousConnection.isConnected() && !parent.nextConnection.isConnected()) { try { parent.nextConnection.connect(child.previousConnection); try { (parent as any).render(); } catch {} try { (child as any).render(); } catch {} } catch {} } } } // Only update store when items were actually consumed (blocks existed) if (unprocessed.length < pendingBlockConnects.length) { useCollaborationStore.setState({ pendingBlockConnects: unprocessed }); } ws.render(); } remoteUpdateRef.current = false; }, [pendingBlockConnects, blocksXmlForFile]); // Apply field value changes from remote collaborators (dropdowns, text fields, etc.) useEffect(() => { if (!pendingBlockChanges.length) return; remoteUpdateRef.current = true; const ws = workspaceRef.current; if (ws) { let change; while ((change = useCollaborationStore.getState().shiftPendingBlockChange())) { const block = ws.getBlockById(change.blockId); if (!block) continue; try { block.setFieldValue(change.value, change.name); try { (block as any).render(); } catch {} } catch {} } } remoteUpdateRef.current = false; }, [pendingBlockChanges]); // Create blocks on receiver when remote user drags a new block from the toolbox useEffect(() => { if (!pendingBlockCreates.length) { console.log('[PendingCreate] no pending creates, skipping'); return; } console.log('[PendingCreate] processing', pendingBlockCreates.length, 'pending creates'); remoteUpdateRef.current = true; const ws = workspaceRef.current; if (ws && activeFileId) { let created = false; while (useCollaborationStore.getState().pendingBlockCreates.length > 0) { const create = useCollaborationStore.getState().shiftPendingBlockCreate(); if (!create) continue; // Skip if block already exists (e.g. already created by XML sync) if (ws.getBlockById(create.blockId)) { console.log('[PendingCreate] block already exists:', create.blockId); continue; } try { console.log('[PendingCreate] creating block:', create.blockId); const dom = Blockly.utils.xml.textToDom(create.xml); Blockly.Xml.domToWorkspace(dom, ws); created = true; console.log('[PendingCreate] block created successfully:', create.blockId); } catch (e) { console.warn('[PendingCreate] failed to create block:', create.blockId, e); } } // Update blocksXml in the store so the blocksXmlForFile comparison // in the workspace-reload effect matches the current workspace, // preventing a destructive clear+reload that could conflict. if (created) { const dom = Blockly.Xml.workspaceToDom(ws); const xml = Blockly.Xml.domToText(dom); console.log('[PendingCreate] updating blocksXml after creating blocks'); useEditorStore.getState().setBlocksXml(activeFileId, xml); } useCollaborationStore.setState({ pendingBlockCreates: [] }); } requestAnimationFrame(() => { setTimeout(() => { remoteUpdateRef.current = false; }, 0); }); }, [pendingBlockCreates, blocksXmlForFile]); // Collaborator block selection overlay const collaborators = useCollaborationStore((s) => s.collaborators); useEffect(() => { const ws = workspaceRef.current; if (!ws) return; try { ws.getAllBlocks(false).forEach((block: any) => block.setHighlighted(false)); collaborators.forEach((c) => { if (c.selectedBlockId && c.selectedBlockId !== '') { const block = ws.getBlockById(c.selectedBlockId); if (block) block.setHighlighted(true); } }); } catch {} // workspace may be disposed }, [collaborators, blocksXmlForFile]); const fitView = useCallback(() => { if (workspaceRef.current) { Blockly.svgResize(workspaceRef.current); workspaceRef.current.zoomToFit(); } }, []); const clearAll = useCallback(() => { if (workspaceRef.current) { workspaceRef.current.clear(); } }, []); // Save edit modal and update block const saveEditModal = useCallback(() => { if (!editingModal) return; const { block } = editingModal; try { Object.entries(editFormValues).forEach(([key, value]) => { block.setFieldValue(value, key); }); } catch {} setEditingModal(null); }, [editingModal, editFormValues]); // Add a new variable stack to the workspace const addNewVariable = useCallback((name: string, type: string) => { const ws = workspaceRef.current; if (!ws) return; Blockly.Xml.domToWorkspace( Blockly.utils.xml.textToDom( `${name}${type}` ), ws ); }, []); const addNewFunction = useCallback((name: string, params: string, asyncFn: boolean) => { const ws = workspaceRef.current; if (!ws) return; Blockly.Xml.domToWorkspace( Blockly.utils.xml.textToDom( `${name}${params}${asyncFn ? 'TRUE' : 'FALSE'}` ), ws ); }, []); const openNewVariableModal = useCallback(() => { setEditingModal({ blockType: 'new_variable', block: null, values: { name: '', varType: 'any' } }); setEditFormValues({ name: '', varType: 'any' }); }, []); const openNewFunctionModal = useCallback(() => { setEditingModal({ blockType: 'new_function', block: null, values: { name: '', params: '', asyncFn: 'FALSE' } }); setEditFormValues({ name: '', params: '', asyncFn: 'FALSE' }); }, []); const saveNewItemFromModal = useCallback(() => { if (!editingModal) return; if (editingModal.blockType === 'new_variable') { const name = editFormValues.name?.trim() || 'myVar'; const varType = editFormValues.varType || 'any'; addNewVariable(name, varType); } else if (editingModal.blockType === 'new_function') { const name = editFormValues.name?.trim() || 'myFunction'; const params = editFormValues.params?.trim() || ''; const asyncFn = editFormValues.asyncFn === 'TRUE'; addNewFunction(name, params, asyncFn); } setEditingModal(null); }, [editingModal, editFormValues, addNewVariable, addNewFunction]); const isNewItem = editingModal?.blockType === 'new_variable' || editingModal?.blockType === 'new_function'; return (
{/* Toolbar */}
Draggable block editor
{/* Blockly workspace */}
{/* Remote collaborator pointers (workspace coords → viewport px) */} {Object.entries(remotePointers).map(([userId, pointer]) => { const w = workspaceRef.current; const cr = blocklyDiv.current?.getBoundingClientRect(); if (!w || !cr) return null; const vx = cr.left + (pointer.x + w.scrollX) * w.scale; const vy = cr.top + (pointer.y + w.scrollY) * w.scale; // Hide pointer if outside the blockly canvas bounds if (vx < cr.left || vx > cr.right || vy < cr.top || vy > cr.bottom) return null; return (
{pointer.username}
); })} {/* Remote collaborator block drags (ghost blocks, workspace coords → viewport) */} {Object.entries(remoteBlockDrags).map(([userId, drag]) => { const w = workspaceRef.current; let vx = drag.x, vy = drag.y; if (w) { const cr = blocklyDiv.current?.getBoundingClientRect(); if (cr) { vx = cr.left + (drag.x + w.scrollX) * w.scale; vy = cr.top + (drag.y + w.scrollY) * w.scale; } } const collab = collaborators.find(c => c.userId === userId); const label = collab ? `${collab.username} dragging` : 'Dragging...'; return (
{label}
); })}
{/* Edit modal for variable/function blocks */} {editingModal && (
setEditingModal(null)}>
e.stopPropagation()}>

{editingModal.blockType === 'new_variable' ? 'New Variable' : editingModal.blockType === 'new_function' ? 'New Function' : editingModal.blockType === 'rb_js_var' ? 'Edit Variable' : editingModal.blockType === 'rb_js_function' ? 'Edit Function' : 'Edit Block'}

setEditFormValues(v => ({ ...v, name: e.target.value }))} placeholder="myVar" />
{(editingModal.blockType === 'new_variable' || editingModal.blockType === 'rb_js_var') && (
)} {(editingModal.blockType === 'new_function' || editingModal.blockType === 'rb_js_function') && (
setEditFormValues(v => ({ ...v, params: e.target.value }))} placeholder="a, b" />
)} {(editingModal.blockType === 'new_function' || editingModal.blockType === 'rb_js_function') && (
setEditFormValues(v => ({ ...v, asyncFn: e.target.checked ? 'TRUE' : 'FALSE' }))} />
)}
)}
); } // Build the Blockly toolbox from our category structure function buildToolbox(blocksByCategory: Map): Blockly.utils.toolbox.ToolboxDefinition { const categories: any[] = []; // Sort categories: events first, then others const sortedCategories = Array.from(blocksByCategory.keys()).sort((a, b) => { if (a === 'events') return -1; if (b === 'events') return 1; return a.localeCompare(b); }); for (const cat of sortedCategories) { const blocks = blocksByCategory.get(cat) || []; const color = CATEGORY_COLORS[cat] || '#6366f1'; const icon = CATEGORY_ICONS[cat] || '■'; const label = CATEGORY_LABELS[cat] || cat; const contents: any[] = []; // Add custom buttons in variable/function categories if (cat === 'variable') { contents.push({ kind: 'button', text: '+ New Variable', callbackKey: 'ADD_VARIABLE' }); } if (cat === 'function') { contents.push({ kind: 'button', text: '+ New Function', callbackKey: 'ADD_FUNCTION' }); } contents.push(...blocks.map((b) => ({ kind: 'block', type: `rb_${b.id}`, ...(b.config ? { fields: Object.fromEntries( b.config.filter((f) => f.type !== 'variable' && f.type !== 'element').map((f) => [f.id, f.default ?? '']) ), } : {}), }))); categories.push({ kind: 'category', name: label, colour: color, icon: icon, contents, }); } return { kind: 'categoryToolbox', contents: categories, }; }