mirror of
https://github.com/fluxscape/fluxscape.git
synced 2026-01-11 23:02:55 +01:00
Co-Authored-By: Eric Tuvesson <eric.tuvesson@gmail.com> Co-Authored-By: mikaeltellhed <2311083+mikaeltellhed@users.noreply.github.com> Co-Authored-By: kotte <14197736+mrtamagotchi@users.noreply.github.com> Co-Authored-By: Anders Larsson <64838990+anders-topp@users.noreply.github.com> Co-Authored-By: Johan <4934465+joolsus@users.noreply.github.com> Co-Authored-By: Tore Knudsen <18231882+torekndsn@users.noreply.github.com> Co-Authored-By: victoratndl <99176179+victoratndl@users.noreply.github.com>
593 lines
18 KiB
JavaScript
593 lines
18 KiB
JavaScript
const OutputProperty = require('./outputproperty');
|
|
|
|
/**
|
|
* Base class for all Nodes
|
|
* @constructor
|
|
*/
|
|
function Node(context, id) {
|
|
this.id = id;
|
|
this.context = context;
|
|
this._dirty = false;
|
|
|
|
this._inputs = {};
|
|
this._inputValues = {};
|
|
this._outputs = {};
|
|
|
|
this._inputConnections = {};
|
|
this._outputList = [];
|
|
this._isUpdating = false;
|
|
this._inputValuesQueue = {};
|
|
this._afterInputsHaveUpdatedCallbacks = [];
|
|
|
|
this._internal = {};
|
|
this._signalsSentThisUpdate = {};
|
|
|
|
this._deleted = false;
|
|
this._deleteListeners = [];
|
|
this._isFirstUpdate = true;
|
|
|
|
this._valuesFromConnections = {};
|
|
this.updateOnDirtyFlagging = true;
|
|
}
|
|
|
|
Node.prototype.getInputValue = function (name) {
|
|
return this._inputValues[name];
|
|
};
|
|
|
|
Node.prototype.registerInput = function (name, input) {
|
|
if (this.hasInput(name)) {
|
|
throw new Error('Input property ' + name + ' already registered');
|
|
}
|
|
|
|
this._inputs[name] = input;
|
|
|
|
if (input.type && input.type.units) {
|
|
const defaultUnit = input.type.defaultUnit || input.type.units[0];
|
|
this._inputValues[name] = {
|
|
value: input.default,
|
|
type: defaultUnit
|
|
};
|
|
} else if (input.hasOwnProperty('default')) {
|
|
this._inputValues[name] = input.default;
|
|
}
|
|
};
|
|
|
|
Node.prototype.deregisterInput = function (name) {
|
|
if (this.hasInput(name) === false) {
|
|
throw new Error('Input property ' + name + " doesn't exist");
|
|
}
|
|
delete this._inputs[name];
|
|
delete this._inputValues[name];
|
|
};
|
|
|
|
Node.prototype.registerInputs = function (inputs) {
|
|
for (const name in inputs) {
|
|
this.registerInput(name, inputs[name]);
|
|
}
|
|
};
|
|
|
|
Node.prototype.getInput = function (name) {
|
|
if (this.hasInput(name) === false) {
|
|
console.log('Node ' + this.name + ': Invalid input property ' + name);
|
|
return undefined;
|
|
}
|
|
|
|
return this._inputs[name];
|
|
};
|
|
|
|
Node.prototype.hasInput = function (name) {
|
|
return name in this._inputs;
|
|
};
|
|
|
|
Node.prototype.registerInputIfNeeded = function () {
|
|
//noop, can be overriden by subclasses
|
|
};
|
|
|
|
Node.prototype.setInputValue = function (name, value) {
|
|
const input = this.getInput(name);
|
|
if (!input) {
|
|
console.log("node doesn't have input", name);
|
|
return;
|
|
}
|
|
|
|
//inputs with units always expect objects in the shape of {value, unit, ...}
|
|
//these inputs might sometimes get raw numbers without units, and in those cases
|
|
//Noodl should just update the value and not the other parameters
|
|
const currentInputValue = this._inputValues[name];
|
|
|
|
if (isNaN(value) === false && currentInputValue && currentInputValue.unit) {
|
|
//update the value, and keep the other parameters
|
|
const newValue = Object.assign({}, currentInputValue); //copy it, so we don't modify the original object (e.g. it might come from a variant)
|
|
newValue.value = value;
|
|
value = newValue;
|
|
}
|
|
|
|
//Save the current input value. Save it before resolving color styles so delta updates on color styles work correctly
|
|
this._inputValues[name] = value;
|
|
|
|
if (input.type === 'color' && this.context && this.context.styles) {
|
|
value = this.context.styles.resolveColor(value);
|
|
} else if (input.type === 'array' && typeof value === 'string') {
|
|
try {
|
|
value = eval(value);
|
|
this.context.editorConnection.clearWarning(this.nodeScope.componentOwner.name, this.id, 'invalid-array-' + name);
|
|
} catch (e) {
|
|
value = [];
|
|
console.log(e);
|
|
if (this.context.editorConnection) {
|
|
this.context.editorConnection.sendWarning(
|
|
this.nodeScope.componentOwner.name,
|
|
this.id,
|
|
'invalid-array-' + name,
|
|
{
|
|
showGlobally: true,
|
|
message: 'Invalid array<br>' + e.toString()
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
input.set.call(this, value);
|
|
};
|
|
|
|
Node.prototype.hasOutput = function (name) {
|
|
return name in this._outputs;
|
|
};
|
|
|
|
Node.prototype.registerOutput = function (name, output) {
|
|
if (this.hasOutput(name)) {
|
|
throw new Error('Output property ' + name + ' already registered');
|
|
}
|
|
|
|
const newOutput = new OutputProperty({
|
|
owner: this,
|
|
getter: output.get || output.getter,
|
|
name: name,
|
|
onFirstConnectionAdded: output.onFirstConnectionAdded,
|
|
onLastConnectionRemoved: output.onLastConnectionRemoved
|
|
});
|
|
|
|
this._outputs[name] = newOutput;
|
|
this._outputList.push(newOutput);
|
|
};
|
|
|
|
Node.prototype.deregisterOutput = function (name) {
|
|
if (this.hasOutput(name) === false) {
|
|
throw new Error('Output property ' + name + " isn't registered");
|
|
}
|
|
|
|
const output = this._outputs[name];
|
|
|
|
if (output.hasConnections()) {
|
|
throw new Error('Output property ' + name + " has connections and can't be removed");
|
|
}
|
|
|
|
delete this._outputs[name];
|
|
var index = this._outputList.indexOf(output);
|
|
this._outputList.splice(index, 1);
|
|
};
|
|
|
|
Node.prototype.registerOutputs = function (outputs) {
|
|
for (var name in outputs) {
|
|
this.registerOutput(name, outputs[name]);
|
|
}
|
|
};
|
|
|
|
Node.prototype.registerOutputIfNeeded = function () {
|
|
//noop, can be overriden by subclasses
|
|
};
|
|
|
|
Node.prototype.getOutput = function (name) {
|
|
if (this.hasOutput(name) === false) {
|
|
throw new Error('Node ' + this.name + " doesn't have a port named " + name);
|
|
}
|
|
return this._outputs[name];
|
|
};
|
|
|
|
Node.prototype.connectInput = function (inputName, sourceNode, sourcePortName) {
|
|
if (this.hasInput(inputName) === false) {
|
|
throw new Error(
|
|
"Invalid connection, input doesn't exist. Trying to connect from " +
|
|
sourceNode.name +
|
|
' output ' +
|
|
sourcePortName +
|
|
' to ' +
|
|
this.name +
|
|
' input ' +
|
|
inputName
|
|
);
|
|
}
|
|
|
|
var sourcePort = sourceNode.getOutput(sourcePortName);
|
|
sourcePort.registerConnection(this, inputName);
|
|
|
|
if (!this._inputConnections[inputName]) {
|
|
this._inputConnections[inputName] = [];
|
|
}
|
|
|
|
this._inputConnections[inputName].push(sourcePort);
|
|
|
|
if (sourceNode._signalsSentThisUpdate[sourcePortName]) {
|
|
this._setValueFromConnection(inputName, true);
|
|
this._setValueFromConnection(inputName, false);
|
|
} else {
|
|
var outputValue = sourcePort.value;
|
|
if (outputValue !== undefined) {
|
|
this._setValueFromConnection(inputName, outputValue);
|
|
|
|
if (this.context) {
|
|
// Send value to editor for connection debugging.
|
|
// Conceptually the value has already been sent,
|
|
// but the editor needs to be notified after a connection is created
|
|
this.context.connectionSentValue(sourcePort, sourcePort.value);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.flagDirty();
|
|
};
|
|
|
|
Node.prototype.removeInputConnection = function (inputName, sourceNodeId, sourcePortName) {
|
|
if (!this._inputConnections[inputName]) {
|
|
throw new Error("Node removeInputConnection: Input doesn't exist");
|
|
}
|
|
|
|
const inputsToPort = this._inputConnections[inputName];
|
|
|
|
for (let i = 0; i < inputsToPort.length; i++) {
|
|
const sourcePort = inputsToPort[i];
|
|
if (sourcePort.owner.id === sourceNodeId && sourcePort.name === sourcePortName) {
|
|
inputsToPort.splice(i, 1);
|
|
|
|
//remove the output from the source node
|
|
const output = sourcePort.owner.getOutput(sourcePortName);
|
|
output.deregisterConnection(this, inputName);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (inputsToPort.length === 0) {
|
|
//no inputs left, remove the bookkeeping that traces values sent to this node as inputs
|
|
delete this._valuesFromConnections[inputName];
|
|
}
|
|
};
|
|
|
|
Node.prototype.isInputConnected = function (inputName) {
|
|
if (!this._inputConnections.hasOwnProperty(inputName)) {
|
|
return false;
|
|
}
|
|
|
|
//We have connections, but they might be from non-connected component inputs.
|
|
// If they are from a component input then check the input on the component instance.
|
|
return this._inputConnections[inputName].some((c) => {
|
|
//if this is not a component input, then we have a proper connection
|
|
if (c.owner.name !== 'Component Inputs') return true;
|
|
|
|
//the name of the output from the component input, is the same as the component instance input
|
|
const component = c.owner.nodeScope.componentOwner;
|
|
return component.isInputConnected(c.name);
|
|
});
|
|
};
|
|
|
|
Node.prototype.update = function () {
|
|
if (this._isUpdating || this._dirty === false) {
|
|
return;
|
|
}
|
|
|
|
if (this._updatedAtIteration !== this.context.updateIteration) {
|
|
this._updatedAtIteration = this.context.updateIteration;
|
|
this._updateIteration = 0;
|
|
if (this._cyclicLoop) this._cyclicLoop = false;
|
|
}
|
|
|
|
this._isUpdating = true;
|
|
const maxUpdateIterations = 100;
|
|
|
|
try {
|
|
while (this._dirty && !this._cyclicLoop) {
|
|
this._updateDependencies();
|
|
|
|
//all inputs are now updated, flag as not dirty
|
|
this._dirty = false;
|
|
|
|
const inputNames = Object.keys(this._inputValuesQueue);
|
|
|
|
let hasMoreInputs = true;
|
|
|
|
while (hasMoreInputs && !this._cyclicLoop) {
|
|
hasMoreInputs = false;
|
|
|
|
for (let i = 0; i < inputNames.length; i++) {
|
|
const inputName = inputNames[i];
|
|
const queue = this._inputValuesQueue[inputName];
|
|
if (queue.length > 0) {
|
|
this.setInputValue(inputName, queue.shift());
|
|
if (queue.length > 0) {
|
|
hasMoreInputs = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
const afterInputCallbacks = this._afterInputsHaveUpdatedCallbacks;
|
|
this._afterInputsHaveUpdatedCallbacks = [];
|
|
for (let i = 0; i < afterInputCallbacks.length; i++) {
|
|
afterInputCallbacks[i].call(this);
|
|
}
|
|
}
|
|
this._updateIteration++;
|
|
|
|
if (this._updateIteration >= maxUpdateIterations) {
|
|
this._cyclicLoop = true;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
this._isUpdating = false;
|
|
throw e;
|
|
}
|
|
|
|
if (this._cyclicLoop) {
|
|
//flag the node as dirty again to let it contiune next frame so we don't just stop it
|
|
//This will allow the browser a chance to render and run other code
|
|
this.context.scheduleNextFrame(() => {
|
|
this.context.nodeIsDirty(this);
|
|
});
|
|
|
|
if (this.context.editorConnection && !this._cyclicWarningSent && this.context.isWarningTypeEnabled('cyclicLoops')) {
|
|
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'cyclic-loop', {
|
|
showGlobally: true,
|
|
message: 'Cyclic loop detected'
|
|
});
|
|
this._cyclicWarningSent = true;
|
|
console.log('cycle detected', {
|
|
id: this.id,
|
|
name: this.name,
|
|
component: this.nodeScope.componentOwner.name
|
|
});
|
|
}
|
|
}
|
|
|
|
this._isFirstUpdate = false;
|
|
this._isUpdating = false;
|
|
};
|
|
|
|
Node.prototype._updateDependencies = function () {
|
|
for (var inputName in this._inputConnections) {
|
|
var connectedPorts = this._inputConnections[inputName];
|
|
for (var i = 0; i < connectedPorts.length; ++i) {
|
|
connectedPorts[i].owner.update();
|
|
}
|
|
}
|
|
};
|
|
|
|
Node.prototype.flagDirty = function () {
|
|
if (this._dirty) {
|
|
return;
|
|
}
|
|
|
|
this._dirty = true;
|
|
|
|
//a hack to not update nodes as a component is being created.
|
|
//Nodes should update once all connections are in place, so inputs that rely on connections, e.g. "Run" on a Function node, have the correct context before running.
|
|
//This flag is being updated externally by the NodeScope and _performDirtyUpdate will be called when the component setup is done
|
|
if (this.updateOnDirtyFlagging) {
|
|
this._performDirtyUpdate();
|
|
}
|
|
};
|
|
|
|
Node.prototype._performDirtyUpdate = function () {
|
|
this.context && this.context.nodeIsDirty(this);
|
|
|
|
for (var i = 0; i < this._outputList.length; ++i) {
|
|
this._outputList[i].flagDependeesDirty();
|
|
}
|
|
};
|
|
|
|
Node.prototype.sendValue = function (name, value) {
|
|
if (this.hasOutput(name) === false) {
|
|
console.log('Error: Node', this.name, "doesn't have a output named", name);
|
|
return;
|
|
}
|
|
|
|
if (value === undefined) {
|
|
return;
|
|
}
|
|
|
|
const output = this.getOutput(name);
|
|
output.sendValue(value);
|
|
|
|
if (this.context) {
|
|
this.context.connectionSentValue(output, value);
|
|
}
|
|
};
|
|
|
|
Node.prototype.flagOutputDirty = function (name) {
|
|
const output = this.getOutput(name);
|
|
this.sendValue(name, output.value);
|
|
};
|
|
|
|
Node.prototype.flagAllOutputsDirty = function () {
|
|
for (const output of this._outputList) {
|
|
this.sendValue(output.name, output.value);
|
|
}
|
|
};
|
|
|
|
Node.prototype.sendSignalOnOutput = function (outputName) {
|
|
if (this.hasOutput(outputName) === false) {
|
|
console.log('Error: Node', this.name, "doesn't have a output named", outputName);
|
|
return;
|
|
}
|
|
|
|
const output = this.getOutput(outputName);
|
|
output.sendValue(true);
|
|
output.sendValue(false);
|
|
|
|
this._signalsSentThisUpdate[outputName] = true;
|
|
this.scheduleAfterInputsHaveUpdated(function () {
|
|
this._signalsSentThisUpdate[outputName] = false;
|
|
});
|
|
|
|
if (this.context) {
|
|
this.context.connectionSentSignal(output);
|
|
}
|
|
};
|
|
|
|
Node.prototype._setValueFromConnection = function (inputName, value) {
|
|
this._valuesFromConnections[inputName] = value;
|
|
this.queueInput(inputName, value);
|
|
};
|
|
|
|
Node.prototype._hasInputBeenSetFromAConnection = function (inputName) {
|
|
return this._valuesFromConnections.hasOwnProperty(inputName);
|
|
};
|
|
|
|
Node.prototype.queueInput = function (inputName, value) {
|
|
if (!this._inputValuesQueue[inputName]) {
|
|
this._inputValuesQueue[inputName] = [];
|
|
}
|
|
|
|
//when values are queued during the very first update, make the last value overwrite previous ones
|
|
//so a chain with multiple nodes with values that connect to each other all
|
|
//consolidate to a single value, instead of piling up in the queue
|
|
if (this._isFirstUpdate) {
|
|
//signals need two values, so make sure we don't suppress the 'false' that comes directly
|
|
//after a 'true'
|
|
const queueValue = this._inputValuesQueue[inputName][0];
|
|
const isSignal = queueValue === true; // && value === true;
|
|
if (!isSignal) {
|
|
//default units are set as an object {value, unit}
|
|
//subsequent inputs can be unitless. and will will then overwrite those
|
|
//and the node will get a value without ever getting a unit.
|
|
//To make sure that doesn't happen, look at the value being overwritten
|
|
//and use the unit from that before overwriting
|
|
if (queueValue instanceof Object && queueValue.unit && value instanceof Object === false) {
|
|
value = {
|
|
value,
|
|
unit: queueValue.unit
|
|
};
|
|
}
|
|
|
|
this._inputValuesQueue[inputName].length = 0;
|
|
}
|
|
}
|
|
|
|
this._inputValuesQueue[inputName].push(value);
|
|
this.flagDirty();
|
|
};
|
|
|
|
Node.prototype.scheduleAfterInputsHaveUpdated = function (callback) {
|
|
this._afterInputsHaveUpdatedCallbacks.push(callback);
|
|
this.flagDirty();
|
|
};
|
|
|
|
Node.prototype.setNodeModel = function (nodeModel) {
|
|
this.model = nodeModel;
|
|
nodeModel.on('parameterUpdated', this._onNodeModelParameterUpdated, this);
|
|
nodeModel.on('variantUpdated', this._onNodeModelVariantUpdated, this);
|
|
|
|
nodeModel.on(
|
|
'inputPortRemoved',
|
|
(port) => {
|
|
if (this.hasInput(port.name)) {
|
|
this.deregisterInput(port.name);
|
|
}
|
|
},
|
|
this
|
|
);
|
|
|
|
nodeModel.on(
|
|
'outputPortRemoved',
|
|
(port) => {
|
|
if (this.hasOutput(port.name)) {
|
|
this.deregisterOutput(port.name);
|
|
}
|
|
},
|
|
this
|
|
);
|
|
};
|
|
|
|
Node.prototype.addDeleteListener = function (listener) {
|
|
this._deleteListeners.push(listener);
|
|
};
|
|
|
|
Node.prototype._onNodeDeleted = function () {
|
|
if (this.model) {
|
|
this.model.removeListenersWithRef(this);
|
|
this.model = undefined;
|
|
}
|
|
|
|
this._deleted = true;
|
|
|
|
for (const deleteListener of this._deleteListeners) {
|
|
deleteListener.call(this);
|
|
}
|
|
};
|
|
|
|
Node.prototype._onNodeModelParameterUpdated = function (event) {
|
|
this.registerInputIfNeeded(event.name);
|
|
|
|
if (event.value !== undefined) {
|
|
if (event.state) {
|
|
//this parameter is only used in a certain visual state
|
|
//make sure we are in that state before setting it
|
|
|
|
if (!this._getVisualStates) {
|
|
console.log('Node has nos visual states, but got a parameter for state', event.state);
|
|
return;
|
|
}
|
|
|
|
const states = this._getVisualStates();
|
|
if (states.indexOf(event.state) !== -1) {
|
|
this.queueInput(event.name, event.value);
|
|
}
|
|
} else {
|
|
this.queueInput(event.name, event.value);
|
|
}
|
|
} else {
|
|
//parameter is undefined, that means it has been removed and we should reset to default
|
|
let defaultValue;
|
|
|
|
const variant = this.variant;
|
|
|
|
if (event.state) {
|
|
//local value has been reset, check the variant first
|
|
if (
|
|
variant &&
|
|
variant.stateParameters.hasOwnProperty(event.state) &&
|
|
variant.stateParameters[event.state].hasOwnProperty(event.name)
|
|
) {
|
|
defaultValue = variant.stateParameters[event.state][event.name];
|
|
}
|
|
//and if variant has no value in that state, check for local values in the neutral state
|
|
else if (this.model.parameters.hasOwnProperty(event.name)) {
|
|
defaultValue = this.model.parameters[event.name];
|
|
}
|
|
//and then look in the variant neutral values
|
|
else if (variant && variant.parameters.hasOwnProperty(event.name)) {
|
|
defaultValue = variant.parameters[event.name];
|
|
}
|
|
} else if (variant && variant.parameters.hasOwnProperty(event.name)) {
|
|
defaultValue = variant.parameters[event.name];
|
|
}
|
|
|
|
if (defaultValue === undefined) {
|
|
//get the default value for the port
|
|
defaultValue = this.context.getDefaultValueForInput(this.model.type, event.name);
|
|
|
|
//when a paramter that's used by a text style is reset, Noodl will modify the original dom node, outside of React
|
|
//React will then re-render, and should apply the values from the text style, but won't see any delta in the virtual dom,
|
|
//even though there is a diff to the real dom.
|
|
//to fix that, we just force React to re-render the entire node
|
|
this._resetReactVirtualDOM && this._resetReactVirtualDOM();
|
|
}
|
|
|
|
this.queueInput(event.name, defaultValue);
|
|
}
|
|
};
|
|
|
|
Node.prototype._onNodeModelVariantUpdated = function (variant) {
|
|
this.setVariant(variant);
|
|
};
|
|
|
|
module.exports = Node;
|