mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-12 07:12:54 +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>
1157 lines
36 KiB
JavaScript
1157 lines
36 KiB
JavaScript
'use strict';
|
|
|
|
import React from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
|
|
import DOMBoundingBoxObserver from './dom-boundingbox-oberver';
|
|
import Layout from './layout';
|
|
import mergeDeep from './mergedeep';
|
|
import NodeSharedPortDefinitions from './node-shared-port-definitions';
|
|
import transitionParameter from './node-transitions';
|
|
|
|
function addOutputPropHandler(node, propCallbacks, propPath) {
|
|
const props = propPath ? node.props[propPath] : node.props;
|
|
|
|
for (const propName in propCallbacks) {
|
|
if (props[propName]) {
|
|
const prevCb = props[propName];
|
|
props[propName] = () => {
|
|
prevCb();
|
|
propCallbacks[propName].call(node);
|
|
};
|
|
} else {
|
|
props[propName] = propCallbacks[propName].bind(node);
|
|
}
|
|
}
|
|
node.forceUpdate();
|
|
}
|
|
|
|
function addPrimitiveOutputPropHandler(node, name, output) {
|
|
let prop;
|
|
|
|
if (output.type === 'signal') {
|
|
prop = () => {
|
|
node.sendSignalOnOutput(name);
|
|
};
|
|
} else {
|
|
prop = (...args) => {
|
|
node.outputPropValues[name] = output.getValue ? output.getValue.call(node, ...args) : args[0];
|
|
node.flagOutputDirty(name);
|
|
output.onChange && output.onChange.call(node, node.outputPropValues[name]);
|
|
};
|
|
}
|
|
|
|
addOutputPropHandler(node, { [name]: prop }, output.propPath);
|
|
}
|
|
|
|
function defineRegularInputProp(input, name) {
|
|
if (!input.type) throw new Error(`input ${name} is missing a type`);
|
|
|
|
if (input.type.units) {
|
|
input.set = function (value) {
|
|
const props = input.propPath ? this.props[input.propPath] : this.props;
|
|
if (value && value.value !== undefined) {
|
|
props[name] = value.value + value.unit;
|
|
} else {
|
|
delete props[name];
|
|
}
|
|
if (input.onChange) {
|
|
input.onChange.call(this, value);
|
|
}
|
|
this.forceUpdate();
|
|
};
|
|
} else {
|
|
input.set = function (value) {
|
|
const props = input.propPath ? this.props[input.propPath] : this.props;
|
|
if (value !== undefined) {
|
|
props[name] = value;
|
|
} else {
|
|
delete props[name];
|
|
}
|
|
if (input.onChange) {
|
|
input.onChange.call(this, value);
|
|
}
|
|
this.forceUpdate();
|
|
};
|
|
}
|
|
}
|
|
|
|
function flattenArray(target, array) {
|
|
for (const e of array) {
|
|
if (Array.isArray(e)) {
|
|
flattenArray(target, e);
|
|
} else if (e !== undefined) {
|
|
target.push(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
class NoodlReactComponent extends React.Component {
|
|
componentDidMount() {
|
|
this.props.noodlNode.sendSignalOnOutput('didMount');
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.props.noodlNode.sendSignalOnOutput('willUnmount');
|
|
//Remove
|
|
const noodlNode = this.props.noodlNode;
|
|
if (noodlNode.currentVisualStates) {
|
|
const statesToRemove = ['hover', 'pressed', 'focused'];
|
|
const vs = noodlNode.currentVisualStates.filter((s) => !statesToRemove.includes(s));
|
|
noodlNode.setVisualStates(vs);
|
|
}
|
|
}
|
|
|
|
render() {
|
|
//So the props are a bit tricky here...
|
|
//this.props consist of:
|
|
// - the props passed by the ReactComponentNode.render() function, just {noodlNode}
|
|
// - any additional third party props coming from the parent react component.
|
|
//E.g. the drag node adds event handlers, style, and className to this.props.
|
|
|
|
const { noodlNode, style, ...otherProps } = this.props;
|
|
|
|
let finalStyle = noodlNode.style;
|
|
|
|
//check if there's additional styling from the react parent
|
|
//if so, we need to combine it with the style from the Noodl node
|
|
//let the extra style take priority over the noodl style, if they share some attributes
|
|
//this is wrapped in an if for performance reasons, "..." is quite slow
|
|
if (style) {
|
|
finalStyle = {
|
|
...noodlNode.style, //styling from the noodl node
|
|
...style //styling from the react component parent
|
|
};
|
|
}
|
|
|
|
const props = {
|
|
ref: (ref) => {
|
|
noodlNode.innerReactComponentRef = ref;
|
|
},
|
|
style: finalStyle,
|
|
//the noodl props coming from the node
|
|
//some components actually have a "style" used for something other than css,
|
|
//so make sure this comes after the "style" prop above, so it can overwrite it
|
|
//(e.g. Jesper's Icon Material UI)
|
|
...noodlNode.props,
|
|
|
|
//otherProps can be empty, but some react components add additional props to their children
|
|
...otherProps
|
|
};
|
|
|
|
if (noodlNode.noodlNodeAsProp) {
|
|
props.noodlNode = noodlNode;
|
|
|
|
//nodes that want the noodlNode also get the parent layout
|
|
//since it's used by all built in nodes for layout purposes
|
|
const parent = noodlNode.getVisualParentNode();
|
|
if (parent && parent.props.layout) {
|
|
props.parentLayout = parent.props.layout;
|
|
}
|
|
}
|
|
|
|
//optimization. This is used by forceUpdate() to only render this node once per frame.
|
|
noodlNode.renderedAtFrame = noodlNode.context.frameNumber;
|
|
|
|
if (noodlNode.useFrame) {
|
|
if (props.textStyle !== undefined) {
|
|
// Apply text style
|
|
props.style = finalStyle = Object.assign({}, props.textStyle, finalStyle);
|
|
}
|
|
Layout.size(finalStyle, props);
|
|
Layout.align(finalStyle, props);
|
|
|
|
/* if(finalStyle.opacity === 0) {
|
|
finalStyle.pointerEvents = 'none';
|
|
}*/
|
|
}
|
|
|
|
return React.createElement(noodlNode.reactComponent, props, noodlNode.renderChildren());
|
|
}
|
|
}
|
|
|
|
function setStylesOnDOMNode(rootElement, styles, styleTag) {
|
|
let element = rootElement;
|
|
|
|
if (styleTag) {
|
|
//check if the root element has the style tag, if not, find the child that does
|
|
if (element.getAttribute('noodl-style-tag') !== styleTag) {
|
|
element = rootElement.querySelector(`[noodl-style-tag=${styleTag}]`);
|
|
}
|
|
}
|
|
|
|
if (!element) return;
|
|
|
|
for (const p in styles) {
|
|
element.style[p] = styles[p];
|
|
}
|
|
}
|
|
|
|
let reactKeyCounter = 0;
|
|
|
|
function createNodeFromReactComponent(def) {
|
|
// visual frame props
|
|
const { frame } = def;
|
|
if (frame !== undefined) {
|
|
if (frame.dimensions) {
|
|
NodeSharedPortDefinitions.addDimensions(def, typeof frame.dimensions === 'object' ? frame.dimensions : undefined);
|
|
}
|
|
|
|
if (frame.position) NodeSharedPortDefinitions.addTransformInputs(def);
|
|
|
|
if (frame.margins) NodeSharedPortDefinitions.addMarginInputs(def);
|
|
|
|
if (frame.padding) NodeSharedPortDefinitions.addPaddingInputs(def);
|
|
|
|
if (frame.align) NodeSharedPortDefinitions.addAlignInputs(def);
|
|
|
|
// NodeSharedPortDefinitions.addSharedVisualInputs(ReactComponentNode);
|
|
|
|
// NodeSharedPortDefinitions.addPointerEventOutputs(ReactComponentNode);
|
|
}
|
|
|
|
const {
|
|
initialize,
|
|
inputs,
|
|
inputProps,
|
|
inputCss,
|
|
outputs,
|
|
outputProps,
|
|
dynamicports,
|
|
defaultCss = {},
|
|
methods
|
|
} = def;
|
|
|
|
//assign default values to style
|
|
const startStyle = Object.assign({}, defaultCss);
|
|
const startStyles = {};
|
|
|
|
for (const name in inputCss) {
|
|
const input = inputCss[name];
|
|
|
|
const hasDefault = input.hasOwnProperty('default') && input.applyDefault !== false;
|
|
if (input.styleTag && !startStyles.hasOwnProperty(input.styleTag)) {
|
|
startStyles[input.styleTag] = {};
|
|
}
|
|
|
|
if (hasDefault) {
|
|
const value = input.type.units ? input.default + input.type.defaultUnit : input.default;
|
|
if (input.styleTag) {
|
|
startStyles[input.styleTag][name] = value;
|
|
} else {
|
|
startStyle[name] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
function boundingBoxObserverCallback(attribute, rect) {
|
|
this.clientBoundingRect = rect;
|
|
if (attribute === 'x') {
|
|
this.flagOutputDirty('screenPositionX');
|
|
} else if (attribute === 'y') {
|
|
this.flagOutputDirty('screenPositionY');
|
|
} else if (attribute === 'width') {
|
|
this.flagOutputDirty('boundingWidth');
|
|
} else if (attribute === 'height') {
|
|
this.flagOutputDirty('boundingHeight');
|
|
}
|
|
}
|
|
|
|
const useVariants = def.useVariants !== undefined ? def.useVariants : true;
|
|
|
|
const ReactComponentNode = {
|
|
name: def.name,
|
|
docs: def.docs,
|
|
displayNodeName: def.displayNodeName || def.displayName,
|
|
shortDesc: '',
|
|
category: 'Visual',
|
|
allowChildren: def.allowChildren === undefined ? true : def.allowChildren, //default to true
|
|
visualStates: def.visualStates,
|
|
allowAsExportRoot: def.allowAsExportRoot,
|
|
singleton: def.singleton,
|
|
useVariants,
|
|
usePortAsLabel: def.usePortAsLabel,
|
|
portLabelTruncationMode: def.portLabelTruncationMode,
|
|
connectionPanel: def.connectionPanel,
|
|
nodeDoubleClickAction: def.nodeDoubleClickAction,
|
|
initialize() {
|
|
this.reactKey = 'key' + reactKeyCounter;
|
|
reactKeyCounter++;
|
|
|
|
this.children = [];
|
|
if (hasChildCountOutput) {
|
|
this.childrenCount = 0;
|
|
}
|
|
|
|
this.props = { styles: {} };
|
|
this.outputPropValues = {};
|
|
this.style = Object.assign({}, startStyle);
|
|
|
|
for (const styleTag in startStyles) {
|
|
this.props.styles[styleTag] = Object.assign({}, startStyles[styleTag]);
|
|
}
|
|
this.childIndex = 0;
|
|
this.clientBoundingRect = {};
|
|
this.noodlNodeAsProp = def.noodlNodeAsProp ? true : false;
|
|
|
|
const pollDelay = this.context && this.context.runningInCanvas ? 300 : 0;
|
|
this.boundingBoxObserver = new DOMBoundingBoxObserver(boundingBoxObserverCallback.bind(this), pollDelay);
|
|
|
|
this.wantsToBeMounted = true;
|
|
|
|
this.useFrame = !!frame;
|
|
|
|
//assign default values to props
|
|
for (const name in inputProps) {
|
|
const input = inputProps[name];
|
|
if (input.propPath && !this.props.hasOwnProperty(input.propPath)) {
|
|
this.props[input.propPath] = {};
|
|
}
|
|
|
|
const props = input.propPath ? this.props[input.propPath] : this.props;
|
|
|
|
if (input.hasOwnProperty('default')) {
|
|
if (input.type.defaultUnit && input.default !== undefined) {
|
|
props[name] = input.default + input.type.defaultUnit;
|
|
} else {
|
|
props[name] = input.default;
|
|
}
|
|
}
|
|
}
|
|
|
|
//set ut props that send data on noodl outputs
|
|
for (const outputName in outputProps) {
|
|
const output = outputProps[outputName];
|
|
if (output.propPath && !this.props.hasOwnProperty(output.propPath)) {
|
|
this.props[output.propPath] = {};
|
|
}
|
|
|
|
if (!output.props) {
|
|
addPrimitiveOutputPropHandler(this, outputName, output);
|
|
} else {
|
|
addOutputPropHandler(this, output.props, output.propPath);
|
|
}
|
|
}
|
|
|
|
this.reactComponentRef = null;
|
|
this.reactComponent = def.getReactComponent.call(this);
|
|
|
|
if (initialize) {
|
|
initialize.call(this);
|
|
}
|
|
},
|
|
getInspectInfo: def.getInspectInfo,
|
|
nodeScopeDidInitialize: def.nodeScopeDidInitialize,
|
|
dynamicports,
|
|
inputs: {
|
|
cssClassName: {
|
|
index: 100010,
|
|
displayName: 'CSS Class',
|
|
group: 'Advanced HTML',
|
|
type: 'string',
|
|
default: '',
|
|
set(value) {
|
|
this.props.className = value;
|
|
this.forceUpdate();
|
|
}
|
|
},
|
|
styleCss: {
|
|
index: 100011,
|
|
displayName: 'CSS Style',
|
|
group: 'Advanced HTML',
|
|
type: {
|
|
name: 'string',
|
|
codeeditor: 'text',
|
|
allowEditOnly: true
|
|
},
|
|
default: '/* background-color: red; */',
|
|
set(value) {
|
|
this.updateAdvancedStyle({ content: value });
|
|
}
|
|
}
|
|
},
|
|
outputs: {
|
|
childIndex: {
|
|
displayName: 'Child Index',
|
|
type: 'number',
|
|
get() {
|
|
return this.childIndex;
|
|
}
|
|
},
|
|
this: {
|
|
displayName: 'This',
|
|
type: 'reference',
|
|
get() {
|
|
return this;
|
|
}
|
|
},
|
|
screenPositionX: {
|
|
group: 'Bounding Box',
|
|
displayName: 'Screen Position X',
|
|
type: 'number',
|
|
get() {
|
|
return this.clientBoundingRect.x;
|
|
},
|
|
onFirstConnectionAdded() {
|
|
this.boundingBoxObserver.addObserver();
|
|
},
|
|
onLastConnectionRemoved() {
|
|
this.boundingBoxObserver.removeObserver();
|
|
}
|
|
},
|
|
screenPositionY: {
|
|
group: 'Bounding Box',
|
|
displayName: 'Screen Position Y',
|
|
type: 'number',
|
|
get() {
|
|
return this.clientBoundingRect.y;
|
|
},
|
|
onFirstConnectionAdded() {
|
|
this.boundingBoxObserver.addObserver();
|
|
},
|
|
onLastConnectionRemoved() {
|
|
this.boundingBoxObserver.removeObserver();
|
|
}
|
|
},
|
|
boundingWidth: {
|
|
group: 'Bounding Box',
|
|
displayName: 'Width',
|
|
type: 'number',
|
|
get() {
|
|
return this.clientBoundingRect.width;
|
|
},
|
|
onFirstConnectionAdded() {
|
|
this.boundingBoxObserver.addObserver();
|
|
},
|
|
onLastConnectionRemoved() {
|
|
this.boundingBoxObserver.removeObserver();
|
|
}
|
|
},
|
|
boundingHeight: {
|
|
group: 'Bounding Box',
|
|
displayName: 'Height',
|
|
type: 'number',
|
|
get() {
|
|
return this.clientBoundingRect.height;
|
|
},
|
|
onFirstConnectionAdded() {
|
|
this.boundingBoxObserver.addObserver();
|
|
},
|
|
onLastConnectionRemoved() {
|
|
this.boundingBoxObserver.removeObserver();
|
|
}
|
|
},
|
|
didMount: {
|
|
group: 'Mounted',
|
|
displayName: 'Did Mount',
|
|
type: 'signal'
|
|
},
|
|
willUnmount: {
|
|
group: 'Mounted',
|
|
displayName: 'Will Unmount',
|
|
type: 'signal'
|
|
}
|
|
},
|
|
methods: {
|
|
updateAdvancedStyle(params) {
|
|
//remove previous styles first
|
|
if (this.customCssStyles) {
|
|
this.removeStyle(Object.keys(this.customCssStyles));
|
|
this.customCssStyles = undefined;
|
|
}
|
|
|
|
let style;
|
|
let errorMessage = '';
|
|
|
|
let rawCss = (params.content || '').replace('\n', '');
|
|
|
|
// strip away comments
|
|
let css = '';
|
|
while (rawCss.length) {
|
|
let nextComment = rawCss.indexOf('/*');
|
|
if (nextComment === -1) {
|
|
nextComment = rawCss.length;
|
|
}
|
|
css += rawCss.substring(0, nextComment);
|
|
rawCss = rawCss.substring(nextComment);
|
|
|
|
if (rawCss.length) {
|
|
//were inside a comment
|
|
let endComment = rawCss.indexOf('*/');
|
|
if (endComment === -1) endComment = rawCss.length;
|
|
rawCss = rawCss.substring(endComment + 2);
|
|
}
|
|
}
|
|
function trim(s) {
|
|
return s.replace(/^\s+|\s+$/gm, '');
|
|
}
|
|
|
|
const styles = css
|
|
.split(';')
|
|
.map(trim)
|
|
.filter((s) => s.length);
|
|
|
|
style = {};
|
|
for (const s of styles) {
|
|
const parts = s.split(':').map(trim);
|
|
|
|
if (s.indexOf('\n') !== -1) {
|
|
errorMessage += 'Missing semicolon: ' + s.split('\n')[0];
|
|
} else if (parts.length !== 2) {
|
|
errorMessage += 'Syntax error: ' + s;
|
|
} else {
|
|
const nameParts = parts[0].split('-');
|
|
for (let i = 1; i < nameParts.length; i++) {
|
|
nameParts[i] = nameParts[i][0].toUpperCase() + nameParts[i].substring(1);
|
|
}
|
|
style[nameParts.join('')] = parts[1];
|
|
}
|
|
}
|
|
|
|
if (errorMessage) {
|
|
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'css-parse-waring', {
|
|
message: 'Error in CSS Style<br>' + errorMessage
|
|
});
|
|
} else {
|
|
this.context.editorConnection.clearWarning(this.nodeScope.componentOwner.name, this.id, 'css-parse-waring');
|
|
style && this.setStyle(style);
|
|
this.customCssStyles = style;
|
|
}
|
|
},
|
|
setChildIndex(index) {
|
|
this.childIndex = index;
|
|
this.flagOutputDirty('childIndex');
|
|
},
|
|
updateChildIndices() {
|
|
let indexOffset = 0;
|
|
for (let i = 0; i < this.children.length; i++) {
|
|
const child = this.children[i];
|
|
if (child.name === 'For Each' || child.name === 'Component Children') {
|
|
indexOffset--;
|
|
}
|
|
child.setChildIndex && child.setChildIndex(i + indexOffset);
|
|
}
|
|
},
|
|
updateChildrenCount() {
|
|
let count = 0;
|
|
this.children.forEach((child) => {
|
|
if (child?.model?.type === 'For Each') {
|
|
count += child.model.children.length;
|
|
} else {
|
|
count++;
|
|
}
|
|
});
|
|
this.childrenCount = count;
|
|
this.flagOutputDirty('childrenCount');
|
|
},
|
|
addChild(child, index) {
|
|
if (index === undefined) {
|
|
index = this.children.length;
|
|
}
|
|
|
|
child.parent = this;
|
|
this.children.splice(index, 0, child);
|
|
this.cachedChildren = undefined;
|
|
this.scheduleUpdateChildCountAndIndicies();
|
|
this.forceUpdate();
|
|
},
|
|
removeChild(child) {
|
|
const index = this.children.indexOf(child);
|
|
if (index !== -1) {
|
|
this.children.splice(index, 1);
|
|
child.parent = undefined;
|
|
|
|
this.cachedChildren = undefined;
|
|
this.scheduleUpdateChildCountAndIndicies();
|
|
this.forceUpdate();
|
|
}
|
|
},
|
|
contains(node) {
|
|
//breadth first
|
|
const index = this.children.indexOf(node);
|
|
if (index !== -1) return true;
|
|
|
|
return this.children.some((child) => child.contains && child.contains(node));
|
|
},
|
|
scheduleUpdateChildCountAndIndicies() {
|
|
if (this.updateChildIndiciesScheduled) return;
|
|
this.updateChildIndiciesScheduled = true;
|
|
this.scheduleAfterInputsHaveUpdated(() => {
|
|
this.updateChildIndices();
|
|
if (hasChildCountOutput) {
|
|
this.updateChildrenCount();
|
|
}
|
|
this.updateChildIndiciesScheduled = false;
|
|
});
|
|
},
|
|
getChildren() {
|
|
return this.children;
|
|
},
|
|
isChild(child) {
|
|
return this.children.indexOf(child) !== -1;
|
|
},
|
|
getChildRoot() {
|
|
return this;
|
|
},
|
|
forceUpdate() {
|
|
if (this.forceUpdateScheduled === true) return;
|
|
this.forceUpdateScheduled = true;
|
|
|
|
this.context.eventEmitter.once('frameEnd', () => {
|
|
this.forceUpdateScheduled = false;
|
|
|
|
if (this.renderedAtFrame === this.context.frameNumber) {
|
|
return;
|
|
}
|
|
|
|
this.reactComponentRef && this.reactComponentRef.setState({});
|
|
});
|
|
this.context.scheduleUpdate();
|
|
},
|
|
_resetReactVirtualDOM() {
|
|
//reset the react key to force a full re-render
|
|
//this can be required since we're editing the DOM tree, without React knowing
|
|
//and can confuse React in certain cases
|
|
this.reactKey = 'key' + reactKeyCounter;
|
|
reactKeyCounter++;
|
|
const parent = this.getVisualParentNode();
|
|
if (parent) {
|
|
parent.cachedChildren = undefined;
|
|
parent.forceUpdate();
|
|
}
|
|
},
|
|
/** Added for SSR Support */
|
|
triggerDidMount() {
|
|
if (this.wantsToBeMounted && !this.didCallTriggerDidMount) {
|
|
this.didCallTriggerDidMount = true;
|
|
|
|
if (this.hasOutput('didMount')) {
|
|
this.sendSignalOnOutput('didMount');
|
|
}
|
|
|
|
// HACK: This is requried for the Page Router.
|
|
if (this.props.didMount) {
|
|
this.props.didMount();
|
|
}
|
|
|
|
// HACK: Repeater... same as above
|
|
if (this.didMount) {
|
|
this.didMount();
|
|
}
|
|
}
|
|
|
|
this.children.forEach((child) => {
|
|
// TODO: Repeater is missing triggerDidMount
|
|
child.triggerDidMount && child.triggerDidMount();
|
|
});
|
|
},
|
|
render() {
|
|
if (!this.wantsToBeMounted) {
|
|
return;
|
|
}
|
|
|
|
//these props will only be sent when this component
|
|
//is added to the react tree. Further updates will only call
|
|
//render on the NoodlReactComponent, so make sure these props
|
|
//don't need to change over the lifetime of this node
|
|
return React.createElement(NoodlReactComponent, {
|
|
key: this.reactKey,
|
|
noodlNode: this,
|
|
ref: (ref) => {
|
|
this.reactComponentRef = ref;
|
|
this.boundingBoxObserver.setTarget(ReactDOM.findDOMNode(ref));
|
|
}
|
|
});
|
|
},
|
|
renderChildren() {
|
|
if (!this.cachedChildren) {
|
|
let c = this.children.map((child) => child.render());
|
|
|
|
let children = [];
|
|
flattenArray(children, c);
|
|
|
|
if (children.length === 0) {
|
|
children = null;
|
|
} else if (children.length === 1) {
|
|
//some components expects a single child only, and won't handle arrays
|
|
//and React.Children.only() throws an error on an array with only one child
|
|
children = children[0];
|
|
}
|
|
|
|
this.cachedChildren = children;
|
|
}
|
|
|
|
return this.cachedChildren;
|
|
},
|
|
setStyle(newStyles, styleTag) {
|
|
//this method will try to set css styles directly on the
|
|
//raw DOM elements, circumventing React.
|
|
//However, there's some layout and align attributes that are dependent
|
|
//on each other and need to go through additional processing.
|
|
//This function will detect those and trigger everything to run through the
|
|
//React component when necessary.
|
|
|
|
//TODO: move all these checks to the inputs themselves
|
|
//so the set-method of e.g. marginLeft can do the check and either
|
|
//set the style directly on the dom node, or trigger a react render
|
|
const styleObject = styleTag ? this.props.styles[styleTag] : this.style;
|
|
|
|
for (const p in newStyles) {
|
|
styleObject[p] = newStyles[p];
|
|
}
|
|
|
|
const domElement = this.getDOMElement();
|
|
if (!domElement) return;
|
|
|
|
let forceUpdate = false;
|
|
|
|
if (!styleTag) {
|
|
if (newStyles.hasOwnProperty('opacity')) {
|
|
//opacity change between zero and non-zero can toggle pointer events
|
|
//to be treated differently, so make sure to force update during those transitions
|
|
|
|
//note: properties in domElement.style are all strings
|
|
forceUpdate =
|
|
newStyles.hasOwnProperty('opacity') &&
|
|
((domElement.style.opacity === '0' && newStyles.opacity > 0) ||
|
|
(domElement.style.opacity !== '0' && newStyles.opacity === 0));
|
|
}
|
|
if (newStyles.transform) {
|
|
let transform = newStyles.transform;
|
|
|
|
const parent = this.getVisualParentNode();
|
|
//three ways we can be position absolute:
|
|
//1. user explicitly set this node to absolute
|
|
//2. parent has no layout, which translate into no flexDirection
|
|
//3. there is no parent, meaning we're a root in the root component
|
|
if (this.style.position === 'absolute' || !parent || !parent.style.flexDirection) {
|
|
if (this.props.alignX === 'center' && !(domElement.style.marginLeft && domElement.style.marginRight))
|
|
transform = 'translateX(-50%) ' + transform;
|
|
if (this.props.alignY === 'center' && !(domElement.style.marginTop && domElement.style.marginBottom))
|
|
transform = 'translateY(-50%) ' + transform;
|
|
}
|
|
|
|
newStyles.transform = transform;
|
|
}
|
|
|
|
const marginsChanged =
|
|
newStyles.hasOwnProperty('marginLeft') ||
|
|
newStyles.hasOwnProperty('marginRight') ||
|
|
newStyles.hasOwnProperty('marginTop') ||
|
|
newStyles.hasOwnProperty('marginBottom');
|
|
const sizeInPercent =
|
|
(this.props.width && this.props.width[this.props.width.length - 1] === '%') ||
|
|
(this.props.height && this.props.height[this.props.height.length - 1] === '%');
|
|
if (sizeInPercent && marginsChanged) {
|
|
forceUpdate = true;
|
|
}
|
|
|
|
if (newStyles.position || newStyles.flexDirection || newStyles.clip) {
|
|
forceUpdate = true;
|
|
}
|
|
}
|
|
|
|
if (forceUpdate) {
|
|
this.forceUpdate();
|
|
} else {
|
|
setStylesOnDOMNode(domElement, newStyles, styleTag);
|
|
}
|
|
},
|
|
removeStyle(styles, styleTag) {
|
|
const styleObject = styleTag ? this.props.styles[styleTag] : this.style;
|
|
|
|
for (const p of styles) {
|
|
delete styleObject[p];
|
|
}
|
|
|
|
const domElement = this.getDOMElement();
|
|
|
|
let forceUpdate = false;
|
|
if (!styleTag && domElement) {
|
|
const forceUpdateAttributes = {
|
|
marginTop: true,
|
|
marginBottom: true,
|
|
marginLeft: true,
|
|
marginRight: true
|
|
};
|
|
|
|
for (const p of styles) {
|
|
if (forceUpdateAttributes[p]) forceUpdate = true;
|
|
}
|
|
}
|
|
|
|
if (domElement) {
|
|
//deleting styles is done by setting them to an empty string
|
|
const newStyles = {};
|
|
for (const p of styles) {
|
|
newStyles[p] = '';
|
|
}
|
|
setStylesOnDOMNode(domElement, newStyles, styleTag);
|
|
}
|
|
|
|
if (forceUpdate) {
|
|
this.forceUpdate();
|
|
}
|
|
},
|
|
getStyle(style) {
|
|
return this.style[style];
|
|
},
|
|
getRef() {
|
|
return this.reactComponentRef;
|
|
},
|
|
getDOMElement() {
|
|
const ref = this.getRef();
|
|
if (!ref) return;
|
|
|
|
return ReactDOM.findDOMNode(ref);
|
|
},
|
|
getVisualParentNode() {
|
|
if (this.parent) return this.parent;
|
|
|
|
//we're a root
|
|
let component = this.nodeScope.componentOwner;
|
|
while (!component.parent && component.parentNodeScope) {
|
|
component = component.parentNodeScope.componentOwner;
|
|
}
|
|
|
|
return component ? component.parent : undefined;
|
|
},
|
|
setVariant(variant) {
|
|
//stop any state transitions that are currently running
|
|
this._stopStateTransitions();
|
|
|
|
this.variant = variant;
|
|
|
|
const parameters = {};
|
|
//apply base variant parameters
|
|
variant && mergeDeep(parameters, variant.parameters);
|
|
|
|
//apply base node parameters
|
|
mergeDeep(parameters, this.model.parameters);
|
|
|
|
//and then states, if any
|
|
if (this.currentVisualStates) {
|
|
const stateParameters = this.getParametersForStates(this.currentVisualStates);
|
|
mergeDeep(parameters, stateParameters);
|
|
}
|
|
|
|
const parametersToSet = Object.keys(parameters).filter((p) => !this._hasInputBeenSetFromAConnection(p));
|
|
|
|
for (const inputName of parametersToSet) {
|
|
this.registerInputIfNeeded(inputName);
|
|
|
|
if (this.hasInput(inputName)) {
|
|
this.queueInput(inputName, parameters[inputName]);
|
|
}
|
|
}
|
|
},
|
|
getParameter(name) {
|
|
if (this.model.parameters.hasOwnProperty(name)) {
|
|
return this.model.parameters[name];
|
|
} else if (this.variant && this.variant.parameters.hasOwnProperty(name)) {
|
|
return this.variant.parameters[name];
|
|
} else {
|
|
return this.context.getDefaultValueForInput(this.model.type, name);
|
|
}
|
|
},
|
|
getParametersForStates(states) {
|
|
const params = {};
|
|
|
|
//1. Get the parameters from the variant
|
|
//2. Then override with all local values from the node's neutral state (so a color in neutral will override all states from the variant)
|
|
//3. then apply the node specific state parameters
|
|
|
|
//1. Apply variant states
|
|
if (this.variant) {
|
|
for (const state of states) {
|
|
if (this.variant.stateParameters && this.variant.stateParameters.hasOwnProperty(state)) {
|
|
mergeDeep(params, this.variant.stateParameters[state]);
|
|
}
|
|
}
|
|
}
|
|
|
|
//2. Override with local values from the nodes neutral state
|
|
for (const param in params) {
|
|
if (this.model.parameters.hasOwnProperty(param)) {
|
|
if (isObject(params[param])) {
|
|
mergeDeep(params[param], this.model.parameters[param]);
|
|
} else {
|
|
params[param] = this.model.parameters[param];
|
|
}
|
|
}
|
|
}
|
|
// mergeDeep(params, this.model.parameters);
|
|
|
|
//3. Apply node specific state paramters
|
|
if (this.model.stateParameters) {
|
|
for (const state of states) {
|
|
if (this.model.stateParameters.hasOwnProperty(state)) {
|
|
mergeDeep(params, this.model.stateParameters[state]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return params;
|
|
},
|
|
_getNewState(prevStates, newStates) {
|
|
const addedStates = newStates.filter((value) => !(prevStates || []).includes(value));
|
|
const newState = addedStates.length ? addedStates[0] : 'neutral';
|
|
|
|
return newState === '' ? 'neutral' : newState;
|
|
},
|
|
_getDefaultTransition(state) {
|
|
if (
|
|
this.model.defaultStateTransitions &&
|
|
this.model.defaultStateTransitions[state] &&
|
|
this.model.defaultStateTransitions[state].curve
|
|
) {
|
|
return this.model.defaultStateTransitions[state];
|
|
} else if (
|
|
this.variant &&
|
|
this.variant.defaultStateTransitions &&
|
|
this.variant.defaultStateTransitions[state] &&
|
|
this.variant.defaultStateTransitions[state].curve
|
|
) {
|
|
return this.variant.defaultStateTransitions[state];
|
|
}
|
|
},
|
|
_getStateTransition(state) {
|
|
let transitions = {};
|
|
|
|
if (this.model.stateTransitions && this.model.stateTransitions[state]) {
|
|
Object.assign(transitions, this.model.stateTransitions[state]);
|
|
}
|
|
|
|
if (this.variant && this.variant.stateTransitions && this.variant.stateTransitions[state]) {
|
|
Object.assign(transitions, this.variant.stateTransitions[state]);
|
|
}
|
|
|
|
return transitions;
|
|
},
|
|
setVisualStates(newStates) {
|
|
if (!this.model) {
|
|
//this node has probably been generated by a router or similar, and is an internal node without a model
|
|
//those nodes can't have visual states
|
|
return;
|
|
}
|
|
|
|
const statesAreEqual =
|
|
this.currentVisualStates &&
|
|
newStates.length === this.currentVisualStates.length &&
|
|
newStates.every((val, index) => val === this.currentVisualStates[index]);
|
|
if (statesAreEqual) return;
|
|
|
|
const prevStateParams = this.currentVisualStates ? this.getParametersForStates(this.currentVisualStates) : {};
|
|
const newStateParams = this.getParametersForStates(newStates);
|
|
|
|
const newState = this._getNewState(this.currentVisualStates, newStates);
|
|
|
|
this.currentVisualStates = newStates;
|
|
|
|
const newValues = {};
|
|
|
|
//all params that were in the old states, but not in the new states, needs to be reset back to original state
|
|
for (const param in prevStateParams) {
|
|
if (!newStateParams.hasOwnProperty(param) && !this._hasInputBeenSetFromAConnection(param)) {
|
|
const value = this.getParameter(param);
|
|
if (value !== undefined) {
|
|
newValues[param] = this.getParameter(param);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const param in newStateParams) {
|
|
if (!this._hasInputBeenSetFromAConnection(param) && newStateParams[param] !== undefined) {
|
|
newValues[param] = newStateParams[param];
|
|
}
|
|
}
|
|
|
|
const defaultTransition = this._getDefaultTransition(newState);
|
|
const stateTransition = this._getStateTransition(newState);
|
|
|
|
for (const param in newValues) {
|
|
if (stateTransition[param] && stateTransition[param].curve) {
|
|
transitionParameter(this, param, newValues[param], stateTransition[param]);
|
|
} else if (!stateTransition[param] && defaultTransition) {
|
|
transitionParameter(this, param, newValues[param], defaultTransition);
|
|
} else {
|
|
//stop any running transition
|
|
if (this._transitions && this._transitions[param]) {
|
|
this._transitions[param].stop();
|
|
delete this._transitions[param];
|
|
}
|
|
|
|
this.queueInput(param, newValues[param]);
|
|
}
|
|
}
|
|
},
|
|
_getVisualStates() {
|
|
return this.currentVisualStates || [];
|
|
},
|
|
_stopStateTransitions() {
|
|
if (!this._transitions) return;
|
|
|
|
for (const name in this._transitions) {
|
|
this._transitions[name].stop();
|
|
delete this._transitions[name];
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
if (useVariants) {
|
|
ReactComponentNode.inputs.variant = {
|
|
displayName: 'Variant',
|
|
group: 'General',
|
|
type: {
|
|
name: 'string',
|
|
allowConnectionsOnly: true
|
|
},
|
|
set(variantName) {
|
|
if (this.variant && this.variant.name === variantName) return;
|
|
const variant = this.context.variants.getVariant(this.model.type, variantName);
|
|
variant && this.setVariant(variant);
|
|
}
|
|
};
|
|
}
|
|
|
|
if (def.mountedInput !== false) {
|
|
ReactComponentNode.inputs.mounted = {
|
|
displayName: 'Mounted',
|
|
index: 9999,
|
|
type: 'boolean',
|
|
group: 'General',
|
|
default: true,
|
|
set(value) {
|
|
value = value ? true : false;
|
|
if (this.wantsToBeMounted !== value) {
|
|
this.wantsToBeMounted = value;
|
|
//either we have a direct parent, or we're a root and need to tell
|
|
//the parent of the component instance instead
|
|
const parent = this.getVisualParentNode();
|
|
if (parent) {
|
|
parent.cachedChildren = undefined;
|
|
parent.forceUpdate();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
const hasChildCountOutput = ReactComponentNode.allowChildren || ReactComponentNode.displayName;
|
|
|
|
if (hasChildCountOutput) {
|
|
ReactComponentNode.outputs.childrenCount = {
|
|
displayName: 'Children Count',
|
|
type: 'number',
|
|
get() {
|
|
return this.childrenCount;
|
|
}
|
|
};
|
|
}
|
|
|
|
//regular inputs
|
|
for (const name in inputs) {
|
|
ReactComponentNode.inputs[name] = inputs[name];
|
|
}
|
|
|
|
//inputs that set react props
|
|
for (const inputName in inputProps) {
|
|
const input = inputProps[inputName];
|
|
if (input.type === 'node') {
|
|
input.type = 'reference';
|
|
input.set = function (value) {
|
|
const props = input.propPath ? this.props[input.propPath] : this.props;
|
|
if (value !== undefined) {
|
|
props[inputName] = value.render();
|
|
} else {
|
|
delete props[inputName];
|
|
}
|
|
this.forceUpdate();
|
|
};
|
|
} else {
|
|
if (input.type === 'signal') {
|
|
console.error(`Error: Signals not supported as a react prop. node: '${def.name}' input: '${inputName}'`);
|
|
} else {
|
|
defineRegularInputProp(input, inputName);
|
|
}
|
|
}
|
|
|
|
ReactComponentNode.inputs[inputName] = input;
|
|
}
|
|
|
|
//inputs that set a css attribute
|
|
for (const name in inputCss) {
|
|
const input = inputCss[name];
|
|
const styleTargetName = input.targetStyleProperty || name;
|
|
|
|
if (input.type.units) {
|
|
input.set = function (value) {
|
|
if (typeof value !== 'object' && input.type.defaultUnit) {
|
|
value = { value, unit: input.type.defaultUnit };
|
|
}
|
|
|
|
if (typeof value === 'object' && value.value !== undefined) {
|
|
//this is a value with a unit
|
|
this.setStyle({ [styleTargetName]: value.value + value.unit }, input.styleTag);
|
|
} else if (value !== undefined) {
|
|
//value without a unit. One example is line height, that can be unitless
|
|
this.setStyle({ [styleTargetName]: value }, input.styleTag);
|
|
} else {
|
|
//value is undefined, so just reset the style
|
|
this.removeStyle([styleTargetName], input.styleTag);
|
|
}
|
|
if (input.onChange) {
|
|
input.onChange.call(this, value);
|
|
}
|
|
};
|
|
} else {
|
|
input.set = function (value) {
|
|
if (value !== undefined) {
|
|
this.setStyle({ [styleTargetName]: value }, input.styleTag);
|
|
} else {
|
|
this.removeStyle([styleTargetName], input.styleTag);
|
|
}
|
|
if (input.onChange) {
|
|
input.onChange.call(this, value);
|
|
}
|
|
};
|
|
}
|
|
|
|
ReactComponentNode.inputs[name] = input;
|
|
}
|
|
|
|
//regular outputs
|
|
for (const name in outputs) {
|
|
ReactComponentNode.outputs[name] = outputs[name];
|
|
}
|
|
|
|
//outputs triggered by react props
|
|
for (const name in outputProps) {
|
|
const output = outputProps[name];
|
|
|
|
if (output.type !== 'signal') {
|
|
output.get = function () {
|
|
return this.outputPropValues[name];
|
|
};
|
|
}
|
|
|
|
ReactComponentNode.outputs[name] = output;
|
|
}
|
|
|
|
for (const name in methods) {
|
|
ReactComponentNode.methods[name] = methods[name];
|
|
}
|
|
|
|
return {
|
|
node: ReactComponentNode,
|
|
setup: def.setup
|
|
};
|
|
}
|
|
|
|
function isObject(item) {
|
|
return item && typeof item === 'object' && !Array.isArray(item);
|
|
}
|
|
|
|
export { createNodeFromReactComponent };
|