feat(blockly): Phase A foundation - Blockly setup, custom blocks, and generators

- Install blockly package (~500KB)
- Create BlocklyWorkspace React component with serialization
- Define custom Noodl blocks (Input/Output, Variables, Objects, Arrays)
- Implement JavaScript code generators for all custom blocks
- Add theme-aware styling for Blockly workspace
- Export initialization functions for easy integration

Part of TASK-012: Blockly Visual Logic Integration
This commit is contained in:
Richard Osborne
2026-01-11 13:30:13 +01:00
parent 6f08163590
commit 554dd9f3b4
21 changed files with 4670 additions and 83 deletions

View File

@@ -74,6 +74,7 @@
"algoliasearch": "^5.35.0",
"archiver": "^5.3.2",
"async": "^3.2.6",
"blockly": "^12.3.1",
"classnames": "^2.5.1",
"dagre": "^0.8.5",
"diff3": "0.0.4",

View File

@@ -232,8 +232,8 @@
}
:root {
--popup-layer-tooltip-border-color: var(--theme-color-secondary);
--popup-layer-tooltip-background-color: var(--theme-color-secondary);
--popup-layer-tooltip-border-color: var(--theme-color-border-default);
--popup-layer-tooltip-background-color: var(--theme-color-bg-3);
}
.popup-layer-tooltip {
@@ -244,7 +244,7 @@
border-color: var(--popup-layer-tooltip-border-color);
border-width: 1px;
padding: 12px 16px;
color: var(--theme-color-fg-highlight);
color: var(--theme-color-fg-default);
position: absolute;
opacity: 0;
-webkit-transition: opacity 0.3s;

View File

@@ -0,0 +1,121 @@
/**
* BlocklyWorkspace Styles
*
* Styling for the Blockly visual programming workspace.
* Uses theme tokens for consistent integration with Noodl editor.
*/
.Root {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background-color: var(--theme-color-bg-1);
overflow: hidden;
}
.BlocklyContainer {
flex: 1;
width: 100%;
height: 100%;
position: relative;
/* Ensure Blockly SVG fills container */
& > .injectionDiv {
width: 100% !important;
height: 100% !important;
}
}
/* Override Blockly default styles to match Noodl theme */
:global {
/* Toolbox styling */
.blocklyToolboxDiv {
background-color: var(--theme-color-bg-2) !important;
border-right: 1px solid var(--theme-color-border-default) !important;
}
.blocklyTreeLabel {
color: var(--theme-color-fg-default) !important;
font-family: var(--theme-font-family) !important;
font-size: 13px !important;
}
.blocklyTreeRow:hover {
background-color: var(--theme-color-bg-3) !important;
}
.blocklyTreeSelected {
background-color: var(--theme-color-primary) !important;
}
/* Flyout styling */
.blocklyFlyoutBackground {
fill: var(--theme-color-bg-2) !important;
fill-opacity: 0.95 !important;
}
/* Block styling - keep default Blockly colors for now */
/* May customize later to match Noodl node colors */
/* Zoom controls */
.blocklyZoom {
& image {
filter: brightness(0.8);
}
}
/* Trashcan */
.blocklyTrash {
& image {
filter: brightness(0.8);
}
}
/* Context menu */
.blocklyContextMenu {
background-color: var(--theme-color-bg-3) !important;
border: 1px solid var(--theme-color-border-default) !important;
border-radius: 4px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
}
.blocklyMenuItem {
color: var(--theme-color-fg-default) !important;
font-family: var(--theme-font-family) !important;
font-size: 13px !important;
padding: 6px 12px !important;
&:hover {
background-color: var(--theme-color-bg-4) !important;
}
&.blocklyMenuItemDisabled {
color: var(--theme-color-fg-default-shy) !important;
opacity: 0.5;
}
}
/* Scrollbars */
.blocklyScrollbarHandle {
fill: var(--theme-color-border-default) !important;
}
/* Field editor backgrounds */
.blocklyWidgetDiv {
& .goog-menu {
background-color: var(--theme-color-bg-3) !important;
border: 1px solid var(--theme-color-border-default) !important;
border-radius: 4px !important;
}
& .goog-menuitem {
color: var(--theme-color-fg-default) !important;
font-family: var(--theme-font-family) !important;
&:hover {
background-color: var(--theme-color-bg-4) !important;
}
}
}
}

View File

@@ -0,0 +1,173 @@
/**
* BlocklyWorkspace Component
*
* React wrapper for Google Blockly visual programming workspace.
* Provides integration with Noodl's node system for visual logic building.
*
* @module BlocklyEditor
*/
import * as Blockly from 'blockly';
import React, { useEffect, useRef, useState } from 'react';
import css from './BlocklyWorkspace.module.scss';
export interface BlocklyWorkspaceProps {
/** Initial workspace JSON (for loading saved state) */
initialWorkspace?: string;
/** Toolbox configuration */
toolbox?: Blockly.utils.toolbox.ToolboxDefinition;
/** Callback when workspace changes */
onChange?: (workspace: Blockly.WorkspaceSvg, json: string, code: string) => void;
/** Read-only mode */
readOnly?: boolean;
/** Custom theme */
theme?: Blockly.Theme;
}
/**
* BlocklyWorkspace - React component for Blockly integration
*
* Handles:
* - Blockly workspace initialization
* - Workspace persistence (save/load)
* - Change detection and callbacks
* - Cleanup on unmount
*/
export function BlocklyWorkspace({
initialWorkspace,
toolbox,
onChange,
readOnly = false,
theme
}: BlocklyWorkspaceProps) {
const blocklyDiv = useRef<HTMLDivElement>(null);
const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
// Initialize Blockly workspace
useEffect(() => {
if (!blocklyDiv.current) return;
console.log('🔧 [Blockly] Initializing workspace');
// Inject Blockly
const workspace = Blockly.inject(blocklyDiv.current, {
toolbox: toolbox || getDefaultToolbox(),
theme: theme,
readOnly: readOnly,
trashcan: true,
zoom: {
controls: true,
wheel: true,
startScale: 1.0,
maxScale: 3,
minScale: 0.3,
scaleSpeed: 1.2
},
grid: {
spacing: 20,
length: 3,
colour: '#ccc',
snap: true
}
});
workspaceRef.current = workspace;
// Load initial workspace if provided
if (initialWorkspace) {
try {
const json = JSON.parse(initialWorkspace);
Blockly.serialization.workspaces.load(json, workspace);
console.log('✅ [Blockly] Loaded initial workspace');
} catch (error) {
console.error('❌ [Blockly] Failed to load initial workspace:', error);
}
}
setIsInitialized(true);
// Listen for changes
const changeListener = () => {
if (onChange && workspace) {
const json = JSON.stringify(Blockly.serialization.workspaces.save(workspace));
const code = Blockly.JavaScript.workspaceToCode(workspace);
onChange(workspace, json, code);
}
};
workspace.addChangeListener(changeListener);
// Cleanup
return () => {
console.log('🧹 [Blockly] Disposing workspace');
workspace.removeChangeListener(changeListener);
workspace.dispose();
workspaceRef.current = null;
setIsInitialized(false);
};
}, [toolbox, theme, readOnly]);
// Handle initial workspace separately to avoid re-initialization
useEffect(() => {
if (isInitialized && initialWorkspace && workspaceRef.current) {
try {
const json = JSON.parse(initialWorkspace);
Blockly.serialization.workspaces.load(json, workspaceRef.current);
} catch (error) {
console.error('❌ [Blockly] Failed to update workspace:', error);
}
}
}, [initialWorkspace]);
return (
<div className={css.Root}>
<div ref={blocklyDiv} className={css.BlocklyContainer} />
</div>
);
}
/**
* Default toolbox with standard Blockly blocks
* This will be replaced with Noodl-specific toolbox
*/
function getDefaultToolbox(): Blockly.utils.toolbox.ToolboxDefinition {
return {
kind: 'categoryToolbox',
contents: [
{
kind: 'category',
name: 'Logic',
colour: '210',
contents: [
{ kind: 'block', type: 'controls_if' },
{ kind: 'block', type: 'logic_compare' },
{ kind: 'block', type: 'logic_operation' },
{ kind: 'block', type: 'logic_negate' },
{ kind: 'block', type: 'logic_boolean' }
]
},
{
kind: 'category',
name: 'Math',
colour: '230',
contents: [
{ kind: 'block', type: 'math_number' },
{ kind: 'block', type: 'math_arithmetic' },
{ kind: 'block', type: 'math_single' }
]
},
{
kind: 'category',
name: 'Text',
colour: '160',
contents: [
{ kind: 'block', type: 'text' },
{ kind: 'block', type: 'text_join' },
{ kind: 'block', type: 'text_length' }
]
}
]
};
}

View File

@@ -0,0 +1,283 @@
/**
* Noodl Custom Blocks for Blockly
*
* Defines custom blocks for Noodl-specific functionality:
* - Inputs/Outputs (node I/O)
* - Variables (Noodl.Variables)
* - Objects (Noodl.Objects)
* - Arrays (Noodl.Arrays)
* - Events/Signals
*
* @module BlocklyEditor
*/
import * as Blockly from 'blockly';
/**
* Initialize all Noodl custom blocks
*/
export function initNoodlBlocks() {
console.log('🔧 [Blockly] Initializing Noodl custom blocks');
// Input/Output blocks
defineInputOutputBlocks();
// Variable blocks
defineVariableBlocks();
// Object blocks (basic - will expand later)
defineObjectBlocks();
// Array blocks (basic - will expand later)
defineArrayBlocks();
console.log('✅ [Blockly] Noodl blocks initialized');
}
/**
* Input/Output Blocks
*/
function defineInputOutputBlocks() {
// Define Input block - declares an input port
Blockly.Blocks['noodl_define_input'] = {
init: function () {
this.appendDummyInput()
.appendField('📥 Define input')
.appendField(new Blockly.FieldTextInput('myInput'), 'NAME')
.appendField('type')
.appendField(
new Blockly.FieldDropdown([
['any', '*'],
['string', 'string'],
['number', 'number'],
['boolean', 'boolean'],
['object', 'object'],
['array', 'array']
]),
'TYPE'
);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(230);
this.setTooltip('Defines an input port that appears on the node');
this.setHelpUrl('');
}
};
// Get Input block - gets value from an input
Blockly.Blocks['noodl_get_input'] = {
init: function () {
this.appendDummyInput().appendField('📥 get input').appendField(new Blockly.FieldTextInput('value'), 'NAME');
this.setOutput(true, null);
this.setColour(230);
this.setTooltip('Gets the value from an input port');
this.setHelpUrl('');
}
};
// Define Output block - declares an output port
Blockly.Blocks['noodl_define_output'] = {
init: function () {
this.appendDummyInput()
.appendField('📤 Define output')
.appendField(new Blockly.FieldTextInput('result'), 'NAME')
.appendField('type')
.appendField(
new Blockly.FieldDropdown([
['any', '*'],
['string', 'string'],
['number', 'number'],
['boolean', 'boolean'],
['object', 'object'],
['array', 'array']
]),
'TYPE'
);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(230);
this.setTooltip('Defines an output port that appears on the node');
this.setHelpUrl('');
}
};
// Set Output block - sets value on an output
Blockly.Blocks['noodl_set_output'] = {
init: function () {
this.appendValueInput('VALUE')
.setCheck(null)
.appendField('📤 set output')
.appendField(new Blockly.FieldTextInput('result'), 'NAME')
.appendField('to');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(230);
this.setTooltip('Sets the value of an output port');
this.setHelpUrl('');
}
};
// Define Signal Input block
Blockly.Blocks['noodl_define_signal_input'] = {
init: function () {
this.appendDummyInput()
.appendField('⚡ Define signal input')
.appendField(new Blockly.FieldTextInput('trigger'), 'NAME');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(180);
this.setTooltip('Defines a signal input that can trigger logic');
this.setHelpUrl('');
}
};
// Define Signal Output block
Blockly.Blocks['noodl_define_signal_output'] = {
init: function () {
this.appendDummyInput()
.appendField('⚡ Define signal output')
.appendField(new Blockly.FieldTextInput('done'), 'NAME');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(180);
this.setTooltip('Defines a signal output that can trigger other nodes');
this.setHelpUrl('');
}
};
// Send Signal block
Blockly.Blocks['noodl_send_signal'] = {
init: function () {
this.appendDummyInput().appendField('⚡ send signal').appendField(new Blockly.FieldTextInput('done'), 'NAME');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(180);
this.setTooltip('Sends a signal to connected nodes');
this.setHelpUrl('');
}
};
}
/**
* Variable Blocks
*/
function defineVariableBlocks() {
// Get Variable block
Blockly.Blocks['noodl_get_variable'] = {
init: function () {
this.appendDummyInput()
.appendField('📖 get variable')
.appendField(new Blockly.FieldTextInput('myVariable'), 'NAME');
this.setOutput(true, null);
this.setColour(330);
this.setTooltip('Gets the value of a global Noodl variable');
this.setHelpUrl('');
}
};
// Set Variable block
Blockly.Blocks['noodl_set_variable'] = {
init: function () {
this.appendValueInput('VALUE')
.setCheck(null)
.appendField('✏️ set variable')
.appendField(new Blockly.FieldTextInput('myVariable'), 'NAME')
.appendField('to');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(330);
this.setTooltip('Sets the value of a global Noodl variable');
this.setHelpUrl('');
}
};
}
/**
* Object Blocks (basic set - will expand in Phase E)
*/
function defineObjectBlocks() {
// Get Object block
Blockly.Blocks['noodl_get_object'] = {
init: function () {
this.appendValueInput('ID').setCheck('String').appendField('📦 get object');
this.setOutput(true, 'Object');
this.setColour(20);
this.setTooltip('Gets a Noodl Object by its ID');
this.setHelpUrl('');
}
};
// Get Object Property block
Blockly.Blocks['noodl_get_object_property'] = {
init: function () {
this.appendValueInput('OBJECT')
.setCheck(null)
.appendField('📖 get')
.appendField(new Blockly.FieldTextInput('name'), 'PROPERTY')
.appendField('from object');
this.setOutput(true, null);
this.setColour(20);
this.setTooltip('Gets a property value from an object');
this.setHelpUrl('');
}
};
// Set Object Property block
Blockly.Blocks['noodl_set_object_property'] = {
init: function () {
this.appendValueInput('OBJECT')
.setCheck(null)
.appendField('✏️ set')
.appendField(new Blockly.FieldTextInput('name'), 'PROPERTY')
.appendField('on object');
this.appendValueInput('VALUE').setCheck(null).appendField('to');
this.setInputsInline(false);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(20);
this.setTooltip('Sets a property value on an object');
this.setHelpUrl('');
}
};
}
/**
* Array Blocks (basic set - will expand in Phase E)
*/
function defineArrayBlocks() {
// Get Array block
Blockly.Blocks['noodl_get_array'] = {
init: function () {
this.appendDummyInput().appendField('📋 get array').appendField(new Blockly.FieldTextInput('myArray'), 'NAME');
this.setOutput(true, 'Array');
this.setColour(260);
this.setTooltip('Gets a Noodl Array by name');
this.setHelpUrl('');
}
};
// Array Length block
Blockly.Blocks['noodl_array_length'] = {
init: function () {
this.appendValueInput('ARRAY').setCheck('Array').appendField('🔢 length of array');
this.setOutput(true, 'Number');
this.setColour(260);
this.setTooltip('Gets the number of items in an array');
this.setHelpUrl('');
}
};
// Array Add block
Blockly.Blocks['noodl_array_add'] = {
init: function () {
this.appendValueInput('ITEM').setCheck(null).appendField(' add');
this.appendValueInput('ARRAY').setCheck('Array').appendField('to array');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(260);
this.setTooltip('Adds an item to the end of an array');
this.setHelpUrl('');
}
};
}

View File

@@ -0,0 +1,165 @@
/**
* Noodl Code Generators for Blockly
*
* Converts Blockly blocks into executable JavaScript code for the Noodl runtime.
* Generated code has access to:
* - Inputs: Input values from connections
* - Outputs: Output values to connections
* - Noodl.Variables: Global variables
* - Noodl.Objects: Global objects
* - Noodl.Arrays: Global arrays
*
* @module BlocklyEditor
*/
import * as Blockly from 'blockly';
import { javascriptGenerator } from 'blockly/javascript';
/**
* Initialize all Noodl code generators
*/
export function initNoodlGenerators() {
console.log('🔧 [Blockly] Initializing Noodl code generators');
// Input/Output generators
initInputOutputGenerators();
// Variable generators
initVariableGenerators();
// Object generators
initObjectGenerators();
// Array generators
initArrayGenerators();
console.log('✅ [Blockly] Noodl generators initialized');
}
/**
* Input/Output Generators
*/
function initInputOutputGenerators() {
// Define Input - no runtime code (used for I/O detection only)
javascriptGenerator.forBlock['noodl_define_input'] = function () {
return '';
};
// Get Input - generates: Inputs["name"]
javascriptGenerator.forBlock['noodl_get_input'] = function (block) {
const name = block.getFieldValue('NAME');
const code = `Inputs["${name}"]`;
return [code, Blockly.JavaScript.ORDER_MEMBER];
};
// Define Output - no runtime code (used for I/O detection only)
javascriptGenerator.forBlock['noodl_define_output'] = function () {
return '';
};
// Set Output - generates: Outputs["name"] = value;
javascriptGenerator.forBlock['noodl_set_output'] = function (block) {
const name = block.getFieldValue('NAME');
const value = javascriptGenerator.valueToCode(block, 'VALUE', Blockly.JavaScript.ORDER_ASSIGNMENT) || 'null';
return `Outputs["${name}"] = ${value};\n`;
};
// Define Signal Input - no runtime code
javascriptGenerator.forBlock['noodl_define_signal_input'] = function () {
return '';
};
// Define Signal Output - no runtime code
javascriptGenerator.forBlock['noodl_define_signal_output'] = function () {
return '';
};
// Send Signal - generates: this.sendSignalOnOutput("name");
javascriptGenerator.forBlock['noodl_send_signal'] = function (block) {
const name = block.getFieldValue('NAME');
return `this.sendSignalOnOutput("${name}");\n`;
};
}
/**
* Variable Generators
*/
function initVariableGenerators() {
// Get Variable - generates: Noodl.Variables["name"]
javascriptGenerator.forBlock['noodl_get_variable'] = function (block) {
const name = block.getFieldValue('NAME');
const code = `Noodl.Variables["${name}"]`;
return [code, Blockly.JavaScript.ORDER_MEMBER];
};
// Set Variable - generates: Noodl.Variables["name"] = value;
javascriptGenerator.forBlock['noodl_set_variable'] = function (block) {
const name = block.getFieldValue('NAME');
const value = javascriptGenerator.valueToCode(block, 'VALUE', Blockly.JavaScript.ORDER_ASSIGNMENT) || 'null';
return `Noodl.Variables["${name}"] = ${value};\n`;
};
}
/**
* Object Generators
*/
function initObjectGenerators() {
// Get Object - generates: Noodl.Objects[id]
javascriptGenerator.forBlock['noodl_get_object'] = function (block) {
const id = javascriptGenerator.valueToCode(block, 'ID', Blockly.JavaScript.ORDER_NONE) || '""';
const code = `Noodl.Objects[${id}]`;
return [code, Blockly.JavaScript.ORDER_MEMBER];
};
// Get Object Property - generates: object["property"]
javascriptGenerator.forBlock['noodl_get_object_property'] = function (block) {
const property = block.getFieldValue('PROPERTY');
const object = javascriptGenerator.valueToCode(block, 'OBJECT', Blockly.JavaScript.ORDER_MEMBER) || '{}';
const code = `${object}["${property}"]`;
return [code, Blockly.JavaScript.ORDER_MEMBER];
};
// Set Object Property - generates: object["property"] = value;
javascriptGenerator.forBlock['noodl_set_object_property'] = function (block) {
const property = block.getFieldValue('PROPERTY');
const object = javascriptGenerator.valueToCode(block, 'OBJECT', Blockly.JavaScript.ORDER_MEMBER) || '{}';
const value = javascriptGenerator.valueToCode(block, 'VALUE', Blockly.JavaScript.ORDER_ASSIGNMENT) || 'null';
return `${object}["${property}"] = ${value};\n`;
};
}
/**
* Array Generators
*/
function initArrayGenerators() {
// Get Array - generates: Noodl.Arrays["name"]
javascriptGenerator.forBlock['noodl_get_array'] = function (block) {
const name = block.getFieldValue('NAME');
const code = `Noodl.Arrays["${name}"]`;
return [code, Blockly.JavaScript.ORDER_MEMBER];
};
// Array Length - generates: array.length
javascriptGenerator.forBlock['noodl_array_length'] = function (block) {
const array = javascriptGenerator.valueToCode(block, 'ARRAY', Blockly.JavaScript.ORDER_MEMBER) || '[]';
const code = `${array}.length`;
return [code, Blockly.JavaScript.ORDER_MEMBER];
};
// Array Add - generates: array.push(item);
javascriptGenerator.forBlock['noodl_array_add'] = function (block) {
const item = javascriptGenerator.valueToCode(block, 'ITEM', Blockly.JavaScript.ORDER_NONE) || 'null';
const array = javascriptGenerator.valueToCode(block, 'ARRAY', Blockly.JavaScript.ORDER_MEMBER) || '[]';
return `${array}.push(${item});\n`;
};
}
/**
* Generate complete JavaScript code from workspace
*
* @param workspace - The Blockly workspace
* @returns Generated JavaScript code
*/
export function generateCode(workspace: Blockly.WorkspaceSvg): string {
return javascriptGenerator.workspaceToCode(workspace);
}

View File

@@ -0,0 +1,35 @@
/**
* BlocklyEditor Module
*
* Entry point for Blockly integration in Noodl.
* Exports components, blocks, and generators for visual logic building.
*
* @module BlocklyEditor
*/
import { initNoodlBlocks } from './NoodlBlocks';
import { initNoodlGenerators } from './NoodlGenerators';
// Main component
export { BlocklyWorkspace } from './BlocklyWorkspace';
export type { BlocklyWorkspaceProps } from './BlocklyWorkspace';
// Block definitions and generators
export { initNoodlBlocks } from './NoodlBlocks';
export { initNoodlGenerators, generateCode } from './NoodlGenerators';
/**
* Initialize all Noodl Blockly extensions
* Call this once at app startup before using Blockly components
*/
export function initBlocklyIntegration() {
console.log('🔧 [Blockly] Initializing Noodl Blockly integration');
// Initialize custom blocks
initNoodlBlocks();
// Initialize code generators
initNoodlGenerators();
console.log('✅ [Blockly] Integration initialized');
}