Files
OpenNoodl/dev-docs/reference/LEARNINGS-BLOCKLY.md
2026-01-12 13:23:12 +01:00

15 KiB

Blockly Integration Learnings

Created: 2026-01-12
Source: TASK-012 Blockly Logic Builder Integration
Context: Building a visual programming interface with Google Blockly in OpenNoodl

Overview

This document captures critical learnings from integrating Google Blockly into OpenNoodl to create the Logic Builder node. These patterns are essential for anyone working with Blockly or integrating visual programming tools into the editor.

Critical Architecture Patterns

1. Editor/Runtime Window Separation 🔴 CRITICAL

The Problem:

The OpenNoodl editor and runtime run in COMPLETELY SEPARATE JavaScript contexts (different windows/iframes). This is easy to forget and causes mysterious bugs.

What Breaks:

// ❌ BROKEN - In runtime, trying to access editor objects
function updatePorts(nodeId, workspace, editorConnection) {
  // This looks reasonable but FAILS silently
  const graphModel = getGraphModel(); // Doesn't exist in runtime!
  const node = graphModel.getNodeWithId(nodeId); // Crashes here
  const code = node.parameters.generatedCode;
}

The Fix:

// ✅ WORKING - Pass data explicitly as parameters
function updatePorts(nodeId, workspace, generatedCode, editorConnection) {
  // generatedCode passed directly - no cross-window access needed
  const detected = parseCode(generatedCode);
  editorConnection.sendDynamicPorts(nodeId, detected.ports);
}

// In editor: Pass the data explicitly
updatePorts(node.id, node.parameters.workspace, node.parameters.generatedCode, connection);

Key Principle:

NEVER assume editor objects/methods are available in runtime. ALWAYS pass data explicitly through function parameters or event payloads.

Applies To:

  • Any dynamic port detection
  • Code generation systems
  • Parameter passing between editor and runtime
  • Event payloads between windows

2. Function Execution Context 🔴 CRITICAL

The Problem:

Using new Function(code).call(context) doesn't work as expected. The generated code can't access variables via this.

What Breaks:

// ❌ BROKEN - Generated code can't access Outputs
const fn = new Function(code); // Code contains: Outputs["result"] = 'test';
fn.call(context); // context has Outputs property

// Result: ReferenceError: Outputs is not defined

The Fix:

// ✅ WORKING - Pass context as function parameters
const fn = new Function(
  'Inputs', // Parameter names
  'Outputs',
  'Noodl',
  'Variables',
  'Objects',
  'Arrays',
  'sendSignalOnOutput',
  code // Function body
);

// Call with actual values as arguments
fn(
  context.Inputs,
  context.Outputs,
  context.Noodl,
  context.Variables,
  context.Objects,
  context.Arrays,
  context.sendSignalOnOutput
);

Why This Works:

The function parameters create a proper lexical scope where the generated code can access variables by name.

Code Generator Pattern:

// When generating code, reference parameters directly
javascriptGenerator.forBlock['noodl_set_output'] = function (block) {
  const name = block.getFieldValue('NAME');
  const value = javascriptGenerator.valueToCode(block, 'VALUE', Order.ASSIGNMENT);

  // Generated code uses parameter name directly
  return `Outputs["${name}"] = ${value};\n`;
};

Key Principle:

ALWAYS pass execution context as function parameters. NEVER rely on this or .call() for context in dynamically compiled code.


3. Blockly v10+ API Compatibility 🟡 IMPORTANT

The Problem:

Blockly v10+ uses a completely different API from older versions. Documentation and examples online are often outdated.

What Breaks:

// ❌ BROKEN - Old API (pre-v10)
import * as Blockly from 'blockly';

import 'blockly/javascript';

// These don't exist in v10+:
Blockly.JavaScript.ORDER_MEMBER;
Blockly.JavaScript.ORDER_ASSIGNMENT;
Blockly.JavaScript.workspaceToCode(workspace);

The Fix:

// ✅ WORKING - Modern v10+ API
import * as Blockly from 'blockly';
import { javascriptGenerator, Order } from 'blockly/javascript';

// Use named exports
Order.MEMBER;
Order.ASSIGNMENT;
javascriptGenerator.workspaceToCode(workspace);

Complete Migration Guide:

Old API (pre-v10) New API (v10+)
Blockly.JavaScript.ORDER_* Order.* from blockly/javascript
Blockly.JavaScript['block_type'] javascriptGenerator.forBlock['block_type']
Blockly.JavaScript.workspaceToCode() javascriptGenerator.workspaceToCode()
Blockly.JavaScript.valueToCode() javascriptGenerator.valueToCode()

Key Principle:

ALWAYS use named imports from blockly/javascript. Check Blockly version first before following online examples.


4. Z-Index Layering (React + Legacy Canvas) 🟡 IMPORTANT

