RealBlocks / client /src /components /BlockEditor /BlockEditor.tsx
SafeSight's picture
Update BlockEditor.tsx
2b15bea
Raw
History Blame Contribute Delete
55 kB
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,
};
}