Files
OpenNoodl/packages/noodl-runtime/src/nodes/std-library/expression.js
Richard Osborne 554dd9f3b4 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
2026-01-11 13:30:13 +01:00

464 lines
13 KiB
JavaScript

'use strict';
const difference = require('lodash.difference');
const ExpressionEvaluator = require('../../expression-evaluator');
const ExpressionNode = {
name: 'Expression',
docs: 'https://docs.noodl.net/nodes/math/expression',
usePortAsLabel: 'expression',
category: 'CustomCode',
color: 'javascript',
nodeDoubleClickAction: {
focusPort: 'Expression'
},
searchTags: ['javascript'],
initialize: function () {
var internal = this._internal;
internal.scope = {};
internal.hasScheduledEvaluation = false;
internal.code = undefined;
internal.cachedValue = 0;
internal.currentExpression = '';
internal.compiledFunction = undefined;
internal.inputNames = [];
internal.inputValues = [];
// New: Expression evaluator integration
internal.noodlDependencies = { variables: [], objects: [], arrays: [] };
internal.unsubscribe = null;
},
methods: {
_onNodeDeleted: function () {
// Clean up reactive subscriptions to prevent memory leaks
if (this._internal.unsubscribe) {
this._internal.unsubscribe();
this._internal.unsubscribe = null;
}
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
this._internal.scope[name] = 0;
this._inputValues[name] = 0;
this.registerInput(name, {
set: function (value) {
this._internal.scope[name] = value;
if (!this.isInputConnected('run')) this._scheduleEvaluateExpression();
}
});
},
_scheduleEvaluateExpression: function () {
var internal = this._internal;
if (internal.hasScheduledEvaluation === false) {
internal.hasScheduledEvaluation = true;
this.flagDirty();
this.scheduleAfterInputsHaveUpdated(function () {
var lastValue = internal.cachedValue;
internal.cachedValue = this._calculateExpression();
if (lastValue !== internal.cachedValue) {
this.flagOutputDirty('result');
this.flagOutputDirty('isTrue');
this.flagOutputDirty('isFalse');
}
if (internal.cachedValue) this.sendSignalOnOutput('isTrueEv');
else this.sendSignalOnOutput('isFalseEv');
internal.hasScheduledEvaluation = false;
});
}
},
_calculateExpression: function () {
var internal = this._internal;
if (!internal.compiledFunction) {
internal.compiledFunction = this._compileFunction();
}
for (var i = 0; i < internal.inputNames.length; ++i) {
var inputValue = internal.scope[internal.inputNames[i]];
internal.inputValues[i] = inputValue;
}
// Get proper Noodl API and append as last parameter for backward compatibility
const JavascriptNodeParser = require('../../javascriptnodeparser');
const noodlAPI = JavascriptNodeParser.createNoodlAPI(this.context && this.context.modelScope);
const argsWithNoodl = internal.inputValues.concat([noodlAPI]);
try {
return internal.compiledFunction.apply(null, argsWithNoodl);
} catch (e) {
console.error('Error in expression:', e.message);
}
return 0;
},
_compileFunction: function () {
var expression = this._internal.currentExpression;
var args = Object.keys(this._internal.scope);
// Add 'Noodl' as last parameter for backward compatibility
args.push('Noodl');
var key = expression + args.join(' ');
if (compiledFunctionsCache.hasOwnProperty(key) === false) {
args.push(expression);
try {
compiledFunctionsCache[key] = construct(Function, args);
} catch (e) {
console.error('Failed to compile JS function', e.message);
}
}
return compiledFunctionsCache[key];
}
},
getInspectInfo() {
return this._internal.cachedValue;
},
inputs: {
expression: {
group: 'General',
inputPriority: 1,
type: {
name: 'string',
allowEditOnly: true,
codeeditor: 'javascript'
},
displayName: 'Expression',
set: function (value) {
var internal = this._internal;
internal.currentExpression = functionPreamble + 'return (' + value + ');';
internal.compiledFunction = undefined;
var newInputs = parsePorts(value);
var inputsToAdd = difference(newInputs, internal.inputNames);
var inputsToRemove = difference(internal.inputNames, newInputs);
var self = this;
inputsToRemove.forEach(function (name) {
self.deregisterInput(name);
delete internal.scope[name];
});
inputsToAdd.forEach(function (name) {
if (self.hasInput(name)) {
return;
}
self.registerInput(name, {
set: function (value) {
internal.scope[name] = value;
if (!this.isInputConnected('run')) this._scheduleEvaluateExpression();
}
});
internal.scope[name] = 0;
self._inputValues[name] = 0;
});
// Detect dependencies for reactive updates
internal.noodlDependencies = ExpressionEvaluator.detectDependencies(value);
// Clean up old subscription
if (internal.unsubscribe) {
internal.unsubscribe();
internal.unsubscribe = null;
}
// Subscribe to Noodl global changes if expression uses them
if (
internal.noodlDependencies.variables.length > 0 ||
internal.noodlDependencies.objects.length > 0 ||
internal.noodlDependencies.arrays.length > 0
) {
internal.unsubscribe = ExpressionEvaluator.subscribeToChanges(
internal.noodlDependencies,
function () {
if (!self.isInputConnected('run')) {
self._scheduleEvaluateExpression();
}
},
self.context && self.context.modelScope
);
}
internal.inputNames = Object.keys(internal.scope);
if (!this.isInputConnected('run')) this._scheduleEvaluateExpression();
}
},
run: {
group: 'Actions',
displayName: 'Run',
type: 'signal',
valueChangedToTrue: function () {
this._scheduleEvaluateExpression();
}
}
},
outputs: {
result: {
group: 'Result',
type: '*',
displayName: 'Result',
getter: function () {
if (!this._internal.currentExpression) {
return 0;
}
return this._internal.cachedValue;
}
},
isTrue: {
group: 'Result',
type: 'boolean',
displayName: 'Is True',
getter: function () {
if (!this._internal.currentExpression) {
return false;
}
return !!this._internal.cachedValue;
}
},
isFalse: {
group: 'Result',
type: 'boolean',
displayName: 'Is False',
getter: function () {
if (!this._internal.currentExpression) {
return true;
}
return !this._internal.cachedValue;
}
},
isTrueEv: {
group: 'Events',
type: 'signal',
displayName: 'On True'
},
isFalseEv: {
group: 'Events',
type: 'signal',
displayName: 'On False'
},
// New typed outputs for better downstream compatibility
asString: {
group: 'Typed Results',
type: 'string',
displayName: 'As String',
getter: function () {
const val = this._internal.cachedValue;
return val !== undefined && val !== null ? String(val) : '';
}
},
asNumber: {
group: 'Typed Results',
type: 'number',
displayName: 'As Number',
getter: function () {
const val = this._internal.cachedValue;
return typeof val === 'number' ? val : Number(val) || 0;
}
},
asBoolean: {
group: 'Typed Results',
type: 'boolean',
displayName: 'As Boolean',
getter: function () {
return !!this._internal.cachedValue;
}
}
}
};
var functionPreamble = [
'var min = Math.min,' +
' max = Math.max,' +
' cos = Math.cos,' +
' sin = Math.sin,' +
' tan = Math.tan,' +
' sqrt = Math.sqrt,' +
' pi = Math.PI,' +
' round = Math.round,' +
' floor = Math.floor,' +
' ceil = Math.ceil,' +
' abs = Math.abs,' +
' random = Math.random,' +
' pow = Math.pow,' +
' log = Math.log,' +
' exp = Math.exp;' +
// Add Noodl global context
'try {' +
' var NoodlContext = (typeof Noodl !== "undefined") ? Noodl : (typeof global !== "undefined" && global.Noodl) || {};' +
' var Variables = NoodlContext.Variables || {};' +
' var Objects = NoodlContext.Objects || {};' +
' var Arrays = NoodlContext.Arrays || {};' +
'} catch (e) {' +
' var Variables = {}, Objects = {}, Arrays = {};' +
'}'
].join('');
//Since apply cannot be used on constructors (i.e. new Something) we need this hax
//see http://stackoverflow.com/questions/1606797/use-of-apply-with-new-operator-is-this-possible
function construct(constructor, args) {
function F() {
return constructor.apply(this, args);
}
F.prototype = constructor.prototype;
return new F();
}
var compiledFunctionsCache = {};
var portsToIgnore = [
'min',
'max',
'cos',
'sin',
'tan',
'sqrt',
'pi',
'round',
'floor',
'ceil',
'abs',
'random',
'pow',
'log',
'exp',
'Math',
'window',
'document',
'undefined',
'Vars',
'Variables',
'Objects',
'Arrays',
'Noodl',
'NoodlContext',
'true',
'false',
'null',
'Boolean'
];
function parsePorts(expression) {
var ports = [];
function addPort(name) {
if (portsToIgnore.indexOf(name) !== -1) return;
if (
ports.some(function (p) {
return p === name;
})
)
return;
ports.push(name);
}
// First remove all strings
expression = expression.replace(/\"([^\"]*)\"/g, '').replace(/\'([^\']*)\'/g, '');
// Extract identifiers
var identifiers = expression.matchAll(/[a-zA-Z\_\$][a-zA-Z0-9\.\_\$]*/g);
for (const _id of identifiers) {
var name = _id[0];
if (name.indexOf('.') !== -1) {
name = name.split('.')[0]; // Take first symbol on "." sequence
}
addPort(name);
}
return ports;
}
function updatePorts(nodeId, expression, editorConnection) {
var portNames = parsePorts(expression);
var ports = portNames.map(function (name) {
return {
group: 'Parameters',
name: name,
type: {
name: '*',
editAsType: 'string'
},
plug: 'input'
};
});
editorConnection.sendDynamicPorts(nodeId, ports);
}
function evalCompileWarnings(editorConnection, node) {
const expression = node.parameters.expression;
if (!expression) {
editorConnection.clearWarning(node.component.name, node.id, 'expression-compile-error');
return;
}
// Validate expression syntax
const validation = ExpressionEvaluator.validateExpression(expression);
if (!validation.valid) {
editorConnection.sendWarning(node.component.name, node.id, 'expression-compile-error', {
message: 'Syntax error: ' + validation.error
});
} else {
editorConnection.clearWarning(node.component.name, node.id, 'expression-compile-error');
// Optionally show detected dependencies as info (helpful for users)
const deps = ExpressionEvaluator.detectDependencies(expression);
const depCount = deps.variables.length + deps.objects.length + deps.arrays.length;
if (depCount > 0) {
const depList = [];
if (deps.variables.length > 0) {
depList.push('Variables: ' + deps.variables.join(', '));
}
if (deps.objects.length > 0) {
depList.push('Objects: ' + deps.objects.join(', '));
}
if (deps.arrays.length > 0) {
depList.push('Arrays: ' + deps.arrays.join(', '));
}
// This is just informational, not an error
// Could be shown in a future info panel
// For now, we'll just log it
console.log('[Expression Node] Reactive dependencies detected:', depList.join('; '));
}
}
}
module.exports = {
node: ExpressionNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
graphModel.on('nodeAdded.Expression', function (node) {
if (node.parameters.expression) {
updatePorts(node.id, node.parameters.expression, context.editorConnection);
evalCompileWarnings(context.editorConnection, node);
}
node.on('parameterUpdated', function (event) {
if (event.name === 'expression') {
updatePorts(node.id, node.parameters.expression, context.editorConnection);
evalCompileWarnings(context.editorConnection, node);
}
});
});
}
};