Spaces:
Running
Running
| 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<string, string>; | |
| } | |
| // 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<string>(); | |
| 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<string>(); | |
| 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<string, string> = { | |
| 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<string, string> = {}; | |
| (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<string, any> = {}; | |
| 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<string, string> = {}; | |
| 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>): 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<string, any> = {}; | |
| 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<string, string> = {}; | |
| 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<string>(); | |
| 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<HTMLDivElement>(null); | |
| const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(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<string | null>(null); | |
| const blocksCreatedDuringDragRef = useRef(false); | |
| // Modal state for editing variable/function blocks | |
| const [editingModal, setEditingModal] = useState<EditModalData | null>(null); | |
| const [editFormValues, setEditFormValues] = useState<Record<string, string>>({}); | |
| // 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<string, BlockDefinition[]>(); | |
| 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<typeof setInterval> | 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( | |
| `<xml><block type="rb_js_var"><field name="name">${name}</field><field name="varType">${type}</field></block></xml>` | |
| ), | |
| ws | |
| ); | |
| }, []); | |
| const addNewFunction = useCallback((name: string, params: string, asyncFn: boolean) => { | |
| const ws = workspaceRef.current; | |
| if (!ws) return; | |
| Blockly.Xml.domToWorkspace( | |
| Blockly.utils.xml.textToDom( | |
| `<xml><block type="rb_js_function"><field name="name">${name}</field><field name="params">${params}</field><field name="asyncFn">${asyncFn ? 'TRUE' : 'FALSE'}</field></block></xml>` | |
| ), | |
| 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 ( | |
| <div className="flex flex-col h-full bg-[#1a2e2e]"> | |
| {/* Toolbar */} | |
| <div className="flex items-center gap-2 px-4 py-2 bg-surface-900 border-b border-surface-700"> | |
| <div className="flex-1 text-xs text-surface-400"> | |
| Draggable block editor | |
| </div> | |
| <button | |
| onClick={openNewVariableModal} | |
| className="hidden" | |
| data-rb-add-var | |
| /> | |
| <button | |
| onClick={openNewFunctionModal} | |
| className="hidden" | |
| data-rb-add-func | |
| /> | |
| <button | |
| onClick={fitView} | |
| className="btn-ghost p-1.5" | |
| title="Fit View" | |
| > | |
| <Maximize2 className="w-4 h-4" /> | |
| </button> | |
| <button | |
| onClick={clearAll} | |
| className="btn-ghost p-1.5" | |
| title="Clear All" | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| {/* Blockly workspace */} | |
| <div | |
| ref={blocklyDiv} | |
| className="flex-1 w-full relative" | |
| style={{ minHeight: '400px' }} | |
| > | |
| {/* 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 ( | |
| <div | |
| key={userId} | |
| className="fixed pointer-events-none z-40" | |
| style={{ | |
| left: vx, | |
| top: vy, | |
| transform: 'translate(-50%, -50%)', | |
| }} | |
| > | |
| <div | |
| className="flex flex-col items-center" | |
| style={{ color: getColor(userId) }} | |
| > | |
| <MousePointer2 className="w-6 h-6 drop-shadow-lg" /> | |
| <span className="text-[9px] font-medium bg-surface-900 px-1 rounded"> | |
| {pointer.username} | |
| </span> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| {/* 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 ( | |
| <div | |
| key={userId} | |
| className="fixed pointer-events-none z-40 opacity-60" | |
| style={{ | |
| left: vx, | |
| top: vy, | |
| transform: 'translate(-50%, -50%)', | |
| color: getColor(userId), | |
| }} | |
| > | |
| <div className="flex items-center gap-1 bg-surface-900/80 px-2 py-1 rounded border" | |
| style={{ borderColor: getColor(userId) }}> | |
| <MousePointer2 className="w-3 h-3" /> | |
| <span className="text-[10px] whitespace-nowrap">{label}</span> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {/* Edit modal for variable/function blocks */} | |
| {editingModal && ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" | |
| onClick={() => setEditingModal(null)}> | |
| <div className="bg-surface-900 border border-surface-700 rounded-lg shadow-xl p-5 w-full max-w-sm" | |
| onClick={(e) => e.stopPropagation()}> | |
| <h3 className="text-sm font-semibold text-surface-200 mb-4"> | |
| {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'} | |
| </h3> | |
| <div className="space-y-3"> | |
| <div> | |
| <label className="block text-xs text-surface-400 mb-1">Name</label> | |
| <input | |
| type="text" | |
| className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1.5 text-sm text-surface-200" | |
| value={editFormValues.name ?? ''} | |
| onChange={(e) => setEditFormValues(v => ({ ...v, name: e.target.value }))} | |
| placeholder="myVar" | |
| /> | |
| </div> | |
| {(editingModal.blockType === 'new_variable' || editingModal.blockType === 'rb_js_var') && ( | |
| <div> | |
| <label className="block text-xs text-surface-400 mb-1">Type</label> | |
| <select | |
| className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1.5 text-sm text-surface-200" | |
| value={editFormValues.varType ?? 'any'} | |
| onChange={(e) => setEditFormValues(v => ({ ...v, varType: e.target.value }))} | |
| > | |
| {VAR_TYPES.map(t => ( | |
| <option key={t.value} value={t.value}>{t.label}</option> | |
| ))} | |
| </select> | |
| </div> | |
| )} | |
| {(editingModal.blockType === 'new_function' || editingModal.blockType === 'rb_js_function') && ( | |
| <div> | |
| <label className="block text-xs text-surface-400 mb-1">Parameters (comma-separated)</label> | |
| <input | |
| type="text" | |
| className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1.5 text-sm text-surface-200" | |
| value={editFormValues.params ?? ''} | |
| onChange={(e) => setEditFormValues(v => ({ ...v, params: e.target.value }))} | |
| placeholder="a, b" | |
| /> | |
| </div> | |
| )} | |
| {(editingModal.blockType === 'new_function' || editingModal.blockType === 'rb_js_function') && ( | |
| <div className="flex items-center gap-2"> | |
| <input | |
| type="checkbox" | |
| id="asyncFn" | |
| className="rounded" | |
| checked={editFormValues.asyncFn === 'TRUE' || editFormValues.asyncFn === 'true'} | |
| onChange={(e) => setEditFormValues(v => ({ ...v, asyncFn: e.target.checked ? 'TRUE' : 'FALSE' }))} | |
| /> | |
| <label htmlFor="asyncFn" className="text-xs text-surface-400">Async function (use await inside)</label> | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex justify-end gap-2 mt-5"> | |
| <button | |
| className="px-3 py-1.5 text-xs text-surface-400 bg-surface-800 rounded hover:bg-surface-700" | |
| onClick={() => setEditingModal(null)} | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| className="px-3 py-1.5 text-xs text-white bg-indigo-600 rounded hover:bg-indigo-500" | |
| onClick={isNewItem ? saveNewItemFromModal : saveEditModal} | |
| > | |
| {isNewItem ? 'Create' : 'Save'} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Build the Blockly toolbox from our category structure | |
| function buildToolbox(blocksByCategory: Map<string, BlockDefinition[]>): 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, | |
| }; | |
| } | |