The Problem:

React overlays on legacy jQuery/canvas systems can be invisible if z-index isn't explicitly set.

What Breaks:

<!-- ❌ BROKEN - Tabs invisible behind canvas -->
<div id="canvas-tabs-root" style="width: 100%; height: 100%">
  <div class="tabs">...</div>
</div>
<canvas id="canvas" style="position: absolute; top: 0; left: 0">
  <!-- Canvas renders ON TOP of tabs! -->
</canvas>

The Fix:

<!-- ✅ WORKING - Explicit z-index layering -->
<div id="canvas-tabs-root" style="position: absolute; z-index: 100; pointer-events: none">
  <div class="tabs" style="pointer-events: all">
    <!-- Tabs visible and clickable -->
  </div>
</div>
<canvas id="canvas" style="position: absolute; top: 0; left: 0">
  <!-- Canvas in background -->
</canvas>

Pointer Events Strategy:

  1. Container: pointer-events: none (transparent to clicks)
  2. Content: pointer-events: all (captures clicks)
  3. Result: Canvas clickable when no tabs, tabs clickable when present

CSS Pattern:

#canvas-tabs-root {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 100; // Above canvas
  pointer-events: none; // Transparent when empty
}

.CanvasTabs {
  pointer-events: all; // Clickable when rendered
}

Key Principle:

In mixed legacy/React systems, ALWAYS set explicit position and z-index. Use pointer-events to manage click-through behavior.


Blockly-Specific Patterns

Block Registration

Must Call Before Workspace Creation:

// ❌ WRONG - Blocks never registered
useEffect(() => {
  const workspace = Blockly.inject(...); // Fails - blocks don't exist yet
}, []);

// ✅ CORRECT - Register first, then inject
useEffect(() => {
  initBlocklyIntegration(); // Registers custom blocks
  const workspace = Blockly.inject(...); // Now blocks exist
}, []);

Initialization Guard Pattern:

let blocklyInitialized = false;

export function initBlocklyIntegration() {
  if (blocklyInitialized) return; // Safe to call multiple times

  // Register blocks
  Blockly.Blocks['my_block'] = {...};
  javascriptGenerator.forBlock['my_block'] = function(block) {...};

  blocklyInitialized = true;
}

Toolbox Configuration

Categories Must Reference Registered Blocks:

function getDefaultToolbox() {
  return {
    kind: 'categoryToolbox',
    contents: [
      {
        kind: 'category',
        name: 'My Blocks',
        colour: 230,
        contents: [
          { kind: 'block', type: 'my_block' } // Must match Blockly.Blocks key
        ]
      }
    ]
  };
}

Workspace Persistence

Save/Load Pattern:

// Save to JSON
const json = Blockly.serialization.workspaces.save(workspace);
const workspaceStr = JSON.stringify(json);
onSave(workspaceStr);

// Load from JSON
const json = JSON.parse(workspaceStr);
Blockly.serialization.workspaces.load(json, workspace);

Code Generation Pattern

Block Definition:

Blockly.Blocks['noodl_set_output'] = {
  init: function () {
    this.appendValueInput('VALUE')
      .setCheck(null)
      .appendField('set output')
      .appendField(new Blockly.FieldTextInput('result'), 'NAME');
    this.setPreviousStatement(true, null);
    this.setNextStatement(true, null);
    this.setColour(230);
  }
};

Code Generator:

javascriptGenerator.forBlock['noodl_set_output'] = function (block, generator) {
  const name = block.getFieldValue('NAME');
  const value = generator.valueToCode(block, 'VALUE', Order.ASSIGNMENT) || '""';

  // Return JavaScript code
  return `Outputs["${name}"] = ${value};\n`;
};

Dynamic Port Detection

Regex Parsing (MVP Pattern)

For MVP, simple regex parsing is sufficient:

function detectOutputPorts(generatedCode) {
  const outputs = [];
  const regex = /Outputs\["([^"]+)"\]/g;
  let match;

  while ((match = regex.exec(generatedCode)) !== null) {
    const name = match[1];
    if (!outputs.find((o) => o.name === name)) {
      outputs.push({ name, type: '*' });
    }
  }

  return outputs;
}

When To Use:

  • MVP/prototypes
  • Simple output detection
  • Known code patterns

When To Upgrade:

  • Need input detection
  • Signal detection
  • Complex expressions
  • AST-based analysis needed

AST Parsing (Future Pattern)

For production, use proper AST parsing:

import * as acorn from 'acorn';

function detectPorts(code) {
  const ast = acorn.parse(code, { ecmaVersion: 2020 });
  const detected = { inputs: [], outputs: [], signals: [] };

  // Walk AST and detect patterns
  walk(ast, {
    MemberExpression(node) {
      if (node.object.name === 'Outputs') {
        detected.outputs.push(node.property.value);
      }
    }
  });

  return detected;
}

Event Coordination Patterns

Editor → Runtime Communication

Use Event Payloads:

// Editor side
EventDispatcher.instance.notifyListeners('LogicBuilder.Updated', {
  nodeId: node.id,
  workspace: workspaceJSON,
  generatedCode: code // Send all needed data
});

// Runtime side
graphModel.on('parameterUpdated', function (event) {
  if (event.name === 'generatedCode') {
    const code = node.parameters.generatedCode; // Now available
    updatePorts(node.id, workspace, code, editorConnection);
  }
});

Canvas Visibility Coordination

EventDispatcher Pattern:

// When Logic Builder tab opens
EventDispatcher.instance.notifyListeners('LogicBuilder.TabOpened');

// Canvas hides itself
EventDispatcher.instance.on('LogicBuilder.TabOpened', () => {
  setCanvasVisibility(false);
});

// When all tabs closed
EventDispatcher.instance.notifyListeners('LogicBuilder.AllTabsClosed');

// Canvas shows itself
EventDispatcher.instance.on('LogicBuilder.AllTabsClosed', () => {
  setCanvasVisibility(true);
});

Common Pitfalls

Don't: Wrap Legacy in React

// ❌ WRONG - Trying to render canvas in React
function CanvasTabs() {
  return (
    <div>
      <div id="canvas-container">{/* Can't put canvas here - it's rendered by vanilla JS */}</div>
      <LogicBuilderTab />
    </div>
  );
}

Do: Separate Concerns

// ✅ CORRECT - Canvas and React separate
// Canvas always rendered by vanilla JS
// React tabs overlay when needed

function CanvasTabs() {
  return tabs.length > 0 ? (
    <div className="overlay">
      {tabs.map((tab) => (
        <Tab key={tab.id} {...tab} />
      ))}
    </div>
  ) : null;
}

Don't: Assume Shared Context

// ❌ WRONG - Accessing editor from runtime
function runtimeFunction() {
  const model = ProjectModel.instance; // Doesn't exist in runtime!
  const node = model.getNode(nodeId);
}

Do: Pass Data Explicitly

// ✅ CORRECT - Data passed as parameters
function runtimeFunction(nodeId, data, connection) {
  // All data provided explicitly
  processData(data);
  connection.sendResult(nodeId, result);
}

Testing Strategies

Manual Testing Checklist

  • Blocks appear in toolbox
  • Blocks draggable onto workspace
  • Workspace saves correctly
  • Code generation works
  • Dynamic ports appear
  • Execution triggers
  • Output values flow
  • Tabs manageable (open/close)
  • Canvas switching works
  • Z-index layering correct

Debug Logging Pattern

// Temporary debug logs (remove before production)
console.log('[BlocklyWorkspace] Code generated:', code.substring(0, 100));
console.log('[Logic Builder] Detected ports:', detectedPorts);
console.log('[Runtime] Execution context:', Object.keys(context));

Remove or gate behind flag:

const DEBUG = false; // Set via environment variable

if (DEBUG) {
  console.log('[Debug] Important info:', data);
}

Performance Considerations

Blockly Workspace Size

  • Small projects (<50 blocks): No issues
  • Medium (50-200 blocks): Slight lag on load
  • Large (>200 blocks): Consider workspace pagination

Code Generation

  • Generated code is cached (only regenerates on change)
  • Regex parsing is O(n) where n = code length (fast enough)
  • AST parsing is slower but more accurate

React Re-renders

// Memoize expensive operations
const toolbox = useMemo(() => getDefaultToolbox(), []);
const workspace = useMemo(() => createWorkspace(toolbox), [toolbox]);

Future Enhancements

Input Port Detection

// Detect: Inputs["myInput"]
const inputRegex = /Inputs\["([^"]+)"\]/g;

Signal Output Detection

// Detect: sendSignalOnOutput("mySignal")
const signalRegex = /sendSignalOnOutput\s*\(\s*["']([^"']+)["']\s*\)/g;

Block Marketplace

  • User-contributed blocks
  • Import/export block definitions
  • Block versioning system

Visual Debugging

  • Step through blocks execution
  • Variable inspection
  • Breakpoints in visual logic

Key Takeaways

  1. Editor and runtime are SEPARATE windows - never forget this
  2. Pass context as function parameters - not via this
  3. Use Blockly v10+ API - check imports carefully
  4. Set explicit z-index - don't rely on DOM order
  5. Keep legacy and React separate - coordinate via events
  6. Initialize blocks before workspace - order matters
  7. Test with real user flow - early and often
  8. Document discoveries immediately - while context is fresh

References


Last Updated: 2026-01-12
Maintainer: Development Team
Status: Production-Ready Patterns