Initial commit

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>
This commit is contained in:
Michael Cartner
2024-01-26 11:52:55 +01:00
commit b9c60b07dc
2789 changed files with 868795 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
import { Button } from '../../components/controls/Button';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
import Utils from './utils';
const ButtonNode = {
name: 'net.noodl.controls.button',
displayName: 'Button',
docs: 'https://docs.noodl.net/nodes/ui-controls/button',
allowChildren: true,
noodlNodeAsProp: true,
usePortAsLabel: 'label',
portLabelTruncationMode: 'length',
nodeDoubleClickAction: {
focusPort: 'label'
},
connectionPanel: {
groupPriority: [
'General',
'Style',
'Actions',
'Events',
'States',
'Mounted',
'Label',
'Label Text Style',
'Hover Events',
'Pointer Events',
'Focus Events'
]
},
initialize() {
this.props.layout = 'row'; //Used to tell child nodes what layout to expect
},
getReactComponent() {
return Button;
},
inputCss: {
backgroundColor: {
index: 100,
displayName: 'Background Color',
group: 'Style',
type: 'color',
default: '#000000',
allowVisualStates: true
}
},
outputProps: {
onClick: {
displayName: 'Click',
group: 'Events',
type: 'signal'
}
}
};
NodeSharedPortDefinitions.addDimensions(ButtonNode, {
defaultSizeMode: 'contentSize',
contentLabel: 'Content'
});
NodeSharedPortDefinitions.addTextStyleInputs(ButtonNode);
NodeSharedPortDefinitions.addAlignInputs(ButtonNode);
NodeSharedPortDefinitions.addTransformInputs(ButtonNode);
NodeSharedPortDefinitions.addPaddingInputs(ButtonNode, {
defaults: {
paddingTop: 5,
paddingRight: 20,
paddingBottom: 5,
paddingLeft: 20
}
});
NodeSharedPortDefinitions.addMarginInputs(ButtonNode);
NodeSharedPortDefinitions.addLabelInputs(ButtonNode, {
defaults: { useLabel: true }
});
NodeSharedPortDefinitions.addIconInputs(ButtonNode, {
enableIconPlacement: true,
defaults: { useIcon: false }
});
NodeSharedPortDefinitions.addSharedVisualInputs(ButtonNode);
NodeSharedPortDefinitions.addBorderInputs(ButtonNode);
NodeSharedPortDefinitions.addShadowInputs(ButtonNode);
Utils.addControlEventsAndStates(ButtonNode);
export default createNodeFromReactComponent(ButtonNode);

View File

@@ -0,0 +1,178 @@
import { Checkbox } from '../../components/controls/Checkbox';
import guid from '../../guid';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
import Utils from './utils';
const CheckBoxNode = {
name: 'net.noodl.controls.checkbox',
displayName: 'Checkbox',
docs: 'https://docs.noodl.net/nodes/ui-controls/checkbox',
allowChildren: false,
noodlNodeAsProp: true,
nodeDoubleClickAction: {
focusPort: 'label'
},
usePortAsLabel: 'label',
portLabelTruncationMode: 'length',
connectionPanel: {
groupPriority: [
'General',
'Style',
'Actions',
'Events',
'States',
'Mounted',
'Label',
'Label Text Style',
'Hover Events',
'Pointer Events',
'Focus Events'
]
},
initialize() {
this.props.sizeMode = 'explicit';
this.props.id = 'input-' + guid();
this.props.checked = this._internal.checked = false;
this.props.checkedChanged = (checked) => {
const changed = this._internal.checked !== checked;
this._internal.checked = checked;
if (changed) {
this.flagOutputDirty('checked');
this.sendSignalOnOutput('onChange');
this._updateVisualState();
}
};
},
getReactComponent() {
return Checkbox;
},
inputs: {
checked: {
type: 'boolean',
displayName: 'Checked',
group: 'General',
default: false,
index: 100,
set: function (value) {
value = !!value;
const changed = value !== this._internal.checked;
this.props.checked = this._internal.checked = value;
if (changed) {
this.forceUpdate();
this.flagOutputDirty('checked');
this._updateVisualState();
}
}
},
check: {
type: 'signal',
displayName: 'Check',
group: 'Actions',
valueChangedToTrue() {
if (this._internal.checked === true) return;
this.props.checked = this._internal.checked = true;
this.forceUpdate();
this.flagOutputDirty('checked');
this._updateVisualState();
}
},
uncheck: {
type: 'signal',
displayName: 'Uncheck',
group: 'Actions',
valueChangedToTrue() {
if (this._internal.checked === false) return;
this.props.checked = this._internal.checked = false;
this.forceUpdate();
this.flagOutputDirty('checked');
this._updateVisualState();
}
}
},
inputCss: {
backgroundColor: {
index: 201,
displayName: 'Background Color',
group: 'Style',
type: 'color',
default: 'transparent',
applyDefault: false,
allowVisualStates: true,
styleTag: 'checkbox'
},
width: {
index: 11,
group: 'Dimensions',
displayName: 'Width',
type: {
name: 'number',
units: ['px', 'vw', 'vh'],
defaultUnit: 'px'
},
default: 32,
allowVisualStates: true,
styleTag: 'checkbox'
},
height: {
index: 12,
group: 'Dimensions',
displayName: 'Height',
type: {
name: 'number',
units: ['px', 'vw', 'vh'],
defaultUnit: 'px'
},
default: 32,
allowVisualStates: true,
styleTag: 'checkbox'
}
},
outputs: {
checked: {
type: 'boolean',
displayName: 'Checked',
group: 'States',
getter: function () {
return this._internal.checked;
}
},
onChange: {
displayName: 'Changed',
group: 'Events',
type: 'signal'
}
}
};
NodeSharedPortDefinitions.addAlignInputs(CheckBoxNode);
NodeSharedPortDefinitions.addTransformInputs(CheckBoxNode);
NodeSharedPortDefinitions.addMarginInputs(CheckBoxNode);
NodeSharedPortDefinitions.addPaddingInputs(CheckBoxNode);
NodeSharedPortDefinitions.addIconInputs(CheckBoxNode);
NodeSharedPortDefinitions.addLabelInputs(CheckBoxNode, {
enableSpacing: true,
styleTag: 'label'
});
NodeSharedPortDefinitions.addSharedVisualInputs(CheckBoxNode);
NodeSharedPortDefinitions.addBorderInputs(CheckBoxNode, {
defaults: {
borderStyle: 'solid',
borderWidth: 2,
borderColor: '#000000',
borderRadius: 3
},
styleTag: 'checkbox'
});
NodeSharedPortDefinitions.addShadowInputs(CheckBoxNode, {
styleTag: 'checkbox'
});
Utils.addControlEventsAndStates(CheckBoxNode, { checked: true });
export default createNodeFromReactComponent(CheckBoxNode);

View File

@@ -0,0 +1,171 @@
import { Select } from '../../components/controls/Select';
import guid from '../../guid';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
import Utils from './utils';
const OptionsNode = {
name: 'net.noodl.controls.options',
displayName: 'Dropdown',
docs: 'https://docs.noodl.net/nodes/ui-controls/dropdown',
allowChildren: false,
noodlNodeAsProp: true,
usePortAsLabel: 'label',
nodeDoubleClickAction: {
focusPort: 'label'
},
connectionPanel: {
groupPriority: [
'General',
'Style',
'Actions',
'Events',
'States',
'Mounted',
'Text Style',
'Label',
'Label Text Style',
'Hover Events',
'Pointer Events',
'Focus Events'
]
},
initialize: function () {
this._itemsChanged = () => {
this.forceUpdate();
};
this.props.id = 'input-' + guid();
this.props.valueChanged = (value) => {
const changed = this._internal.value !== value;
this._internal.value = value;
if (changed) {
this.flagOutputDirty('value');
this.sendSignalOnOutput('onChange');
}
};
},
getReactComponent() {
return Select;
},
inputs: {
items: {
type: 'array',
displayName: 'Items',
group: 'General',
set: function (newValue) {
if (this._internal.items !== newValue && this._internal.items !== undefined) {
this._internal.items.off('change', this._itemsChanged);
}
this._internal.items = newValue;
this._internal.items.on('change', this._itemsChanged);
this.props.items = this._internal.items;
this.forceUpdate();
}
},
value: {
type: 'string',
displayName: 'Value',
group: 'General',
set: function (value) {
if (value !== undefined && typeof value !== 'string') {
if (value?.toString !== undefined) value = value.toString();
else return;
}
// // if value is passed in before the items, then items is undefined
// if (this._internal.items) {
// //make sure this is a valid value that exists in the dropdown. If it doesn't, deselect all options
// value = this._internal.items.find((i) => i.Value === value) ? value : undefined;
// }
const changed = value !== this._internal.value;
this.props.value = this._internal.value = value;
if (changed) {
this.forceUpdate();
this.flagOutputDirty('value');
}
}
}
},
inputProps: {
placeholder: {
displayName: 'Placeholder',
type: 'string',
group: 'Placeholder'
},
placeholderOpacity: {
group: 'Placeholder',
displayName: 'Placeholder opacity',
type: 'number',
default: 0.5
}
},
outputs: {
value: {
type: 'string',
displayName: 'Value',
group: 'States',
getter: function () {
return this._internal.value;
}
},
onChange: {
type: 'signal',
displayName: 'Changed',
group: 'Events'
}
},
inputCss: {
backgroundColor: {
index: 100,
displayName: 'Background Color',
group: 'Style',
type: 'color',
default: 'transparent',
styleTag: 'inputWrapper',
allowVisualStates: true
}
}
};
NodeSharedPortDefinitions.addDimensions(OptionsNode, {
defaultSizeMode: 'contentSize',
contentLabel: 'Content'
});
NodeSharedPortDefinitions.addAlignInputs(OptionsNode);
NodeSharedPortDefinitions.addTextStyleInputs(OptionsNode);
NodeSharedPortDefinitions.addTransformInputs(OptionsNode);
NodeSharedPortDefinitions.addPaddingInputs(OptionsNode, {
styleTag: 'inputWrapper'
});
NodeSharedPortDefinitions.addMarginInputs(OptionsNode);
NodeSharedPortDefinitions.addIconInputs(OptionsNode, {
enableIconPlacement: true,
defaults: { useIcon: false, iconColor: '#000000' }
});
NodeSharedPortDefinitions.addLabelInputs(OptionsNode, {
enableSpacing: true,
styleTag: 'label'
});
NodeSharedPortDefinitions.addSharedVisualInputs(OptionsNode);
NodeSharedPortDefinitions.addBorderInputs(OptionsNode, {
defaults: {
borderStyle: 'solid',
borderWidth: 2,
borderColor: '#000000',
borderRadius: 5
},
styleTag: 'inputWrapper'
});
NodeSharedPortDefinitions.addShadowInputs(OptionsNode, {
styleTag: 'inputWrapper'
});
Utils.addControlEventsAndStates(OptionsNode);
export default createNodeFromReactComponent(OptionsNode);

View File

@@ -0,0 +1,164 @@
import { RadioButton } from '../../components/controls/RadioButton';
import guid from '../../guid';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
import Utils from './utils';
const RadioButtonNode = {
name: 'net.noodl.controls.radiobutton',
displayName: 'Radio Button',
docs: 'https://docs.noodl.net/nodes/ui-controls/radio-button',
allowChildren: false,
noodlNodeAsProp: true,
usePortAsLabel: 'label',
nodeDoubleClickAction: {
focusPort: 'label'
},
connectionPanel: {
groupPriority: [
'General',
'Style',
'Fill Style',
'Actions',
'Events',
'States',
'Mounted',
'Label',
'Label Text Style',
'Hover Events',
'Pointer Events',
'Focus Events'
]
},
initialize() {
this.props.sizeMode = 'explicit';
this.props.id = 'input-' + guid();
this._internal.checked = false;
this.props.checkedChanged = (checked) => {
const changed = this._internal.checked !== checked;
this._internal.checked = checked;
if (changed) {
this.flagOutputDirty('checked');
this._updateVisualState();
}
};
this.props.styles.fill = {};
},
getReactComponent() {
return RadioButton;
},
inputs: {
fillColor: {
index: 19,
displayName: 'Fill Color',
group: 'Fill Style',
type: 'color',
allowVisualStates: true,
styleTag: 'fill',
set(value) {
this.setStyle({ backgroundColor: value }, 'fill');
}
}
},
inputProps: {
value: {
type: 'string',
displayName: 'Value',
group: 'General',
index: 100
},
fillSpacing: {
displayName: 'Fill Spacing',
group: 'Fill Style',
type: {
name: 'number',
units: ['px', 'vw', 'vh'],
defaultUnit: 'px'
},
allowVisualStates: true,
default: 2
}
},
inputCss: {
width: {
index: 11,
group: 'Dimensions',
displayName: 'Width',
type: {
name: 'number',
units: ['px', '%', 'vw', 'vh'],
defaultUnit: 'px'
},
default: 32,
allowVisualStates: true,
styleTag: 'radio',
onChange() {
this.forceUpdate();
}
},
height: {
index: 12,
group: 'Dimensions',
displayName: 'Height',
type: {
name: 'number',
units: ['px', '%'],
defaultUnit: 'px'
},
default: 32,
allowVisualStates: true,
styleTag: 'radio',
onChange() {
this.forceUpdate();
}
},
backgroundColor: {
index: 201,
displayName: 'Background Color',
group: 'Style',
type: 'color',
allowVisualStates: true,
styleTag: 'radio',
default: 'transparent',
applyDefault: false
}
},
outputs: {
checked: {
type: 'boolean',
displayName: 'Checked',
group: 'States',
get() {
return this._internal.checked;
}
}
}
};
NodeSharedPortDefinitions.addAlignInputs(RadioButtonNode);
NodeSharedPortDefinitions.addTransformInputs(RadioButtonNode);
NodeSharedPortDefinitions.addMarginInputs(RadioButtonNode);
NodeSharedPortDefinitions.addPaddingInputs(RadioButtonNode);
NodeSharedPortDefinitions.addIconInputs(RadioButtonNode);
NodeSharedPortDefinitions.addLabelInputs(RadioButtonNode, {
enableSpacing: true,
styleTag: 'label'
});
NodeSharedPortDefinitions.addSharedVisualInputs(RadioButtonNode);
NodeSharedPortDefinitions.addBorderInputs(RadioButtonNode, {
defaults: {
borderStyle: 'solid',
borderWidth: 2,
borderRadius: 16
},
styleTag: 'radio'
});
NodeSharedPortDefinitions.addShadowInputs(RadioButtonNode, {
styleTag: 'radio'
});
Utils.addControlEventsAndStates(RadioButtonNode, { checked: true });
export default createNodeFromReactComponent(RadioButtonNode);

View File

@@ -0,0 +1,125 @@
import { RadioButtonGroup } from '../../components/controls/RadioButtonGroup';
import { flexDirectionValues } from '../../constants/flex';
import guid from '../../guid';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
let RadioButtonGroupNode = {
name: 'Radio Button Group',
displayName: 'Radio Button Group',
docs: 'https://docs.noodl.net/nodes/ui-controls/radio-button-group',
allowChildren: true,
noodlNodeAsProp: true,
useVariants: false,
connectionPanel: {
groupPriority: ['General', 'Style', 'Actions', 'Events', 'Mounted', 'States']
},
initialize() {
this.props.name = 'radio-' + guid();
this.props.valueChanged = (value) => {
const changed = this._internal.value !== value;
this._internal.value = value;
this.props.value = value;
if (changed) {
this.forceUpdate();
this.flagOutputDirty('value');
this.sendSignalOnOutput('onChange');
}
};
},
getReactComponent() {
return RadioButtonGroup;
},
defaultCss: {
display: 'flex',
position: 'relative',
flexDirection: 'column'
},
inputs: {
flexDirection: {
//don't rename for backwards compat
index: 11,
displayName: 'Layout',
group: 'Layout',
type: {
name: 'enum',
enums: [
{ label: 'Vertical', value: 'column' },
{ label: 'Horizontal', value: 'row' }
]
},
default: 'column',
set(value) {
this.props.layout = value;
if (value !== 'none') {
this.setStyle({ flexDirection: value });
} else {
this.removeStyle(['flexDirection']);
}
if (this.context.editorConnection) {
// Send warning if the value is wrong
if (value !== 'none' && !flexDirectionValues.includes(value)) {
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'layout-warning', {
message: 'Invalid Layout value has to be a valid flex-direction value.'
});
} else {
this.context.editorConnection.clearWarning(this.nodeScope.componentOwner.name, this.id, 'layout-warning');
}
}
this.forceUpdate();
}
},
value: {
index: 20,
type: 'string',
displayName: 'Value',
group: 'General',
set: function (value) {
if (typeof value !== 'string' && value.toString !== undefined) value = value.toString();
if (typeof value !== 'string') return;
const changed = value !== this._internal.value;
this.props.value = this._internal.value = value;
if (changed) {
this.forceUpdate();
this.flagOutputDirty('value');
}
}
}
},
outputs: {
value: {
type: 'string',
displayName: 'Value',
group: 'States',
getter: function () {
return this._internal.value;
}
},
onChange: {
type: 'signal',
displayName: 'Changed',
group: 'Events'
}
},
inputProps: {},
outputProps: {}
};
NodeSharedPortDefinitions.addDimensions(RadioButtonGroupNode, {
defaultSizeMode: 'contentSize',
contentLabel: 'Content'
});
NodeSharedPortDefinitions.addAlignInputs(RadioButtonGroupNode);
NodeSharedPortDefinitions.addTransformInputs(RadioButtonGroupNode);
NodeSharedPortDefinitions.addMarginInputs(RadioButtonGroupNode);
NodeSharedPortDefinitions.addPaddingInputs(RadioButtonGroupNode);
NodeSharedPortDefinitions.addSharedVisualInputs(RadioButtonGroupNode);
RadioButtonGroupNode = createNodeFromReactComponent(RadioButtonGroupNode);
export default RadioButtonGroupNode;

View File

@@ -0,0 +1,498 @@
import { Slider } from '../../components/controls/Slider';
import guid from '../../guid';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
import Utils from './utils';
const thumbPopout = { group: 'thumb-styles', label: 'Thumb Styles' };
const trackPopout = { group: 'track-styles', label: 'Track Styles' };
const RangeNode = {
name: 'net.noodl.controls.range',
displayNodeName: 'Slider',
docs: 'https://docs.noodl.net/nodes/ui-controls/slider',
allowChildren: false,
noodlNodeAsProp: true,
connectionPanel: {
groupPriority: [
'General',
'Style',
'Actions',
'Events',
'States',
'Mounted',
'Hover Events',
'Pointer Events',
'Focus Events'
]
},
initialize() {
this.props.sizeMode = 'contentHeight';
this.props.id = 'input-' + guid();
this.props.value = this.props.min;
this._internal.outputValue = 0;
this.props._nodeId = this.id;
//this is used by the range to communicate with the node whenever the range changes value
this.props.updateOutputValue = (value: string | number) => {
value = typeof value === 'string' ? parseFloat(value) : value;
const valueChanged = this._internal.outputValue !== value;
if (valueChanged) {
this._internal.outputValue = value;
this.flagOutputDirty('value');
this._updateOutputValuePercent(value);
this.sendSignalOnOutput('onChange');
}
// On first mount, output the initial percentage.
if (!this._internal.valuePercent) {
this._updateOutputValuePercent(value);
}
};
this.props.updateOutputValue(this.props.value); // Set the value output to an initial value
},
getReactComponent() {
return Slider;
},
inputs: {
value: {
type: 'string',
displayName: 'Value',
group: 'General',
index: 100,
set(value) {
this._setInputValue(value);
}
}
},
outputs: {
value: {
type: 'number',
displayName: 'Value',
group: 'States',
get() {
return this._internal.outputValue;
}
},
valuePercent: {
type: 'number',
displayName: 'Value Percent',
group: 'States',
get() {
return this._internal.valuePercent;
}
},
onChange: {
type: 'signal',
displayName: 'Changed',
group: 'Events'
}
},
inputProps: {
min: {
type: 'number',
displayName: 'Min',
group: 'General',
default: 0,
index: 100,
onChange() {
this._setInputValue(this.props.value);
}
},
max: {
type: 'number',
displayName: 'Max',
group: 'General',
default: 100,
index: 100,
onChange() {
this._setInputValue(this.props.value);
}
},
step: {
type: 'number',
displayName: 'Step',
group: 'General',
default: 1,
index: 100
},
width: {
index: 11,
group: 'Dimensions',
displayName: 'Width',
type: {
name: 'number',
units: ['%', 'px', 'vw'],
defaultUnit: '%'
},
default: 100,
allowVisualStates: true
},
// Styles
thumbWidth: {
group: 'Thumb Style',
displayName: 'Width',
type: {
name: 'number',
units: ['px', 'vw', '%'],
defaultUnit: 'px',
allowEditOnly: true
},
default: 16,
popout: thumbPopout,
allowVisualStates: true
},
thumbHeight: {
group: 'Thumb Style',
displayName: 'Height',
type: {
name: 'number',
units: ['px', 'vh', '%'],
defaultUnit: 'px',
allowEditOnly: true
},
default: 16,
popout: thumbPopout,
allowVisualStates: true
},
thumbColor: {
group: 'Thumb Style',
displayName: 'Color',
type: { name: 'color', allowEditOnly: true },
default: '#000000',
popout: thumbPopout,
allowVisualStates: true
},
trackHeight: {
group: 'Track Style',
displayName: 'Height',
type: {
name: 'number',
units: ['px', 'vh', '%'],
defaultUnit: 'px',
allowEditOnly: true
},
default: 6,
popout: trackPopout,
allowVisualStates: true
},
trackColor: {
group: 'Track Style',
displayName: 'Inactive Color',
type: { name: 'color', allowEditOnly: true },
default: '#f0f0f0',
popout: trackPopout,
allowVisualStates: true
},
trackActiveColor: {
group: 'Track Style',
displayName: 'Active Color',
type: { name: 'color', allowEditOnly: true },
default: '#f0f0f0',
popout: trackPopout,
allowVisualStates: true
}
},
methods: {
_updateOutputValuePercent(value: number) {
const min = this.props.min;
const max = this.props.max;
const valuePercent = Math.floor(((value - min) / (max - min)) * 100);
const valuePercentChanged = this._internal.valuePercentChanged !== valuePercent;
this._internal.valuePercent = valuePercent;
valuePercentChanged && this.flagOutputDirty('valuePercent');
},
_setInputValue(newValue) {
//make sure value never goes out of range
const value = Math.max(this.props.min, Math.min(this.props.max, newValue || 0));
const changed = value !== this.props.value;
if (changed) {
this.props.value = value;
this.forceUpdate();
}
}
}
};
//Add borders
function addBorderInputs(definition, opts) {
opts = opts || {};
const defaults = opts.defaults || {};
const popout = opts.popout;
defaults.borderStyle = 'none';
defaults.borderWidth = 0;
defaults.borderColor = '#000000';
const prefixLabel = opts.propPrefix[0].toUpperCase() + opts.propPrefix.slice(1);
function defineBorderTab(definition, suffix, tabName, indexOffset) {
const styleName = opts.propPrefix + `Border${suffix}Style`;
const widthName = opts.propPrefix + `Border${suffix}Width`;
const colorName = opts.propPrefix + `Border${suffix}Color`;
let orNotSet = defaults.borderStyle !== 'none' ? 'OR borderStyle NOT SET' : '';
if (suffix) {
if (defaults[styleName] && defaults[styleName] !== 'none') orNotSet += `OR ${styleName} NOT SET`;
NodeSharedPortDefinitions.addDynamicInputPorts(
definition,
`${styleName} = solid OR ${styleName} = dashed OR ${styleName} = dotted OR borderStyle = solid OR borderStyle = dashed OR borderStyle = dotted ${orNotSet}`,
[`${widthName}`, `${colorName}`]
);
} else {
NodeSharedPortDefinitions.addDynamicInputPorts(
definition,
`${styleName} = solid OR ${styleName} = dashed OR ${styleName} = dotted ${orNotSet}`,
[`${widthName}`, `${colorName}`]
);
}
const tab = {
group: opts.propPrefix + '-border-styles',
tab: tabName,
label: suffix
};
const editorName = (name) => `${prefixLabel} ${name} ${suffix ? '(' + suffix + ')' : ''}`;
const index = 202 + indexOffset * 4;
const groupName = prefixLabel + ' Border Style';
NodeSharedPortDefinitions.addInputProps(definition, {
[styleName]: {
index: index + 1,
displayName: 'Border Style',
editorName: editorName('Border Style'),
group: groupName,
type: {
name: 'enum',
enums: [
{ label: 'None', value: 'none' },
{ label: 'Solid', value: 'solid' },
{ label: 'Dotted', value: 'dotted' },
{ label: 'Dashed', value: 'dashed' }
]
},
default: defaults[`border${suffix}Style`],
tab,
popout,
allowVisualStates: true
},
[widthName]: {
index: index + 2,
displayName: 'Border Width',
editorName: editorName('Border Width'),
group: groupName,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: defaults[`border${suffix}Width`],
tab,
popout,
allowVisualStates: true
},
[colorName]: {
index: index + 3,
displayName: 'Border Color',
editorName: editorName('Border Color'),
group: groupName,
type: 'color',
default: defaults[`border${suffix}Color`],
tab,
popout,
allowVisualStates: true
}
});
}
defineBorderTab(definition, '', 'borders-all', 0);
defineBorderTab(definition, 'Left', 'borders-left', 1);
defineBorderTab(definition, 'Top', 'borders-top', 2);
defineBorderTab(definition, 'Right', 'borders-right', 3);
defineBorderTab(definition, 'Bottom', 'borders-bottom', 4);
}
function addBorderRadius(definition, opts) {
opts = opts || {};
const defaults = opts.defaults || {};
const popout = opts.popout;
if (!defaults.borderRadius) defaults.borderRadius = 0;
const prefixLabel = opts.propPrefix[0].toUpperCase() + opts.propPrefix.slice(1);
function defineCornerTab(definition, suffix, tabName, indexOffset) {
const editorName = (name) => `${prefixLabel} ${name} ${suffix ? '(' + suffix + ')' : ''}`;
const tab = {
group: opts.propPrefix + '-corners',
tab: tabName,
label: suffix
};
const radiusName = `Border${suffix}Radius`;
NodeSharedPortDefinitions.addInputProps(definition, {
[opts.propPrefix + radiusName]: {
index: 240 + indexOffset,
displayName: 'Corner Radius',
editorName: editorName('Corner Radius'),
group: prefixLabel + ' Corner Radius',
type: {
name: 'number',
units: ['px', '%'],
defaultUnit: 'px'
},
default: defaults[`border${suffix}Radius`],
tab,
popout
}
});
}
defineCornerTab(definition, '', 'corners-all', 0);
defineCornerTab(definition, 'TopLeft', 'corners-top-left', 1);
defineCornerTab(definition, 'TopRight', 'corners-top-right', 2);
defineCornerTab(definition, 'BottomRight', 'corners-bottom-right', 3);
defineCornerTab(definition, 'BottomLeft', 'corners-bottom-left', 4);
}
function addShadowInputs(definition, opts) {
opts = opts || {};
const popout = opts.popout;
const prefix = opts.propPrefix;
NodeSharedPortDefinitions.addDynamicInputPorts(definition, `${prefix}BoxShadowEnabled = true`, [
`${prefix}BoxShadowOffsetX`,
`${prefix}BoxShadowOffsetY`,
`${prefix}BoxShadowInset`,
`${prefix}BoxShadowBlurRadius`,
`${prefix}BoxShadowSpreadRadius`,
`${prefix}BoxShadowColor`
]);
const prefixLabel = opts.propPrefix[0].toUpperCase() + opts.propPrefix.slice(1);
const editorName = (name) => `${prefixLabel} ${name}`;
NodeSharedPortDefinitions.addInputProps(definition, {
[`${prefix}BoxShadowEnabled`]: {
index: 250,
group: opts.group || 'Box Shadow',
displayName: 'Shadow Enabled',
editorName: editorName('Shadow Enabled'),
type: 'boolean',
allowVisualStates: true,
popout
},
[`${prefix}BoxShadowOffsetX`]: {
index: 251,
group: opts.group || 'Box Shadow',
displayName: 'Offset X',
editorName: editorName('Offset X'),
default: 0,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
allowVisualStates: true,
popout
},
[`${prefix}BoxShadowOffsetY`]: {
index: 252,
group: opts.group || 'Box Shadow',
displayName: 'Offset Y',
editorName: editorName('Offset Y'),
default: 0,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
allowVisualStates: true,
popout
},
[`${prefix}BoxShadowBlurRadius`]: {
index: 253,
group: opts.group || 'Box Shadow',
displayName: 'Blur Radius',
editorName: editorName('Blur Radius'),
default: 5,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
allowVisualStates: true,
popout
},
[`${prefix}BoxShadowSpreadRadius`]: {
index: 254,
group: opts.group || 'Box Shadow',
displayName: 'Spread Radius',
editorName: editorName('Spread Radius'),
default: 2,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
allowVisualStates: true,
popout
},
[`${prefix}BoxShadowInset`]: {
index: 255,
group: opts.group || 'Box Shadow',
displayName: 'Inset',
editorName: editorName('Inset'),
type: 'boolean',
default: false,
allowVisualStates: true,
popout
},
[`${prefix}BoxShadowColor`]: {
index: 256,
group: opts.group || 'Box Shadow',
displayName: 'Shadow Color',
editorName: editorName('Shadow Color'),
type: 'color',
default: '#00000033',
allowVisualStates: true,
popout
}
});
}
NodeSharedPortDefinitions.addAlignInputs(RangeNode);
NodeSharedPortDefinitions.addTransformInputs(RangeNode);
NodeSharedPortDefinitions.addMarginInputs(RangeNode);
NodeSharedPortDefinitions.addPaddingInputs(RangeNode);
NodeSharedPortDefinitions.addSharedVisualInputs(RangeNode);
addBorderInputs(RangeNode, { propPrefix: 'track', popout: trackPopout });
addBorderRadius(RangeNode, { propPrefix: 'track', popout: trackPopout });
addShadowInputs(RangeNode, {
propPrefix: 'track',
popout: trackPopout,
group: 'Track Box Shadow'
});
addBorderInputs(RangeNode, { propPrefix: 'thumb', popout: thumbPopout });
addBorderRadius(RangeNode, { propPrefix: 'thumb', popout: thumbPopout });
addShadowInputs(RangeNode, {
propPrefix: 'thumb',
popout: thumbPopout,
group: 'Thumb Box Shadow'
});
Utils.addControlEventsAndStates(RangeNode);
export default createNodeFromReactComponent(RangeNode);

View File

@@ -0,0 +1,281 @@
import { TextInput } from '../../components/controls/TextInput';
import guid from '../../guid';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
import Utils from './utils';
function _styleTemplate(className, props) {
return `
.${className}::placeholder {
opacity: ${props.placeholderOpacity};
}
`;
}
const TextInputNode = {
name: 'net.noodl.controls.textinput',
displayName: 'Text Input',
docs: 'https://docs.noodl.net/nodes/ui-controls/text-input',
allowChildren: false,
noodlNodeAsProp: true,
usePortAsLabel: 'label',
nodeDoubleClickAction: {
focusPort: 'label'
},
connectionPanel: {
groupPriority: [
'General',
'Text',
'Style',
'Actions',
'Events',
'States',
'Mounted',
'Text Style',
'Label',
'Label Text Style',
'Hover Events',
'Pointer Events',
'Focus Events'
]
},
getReactComponent() {
return TextInput;
},
initialize() {
this.props.startValue = '';
this.props.id = this._internal.controlId = 'input-' + guid();
},
inputProps: {
type: {
displayName: 'Type',
group: 'Text',
index: 19,
type: {
name: 'enum',
enums: [
{ label: 'Text', value: 'text' },
{ label: 'Text Area', value: 'textArea' },
{ label: 'Email', value: 'email' },
{ label: 'Number', value: 'number' },
{ label: 'Password', value: 'password' },
{ label: 'URL', value: 'url' }
]
},
default: 'text'
},
placeholder: {
index: 22,
group: 'Text',
displayName: 'Placeholder',
default: 'Type here...',
type: {
name: 'string'
}
},
maxLength: {
group: 'Text',
displayName: 'Max length',
type: 'number',
index: 24
}
},
inputs: {
placeHolderOpacity: {
index: 23,
group: 'Text',
displayName: 'Placeholder opacity',
type: 'number',
default: 0.5,
set(value) {
const className = this._internal.controlId;
Utils.updateStylesForClass(className, { placeholderOpacity: value }, _styleTemplate);
}
},
set: {
group: 'Actions',
displayName: 'Set',
type: 'signal',
valueChangedToTrue() {
this.scheduleAfterInputsHaveUpdated(() => {
this.setText(this._internal.text);
});
}
},
startValue: {
index: 18,
displayName: 'Text',
type: 'string',
group: 'Text',
set(value) {
if (this._internal.text === value) return;
this._internal.text = value;
if (this.isInputConnected('set') === false) {
this.setText(value);
}
}
},
clear: {
type: 'signal',
group: 'Actions',
displayName: 'Clear',
valueChangedToTrue() {
this.clear();
}
},
focus: {
type: 'signal',
group: 'Actions',
displayName: 'Focus',
valueChangedToTrue() {
this.context.setNodeFocused(this, true);
}
},
blur: {
type: 'signal',
group: 'Actions',
displayName: 'Blur',
valueChangedToTrue() {
this.context.setNodeFocused(this, false);
}
},
textAlignX: {
group: 'Text Alignment',
index: 13,
displayName: 'Text Horizontal Align',
type: {
name: 'enum',
enums: [
{ label: 'left', value: 'left' },
{ label: 'center', value: 'center' },
{ label: 'right', value: 'right' }
],
alignComp: 'justify'
},
default: 'left',
set(value) {
switch (value) {
case 'left':
this.setStyle({ textAlign: 'left' }, 'input');
break;
case 'center':
this.setStyle({ textAlign: 'center' }, 'input');
break;
case 'right':
this.setStyle({ textAlign: 'right' }, 'input');
break;
}
}
}
},
inputCss: {
backgroundColor: {
index: 100,
displayName: 'Background Color',
group: 'Style',
type: 'color',
default: 'transparent',
allowVisualStates: true,
styleTag: 'inputWrapper'
}
},
outputProps: {
// Value
onTextChanged: {
group: 'General',
displayName: 'Text',
type: 'string',
index: 1,
onChange() {
this.sendSignalOnOutput('textChanged');
}
},
// Events
onEnter: {
group: 'Events',
displayName: 'On Enter',
type: 'signal'
}
},
outputs: {
textChanged: {
displayName: 'Text Changed',
type: 'signal',
group: 'General',
index: 2
}
},
methods: {
_focus() {
if (!this.innerReactComponentRef) return;
this.innerReactComponentRef.focus();
},
_blur() {
if (!this.innerReactComponentRef) return;
this.innerReactComponentRef.blur();
},
clear() {
this.props.startValue = '';
if (this.innerReactComponentRef) {
this.innerReactComponentRef.setText('');
}
},
setText(text) {
this.props.startValue = text;
if (this.innerReactComponentRef) {
//the text component is currently mounted, and will signal the onTextChanged output
if (this.innerReactComponentRef.hasFocus() === false) {
this.innerReactComponentRef.setText(text);
}
} else if (this.outputPropValues['onTextChanged'] !== text) {
//text component isn't mounted yet, set the output manually
this.outputPropValues['onTextChanged'] = text;
this.flagOutputDirty('onTextChanged');
}
}
}
};
NodeSharedPortDefinitions.addDimensions(TextInputNode, {
defaultSizeMode: 'contentSize',
contentLabel: 'Text'
});
NodeSharedPortDefinitions.addIconInputs(TextInputNode, {
enableIconPlacement: true,
defaults: { useIcon: false, iconColor: '#000000' }
});
NodeSharedPortDefinitions.addLabelInputs(TextInputNode, {
enableSpacing: true,
styleTag: 'label',
displayName: 'Label'
});
NodeSharedPortDefinitions.addTextStyleInputs(TextInputNode, {
styleTag: 'input',
portPrefix: '',
portIndex: 18,
popout: {
group: 'input-text-style',
label: 'Text Style',
parentGroup: 'Text'
}
});
NodeSharedPortDefinitions.addAlignInputs(TextInputNode);
NodeSharedPortDefinitions.addTransformInputs(TextInputNode);
NodeSharedPortDefinitions.addPaddingInputs(TextInputNode, {
styleTag: 'inputWrapper'
});
NodeSharedPortDefinitions.addMarginInputs(TextInputNode);
NodeSharedPortDefinitions.addSharedVisualInputs(TextInputNode);
NodeSharedPortDefinitions.addBorderInputs(TextInputNode, {
styleTag: 'inputWrapper'
});
NodeSharedPortDefinitions.addShadowInputs(TextInputNode, {
styleTag: 'inputWrapper'
});
Utils.addControlEventsAndStates(TextInputNode);
export default createNodeFromReactComponent(TextInputNode);

View File

@@ -0,0 +1,309 @@
import PointerListeners from '../../pointerlisteners';
function _shallowCompare(o1, o2) {
let p;
for (p in o1) {
if (o1.hasOwnProperty(p)) {
if (o1[p] !== o2[p]) {
return false;
}
}
}
for (p in o2) {
if (o2.hasOwnProperty(p)) {
if (o1[p] !== o2[p]) {
return false;
}
}
}
return true;
}
const _styleSheets = {};
function updateStylesForClass(_class, props, _styleTemplate) {
if (_styleSheets[_class]) {
// Check if props have changed
if (!_shallowCompare(props, _styleSheets[_class].props)) {
_styleSheets[_class].style.innerHTML = _styleTemplate(_class, props);
_styleSheets[_class].props = Object.assign({}, props);
}
} else {
// Create a new style sheet if none exists
const style = document.createElement('style');
style.innerHTML = _styleTemplate(_class, props);
document.head.appendChild(style);
_styleSheets[_class] = { style, props: Object.assign({}, props) };
}
}
function mergeAttribute(definition, attribute, values) {
if (!definition[attribute]) {
definition[attribute] = {};
}
for (const name in values) {
definition[attribute][name] = values[name];
}
}
function addInputs(definition, values) {
mergeAttribute(definition, 'inputs', values);
}
function addInputProps(definition, values) {
mergeAttribute(definition, 'inputProps', values);
}
function addOutputProps(definition, values) {
mergeAttribute(definition, 'outputProps', values);
}
function addOutputs(definition, values) {
mergeAttribute(definition, 'outputs', values);
}
function addControlEventsAndStates(definition, args?) {
args = args || {};
definition.visualStates = [
{ name: 'neutral', label: 'Neutral' },
{ name: 'hover', label: 'Hover' },
{ name: 'pressed', label: 'Pressed' },
{ name: 'focused', label: 'Focused' },
{ name: 'disabled', label: 'Disabled' }
];
if (args.checked) {
definition.visualStates.splice(3, 0, { name: 'checked', label: 'Checked' });
}
addInputs(definition, {
enabled: {
type: 'boolean',
displayName: 'Enabled',
group: 'General',
default: true,
set: function (value) {
value = !!value;
const changed = value !== this._internal.enabled;
this.props.enabled = this._internal.enabled = value;
if (changed) {
this._updateVisualState();
this.forceUpdate();
this.flagOutputDirty('enabled');
}
}
}
});
addInputProps(definition, {
blockTouch: {
index: 450,
displayName: 'Block Pointer Events',
type: 'boolean',
group: 'Pointer Events'
}
});
definition.methods._updateVisualState = function () {
const states = [];
//make sure they are in the order they should be applied
if (this._internal.enabled) {
if (this.outputPropValues.hoverState) states.push('hover');
if (this.outputPropValues.pressedState) states.push('pressed');
if (this.outputPropValues.focusState) states.push('focused');
}
if (args.checked && this._internal.checked) states.push('checked');
if (!this._internal.enabled) states.push('disabled');
this.setVisualStates(states);
};
addOutputProps(definition, {
// Focus
focusState: {
displayName: 'Focused',
group: 'States',
type: 'boolean',
props: {
onFocus() {
this.outputPropValues.focusState = true;
this.flagOutputDirty('focusState');
this._updateVisualState();
},
onBlur() {
this.outputPropValues.focusState = false;
this.flagOutputDirty('focusState');
this._updateVisualState();
}
}
},
onFocus: {
displayName: 'Focused',
group: 'Focus Events',
type: 'signal',
props: {
onFocus() {
this.sendSignalOnOutput('onFocus');
}
}
},
onBlur: {
displayName: 'Blurred',
group: 'Focus Events',
type: 'signal',
props: {
onBlur() {
this.sendSignalOnOutput('onBlur');
}
}
},
// Hover
hoverState: {
displayName: 'Hover',
group: 'States',
type: 'boolean',
props: {
onMouseOver() {
this.outputPropValues.hoverState = true;
this.flagOutputDirty('hoverState');
this._updateVisualState();
},
onMouseLeave() {
this.outputPropValues.hoverState = false;
this.flagOutputDirty('hoverState');
this._updateVisualState();
}
}
},
hoverStart: {
displayName: 'Hover Start',
group: 'Pointer Events',
type: 'signal',
props: {
onMouseOver() {
this.sendSignalOnOutput('hoverStart');
}
}
},
hoverEnd: {
displayName: 'Hover End',
group: 'Pointer Events',
type: 'signal',
props: {
onMouseLeave() {
this.sendSignalOnOutput('hoverEnd');
}
}
},
// Pressed
pressedState: {
displayName: 'Pressed',
group: 'States',
type: 'boolean',
props: {
onMouseDown() {
this.outputPropValues.pressedState = true;
this.flagOutputDirty('pressedState');
this._updateVisualState();
},
onTouchStart() {
this.outputPropValues.pressedState = true;
this.flagOutputDirty('pressedState');
this._updateVisualState();
},
onMouseUp() {
this.outputPropValues.pressedState = false;
this.flagOutputDirty('pressedState');
this._updateVisualState();
},
onTouchEnd() {
this.outputPropValues.pressedState = false;
this.flagOutputDirty('pressedState');
this._updateVisualState();
},
onTouchCancel() {
this.outputPropValues.pressedState = false;
this.flagOutputDirty('pressedState');
this._updateVisualState();
},
onMouseLeave() {
this.outputPropValues.pressedState = false;
this.flagOutputDirty('pressedState');
this._updateVisualState();
}
}
},
pointerDown: {
displayName: 'Pointer Down',
group: 'Pointer Events',
type: 'signal',
props: {
onMouseDown() {
this.sendSignalOnOutput('pointerDown');
},
onTouchStart() {
this.sendSignalOnOutput('pointerDown');
}
}
},
pointerUp: {
displayName: 'Pointer Up',
group: 'Pointer Events',
type: 'signal',
props: {
onMouseUp() {
this.sendSignalOnOutput('pointerUp');
},
onTouchEnd() {
this.sendSignalOnOutput('pointerUp');
},
onTouchCancel() {
this.sendSignalOnOutput('pointerUp');
}
}
}
});
addOutputs(definition, {
enabled: {
type: 'boolean',
displayName: 'Enabled',
group: 'States',
getter: function () {
return this._internal.enabled;
}
}
});
const oldInit = definition.initialize;
definition.initialize = function () {
oldInit && oldInit.call(this);
this.props.enabled = this._internal.enabled = true;
this.outputPropValues.hoverState = this.outputPropValues.focusState = this.outputPropValues.pressedState = false;
};
}
function controlEvents(props) {
return Object.assign(
{},
{
onFocus: props.onFocus,
onBlur: props.onBlur
},
PointerListeners(props)
);
}
export default {
updateStylesForClass,
addControlEventsAndStates,
controlEvents
};

View File

@@ -0,0 +1,149 @@
const { EdgeTriggeredInput } = require('@noodl/runtime');
const ClosePopupNode = {
name: 'NavigationClosePopup',
displayNodeName: 'Close Popup',
category: 'Navigation',
docs: 'https://docs.noodl.net/nodes/popups/close-popup',
initialize: function () {
this._internal.resultValues = {};
},
inputs: {
results: {
type: { name: 'stringlist', allowEditOnly: true },
group: 'Results',
set: function (value) {
this._internal.results = value;
}
},
closeActions: {
type: { name: 'stringlist', allowEditOnly: true },
group: 'Close Actions',
set: function (value) {
this._internal.closeActions = value;
}
},
close: {
type: 'Signal',
displayName: 'Close',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleClose();
}
}
},
methods: {
setResultValue: function (key, value) {
this._internal.resultValues[key] = value;
},
_setCloseCallback: function (cb) {
this._internal.closeCallback = cb;
},
scheduleClose: function () {
var _this = this;
var internal = this._internal;
if (!internal.hasScheduledClose) {
internal.hasScheduledClose = true;
this.scheduleAfterInputsHaveUpdated(function () {
internal.hasScheduledClose = false;
_this.close();
});
}
},
close: function () {
if (this._internal.closeCallback)
this._internal.closeCallback(this._internal.closeAction, this._internal.resultValues);
},
closeActionTriggered: function (name) {
this._internal.closeAction = name;
this.scheduleClose();
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name.startsWith('result-'))
return this.registerInput(name, {
set: this.setResultValue.bind(this, name.substring('result-'.length))
});
if (name.startsWith('closeAction-'))
return this.registerInput(name, {
set: EdgeTriggeredInput.createSetter({
valueChangedToTrue: this.closeActionTriggered.bind(this, name)
})
});
}
}
};
module.exports = {
node: ClosePopupNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
function _updatePorts() {
var ports = [];
// Add results inputs
var results = node.parameters['results'];
if (results) {
results = results ? results.split(',') : undefined;
for (var i in results) {
var p = results[i];
ports.push({
type: {
name: '*'
},
plug: 'input',
group: 'Results',
name: 'result-' + p,
displayName: p
});
}
}
// Add close actions
var closeActions = node.parameters['closeActions'];
if (closeActions) {
closeActions = closeActions ? closeActions.split(',') : undefined;
for (var i in closeActions) {
var p = closeActions[i];
ports.push({
type: 'signal',
plug: 'input',
group: 'Close Actions',
name: 'closeAction-' + p,
displayName: p
});
}
}
context.editorConnection.sendDynamicPorts(node.id, ports);
}
_updatePorts();
node.on('parameterUpdated', function (event) {
if (event.name === 'results' || event.name === 'closeActions') {
_updatePorts();
}
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.NavigationClosePopup', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('NavigationClosePopup')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -0,0 +1,152 @@
const NavigateBack = {
name: 'PageStackNavigateBack',
displayNodeName: 'Pop Component Stack',
category: 'Navigation',
docs: 'https://docs.noodl.net/nodes/component-stack/pop-component',
inputs: {
navigate: {
displayName: 'Navigate',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleNavigate();
}
},
results: {
type: { name: 'stringlist', allowEditOnly: true },
group: 'Results',
set: function (value) {
this._internal.results = value;
}
},
backActions: {
type: { name: 'stringlist', allowEditOnly: true },
group: 'Back Actions',
set: function (value) {
this._internal.backActions = value;
}
}
},
initialize: function () {
this._internal.resultValues = {};
},
methods: {
scheduleNavigate: function () {
var _this = this;
var internal = this._internal;
if (!internal.hasScheduledNavigate) {
internal.hasScheduledNavigate = true;
this.scheduleAfterInputsHaveUpdated(function () {
internal.hasScheduledNavigate = false;
_this.navigate();
});
}
},
_setBackCallback(cb) {
this._internal.backCallback = cb;
},
navigate() {
if (this._internal.backCallback === undefined) return;
this._internal.backCallback({
backAction: this._internal.backAction,
results: this._internal.resultValues
});
},
setResultValue: function (key, value) {
this._internal.resultValues[key] = value;
},
backActionTriggered: function (name) {
this._internal.backAction = name;
this.scheduleNavigate();
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name.startsWith('result-'))
return this.registerInput(name, {
set: this.setResultValue.bind(this, name.substring('result-'.length))
});
if (name.startsWith('backAction-'))
return this.registerInput(name, {
set: _createSignal({
valueChangedToTrue: this.backActionTriggered.bind(this, name)
})
});
}
}
};
function setup(context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
function _updatePorts() {
var ports = [];
// Add results inputs
var results = node.parameters.results;
if (results) {
results = results ? results.split(',') : undefined;
for (var i in results) {
var p = results[i];
ports.push({
type: {
name: '*'
},
plug: 'input',
group: 'Results',
name: 'result-' + p,
displayName: p
});
}
}
// Add back actions
var backActions = node.parameters.backActions;
if (backActions) {
backActions = backActions ? backActions.split(',') : undefined;
for (var i in backActions) {
var p = backActions[i];
ports.push({
type: 'signal',
plug: 'input',
group: 'Back Actions',
name: 'backAction-' + p,
displayName: p
});
}
}
context.editorConnection.sendDynamicPorts(node.id, ports);
}
_updatePorts();
node.on('parameterUpdated', function (event) {
if (event.name === 'results' || event.name === 'backActions') {
_updatePorts();
}
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.PageStackNavigateBack', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('PageStackNavigateBack')) {
_managePortsForNode(node);
}
});
}
module.exports = {
node: NavigateBack,
setup: setup
};

View File

@@ -0,0 +1,198 @@
'use strict';
const NoodlRuntime = require('@noodl/runtime');
const NavigateToPathNode = {
name: 'PageStackNavigateToPath',
displayNodeName: 'Navigate To Path',
category: 'Navigation',
docs: 'https://docs.noodl.net/nodes/navigation/navigate-to-path',
initialize() {
const internal = this._internal;
internal.params = {};
internal.query = {};
internal.openInNewTab = false;
},
inputs: {
path: {
type: { name: 'string' },
displayName: 'Path',
group: 'General',
set(value) {
this._internal.path = value;
}
},
queryNames: {
type: { name: 'stringlist', allowEditOnly: true },
displayName: 'Query',
group: 'Query',
set(value) {
this._internal.queryNames = value;
}
},
openInNewTab: {
index: 10,
displayName: 'Open in new tab',
group: 'General',
default: false,
type: 'boolean',
set(value) {
this._internal.openInNewTab = !!value;
}
},
navigate: {
displayName: 'Navigate',
group: 'Actions',
valueChangedToTrue() {
this.scheduleNavigate();
}
}
},
outputs: {},
methods: {
scheduleNavigate() {
var internal = this._internal;
if (!internal.hasScheduledNavigate) {
internal.hasScheduledNavigate = true;
this.scheduleAfterInputsHaveUpdated(() => {
internal.hasScheduledNavigate = false;
this.navigate();
});
}
},
navigate() {
var internal = this._internal;
var formattedPath = internal.path;
if (formattedPath === undefined) return;
var matches = internal.path.match(/\{[A-Za-z0-9_]*\}/g);
var inputs = [];
if (matches) {
inputs = matches.map(function (name) {
return name.replace('{', '').replace('}', '');
});
}
inputs.forEach(function (name) {
var v = internal.params[name];
formattedPath = formattedPath.replace('{' + name + '}', v !== undefined ? v : '');
});
var urlPath, hashPath;
var navigationPathType = NoodlRuntime.instance.getProjectSettings()['navigationPathType'];
if (navigationPathType === undefined || navigationPathType === 'hash') hashPath = formattedPath;
else urlPath = formattedPath;
var query = [];
if (internal.queryNames !== undefined) {
internal.queryNames.split(',').forEach((q) => {
if (internal.query[q] !== undefined) {
query.push(q + '=' + internal.query[q]);
}
});
}
var compiledUrl =
(urlPath !== undefined ? urlPath : '') +
(query.length >= 1 ? '?' + query.join('&') : '') +
(hashPath !== undefined ? '#' + hashPath : '');
if (this._internal.openInNewTab) {
window.open(compiledUrl, '_blank');
} else {
window.history.pushState({}, '', compiledUrl);
dispatchEvent(new PopStateEvent('popstate', {}));
}
},
setParam(name, value) {
this._internal.params[name] = value;
},
setQuery(name, value) {
this._internal.query[name] = value;
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name.startsWith('p-'))
return this.registerInput(name, {
set: this.setParam.bind(this, name.substring('p-'.length))
});
if (name.startsWith('q-'))
return this.registerInput(name, {
set: this.setQuery.bind(this, name.substring('q-'.length))
});
}
}
};
module.exports = {
node: NavigateToPathNode,
setup: function setup(context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
function _updatePorts() {
var ports = [];
if (node.parameters['path'] !== undefined) {
var inputs = node.parameters['path'].match(/\{[A-Za-z0-9_]*\}/g) || [];
var portsNames = inputs.map(function (def) {
return def.replace('{', '').replace('}', '');
});
var ports = portsNames
//get unique names
.filter(function (value, index, self) {
return self.indexOf(value) === index;
})
//and map names to ports
.map(function (name) {
return {
name: 'p-' + name,
displayName: name,
group: 'Parameter',
type: '*',
plug: 'input'
};
});
}
if (node.parameters['queryNames'] !== undefined) {
node.parameters['queryNames'].split(',').forEach((q) => {
ports.push({
name: 'q-' + q,
displayName: q,
group: 'Query',
plug: 'input',
type: '*'
});
});
}
context.editorConnection.sendDynamicPorts(node.id, ports);
}
_updatePorts();
node.on('parameterUpdated', function (event) {
_updatePorts();
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.PageStackNavigateToPath', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('PageStackNavigateToPath')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -0,0 +1,324 @@
const Transitions = require('./transitions');
const NavigationHandler = require('./navigation-handler');
const Navigate = {
name: 'PageStackNavigate',
displayNodeName: 'Push Component To Stack',
category: 'Navigation',
docs: 'https://docs.noodl.net/nodes/component-stack/push-component',
initialize: function () {
this._internal.transitionParams = {};
this._internal.pageParams = {};
this._internal.backResults = {};
},
inputs: {
stack: {
type: { name: 'string', identifierOf: 'PackStack' },
displayName: 'Stack',
group: 'General',
default: 'Main',
set: function (value) {
this._internal.stack = value;
}
},
mode: {
type: {
name: 'enum',
enums: [
{ label: 'Push', value: 'push' },
{ label: 'Replace', value: 'replace' }
]
},
displayName: 'Mode',
default: 'push',
group: 'General',
set: function (value) {
this._internal.navigationMode = value;
}
},
navigate: {
displayName: 'Navigate',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleNavigate();
}
}
},
outputs: {
navigated: {
type: 'signal',
displayName: 'Navigated',
group: 'Events'
}
},
methods: {
scheduleNavigate: function () {
var _this = this;
var internal = this._internal;
if (!internal.hasScheduledNavigate) {
internal.hasScheduledNavigate = true;
this.scheduleAfterInputsHaveUpdated(function () {
internal.hasScheduledNavigate = false;
_this.navigate();
});
}
},
navigate() {
if (this._internal.navigationMode === 'push' || this._internal.navigationMode === undefined) {
NavigationHandler.instance.navigate(this._internal.stack, {
target: this._internal.target,
transition: { ...{ type: this._internal.transition }, ...this._internal.transitionParams },
params: this._internal.pageParams,
backCallback: (action, results) => {
this._internal.backResults = results;
for (var key in results) {
if (this.hasOutput('backResult-' + key)) this.flagOutputDirty('backResult-' + key);
}
if (action !== undefined) this.sendSignalOnOutput(action);
},
hasNavigated: () => {
this.sendSignalOnOutput('navigated');
}
});
} else if (this._internal.navigationMode === 'replace') {
NavigationHandler.instance.replace(this._internal.stack, {
target: this._internal.target,
params: this._internal.pageParams,
hasNavigated: () => {
this.scheduleAfterInputsHaveUpdated(() => {
this.sendSignalOnOutput('navigated');
});
}
});
}
},
setTransitionParam: function (param, value) {
this._internal.transitionParams[param] = value;
},
setPageParam: function (param, value) {
this._internal.pageParams[param] = value;
},
getBackResult: function (param, value) {
return this._internal.backResults[param];
},
setTargetPageId: function (pageId) {
this._internal.target = pageId;
},
setTransition: function (value) {
this._internal.transition = value;
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name === 'target')
return this.registerInput(name, {
set: this.setTargetPageId.bind(this)
});
else if (name === 'transition')
return this.registerInput(name, {
set: this.setTransition.bind(this)
});
else if (name.startsWith('tr-'))
return this.registerInput(name, {
set: this.setTransitionParam.bind(this, name.substring('tr-'.length))
});
else if (name.startsWith('pm-'))
return this.registerInput(name, {
set: this.setPageParam.bind(this, name.substring('pm-'.length))
});
},
registerOutputIfNeeded: function (name) {
if (this.hasOutput(name)) {
return;
}
if (name.startsWith('backResult-'))
return this.registerOutput(name, {
getter: this.getBackResult.bind(this, name.substring('backResult-'.length))
});
else if (name.startsWith('backAction-'))
return this.registerOutput(name, {
getter: function () {
/** No needed for signals */
}
});
}
}
};
function setup(context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
function _updatePorts() {
var ports = [];
// Only push mode have transition
if (node.parameters['mode'] === 'push' || node.parameters['mode'] === undefined) {
ports.push({
name: 'transition',
plug: 'input',
type: { name: 'enum', enums: Object.keys(Transitions) },
default: 'Push',
displayName: 'Transition',
group: 'Transition'
});
var transition = node.parameters['transition'] || 'Push';
if (Transitions[transition]) ports = ports.concat(Transitions[transition].ports(node.parameters));
}
// if(node.parameters['stack'] !== undefined) {
var pageStacks = graphModel.getNodesWithType('Page Stack');
var pageStack = pageStacks.find(
(ps) => (ps.parameters['name'] || 'Main') === (node.parameters['stack'] || 'Main')
);
if (pageStack !== undefined) {
var pages = pageStack.parameters['pages'];
if (pages !== undefined && pages.length > 0) {
ports.push({
plug: 'input',
type: { name: 'enum', enums: pages.map((p) => ({ label: p.label, value: p.id })), allowEditOnly: true },
group: 'General',
displayName: 'Target Page',
name: 'target',
default: pages[0].id
});
// See if there is a target page with component
var targetPageId = node.parameters['target'] || pages[0].id;
var targetComponentName = pageStack.parameters['pageComp-' + targetPageId];
if (targetComponentName !== undefined) {
const component = graphModel.components[targetComponentName];
if (component !== undefined) {
// Make all inputs of the component to inputs of this navigation node
for (var inputName in component.inputPorts) {
ports.push({
name: 'pm-' + inputName,
displayName: inputName,
type: '*',
plug: 'input',
group: 'Parameters'
});
}
// Find all navigate back nodes and compile the back actions as
// outputs of this node
for (const backNode of component.getNodesWithType('PageStackNavigateBack')) {
if (backNode.parameters['backActions'] !== undefined) {
backNode.parameters['backActions'].split(',').forEach((a) => {
if (ports.find((_p) => _p.name === 'backAction-' + a)) return;
ports.push({
name: 'backAction-' + a,
displayName: a,
type: 'signal',
plug: 'output',
group: 'Back Actions'
});
});
}
if (backNode.parameters['results']) {
backNode.parameters['results'].split(',').forEach((p) => {
if (ports.find((_p) => _p.name === 'backResult-' + p)) return;
ports.push({
name: 'backResult-' + p,
displayName: p,
type: '*',
plug: 'output',
group: 'Back Results'
});
});
}
}
}
}
}
}
// }
context.editorConnection.sendDynamicPorts(node.id, ports);
}
function _trackTargetComponent() {
var pageStacks = graphModel.getNodesWithType('Page Stack');
var pageStack = pageStacks.find((ps) => ps.parameters['name'] === node.parameters['stack']);
if (pageStack === undefined) return;
var pages = pageStack.parameters['pages'];
if (pages === undefined || pages.length === 0) return;
var targetCompoment = pageStack.parameters['pageComp-' + (node.parameters['target'] || pages[0].id)];
if (targetCompoment === undefined) return;
var c = graphModel.components[targetCompoment];
if (c === undefined) return;
c.on('inputPortAdded', _updatePorts);
c.on('inputPortRemoved', _updatePorts);
// Also track all back navigate for changes
for (const _n of c.getNodesWithType('PageStackNavigateBack')) {
_n.on('parameterUpdated', _updatePorts);
}
// Track back navigate added and removed
c.on('nodeAdded', (_n) => {
if (_n.type === 'PageStackNavigateBack') {
_n.on('parameterUpdated', _updatePorts);
_updatePorts();
}
});
c.on('nodeWasRemoved', (_n) => {
if (_n.type === 'PageStackNavigateBack') _updatePorts();
});
}
_updatePorts();
_trackTargetComponent();
node.on('parameterUpdated', function (ev) {
if (ev.name === 'target') {
_trackTargetComponent();
_updatePorts();
} else if (ev.name === 'stack' || ev.name === 'mode' || ev.name === 'transition' || ev.name.startsWith('tr-')) _updatePorts();
});
// Track all page stacks for changes (if there are any changes to the pages of the name of a stack we might have to update)
function _trackPageStack(node) {
node.on('parameterUpdated', function (ev) {
if (ev.name === 'pages' || ev.name === 'name') _updatePorts();
});
}
graphModel.on('nodeAdded.Page Stack', _trackPageStack);
graphModel.on('nodeWasRemoved.Page Stack', _updatePorts);
for (const node of graphModel.getNodesWithType('Page Stack')) {
_trackPageStack(node);
}
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.PageStackNavigate', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('PageStackNavigate')) {
_managePortsForNode(node);
}
});
}
module.exports = {
node: Navigate,
setup: setup
};

View File

@@ -0,0 +1,87 @@
class NavigationHandler {
constructor() {
this._pageStacks = {};
this._navigationQueue = [];
}
_performNavigation(name, args, type) {
name = name || 'Main';
if (this._pageStacks[name]) {
for (const pageStack of this._pageStacks[name]) {
type === 'navigate' ? pageStack.navigate(args) : pageStack.replace(args);
}
} else {
this._navigationQueue.push({ name, args, type });
}
}
navigate(name, args) {
name = name || 'Main';
this._performNavigation(name, args, 'navigate');
}
replace(name, args) {
name = name || 'Main';
this._performNavigation(name, args, 'replace');
}
registerPageStack(name, pageStack) {
name = name || 'Main';
if (!this._pageStacks[name]) {
this._pageStacks[name] = [];
}
this._pageStacks[name].push(pageStack);
let hasReset = false;
let hasNavigated = false;
let i = 0;
while (i < this._navigationQueue.length) {
const e = this._navigationQueue[i];
if (e.name === name) {
if (e.type === 'navigate') {
if (!hasReset) {
//we need to reset to the start page before doing the first navigation
pageStack.reset();
hasReset = true;
}
pageStack.navigate(e.args);
} else {
pageStack.replace(e.args);
}
hasNavigated = true;
this._navigationQueue.splice(i, 1);
} else {
i++;
}
}
if (!hasNavigated) {
pageStack.reset(); //no navigation has happened, call reset() so the start page is created
}
}
deregisterPageStack(name, pageStack) {
name = name || 'Main';
if (!this._pageStacks[name]) {
return;
}
const index = this._pageStacks[name].indexOf(pageStack);
if (index === -1) {
return;
}
this._pageStacks[name].splice(index, 1);
if (this._pageStacks[name].length === 0) {
delete this._pageStacks[name];
}
}
}
NavigationHandler.instance = new NavigationHandler();
module.exports = NavigationHandler;

View File

@@ -0,0 +1,720 @@
import ASyncQueue from '../../async-queue';
import { createNodeFromReactComponent } from '../../react-component-node';
const { useEffect } = require('react');
const guid = require('../../guid');
const NavigationHandler = require('./navigation-handler');
const NoodlRuntime = require('@noodl/runtime');
const Transitions = require('./transitions');
function PageStackReactComponent(props) {
const { didMount, willUnmount, style, children } = props;
useEffect(() => {
didMount();
return () => {
willUnmount();
};
}, []);
return <div style={style}>{children}</div>;
}
const PageStack = {
name: 'Page Stack',
displayNodeName: 'Component Stack',
category: 'Visuals',
docs: 'https://docs.noodl.net/nodes/component-stack/component-stack-node',
useVariants: false,
initialize() {
this._internal.stack = [];
this._internal.topPageName = '';
this._internal.stackDepth = 0;
this._internal.pageInfo = {};
this._internal.asyncQueue = new ASyncQueue();
this.onScheduleReset = () => {
this.scheduleReset();
};
this.props.didMount = () => {
this._internal.isMounted = true;
// Listen to push state events and update stack
if (window.history && window.history.pushState) {
//this is event is manually sent on push, so covers both pop and push state
window.addEventListener('popstate', this.onScheduleReset);
} else {
// Only hash support
window.addEventListener('hashchange', this.onScheduleReset);
}
this._registerPageStack();
};
this.props.willUnmount = () => {
this._internal.isMounted = false;
window.removeEventListener('popstate', this.onScheduleReset);
window.removeEventListener('hashchange', this.onScheduleReset);
this._deregisterPageStack();
};
},
getInspectInfo() {
if (this._internal.stack.length === 0) {
return 'No active page';
}
const info = [{ type: 'text', value: 'Active Components:' }];
return info.concat(
this._internal.stack.map((p, i) => ({
type: 'text',
value: '- ' + this._internal.pages.find((pi) => pi.id === p.pageId).label
}))
);
},
defaultCss: {
width: '100%',
flex: '1 1 100%',
position: 'relative',
display: 'flex',
flexDirection: 'column'
},
getReactComponent() {
return PageStackReactComponent;
},
inputs: {
name: {
type: { name: 'string', identifierOf: 'PackStack' },
displayName: 'Name',
group: 'General',
default: 'Main',
set: function (value) {
this._deregisterPageStack();
this._internal.name = value;
if (this._internal.isMounted) {
this._registerPageStack();
}
}
},
/* startPage: {
type: 'component',
displayName: 'Start Page',
group: 'General',
set: function (value) {
this._internal.startPage = value;
this.scheduleReset();
}
},*/
useRoutes: {
type: 'boolean',
displayName: 'Use Routes',
group: 'General',
default: false,
set: function (value) {
this._internal.useRoutes = !!value;
}
},
clip: {
displayName: 'Clip Content',
type: 'boolean',
group: 'Layout',
default: true,
set(value) {
if (value) {
this.setStyle({ overflow: 'hidden' });
} else {
this.removeStyle(['overflow']);
}
}
},
pages: {
type: 'proplist',
displayName: 'Components',
group: 'Components',
set: function (value) {
this._internal.pages = value;
if (this._internal.isMounted) {
this.scheduleReset();
}
}
},
reset: {
type: 'signal',
displayName: 'Reset',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleReset();
}
}
},
inputCss: {
backgroundColor: {
type: 'color',
displayName: 'Background Color',
group: 'Style',
default: 'transparent',
applyDefault: false
}
},
outputs: {
topPageName: {
type: 'string',
displayName: 'Top Component Name',
get() {
return this._internal.topPageName;
}
},
stackDepth: {
type: 'number',
displayName: 'Stack Depth',
get() {
return this._internal.stackDepth;
}
}
},
methods: {
_registerPageStack() {
NavigationHandler.instance.registerPageStack(this._internal.name, this);
},
_deregisterPageStack() {
NavigationHandler.instance.deregisterPageStack(this._internal.name, this);
},
_pageNameForId(id) {
if (this._internal.pages === undefined) return;
const page = this._internal.pages.find((p) => p.id === id);
if (page === undefined) return;
return page.label;
},
setPageOutputs(outputs) {
for (const prop in outputs) {
this._internal[prop] = outputs[prop];
this.flagOutputDirty(prop);
}
},
scheduleReset() {
var internal = this._internal;
if (!internal.hasScheduledReset) {
internal.hasScheduledReset = true;
this.scheduleAfterInputsHaveUpdated(() => {
internal.hasScheduledReset = false;
this.reset();
});
}
},
createPageContainer() {
const group = this.nodeScope.createPrimitiveNode('Group');
group.setStyle({ flex: '1 0 100%' });
return group;
},
reset() {
this._internal.asyncQueue.enqueue(this.resetAsync.bind(this));
},
async resetAsync() {
var children = this.getChildren();
for (var i in children) {
var c = children[i];
this.removeChild(c);
this.nodeScope.deleteNode(c);
}
if (this._internal.pages === undefined || this._internal.pages.length === 0) return;
var startPageId,
params = {};
var pageFromUrl = this.matchPageFromUrl();
if (pageFromUrl !== undefined) {
// We have an url matching a page, use that page as start page
startPageId = pageFromUrl.pageId;
params = Object.assign({}, pageFromUrl.query, pageFromUrl.params);
} else {
var startPageId = this._internal.startPageId;
if (startPageId === undefined) startPageId = this._internal.pages[0].id;
}
var pageInfo = this._internal.pageInfo[startPageId];
if (pageInfo === undefined || pageInfo.component === undefined) return; // No component specified for page
var content = await this.nodeScope.createNode(pageInfo.component, guid());
for (var key in params) {
content.setInputValue(key, params[key]);
}
const group = this.createPageContainer();
group.addChild(content);
this.addChild(group);
this._internal.stack = [
{
from: null,
page: group,
pageId: startPageId,
pageInfo: pageInfo,
params: params,
componentName: this._internal.startPage
}
];
this.setPageOutputs({
topPageName: this._pageNameForId(startPageId),
stackDepth: this._internal.stack.length
});
},
getRelativeURL() {
var top = this._internal.stack[this._internal.stack.length - 1];
if (top === undefined) return;
var urlPath = top.pageInfo.path;
if (urlPath === undefined) {
var pageItem = this._internal.pages.find((p) => p.id == top.pageId);
if (pageItem === undefined) return;
urlPath = pageItem.label.replace(/\s+/g, '-').toLowerCase();
}
// First add matching parameters to path
var paramsInPath = urlPath.match(/{([^}]+)}/g);
var params = Object.assign({}, top.params);
if (paramsInPath) {
for (var param of paramsInPath) {
var key = param.replace(/[{}]/g, '');
if (top.params[key] !== undefined) {
urlPath = urlPath.replace(param, encodeURIComponent(params[key]));
delete params[key];
}
}
}
// Add other paramters as query
var query = [];
for (var key in params) {
query.push({
name: key,
value: params[key]
});
}
if (urlPath.startsWith('/')) urlPath = urlPath.substring(1);
return {
path: urlPath,
query: query
};
},
getNavigationAbsoluteURL() {
var parent = this.parent;
while (parent !== undefined && typeof parent.getNavigationAbsoluteURL !== 'function') {
parent = parent.getVisualParentNode();
}
if (parent === undefined) {
var parentUrl = { path: '', query: [] };
} else {
var parentUrl = parent.getNavigationAbsoluteURL();
}
var thisUrl = this.getRelativeURL();
if (thisUrl === undefined) return parentUrl;
else {
return {
path: parentUrl.path + (parentUrl.path.endsWith('/') ? '' : '/') + thisUrl.path,
query: parentUrl.query.concat(thisUrl.query)
};
}
},
_getLocationPath: function () {
var navigationPathType = NoodlRuntime.instance.getProjectSettings()['navigationPathType'];
if (navigationPathType === undefined || navigationPathType === 'hash') {
// Use hash as path
var hash = location.hash;
if (hash) {
if (hash[0] === '#') hash = hash.substring(1);
if (hash[0] === '/') hash = hash.substring(1);
}
return hash;
} else {
// Use url as path
var path = location.pathname;
if (path) {
if (path[0] === '/') path = path.substring(1);
}
return path;
}
},
_getSearchParams: function () {
var match,
pl = /\+/g, // Regex for replacing addition symbol with a space
search = /([^&=]+)=?([^&]*)/g,
decode = function (s) {
return decodeURIComponent(s.replace(pl, ' '));
},
query = window.location.search.substring(1);
var urlParams = {};
while ((match = search.exec(query))) urlParams[decode(match[1])] = decode(match[2]);
return urlParams;
},
getNavigationRemainingPath() {
return this._internal.remainingNavigationPath;
},
matchPageFromUrl(url) {
if (!this._internal.useRoutes) return;
if (this._internal.pages === undefined || this._internal.pages.length === 0) return;
// Attempt to find relative path from closest navigation parent
var parent = this.parent;
while (parent !== undefined && typeof parent.getNavigationRemainingPath !== 'function') {
parent = parent.getVisualParentNode();
}
if (parent === undefined) {
var urlPath = this._getLocationPath();
if (urlPath[0] === '/') urlPath = urlPath.substring(1);
var pathParts = urlPath.split('/');
} else {
var pathParts = parent.getNavigationRemainingPath();
}
if (pathParts === undefined) return;
var urlQuery = this._getSearchParams();
function _matchPathParts(path, pattern) {
var params = {};
for (var i = 0; i < pattern.length; i++) {
if (path[i] === undefined) return;
var _p = pattern[i];
if (_p[0] === '{' && _p[_p.length - 1] === '}') {
// This is a param, collect it
params[_p.substring(1, _p.length - 1)] = decodeURIComponent(path[i]);
} else if (_p !== path[i]) return;
}
return {
params: params,
remainingPathParts: path.splice(pattern.length)
};
}
for (var page of this._internal.pages) {
var pageInfo = this._internal.pageInfo[page.id];
if (pageInfo === undefined) continue;
var pagePattern = pageInfo.path;
if (pagePattern === undefined) {
pagePattern = page.label.replace(/\s+/g, '-').toLowerCase();
}
if (pagePattern[0] === '/') pagePattern = pagePattern.substring(1);
let match = _matchPathParts(pathParts, pagePattern.split('/'));
if (match) {
// This page is a match
this._internal.remainingNavigationPath = match.remainingPathParts;
return {
pageId: page.id,
params: match.params,
query: urlQuery
};
}
}
},
_updateUrlWithTopPage() {
// Push the state to the browser url
if (this._internal.useRoutes && window.history !== undefined) {
var url = this.getNavigationAbsoluteURL();
var urlPath, hashPath;
var navigationPathType = NoodlRuntime.instance.getProjectSettings()['navigationPathType'];
if (navigationPathType === undefined || navigationPathType === 'hash') hashPath = url.path;
else urlPath = url.path;
var query = url.query.map((q) => q.name + '=' + q.value);
var compiledUrl =
(urlPath !== undefined ? urlPath : '') +
(query.length >= 1 ? '?' + query.join('&') : '') +
(hashPath !== undefined ? '#' + hashPath : '');
this._internal.remainingNavigationPath = undefined; // Reset remaining nav path
// console.log(compiledUrl);
window.history.pushState({}, '', compiledUrl);
}
},
replace(args) {
this._internal.asyncQueue.enqueue(this.replaceAsync.bind(this, args));
},
async replaceAsync(args) {
if (this._internal.pages === undefined || this._internal.pages.length === 0) return;
if (this._internal.isTransitioning) return;
var pageId = args.target || this._internal.pages[0].id;
var pageInfo = this._internal.pageInfo[pageId];
if (pageInfo === undefined || pageInfo.component === undefined) return; // No component specified for page
// Remove all current pages in the stack
var children = this.getChildren();
for (var i in children) {
var c = children[i];
this.removeChild(c);
this.nodeScope.deleteNode(c);
}
const group = this.createPageContainer();
// Create the page content
const content = await this.nodeScope.createNode(pageInfo.component, guid());
for (var key in args.params) {
content.setInputValue(key, args.params[key]);
}
group.addChild(content);
this.addChild(group);
// Replace stack
this._internal.stack = [
{
from: null,
page: group,
pageId: pageId,
pageInfo: pageInfo,
params: args.params,
componentName: args.target
}
];
this.setPageOutputs({
topPageName: this._pageNameForId(pageId),
stackDepth: this._internal.stack.length
});
this._updateUrlWithTopPage();
args.hasNavigated && args.hasNavigated();
},
navigate(args) {
this._internal.asyncQueue.enqueue(this.navigateAsync.bind(this, args));
},
async navigateAsync(args) {
if (this._internal.pages === undefined || this._internal.pages.length === 0) return;
if (this._internal.isTransitioning) return;
var pageId = args.target || this._internal.pages[0].id;
var pageInfo = this._internal.pageInfo[pageId];
if (pageInfo === undefined || pageInfo.component === undefined) return; // No component specified for page
// Create the container group
const group = this.createPageContainer();
group.setInputValue('position', 'absolute');
// Create the page content
const content = await this.nodeScope.createNode(pageInfo.component, guid());
for (var key in args.params) {
content.setInputValue(key, args.params[key]);
}
group.addChild(content);
// Connect navigate back nodes
var navigateBackNodes = content.nodeScope.getNodesWithType('PageStackNavigateBack');
if (navigateBackNodes && navigateBackNodes.length > 0) {
for (var j = 0; j < navigateBackNodes.length; j++) {
navigateBackNodes[j]._setBackCallback(this.back.bind(this));
}
}
// Push the new top
var top = this._internal.stack[this._internal.stack.length - 1];
var newTop = {
from: top.page,
page: group,
pageInfo: pageInfo,
pageId: pageId,
params: args.params,
transition: new Transitions[args.transition.type || 'Push'](top.page, group, args.transition),
backCallback: args.backCallback,
componentName: args.target
};
this._internal.stack.push(newTop);
this.setPageOutputs({
topPageName: this._pageNameForId(args.target),
stackDepth: this._internal.stack.length
});
this._updateUrlWithTopPage();
newTop.transition.forward(0);
this._internal.isTransitioning = true;
newTop.transition.start({
end: () => {
this._internal.isTransitioning = false;
// Transition has completed, remove the previous top from the stack
this.removeChild(top.page);
group.setInputValue('position', 'relative');
}
});
this.addChild(group);
args.hasNavigated && args.hasNavigated();
},
back(args) {
if (this._internal.stack.length <= 1) return;
if (this._internal.isTransitioning) return;
var top = this._internal.stack[this._internal.stack.length - 1];
top.page.setInputValue('position', 'absolute');
// Insert the destination in the stack again
this.addChild(top.from, 0);
top.backCallback && top.backCallback(args.backAction, args.results);
this.setPageOutputs({
topPageName: this._pageNameForId(this._internal.stack[this._internal.stack.length - 2].pageId),
stackDepth: this._internal.stack.length - 1
});
this._internal.isTransitioning = true;
top.transition.start({
end: () => {
this._internal.isTransitioning = false;
top.page.setInputValue('position', 'relative');
this.removeChild(top.page);
this.nodeScope.deleteNode(top.page);
this._internal.stack.pop();
this._updateUrlWithTopPage();
},
back: true
});
},
setPageComponent(pageId, component) {
var internal = this._internal;
if (!internal.pageInfo[pageId]) internal.pageInfo[pageId] = {};
internal.pageInfo[pageId].component = component;
// this.scheduleRefresh();
},
setPagePath(pageId, path) {
var internal = this._internal;
if (!internal.pageInfo[pageId]) internal.pageInfo[pageId] = {};
internal.pageInfo[pageId].path = path;
// this.scheduleRefresh();
},
setStartPage(pageId) {
this._internal.startPageId = pageId;
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name.startsWith('pageComp-'))
return this.registerInput(name, {
set: this.setPageComponent.bind(this, name.substring('pageComp-'.length))
});
if (name.startsWith('pagePath-'))
return this.registerInput(name, {
set: this.setPagePath.bind(this, name.substring('pagePath-'.length))
});
if (name === 'startPage')
return this.registerInput(name, {
set: this.setStartPage.bind(this)
});
}
},
setup(context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
function _updatePorts() {
var ports = [];
if (node.parameters['pages'] !== undefined && node.parameters['pages'].length > 0) {
node.parameters['pages'].forEach((p) => {
// Component for page
ports.push({
name: 'pageComp-' + p.id,
displayName: 'Component',
editorName: p.label + ' | Component',
plug: 'input',
type: 'component',
parent: 'pages',
parentItemId: p.id
});
// Path for page
if (node.parameters['useRoutes'] === true) {
ports.push({
name: 'pagePath-' + p.id,
displayName: 'Path',
editorName: p.label + ' | Path',
plug: 'input',
type: 'string',
default: p.label.replace(/\s+/g, '-').toLowerCase(),
parent: 'pages',
parentItemId: p.id
});
}
});
ports.push({
plug: 'input',
type: {
name: 'enum',
enums: node.parameters['pages'].map((p) => ({
label: p.label,
value: p.id
})),
allowEditOnly: true
},
group: 'General',
displayName: 'Start Page',
name: 'startPage',
default: node.parameters['pages'][0].id
});
}
context.editorConnection.sendDynamicPorts(node.id, ports);
}
_updatePorts();
node.on('parameterUpdated', function (ev) {
if (ev.name === 'pages' || ev.name === 'useRoutes') _updatePorts();
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.Page Stack', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('Page Stack')) {
_managePortsForNode(node);
}
});
}
};
export default createNodeFromReactComponent(PageStack);

View File

@@ -0,0 +1,103 @@
'use strict';
const PageInputsNode = {
name: 'PageInputs',
displayNodeName: 'Page Inputs',
category: 'Navigation',
docs: 'https://docs.noodl.net/nodes/navigation/page-inputs',
color: 'component',
initialize: function () {
var internal = this._internal;
internal.params = {};
},
inputs: {
pathParams: {
type: { name: 'stringlist', allowEditOnly: true },
group: 'Path Parameters'
},
queryParams: {
type: { name: 'stringlist', allowEditOnly: true },
group: 'Query Parameters'
}
},
outputs: {},
methods: {
_setPageParams: function (params) {
for (var key in params) {
this._internal.params[key] = params[key];
if (this.hasOutput('pm-' + key)) this.flagOutputDirty('pm-' + key);
}
},
getPageParam: function (name) {
return this._internal.params[name];
},
registerOutputIfNeeded: function (name) {
if (this.hasOutput(name)) {
return;
}
if (name.startsWith('pm-'))
return this.registerOutput(name, {
getter: this.getPageParam.bind(this, name.substring('pm-'.length))
});
}
}
};
module.exports = {
node: PageInputsNode
/* setup: function setup(context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
function _updatePorts() {
var ports = []
const uniqueNames = {}
if(node.parameters['pathParams'] !== undefined) {
node.parameters['pathParams'].split(',').forEach((p) => {
uniqueNames[p] = true
})
}
if(node.parameters['queryParams'] !== undefined) {
node.parameters['queryParams'].split(',').forEach((p) => {
uniqueNames[p] = true
})
}
Object.keys(uniqueNames).forEach((outputName) => {
ports.push({
name: 'pm-' + outputName,
displayName: outputName,
type: '*',
plug: 'output',
group: 'Parameters'
})
})
context.editorConnection.sendDynamicPorts(node.id, ports);
}
// Track page node in this component, update if there are any changes
_updatePorts();
node.on("parameterUpdated", function (event) {
_updatePorts();
})
}
graphModel.on("editorImportComplete", ()=> {
graphModel.on("nodeAdded.PageInputs", function (node) {
_managePortsForNode(node)
})
for(const node of graphModel.getNodesWithType('PageInputs')) {
_managePortsForNode(node)
}
})
}*/
};

View File

@@ -0,0 +1,220 @@
import { META_TAGS, Page } from '../../components/navigation/Page';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
const PageNode = {
name: 'Page',
displayNodeName: 'Page',
category: 'Visuals',
docs: 'https://docs.noodl.net/nodes/navigation/page',
useVariants: false,
mountedInput: false,
allowAsExportRoot: false,
singleton: true,
connectionPanel: {
groupPriority: ['General', 'Mounted']
},
initialize: function () {
this.props.layout = 'column'; //this allows the children to know what type of layout type they're in
if (this.isInputConnected('onPageReady')) {
this.nodeScope.context.eventEmitter.emit('SSR_PageLoading', this.id);
}
},
defaultCss: {
display: 'flex',
flexDirection: 'column',
position: 'relative',
alignItems: 'flex-start',
flex: '1 1',
alignSelf: 'stretch'
},
getReactComponent() {
return Page;
},
inputs: {
// TODO: Enable with SSR
// onPageReady: {
// displayName: 'Page Ready',
// type: 'signal',
// valueChangedToTrue() {
// this.nodeScope.context.eventEmitter.emit('SSR_PageReady', this.id);
// }
// },
sitemapIncluded: {
index: 80001,
displayName: 'Included',
group: 'Experimental Sitemap',
default: true,
type: {
name: 'boolean',
allowEditOnly: true
}
},
sitemapChangefreq: {
index: 80002,
displayName: 'Change Freq',
group: 'Experimental Sitemap',
default: 'weekly',
type: {
name: 'enum',
allowEditOnly: true,
enums: [
{ label: 'always', value: 'always' },
{ label: 'hourly', value: 'hourly' },
{ label: 'daily', value: 'daily' },
{ label: 'weekly', value: 'weekly' },
{ label: 'monthly', value: 'monthly' },
{ label: 'yearly', value: 'yearly' },
{ label: 'never', value: 'never' }
]
}
},
sitemapPriority: {
index: 80003,
displayName: 'Priority',
group: 'Experimental Sitemap',
default: 0.5,
type: {
name: 'number',
allowEditOnly: true
}
}
// NOTE: Hide this for now, this is going to be important for SSR
// sitemapScript: {
// index: 80004,
// displayName: 'Script',
// group: 'Sitemap',
// type: {
// name: 'string',
// allowEditOnly: true,
// codeeditor: 'javascript'
// }
// }
},
inputProps: META_TAGS.reduce((result, x, index) => {
result[x.key] = {
index: 80000 + index,
displayName: x.displayName,
editorName: x.editorName || x.displayName,
propPath: 'metatags',
group: x.group,
popout: x.popout,
type: x.type || 'string'
};
return result;
}, {}),
methods: {
getUrlPath: function () {
return this._internal.urlPath;
},
getTitle: function () {
return this._internal.title;
},
/* setRouter:function(value) {
this._internal.router = value
},*/
setTitle: function (value) {
this._internal.title = value;
},
setUrlPath: function (value) {
this._internal.urlPath = value;
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
/* if (name === 'router') return this.registerInput(name, {
set: this.setRouter.bind(this)
})*/
if (name === 'title')
return this.registerInput(name, {
set: this.setTitle.bind(this)
});
if (name === 'urlPath')
return this.registerInput(name, {
set: this.setUrlPath.bind(this)
});
}
},
setup(context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
function _updatePorts() {
var ports = [];
// Show router selector if more that one
/* var routers = graphModel.getNodesWithType('Router')
if(routers.length > 1) {
ports.push({
plug: 'input',
type: { name: 'enum', enums: routers.map((r) => ({ label: (r.parameters['name'] || 'Main'), value: (r.parameters['name'] || 'Main') })), allowEditOnly: true },
group: 'General',
displayName: 'Router',
name: 'router',
default:'Main'
})
}*/
// Title and urlpath ports
const titleParts = node.component.name.split('/');
ports.push({
name: 'title',
displayName: 'Title',
type: 'string',
group: 'General',
plug: 'input',
default: titleParts[titleParts.length - 1]
});
const title = node.parameters['title'] || titleParts[titleParts.length - 1];
const defaultUrlPath = title.replace(/\s+/g, '-').toLowerCase();
ports.push({
name: 'urlPath',
displayName: 'Url Path',
type: 'string',
group: 'General',
plug: 'input',
default: defaultUrlPath
});
context.editorConnection.sendDynamicPorts(node.id, ports);
}
_updatePorts();
node.on('parameterUpdated', function (ev) {
if (ev.name === 'title') _updatePorts();
});
// graphModel.on("nodeAdded.Router", _updatePorts)
// graphModel.on("nodeWasRemoved.Router", _updatePorts)
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.Page', function (node) {
_managePortsForNode(node);
});
graphModel.on('componentRenamed', function (component) {
const page = graphModel.getNodesWithType('Page').filter((x) => component.roots.includes(x.id));
if (page.length > 0) {
_managePortsForNode(page[0]);
}
});
for (const node of graphModel.getNodesWithType('Page')) {
_managePortsForNode(node);
}
});
}
};
//NodeSharedPortDefinitions.addSharedVisualInputs(PageNode);
NodeSharedPortDefinitions.addPaddingInputs(PageNode);
export default createNodeFromReactComponent(PageNode);

View File

@@ -0,0 +1,134 @@
import NoodlRuntime from '@noodl/runtime';
import type { NodeConstructor } from '../../../typings/global';
export type NavigateArgs = {
target: string;
params: Record<string, string | number | boolean>;
openInNewTab?: boolean;
hasNavigated: () => void;
};
export type ComponentPageInfo = {
path: string;
title: string;
component: string;
};
export class RouterHandler {
static instance = new RouterHandler();
_routers: Record<string, any>;
_navigationQueue: any[];
constructor() {
this._routers = {};
this._navigationQueue = [];
}
navigate(name: string, args: NavigateArgs) {
//add a 1ms timeout so other nodes have a chance to run before the page is destroyed.
setTimeout(() => {
const routerNames = Object.keys(this._routers);
if (routerNames.length === 1) {
name = routerNames[0];
}
if (this._routers[name]) {
for (const router of this._routers[name]) {
router.navigate(args);
}
} else {
this._navigationQueue.push({ name, args });
}
}, 1);
}
registerRouter(name: string, router: NodeConstructor) {
name = name || 'Main';
if (!this._routers[name]) {
this._routers[name] = [];
}
this._routers[name].push(router);
let hasNavigated = false;
let i = 0;
while (i < this._navigationQueue.length) {
const e = this._navigationQueue[i];
if (e.name === name) {
router.navigate(e.args);
hasNavigated = true;
this._navigationQueue.splice(i, 1);
} else {
i++;
}
}
if (!hasNavigated) {
router.reset(); //no navigation has happened, call reset() so the start page is created
}
}
deregisterRouter(name: string, router: NodeConstructor) {
name = name || 'Main';
if (!this._routers[name]) {
return;
}
const index = this._routers[name].indexOf(router);
if (index === -1) {
return;
}
this._routers[name].splice(index, 1);
if (this._routers[name].length === 0) {
delete this._routers[name];
}
}
getPagesForRouter(name) {
const routerIndex = NoodlRuntime.instance.graphModel.routerIndex;
const routers = routerIndex.routers;
if (routers === undefined || routers.length === 0) return [];
const matchingRouters = name === undefined ? [routers[0]] : routers.filter((r) => r.name === name);
const pageComponents = new Set();
matchingRouters.forEach((r) => {
const pages = r.pages;
if (pages !== undefined && pages.routes !== undefined) {
pages.routes.forEach((r) => {
pageComponents.add(r);
});
}
});
return Array.from(pageComponents)
.map((c) => this.getPageInfoForComponent(String(c)))
.filter((item) => !!item);
}
getPageInfoForComponent(componentName: string): ComponentPageInfo | undefined {
const routerIndex = NoodlRuntime.instance.graphModel.routerIndex;
const page = routerIndex.pages.find((p) => p.component === componentName);
return page;
}
/**
* Occurs when the Router navigates to a new page.
*
* @param routerName
* @param page
*/
onNavigated(routerName: string, page: ComponentPageInfo) {
// @ts-expect-error window.Noodl is not defined
window.Noodl.Events.emit('NoodlApp_Navigated', {
routerName,
...page
});
}
}

View File

@@ -0,0 +1,94 @@
const { RouterHandler } = require('./router-handler');
const RouterNavigate = {
name: 'RouterNavigate',
displayNodeName: 'Navigate',
category: 'Navigation',
docs: 'https://docs.noodl.net/nodes/navigation/navigate',
initialize: function () {
this._internal.pageParams = {};
this._internal.openInNewTab = false;
},
inputs: {
navigate: {
displayName: 'Navigate',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleNavigate();
}
},
openInNewTab: {
index: 10,
displayName: 'Open in new tab',
group: 'General',
default: false,
type: 'boolean',
set(value) {
this._internal.openInNewTab = !!value;
}
}
},
outputs: {
navigated: {
type: 'signal',
displayName: 'Navigated',
group: 'Events'
}
},
methods: {
scheduleNavigate: function () {
var internal = this._internal;
if (!internal.hasScheduledNavigate) {
internal.hasScheduledNavigate = true;
this.scheduleAfterInputsHaveUpdated(() => {
internal.hasScheduledNavigate = false;
this.navigate();
});
}
},
navigate() {
RouterHandler.instance.navigate(this._internal.router, {
target: this._internal.target,
params: this._internal.pageParams,
openInNewTab: this._internal.openInNewTab,
hasNavigated: () => {
this.scheduleAfterInputsHaveUpdated(() => {
this.sendSignalOnOutput('navigated');
});
}
});
},
setPageParam: function (param, value) {
this._internal.pageParams[param] = value;
},
setTargetPage: function (page) {
this._internal.target = page;
},
setRouter: function (value) {
this._internal.router = value;
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name === 'target') {
return this.registerInput(name, {
set: this.setTargetPage.bind(this)
});
} else if (name === 'router') {
return this.registerInput(name, {
set: this.setRouter.bind(this)
});
} else if (name.startsWith('pm-')) {
return this.registerInput(name, {
set: this.setPageParam.bind(this, name.substring('pm-'.length))
});
}
}
}
};
module.exports = {
node: RouterNavigate
};

View File

@@ -0,0 +1,628 @@
import React, { useEffect } from 'react';
import NoodlRuntime from '@noodl/runtime';
import ASyncQueue from '../../async-queue';
import guid from '../../guid';
import { createNodeFromReactComponent } from '../../react-component-node';
import { Noodl, Slot } from '../../types';
import { ComponentPageInfo, NavigateArgs, RouterHandler } from './router-handler';
export interface RouterReactComponentProps extends Noodl.ReactProps {
didMount: () => void;
willUnmount: () => void;
children: Slot;
}
function RouterReactComponent(props: RouterReactComponentProps) {
const { didMount, willUnmount, children, style } = props;
useEffect(() => {
didMount();
return () => {
willUnmount();
};
}, []);
return (
<div className={props.className} style={style}>
{children}
</div>
);
}
function _trimUrlPart(url) {
if (url[0] === '/') url = url.substring(1);
if (url[url.length - 1] === '/') url = url.substring(0, url.length - 1);
return url;
}
function getBaseUrlLength(url: string): number {
// If the URL is a full URL, then we only want to get the pathname.
// Otherwise we just return the url length which should be the pathname.
if (!url.startsWith('/')) {
try {
// Lets say the baseUrl is:
// "https://collar-zippy-overcome.sandbox.noodl.app/my-folder"
// Then we want to remove "/my-folder" from the substring
return new URL(url).pathname.length;
} catch {
/* noop */
}
}
return url.length;
}
const RouterNode = {
name: 'Router',
displayNodeName: 'Page Router',
category: 'Visuals',
docs: 'https://docs.noodl.net/nodes/navigation/page-router',
useVariants: false,
connectionPanel: {
groupPriority: ['General', 'Actions', 'Events', 'Mounted']
},
initialize: function () {
this._internal.asyncQueue = new ASyncQueue();
this.onScheduleReset = () => {
this.scheduleReset();
};
this.props.didMount = () => {
this._internal.isMounted = true;
// SSR Support
if (typeof window !== 'undefined') {
// Listen to push state events and update stack
if (window.history && window.history.pushState) {
//this is event is manually sent on push, so covers both pop and push state
window.addEventListener('popstate', this.onScheduleReset);
} else {
// Only hash support
window.addEventListener('hashchange', this.onScheduleReset);
}
}
this._registerRouter();
};
this.props.willUnmount = () => {
this._internal.isMounted = false;
window.removeEventListener('popstate', this.onScheduleReset);
window.removeEventListener('hashchange', this.onScheduleReset);
this._deregisterRouter();
};
this.props.layout = 'column';
},
getInspectInfo() {
return this._internal.currentUrl;
},
defaultCss: {
flex: '1 1',
alignSelf: 'stretch',
position: 'relative',
display: 'flex',
flexDirection: 'column'
},
getReactComponent() {
return RouterReactComponent;
},
inputs: {
name: {
type: 'string',
displayName: 'Name',
group: 'General',
set: function (value) {
this._deregisterRouter();
this._internal.name = value;
if (this._internal.isMounted) {
this._registerRouter();
}
}
},
pages: {
type: { name: 'pages', allowEditOnly: true },
displayName: 'Pages',
group: 'Pages',
set: function (value) {
this._internal.pages = value;
if (this._internal.isMounted) {
this.scheduleReset();
}
}
},
urlPath: {
type: 'string',
displayName: 'Url path',
group: 'General',
set: function (value) {
this._internal.urlPath = value;
}
},
clip: {
displayName: 'Clip Behavior',
type: {
name: 'enum',
enums: [
{ value: 'contentHeight', label: 'Expand to content size' },
{ value: 'scroll', label: 'Scroll' },
{ value: 'clip', label: 'Clip content' }
]
},
group: 'Layout',
default: 'contentHeight',
set(value) {
switch (value) {
case 'scroll':
this.setStyle({ overflow: 'auto' });
break;
case 'clip':
this.setStyle({ overflow: 'hidden' });
break;
default:
this.removeStyle(['overflow']);
break;
}
}
},
reset: {
type: 'signal',
displayName: 'Reset',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleReset();
}
}
},
inputCss: {
backgroundColor: {
type: 'color',
displayName: 'Background Color',
group: 'Style',
default: 'transparent',
applyDefault: false
}
},
outputs: {
currentPageTitle: {
type: 'string',
group: 'General',
displayName: 'Current Page Title',
getter: function () {
return this._internal.currentPage !== undefined ? this._internal.currentPage.title : undefined;
}
},
currentPageComponent: {
type: 'string',
group: 'General',
displayName: 'Current Page Component',
getter: function () {
return this._internal.currentPage !== undefined ? this._internal.currentPage.component : undefined;
}
}
},
methods: {
_registerRouter() {
RouterHandler.instance.registerRouter(this._internal.name, this);
},
_deregisterRouter() {
RouterHandler.instance.deregisterRouter(this._internal.name, this);
},
setPageOutputs(outputs) {
for (const prop in outputs) {
this._internal[prop] = outputs[prop];
this.flagOutputDirty(prop);
}
},
scheduleReset() {
const internal = this._internal;
if (!internal.hasScheduledReset) {
internal.hasScheduledReset = true;
this.scheduleAfterInputsHaveUpdated(() => {
internal.hasScheduledReset = false;
this.reset();
});
}
},
createPageContainer() {
const group = this.nodeScope.createPrimitiveNode('Group');
group.setStyle({ flex: '1 0 100%' });
return group;
},
reset() {
this._internal.asyncQueue.enqueue(this.resetAsync.bind(this));
},
scrollToTop() {
const dom = this.getDOMElement();
if (dom) {
dom.scrollTop = 0;
if (NoodlRuntime.instance.getProjectSettings().bodyScroll) {
//Automatically scroll the page router into view in case it's currently outside the viewport
//Might want to add an option to disable this
dom.scrollIntoView();
}
}
},
async resetAsync() {
let component: string;
let params = {};
const matchFromUrl = this.matchPageFromUrl();
if (matchFromUrl) {
if (!matchFromUrl.page) {
// Use the start page
component = this._internal.pages !== undefined ? this._internal.pages.startPage : undefined;
params = Object.assign({}, matchFromUrl.params, matchFromUrl.query);
} else {
// Use the matching page
component = matchFromUrl.page.component;
params = Object.assign({}, matchFromUrl.params, matchFromUrl.query);
}
} else {
// TODO: Clean up matchPageFromUrl, in this case it returns undefined.
// remainingNavigationPath is undefined, since it have to run
// matchPageFromUrl to get the path. So it feels like it needs
// some bigger refactoring to make it understandable.
component = this._internal.pages.startPage;
params = {};
}
if (component === undefined) return; // No component specified for page
const currentPage = RouterHandler.instance.getPageInfoForComponent(component);
if (this._internal.currentPage === currentPage) {
//already at the correct page, keep the current page
//update page inputs if they have changed
//TODO: fix if a parameter goes from a value to undefined, the old value will still exist in the connection from previous navigation
if (!shallowObjectsEqual(this._internal.currentParams, params)) {
this._internal.currentParams = params;
this._updatePageInputs(this._internal.currentPageComponent.nodeScope, params);
}
return;
}
this.scrollToTop();
// Reset the current page for this router
// First remove all children
const children = this.getChildren();
for (const i in children) {
const c = children[i];
this.removeChild(c);
this.nodeScope.deleteNode(c);
}
const content = await this.nodeScope.createNode(component, guid());
this._internal.currentPageComponent = content;
// Find the root page node
const pageNodes = content.nodeScope.getNodesWithType('Page');
if (pageNodes === undefined || pageNodes.length !== 1) {
return; // Should be only one page root
}
this._internal.currentPage = RouterHandler.instance.getPageInfoForComponent(component);
this._internal.currentParams = params;
this.flagOutputDirty('currentPageTitle');
this.flagOutputDirty('currentPageComponent');
// @ts-expect-error Noodl is not defined
Noodl.SEO.setTitle(this._internal.currentPage.title);
this._updatePageInputs(this._internal.currentPageComponent.nodeScope, params);
const group = this.createPageContainer();
group.addChild(content);
this.addChild(group);
/* this.setPageOutputs({
currentUrl: pageInfo.path,
currentTitle: pageInfo.title
});*/
},
_updatePageInputs(nodeScope, params) {
for (const pageInputNode of nodeScope.getNodesWithType('PageInputs')) {
pageInputNode._setPageParams(params);
}
},
getRelativeURL(targetPage: ComponentPageInfo, pageParams: NavigateArgs['params']) {
if (!targetPage) return;
let urlPath = targetPage.path;
if (urlPath === undefined) return;
// First add matching parameters to path
const paramsInPath = urlPath.match(/{([^}]+)}/g);
const params = Object.assign({}, pageParams);
if (paramsInPath) {
for (const param of paramsInPath) {
const key = param.replace(/[{}]/g, '');
if (pageParams[key] !== undefined) {
urlPath = urlPath.replace(param, encodeURIComponent(params[key]));
delete params[key];
}
}
}
// Add other paramters as query
const query = [];
for (const key in params) {
query.push({
name: key,
value: encodeURIComponent(params[key])
});
}
urlPath = _trimUrlPart(urlPath);
// Prepend this routers url path if there is one
if (this._internal.urlPath !== undefined) urlPath = _trimUrlPart(this._internal.urlPath) + '/' + urlPath;
return {
path: urlPath,
query: query
};
},
getNavigationAbsoluteURL(targetPage: ComponentPageInfo, pageParams: NavigateArgs['params']) {
let parent = this.parent;
let parentUrl = { path: '', query: [] };
while (parent !== undefined && typeof parent.getNavigationAbsoluteURL !== 'function') {
parent = parent.getVisualParentNode();
}
if (parent) {
parentUrl = parent.getNavigationAbsoluteURL(parent._internal.currentPage, parent._internal.currentParams);
}
const thisUrl = this.getRelativeURL(targetPage, pageParams);
if (thisUrl) {
const haveForwardSlash = parentUrl.path.endsWith('/') || thisUrl.path.startsWith('/');
return {
path: parentUrl.path + (haveForwardSlash ? '' : '/') + thisUrl.path,
query: parentUrl.query.concat(thisUrl.query)
};
}
return parentUrl;
},
_getLocationPath: function () {
const navigationPathType = NoodlRuntime.instance.getProjectSettings()['navigationPathType'];
if (navigationPathType === undefined || navigationPathType === 'hash') {
// Use hash as path
let hash = location.hash;
if (hash) {
if (hash[0] === '#') hash = hash.substring(1);
if (hash[0] === '/') hash = hash.substring(1);
}
return decodeURI(hash);
} else {
// Use url as path
let path = location.pathname;
if (path) {
if (path[0] === '/') {
// @ts-expect-error missing Noodl typings
const baseUrl = Noodl.Env['BaseUrl'];
if (baseUrl) {
const pathnameLength = getBaseUrlLength(baseUrl);
path = path.substring(pathnameLength);
} else {
path = path.substring(1);
}
}
}
return decodeURI(path);
}
},
_getSearchParams: function () {
// Regex for replacing addition symbol with a space
const pl = /\+/g;
const search = /([^&=]+)=?([^&]*)/g;
const decode = function (s) {
return decodeURIComponent(s.replace(pl, ' '));
};
const query = location.search.substring(1);
let match: RegExpExecArray;
const urlParams = {};
while ((match = search.exec(query))) urlParams[decode(match[1])] = decode(match[2]);
return urlParams;
},
getNavigationRemainingPath() {
return this._internal.remainingNavigationPath;
},
matchPageFromUrl() {
// Attempt to find relative path from closest navigation parent
let parent = this.parent;
while (parent !== undefined && typeof parent.getNavigationRemainingPath !== 'function') {
parent = parent.getVisualParentNode();
}
let pathParts = undefined;
// Either use current browser location if we have no parent, or use remaining path
// from parent
if (parent === undefined) {
let urlPath = this._getLocationPath();
if (urlPath[0] === '/') urlPath = urlPath.substring(1);
pathParts = urlPath.split('/');
} else {
pathParts = parent.getNavigationRemainingPath();
}
if (pathParts === undefined) return;
const urlQuery = this._getSearchParams();
function _matchPathParts(path, pattern) {
const params = {};
for (let i = 0; i < pattern.length; i++) {
const _p = pattern[i];
if (_p[0] === '{' && _p[_p.length - 1] === '}') {
// This is a param, collect it
if (path[i] !== undefined) {
params[_p.substring(1, _p.length - 1)] = decodeURIComponent(path[i]);
}
} else if (path[i] === undefined || _p !== path[i]) return;
}
return {
params: params,
remainingPathParts: path.slice().splice(pattern.length) // Make copy
};
}
const pages = RouterHandler.instance.getPagesForRouter(this._internal.name);
if (pages === undefined || pages.length === 0) return;
let matchedPage,
bestMatchLength = 9999;
for (const pageInfo of pages) {
let pagePattern = pageInfo.path;
if (pagePattern === undefined) continue;
pagePattern = _trimUrlPart(pagePattern);
// Prepend this routers url path if there is one
if (this._internal.urlPath !== undefined)
pagePattern = _trimUrlPart(this._internal.urlPath) + '/' + pagePattern;
const pagePatternParts = pagePattern.split('/');
const match = _matchPathParts(pathParts, pagePatternParts);
const dist = Math.abs(pagePatternParts.length - pathParts.length);
if (match && bestMatchLength > dist) {
// This page is a match
matchedPage = { match, pageInfo };
bestMatchLength = dist;
}
}
if (matchedPage) {
this._internal.remainingNavigationPath = matchedPage.match.remainingPathParts;
return {
page: matchedPage.pageInfo,
params: matchedPage.match.params,
query: urlQuery
};
} else {
return {
page: undefined, // no matched page
params: {},
query: urlQuery
};
}
},
_getCompleteUrlToPage(page: ComponentPageInfo, pageParams: NavigateArgs['params']) {
const url = this.getNavigationAbsoluteURL(page, pageParams);
let urlPath, hashPath;
const navigationPathType = NoodlRuntime.instance.getProjectSettings()['navigationPathType'];
if (navigationPathType === undefined || navigationPathType === 'hash') {
hashPath = url.path;
} else {
urlPath = url.path;
}
const query = url.query.map((q) => q.name + '=' + q.value);
const compiledUrl =
(urlPath !== undefined ? urlPath : '') +
(query.length >= 1 ? '?' + query.join('&') : '') +
(hashPath !== undefined ? '#' + hashPath : '');
return compiledUrl;
},
_updateUrlWithTopPage() {
// Push the state to the browser url
if (window.history !== undefined) {
this._internal.remainingNavigationPath = undefined; // Reset remaining nav path
const url = this._getCompleteUrlToPage(this._internal.currentPage, this._internal.currentParams);
window.history.pushState({}, '', url);
}
},
navigate(args) {
this._internal.asyncQueue.enqueue(this.navigateAsync.bind(this, args));
},
async navigateAsync(args: NavigateArgs) {
if (args.target === undefined) return;
const newPage = RouterHandler.instance.getPageInfoForComponent(args.target);
if (!newPage) return; //TODO: send error to editor, "invalid page component name"
if (args.openInNewTab) {
const url = this._getCompleteUrlToPage(newPage, args.params);
window.open(url, '_blank');
args.hasNavigated && args.hasNavigated();
} else {
await this._navigateInCurrentWindow(newPage, args);
}
},
async _navigateInCurrentWindow(newPage: ComponentPageInfo, args: NavigateArgs) {
this.scrollToTop();
// Remove all current pages in the stack
const children = this.getChildren();
for (const i in children) {
const c = children[i];
this.removeChild(c);
this.nodeScope.deleteNode(c);
}
const group = this.createPageContainer();
// Create the page content
const content = await this.nodeScope.createNode(args.target, guid());
this._internal.currentPage = newPage;
this._internal.currentParams = args.params;
this.flagOutputDirty('currentPageTitle');
this.flagOutputDirty('currentPageComponent');
// @ts-expect-error Noodl is not defined
Noodl.SEO.setTitle(this._internal.currentPage.title);
const pageInputNodes = content.nodeScope.getNodesWithType('PageInputs');
if (pageInputNodes !== undefined && pageInputNodes.length > 0) {
pageInputNodes.forEach((node) => {
node._setPageParams(args.params);
});
}
group.addChild(content);
this.addChild(group);
this._updateUrlWithTopPage();
args.hasNavigated && args.hasNavigated();
RouterHandler.instance.onNavigated(this._internal.name, newPage);
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
}
}
};
function shallowObjectsEqual(object1, object2) {
const keys1 = Object.keys(object1 || {});
const keys2 = Object.keys(object2 || {});
if (keys1.length !== keys2.length) {
return false;
}
return keys1.every((key) => object1[key] === object2[key]);
}
export default createNodeFromReactComponent(RouterNode);

View File

@@ -0,0 +1,204 @@
const ShowPopupNode = {
name: 'NavigationShowPopup',
displayNodeName: 'Show Popup',
category: 'Navigation',
docs: 'https://docs.noodl.net/nodes/popups/show-popup',
initialize: function () {
this._internal.popupParams = {};
this._internal.closeResults = {};
},
inputs: {
target: {
type: 'component',
displayName: 'Target',
group: 'General',
set: function (value) {
this._internal.target = value;
}
},
show: {
type: 'signal',
displayName: 'Show',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleShow();
}
}
},
outputs: {
Closed: {
type: 'signal'
}
},
methods: {
setPopupParam: function (param, value) {
this._internal.popupParams[param] = value;
},
getCloseResult: function (param) {
return this._internal.closeResults[param];
},
scheduleShow: function () {
var _this = this;
var internal = this._internal;
if (!internal.hasScheduledShow) {
internal.hasScheduledShow = true;
this.scheduleAfterInputsHaveUpdated(function () {
internal.hasScheduledShow = false;
_this.show();
});
}
},
show: function () {
if (this._internal.target == undefined) return;
this.context.showPopup(this._internal.target, this._internal.popupParams, {
senderNode: this.nodeScope.componentOwner,
onClosePopup: (action, results) => {
this._internal.closeResults = results;
for (var key in results) {
if (this.hasOutput('closeResult-' + key)) this.flagOutputDirty('closeResult-' + key);
}
if (!action) this.sendSignalOnOutput('Closed');
else this.sendSignalOnOutput(action);
}
});
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name.startsWith('popupParam-'))
return this.registerInput(name, {
set: this.setPopupParam.bind(this, name.substring('popupParam-'.length))
});
},
registerOutputIfNeeded: function (name) {
if (this.hasOutput(name)) {
return;
}
if (name.startsWith('closeResult-'))
return this.registerOutput(name, {
getter: this.getCloseResult.bind(this, name.substring('closeResult-'.length))
});
if (name.startsWith('closeAction-'))
return this.registerOutput(name, {
getter: function () {
/** No needed for signals */
}
});
}
}
};
module.exports = {
node: ShowPopupNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
function _updatePorts() {
var ports = [];
var targetComponentName = node.parameters['target'];
if (targetComponentName !== undefined) {
var c = graphModel.components[targetComponentName];
if (c) {
for (var inputName in c.inputPorts) {
var o = c.inputPorts[inputName];
ports.push({
name: 'popupParam-' + inputName,
displayName: inputName,
type: o.type || '*',
plug: 'input',
group: 'Params'
});
}
for (const _n of c.getNodesWithType('NavigationClosePopup')) {
if (_n.parameters['closeActions'] !== undefined) {
_n.parameters['closeActions'].split(',').forEach((a) => {
if (ports.find((p) => p.name === a)) return;
ports.push({
name: 'closeAction-' + a,
displayName: a,
type: 'signal',
plug: 'output',
group: 'Close Actions'
});
});
}
if (_n.parameters['results'] !== undefined) {
_n.parameters['results'].split(',').forEach((p) => {
ports.push({
name: 'closeResult-' + p,
displayName: p,
type: '*',
plug: 'output',
group: 'Close Results'
});
});
}
}
}
}
context.editorConnection.sendDynamicPorts(node.id, ports);
}
function _trackTargetComponent(name) {
if (name === undefined) return;
var c = graphModel.components[name];
if (c === undefined) return;
c.on('inputPortAdded', _updatePorts);
c.on('inputPortRemoved', _updatePorts);
// Also track all close popups for changes
for (const _n of c.getNodesWithType('NavigationClosePopup')) {
_n.on('parameterUpdated', _updatePorts);
}
// Track close popup added and removed
c.on('nodeAdded', (_n) => {
if (_n.type === 'NavigationClosePopup') {
_n.on('parameterUpdated', _updatePorts);
_updatePorts();
}
});
c.on('nodeWasRemoved', (_n) => {
if (_n.type === 'NavigationClosePopup') _updatePorts();
});
}
_updatePorts();
_trackTargetComponent(node.parameters['target']);
// Track parameter updated
node.on('parameterUpdated', function (event) {
if (event.name === 'target') {
_updatePorts();
_trackTargetComponent(node.parameters['target']);
}
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.NavigationShowPopup', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('NavigationShowPopup')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -0,0 +1,5 @@
module.exports = {
None: require('./transitions/none-transition'),
Push: require('./transitions/push-transition'),
Popup: require('./transitions/popup-transition')
};

View File

@@ -0,0 +1,24 @@
const Transition = require('./transition');
class NoneTransition extends Transition {
constructor(from, to, params) {
super();
this.from = from;
this.to = to;
this.timing = { dur: 0, delay: 0 };
}
update(t) {}
forward(t) {}
back(t) {}
static ports() {
return [];
}
}
module.exports = NoneTransition;

View File

@@ -0,0 +1,121 @@
const Transition = require('./transition');
const BezierEasing = require('bezier-easing');
class PopupTransition extends Transition {
constructor(from, to, params) {
super();
this.from = from;
this.to = to;
this.timing = params.timing || { curve: [0.0, 0.0, 0.58, 1.0], dur: 300, delay: 0 };
this.distance = params.shift || { value: 25, unit: '%' };
if (typeof this.distance === 'number') this.distance = { value: this.distance, unit: '%' };
this.direction = params.direction || 'Right';
this.timing.curve[0] = Math.min(1, Math.max(0, this.timing.curve[0]));
this.timing.curve[2] = Math.min(1, Math.max(0, this.timing.curve[2]));
this.ease = BezierEasing.apply(null, this.timing.curve).get;
this.fadein = params.fadein === undefined ? false : params.fadein;
this.zoom = params.zoom || { value: 25, unit: '%' };
if (typeof this.zoom === 'number') this.zoom = { value: this.zoom, unit: '%' };
}
update(t) {
if (this.direction === 'In' || this.direction === 'Out') {
var zoom = this.zoom.value / 100;
zoom = this.direction === 'Out' ? -zoom : zoom;
this.to.setStyle({
transform: 'scale(' + (1 - zoom * (1 - t)) + ')',
opacity: this.crossfade ? t : 1
});
} else {
var dist = this.distance.value;
var unit = this.distance.unit;
const targets = {
Up: { x: 0, y: -1 },
Down: { x: 0, y: 1 },
Left: { x: -1, y: 0 },
Right: { x: 1, y: 0 }
};
const target = {
x: targets[this.direction].x * dist,
y: targets[this.direction].y * dist
};
this.to.setStyle({
transform: 'translate(' + target.x * (t - 1) + unit + ',' + target.y * (t - 1) + unit + ')',
opacity: this.fadein ? t : 1
});
}
}
forward(t) {
var _t = this.ease(t);
this.update(_t);
}
back(t) {
var _t = this.ease(t);
this.update(1 - _t);
}
static ports(parameters) {
var ports = [];
ports.push({
name: 'tr-direction',
displayName: 'Direction',
group: 'Transition',
type: { name: 'enum', enums: ['Right', 'Left', 'Up', 'Down', 'In', 'Out'] },
default: 'Right',
plug: 'input'
});
if (parameters['tr-direction'] === 'In' || parameters['tr-direction'] === 'Out') {
ports.push({
name: 'tr-zoom',
displayName: 'Zoom',
group: 'Transition',
type: { name: 'number', units: ['%'] },
default: { value: 25, unit: '%' },
plug: 'input'
});
} else {
ports.push({
name: 'tr-shift',
displayName: 'Shift Distance',
group: 'Transition',
type: { name: 'number', units: ['%', 'px'] },
default: { value: 25, unit: '%' },
plug: 'input'
});
}
ports.push({
name: 'tr-fadein',
displayName: 'Fade In',
group: 'Transition',
type: 'boolean',
default: false,
plug: 'input'
});
ports.push({
name: 'tr-timing',
displayName: 'Timing',
group: 'Transition',
type: 'curve',
plug: 'input'
});
return ports;
}
}
module.exports = PopupTransition;

View File

@@ -0,0 +1,186 @@
const Transition = require('./transition');
const BezierEasing = require('bezier-easing');
class PushTransition extends Transition {
constructor(from, to, params) {
super();
this.from = from;
this.to = to;
this.timing = params.timing || { curve: [0.0, 0.0, 0.58, 1.0], dur: 300, delay: 0 };
this.distance = params.shift || { value: 25, unit: '%' };
if (typeof this.distance === 'number') this.distance = { value: this.distance, unit: '%' };
this.direction = params.direction || 'Left';
this.timing.curve[0] = Math.min(1, Math.max(0, this.timing.curve[0]));
this.timing.curve[2] = Math.min(1, Math.max(0, this.timing.curve[2]));
this.ease = BezierEasing.apply(null, this.timing.curve).get;
this.crossfade = params.crossfade === undefined ? false : params.crossfade;
this.darkOverlay = params.darkoverlay === undefined ? true : params.darkoverlay;
this.darkOverlayAmount = params.darkoverlayamount === undefined ? 0.5 : params.darkoverlayamount;
this.zoom = params.zoom || { value: 25, unit: '%' };
if (typeof this.zoom === 'number') this.zoom = { value: this.zoom, unit: '%' };
if (this.darkOverlay) {
this.darkOverlay = from.nodeScope.createPrimitiveNode('Group');
this.darkOverlay.setInputValue('position', 'absolute');
this.darkOverlay.setInputValue('sizeMode', 'explicit');
this.darkOverlay.setInputValue('width', { value: 100, unit: '%' });
this.darkOverlay.setInputValue('height', { value: 100, unit: '%' });
this.darkOverlay.setInputValue('backgroundColor', '#000000');
this.darkOverlay.setInputValue('opacity', 0);
}
}
update(t) {
if (this.direction === 'In' || this.direction === 'Out') {
var zoom = this.zoom.value / 100;
zoom = this.direction === 'Out' ? -zoom : zoom;
this.from.setStyle({
transform: 'scale(' + (1 + zoom * t) + ')',
opacity: this.crossfade ? 1 - t : 1
});
this.to.setStyle({
transform: 'scale(' + (1 - zoom * (1 - t)) + ')',
opacity: this.crossfade ? t : 1
});
} else {
var dist = this.distance.value;
var unit = this.distance.unit;
const targets = {
Up: { x: 0, y: -1 },
Down: { x: 0, y: 1 },
Left: { x: -1, y: 0 },
Right: { x: 1, y: 0 }
};
const from = {
x: targets[this.direction].x * dist,
y: targets[this.direction].y * dist
};
const target = {
x: targets[this.direction].x * 100,
y: targets[this.direction].y * 100
};
this.from.setStyle({
transform: 'translate(' + from.x * t + unit + ',' + from.y * t + unit + ')',
opacity: this.crossfade ? 1 - t : 1
});
this.to.setStyle({
transform: 'translate(' + target.x * (t - 1) + unit + ',' + target.y * (t - 1) + '%)',
opacity: this.crossfade ? t : 1
});
}
if (this.darkOverlay) {
this.darkOverlay.setStyle({
opacity: t * this.darkOverlayAmount
});
}
}
forward(t) {
var _t = this.ease(t);
this.update(_t);
}
back(t) {
var _t = this.ease(t);
this.update(1 - _t);
}
start(args) {
super.start(args);
if (this.darkOverlay) {
this.from.addChild(this.darkOverlay);
}
}
end(args) {
if (this.darkOverlay) {
this.from.removeChild(this.darkOverlay);
}
super.end(args);
}
static ports(parameters) {
const ports = [];
ports.push({
name: 'tr-direction',
displayName: 'Direction',
group: 'Transition',
type: { name: 'enum', enums: ['Right', 'Left', 'Up', 'Down', 'In', 'Out'] },
default: 'Left',
plug: 'input'
});
if (parameters['tr-direction'] === 'In' || parameters['tr-direction'] === 'Out') {
ports.push({
name: 'tr-zoom',
displayName: 'Zoom',
group: 'Transition',
type: { name: 'number', units: ['%'] },
default: { value: 25, unit: '%' },
plug: 'input'
});
} else {
ports.push({
name: 'tr-shift',
displayName: 'Shift Distance',
group: 'Transition',
type: { name: 'number', units: ['%', 'px'] },
default: { value: 25, unit: '%' },
plug: 'input'
});
}
ports.push({
name: 'tr-crossfade',
displayName: 'Crossfade',
group: 'Transition',
type: 'boolean',
default: false,
plug: 'input'
});
ports.push({
name: 'tr-darkoverlay',
displayName: 'Dark Overlay',
group: 'Transition',
type: 'boolean',
default: true,
plug: 'input'
});
ports.push({
name: 'tr-darkoverlayamount',
displayName: 'Dark Overlay Amount',
group: 'Transition',
type: 'number',
default: 0.5,
plug: 'input'
});
ports.push({
name: 'tr-timing',
displayName: 'Timing',
group: 'Transition',
type: 'curve',
plug: 'input'
});
return ports;
}
}
module.exports = PushTransition;

View File

@@ -0,0 +1,34 @@
class Transition {
constructor() {
this._frame = this.frame.bind(this);
}
start(args) {
this.cb = args.end;
if (this.timing.delay + this.timing.dur === 0) {
this.end();
} else {
this.transitionForward = !args.back;
this.startTime = window.performance.now();
requestAnimationFrame(this._frame);
}
}
frame() {
var t = (window.performance.now() - (this.startTime + this.timing.delay)) / this.timing.dur;
var _t = Math.max(0, Math.min(t, 1));
this.transitionForward ? this.forward(_t) : this.back(_t);
if (window.performance.now() <= this.startTime + this.timing.dur + this.timing.delay)
requestAnimationFrame(this._frame);
else this.end();
}
end() {
this.cb && this.cb();
}
}
module.exports = Transition;

View File

@@ -0,0 +1,138 @@
'use strict';
var EaseCurves = require('../../easecurves');
var defaultDuration = 300;
var AnimateToValue = {
name: 'net.noodl.animatetovalue',
docs: 'https://docs.noodl.net/nodes/logic/animate-to-value',
displayName: 'Animate To Value',
shortDesc: 'This node can interpolate smooothely from the current value to a target value.',
category: 'Animation',
initialize: function () {
var self = this,
_internal = this._internal;
_internal.currentNumber = 0;
_internal.numberInitialized = false;
_internal.animationStarted = false;
_internal.setCurrentNumberEnabled = false;
_internal.overrideValue = 0;
_internal._animation = this.context.timerScheduler.createTimer({
duration: defaultDuration,
startValue: 0,
endValue: 0,
ease: EaseCurves.easeOut,
onStart: function () {
_internal.animationStarted = true;
},
onRunning: function (t) {
_internal.currentNumber = this.ease(this.startValue, this.endValue, t);
self.flagOutputDirty('currentValue');
},
onFinish: function () {
self.sendSignalOnOutput('atTargetValue');
}
});
},
getInspectInfo() {
return this._internal.currentNumber;
},
inputs: {
targetValue: {
type: {
name: 'number'
},
displayName: 'Target Value',
group: 'Target Value',
default: undefined, //default is undefined so transition initializes to the first input value
set: function (value) {
if (value === true) {
value = 1;
} else if (value === false) {
value = 0;
}
value = Number(value);
if (isNaN(value)) {
//bail out on NaN values
return;
}
var internal = this._internal;
if (internal.numberInitialized === false) {
internal.currentNumber = value;
internal.numberInitialized = true;
internal._animation.endValue = value;
this.flagOutputDirty('currentValue');
return;
} else if (value === internal._animation.endValue) {
//same as previous value
return;
}
internal._animation.startValue = internal.currentNumber;
internal._animation.endValue = value;
internal._animation.start();
}
},
duration: {
type: 'number',
group: 'Parameters',
displayName: 'Duration',
default: defaultDuration,
set: function (value) {
this._internal._animation.duration = value;
}
},
delay: {
type: 'number',
group: 'Parameters',
displayName: 'Delay',
default: 0,
set: function (value) {
this._internal._animation.delay = value;
}
},
easingCurve: {
type: {
name: 'enum',
enums: [
{ value: 'easeOut', label: 'Ease Out' },
{ value: 'easeIn', label: 'Ease In' },
{ value: 'linear', label: 'Linear' },
{ value: 'easeInOut', label: 'Ease In Out' }
]
},
default: 'easeOut',
displayName: 'Easing Curve',
group: 'Parameters',
set: function (value) {
this._internal._animation.ease = EaseCurves[value];
}
}
},
outputs: {
currentValue: {
type: 'number',
displayName: 'Current Value',
group: 'Current State',
getter: function () {
return this._internal.currentNumber;
}
},
atTargetValue: {
type: 'signal',
displayName: 'At Target Value',
group: 'Signals'
}
}
};
module.exports = {
node: AnimateToValue
};

View File

@@ -0,0 +1,112 @@
'use strict';
const EaseCurves = require('../../easecurves');
function clamp(min, max, value) {
return Math.max(min, Math.min(max, value));
}
function setRGB(result, hex) {
for (var i = 0; i < 3; ++i) {
var index = 1 + i * 2;
result[i] = parseInt(hex.substring(index, index + 2), 16);
}
}
function componentToHex(c) {
var hex = c.toString(16);
return hex.length == 1 ? '0' + hex : hex;
}
function rgbToHex(rgb) {
return '#' + componentToHex(rgb[0]) + componentToHex(rgb[1]) + componentToHex(rgb[2]);
}
//reusing these to reduce GC pressure
let rgb0 = [0, 0, 0];
let rgb1 = [0, 0, 0];
let rgb2 = [0, 0, 0];
const ColorBlendNode = {
name: 'Color Blend',
docs: 'https://docs.noodl.net/nodes/utilities/color-blend',
shortDesc:
'Given any number of input colors this node can interpolate between these and give the result color as output.',
category: 'Interpolation',
getInspectInfo() {
return [{ type: 'color', value: this._internal.resultColor }];
},
initialize() {
const internal = this._internal;
internal.resultColor = '#000000';
internal.blendValue = 0;
internal.colors = [];
},
numberedInputs: {
color: {
type: 'color',
displayPrefix: 'Color',
createSetter(index) {
return function (value) {
this._internal.colors[index] = value;
this.updateColor();
};
}
}
},
inputs: {
blendValue: {
type: 'number',
displayName: 'Blend Value',
default: 0,
set: function (value) {
this._internal.blendValue = value;
this.updateColor();
}
}
},
outputs: {
result: {
type: 'color',
displayName: 'Result',
getter: function () {
return this._internal.resultColor;
}
}
},
methods: {
updateColor() {
var colors = this._internal.colors;
if (colors.length === 0) {
return;
}
function getColor(index) {
return colors[index] ? colors[index] : '#000000';
}
var clampedBlendValue = clamp(0, colors.length - 1, this._internal.blendValue);
var index = Math.floor(clampedBlendValue);
var t = clampedBlendValue - index;
if (t === 0) {
this._internal.resultColor = getColor(index);
} else {
setRGB(rgb0, getColor(index));
setRGB(rgb1, getColor(index + 1));
rgb2[0] = Math.floor(EaseCurves.linear(rgb0[0], rgb1[0], t));
rgb2[1] = Math.floor(EaseCurves.linear(rgb0[1], rgb1[1], t));
rgb2[2] = Math.floor(EaseCurves.linear(rgb0[2], rgb1[2], t));
this._internal.resultColor = rgbToHex(rgb2);
}
this.flagOutputDirty('result');
}
}
};
module.exports = {
node: ColorBlendNode
};

View File

@@ -0,0 +1,161 @@
'use strict';
const Model = require('@noodl/runtime/src/model');
function extendSetComponentObjectProperties(def) {
const SetComponentObjectProperties = {
name: def.name,
displayNodeName: def.displayName,
category: 'Component Utilities',
color: 'component',
docs: def.docs,
initialize: function () {
this._internal.inputValues = {};
},
inputs: {
properties: {
type: {
name: 'stringlist',
allowEditOnly: true
},
displayName: 'Properties',
group: 'Properties',
set(value) {}
},
store: {
type: 'signal',
group: 'Actions',
displayName: 'Do',
valueChangedToTrue() {
this.scheduleStore();
}
}
},
outputs: {
stored: {
type: 'signal',
group: 'Events',
displayName: 'Done'
}
},
methods: {
getComponentObjectId: def.getComponentObjectId,
scheduleStore() {
if (this.hasScheduledStore) return;
this.hasScheduledStore = true;
var internal = this._internal;
this.scheduleAfterInputsHaveUpdated(() => {
const model = Model.get(this.getComponentObjectId());
this.hasScheduledStore = false;
const properties = this.model.parameters.properties || '';
const validProperties = properties.split(',');
const keysToSet = Object.keys(internal.inputValues).filter((key) => validProperties.indexOf(key) !== -1);
for (const i of keysToSet) {
model.set(i, internal.inputValues[i], { resolve: true });
}
this.sendSignalOnOutput('stored');
});
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name.startsWith('prop-')) {
const propertyName = name.substring('prop-'.length);
this.registerInput(name, {
set(value) {
this._internal.inputValues[propertyName] = value;
}
});
} else if (name.startsWith('type-')) {
this.registerInput(name, {
set(value) {}
});
}
}
}
};
function updatePorts(nodeId, parameters, editorConnection) {
var ports = [];
const _types = [
{ label: 'String', value: 'string' },
{ label: 'Boolean', value: 'boolean' },
{ label: 'Number', value: 'number' },
{ label: 'Date', value: 'date' },
{ label: 'Array', value: 'array' },
{ label: 'Object', value: 'object' },
{ label: 'Any', value: '*' }
];
// Add value outputs
var properties = parameters.properties;
if (properties) {
properties = properties ? properties.split(',') : undefined;
for (var i in properties) {
var p = properties[i];
// Property input
ports.push({
type: {
name: parameters['type-' + p] === undefined ? '*' : parameters['type-' + p]
},
plug: 'input',
group: 'Property Values',
displayName: p,
// editorName:p,
name: 'prop-' + p
});
// Property type
ports.push({
type: {
name: 'enum',
enums: _types,
allowEditOnly: true
},
plug: 'input',
group: 'Property Types',
displayName: p,
default: '*',
name: 'type-' + p
});
}
}
editorConnection.sendDynamicPorts(nodeId, ports, {
detectRenamed: {
plug: 'input'
}
});
}
return {
node: SetComponentObjectProperties,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
graphModel.on('nodeAdded.' + def.name, (node) => {
updatePorts(node.id, node.parameters, context.editorConnection);
node.on('parameterUpdated', (event) => {
if (event.name === 'properties' || event.name.startsWith('type-')) {
updatePorts(node.id, node.parameters, context.editorConnection);
}
});
});
}
};
}
module.exports = {
extendSetComponentObjectProperties
};

View File

@@ -0,0 +1,238 @@
'use strict';
const { Node } = require('@noodl/runtime');
const Model = require('@noodl/runtime/src/model');
const ComponentObject = {
name: 'net.noodl.ComponentObject',
displayNodeName: 'Component Object',
category: 'Component Utilities',
color: 'component',
docs: 'https://docs.noodl.net/nodes/component-utilities/component-object',
initialize: function () {
this._internal.inputValues = {};
this._internal.dirtyValues = {};
this._internal.onModelChangedCallback = (args) => {
if (this.isInputConnected('fetch') !== false) return;
if (this.hasOutput('value-' + args.name)) {
this.flagOutputDirty('value-' + args.name);
}
if (this.hasOutput('changed-' + args.name)) {
this.sendSignalOnOutput('changed-' + args.name);
}
this.sendSignalOnOutput('changed');
};
const model = Model.get('componentState' + this.nodeScope.componentOwner.getInstanceId());
this._internal.model = model;
model.on('change', this._internal.onModelChangedCallback);
},
getInspectInfo() {
return {
type: 'value',
value: this._internal.model.data
};
},
inputs: {
properties: {
type: {
name: 'stringlist',
allowEditOnly: true
},
displayName: 'Properties',
group: 'Properties',
set(value) {}
},
fetch: {
displayName: 'Fetch',
group: 'Actions',
valueChangedToTrue() {
this.scheduleFetch();
}
}
},
outputs: {
changed: {
type: 'signal',
displayName: 'Changed',
group: 'Events'
},
fetched: {
type: 'signal',
displayName: 'Fetched',
group: 'Events'
}
},
methods: {
scheduleStore() {
if (this.hasScheduledStore) return;
this.hasScheduledStore = true;
var internal = this._internal;
this.scheduleAfterInputsHaveUpdated(() => {
this.hasScheduledStore = false;
for (var i in internal.dirtyValues) {
internal.model.set(i, internal.inputValues[i], { resolve: true });
}
internal.dirtyValues = {};
});
},
scheduleFetch() {
if (this.hasScheduledFetch) return;
this.hasScheduledFetch = true;
this.scheduleAfterInputsHaveUpdated(() => {
this.hasScheduledFetch = false;
this.fetch();
});
},
fetch() {
for (var key in this._internal.model.data) {
if (this.hasOutput('value-' + key)) {
this.flagOutputDirty('value-' + key);
if (this.hasOutput('changed-' + key)) {
this.sendSignalOnOutput('changed-' + key);
}
}
}
this.sendSignalOnOutput('fetched');
},
_onNodeDeleted() {
Node.prototype._onNodeDeleted.call(this);
this._internal.model.off('change', this._internal.onModelChangedCallback);
},
registerOutputIfNeeded(name) {
if (this.hasOutput(name)) {
return;
}
const split = name.split('-');
const propertyName = split[split.length - 1];
this.registerOutput(name, {
get() {
return this._internal.model.get(propertyName, { resolve: true });
}
});
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name.startsWith('value-')) {
const propertyName = name.substring('value-'.length);
this.registerInput(name, {
set(value) {
this._internal.inputValues[propertyName] = value;
this._internal.dirtyValues[propertyName] = true;
this.scheduleStore();
}
});
}
/* else if (name.startsWith('start-value-')) {
this.registerInput(name, {
set(value) {
this._internal.model.set(propertyName, value)
}
});
}
else if (name.startsWith('type-')) {
this.registerInput(name, {
set(value) {}
});
}*/
}
}
};
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// Add value outputs
if (parameters.properties) {
var properties = parameters.properties.split(',');
for (var i in properties) {
var p = properties[i];
ports.push({
type: {
name: '*',
allowConnectionsOnly: true
},
plug: 'input/output',
group: 'Properties',
name: 'value-' + p,
displayName: p
});
/* ports.push({
type: {
name: parameters['type-' + p] || 'string',
allowEditOnly: true
},
plug: 'input',
group: 'Start Values',
name: 'start-value-' + p,
displayName: p
});
ports.push({
type: {
name: 'enum',
enums: [
{ label: 'Number', value: 'number' },
{ label: 'String', value: 'string' },
{ label: 'Boolean', value: 'boolean' },
{ label: 'Color', value: 'color' },
{ label: 'Image', value: 'image' }
],
allowEditOnly: true
},
default: 'string',
plug: 'input',
group: 'Types',
displayName: p,
name: 'type-' + p,
});*/
ports.push({
type: 'signal',
plug: 'output',
group: 'Changed Events',
displayName: p + ' Changed',
name: 'changed-' + p
});
}
}
editorConnection.sendDynamicPorts(nodeId, ports, {
detectRenamed: {
plug: 'input/output'
}
});
}
module.exports = {
node: ComponentObject,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
graphModel.on('nodeAdded.net.noodl.ComponentObject', (node) => {
updatePorts(node.id, node.parameters, context.editorConnection);
node.on('parameterUpdated', (event) => {
if (event.name === 'properties') {
updatePorts(node.id, node.parameters, context.editorConnection);
}
});
});
}
};

View File

@@ -0,0 +1,296 @@
'use strict';
const { Node } = require('@noodl/runtime');
const Model = require('@noodl/runtime/src/model');
const EventEmitter = require('events').EventEmitter;
const graphEventEmitter = new EventEmitter();
graphEventEmitter.setMaxListeners(1000000);
const ParentComponentObject = {
name: 'net.noodl.ParentComponentObject',
displayNodeName: 'Parent Component Object',
category: 'Component Utilities',
color: 'component',
docs: 'https://docs.noodl.net/nodes/component-utilities/parent-component-object',
initialize() {
this._internal.inputValues = {};
this._internal.onModelChangedCallback = (args) => {
if (this.isInputConnected('fetch') !== false) return;
if (this.hasOutput('value-' + args.name)) {
this.flagOutputDirty('value-' + args.name);
}
if (this.hasOutput('changed-' + args.name)) {
this.sendSignalOnOutput('changed-' + args.name);
}
this.sendSignalOnOutput('changed');
};
//TODO: don't listen for delta updates when running deployed
this.onComponentStateNodesChanged = () => {
const id = this.findParentComponentStateModelId();
if (this._internal.modelId !== id) {
this._internal.modelId = id;
if (this.isInputConnected('fetch') === false) {
this.setModelId(this._internal.modelId);
}
}
};
graphEventEmitter.on('componentStateNodesChanged', this.onComponentStateNodesChanged);
this.updateComponentState();
},
//to search up the tree the root nodes in this component must have been initialized
//we also need the connections to be setup so we can use isInputConnected
//nodeScopeDidInitialize takes care of that
nodeScopeDidInitialize() {
//FIXME: temporary hack. Our parent's node scope might not have finished created yet
//so just wait until after this update. It'll make the parent component state
//have a delay in propagating outputs which can cause subtle bugs.
//The fix is to call this code when the entire node tree has been created,
//before running updating the next update.
if (!this._internal.modelId) {
this.context.scheduleAfterUpdate(() => {
this.updateComponentState();
});
}
},
getInspectInfo() {
const model = this._internal.model;
if (!model) return 'No parent component state found';
const modelInfo = [{ type: 'text', value: this._internal.parentComponentName }];
const data = this._internal.model.data;
return modelInfo.concat(
Object.keys(data).map((key) => {
return { type: 'text', value: key + ': ' + data[key] };
})
);
},
inputs: {
properties: {
type: {
name: 'stringlist',
allowEditOnly: true
},
displayName: 'Properties',
group: 'Properties',
set(value) {}
},
fetch: {
displayName: 'Fetch',
group: 'Actions',
valueChangedToTrue: function () {
this.setModelId(this._internal.modelId);
}
}
},
outputs: {
changed: {
type: 'signal',
displayName: 'Changed',
group: 'Events'
},
fetched: {
type: 'signal',
displayName: 'Fetched',
group: 'Events'
}
},
methods: {
updateComponentState() {
this._internal.modelId = this.findParentComponentStateModelId();
if (this.isInputConnected('fetch') === false) {
this.setModelId(this._internal.modelId);
}
},
findParentComponentStateModelId() {
function getParentComponent(component) {
let parent;
if (component.getRoots().length > 0) {
//visual
const root = component.getRoots()[0];
if (root.getVisualParentNode) {
//regular visual node
if (root.getVisualParentNode()) {
parent = root.getVisualParentNode().nodeScope.componentOwner;
}
} else if (root.parentNodeScope) {
//component instance node
parent = component.parentNodeScope.componentOwner;
}
} else if (component.parentNodeScope) {
parent = component.parentNodeScope.componentOwner;
}
//check that a parent exists and that the component is different
if (parent && parent.nodeScope && parent.nodeScope.componentOwner !== component) {
//check if parent has a Component State node
if (parent.nodeScope.getNodesWithType('net.noodl.ComponentObject').length > 0) {
return parent;
}
//if not, continue searching up the tree
return getParentComponent(parent);
}
}
const parent = getParentComponent(this.nodeScope.componentOwner);
if (!parent) return;
this._internal.parentComponentName = parent.name;
return 'componentState' + parent.getInstanceId();
},
setModelId(id) {
this._internal.model && this._internal.model.off('change', this._internal.onModelChangedCallback);
this._internal.model = undefined;
if (!id) return;
const model = Model.get(id);
this._internal.model = model;
model.on('change', this._internal.onModelChangedCallback);
for (var key in model.data) {
if (this.hasOutput('value-' + key)) {
this.flagOutputDirty('value-' + key);
}
if (this.hasOutput('changed-' + key)) {
this.sendSignalOnOutput('changed-' + key);
}
}
this.sendSignalOnOutput('changed');
this.sendSignalOnOutput('fetched');
},
scheduleStore() {
if (this.hasScheduledStore) return;
this.hasScheduledStore = true;
var internal = this._internal;
this.scheduleAfterInputsHaveUpdated(() => {
this.hasScheduledStore = false;
if (!internal.model) return;
for (var i in internal.inputValues) {
internal.model.set(i, internal.inputValues[i], { resolve: true });
}
});
},
_onNodeDeleted() {
Node.prototype._onNodeDeleted.call(this);
graphEventEmitter.off('componentStateNodesChanged', this.onComponentStateNodesChanged);
this._internal.model && this._internal.model.off('change', this._internal.onModelChangedCallback);
},
registerOutputIfNeeded(name) {
if (this.hasOutput(name)) {
return;
}
const propertyName = name.substring('value-'.length);
this.registerOutput(name, {
get() {
if (!this._internal.model) return undefined;
return this._internal.model.get(propertyName, { resolve: true });
}
});
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name.startsWith('value-')) {
const propertyName = name.substring('value-'.length);
this.registerInput(name, {
set(value) {
this._internal.inputValues[propertyName] = value;
this.scheduleStore();
}
});
}
}
}
};
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// Add value outputs
var properties = parameters.properties && parameters.properties.split(',');
for (var i in properties) {
var p = properties[i];
ports.push({
type: {
name: '*', //parameters['type-' + p] || 'string',
allowConnectionsOnly: true
},
plug: 'input/output',
group: 'Properties',
name: 'value-' + p,
displayName: p
});
ports.push({
type: 'signal',
plug: 'output',
group: 'Changed Events',
displayName: p + ' Changed',
name: 'changed-' + p
});
}
editorConnection.sendDynamicPorts(nodeId, ports, {
detectRenamed: {
plug: 'input/output'
}
});
}
module.exports = {
node: ParentComponentObject,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
graphModel.on('nodeAdded.net.noodl.ParentComponentObject', (node) => {
updatePorts(node.id, node.parameters, context.editorConnection);
node.on('parameterUpdated', (event) => {
updatePorts(node.id, node.parameters, context.editorConnection);
});
});
//TODO: handle additional delta update event:
// - visual parent changed
//this are the same events that'll create and delete the Comopent State instance node
//it might not have had a chance to run yet if we're first in the event list, so
//use a setTimeout
graphModel.on('nodeAdded.net.noodl.ComponentObject', (node) => {
setTimeout(() => {
graphEventEmitter.emit('componentStateNodesChanged');
}, 0);
});
graphModel.on('nodeRemoved.net.noodl.ComponentObject', (node) => {
setTimeout(() => {
graphEventEmitter.emit('componentStateNodesChanged');
});
});
}
};

View File

@@ -0,0 +1,12 @@
'use strict';
const Base = require('./base');
module.exports = Base.extendSetComponentObjectProperties({
name: 'net.noodl.SetComponentObjectProperties',
displayName: 'Set Component Object Properties',
docs: 'https://docs.noodl.net/nodes/component-utilities/set-component-object-properties',
getComponentObjectId: function () {
return 'componentState' + this.nodeScope.componentOwner.getInstanceId();
}
});

View File

@@ -0,0 +1,51 @@
'use strict';
const { Node } = require('@noodl/runtime');
const Model = require('@noodl/runtime/src/model');
const Base = require('./base');
module.exports = Base.extendSetComponentObjectProperties({
name: 'net.noodl.SetParentComponentObjectProperties',
displayName: 'Set Parent Component Object Properties',
docs: 'https://docs.noodl.net/nodes/component-utilities/set-parent-component-object-properties',
getComponentObjectId: function () {
function getParentComponent(component) {
let parent;
if (component.getRoots().length > 0) {
//visual
const root = component.getRoots()[0];
if (root.getVisualParentNode) {
//regular visual node
if (root.getVisualParentNode()) {
parent = root.getVisualParentNode().nodeScope.componentOwner;
}
} else if (root.parentNodeScope) {
//component instance node
parent = component.parentNodeScope.componentOwner;
}
} else if (component.parentNodeScope) {
parent = component.parentNodeScope.componentOwner;
}
//check that a parent exists and that the component is different
if (parent && parent.nodeScope && parent.nodeScope.componentOwner !== component) {
//check if parent has a Component State node
if (parent.nodeScope.getNodesWithType('net.noodl.ComponentObject').length > 0) {
return parent;
}
//if not, continue searching up the tree
return getParentComponent(parent);
}
}
const parent = getParentComponent(this.nodeScope.componentOwner);
if (!parent) return;
this._internal.parentComponentName = parent.name;
return 'componentState' + parent.getInstanceId();
}
});

View File

@@ -0,0 +1,342 @@
const NoodlRuntime = require('@noodl/runtime');
const CloudStore = require('@noodl/runtime/src/api/cloudstore');
('use strict');
/*var defaultBeforeCallScript = "// Add custom code to setup the parameters send to the function\n"+
"// The parameters are found in the object called 'Parameters'\n";
var defaultAfterCallScript = "// Add custom code modfiy the result from the cloud function\n"+
"// The result is found in the object called 'Result'\n";*/
function _makeRequest(path, options) {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
var json;
try {
json = JSON.parse(xhr.response);
} catch (e) {}
if (xhr.status === 200 || xhr.status === 201) {
options.success(json);
} else options.error(json);
}
};
xhr.open(options.method || 'GET', options.endpoint + path, true);
xhr.setRequestHeader('X-Parse-Application-Id', options.appId);
xhr.setRequestHeader('Content-Type', 'application/json');
// Check for current users
var _cu = localStorage['Parse/' + options.appId + '/currentUser'];
if (_cu !== undefined) {
try {
const currentUser = JSON.parse(_cu);
xhr.setRequestHeader('X-Parse-Session-Token', currentUser.sessionToken);
} catch (e) {
// Failed to extract session token
}
}
xhr.send(JSON.stringify(options.content));
}
var CloudFunctionNode = {
name: 'Cloud Function',
category: 'Cloud Services',
color: 'data',
usePortAsLabel: 'functionName',
docs: 'https://docs.noodl.net/nodes/data/cloud-data/cloud-function',
deprecated: true,
initialize: function () {
this._internal.paramsValues = {};
// this._internal.resultsValues = {};
// this._internal.convertArraysAndObjects = true;
},
getInspectInfo() {
const result = this._internal.lastCallResult;
if (!result) return '[Not executed yet]';
return [{ type: 'value', value: result }];
},
inputs: {
functionName: {
type: 'string',
displayName: 'Function Name',
group: 'General',
set: function (value) {
this._internal.functionName = value;
}
},
params: {
group: 'Parameters',
type: { name: 'stringlist', allowEditOnly: true },
set: function (value) {
this._internal.params = value;
}
},
/* results:{
group:'Result',
type:{name:'stringlist',allowEditOnly:true},
set:function(value) {
this._internal.results = value;
}
},
beforeCallScript: {
group:'Scripts',
displayName:'Before Call',
default:defaultBeforeCallScript,
type:{name:'string',codeeditor:'javascript'},
set: function(scriptCode) {
try {
if(scriptCode !== undefined) {
var args = ['Parameters'].concat([scriptCode]);
this._internal.scriptBeforeCallFunc = Function.apply(null,args);
}
}
catch(e) {
this._internal.scriptBeforeCallFunc = undefined;
console.log('Error while parsing script (before call): ' + e);
}
}
},
afterCallScript: {
group:'Scripts',
displayName:'After Call',
default:defaultAfterCallScript,
type:{name:'string',codeeditor:'javascript'},
set: function(scriptCode) {
try {
if(scriptCode !== undefined) {
var args = ['Result'].concat([scriptCode]);
this._internal.scriptAfterCallFunc = Function.apply(null,args);
}
}
catch(e) {
this._internal.scriptAfterCallFunc = undefined;
console.log('Error while parsing script (after call): ' + e);
}
}
},*/
call: {
type: 'signal',
displayName: 'Call',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleCall();
}
}
/* convertArraysAndObjects:{
group:'Advanced',
displayName:'Convert Objects',
type:'boolean',
default:true,
set: function(value) {
this._internal.convertArraysAndObjects = value;
}
}*/
},
outputs: {
success: {
type: 'signal',
displayName: 'Success',
group: 'Signals'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Signals'
},
result: {
type: '*',
displayName: 'Result',
group: 'Output',
getter: function () {
return this._internal.result;
}
}
},
methods: {
/* getResultsValue:function(name) {
return this._internal.resultsValues[name];
},*/
registerOutputIfNeeded: function (name) {
if (this.hasOutput(name)) {
return;
}
/* if(name.startsWith('res-')) this.registerOutput(name, {
getter: this.getResultsValue.bind(this, name.substring('res-'.length))
});*/
},
setParamsValue: function (name, value) {
this._internal.paramsValues[name] = value;
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name.startsWith('pm-'))
this.registerInput(name, {
set: this.setParamsValue.bind(this, name.substring('pm-'.length))
});
},
scheduleCall: function () {
var internal = this._internal;
if (!internal.hasScheduledCall) {
internal.hasScheduledCall = true;
this.scheduleAfterInputsHaveUpdated(this.doCall.bind(this));
}
},
doCall: function () {
this._internal.hasScheduledCall = false;
const cloudServices = NoodlRuntime.instance.getMetaData('cloudservices');
if (cloudServices === undefined) {
console.log('No cloud services defined in this project.');
this._internal.lastCallResult = {
status: 'failure',
res: 'No active cloud services in this project'
};
this.sendSignalOnOutput('failure');
return;
}
var appId = cloudServices.appId;
var endpoint = cloudServices.endpoint;
// Run before call script
/* if(this._internal.scriptBeforeCallFunc !== undefined) {
this._internal.scriptBeforeCallFunc(this._internal.paramsValues);
}*/
_makeRequest('/functions/' + encodeURIComponent(this._internal.functionName), {
appId,
endpoint,
content: this._internal.paramsValues,
method: 'POST',
success: (res) => {
var res = res.result; // Cloud functions always return "result"
if (res === undefined) {
this.sendSignalOnOutput('failure');
return;
}
// Run after call script
/* if(this._internal.scriptAfterCallFunc !== undefined) {
this._internal.scriptAfterCallFunc(res);
}*/
this._internal.result = CloudStore._deserializeJSON(res);
this.flagOutputDirty('result');
this._internal.lastCallResult = {
status: 'success',
result: this._internal.result
};
// Deserialize values into Noodl arrays and objects
/* if(this._internal.convertArraysAndObjects) {
for(var key in res) {
if(res[key] !== undefined)
res[key] = CloudStore._fromJSON(res[key]);
}
}*/
/*this._internal.resultsValues = res;
for(var key in res) {
if(this.hasOutput('res-'+key)) {
this.flagOutputDirty('res-'+key);
}
}*/
this.sendSignalOnOutput('success');
},
error: (res) => {
this._internal.lastCallResult = {
status: 'failure',
res
};
this.sendSignalOnOutput('failure');
}
});
}
}
};
module.exports = {
node: CloudFunctionNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
function _updatePorts() {
var ports = [];
// Add results outputs
/* var results = node.parameters.results;
if (results !== undefined) {
results = results.split(',');
for (var i in results) {
var p = results[i];
ports.push({
type: {
name: '*',
},
plug: 'output',
group: 'Results',
name: 'res-' + p,
displayName: p
});
}
}*/
// Add params inputs
var params = node.parameters.params;
if (params !== undefined) {
params = params.split(',');
for (var i in params) {
var p = params[i];
ports.push({
type: '*',
plug: 'input',
group: 'Parameters',
name: 'pm-' + p,
displayName: p
});
}
}
context.editorConnection.sendDynamicPorts(node.id, ports);
}
_updatePorts();
node.on('parameterUpdated', function (event) {
if (event.name === 'params') {
_updatePorts();
}
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.Cloud Function', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('Cloud Function')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -0,0 +1,212 @@
const NoodlRuntime = require('@noodl/runtime');
const CloudStore = require('@noodl/runtime/src/api/cloudstore');
('use strict');
function _makeRequest(path, options) {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
var json;
try {
json = JSON.parse(xhr.response);
} catch (e) {}
if (xhr.status === 200 || xhr.status === 201) {
options.success(json);
} else options.error(json);
}
};
xhr.open(options.method || 'GET', options.endpoint + path, true);
xhr.setRequestHeader('X-Parse-Application-Id', options.appId);
xhr.setRequestHeader('Content-Type', 'application/json');
const cloudServices = NoodlRuntime.instance.getMetaData('cloudservices');
if (cloudServices && cloudServices.deployVersion) {
xhr.setRequestHeader('x-noodl-cloud-version', cloudServices.deployVersion);
}
// Check for current users
var _cu = localStorage['Parse/' + options.appId + '/currentUser'];
if (_cu !== undefined) {
try {
const currentUser = JSON.parse(_cu);
xhr.setRequestHeader('X-Parse-Session-Token', currentUser.sessionToken);
} catch (e) {
// Failed to extract session token
}
}
xhr.send(JSON.stringify(options.content));
}
var CloudFunctionNode = {
name: 'CloudFunction2',
displayName: 'Cloud Function',
category: 'Cloud Services',
color: 'data',
usePortAsLabel: 'function',
docs: 'https://docs.noodl.net/nodes/data/cloud-data/cloud-function',
initialize: function () {
this._internal.paramsValues = {};
this._internal.resultsValues = {};
},
getInspectInfo() {
const result = this._internal.lastCallResult;
if (!result) return '[Not executed yet]';
return [{ type: 'value', value: result }];
},
inputs: {
call: {
type: 'signal',
displayName: 'Call',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleCall();
}
}
},
outputs: {
success: {
type: 'signal',
displayName: 'Success',
group: 'Signals'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Signals'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Error',
getter: function () {
return this._internal.error;
}
}
},
methods: {
setError: function (err) {
this._internal.error = err;
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
},
getResultsValue: function (name) {
return this._internal.resultsValues[name];
},
registerOutputIfNeeded: function (name) {
if (this.hasOutput(name)) {
return;
}
if (name.startsWith('out-'))
this.registerOutput(name, {
getter: this.getResultsValue.bind(this, name.substring('out-'.length))
});
},
setParamsValue: function (name, value) {
this._internal.paramsValues[name] = value;
},
setFunctionName: function (value) {
this._internal.functionName = value;
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name === 'function')
this.registerInput(name, {
set: this.setFunctionName.bind(this)
});
if (name.startsWith('in-'))
this.registerInput(name, {
set: this.setParamsValue.bind(this, name.substring('in-'.length))
});
},
scheduleCall: function () {
var internal = this._internal;
if (!internal.hasScheduledCall) {
internal.hasScheduledCall = true;
this.scheduleAfterInputsHaveUpdated(this.doCall.bind(this));
}
},
doCall: function () {
this._internal.hasScheduledCall = false;
const cloudServices = NoodlRuntime.instance.getMetaData('cloudservices');
if (this.context.editorConnection) {
if (cloudServices === undefined || cloudServices.endpoint === undefined) {
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'cloud-function-2', {
message: 'No cloud services defined in this project.'
});
} else if (this._internal.functionName === undefined) {
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'cloud-function-2', {
message: 'No function specified'
});
} else {
this.context.editorConnection.clearWarning(this.nodeScope.componentOwner.name, this.id, 'cloud-function-2');
}
}
const appId = cloudServices.appId;
const endpoint = this.context.editorConnection.isRunningLocally()
? `http://${window.location.hostname}:8577`
: cloudServices.endpoint;
_makeRequest('/functions/' + encodeURIComponent(this._internal.functionName), {
appId,
endpoint,
content: this._internal.paramsValues,
method: 'POST',
success: (res) => {
if (res === undefined) {
// No result, still success
this._internal.lastCallResult = {
status: 'success',
parameters: this._internal.paramsValues,
results: 'empty'
};
} else {
const results = res.result || {};
for (let key in results) {
this._internal.resultsValues[key] = results[key];
if (this.hasOutput('out-' + key)) this.flagOutputDirty('out-' + key);
}
this._internal.lastCallResult = {
status: 'success',
parameters: this._internal.paramsValues,
results: this._internal.resultsValues
};
}
this.sendSignalOnOutput('success');
},
error: (e) => {
const error = typeof e === 'string' ? e : e.error || 'Failed running cloud function.';
this._internal.lastCallResult = {
status: 'failure',
error
};
this.setError(error);
}
});
}
}
};
module.exports = {
node: CloudFunctionNode,
setup: function (context, graphModel) {
// Handled in editor adapter
}
};

View File

@@ -0,0 +1,56 @@
const Collection = require('@noodl/runtime/src/collection');
const CollectionClearNode = {
name: 'CollectionClear',
docs: 'https://docs.noodl.net/nodes/data/array/clear-array',
displayNodeName: 'Clear Array',
category: 'Data',
usePortAsLabel: 'collectionId',
color: 'data',
inputs: {
collectionId: {
type: {
name: 'string',
identifierOf: 'CollectionName',
identifierDisplayName: 'Array Ids'
},
displayName: 'Array Id',
group: 'General',
set: function (value) {
if (value instanceof Collection) value = value.getId(); // Can be passed as collection as well
this.setCollectionID(value);
}
},
clear: {
displayName: 'Do',
group: 'Actions',
valueChangedToTrue() {
this.scheduleAfterInputsHaveUpdated(() => {
const collection = this._internal.collection;
collection.set([]);
this.sendSignalOnOutput('modified');
});
}
}
},
outputs: {
modified: {
group: 'Events',
type: 'signal',
displayName: 'Done'
}
},
methods: {
setCollectionID: function (id) {
this.setCollection(Collection.get(id));
},
setCollection: function (collection) {
this._internal.collection = collection;
}
}
};
module.exports = {
node: CollectionClearNode
};

View File

@@ -0,0 +1,97 @@
'use strict';
const { Node } = require('@noodl/runtime');
var Model = require('@noodl/runtime/src/model'),
Collection = require('@noodl/runtime/src/collection');
var CollectionInsertNode = {
name: 'CollectionInsert',
docs: 'https://docs.noodl.net/nodes/data/array/insert-into-array',
displayNodeName: 'Insert Object Into Array',
shortDesc: 'A collection of models, mainly used together with a For Each Node.',
category: 'Data',
usePortAsLabel: 'collectionId',
color: 'data',
initialize: function () {},
inputs: {
collectionId: {
type: {
name: 'string',
identifierOf: 'CollectionName',
identifierDisplayName: 'Array Ids'
},
displayName: 'Array Id',
group: 'General',
set: function (value) {
if (value instanceof Collection) value = value.getId(); // Can be passed as collection as well
this.setCollectionID(value);
}
},
modifyId: {
type: { name: 'string', allowConnectionsOnly: true },
displayName: 'Object Id',
group: 'Modify',
set: function (value) {
this._internal.modifyId = value;
}
},
add: {
displayName: 'Do',
group: 'Actions',
valueChangedToTrue: function () {
var _this = this;
var internal = this._internal;
this.scheduleAfterInputsHaveUpdated(function () {
if (this.context.editorConnection) {
this.context.editorConnection.clearWarning(this.nodeScope.componentOwner.name, this.id, 'insert-warning');
}
if (internal.modifyId === undefined) {
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'insert-warning', {
showGlobally: true,
message: 'No Object Id specified'
});
}
return;
}
if (internal.collection === undefined) {
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'insert-warning', {
showGlobally: true,
message: 'No Array Id specified'
});
}
return;
}
var model = Model.get(internal.modifyId);
internal.collection.add(model);
_this.sendSignalOnOutput('modified');
});
}
}
},
outputs: {
modified: {
group: 'Events',
type: 'signal',
displayName: 'Done'
}
},
prototypeExtensions: {
setCollectionID: function (id) {
this.setCollection(Collection.get(id));
},
setCollection: function (collection) {
this._internal.collection = collection;
}
}
};
module.exports = {
node: CollectionInsertNode
};

View File

@@ -0,0 +1,78 @@
'use strict';
const { Node } = require('@noodl/runtime');
var Model = require('@noodl/runtime/src/model'),
Collection = require('@noodl/runtime/src/collection');
var CollectionNewNode = {
name: 'CollectionNew',
docs: 'https://docs.noodl.net/nodes/data/array/create-new-array',
displayNodeName: 'Create New Array',
shortDesc: 'A collection of models, mainly used together with a For Each Node.',
category: 'Data',
color: 'data',
initialize: function () {},
inputs: {
new: {
displayName: 'Do',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleNew();
}
},
items: {
type: 'array',
group: 'General',
displayName: 'Items',
set: function (value) {
this._internal.sourceCollection = value;
}
}
},
outputs: {
id: {
type: 'string',
displayName: 'Id',
group: 'General',
getter: function () {
return this._internal.collection ? this._internal.collection.getId() : this._internal.collectionId;
}
},
created: {
group: 'Events',
type: 'signal',
displayName: 'Done'
}
},
prototypeExtensions: {
setCollectionID: function (id) {
this.setCollection(Collection.get(id));
},
setCollection: function (collection) {
this._internal.collection = collection;
this.flagOutputDirty('id');
},
scheduleNew: function () {
var _this = this;
if (this.hasScheduledNew) return;
this.hasScheduledNew = true;
this.scheduleAfterInputsHaveUpdated(function () {
_this.hasScheduledNew = false;
const collection = Collection.get();
if (this._internal.sourceCollection !== undefined) collection.set(this._internal.sourceCollection);
_this.setCollection(collection);
_this.sendSignalOnOutput('created');
});
}
}
};
module.exports = {
node: CollectionNewNode
};

View File

@@ -0,0 +1,76 @@
'use strict';
const { Node } = require('@noodl/runtime');
var Model = require('@noodl/runtime/src/model'),
Collection = require('@noodl/runtime/src/collection');
var CollectionRemoveNode = {
name: 'CollectionRemove',
docs: 'https://docs.noodl.net/nodes/data/array/remove-from-array',
displayNodeName: 'Remove Object From Array',
shortDesc: 'A collection of models, mainly used together with a For Each Node.',
category: 'Data',
usePortAsLabel: 'collectionId',
color: 'data',
initialize: function () {},
inputs: {
collectionId: {
type: {
name: 'string',
identifierOf: 'CollectionName',
identifierDisplayName: 'Array Ids'
},
displayName: 'Array Id',
group: 'General',
set: function (value) {
if (value instanceof Collection) value = value.getId(); // Can be passed as collection as well
this.setCollectionID(value);
}
},
modifyId: {
type: { name: 'string', allowConnectionsOnly: true },
displayName: 'Object Id',
group: 'Modify',
set: function (value) {
this._internal.modifyId = value;
}
},
remove: {
displayName: 'Do',
group: 'Actions',
valueChangedToTrue: function () {
var _this = this;
var internal = this._internal;
this.scheduleAfterInputsHaveUpdated(function () {
if (internal.modifyId === undefined) return;
if (internal.collection === undefined) return;
var model = Model.get(internal.modifyId);
internal.collection.remove(model);
_this.sendSignalOnOutput('modified');
});
}
}
},
outputs: {
modified: {
group: 'Events',
type: 'signal',
displayName: 'Done'
}
},
prototypeExtensions: {
setCollectionID: function (id) {
this.setCollection(Collection.get(id));
},
setCollection: function (collection) {
this._internal.collection = collection;
}
}
};
module.exports = {
node: CollectionRemoveNode
};

View File

@@ -0,0 +1,222 @@
'use strict';
const { Node } = require('@noodl/runtime');
var Model = require('@noodl/runtime/src/model'),
Collection = require('@noodl/runtime/src/collection');
var CollectionNode = {
name: 'Collection2',
docs: 'https://docs.noodl.net/nodes/data/array/array-node',
displayNodeName: 'Array',
shortDesc: 'A collection of models, mainly used together with a For Each Node.',
category: 'Data',
usePortAsLabel: 'collectionId',
color: 'data',
initialize: function () {
var _this = this;
var collectionChangedScheduled = false;
this._internal.collectionChangedCallback = function () {
if (_this.isInputConnected('fetch') === true) return; // Ignore if we have explicit fetch connection
//this can be called multiple times when adding/removing more than one item
//so optimize by only updating outputs once
if (collectionChangedScheduled) return;
collectionChangedScheduled = true;
_this.scheduleAfterInputsHaveUpdated(function () {
_this.sendSignalOnOutput('changed');
_this.flagOutputDirty('count');
collectionChangedScheduled = false;
});
};
// When the source collection has changed, simply copy items into this collection
this._internal.sourceCollectionChangedCallback = function () {
if (_this.isInputConnected('store') === true) return; // Ignore if we have explicit store connection
_this.scheduleCopyItems();
};
},
getInspectInfo() {
const collection = this._internal.collection;
if (!collection) {
return { type: 'text', value: '[No Array]' };
}
return [
{
type: 'text',
value: 'Id: ' + collection.getId()
},
{
type: 'value',
value: collection.items
}
];
},
inputs: {
collectionId: {
type: {
name: 'string',
identifierOf: 'CollectionName',
identifierDisplayName: 'Array Ids'
},
displayName: 'Id',
group: 'General',
set: function (value) {
if (value instanceof Collection) value = value.getId(); // Can be passed as collection as well
this._internal.collectionId = value; // Wait to fetch data
if (this.isInputConnected('fetch') === false) this.setCollectionID(value);
else {
this.flagOutputDirty('id');
}
}
},
items: {
type: 'array',
group: 'General',
displayName: 'Items',
set: function (value) {
var _this = this;
if (value === undefined) return;
if (value === this._internal.collection) return;
this._internal.pendingSourceCollection = value;
if (this.isInputConnected('store') === false) {
// Don't auto copy if we have connections to store
this.scheduleAfterInputsHaveUpdated(function () {
_this.setSourceCollection(value);
});
}
}
},
fetch: {
displayName: 'Fetch',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleSetCollection();
}
}
},
outputs: {
id: {
type: 'string',
displayName: 'Id',
group: 'General',
getter: function () {
return this._internal.collection ? this._internal.collection.getId() : this._internal.collectionId;
}
},
items: {
type: 'array',
displayName: 'Items',
group: 'General',
getter: function () {
return this._internal.collection;
}
},
count: {
type: 'number',
displayName: 'Count',
group: 'General',
getter: function () {
return this._internal.collection ? this._internal.collection.size() : 0;
}
},
changed: {
group: 'Events',
type: 'signal',
displayName: 'Changed'
},
fetched: {
group: 'Events',
type: 'signal',
displayName: 'Fetched'
}
},
prototypeExtensions: {
setCollectionID: function (id) {
this.setCollection(Collection.get(id));
},
setCollection: function (collection) {
if (this._internal.collection)
// Remove old listener if existing
this._internal.collection.off('change', this._internal.collectionChangedCallback);
this._internal.collection = collection;
this.flagOutputDirty('id');
collection.on('change', this._internal.collectionChangedCallback);
this.flagOutputDirty('items');
this.flagOutputDirty('count');
},
setSourceCollection: function (collection) {
var internal = this._internal;
if (internal.sourceCollection && internal.sourceCollection instanceof Collection)
// Remove old listener if existing
internal.sourceCollection.off('change', internal.sourceCollectionChangedCallback);
internal.sourceCollection = collection;
if (internal.sourceCollection instanceof Collection)
internal.sourceCollection.on('change', internal.sourceCollectionChangedCallback);
this._copySourceItems();
},
scheduleSetCollection: function () {
var _this = this;
if (this.hasScheduledSetCollection) return;
this.hasScheduledSetCollection = true;
this.scheduleAfterInputsHaveUpdated(function () {
_this.hasScheduledSetCollection = false;
_this.setCollectionID(_this._internal.collectionId);
_this.sendSignalOnOutput('fetched');
});
},
scheduleStore: function () {
var _this = this;
if (this.hasScheduledStore) return;
this.hasScheduledStore = true;
var internal = this._internal;
this.scheduleAfterInputsHaveUpdated(function () {
_this.hasScheduledStore = false;
_this.setSourceCollection(internal.pendingSourceCollection);
});
},
_copySourceItems: function () {
var internal = this._internal;
if (internal.collection === undefined && this.isInputConnected('fetch') === false)
this.setCollection(Collection.get());
internal.collection && internal.collection.set(internal.sourceCollection);
},
scheduleCopyItems: function () {
var _this = this;
var internal = this._internal;
if (this.hasScheduledCopyItems) return;
this.hasScheduledCopyItems = true;
this.scheduleAfterInputsHaveUpdated(function () {
_this.hasScheduledCopyItems = false;
_this._copySourceItems();
});
},
_onNodeDeleted: function () {
Node.prototype._onNodeDeleted.call(this);
if (this._internal.collection)
// Remove old listener if existing
this._internal.collection.off('change', this._internal.collectionChangedCallback);
}
}
};
module.exports = {
node: CollectionNode
};

View File

@@ -0,0 +1,454 @@
'use strict';
const { Node, EdgeTriggeredInput } = require('@noodl/runtime');
const Collection = require('@noodl/runtime/src/collection'),
Model = require('@noodl/runtime/src/model');
function applyFilter(item, filter) {
for (var key in filter) {
var op = filter[key];
//check neq first, it's the only operation where the key can be undefined
if (op['$neq'] !== undefined) {
if (!(item[key] != op['$neq'])) return false;
} else if (item[key] === undefined) return false;
// The key does not exist, always return false
else if (op['$eq'] !== undefined && !(item[key] == op['$eq'])) return false;
else if (op['$gt'] !== undefined && !(item[key] > op['$gt'])) return false;
else if (op['$lt'] !== undefined && !(item[key] < op['$lt'])) return false;
else if (op['$gte'] !== undefined && !(item[key] >= op['$gte'])) return false;
else if (op['$lte'] !== undefined && !(item[key] <= op['$lte'])) return false;
else if (op['$regex'] !== undefined) {
// Test if string matches regex
var a = item[key] + ''; // Convert to string
var regex = new RegExp(op['$regex'], op['$case'] !== true ? 'i' : undefined);
if (!regex.test(a)) return false;
}
}
return true;
}
function sorter(a, b) {
if (a instanceof Model) a = a.data;
if (b instanceof Model) b = b.data;
for (var key in this) {
var _a = a[key];
var _b = b[key];
if (_a !== _b) {
if (typeof _a === 'string' && typeof _b === 'string') {
if (this[key] === 1) {
return _a > _b ? 1 : -1;
} else return _a > _b ? -1 : 1;
} else if (typeof _a === 'number' && typeof _b === 'number') {
return this[key] === 1 ? _a - _b : _b - _a;
} else {
if (this[key] === 1) {
return _a > _b ? 1 : -1;
} else return _a > _b ? -1 : 1;
}
}
}
return 0;
}
var FilterCollectionNode = {
name: 'Filter Collection',
docs: 'https://docs.noodl.net/nodes/data/array/array-filter',
displayNodeName: 'Array Filter',
shortDesc: 'Filter, sort and limit array',
category: 'Data',
color: 'data',
initialize: function () {
var _this = this;
this._internal.collectionChangedCallback = function () {
if (_this.isInputConnected('filter') === true) return;
_this.scheduleFilter();
};
this._internal.enabled = true;
this._internal.filterSettings = {};
//this._internal.filteredCollection = Collection.get();
},
getInspectInfo() {
const collection = this._internal.filteredCollection;
if (!collection) {
return { type: 'text', value: '[Not executed yet]' };
}
return [
{
type: 'text',
value: 'Id: ' + collection.getId()
},
{
type: 'value',
value: collection.items
}
];
},
inputs: {
items: {
type: 'array',
displayName: 'Items',
group: 'General',
set(value) {
this.bindCollection(value);
if (this.isInputConnected('filter') === false) this.scheduleFilter();
}
},
enabled: {
type: 'boolean',
group: 'General',
displayName: 'Enabled',
default: true,
set: function (value) {
this._internal.enabled = value;
if (this.isInputConnected('filter') === false) this.scheduleFilter();
}
},
filter: {
type: 'signal',
group: 'Actions',
displayName: 'Filter',
valueChangedToTrue: function () {
this.scheduleFilter();
}
}
},
outputs: {
items: {
type: 'array',
displayName: 'Items',
group: 'General',
getter: function () {
return this._internal.filteredCollection;
}
},
firstItemId: {
type: 'string',
displayName: 'First Item Id',
group: 'General',
getter: function () {
if (this._internal.filteredCollection !== undefined) {
const firstItem = this._internal.filteredCollection.get(0);
if (firstItem !== undefined) return firstItem.getId();
}
}
},
/* firstItem:{
type: 'object',
displayName: 'First Item',
group: 'General',
getter: function () {
if(this._internal.filteredCollection !== undefined) {
return this._internal.filteredCollection.get(0);
}
}
}, */
count: {
type: 'number',
displayName: 'Count',
group: 'General',
getter: function () {
return this._internal.filteredCollection ? this._internal.filteredCollection.size() : 0;
}
},
modified: {
group: 'Events',
type: 'signal',
displayName: 'Filtered'
}
},
prototypeExtensions: {
unbindCurrentCollection: function () {
var collection = this._internal.collection;
if (!collection) return;
collection.off('change', this._internal.collectionChangedCallback);
this._internal.collection = undefined;
},
bindCollection: function (collection) {
this.unbindCurrentCollection();
this._internal.collection = collection;
collection && collection.on('change', this._internal.collectionChangedCallback);
},
_onNodeDeleted: function () {
Node.prototype._onNodeDeleted.call(this);
this.unbindCurrentCollection();
},
getFilter: function () {
const filterSettings = this._internal.filterSettings;
const options = ['case']; // List all supported options here
if (filterSettings['filterFilter']) {
const filters = filterSettings['filterFilter'].split(',');
var _filter = {};
filters.forEach(function (f) {
var op = '$' + (filterSettings['filterFilterOp-' + f] || 'eq');
_filter[f] = {};
_filter[f][op] = filterSettings['filterFilterValue-' + f];
options.forEach((o) => {
var option = filterSettings['filterFilterOption-' + o + '-' + f];
if (option) _filter[f]['$' + o] = option;
});
});
return _filter;
}
},
getSort: function () {
const filterSettings = this._internal.filterSettings;
if (filterSettings['filterSort']) {
const sort = filterSettings['filterSort'].split(',');
var _sort = {};
sort.forEach(function (s) {
_sort[s] = filterSettings['filterSort-' + s] === 'descending' ? -1 : 1;
});
return _sort;
}
},
getLimit: function () {
const filterSettings = this._internal.filterSettings;
if (!filterSettings['filterEnableLimit']) return;
else return filterSettings['filterLimit'] || 10;
},
getSkip: function () {
const filterSettings = this._internal.filterSettings;
if (!filterSettings['filterEnableLimit']) return;
else return filterSettings['filterSkip'] || 0;
},
scheduleFilter: function () {
if (this.collectionChangedScheduled) return;
this.collectionChangedScheduled = true;
this.scheduleAfterInputsHaveUpdated(() => {
this.collectionChangedScheduled = false;
if (!this._internal.collection) return;
// Apply filter and write to output collection
var filtered = [].concat(this._internal.collection.items); // Make sure we clone the array
if (this._internal.enabled) {
var filter = this.getFilter();
if (filter) filtered = filtered.filter((m) => applyFilter(m.data, filter));
var sort = this.getSort();
if (sort) filtered.sort(sorter.bind(sort));
var skip = this.getSkip();
if (skip) filtered = filtered.slice(skip, filtered.length);
var limit = this.getLimit();
if (limit) filtered = filtered.slice(0, limit);
}
this._internal.filteredCollection = Collection.create(filtered);
this.sendSignalOnOutput('modified');
this.flagOutputDirty('firstItemId');
this.flagOutputDirty('items');
// this.flagOutputDirty('firstItem');
this.flagOutputDirty('count');
});
},
registerInputIfNeeded: function (name) {
var _this = this;
if (this.hasInput(name)) {
return;
}
this.registerInput(name, {
set: userInputSetter.bind(this, name)
});
}
}
};
function userInputSetter(name, value) {
/* jshint validthis:true */
this._internal.filterSettings[name] = value;
if (this.isInputConnected('filter') === false) this.scheduleFilter();
}
function updatePorts(nodeId, parameters, editorConnection, dbCollections) {
var ports = [];
ports.push({
type: 'boolean',
plug: 'input',
group: 'Limit',
name: 'filterEnableLimit',
displayName: 'Use limit'
});
if (parameters['filterEnableLimit']) {
ports.push({
type: 'number',
default: 10,
plug: 'input',
group: 'Limit',
name: 'filterLimit',
displayName: 'Limit'
});
ports.push({
type: 'number',
default: 0,
plug: 'input',
group: 'Limit',
name: 'filterSkip',
displayName: 'Skip'
});
}
ports.push({
type: { name: 'stringlist', allowEditOnly: true },
plug: 'input',
group: 'Filter',
name: 'filterFilter',
displayName: 'Filter'
});
ports.push({
type: { name: 'stringlist', allowEditOnly: true },
plug: 'input',
group: 'Sort',
name: 'filterSort',
displayName: 'Sort'
});
const filterOps = {
string: [
{ value: 'eq', label: 'Equals' },
{ value: 'neq', label: 'Not Equals' },
{ value: 'regex', label: 'Matches RegEx' }
],
boolean: [
{ value: 'eq', label: 'Equals' },
{ value: 'neq', label: 'Not Equals' }
],
number: [
{ value: 'eq', label: 'Equals' },
{ value: 'neq', label: 'Not Equals' },
{ value: 'lt', label: 'Less than' },
{ value: 'gt', label: 'Greater than' },
{ value: 'gte', label: 'Greater than or equal' },
{ value: 'lte', label: 'Less than or equal' }
]
};
if (parameters['filterFilter']) {
var filters = parameters['filterFilter'].split(',');
filters.forEach((f) => {
// Type
ports.push({
type: {
name: 'enum',
enums: [
{ value: 'string', label: 'String' },
{ value: 'number', label: 'Number' },
{ value: 'boolean', label: 'Boolean' }
]
},
default: 'string',
plug: 'input',
group: f + ' filter',
displayName: 'Type',
editorName: f + ' filter | Type',
name: 'filterFilterType-' + f
});
var type = parameters['filterFilterType-' + f];
// String filter type
ports.push({
type: { name: 'enum', enums: filterOps[type || 'string'] },
default: 'eq',
plug: 'input',
group: f + ' filter',
displayName: 'Op',
editorName: f + ' filter| Op',
name: 'filterFilterOp-' + f
});
// Case sensitivite option
if (parameters['filterFilterOp-' + f] === 'regex') {
ports.push({
type: 'boolean',
default: false,
plug: 'input',
group: f + ' filter',
displayName: 'Case sensitive',
editorName: f + ' filter| Case',
name: 'filterFilterOption-case-' + f
});
}
ports.push({
type: type || 'string',
plug: 'input',
group: f + ' filter',
displayName: 'Value',
editorName: f + ' Filter Value',
name: 'filterFilterValue-' + f
});
});
}
if (parameters['filterSort']) {
var filters = parameters['filterSort'].split(',');
filters.forEach((f) => {
ports.push({
type: {
name: 'enum',
enums: [
{ value: 'ascending', label: 'Ascending' },
{ value: 'descending', label: 'Descending' }
]
},
default: 'ascending',
plug: 'input',
group: f + ' sort',
displayName: 'Sort',
editorName: f + ' sorting',
name: 'filterSort-' + f
});
});
}
editorConnection.sendDynamicPorts(nodeId, ports);
}
module.exports = {
node: FilterCollectionNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
graphModel.on('nodeAdded.Filter Collection', function (node) {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel.getMetaData('dbCollections'));
node.on('parameterUpdated', function (event) {
if (event.name.startsWith('filter')) {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel.getMetaData('dbCollections'));
}
});
graphModel.on('metadataChanged.dbCollections', function (data) {
updatePorts(node.id, node.parameters, context.editorConnection, data);
});
});
}
};

View File

@@ -0,0 +1,694 @@
const { Node } = require('@noodl/runtime');
const guid = require('../../../guid');
const Collection = require('@noodl/runtime/src/collection');
const React = require('react');
const NoodlRuntime = require('@noodl/runtime');
const { useEffect } = React;
function ForEachComponent(props) {
const { didMount, willUnmount } = props;
useEffect(() => {
didMount();
return () => {
willUnmount();
};
}, []);
return null;
}
const defaultDynamicScript =
"// Set the 'component' variable to the name of the desired component for this item.\n" +
"// Component name must start with a '/'.\n" +
"// A component in a sheet is referred to by '/#Sheet Name/Comopnent Name'.\n" +
"// The data for each item is available in a variable called 'item'\n" +
"component = '/MyComponent';";
const ForEachDefinition = {
name: 'For Each',
displayNodeName: 'Repeater',
docs: 'https://docs.noodl.net/nodes/ui-controls/repeater',
color: 'visual',
category: 'Visual',
dynamicports: [
{
name: 'conditionalports/extended',
condition: 'templateType = explicit OR templateType NOT SET',
inputs: ['template']
},
{
name: 'conditionalports/extended',
condition: 'templateType = dynamic',
inputs: ['templateScript']
}
],
initialize() {
this._internal.itemNodes = [];
this._internal.itemOutputSignals = {};
this._internal.itemOutputs = {};
this._internal.collection = Collection.get(); // We keep an internal collection so we don't have to refresh all content if the input items collection changes
this._internal.queuedOperations = [];
this._internal.mountedOperations = [];
// Add an item
this._internal.collection.on('add', async (args) => {
if (!this._internal.target) return;
this._queueOperation(async () => {
const baseIndex = this._internal.target.getChildren().indexOf(this) + 1;
await this.addItem(args.item, baseIndex + args.index);
});
});
// Remove an item
this._internal.collection.on('remove', (args) => {
this._queueOperation(() => {
this.removeItem(args.item);
});
});
// On collection changed
this._internal.onItemsCollectionChanged = () => {
const repeaterDisabledWhenUnmounted = NoodlRuntime.instance.getProjectSettings().repeaterDisabledWhenUnmounted;
if (repeaterDisabledWhenUnmounted && !this.isMounted) {
this._internal.mountedOperations.push(() => {
this._internal.collection.set(this._internal.items);
});
} else {
this._queueOperation(() => {
this._internal.collection.set(this._internal.items);
});
}
};
this.addDeleteListener(() => {
this._deleteAllItemNodes();
});
},
inputs: {
items: {
group: 'Data',
displayName: 'Items',
type: 'array',
set: function (value) {
if (!value) return;
if (value === this._internal.items) return;
this.bindCollection(value);
//this.scheduleRefresh();
}
},
templateType: {
group: 'Appearance',
displayName: 'Template Type',
type: {
name: 'enum',
enums: [
{ label: 'Explicit', value: 'explicit' },
{ label: 'Dynamic', value: 'dynamic' }
]
},
default: 'explicit',
set: function (value) {
this._internal.templateType = value;
this.scheduleRefresh();
}
},
template: {
type: 'component',
displayName: 'Template',
group: 'Appearance',
set: function (value) {
this._internal.template = value;
this.scheduleRefresh();
}
},
templateScript: {
type: { name: 'string', codeeditor: 'javascript', allowEditOnly: true },
displayName: 'Script',
group: 'Appearance',
default: defaultDynamicScript,
set: function (value) {
try {
this._internal.templateFunction = new Function('item', 'var component;' + value + ';return component;');
} catch (e) {
console.log(e);
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(
this.nodeScope.componentOwner.name,
this.id,
'foreach-syntax-warning',
{ message: '<strong>Syntax</strong>: ' + e.message }
);
}
}
this.scheduleRefresh();
}
},
refresh: {
group: 'Appearance',
displayName: 'Refresh',
type: 'signal',
valueChangedToTrue: function () {
this.scheduleRefresh();
}
}
},
outputs: {
itemActionItemId: {
type: 'string',
group: 'Actions',
displayName: 'Item Id',
getter: function () {
return this._internal.itemActionItemId;
}
}
},
prototypeExtensions: {
updateTarget: function (targetId) {
this._internal.target = targetId ? this.nodeScope.getNodeWithId(targetId) : undefined;
this.scheduleRefresh();
},
setNodeModel: function (nodeModel) {
Node.prototype.setNodeModel.call(this, nodeModel);
if (nodeModel.parent) {
this.updateTarget(nodeModel.parent.id);
}
var self = this;
nodeModel.on(
'parentUpdated',
function (newParent) {
self.updateTarget(newParent ? newParent.id : undefined);
},
this
);
},
scheduleRefresh: function () {
var _this = this;
var internal = this._internal;
if (!internal.hasScheduledRefresh) {
internal.hasScheduledRefresh = true;
this.scheduleAfterInputsHaveUpdated(() => {
this._queueOperation(() => {
this.refresh();
});
});
}
},
unbindCurrentCollection: function () {
var collection = this._internal.items;
if (!collection) return;
Collection.instanceOf(collection) && collection.off('change', this._internal.onItemsCollectionChanged);
this._internal.items = undefined;
},
bindCollection: function (collection) {
var internal = this._internal;
this.unbindCurrentCollection();
Collection.instanceOf(collection) && collection.on('change', this._internal.onItemsCollectionChanged);
internal.items = collection;
this.scheduleCopyItems();
},
getTemplateForModel: function (model) {
var internal = this._internal;
if (internal.templateType === undefined || internal.templateType === 'explicit') return internal.template;
if (!internal.templateFunction) return;
try {
var template = internal.templateFunction(model);
} catch (e) {
console.log(e);
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(
this.nodeScope.componentOwner.name,
this.id,
'foreach-dynamic-warning',
{ message: '<strong>Dynamic template</strong>: ' + e.message }
);
}
}
//simple (and limited) way to support ./ and ../ at the start of component template names
if (template) {
if (template.startsWith('./')) {
template = this.model.component.name + template.substring(1);
}
if (template.startsWith('../')) {
const pathParts = this.model.component.name.split('/');
const parentPath = pathParts.slice(0, pathParts.length - 1).join('/');
template = parentPath + template.substring(2);
}
}
return template;
},
_mapInputs: function (itemNode, model) {
if (this._internal.inputMapFunc !== undefined) {
// We have a mapping function, run the function and use the mapped values
// as inputs
this._internal.inputMapFunc(function (mappings) {
for (var key in mappings) {
if (itemNode.hasInput(key)) {
if (typeof mappings[key] === 'function') {
itemNode.setInputValue(key, mappings[key](model));
} else if (typeof mappings[key] === 'string') {
itemNode.setInputValue(key, model.get(mappings[key]));
}
}
}
}, model);
}
},
addItem: async function (model, index) {
var internal = this._internal;
// Create a new component for this item
var template = this.getTemplateForModel(model);
if (!template) return;
var itemNode = await this.nodeScope.createNode(template, guid(), {
_forEachModel: model,
_forEachNode: this
});
// Set input values for all model data, and track changes
if (this._internal.inputMapFunc === undefined) {
//set component inputs with values from model
if (itemNode.hasInput('Id')) {
itemNode.setInputValue('Id', model.getId());
}
if (itemNode.hasInput('id')) {
itemNode.setInputValue('id', model.getId());
}
for (var inputKey in itemNode._inputs) {
if (model.data[inputKey] !== undefined) itemNode.setInputValue(inputKey, model.data[inputKey]);
}
//listen to changes on model
itemNode._forEachModelChangeListener = function (ev) {
if (itemNode._inputs[ev.name]) itemNode.setInputValue(ev.name, ev.value);
};
model.on('change', itemNode._forEachModelChangeListener);
//listen to changes to the component inputs
itemNode.componentModel.on(
'inputPortAdded',
(port) => {
if (port.name === 'id') itemNode.setInputValue('id', model.getId());
if (port.name === 'Id') itemNode.setInputValue('Id', model.getId());
if (model.data[port.name] !== undefined) {
itemNode.setInputValue(port.name, model.data[port.name]);
}
},
this
);
} else {
// If there is a map script, then use it
this._mapInputs(itemNode, model);
itemNode._forEachModelChangeListener = () => this._mapInputs(itemNode, model);
model.on('change', itemNode._forEachModelChangeListener);
}
// Create connections for all item output signals that we should forward
itemNode._internal.creatorCallbacks = {
onOutputChanged: (name, value, oldValue) => {
if ((oldValue === false || oldValue === undefined) && value === true && internal.itemOutputSignals[name]) {
this.itemOutputSignalTriggered(name, model, itemNode);
}
}
};
// Connect all model nodes of the component that have id type = instance
/*var itemScopes = itemNode.nodeScope.getNodesWithType('Model')
if(itemScopes && itemScopes.length>0) {
for(var j = 0; j < itemScopes.length; j++) {
itemScopes[j].hasInstanceIDType()&&itemScopes[j].setModel(model);
}
}*/
// If there is a for each actions node, signal that the item has been added
var forEachActions = itemNode.nodeScope.getNodesWithType('For Each Actions');
for (var j = 0; j < forEachActions.length; j++) {
forEachActions[j].signalAdded();
}
internal.itemNodes.push(itemNode);
internal.target.addChild(itemNode, index);
},
removeItem: function (model) {
var internal = this._internal;
if (!internal.target) return;
function findChild() {
var children = internal.target.getChildren();
for (var i in children) {
var c = children[i];
if (c._forEachModel === model && !c._forEachRemoveInProgress) return c;
}
}
var child = findChild();
if (!child) return;
var forEachActions = child.nodeScope.getNodesWithType('For Each Actions');
if (forEachActions && forEachActions.length > 0) {
// Run a try remove on the for each actions, remove the child when completed
child._forEachRemoveInProgress = true;
forEachActions[0].tryRemove(() => this._deleteItem(child));
} else {
// There are no for each actions, just remove the item
this._deleteItem(child);
}
var idx = internal.itemNodes.indexOf(child);
idx !== -1 && internal.itemNodes.splice(idx, 1);
},
_deleteItem(item) {
item._forEachModel.off('change', item._forEachModelChangeListener);
item.model && item.model.removeListenersWithRef(this);
item.componentModel && item.componentModel.removeListenersWithRef(this);
const parent = item.parent;
if (item._deleted || !parent) return;
parent.removeChild(item);
this.nodeScope.deleteNode(item);
},
_deleteAllItemNodes: function () {
if (!this._internal.itemNodes) return;
for (const itemNode of this._internal.itemNodes) {
this._deleteItem(itemNode);
}
this._internal.itemNodes = [];
},
refresh: async function () {
var internal = this._internal;
internal.hasScheduledRefresh = false;
if (!(internal.template || internal.templateFunction) || !internal.items) return;
this._deleteAllItemNodes();
//check if we have a target to add nodes to
if (!internal.target) return;
// figure out our index in our target
const baseIndex = this._internal.target.getChildren().indexOf(this) + 1;
// Iterate over all models and create items
for (var i = 0; i < internal.collection.size(); i++) {
var model = internal.collection.get(i);
await this.addItem(model, baseIndex + i);
}
},
_queueOperation(op) {
this._internal.queuedOperations.push(op);
this._runQueueOperations();
},
async _runQueueOperations() {
if (this.runningOperations) {
return;
}
this.runningOperations = true;
const repeaterCreateComponentsAsync = NoodlRuntime.instance.getProjectSettings().repeaterCreateComponentsAsync;
if (repeaterCreateComponentsAsync) {
//create items in chunks of roughly 25ms at a time
//so basically trying to keep ~30 fps
const runOps = async () => {
const start = performance.now();
while (this._internal.queuedOperations.length && performance.now() - start < 25) {
const op = this._internal.queuedOperations.shift();
await op();
}
if (this._internal.queuedOperations.length) {
setTimeout(runOps, 0);
} else {
this.runningOperations = false;
}
};
runOps();
} else {
while (this._internal.queuedOperations.length) {
const op = this._internal.queuedOperations.shift();
await op();
}
this.runningOperations = false;
}
},
_onNodeDeleted: function () {
Node.prototype._onNodeDeleted.call(this);
this._internal.queuedOperations.length = 0; //delete all queued operations
this.unbindCurrentCollection();
},
render() {
return <ForEachComponent key={this.id} didMount={() => this.didMount()} willUnmount={() => this.willUnmount()} />;
},
didMount() {
this.isMounted = true;
for (const op of this._internal.mountedOperations) {
this._queueOperation(op);
}
this._internal.mountedOperations = [];
},
willUnmount() {
this.isMounted = false;
},
getItemActionParameter: function (name) {
if (!this._internal.itemActionParameters) return;
return this._internal.itemActionParameters[name];
},
scheduleCopyItems: function () {
if (this._internal.hasScheduledCopyItems) return;
this._internal.hasScheduledCopyItems = true;
this.scheduleAfterInputsHaveUpdated(() => {
this._internal.hasScheduledCopyItems = false;
if (this._internal.items === undefined) return;
const repeaterDisabledWhenUnmounted = NoodlRuntime.instance.getProjectSettings().repeaterDisabledWhenUnmounted;
if (repeaterDisabledWhenUnmounted && !this.isMounted) {
this._internal.mountedOperations.push(() => {
this._internal.collection.set(this._internal.items);
});
} else {
this._internal.collection.set(this._internal.items);
}
});
},
itemOutputSignalTriggered: function (name, model, itemNode) {
this._internal.itemActionItemId = model.getId();
this._internal.itemActionSignal = name;
this.flagOutputDirty('itemActionItemId');
// Send signal and update item outputs after they have been correctly updated
if (!this._internal.hasScheduledTriggerItemOutputSignal) {
this._internal.hasScheduledTriggerItemOutputSignal = true;
this.context.scheduleAfterUpdate(() => {
this._internal.hasScheduledTriggerItemOutputSignal = false;
for (var key in itemNode._outputs) {
var _output = 'itemOutput-' + key;
if (this.hasOutput(_output)) {
this._internal.itemOutputs[key] = itemNode._outputs[key].value;
this.flagOutputDirty(_output);
}
}
this.sendSignalOnOutput('itemOutputSignal-' + this._internal.itemActionSignal);
});
}
},
getItemOutput: function (name) {
return this._internal.itemOutputs[name];
},
registerOutputIfNeeded: function (name) {
if (this.hasOutput(name)) {
return;
}
if (name.startsWith('itemOutputSignal-')) {
this._internal.itemOutputSignals[name.substring('itemOutputSignal-'.length)] = true;
this.registerOutput(name, {
getter: function () {
/** No needed for signals */
}
});
} else if (name.startsWith('itemOutput-'))
this.registerOutput(name, {
getter: this.getItemOutput.bind(this, name.substring('itemOutput-'.length))
});
},
setInputMappingScript: function (value) {
if (this.context.editorConnection) {
this.context.editorConnection.clearWarning(
this.nodeScope.componentOwner.name,
this.id,
'foreach-inputmapping-warning'
);
}
this._internal.inputMappingScript = value;
if (this._internal.inputMappingScript) {
try {
this._internal.inputMapFunc = new Function('map', 'object', this._internal.inputMappingScript);
} catch (e) {
this._internal.inputMapFunc = undefined;
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(
this.nodeScope.componentOwner.name,
this.id,
'foreach-inputmapping-warning',
{ message: '<strong>Input mapping</strong>: ' + e.message }
);
}
}
} else {
this._internal.inputMapFunc = undefined;
}
this.scheduleRefresh();
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name === 'inputMappingScript')
return this.registerInput(name, {
set: this.setInputMappingScript.bind(this)
});
}
}
};
function _typeName(t) {
if (typeof t === 'object') return t.name;
else return t;
}
const defaultMapCode =
'// Here you add mappings between the properties of the item objects and the inputs of the components.\n' +
"// 'myComponentInput': 'myObjectProperty',\n" +
"// 'anotherComponentInput': function () { return object.get('someProperty') + ' ' + object.get('otherProp') }\n" +
'// These are the default mappings based on the selected template component.\n' +
'map({\n' +
'{{#mappings}}' +
'})\n';
module.exports = {
ForEachComponent: ForEachComponent,
node: ForEachDefinition,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
function _collectPortsInTemplateComponent() {
var templateComponentName = node.parameters.template;
if (templateComponentName === undefined) return;
var ports = [];
var c = graphModel.components[templateComponentName];
if (c === undefined) return;
// Collect item outputs and signals
for (var outputName in c.outputPorts) {
var o = c.outputPorts[outputName];
if (_typeName(o.type) === 'signal') {
ports.push({
name: 'itemOutputSignal-' + outputName,
displayName: outputName,
type: 'signal',
plug: 'output',
group: 'Item Signals'
});
} else {
ports.push({
name: 'itemOutput-' + outputName,
displayName: outputName,
type: o.type,
plug: 'output',
group: 'Item Outputs'
});
}
}
// Collect default mappigs for template component inputs
var defaultMappings = '';
for (var inputName in c.inputPorts) {
var o = c.inputPorts[inputName];
if (_typeName(o.type) !== 'signal') {
defaultMappings += "\t'" + inputName + "': '" + inputName + "',\n";
}
}
ports.push({
name: 'inputMappingScript',
type: { name: 'string', codeeditor: 'javascript' },
displayName: 'Script',
group: 'Input Mapping',
default: defaultMapCode.replace('{{#mappings}}', defaultMappings),
plug: 'input'
});
context.editorConnection.sendDynamicPorts(node.id, ports, {
detectRenamed: {
plug: 'output',
prefix: 'itemOutput'
}
});
}
function _trackComponentOutputs(componentName) {
if (componentName === undefined) return;
var c = graphModel.components[componentName];
if (c === undefined) return;
c.on('outputPortAdded', _collectPortsInTemplateComponent);
c.on('outputPortRemoved', _collectPortsInTemplateComponent);
c.on('outputPortTypesUpdated', _collectPortsInTemplateComponent);
c.on('inputPortTypesUpdated', _collectPortsInTemplateComponent);
c.on('inputPortAdded', _collectPortsInTemplateComponent);
c.on('inputPortRemoved', _collectPortsInTemplateComponent);
}
_collectPortsInTemplateComponent();
_trackComponentOutputs(node.parameters.template);
node.on('parameterUpdated', function (event) {
if (event.name === 'template') {
_collectPortsInTemplateComponent();
_trackComponentOutputs(node.parameters.template);
}
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.For Each', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('For Each')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -0,0 +1,150 @@
const { EdgeTriggeredInput } = require('@noodl/runtime');
const ForEachActionsDefinition = {
name: 'For Each Actions',
docs: 'https://docs.noodl.net/nodes/ui-controls/repeater-item',
displayNodeName: 'Repeater Item',
category: 'Data',
color: 'data',
inputs: {
removeCompleted: {
type: { name: 'boolean', allowConnectionsOnly: true },
displayName: 'Remove Completed',
group: 'Events',
valueChangedToTrue: function () {
this._internal.removeCompletedCallback && this._internal.removeCompletedCallback();
}
}
/* itemActions:{
type:{name:'stringlist',allowEditOnly:true},
group:'Actions',
set:function(value) {
}
},
itemActionParameters:{
type:{name:'stringlist',allowEditOnly:true},
group:'Action Parameters',
set:function(value) {
}
} */
},
outputs: {
added: {
type: 'signal',
displayName: 'Added',
group: 'Events'
},
tryRemove: {
type: 'signal',
displayName: 'Try Remove',
group: 'Events'
},
itemId: {
type: 'string',
displayName: 'Item Id',
group: 'General',
get() {
return this.getItemId();
}
}
},
prototypeExtensions: {
getItemId() {
const model = this.nodeScope.componentOwner._forEachModel;
return model && model.getId();
},
signalAdded: function () {
this.sendSignalOnOutput('added');
},
tryRemove: function (callback) {
if (this.getOutput('tryRemove').hasConnections()) {
this._internal.removeCompletedCallback = callback;
this.sendSignalOnOutput('tryRemove');
} else {
// Schedule for later in this frame so any collection nodes
// being delete can complete data persistence before being
// deleted
this.scheduleAfterInputsHaveUpdated(function () {
callback();
});
}
},
itemActionTriggered(name) {
this.scheduleAfterInputsHaveUpdated(() => {
const itemId = this.getItemId();
const parentForEach = this.nodeScope.componentOwner._forEachNode;
parentForEach.signalItemAction(name, itemId, this._internal.actionParameters || {});
});
},
setItemActionParameter(name) {
if (!this._internal.actionParameters) this._internal.actionParameters = {};
this._internal.actionParameters[name] = name;
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name.startsWith('itemAction-'))
return this.registerInput(name, {
set: EdgeTriggeredInput.createSetter({
valueChangedToTrue: this.itemActionTriggered.bind(this, name)
})
});
if (name.startsWith('itemActionParameter-'))
return this.registerInput(name, {
set: this.setItemActionParameter.bind(this, name)
});
}
}
};
module.exports = {
node: ForEachActionsDefinition,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
/* graphModel.on("nodeAdded.For Each Actions", function (node) {
function _updatePorts() {
var ports = [];
var actions = node.parameters['itemActions'];
if(actions) {
actions.split(',').forEach((a) => {
ports.push({
name:'itemAction-' + a,
displayName:a,
plug:'input',
type:'signal',
group:'Actions',
})
})
}
var parameters = node.parameters['itemActionParameters'];
if(parameters) {
parameters.split(',').forEach((p) => {
ports.push({
name:'itemActionParameter-' + p,
displayName:p,
plug:'input',
type:'*',
group:'Parameters',
})
})
}
context.editorConnection.sendDynamicPorts(node.id, ports);
}
_updatePorts();
node.on('parameterUpdated',function(event) {
if(event.name === 'itemActions' || event.name === 'itemActionParameters') _updatePorts();
})
})*/
}
};

View File

@@ -0,0 +1,144 @@
'use strict';
const { Node } = require('@noodl/runtime');
var Model = require('@noodl/runtime/src/model'),
Collection = require('@noodl/runtime/src/collection');
const defaultMapCode =
'map({\n' +
'\t// Here you add mappings between the input object and the mapped output object.\n' +
"\t//myOutputProp: 'inputProp',\n" +
"\t//anotherProperty: function(object) { return object.get('someProperty') + ' ' + object.get('otherProp') }\n" +
'})\n';
var MapCollectionNode = {
name: 'Map Collection',
docs: 'https://docs.noodl.net/nodes/data/array/array-map',
displayNodeName: 'Array Map',
shortDesc: 'Map array fields',
category: 'Data',
color: 'data',
initialize: function () {
var _this = this;
this._internal.collectionChangedCallback = function () {
_this.scheduleMap();
};
// this._internal.mappedCollection = Collection.get();
},
inputs: {
items: {
type: 'array',
displayName: 'Items',
group: 'General',
set: function (value) {
this.setCollection(value);
this.scheduleMap();
}
},
mapScript: {
type: {
name: 'string',
allowEditOnly: true,
codeeditor: 'javascript'
},
displayName: 'Script',
default: defaultMapCode,
set: function (value) {
this._internal.mapCode = value;
try {
this._internal.mapFunc = new Function('map', 'object', this._internal.mapCode);
} catch (e) {
this._internal.mapFunc = undefined;
console.log('Error while parsing map script: ' + e);
}
this.scheduleMap();
}
}
},
outputs: {
items: {
type: 'array',
displayName: 'Items',
group: 'General',
getter: function () {
return this._internal.mappedCollection;
}
},
count: {
type: 'number',
displayName: 'Count',
group: 'General',
getter: function () {
return this._internal.mappedCollection ? this._internal.mappedCollection.size() : 0;
}
},
modified: {
group: 'Events',
type: 'signal',
displayName: 'Changed'
}
},
prototypeExtensions: {
setCollection: function (collection) {
this.bindCollection(collection);
this.flagOutputDirty('items');
this.flagOutputDirty('count');
},
unbindCurrentCollection: function () {
var collection = this._internal.collection;
if (!collection) return;
collection.off('change', this._internal.collectionChangedCallback);
this._internal.collection = undefined;
},
bindCollection: function (collection) {
this.unbindCurrentCollection();
this._internal.collection = collection;
collection && collection.on('change', this._internal.collectionChangedCallback);
},
_onNodeDeleted: function () {
Node.prototype._onNodeDeleted.call(this);
this.unbindCurrentCollection();
},
scheduleMap: function () {
if (this.collectionChangedScheduled) return;
this.collectionChangedScheduled = true;
this.scheduleAfterInputsHaveUpdated(() => {
this.collectionChangedScheduled = false;
if (this._internal.collection === undefined) return;
var mappedModels = this._internal.collection.map((model) => {
var m = Model.create();
this._internal.mapFunc(function (mappings) {
for (var key in mappings) {
if (typeof mappings[key] === 'function') {
m.set(key, mappings[key](model));
} else if (typeof mappings[key] === 'string') {
m.set(key, model.get(mappings[key]));
}
}
}, model);
return m;
});
this._internal.mappedCollection = Collection.create(mappedModels);
this.sendSignalOnOutput('modified');
this.flagOutputDirty('items');
this.flagOutputDirty('count');
});
}
}
};
module.exports = {
node: MapCollectionNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
}
};

View File

@@ -0,0 +1,307 @@
'use strict';
const { Services } = require('@noodl/runtime');
var PersistHelper = function (args) {
for (var i in args) this[i] = args[i];
this.hasScheduled = {};
requestInitialFetchOnConnected();
};
function guid() {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
}
// Handling of initial fetch for sync models and collections
// Each model/collection requiring initial fetch is only requested once
var hasScheduledInitialFetchForId = {};
var initialFetchHandlerForType = {};
PersistHelper.setInitialFetchHandlerForType = function (type, handler) {
initialFetchHandlerForType[type] = handler;
};
function initialFetchMessageHandler(message) {
var id = message.topic.substring('ndl/persist/getaccepted/'.length);
if (hasScheduledInitialFetchForId[id] === message.payload.clientToken) {
var type = id.split('/')[0];
var _id = id.substring(type.length + 1);
initialFetchHandlerForType[type](_id, message.payload.data);
}
}
function requestInitialFetchForId(id) {
if (hasScheduledInitialFetchForId[id]) return;
Services.pubsub.subscribe('ndl/persist/getaccepted/' + id, initialFetchMessageHandler);
hasScheduledInitialFetchForId[id] = guid();
Services.pubsub.publish('ndl/persist/get/' + id, {
clientToken: hasScheduledInitialFetchForId[id]
});
}
var connectionHandlerInstalled = false;
function requestInitialFetchOnConnected() {
if (!connectionHandlerInstalled) {
connectionHandlerInstalled = true;
Services.pubsub.on('connected', function () {
// Must request all sync models
for (var id in hasScheduledInitialFetchForId) {
Services.pubsub.publish('ndl/persist/get/' + id, {
clientToken: hasScheduledInitialFetchForId[id]
});
}
});
}
}
// Handling of delta notifications from persist servier
// each delta notification is handled only once by the first
// handler that is registered
var deltaHandlers = {};
var deltaHandlerForType = {};
PersistHelper.setDeltaHandlerForType = function (type, handler) {
deltaHandlerForType[type] = handler;
};
function deltaMessageHandler(message) {
var id = message.topic.substring('ndl/persist/delta/'.length);
if (deltaHandlers[id]) {
var type = id.split('/')[0];
var _id = id.substring(type.length + 1);
deltaHandlerForType[type](_id, message.payload.delta);
}
}
function subscribeToDeltaForId(id) {
if (!deltaHandlers[id]) {
// First subscrive, register handler and notify pubsub that we want to subscribe to the topic
deltaHandlers[id] = { cnt: 1 };
Services.pubsub.subscribe('ndl/persist/delta/' + id, deltaMessageHandler);
} else {
// If this is the second time someone subscribes for this id, just update the count
deltaHandlers[id].cnt++;
}
}
function unsubscribeToDeltaForId(id) {
if (deltaHandlers[id]) {
deltaHandlers[id].cnt--; // Reduce count
if (deltaHandlers[id].cnt === 0) {
// No more interested in delta for this id, unsubscribe from persist service
Services.pubsub.unsubscribe('ndl/persist/delta/' + id, deltaMessageHandler);
delete deltaHandlers[id];
delete hasScheduledInitialFetchForId[id]; // Next time someone subscribes for deltas, make a new initial fetch
}
}
}
PersistHelper.prototype.setId = function (id) {
this.id = id;
var _this = this;
this.schedule('subscribeToPersistService', function () {
_this.subscribeToPersistService();
});
};
PersistHelper.prototype.isWorking = function () {
return !!this.persistWorking;
};
PersistHelper.prototype.setPersistType = function (type) {
this.persistType = type;
var _this = this;
this.schedule('subscribeToPersistService', function () {
_this.subscribeToPersistService();
});
};
PersistHelper.prototype.schedule = function (type, callback) {
var _this = this;
if (this.hasScheduled[type]) return;
this.hasScheduled[type] = true;
this.scheduleFunction(function () {
callback();
_this.hasScheduled[type] = false;
});
};
PersistHelper.prototype.unsubscribe = function () {
if (this.lastSubscribedId) {
// Unsubscribe to current model id
Services.pubsub.unsubscribe('ndl/persist/updateaccepted/' + this.lastSubscribedId, this.messageHandler);
Services.pubsub.unsubscribe('ndl/persist/updaterejected/' + this.lastSubscribedId, this.messageHandler);
Services.pubsub.unsubscribe('ndl/persist/getaccepted/' + this.lastSubscribedId, this.messageHandler);
Services.pubsub.unsubscribe('ndl/persist/getrejected/' + this.lastSubscribedId, this.messageHandler);
unsubscribeToDeltaForId(this.lastSubscribedId);
}
};
PersistHelper.prototype.handleMessage = function (message) {
// Handle update accepted
if (message.topic.indexOf('ndl/persist/updateaccepted/') === 0) {
if (message.payload.clientToken === this.persistUpdateClientToken) {
this.persistWorking = false;
this.onWorkingChanged(this.persistWorking);
this.onPersistSuccess();
}
}
// Handle update rejected
else if (message.topic.indexOf('ndl/persist/updaterejected/') === 0) {
if (message.payload.clientToken === this.persistUpdateClientToken) {
this.persistWorking = false;
this.onWorkingChanged(this.persistWorking);
this.onPersistFailure();
}
}
// Handle get accepted
else if (message.topic.indexOf('ndl/persist/getaccepted/') === 0) {
if (message.payload.clientToken === this.persistGetClientToken) {
this.persistWorking = false;
this.onWorkingChanged(this.persistWorking);
this.onFetchSuccess(message.payload.data);
}
}
// Handle get rejected
else if (message.topic.indexOf('ndl/persist/getrejected/') === 0) {
if (message.payload.clientToken === this.persistGetClientToken) {
this.persistWorking = false;
this.onWorkingChanged(this.persistWorking);
this.onFetchFailure();
}
}
};
PersistHelper.prototype.subscribeToPersistService = function () {
var _this = this;
var id = this.id !== '' ? this.id : undefined;
if (!(this.persistType === 'global' || this.persistType === 'sync')) return; // Only subscribe in global and sync mode
if (this.lastSubscribedId === id) return; // We are already subscribed to this id
this.unsubscribe();
this.lastSubscribedId = id;
if (id === undefined) return;
// Subscribe to persistance topics
if (this.persistType === 'global' || this.persistType === 'sync') {
this.messageHandler = this.handleMessage.bind(this);
// Subscribe to persist accepted for this model
Services.pubsub.subscribe('ndl/persist/updateaccepted/' + id, this.messageHandler);
// Subscrive to presist rejected for this model
Services.pubsub.subscribe('ndl/persist/updaterejected/' + id, this.messageHandler);
// Subscrive to get accepted for this model
Services.pubsub.subscribe('ndl/persist/getaccepted/' + id, this.messageHandler);
// Subscrive to get rejected for this model
Services.pubsub.subscribe('ndl/persist/getrejected/' + id, this.messageHandler);
// Subscrive to delta topics
if (this.persistType === 'sync') {
subscribeToDeltaForId(id);
// If we are in sync mode and we have not previously scheduled a fetch for this id
// fetch it
requestInitialFetchForId(id);
}
}
};
PersistHelper.prototype.schedulePersist = function () {
var _this = this;
this.schedule('schedulePersist', function () {
_this.persistData(_this.onPersistDataNeeded());
});
};
PersistHelper.prototype.persistData = function (data) {
if (!data) return;
var persistType = this.persistType;
var id = this.id;
if (persistType === 'local') {
// Store model on local storage
try {
localStorage['noodl-persist-' + id] = JSON.stringify(data);
this.onPersistSuccess();
} catch (e) {
this.onPersistFailure();
}
} else if (persistType === 'global' || persistType === 'sync') {
// Send update request to persist service
this.persistUpdateClientToken = guid();
Services.pubsub.publish('ndl/persist/update/' + id, {
data: data,
clientToken: this.persistUpdateClientToken
});
this.persistWorking = true;
this.onWorkingChanged(this.persistWorking);
}
};
PersistHelper.prototype.scheduleFetch = function () {
var _this = this;
this.schedule('scheduleFetch', function () {
_this.fetchData();
});
};
PersistHelper.prototype.fetchData = function () {
var persistType = this.persistType;
var id = this.id;
if (persistType === 'local') {
// Store model on local storage
try {
var json = localStorage['noodl-persist-' + id];
var data = JSON.parse(json ? json : '{}');
if (id.indexOf('collection/') === 0) {
// This is a collection, fetch all models
var items = [];
for (var i = 0; i < data.length; i++) {
var modelJson = localStorage['noodl-persist-model/' + data[i]];
var modelData = JSON.parse(modelJson ? modelJson : '{}');
modelData.id = data[i];
items.push(modelData);
}
this.onFetchSuccess(items);
} else this.onFetchSuccess(data);
} catch (e) {
this.onFetchFailure();
}
} else if (persistType === 'global' || persistType === 'sync') {
// Send get request to persist service
this.persistGetClientToken = guid();
Services.pubsub.publish('ndl/persist/get/' + id, {
clientToken: this.persistGetClientToken
});
this.persistWorking = true;
this.onWorkingChanged(this.persistWorking);
}
};
module.exports = PersistHelper;

View File

@@ -0,0 +1,140 @@
'use strict';
const { Node } = require('@noodl/runtime');
const Model = require('@noodl/runtime/src/model');
const Collection = require('@noodl/runtime/src/collection');
var SetVariableNodeDefinition = {
name: 'Set Variable',
docs: 'https://docs.noodl.net/nodes/data/variable/set-variable',
category: 'Data',
usePortAsLabel: 'name',
color: 'data',
initialize: function () {
var internal = this._internal;
internal.variablesModel = Model.get('--ndl--global-variables');
},
outputs: {
done: {
type: 'signal',
displayName: 'Done',
group: 'Events'
}
},
inputs: {
name: {
type: {
name: 'string',
identifierOf: 'VariableName',
identifierDisplayName: 'Variable names'
},
displayName: 'Name',
group: 'General',
set: function (value) {
this._internal.name = value;
}
},
setWith: {
type: {
name: 'enum',
enums: [
{ label: 'String', value: 'string' },
{ label: 'Boolean', value: 'boolean' },
{ label: 'Number', value: 'number' },
{ label: 'Empty string', value: 'emptyString' },
{ label: 'Date', value: 'date' },
{ label: 'Object', value: 'object' },
{ label: 'Array', value: 'array' },
{ label: 'Any', value: '*' }
],
allowEditOnly: true
},
displayName: 'Set as',
default: '*',
group: 'General',
set: function (value) {
this._internal.setWith = value;
}
},
do: {
displayName: 'Do',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleStore();
}
}
},
methods: {
setValue: function (value) {
this._internal.value = value;
},
scheduleStore: function () {
if (this.hasScheduledStore) return;
this.hasScheduledStore = true;
var internal = this._internal;
this.scheduleAfterInputsHaveUpdated(function () {
this.hasScheduledStore = false;
var value = internal.setWith === 'emptyString' ? '' : internal.value;
if (internal.setWith === 'object' && typeof value === 'string') value = Model.get(value); // Can set arrays with "id" or array
if (internal.setWith === 'array' && typeof value === 'string') value = Collection.get(value); // Can set arrays with "id" or array
if (internal.setWith === 'boolean') value = !!value;
//use forceChange to always trigger Variable nodes to send the value on their output, even if it's the same value twice
internal.variablesModel.set(internal.name, value, {
forceChange: true
});
this.sendSignalOnOutput('done');
});
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name === 'value')
this.registerInput(name, {
set: this.setValue.bind(this)
});
}
}
};
module.exports = {
node: SetVariableNodeDefinition,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
graphModel.on('nodeAdded.Set Variable', function (node) {
function _updatePorts() {
var ports = [];
if (node.parameters.setWith === 'emptyString') {
// No ports needed
} else {
ports.push({
type: node.parameters.setWith !== undefined ? node.parameters.setWith : '*',
plug: 'input',
group: 'General',
name: 'value',
displayName: 'Value'
});
}
context.editorConnection.sendDynamicPorts(node.id, ports);
}
_updatePorts();
node.on('parameterUpdated', function (event) {
_updatePorts();
});
});
}
};

View File

@@ -0,0 +1,229 @@
'use strict';
const Collection = require('@noodl/runtime/src/collection');
function CSVToArray(strData, strDelimiter) {
// Check to see if the delimiter is defined. If not,
// then default to comma.
strDelimiter = strDelimiter || ',';
// Create a regular expression to parse the CSV values.
var objPattern = new RegExp(
// Delimiters.
'(\\' +
strDelimiter +
'|\\r?\\n|\\r|^)' +
// Quoted fields.
'(?:"([^"]*(?:""[^"]*)*)"|' +
// Standard fields.
'([^"\\' +
strDelimiter +
'\\r\\n]*))',
'gi'
);
// Create an array to hold our data. Give the array
// a default empty first row.
var arrData = [[]];
// Create an array to hold our individual pattern
// matching groups.
var arrMatches = null;
var prevLastIndex;
// Keep looping over the regular expression matches
// until we can no longer find a match.
while ((arrMatches = objPattern.exec(strData)) && prevLastIndex !== objPattern.lastIndex) {
prevLastIndex = objPattern.lastIndex;
// Get the delimiter that was found.
var strMatchedDelimiter = arrMatches[1];
// Check to see if the given delimiter has a length
// (is not the start of string) and if it matches
// field delimiter. If id does not, then we know
// that this delimiter is a row delimiter.
if (strMatchedDelimiter.length && strMatchedDelimiter !== strDelimiter) {
// Since we have reached a new row of data,
// add an empty row to our data array.
arrData.push([]);
}
var strMatchedValue;
// Now that we have our delimiter out of the way,
// let's check to see which kind of value we
// captured (quoted or unquoted).
if (arrMatches[2]) {
// We found a quoted value. When we capture
// this value, unescape any double quotes.
strMatchedValue = arrMatches[2].replace(new RegExp('""', 'g'), '"');
} else {
// We found a non-quoted value.
strMatchedValue = arrMatches[3];
}
// Now that we have our value string, let's add
// it to the data array.
arrData[arrData.length - 1].push(strMatchedValue);
}
// Return the parsed data.
return arrData;
}
var CSVNode = {
name: 'Static Data',
docs: 'https://docs.noodl.net/nodes/data/array/static-array',
displayNodeName: 'Static Array',
shortDesc: 'Store static data to populate a Collection with items.',
category: 'Data',
color: 'data',
nodeDoubleClickAction: [
{
focusPort: 'JSON'
},
{
focusPort: 'CSV'
}
],
getInspectInfo() {
if (this._internal.collection) {
return [
{
type: 'value',
value: this._internal.collection.items
}
];
}
},
dynamicports: [
{
name: 'conditionalports/extended',
condition: 'type = csv OR type NOT SET',
inputs: ['csv']
},
{
name: 'conditionalports/extended',
condition: 'type = json',
inputs: ['json']
}
],
inputs: {
type: {
type: {
name: 'enum',
enums: [
{ label: 'CSV', value: 'csv' },
{ label: 'JSON', value: 'json' }
],
allowEditOnly: true
},
displayName: 'Type',
group: 'General',
default: 'csv',
set: function (value) {
this._internal.type = value;
}
},
csv: {
type: { name: 'string', codeeditor: 'text', allowEditOnly: true },
displayName: 'CSV',
group: 'General',
set: function (value) {
this._internal.csv = value;
this.scheduleParseData();
}
},
json: {
type: { name: 'string', codeeditor: 'json', allowEditOnly: true },
displayName: 'JSON',
group: 'General',
set: function (value) {
this._internal.json = value;
this.scheduleParseData();
}
}
},
outputs: {
items: {
type: 'array',
displayName: 'Items',
group: 'General',
getter: function () {
return this._internal.collection;
}
},
count: {
type: 'number',
displayName: 'Count',
group: 'General',
get() {
return this._internal.collection ? this._internal.collection.size() : 0;
}
}
},
methods: {
scheduleParseData: function () {
var internal = this._internal;
if (!internal.hasScheduledParseData) {
internal.hasScheduledParseData = true;
this.scheduleAfterInputsHaveUpdated(this.parseData.bind(this));
}
},
parseData: function () {
var internal = this._internal;
internal.hasScheduledParseData = false;
internal.collection = Collection.get();
if (internal.type === undefined || internal.type === 'csv') {
// Data is string, parse it as CSV
var data = CSVToArray(internal.csv);
var json = [];
var fields = data[0];
for (var i = 1; i < data.length; i++) {
var row = data[i];
var obj = {};
for (var j = 0; j < fields.length; j++) {
obj[fields[j]] = row[j];
}
json.push(obj);
}
internal.collection.set(json);
this.flagOutputDirty('items');
this.flagOutputDirty('count');
} else if (internal.type === 'json') {
if (this.context.editorConnection) {
this.context.editorConnection.clearWarning(this.nodeScope.componentOwner.name, this.id, 'json-parse-warning');
}
try {
const json = JSON.parse(internal.json);
internal.collection.set(json);
this.flagOutputDirty('items');
this.flagOutputDirty('count');
} catch (e) {
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(
this.nodeScope.componentOwner.name,
this.id,
'json-parse-warning',
{
showGlobally: true,
message: e.message
}
);
}
}
}
}
}
};
module.exports = {
node: CSVNode
};

View File

@@ -0,0 +1,127 @@
'use strict';
const { Node } = require('@noodl/runtime');
var Model = require('@noodl/runtime/src/model');
var VariableNodeDefinition = {
name: 'Variable2',
displayNodeName: 'Variable',
docs: 'https://docs.noodl.net/nodes/data/variable/variable-node',
category: 'Data',
usePortAsLabel: 'name',
color: 'data',
initialize: function () {
var _this = this;
var internal = this._internal;
this._internal.onModelChangedCallback = function (args) {
if (!_this.isInputConnected('fetch') && args.name === internal.name) {
_this.sendSignalOnOutput('changed');
_this.flagOutputDirty('value');
}
};
internal.variablesModel = Model.get('--ndl--global-variables');
internal.variablesModel.on('change', this._internal.onModelChangedCallback);
},
getInspectInfo() {
if (this._internal.name) {
return this._internal.variablesModel.get(this._internal.name);
}
return '[No value set]';
},
outputs: {
name: {
type: 'string',
displayName: 'Name',
group: 'General',
getter: function () {
return this._internal.name;
}
},
changed: {
type: 'signal',
displayName: 'Changed',
group: 'Events'
},
fetched: {
type: 'signal',
displayName: 'Fetched',
group: 'Events'
},
value: {
type: '*',
displayName: 'Value',
group: 'General',
getter: function () {
var internal = this._internal;
if (!internal.name) return;
return internal.variablesModel.get(internal.name);
}
}
},
inputs: {
name: {
type: {
name: 'string',
identifierOf: 'VariableName',
identifierDisplayName: 'Variable names'
},
displayName: 'Name',
group: 'General',
set: function (value) {
if (this.isInputConnected('fetch') === false) this.setVariableName(value);
else {
this._internal.name = value; // Wait to fetch data
this.flagOutputDirty('name');
}
}
},
fetch: {
displayName: 'Fetch',
group: 'Actions',
valueChangedToTrue: function () {
this.setVariableName(this._internal.name);
}
},
value: {
type: '*',
displayName: 'Value',
group: 'General',
set: function (value) {
this._internal.value = value;
this.scheduleStore();
}
}
},
prototypeExtensions: {
scheduleStore: function () {
if (this.hasScheduledStore) return;
this.hasScheduledStore = true;
var internal = this._internal;
this.scheduleAfterInputsHaveUpdated(function () {
this.hasScheduledStore = false;
internal.variablesModel.set(internal.name, internal.value);
});
},
setVariableName: function (name) {
this._internal.name = name;
this.flagOutputDirty('name');
this.flagOutputDirty('value');
this.sendSignalOnOutput('fetched');
},
_onNodeDeleted: function () {
Node.prototype._onNodeDeleted.call(this);
this._internal.variablesModel.off('change', this._internal.onModelChangedCallback);
}
}
};
module.exports = {
node: VariableNodeDefinition
};

View File

@@ -0,0 +1,213 @@
'use strict';
const { Node } = require('@noodl/runtime');
const EventReceiver = {
name: 'Event Receiver',
docs: 'https://docs.noodl.net/nodes/events/receive-event',
displayNodeName: 'Receive Event',
category: 'Events',
usePortAsLabel: 'channelName',
color: 'component',
initialize: function () {
var internal = this._internal;
internal.outputValues = {};
internal.outputNames = [];
internal.eventReceived = false;
internal._isEnabled = true;
internal.channelName = '';
},
inputs: {
enabled: {
displayName: 'Enabled',
type: 'boolean',
default: true,
set: function (value) {
this._internal._isEnabled = value ? true : false;
}
},
consume: {
displayName: 'Consume',
type: {
name: 'enum',
enums: [
{ label: 'Never', value: 'never' },
{ label: 'Always', value: 'always' }
]
},
default: 'never',
set: function (value) {
this._internal.consume = value;
}
},
channelName: {
type: { name: 'string', identifierOf: 'EventChannelName' },
displayName: 'Channel',
set: function (value) {
if (this._internal.onEventReceivedCallback) {
//remove old listener
this.context.eventSenderEmitter.removeListener(
this._internal.channelName,
this._internal.onEventReceivedCallback
);
this._internal.onEventReceivedCallback = null;
}
this._internal.channelName = value;
this.registerListenersForChannel(value);
}
}
},
outputs: {
eventReceived: {
displayName: 'Received',
type: 'signal'
}
},
prototypeExtensions: {
registerOutputIfNeeded: function (name) {
if (this.hasOutput(name)) {
return;
}
var self = this;
this._internal.outputNames.push(name);
this.registerOutput(name, {
getter: function () {
return self._internal.outputValues[name];
}
});
},
handleEvent: function (eventData) {
if (this._internal._isEnabled === false) {
return;
}
this.sendSignalOnOutput('eventReceived');
for (var name in eventData) {
if (this.hasOutput(name)) {
this._internal.outputValues[name] = eventData[name];
this.flagOutputDirty(name);
}
}
return this._internal.consume === 'always';
},
onEventReceived: function (eventData) {
this.handleEvent(eventData);
},
_onNodeDeleted: function () {
Node.prototype._onNodeDeleted.call(this);
if (this._internal.onEventReceivedCallback) {
var eventEmitter = this.context.eventSenderEmitter;
eventEmitter.removeListener(this._internal.channelName, this._internal.onEventReceivedCallback);
}
},
registerListenersForChannel: function (channelName) {
var eventEmitter = this.context.eventSenderEmitter;
this._internal.onEventReceivedCallback = this.onEventReceived.bind(this);
eventEmitter.on(channelName, this._internal.onEventReceivedCallback);
var self = this;
this.context.eventEmitter.once('applicationDataReloaded', function () {
if (self._internal.onEventReceivedCallback) {
eventEmitter.removeListener(channelName, self._internal.onEventReceivedCallback);
}
});
},
getChannelName: function () {
return this._internal.channelName;
}
}
};
module.exports = {
node: EventReceiver
};
module.exports = {
node: EventReceiver,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function onEventReceiver(node) {
var channelName = node.parameters.channelName;
function _collectPayloadPorts() {
var eventSenders = graphModel.getNodesWithType('Event Sender');
var matching = eventSenders.filter((node) => node.parameters.channelName === channelName);
var portKeys = {};
matching.forEach((node) => {
const ports = node.parameters.payload ? node.parameters.payload.split(',') : [];
for (let key of ports) {
portKeys[key] = true;
}
});
var ports = [];
for (var key in portKeys) {
ports.push({
name: key,
type: '*',
plug: 'output',
displayName: key
});
}
context.editorConnection.sendDynamicPorts(node.id, ports, {
detectRenamed: {
plug: 'output'
}
});
}
_collectPayloadPorts();
node.on('parameterUpdated', function (event) {
if (event.name === 'channelName') {
channelName = event.value;
_collectPayloadPorts();
}
});
// Track all event senders and update ports when they change
function _trackEventSender(node) {
//_collectPayloadPorts();
node.on('inputPortAdded', function (event) {
_collectPayloadPorts();
});
node.on('inputPortRemoved', function (event) {
_collectPayloadPorts();
});
node.on('parameterUpdated', function (event) {
if (event.name === 'channelName') {
_collectPayloadPorts();
}
});
}
graphModel.getNodesWithType('Event Sender').forEach(_trackEventSender);
graphModel.on('nodeAdded.Event Sender', _trackEventSender);
graphModel.on('nodeRemoved.Event Sender', (node) => {
_collectPayloadPorts();
});
}
//wait with dynamic ports until the entire graph is loaded
graphModel.on('editorImportComplete', () => {
//all future added nodes though delta updates
graphModel.on('nodeAdded.Event Receiver', (node) => onEventReceiver(node));
//existing nodes from the initial export
graphModel.getNodesWithType('Event Receiver').forEach((node) => onEventReceiver(node));
});
}
};

View File

@@ -0,0 +1,136 @@
'use strict';
const EventSender = {
name: 'Event Sender',
docs: 'https://docs.noodl.net/nodes/events/send-event',
displayNodeName: 'Send Event',
category: 'Events',
usePortAsLabel: 'channelName',
color: 'component',
exportDynamicPorts: true,
initialize: function () {
this._internal.inputValues = {};
this._internal.channelName = '';
this._internal.propagation = 'global';
},
inputs: {
sendEvent: {
displayName: 'Send',
valueChangedToTrue: function () {
var self = this;
//wait for all other inputs to update before sending
this.scheduleAfterInputsHaveUpdated(function () {
if (self._internal.propagation === 'global') {
self.context.sendGlobalEventFromEventSender(self._internal.channelName, self._internal.inputValues);
} else {
self.nodeScope.sendEventFromThisScope(
self._internal.channelName,
self._internal.inputValues,
self._internal.propagation
);
}
});
}
},
channelName: {
type: {
name: 'string',
allowEditOnly: true,
identifierOf: 'EventChannelName',
identifierDisplayName: 'Event Channels'
},
default: '',
group: 'Settings',
displayName: 'Channel Name',
set: function (value) {
this._internal.channelName = value;
this._internal.inputValues._channelName = value;
}
},
propagation: {
type: {
name: 'enum',
enums: [
{ value: 'global', label: 'Global' },
{ value: 'parent', label: 'Parent' },
{ value: 'children', label: 'Children' },
{ value: 'siblings', label: 'Siblings' }
]
},
default: 'global',
group: 'Settings',
displayName: 'Send to',
set: function (value) {
this._internal.propagation = value;
}
},
payload: {
type: {
name: 'stringlist',
allowEditOnly: true
},
group: 'Payload'
}
},
prototypeExtensions: {
registerInputIfNeeded: {
value: function (name) {
if (this.hasInput(name)) {
return;
}
var self = this;
this.registerInput(name, {
set: function (value) {
self._internal.inputValues[name] = value;
}
});
}
}
}
};
function updatePorts(nodeId, parameters, editorConnection) {
var ports = [];
// Add payload inputs
var payload = parameters.payload;
if (payload) {
payload = payload.split(',');
for (const p of payload) {
ports.push({
type: {
name: '*',
allowConnectionsOnly: true
},
plug: 'input',
group: 'Payload',
name: p,
displayName: p
});
}
}
editorConnection.sendDynamicPorts(nodeId, ports, {
detectRenamed: {
plug: 'input'
}
});
}
module.exports = {
node: EventSender,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
graphModel.on('nodeAdded.Event Sender', function (node) {
updatePorts(node.id, node.parameters, context.editorConnection);
node.on('parameterUpdated', function (event) {
updatePorts(node.id, node.parameters, context.editorConnection);
});
});
}
};

View File

@@ -0,0 +1,35 @@
const ExternalLinkNode = {
name: 'net.noodl.externallink',
displayNodeName: 'External Link',
docs: 'https://docs.noodl.net/nodes/navigation/external-link',
category: 'Navigation',
nodeDoubleClickAction: {
focusPort: 'link'
},
inputs: {
link: {
type: 'string',
displayName: 'Link'
},
openInNewTab: {
type: 'boolean',
displayName: 'Open In New Tab',
default: true
},
do: {
type: 'signal',
displayName: 'Do',
valueChangedToTrue() {
const openInNewTab = this.getInputValue('openInNewTab');
const params = openInNewTab ? 'noopener,noreferrer' : '';
const target = openInNewTab === true || openInNewTab === undefined ? '_blank' : '_self';
window.open(this.getInputValue('link'), target, params);
}
}
}
};
export default {
node: ExternalLinkNode
};

View File

@@ -0,0 +1,720 @@
'use strict';
const Node = require('@noodl/runtime').Node,
JavascriptNodeParser = require('@noodl/runtime/src/javascriptnodeparser');
const guid = require('../../guid');
/*const defaultCode = "define({\n"+
"\t// The input ports of the Javascript node, name of input and type\n"+
"\tinputs:{\n"+
"\t // ExampleInput:'number',\n"+
"\t // Available types are 'number', 'string', 'boolean', 'color' and 'signal',\n"+
"\t mySignal:'signal',\n"+
"\t},\n"+
"\t\n"+
"\t// The output ports of the Javascript node, name of output and type\n"+
"\toutputs:{\n"+
"\t // ExampleOutput:'string',\n"+
"\t},\n"+
"\t\n"+
"\t// All signal inputs need their own function with the corresponding name that\n"+
"\t// will be run when a signal is received on the input.\n"+
"\tmySignal:function(inputs,outputs) {\n"+
"\t\t// ...\n"+
"\t},\n"+
"\t\n"+
"\t// This function will be called when any of the inputs have changed\n"+
"\tchange:function(inputs,outputs) {\n"+
"\t\t// ...\n"+
"\t}\n"+
"})\n";*/
/*
const defaultCode = "script({\n"+
"\t// The input ports of the Javascript node, name of input and type\n"+
"\tinputs:{\n"+
"\t // ExampleInput:'number',\n"+
"\t // Available types are 'number', 'string', 'boolean', 'color'\n"+
"\t //myNumber:'number',\n"+
"\t},\n"+
"\t\n"+
"\t// The output ports of the Javascript node, name of output and type\n"+
"\toutputs:{\n"+
"\t // ExampleOutput:'string',\n"+
"\t},\n"+
"\t\n"+
"\t// Declare signal handle functions here, each function will be \n"+
"\t// exposed as a signal input to this node.\n"+
"\tsignals:{\n"+
"\t\t// mySignal:function() { }\n"+
"\t},\n"+
"\t\n"+
"\t// These functions will be called when the correspinding input\n"+
"\t// is changed and the new value is provided\n"+
"\tchanged:{\n"+
"\t\t// myNumber:function(value) { }\n"+
"\t},\n"+
"\t\n"+
"\t// Here you can declare any function that will then be available\n"+
"\t// in this. So you can acces the function below with this.aFunction()\n"+
"\tmethods:{\n"+
"\t\t// aFunction:function(value) { }\n"+
"\t}\n"+
"})\n";
*/
const defaultCode = '';
var Javascript = {
name: 'Javascript2',
docs: 'https://docs.noodl.net/nodes/javascript/script',
displayNodeName: 'Script',
category: 'CustomCode',
color: 'javascript',
nodeDoubleClickAction: {
focusPort: 'Code'
},
searchTags: ['javascript'],
exportDynamicPorts: true,
initialize: function () {
var internal = this._internal;
internal.inputValues = {};
internal.outputValues = {};
internal.outputProperties = {};
internal.runScheduled = false;
internal.setupScheduled = false;
internal.runNextFrameScheduled = false;
internal.isWaitingForExternalFileToLoad = false;
internal.useExternalFile = false;
internal.runFunction = undefined;
internal.destroyFunction = undefined;
internal.setupFunction = undefined;
internal.hasParsedCode = false;
internal.changedInputs = {};
internal.signalScheduled = {};
internal.killed = false;
internal.inputQueue = [];
var self = this;
internal.userFunctionScope = {
createComponent(componentName) {
if (componentName && componentName.length > 0 && componentName[0] !== '/') {
componentName = '/' + componentName;
}
return self.nodeScope.createNode(componentName, guid());
},
deleteComponent(component) {
self.nodeScope.deleteNode(component);
},
flagOutputDirty: function (name) {
if (!name) {
throw new Error('Output port name must be specified');
}
self.flagOutputDirty(name);
},
runNextFrame: function () {
if (internal.runNextFrameScheduled) {
return;
}
internal.runNextFrameScheduled = true;
self.context.scheduleNextFrame(function () {
internal.runNextFrameScheduled = false;
if (!internal.killed) {
scheduleRun.call(self);
}
});
},
sendSignalOnOutput: function (name) {
self.sendSignalOnOutput(name);
}
};
internal.onFrameStart = onFrameStart.bind(this);
},
dynamicports: [
{
condition: 'useExternalFile = no OR useExternalFile NOT SET',
inputs: ['code']
},
{
condition: 'useExternalFile = yes',
inputs: ['externalFile']
}
],
inputs: {
scriptInputs: {
type: {
name: 'proplist',
allowEditOnly: true
},
group: 'Script Inputs',
set: function (value) {
// ignore
}
},
scriptOutputs: {
type: {
name: 'proplist',
allowEditOnly: true
},
group: 'Script Outputs',
set: function (value) {
// ignore
}
},
useExternalFile: {
type: {
name: 'enum',
enums: [
{
value: 'yes',
label: 'Yes'
},
{
value: 'no',
label: 'No'
}
],
allowEditOnly: true
},
default: 'no',
displayName: 'Use External File',
group: 'Code',
set: function (value) {
this._internal.isWaitingForExternalFileToLoad = value === 'yes';
this._internal.useExternalFile = value === 'yes';
}
},
code: {
displayName: 'Code',
group: 'Code',
type: {
name: 'string',
allowEditOnly: true,
codeeditor: 'javascript'
},
default: defaultCode,
set: function (value) {
if (!value) {
return;
}
var self = this;
this.scheduleAfterInputsHaveUpdated(function () {
if (this._internal.useExternalFile === false) {
this._callDestroyFunction();
var parser = JavascriptNodeParser.createFromCode(value, {
node: this
});
self._onCodeParsed(parser);
}
});
}
},
externalFile: {
displayName: 'File Path',
group: 'Code',
type: {
name: 'source',
allowEditOnly: true
},
set: function (url) {
if (this._internal.useExternalFile === false) {
return;
}
var self = this;
JavascriptNodeParser.createFromURL(
url,
function (parser) {
self._internal.isWaitingForExternalFileToLoad = false;
self._onCodeParsed(parser);
},
{
node: this
}
);
}
}
},
prototypeExtensions: {
_onNodeDeleted: function () {
Node.prototype._onNodeDeleted.call(this);
this._internal.killed = true;
this._callDestroyFunction();
},
update: function () {
if (this._internal.isWaitingForExternalFileToLoad === true) {
this._dirty = false;
} else {
Node.prototype.update.call(this);
}
},
_onCodeParsed: function (parser) {
const editorConnection = this.context.editorConnection;
if (editorConnection) {
for (const w of ['js-destroy-waring', 'js-run-waring', 'js-setup-waring']) {
editorConnection.clearWarning(this.nodeScope.componentOwner.name, this.id, w);
}
}
if (parser.error) {
return;
}
//register all color inputs with type 'color' to enable color resolving
Object.keys(this.model.inputPorts).forEach((name) => {
const type = this.model.inputPorts[name].type;
if (type === 'color' || type.name === 'color') {
this._internal.inputValues[name] = undefined;
if (!this.hasInput(name)) {
this.registerInput(name, {
type: 'color',
set: userInputSetter.bind(this, name)
});
} else {
//input was registered before js was done parsing
//patch it instead of creating a new one
this.getInput(name).type = 'color';
}
}
});
Object.keys(this.model.outputPorts).forEach((name) => {
this.registerOutputIfNeeded(name);
});
this._internal.setupFunction = parser.setup;
this._internal.runFunction = parser.change; // Run function is actually called change
this._internal.destroyFunction = parser.destroy;
this._internal.definedObject = parser.definedObject;
if (this._internal.setupFunction) {
scheduleSetup.call(this);
}
if (this._internal.runFunction) {
scheduleRun.call(this);
}
this._internal.hasParsedCode = true;
//set all the inputs that arrived before the code was parsed
if (this._internal.inputQueue) {
for (const { name, value } of this._internal.inputQueue) {
this.setInputValue(name, value);
}
//delete the queue, not needed anymore
this._internal.inputQueue = undefined;
}
// Node API
parser.apis.Node.Inputs = this._internal.inputValues;
parser.apis.Node.Outputs = this._internal.outputProperties;
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
this._internal.inputValues[name] = undefined;
this.registerInput(name, {
set: userInputSetter.bind(this, name)
});
},
registerOutputIfNeeded: function (name) {
if (this.hasOutput(name)) {
return;
}
var self = this;
const isSignal = _typename(this.model.outputPorts[name].type) === 'signal';
Object.defineProperty(this._internal.outputProperties, name, {
set: function (value) {
if (isSignal) return; // Cannot set signal functions
self._internal.outputValues[name] = value;
self.flagOutputDirty(name);
},
get: function () {
if (isSignal)
return () => {
if (self.hasOutput(name)) self.sendSignalOnOutput(name);
};
return self._internal.outputValues[name];
}
});
this.registerOutput(name, {
getter: userOutputGetter.bind(this, name)
});
},
_callRunFunction: function () {
var internal = this._internal;
if (!internal.runFunction || internal.killed) {
return;
}
try {
internal.runFunction.call(
internal.userFunctionScope,
internal.inputValues,
internal.outputProperties,
internal.changedInputs
);
} catch (e) {
console.log('Error in JS node run code.', Object.getPrototypeOf(e).constructor.name + ': ' + e.message);
if (this.context.editorConnection && this.context.isWarningTypeEnabled('javascriptExecution')) {
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'js-run-waring', {
showGlobally: true,
message: '<strong>run</strong>: ' + e.message
});
}
}
},
_callSignalFunction: function (name) {
var internal = this._internal;
if (!internal.definedObject || internal.killed) {
return;
}
if (!internal.definedObject[name] || typeof internal.definedObject[name] !== 'function') {
return;
}
try {
internal.definedObject[name].call(internal.userFunctionScope, internal.inputValues, internal.outputProperties);
} catch (e) {
console.log(
'Error in JS node signal function code.',
Object.getPrototypeOf(e).constructor.name + ': ' + e.message
);
if (this.context.editorConnection && this.context.isWarningTypeEnabled('javascriptExecution')) {
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'js-run-waring', {
showGlobally: true,
message: '<strong>run</strong>: ' + e.message
});
}
}
},
_callDestroyFunction: function () {
var internal = this._internal;
if (!internal.destroyFunction) {
return;
}
try {
internal.destroyFunction.call(internal.userFunctionScope, internal.inputValues, internal.outputProperties);
} catch (e) {
console.log('Error in JS node destroy code.', Object.getPrototypeOf(e).constructor.name + ': ' + e.message);
if (this.context.editorConnection && this.context.isWarningTypeEnabled('javascriptExecution')) {
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'js-destroy-waring', {
showGlobally: true,
message: '<strong>setup</strong>: ' + e.message
});
}
}
},
_callSetupFunction: function () {
var internal = this._internal;
if (!internal.setupFunction || internal.killed) {
return;
}
try {
internal.setupFunction.call(internal.userFunctionScope, internal.inputValues, internal.outputProperties);
} catch (e) {
console.log('Error in JS node setup code.', Object.getPrototypeOf(e).constructor.name + ': ' + e.message);
if (this.context.editorConnection && this.context.isWarningTypeEnabled('javascriptExecution')) {
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'js-setup-waring', {
showGlobally: true,
message: '<strong>setup</strong>: ' + e.message
});
}
}
}
}
};
function scheduleSetup() {
/* jshint validthis:true */
if (this._internal.setupScheduled) {
return;
}
this._internal.setupScheduled = true;
this.scheduleAfterInputsHaveUpdated(function () {
if (!this._internal.killed) {
this._callSetupFunction();
this._internal.setupScheduled = false;
}
});
}
function scheduleRun() {
/* jshint validthis:true */
if (this._internal.runScheduled || this._internal.killed) {
return;
}
this._internal.runScheduled = true;
this.scheduleAfterInputsHaveUpdated(function () {
if (!this._internal.killed) {
this._callRunFunction();
this._internal.changedInputs = {};
this._internal.runScheduled = false;
}
});
}
function scheduleSignal(name) {
/* jshint validthis:true */
if (this._internal.signalScheduled[name] || this._internal.killed) {
return;
}
this._internal.signalScheduled[name] = true;
this.scheduleAfterInputsHaveUpdated(function () {
if (!this._internal.killed) {
this._callSignalFunction(name);
this._internal.signalScheduled[name] = false;
}
});
}
function onFrameStart() {
/* jshint validthis:true */
this._internal.runNextFrame = false;
scheduleRun.call(this);
}
function _typename(type) {
if (typeof type === 'string') return type;
else return type.name;
}
function userInputSetter(name, value) {
/* jshint validthis:true */
if (this._internal.hasParsedCode === true) {
if (this.model.inputPorts[name] !== undefined && _typename(this.model.inputPorts[name].type) === 'signal') {
// If this is a signal, call the signal function
if (this._internal.definedObject && typeof this._internal.definedObject[name] === 'function') {
// This is a signal input, schedule a call to the signal function
if (value) scheduleSignal.call(this, name);
}
} else {
this._internal.inputValues[name] = value;
this._internal.changedInputs[name] = true;
scheduleRun.call(this);
}
} else {
//inputs are arriving before the code is parsed
//queue them up and set them later to make sure signals are
//properly recognized
this._internal.inputQueue.push({
name,
value
});
}
}
function userOutputGetter(name) {
/* jshint validthis:true */
return this._internal.outputValues[name];
}
function _parseAndSourceJavascript(nodeModel, context, fn) {
var editorConnection = context.editorConnection;
if (!nodeModel.parameters) {
return;
}
function clearWarnings() {
for (const w of ['js-parse-waring', 'js-destroy-waring', 'js-run-waring', 'js-setup-waring']) {
editorConnection.clearWarning(nodeModel.component.name, nodeModel.id, w);
}
}
function onCodeParsed(parser) {
if (parser.error) {
editorConnection.sendWarning(nodeModel.component.name, nodeModel.id, 'js-parse-waring', {
showGlobally: true,
message: parser.error
});
} else {
clearWarnings();
}
fn(parser.getPorts());
}
if (nodeModel.parameters.externalFile && nodeModel.parameters.useExternalFile === 'yes') {
var url = nodeModel.parameters.externalFile;
JavascriptNodeParser.createFromURL(url, onCodeParsed);
} else if (nodeModel.parameters.code) {
var parser = JavascriptNodeParser.createFromCode(nodeModel.parameters.code);
onCodeParsed(parser);
} else {
//no code, just send empty port list
clearWarnings();
fn([]);
}
}
module.exports = {
node: Javascript,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
function _updatePorts() {
var ports = [];
const _inputTypeEnums = [
{
value: 'string',
label: 'String'
},
{
value: 'boolean',
label: 'Boolean'
},
{
value: 'number',
label: 'Number'
},
{
value: 'object',
label: 'Object'
},
{
value: 'array',
label: 'Array'
}
];
const _outputTypeEnums = [
{
value: 'string',
label: 'String'
},
{
value: 'boolean',
label: 'Boolean'
},
{
value: 'number',
label: 'Number'
},
{
value: 'object',
label: 'Object'
},
{
value: 'array',
label: 'Array'
},
{
value: 'signal',
label: 'Signal'
}
];
// Outputs
if (node.parameters['scriptOutputs'] !== undefined && node.parameters['scriptOutputs'].length > 0) {
node.parameters['scriptOutputs'].forEach((p) => {
// Type for output
ports.push({
name: 'outtype-' + p.label,
displayName: 'Type',
plug: 'input',
type: {
name: 'enum',
enums: _outputTypeEnums,
allowEditOnly: true
},
default: 'string',
parent: 'scriptOutputs',
parentItemId: p.id
});
// Value for output
ports.push({
name: p.label,
plug: 'output',
type: node.parameters['outtype-' + p.label] || '*',
group: 'Outputs'
});
});
}
// Inputs
if (node.parameters['scriptInputs'] !== undefined && node.parameters['scriptInputs'].length > 0) {
node.parameters['scriptInputs'].forEach((p) => {
// Type for input
ports.push({
name: 'intype-' + p.label,
displayName: 'Type',
plug: 'input',
type: {
name: 'enum',
enums: _inputTypeEnums,
allowEditOnly: true
},
default: 'string',
parent: 'scriptInputs',
parentItemId: p.id
});
// Default Value for input
ports.push({
name: p.label,
plug: 'input',
type: node.parameters['intype-' + p.label] || 'string',
group: 'Inputs'
});
});
}
_parseAndSourceJavascript(node, context, function (_ports) {
// Merge in ports from script
_ports.forEach((p) => {
if (ports.find((_p) => _p.name === p.name && _p.plug === p.plug)) return; // Port already exists
ports.push(p);
});
context.editorConnection.sendDynamicPorts(node.id, ports);
});
}
_updatePorts();
node.on('parameterUpdated', function (ev) {
_updatePorts();
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.Javascript2', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('Javascript2')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -0,0 +1,133 @@
'use strict';
const NumberRemapperNode = {
name: 'Number Remapper',
docs: 'https://docs.noodl.net/nodes/math/number-remapper',
category: 'Math',
initialize: function () {
var internal = this._internal;
internal._currentInputValue = 0;
internal._remappedValue = 0;
internal._minInputValue = 0;
internal._maxInputValue = 0;
internal._minOutputValue = 0;
internal._maxOutputValue = 1;
internal._clampOutput = true;
},
getInspectInfo() {
return this._internal._remappedValue;
},
inputs: {
inputValue: {
group: 'Value to Remap',
type: {
name: 'number',
allowConnectionOnly: true
},
default: 0,
displayName: 'Input Value',
set: function (value) {
this._internal._currentInputValue = value;
this._calculateNewOutputValue();
}
},
minInputValue: {
group: 'Input Parameters',
type: {
name: 'number'
},
default: 0,
displayName: 'Input Minimum',
set: function (value) {
this._internal._minInputValue = value;
this._calculateNewOutputValue();
}
},
maxInputValue: {
group: 'Input Parameters',
type: {
name: 'number'
},
default: 0,
displayName: 'Input Maximum',
set: function (value) {
this._internal._maxInputValue = value;
this._calculateNewOutputValue();
}
},
minOutputValue: {
group: 'Output Parameters',
type: {
name: 'number'
},
default: 0,
displayName: 'Output Minimum',
set: function (value) {
this._internal._minOutputValue = value;
this._calculateNewOutputValue();
}
},
maxOutputValue: {
group: 'Output Parameters',
type: {
name: 'number'
},
default: 1,
displayName: 'Output Maximum',
set: function (value) {
this._internal._maxOutputValue = value;
this._calculateNewOutputValue();
}
},
clamp: {
group: 'Output Parameters',
type: {
name: 'boolean',
allowEditOnly: true
},
default: true,
displayName: 'Clamp Output',
set: function (value) {
this._internal._clampOutput = value ? true : false;
this._calculateNewOutputValue();
}
}
},
outputs: {
remappedValue: {
type: 'number',
displayName: 'Remapped Value',
group: 'Outputs',
getter: function () {
return this._internal._remappedValue;
}
}
},
prototypeExtensions: {
_calculateNewOutputValue: {
value: function () {
var normalizedValue,
_internal = this._internal;
if (_internal._maxInputValue === _internal._minInputValue) {
normalizedValue = 0;
} else {
normalizedValue =
(_internal._currentInputValue - _internal._minInputValue) /
(_internal._maxInputValue - _internal._minInputValue);
}
if (_internal._clampOutput) {
normalizedValue = Math.max(0, Math.min(1, normalizedValue));
}
_internal._remappedValue =
_internal._minOutputValue + normalizedValue * (_internal._maxOutputValue - _internal._minOutputValue);
this.flagOutputDirty('remappedValue');
}
}
}
};
module.exports = {
node: NumberRemapperNode
};

View File

@@ -0,0 +1,110 @@
'use strict';
const OpenFilePicker = {
name: 'Open File Picker',
docs: 'https://docs.noodl.net/nodes/utilities/open-file-picker',
category: 'Utilities',
getInspectInfo() {
if (this._internal.file) {
return this._internal.file.path;
}
},
initialize() {
//for some reason the input element has to be created here, it doesn't
//work predictably in Safari when created in the open signal function.
//Creating it here and reusing it seems to work correctly in all browsers.
const input = document.createElement('input');
input.type = 'file';
this._internal.inputElement = input;
},
inputs: {
open: {
type: 'signal',
displayName: 'Open',
group: 'Actions',
valueChangedToTrue() {
const input = this._internal.inputElement;
const onChange = (e) => {
this._internal.file = e.target.files[0];
this.flagOutputDirty('file');
this.flagOutputDirty('path');
this.flagOutputDirty('name');
this.flagOutputDirty('sizeInBytes');
this.flagOutputDirty('type');
this.sendSignalOnOutput('success');
input.onchange = null;
input.value = ''; //reset value so the same file can be picked again
};
input.accept = this._internal.acceptedFileTypes;
input.onchange = onChange;
input.click();
}
},
acceptedFileTypes: {
group: 'General',
type: 'string',
displayName: 'Accepted file types',
set(value) {
this._internal.acceptedFileTypes = value;
}
}
},
outputs: {
file: {
type: '*',
displayName: 'File',
group: 'General',
get() {
return this._internal.file;
}
},
path: {
displayName: 'Path',
group: 'Metadata',
type: 'string',
get() {
return this._internal.file && this._internal.file.path;
}
},
name: {
displayName: 'Name',
group: 'Metadata',
type: 'string',
get() {
return this._internal.file && this._internal.file.name;
}
},
sizeInBytes: {
displayName: 'Size in bytes',
group: 'Metadata',
type: 'number',
get() {
return this._internal.file && this._internal.file.size;
}
},
type: {
displayName: 'Type',
group: 'Metadata',
type: 'string',
get() {
return this._internal.file && this._internal.file.type;
}
},
success: {
type: 'signal',
group: 'Events',
displayName: 'Success'
}
}
};
module.exports = {
node: OpenFilePicker
};

View File

@@ -0,0 +1,53 @@
'use strict';
const ScreenResolution = {
name: 'Screen Resolution',
docs: 'https://docs.noodl.net/nodes/utilities/screen-resolution',
category: 'Utilities',
initialize() {
// Add SSR Support
if (typeof window === 'undefined') return;
window.addEventListener('resize', () => {
this._viewportSizeChanged();
});
this._viewportSizeChanged();
},
getInspectInfo() {
return this._internal.width + ' x ' + this._internal.height;
},
outputs: {
width: {
type: 'number',
displayName: 'Width',
get() {
return this._internal.width;
}
},
height: {
type: 'number',
displayName: 'Height',
get() {
return this._internal.height;
}
},
aspectRatio: {
type: 'number',
displayName: 'Aspect Ratio',
get() {
return this._internal.width / this._internal.height;
}
}
},
methods: {
_viewportSizeChanged() {
this._internal.width = window.innerWidth;
this._internal.height = window.innerHeight;
this.flagAllOutputsDirty();
}
}
};
module.exports = {
node: ScreenResolution
};

View File

@@ -0,0 +1,764 @@
'use strict';
const { EdgeTriggeredInput } = require('@noodl/runtime');
const EaseCurves = require('../../easecurves');
const BezierEasing = require('bezier-easing');
const defaultDuration = 300;
const previousStates = {},
previousValues = {};
function setRGBA(result, hex) {
if (hex === 'transparent' || !hex) {
result[3] = 0;
return;
}
const numComponents = (hex.length - 1) / 2;
for (let i = 0; i < numComponents; ++i) {
const index = 1 + i * 2;
result[i] = parseInt(hex.substring(index, index + 2), 16);
}
}
function componentToHex(c) {
var hex = c.toString(16);
return hex.length == 1 ? '0' + hex : hex;
}
function rgbaToHex(rgba) {
return '#' + componentToHex(rgba[0]) + componentToHex(rgba[1]) + componentToHex(rgba[2]) + componentToHex(rgba[3]);
}
const StatesNode = {
name: 'States',
docs: 'https://docs.noodl.net/nodes/utilities/logic/states',
shortDesc: 'Define states with values and this node can interpolate between these values when the state is changed.',
category: 'Animation',
initialize: function () {
var _this = this,
_internal = this._internal;
_internal.useTransitions = true;
_internal.currentValues = {};
_internal.stateParameters = {};
_internal.stateParameterTypes = {};
_internal.startValues = {};
_internal.bezierEaseCurves = {};
_internal.transitionFuncs = {};
_internal.valuesAreInitialised = false;
_internal.animation = this.context.timerScheduler.createTimer({
duration: defaultDuration,
ease: EaseCurves.easeOut,
onStart: function () {
var values = _internal.values;
var startValues = _internal.startValues;
var stateValues = _internal.stateParameters;
var valueTypes = _internal.stateParameterTypes;
var prefix = 'value-' + _internal.state + '-';
this.targetValues = {};
this.startValues = {};
this.valueTypes = {};
for (var v in this.transitionCurves) {
// var v = values[i];
if (valueTypes['type-' + v] === 'number' || valueTypes['type-' + v] === undefined) {
this.valueTypes[v] = 'number';
this.startValues[v] = startValues[v];
this.targetValues[v] = stateValues[prefix + v] || 0;
} else if (valueTypes['type-' + v] === 'color') {
this.valueTypes[v] = 'color';
this.startValues[v] = [0, 0, 0, 255];
setRGBA(this.startValues[v], _this.context.styles.resolveColor(startValues[v] || '#000000'));
this.targetValues[v] = [0, 0, 0, 255];
setRGBA(this.targetValues[v], _this.context.styles.resolveColor(stateValues[prefix + v] || '#000000'));
}
}
},
onRunning: function (t) {
var ms = t * this.duration;
var currentValues = _internal.currentValues;
var rgba2 = [0, 0, 0, 255];
for (var v in this.transitionCurves) {
var c = this.transitionCurves[v];
// var v = values[i];
if (ms < c.delay) currentValues[v] = this.startValues[v];
else if (ms >= c.delay + c.dur)
currentValues[v] = this.valueTypes[v] === 'color' ? rgbaToHex(this.targetValues[v]) : this.targetValues[v];
else {
var _t = _internal.transitionFuncs[v].get((ms - c.delay) / c.dur);
if (this.valueTypes[v] === 'number') {
//convert values to Numers, since they might be strings, which can cause NaN
currentValues[v] = EaseCurves.linear(Number(this.startValues[v]), Number(this.targetValues[v]), _t);
} else if (this.valueTypes[v] === 'color') {
let rgba0 = this.startValues[v];
let rgba1 = this.targetValues[v];
rgba2[0] = Math.floor(EaseCurves.linear(rgba0[0], rgba1[0], _t));
rgba2[1] = Math.floor(EaseCurves.linear(rgba0[1], rgba1[1], _t));
rgba2[2] = Math.floor(EaseCurves.linear(rgba0[2], rgba1[2], _t));
rgba2[3] = Math.floor(EaseCurves.linear(rgba0[3], rgba1[3], _t));
currentValues[v] = rgbaToHex(rgba2);
}
}
_this.flagOutputDirty(v);
}
},
onFinish: function () {
var port = 'reached-' + _internal.state;
if (_this.hasOutput(port)) _this.sendSignalOnOutput(port);
}
});
},
getInspectInfo() {
return `Current state: ${this._internal.state}`;
},
inputs: {
states: {
type: { name: 'stringlist', allowEditOnly: true },
displayName: 'States',
group: 'States',
set: function (value) {
this._internal.states = value ? value.split(',') : [];
// Set the state to first value if no state change is scheduled during
// input updates
if (this._internal.states.length > 0) {
var _this = this;
if (!_this._internal.state) this.scheduleGoToState(_this._internal.startState || _this._internal.states[0]);
/* this.scheduleAfterInputsHaveUpdated(function () {
if (!_this._internal.state) _this.goToState(_this._internal.startState ||_this._internal.states[0]);
});*/
}
}
},
values: {
type: { name: 'stringlist', allowEditOnly: true },
displayName: 'Values',
group: 'Values',
set: function (value) {
var internal = this._internal;
internal.values = value.split(',');
// Register output values at this point
for (var i in internal.values) {
this.registerOutputIfNeeded(internal.values[i]);
}
}
},
toggle: {
group: 'Go to state',
displayName: 'Toggle',
valueChangedToTrue: function () {
var internal = this._internal;
var _this = this;
if (!internal.states) return;
// Figure out which state to toggle to
var idx = internal.states.indexOf(internal.state);
var nextIdx = (idx + 1) % internal.states.length;
// Go to state when all updates have updated
//this._internal.scheduledToGoToState = internal.states[nextIdx];
this.scheduleGoToState(internal.states[nextIdx]);
/* this.scheduleAfterInputsHaveUpdated(function () {
_this.goToState(internal.states[nextIdx]);
});*/
}
},
useTransitions: {
type: 'boolean',
displayName: 'Use Transitions',
group: 'General',
default: true,
set: function (value) {
var internal = this._internal;
internal.useTransitions = value;
}
}
},
outputs: {
currentState: {
type: 'string',
displayName: 'State',
group: 'Current State',
getter: function () {
return this._internal.state;
}
},
stateChanged: {
type: 'signal',
displayName: 'State Changed',
group: 'Current State'
}
},
prototypeExtensions: {
registerOutputIfNeeded: function (name) {
var internal = this._internal;
if (this.hasOutput(name)) return;
this.registerOutput(name, {
getter: function () {
return internal.currentValues[name];
}
});
},
registerInputIfNeeded: function (name) {
var _this = this;
var internal = this._internal;
if (this.hasInput(name)) return;
if (name.indexOf('to-') === 0) {
// This is a go to state signal input
var state = name.substring(3);
this.registerInput(name, {
set: EdgeTriggeredInput.createSetter({
valueChangedToTrue: function () {
//this._internal.scheduledToGoToState = state;
this.scheduleGoToState(state);
//this.scheduleAfterInputsHaveUpdated(function () { _this.goToState(state) });
}
})
});
} else if (name === 'startState') {
// Note: this is kept for backwards compatability, but this port is no longer part of the dynamic ports def
// Other state parameters are stored
this.registerInput(name, {
set: function (value) {
//this._internal.scheduledToGoToState = value;
this._internal.startState = value;
this.scheduleGoToState(value);
// this.scheduleAfterInputsHaveUpdated(function () { _this.goToState(value); });
}
});
} else if (name === 'currentState') {
this.registerInput(name, {
set: this.scheduleGoToState.bind(this)
});
} else if (name.indexOf('type-') === 0) {
this.registerInput(name, {
set: function (value) {
internal.stateParameterTypes[name] = value;
}
});
} else if (name.indexOf('value-') === 0) {
// Other state parameters are stored
var parts = name.split('-');
var state = parts[1];
var valueName = parts[2];
this.registerInput(name, {
set: function (value) {
internal.stateParameters[name] = value;
if (internal.state === state) {
// If we are at the state, update the current value immediately
internal.currentValues[valueName] = value;
_this.flagOutputDirty(valueName);
}
}
});
} else if (name.search(/duration-/g) === 0) {
this.registerInput(name, {
set: function (value) {
internal.stateParameters[name] = value;
}
});
} else if (name.search(/transition/g) === 0) {
var state = name.substring(11);
this.registerInput(name, {
set: function (value) {
internal.stateParameters[name] = value;
/* if (value === 'cubicBezier') {
this.updateCubicBezierFunction(state); //create a default bezier easing curve
}*/
}
});
}
/* else if (name.search(/cubicBezierP[1-2][X-Y]-/g) === 0) {
var state = name.substring(15);
this.registerInput(name, {
set: function (value) {
internal.stateParameters[name] = value;
this.updateCubicBezierFunction(state); //update easing curve
}
});
}*/
},
/* updateCubicBezierFunction: function (state) {
var points = [];
var internal = this._internal;
['1X', '1Y', '2X', '2Y'].forEach(function (p, i) {
var value = internal.stateParameters['cubicBezierP' + p + '-' + state] || 0;
if (i % 2 === 0) {
//X values must equal [0,1]
value = Math.min(1, Math.max(0, value));
}
points.push(value);
});
var cubicBezierEase = BezierEasing(points);
internal.bezierEaseCurves[state] = function (start, end, t) {
return EaseCurves.linear(start, end, cubicBezierEase.get(t));
};
},*/
setCurrentState: function (value) {
this.scheduleGoToState(value);
},
jumpToState: function (state) {
var internal = this._internal;
if (!internal.states) return;
if (!state) state = internal.states[0];
if (internal.state === state) return;
internal.animation.stop();
var prefix = 'value-' + state + '-';
for (var i in internal.values) {
var v = internal.values[i];
internal.currentValues[v] = internal.stateParameters[prefix + v] || 0;
this.flagOutputDirty(v);
}
internal.state = state;
//console.log('currentState dirty:' + state);
this.flagOutputDirty('currentState');
if (internal.valuesAreInitialised) {
// Do not send state changed on first initial state set
//console.log('stateChanged signal', state);
this.sendSignalOnOutput('stateChanged');
}
internal.valuesAreInitialised = true;
this.updateAtStatePorts();
},
scheduleGoToState: function (state) {
var _this = this;
this._internal.goToState = state;
//console.log('set go to state: ' + state)
if (this.hasScheduledGoToState) return;
this.hasScheduledGoToState = true;
this.scheduleAfterInputsHaveUpdated(function () {
//console.log('changing state: ' + _this._internal.goToState)
_this.hasScheduledGoToState = false;
_this.goToState(_this._internal.goToState);
});
},
goToState: function (state) {
var internal = this._internal;
if (!internal.states) return;
if (!state) state = internal.states[0];
if (internal.state === state) return;
//this._internal.scheduledToGoToState = undefined;
if (!internal.valuesAreInitialised) {
// First time go to state is called, jump to the state
this.jumpToState(state);
} else {
// Copy current values as start values
var delay = 0;
var dur = 0;
var transitionCurves = {};
for (var i in internal.values) {
var v = internal.values[i];
internal.startValues[v] = internal.currentValues[v];
const parameterType = internal.stateParameterTypes['type-' + v];
if (parameterType === 'boolean') {
// These types don't transition, just set them
var _b = internal.stateParameters['value-' + state + '-' + v];
internal.currentValues[v] = _b === undefined ? false : !!_b;
this.flagOutputDirty(v);
} else if (parameterType === 'string' || parameterType === 'textStyle') {
// These types don't transition, just set them
internal.currentValues[v] = internal.stateParameters['value-' + state + '-' + v];
this.flagOutputDirty(v);
} else {
// Figure out transition curve
var transitionCurve = internal.stateParameters['transition-' + state + '-' + v];
if (!transitionCurve)
transitionCurve = internal.stateParameters['transitiondef-' + state] || {
curve: [0.0, 0.0, 0.58, 1.0],
dur: 300,
delay: 0
};
if ((transitionCurve.dur === 0 && transitionCurve.delay === 0) || !internal.useTransitions) {
// Simply set the target value
internal.currentValues[v] = internal.stateParameters['value-' + state + '-' + v];
this.flagOutputDirty(v);
} else {
// Calculate total duration and delay
internal.transitionFuncs[v] = BezierEasing(transitionCurve.curve);
transitionCurves[v] = transitionCurve;
delay = Math.min(delay, transitionCurve.delay);
dur = Math.max(dur, transitionCurve.dur + transitionCurve.delay);
}
}
}
// Setup and start animation
//var easeCurveName = internal.stateParameters['transition-' + state] || 'easeOut';
//internal.animation.ease = easeCurveName === 'cubicBezier' ? internal.bezierEaseCurves[state] : EaseCurves[easeCurveName];
//var durationKey = 'duration-' + state;
//internal.animation.duration = internal.stateParameters.hasOwnProperty(durationKey) ? internal.stateParameters[durationKey] : defaultDuration;
if (dur > 0 || delay > 0) {
internal.animation.transitionCurves = transitionCurves;
internal.animation.duration = dur;
internal.animation.delay = delay;
internal.animation.start();
}
internal.state = state;
//console.log('currentState dirty:' + state);
this.flagOutputDirty('currentState');
//console.log('stateChanged signal', state);
this.sendSignalOnOutput('stateChanged');
this.updateAtStatePorts();
if (dur == 0 && delay == 0) {
// Send reached signal if no transition
var port = 'reached-' + internal.state;
if (this.hasOutput(port)) this.sendSignalOnOutput(port);
}
}
},
updateAtStatePorts: function () {
var internal = this._internal;
var states = internal.states;
for (var i in states) {
var s = states[i];
var port = 'at-' + s;
internal.currentValues[port] = internal.state === s;
if (this.hasOutput(port)) this.flagOutputDirty(port);
}
}
}
};
function detectRename(before, after) {
if (!before || !after) return;
if (before.length !== after.length) return; // Must be of same length
var res = {};
for (var i = 0; i < before.length; i++) {
if (after.indexOf(before[i]) === -1) {
if (res.before) return; // Can only be one from before that is missing
res.before = before[i];
}
if (before.indexOf(after[i]) === -1) {
if (res.after) return; // Only one can be missing,otherwise we cannot match
res.after = after[i];
}
}
return res.before && res.after ? res : undefined;
}
function updatePorts(nodeId, parameters, editorConnection) {
var states = parameters.states;
var values = parameters.values;
var ports = [];
// Add value outputs
values = values ? values.split(',') : undefined;
for (var i in values) {
var p = values[i];
ports.push({
type: {
name: parameters['type-' + p] || 'number',
allowConnectionsOnly: true
},
plug: 'output',
group: 'Values',
name: p
});
// Type selector
ports.push({
type: {
name: 'enum',
enums: [
{ label: 'Number', value: 'number' },
{ label: 'String', value: 'string' },
{ label: 'Boolean', value: 'boolean' },
{ label: 'Color', value: 'color' },
{ label: 'Text Style', value: 'textStyle' }
],
allowEditOnly: true
},
default: 'number',
plug: 'input',
group: 'Types',
displayName: p,
name: 'type-' + p
});
}
// Add state value inputs
states = states ? states.split(',') : undefined;
states &&
states.forEach(function (state) {
values &&
values.forEach(function (value) {
ports.push({
plug: 'input',
type: parameters['type-' + value] || 'number',
group: state + ' Values',
name: 'value-' + state + '-' + value,
displayName: value,
editorName: state + '|' + value
});
});
// State transition
if (values && parameters['useTransitions'] !== false) {
ports.push({
plug: 'input',
type: 'curve',
displayName: 'Default',
default: { curve: [0.0, 0.0, 0.58, 1.0], dur: 300, delay: 0 },
group: state + ' Transitions',
name: 'transitiondef-' + state
});
values.forEach(function (value) {
if (
parameters['type-' + value] === undefined ||
parameters['type-' + value] === 'number' ||
parameters['type-' + value] === 'color'
) {
ports.push({
plug: 'input',
type: { name: 'curve' },
default: parameters['transitiondef-' + state] || {
curve: [0.0, 0.0, 0.58, 1.0],
dur: 300,
delay: 0
},
group: state + ' Transitions',
name: 'transition-' + state + '-' + value,
displayName: value,
editorName: 'Transition ' + state + '|' + value
});
}
});
}
/* ports.push({
plug: 'input',
type: {
name: 'enum',
enums: [
{ value: 'easeOut', label: "Ease Out" },
{ value: 'easeIn', label: "Ease In" },
{ value: 'linear', label: "Linear" },
{ value: 'easeInOut', label: "Ease In Out" },
{ value: 'cubicBezier', label: 'Cubic Bezier' }
]
},
default: 'easeOut',
displayName: "Easing Curve",
group: state + ' Transition',
name: 'transition-' + state,
});
//add cubic bezier inputs if transition is set to cubic
if (parameters['transition-' + state] === 'cubicBezier') {
ports = ports.concat([
{
name: 'cubicBezierP1X-' + state,
editorName: state + '|' + 'P1 X',
displayName: 'P1 X',
group: state + ' Transition',
plug: 'input',
type: 'number',
default: 0
},
{
name: 'cubicBezierP1Y-' + state,
editorName: state + '|' + 'P1 Y',
displayName: 'P1 Y',
group: state + ' Transition',
plug: 'input',
type: 'number',
default: 0
},
{
name: 'cubicBezierP2X-' + state,
editorName: state + '|' + 'P2 X',
displayName: 'P2 X',
group: state + ' Transition',
plug: 'input',
type: 'number',
default: 0
},
{
name: 'cubicBezierP2Y-' + state,
editorName: state + '|' + 'P2 Y',
displayName: 'P2 Y',
group: state + ' Transition',
plug: 'input',
type: 'number',
default: 0
}
]);
}
ports.push({
plug: 'input',
type: 'number',
default: defaultDuration,
displayName: "Duration",
group: state + ' Transition',
name: 'duration-' + state,
});*/
// Go to state port
ports.push({
plug: 'input',
type: { name: 'signal', allowConnectionsOnly: true },
displayName: 'To ' + state,
name: 'to-' + state,
group: 'Go to state'
});
// At state output
ports.push({
plug: 'output',
type: 'boolean',
displayName: 'At ' + state,
name: 'at-' + state,
group: 'Current state'
});
// Has reached state output
ports.push({
plug: 'output',
type: 'signal',
displayName: 'Has Reached ' + state,
name: 'reached-' + state,
group: 'Current state'
});
});
// Add current state port
if (states) {
ports.push({
plug: 'input',
type: { name: 'enum', enums: states },
group: 'States',
displayName: 'State',
name: 'currentState',
default: parameters['startState'] || states[0] // This is kept for backwards compatability, the startState port does no longer exist
});
}
// Detect state and value rename
var stateRenamed = detectRename(previousStates[nodeId], states);
previousStates[nodeId] = states;
var valueRenamed = detectRename(previousValues[nodeId], values);
previousValues[nodeId] = values;
let renamed;
if (stateRenamed) {
renamed = {
plug: 'input',
before: stateRenamed.before,
after: stateRenamed.after,
patterns: [
'transition-{{*}}',
/* 'duration-{{*}}',
'cubicBezierP1X-{{*}}',
'cubicBezierP2X-{{*}}',
'cubicBezierP1Y-{{*}}',
'cubicBezierP2Y-{{*}}',*/
'to-{{*}}',
'at-{{*}}',
'reached-{{*}}'
]
};
// A state has been renamed
values &&
values.forEach(function (value) {
renamed.patterns.push('value-{{*}}-' + value);
});
} else if (valueRenamed) {
renamed = [
{
plug: 'output',
before: valueRenamed.before,
after: valueRenamed.after,
patterns: ['{{*}}']
},
{
plug: 'input',
before: valueRenamed.before,
after: valueRenamed.after,
patterns: ['type-{{*}}']
},
{
plug: 'input',
before: valueRenamed.before,
after: valueRenamed.after,
patterns: states
? states.map(function (s) {
return 'value-' + s + '-' + '{{*}}';
})
: undefined
}
];
}
editorConnection.sendDynamicPorts(nodeId, ports, { renamed: renamed });
}
module.exports = {
node: StatesNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
graphModel.on('nodeAdded.States', function (node) {
if (node.parameters.states) {
updatePorts(node.id, node.parameters, context.editorConnection);
}
node.on('parameterUpdated', function (event) {
if (
event.name === 'useTransitions' ||
event.name === 'states' ||
event.name === 'values' ||
event.name.startsWith('transition') ||
event.name.startsWith('type-')
) {
updatePorts(node.id, node.parameters, context.editorConnection);
}
});
});
}
};

View File

@@ -0,0 +1,92 @@
'use strict';
const Switch = {
name: 'Switch',
docs: 'https://docs.noodl.net/nodes/logic/switch',
category: 'Logic',
initialize: function () {
this._internal.state = false;
this._internal.initialized = false;
},
getInspectInfo() {
return this._internal.state;
},
inputs: {
on: {
displayName: 'On',
group: 'Change State',
valueChangedToTrue: function () {
if (this._internal.state === true) {
return;
}
this._internal.state = true;
this.flagOutputDirty('state');
this.emitSignals();
}
},
off: {
displayName: 'Off',
group: 'Change State',
valueChangedToTrue: function () {
if (this._internal.state === false) {
return;
}
this._internal.state = false;
this.flagOutputDirty('state');
this.emitSignals();
}
},
flip: {
displayName: 'Flip',
group: 'Change State',
valueChangedToTrue: function () {
this._internal.state = !this._internal.state;
this.flagOutputDirty('state');
this.emitSignals();
}
},
onFromStart: {
type: 'boolean',
displayName: 'State',
group: 'General',
default: false,
set: function (value) {
this._internal.state = !!value;
this.flagOutputDirty('state');
this.emitSignals();
}
}
},
outputs: {
state: {
type: 'boolean',
displayName: 'Current State',
getter: function () {
return this._internal.state;
}
},
switchedToOn: {
displayName: 'Switched To On',
type: 'signal',
group: 'Signals'
},
switchedToOff: {
displayName: 'Switched To Off',
type: 'signal',
group: 'Signals'
}
},
prototypeExtensions: {
emitSignals: function () {
if (this._internal.state === true) {
this.sendSignalOnOutput('switchedToOn');
} else {
this.sendSignalOnOutput('switchedToOff');
}
}
}
};
module.exports = {
node: Switch
};

View File

@@ -0,0 +1,85 @@
'use strict';
const Timer = {
name: 'Timer',
docs: 'https://docs.noodl.net/nodes/utilities/delay',
displayName: 'Delay',
category: 'Utilities',
nodeDoubleClickAction: {
focusPort: 'duration'
},
initialize: function () {
var self = this;
this._internal._animation = this.context.timerScheduler.createTimer({
duration: 0,
onStart: function () {
self.sendSignalOnOutput('timerStarted');
},
onFinish: function () {
self.sendSignalOnOutput('timerFinished');
}
});
this.addDeleteListener(() => {
this._internal._animation.stop();
});
},
getInspectInfo() {
if (this._internal._animation.isRunning()) {
return Math.floor(this._internal._animation.durationLeft() / 10) / 100 + ' seconds';
}
return 'Not running';
},
inputs: {
start: {
displayName: 'Start',
valueChangedToTrue: function () {
if (this._internal._animation._isRunning === false) {
this._internal._animation.start();
}
}
},
restart: {
displayName: 'Restart',
valueChangedToTrue: function () {
this._internal._animation.start();
}
},
duration: {
type: 'number',
displayName: 'Duration',
default: 0,
set: function (value) {
this._internal._animation.duration = value;
}
},
startDelay: {
type: 'number',
displayName: 'Start Delay',
default: 0,
set: function (value) {
this._internal._animation.delay = value;
}
},
stop: {
displayName: 'Stop',
valueChangedToTrue: function () {
this._internal._animation.stop();
}
}
},
outputs: {
timerStarted: {
type: 'signal',
displayName: 'Started'
},
timerFinished: {
type: 'signal',
displayName: 'Finished'
}
}
};
module.exports = {
node: Timer
};

View File

@@ -0,0 +1,138 @@
'use strict';
const CloudFile = require('@noodl/runtime/src/api/cloudfile');
const CloudStore = require('@noodl/runtime/src/api/cloudstore');
const UploadFile = {
name: 'Upload File',
docs: 'https://docs.noodl.net/nodes/data/cloud-data/upload-file',
category: 'Cloud Services',
color: 'data',
getInspectInfo() {
return this._internal.response;
},
inputs: {
file: {
group: 'General',
displayName: 'File',
type: '*',
set(file) {
this._internal.file = file;
}
},
upload: {
type: 'signal',
displayName: 'Upload',
group: 'Actions',
valueChangedToTrue() {
this.scheduleAfterInputsHaveUpdated(() => {
const file = this._internal.file;
if (!file) {
this.setError('No file specified');
return;
}
CloudStore.instance.uploadFile({
file,
onUploadProgress: (p) => {
this._internal.progressTotal = p.total;
this._internal.progressLoaded = p.loaded;
this.flagOutputDirty('progressTotalBytes');
this.flagOutputDirty('progressLoadedBytes');
this.flagOutputDirty('progressLoadedPercent');
this.sendSignalOnOutput('progressChanged');
},
success: (response) => {
this._internal.cloudFile = new CloudFile(response);
this.flagOutputDirty('cloudFile');
this.sendSignalOnOutput('success');
},
error: (e) => this.setError(e)
});
});
}
}
},
outputs: {
cloudFile: {
group: 'General',
displayName: 'Cloud File',
type: 'cloudfile',
get() {
return this._internal.cloudFile;
}
},
success: {
group: 'Events',
displayName: 'Success',
type: 'signal'
},
failure: {
group: 'Events',
displayName: 'Failure',
type: 'signal'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Error',
get() {
return this._internal.error;
}
},
errorStatus: {
type: 'number',
displayName: 'Error Status Code',
group: 'Error',
get() {
return this._internal.errorStatus;
}
},
progressChanged: {
type: 'signal',
displayName: 'Progress Changed',
group: 'Events'
},
progressTotalBytes: {
type: 'number',
displayName: 'Total Bytes',
group: 'Progress',
get() {
return this._internal.progressTotal;
}
},
progressLoadedBytes: {
type: 'number',
displayName: 'Uploaded Bytes',
group: 'Progress',
get() {
return this._internal.progressLoaded;
}
},
progressLoadedPercent: {
type: 'number',
displayName: 'Uploaded Percent',
group: 'Progress',
get() {
if (!this._internal.progressTotal) return 0;
return (this._internal.progressLoaded / this._internal.progressTotal) * 100;
}
}
},
methods: {
setError(err) {
this._internal.error = err.hasOwnProperty('error') ? err.error : err;
//use the error code. If there is none, use the http status
this._internal.errorStatus = err.code || err.status || 0;
this.flagOutputDirty('error');
this.flagOutputDirty('errorStatus');
this.sendSignalOnOutput('failure');
}
}
};
module.exports = {
node: UploadFile
};

View File

@@ -0,0 +1,188 @@
'use strict';
const { Node, EdgeTriggeredInput } = require('@noodl/runtime');
const UserService = require('./userservice');
var LoginNodeDefinition = {
name: 'net.noodl.user.LogIn',
docs: 'https://docs.noodl.net/nodes/data/user/log-in',
displayNodeName: 'Log In',
category: 'Cloud Services',
color: 'data',
initialize: function () {
var internal = this._internal;
},
getInspectInfo() {},
outputs: {
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Error',
getter: function () {
return this._internal.error;
}
}
},
inputs: {
login: {
displayName: 'Do',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleLogIn();
}
},
username: {
displayName: 'Username',
type: 'string',
group: 'General',
set: function (value) {
this._internal.username = value;
}
},
password: {
displayName: 'Password',
type: 'string',
group: 'General',
set: function (value) {
this._internal.password = value;
}
}
},
methods: {
setError: function (err) {
this._internal.error = err;
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'user-login-warning', {
message: err,
showGlobally: true
});
}
},
clearWarnings() {
if (this.context.editorConnection) {
this.context.editorConnection.clearWarning(this.nodeScope.componentOwner.name, this.id, 'user-login-warning');
}
},
scheduleLogIn: function () {
const internal = this._internal;
if (this.logInScheduled === true) return;
this.logInScheduled = true;
this.scheduleAfterInputsHaveUpdated(() => {
this.logInScheduled = false;
UserService.instance.logIn({
username: this._internal.username,
password: this._internal.password,
success: () => {
this.sendSignalOnOutput('success');
},
error: (e) => {
this.setError(e);
}
});
});
}
}
};
/*function updatePorts(nodeId, parameters, editorConnection, dbCollections) {
var ports = [];
ports.push({
name: 'collectionName',
displayName: "Class",
group: "General",
type: { name: 'enum', enums: (dbCollections !== undefined) ? dbCollections.map((c) => { return { value: c.name, label: c.name } }) : [], allowEditOnly: true },
plug: 'input'
})
if (parameters.collectionName && dbCollections) {
// Fetch ports from collection keys
var c = dbCollections.find((c) => c.name === parameters.collectionName);
if (c && c.schema && c.schema.properties) {
var props = c.schema.properties;
for (var key in props) {
var p = props[key];
if (ports.find((_p) => _p.name === key)) continue;
if(p.type === 'Relation') {
}
else { // Other schema type ports
const _typeMap = {
"String":"string",
"Boolean":"boolean",
"Number":"number",
"Date":"date"
}
ports.push({
type: {
name: _typeMap[p.type]?_typeMap[p.type]:'*',
},
plug: 'output',
group: 'Properties',
name: 'prop-' + key,
displayName: key,
})
ports.push({
type: 'signal',
plug: 'output',
group: 'Changed Events',
displayName: key+ ' Changed',
name: 'changed-' + key,
})
}
}
}
}
editorConnection.sendDynamicPorts(nodeId, ports);
}*/
module.exports = {
node: LoginNodeDefinition,
setup: function (context, graphModel) {
/* if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel.getMetaData('dbCollections'));
node.on("parameterUpdated", function (event) {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel.getMetaData('dbCollections'));
});
graphModel.on('metadataChanged.dbCollections', function (data) {
updatePorts(node.id, node.parameters, context.editorConnection, data);
})
}
graphModel.on("editorImportComplete", ()=> {
graphModel.on("nodeAdded.DbModel2", function (node) {
_managePortsForNode(node)
})
for(const node of graphModel.getNodesWithType('DbModel2')) {
_managePortsForNode(node)
}
})*/
}
};

View File

@@ -0,0 +1,88 @@
'use strict';
const { Node, EdgeTriggeredInput } = require('@noodl/runtime');
const UserService = require('./userservice');
var LogOutNodeDefinition = {
name: 'net.noodl.user.LogOut',
docs: 'https://docs.noodl.net/nodes/data/user/log-out',
displayNodeName: 'Log Out',
category: 'Cloud Services',
color: 'data',
initialize: function () {
var internal = this._internal;
},
getInspectInfo() {},
outputs: {
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Error',
getter: function () {
return this._internal.error;
}
}
},
inputs: {
login: {
displayName: 'Do',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleLogOut();
}
}
},
methods: {
setError: function (err) {
this._internal.error = err;
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'user-login-warning', {
message: err,
showGlobally: true
});
}
},
clearWarnings() {
if (this.context.editorConnection) {
this.context.editorConnection.clearWarning(this.nodeScope.componentOwner.name, this.id, 'user-login-warning');
}
},
scheduleLogOut: function () {
const internal = this._internal;
if (this.logOutScheduled === true) return;
this.logOutScheduled = true;
this.scheduleAfterInputsHaveUpdated(() => {
this.logOutScheduled = false;
UserService.instance.logOut({
success: () => {
this.sendSignalOnOutput('success');
},
error: (e) => {
this.setError(e);
}
});
});
}
}
};
module.exports = {
node: LogOutNodeDefinition,
setup: function (context, graphModel) {}
};

View File

@@ -0,0 +1,107 @@
'use strict';
const { Node, EdgeTriggeredInput } = require('@noodl/runtime');
const UserService = require('./userservice');
var RequestPasswordResetNodeDefinition = {
name: 'net.noodl.user.RequestPasswordReset',
docs: 'https://docs.noodl.net/nodes/data/user/request-password-reset',
displayNodeName: 'Request Password Reset',
category: 'Cloud Services',
color: 'data',
deprecated: true, // Use cloud functions
initialize: function () {
var internal = this._internal;
},
getInspectInfo() {},
outputs: {
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Error',
getter: function () {
return this._internal.error;
}
}
},
inputs: {
send: {
displayName: 'Do',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleRequestPasswordReset();
}
},
email: {
type: 'string',
displayName: 'Email',
group: 'General',
set: function (value) {
this._internal.email = value;
}
}
},
methods: {
setError: function (err) {
this._internal.error = err;
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(
this.nodeScope.componentOwner.name,
this.id,
'user-send-email-verification-warning',
{
message: err,
showGlobally: true
}
);
}
},
clearWarnings() {
if (this.context.editorConnection) {
this.context.editorConnection.clearWarning(
this.nodeScope.componentOwner.name,
this.id,
'user-send-email-verification-warning'
);
}
},
scheduleRequestPasswordReset: function () {
const internal = this._internal;
if (this.sendScheduled === true) return;
this.sendScheduled = true;
this.scheduleAfterInputsHaveUpdated(() => {
this.sendScheduled = false;
UserService.instance.requestPasswordReset({
email: this._internal.email,
success: () => {
this.sendSignalOnOutput('success');
},
error: (e) => {
this.setError(e);
}
});
});
}
}
};
module.exports = {
node: RequestPasswordResetNodeDefinition,
setup: function (context, graphModel) {}
};

View File

@@ -0,0 +1,125 @@
'use strict';
const { Node, EdgeTriggeredInput } = require('@noodl/runtime');
const UserService = require('./userservice');
var ResetPasswordNodeDefinition = {
name: 'net.noodl.user.ResetPassword',
docs: 'https://docs.noodl.net/nodes/data/user/reset-password',
displayNodeName: 'Reset Password',
category: 'Cloud Services',
color: 'data',
deprecated: true, // Use cloud functions
initialize: function () {
var internal = this._internal;
},
getInspectInfo() {},
outputs: {
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Error',
getter: function () {
return this._internal.error;
}
}
},
inputs: {
reset: {
displayName: 'Do',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleResetPassword();
}
},
token: {
type: 'string',
displayName: 'Token',
group: 'General',
set: function (value) {
this._internal.token = value;
}
},
username: {
type: 'string',
displayName: 'Username',
group: 'General',
set: function (value) {
this._internal.username = value;
}
},
newPassword: {
type: 'string',
displayName: 'New Password',
group: 'General',
set: function (value) {
this._internal.newPassword = value;
}
}
},
methods: {
setError: function (err) {
this._internal.error = err;
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(
this.nodeScope.componentOwner.name,
this.id,
'user-verify-email-warning',
{
message: err,
showGlobally: true
}
);
}
},
clearWarnings() {
if (this.context.editorConnection) {
this.context.editorConnection.clearWarning(
this.nodeScope.componentOwner.name,
this.id,
'user-verify-email-warning'
);
}
},
scheduleResetPassword: function () {
const internal = this._internal;
if (this.resetPasswordScheduled === true) return;
this.resetPasswordScheduled = true;
this.scheduleAfterInputsHaveUpdated(() => {
this.resetPasswordScheduled = false;
UserService.instance.resetPassword({
token: this._internal.token,
username: this._internal.username,
newPassword: this._internal.newPassword,
success: () => {
this.sendSignalOnOutput('success');
},
error: (e) => {
this.setError(e);
}
});
});
}
}
};
module.exports = {
node: ResetPasswordNodeDefinition,
setup: function (context, graphModel) {}
};

View File

@@ -0,0 +1,107 @@
'use strict';
const { Node, EdgeTriggeredInput } = require('@noodl/runtime');
const UserService = require('./userservice');
var SendEmailVerificationNodeDefinition = {
name: 'net.noodl.user.SendEmailVerification',
docs: 'https://docs.noodl.net/nodes/data/user/send-email-verification',
displayNodeName: 'Send Email Verification',
category: 'Cloud Services',
color: 'data',
deprecated: true, // Use cloud functions
initialize: function () {
var internal = this._internal;
},
getInspectInfo() {},
outputs: {
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Error',
getter: function () {
return this._internal.error;
}
}
},
inputs: {
send: {
displayName: 'Do',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleSendEmailVerification();
}
},
email: {
type: 'string',
displayName: 'Email',
group: 'General',
set: function (value) {
this._internal.email = value;
}
}
},
methods: {
setError: function (err) {
this._internal.error = err;
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(
this.nodeScope.componentOwner.name,
this.id,
'user-send-email-verification-warning',
{
message: err,
showGlobally: true
}
);
}
},
clearWarnings() {
if (this.context.editorConnection) {
this.context.editorConnection.clearWarning(
this.nodeScope.componentOwner.name,
this.id,
'user-send-email-verification-warning'
);
}
},
scheduleSendEmailVerification: function () {
const internal = this._internal;
if (this.sendScheduled === true) return;
this.sendScheduled = true;
this.scheduleAfterInputsHaveUpdated(() => {
this.sendScheduled = false;
UserService.instance.sendEmailVerification({
email: this._internal.email,
success: () => {
this.sendSignalOnOutput('success');
},
error: (e) => {
this.setError(e);
}
});
});
}
}
};
module.exports = {
node: SendEmailVerificationNodeDefinition,
setup: function (context, graphModel) {}
};

View File

@@ -0,0 +1,199 @@
'use strict';
const { Node, EdgeTriggeredInput } = require('@noodl/runtime');
const UserService = require('./userservice');
var SignUpNodeDefinition = {
name: 'net.noodl.user.SignUp',
docs: 'https://docs.noodl.net/nodes/data/user/sign-up',
displayNodeName: 'Sign Up',
category: 'Cloud Services',
color: 'data',
initialize: function () {
var internal = this._internal;
internal.userProperties = {};
},
getInspectInfo() {},
outputs: {
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Error',
getter: function () {
return this._internal.error;
}
}
},
inputs: {
signup: {
displayName: 'Do',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleSignUp();
}
},
username: {
displayName: 'Username',
type: 'string',
group: 'General',
set: function (value) {
this._internal.username = value;
}
},
password: {
displayName: 'Password',
type: 'string',
group: 'General',
set: function (value) {
this._internal.password = value;
}
},
email: {
displayName: 'Email',
type: 'string',
group: 'General',
set: function (value) {
this._internal.email = value;
}
}
},
methods: {
setError: function (err) {
this._internal.error = err;
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'user-login-warning', {
message: err,
showGlobally: true
});
}
},
clearWarnings() {
if (this.context.editorConnection) {
this.context.editorConnection.clearWarning(this.nodeScope.componentOwner.name, this.id, 'user-login-warning');
}
},
scheduleSignUp: function () {
const internal = this._internal;
if (this.signUpScheduled === true) return;
this.signUpScheduled = true;
this.scheduleAfterInputsHaveUpdated(() => {
this.signUpScheduled = false;
UserService.instance.signUp({
username: this._internal.username,
password: this._internal.password,
email: this._internal.email,
properties: internal.userProperties,
success: () => {
this.sendSignalOnOutput('success');
},
error: (e) => {
this.setError(e);
}
});
});
},
setUserProperty: function (name, value) {
this._internal.userProperties[name] = value;
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
if (name.startsWith('prop-'))
return this.registerInput(name, {
set: this.setUserProperty.bind(this, name.substring('prop-'.length))
});
}
}
};
function updatePorts(nodeId, parameters, editorConnection, systemCollections) {
var ports = [];
if (systemCollections) {
// Fetch ports from collection keys
var c = systemCollections.find((c) => c.name === '_User');
if (c && c.schema && c.schema.properties) {
var props = c.schema.properties;
const _ignoreKeys = ['authData', 'password', 'username', 'createdAt', 'updatedAt', 'emailVerified', 'email'];
for (var key in props) {
if (_ignoreKeys.indexOf(key) !== -1) continue;
var p = props[key];
if (ports.find((_p) => _p.name === key)) continue;
if (p.type === 'Relation') {
} else {
// Other schema type ports
const _typeMap = {
String: 'string',
Boolean: 'boolean',
Number: 'number',
Date: 'date'
};
ports.push({
type: {
name: _typeMap[p.type] ? _typeMap[p.type] : '*'
},
plug: 'input',
group: 'Properties',
name: 'prop-' + key,
displayName: key
});
}
}
}
}
editorConnection.sendDynamicPorts(nodeId, ports);
}
module.exports = {
node: SignUpNodeDefinition,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel.getMetaData('systemCollections'));
node.on('parameterUpdated', function (event) {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel.getMetaData('systemCollections'));
});
graphModel.on('metadataChanged.systemCollections', function (data) {
updatePorts(node.id, node.parameters, context.editorConnection, data);
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.net.noodl.user.SignUp', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('net.noodl.user.SignUp')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -0,0 +1,327 @@
const NoodlRuntime = require('@noodl/runtime');
const EventEmitter = require('events').EventEmitter;
const guid = require('../../../guid');
const CloudStore = require('@noodl/runtime/src/api/cloudstore');
class UserService {
constructor() {
this._initCloudServices();
this.events = new EventEmitter();
this.events.setMaxListeners(100000);
// Check for current user session, and validate if it exists
const currentUser = this.getUserFromLocalStorage();
if (currentUser) {
this.current = this.getUserModel();
this.fetchCurrentUser({
success: () => {},
error: () => {
// The session is nolonger valid
delete localStorage['Parse/' + this.appId + '/currentUser'];
delete this.current;
this.events.emit('sessionLost');
}
});
}
}
getUserFromLocalStorage() {
const currentUser = localStorage['Parse/' + this.appId + '/currentUser'];
if (currentUser) {
try {
return JSON.parse(currentUser);
} catch (e) {
//do nothing
}
}
return undefined;
}
_initCloudServices() {
const cloudServices = NoodlRuntime.instance.getMetaData('cloudservices');
if (cloudServices) {
this.appId = cloudServices.appId;
this.endpoint = cloudServices.endpoint;
}
}
on() {
this.events.on.apply(this.events, arguments);
}
off() {
this.events.off.apply(this.events, arguments);
}
_makeRequest(path, options) {
if (!this.endpoint) {
if (options.error) {
options.error({ error: 'No active cloud service', status: 0 });
}
return;
}
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
var json;
try {
json = JSON.parse(xhr.response);
} catch (e) {}
if (xhr.status === 200 || xhr.status === 201) {
options.success(json || xhr.response);
} else options.error(json || { error: xhr.responseText, status: xhr.status });
}
};
xhr.open(options.method || 'GET', this.endpoint + path, true);
xhr.setRequestHeader('X-Parse-Application-Id', this.appId);
// Installation Id
var _iid = localStorage['Parse/' + this.appId + '/installationId'];
if (_iid === undefined) {
_iid = localStorage['Parse/' + this.appId + '/installationId'] = guid();
}
xhr.setRequestHeader('X-Parse-Installation-Id', _iid);
// Check for current users
if (options.sessionToken) xhr.setRequestHeader('X-Parse-Session-Token', options.sessionToken);
else {
var currentUser = this.getUserFromLocalStorage();
if (currentUser !== undefined) {
xhr.setRequestHeader('X-Parse-Session-Token', currentUser.sessionToken);
}
}
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify(options.content));
}
logIn(options) {
this._makeRequest('/login', {
method: 'POST',
content: {
username: options.username,
password: options.password,
_method: 'GET'
},
success: (response) => {
// Store current user
localStorage['Parse/' + this.appId + '/currentUser'] = JSON.stringify(response);
this.current = this.getUserModel(); // Make sure the user model is updated
options.success(response);
this.events.emit('loggedIn');
},
error: (e) => {
options.error(e.error);
}
});
}
logOut(options) {
this._makeRequest('/logout', {
method: 'POST',
content: {},
success: (response) => {
// Store current user
delete localStorage['Parse/' + this.appId + '/currentUser'];
delete this.current;
options.success();
this.events.emit('loggedOut');
},
error: (e) => {
options.error(e.error);
}
});
}
signUp(options) {
//make a shallow copy to feed through CloudStore._serializeObject, which will modify the object
const additionalUserProps = options.properties
? CloudStore._serializeObject({ ...options.properties }, '_User')
: {};
this._makeRequest('/users', {
method: 'POST',
content: Object.assign({}, additionalUserProps, {
username: options.username,
password: options.password,
email: options.email
}),
success: (response) => {
// Store current user
const _cu = Object.assign(response, { username: options.username }, options.properties);
localStorage['Parse/' + this.appId + '/currentUser'] = JSON.stringify(_cu);
this.current = this.getUserModel(); // Make sure the user model is updated
options.success(response);
this.events.emit('loggedIn');
},
error: (e) => {
options.error(e.error);
}
});
}
setUserProperties(options) {
const _cu = this.getCurrentUser();
if (_cu !== undefined) {
//make a shallow copy to feed through CloudStore._serializeObject, which will modify the object
const propsToSave = CloudStore._serializeObject({ ...options.properties }, '_User');
const _content = Object.assign({}, { email: options.email, username: options.username }, propsToSave);
delete _content.emailVerified; // Remove props you cannot set
delete _content.createdAt;
delete _content.updatedAt;
//delete _content.username;
this._makeRequest('/users/' + _cu.objectId, {
method: 'PUT',
content: _content,
success: (response) => {
// Store current user
Object.assign(_cu, _content);
localStorage['Parse/' + this.appId + '/currentUser'] = JSON.stringify(_cu);
this.current = this.getUserModel(); // Make sure the user model is updated
options.success(response);
},
error: (e) => {
options.error(e.error);
}
});
}
}
fetchCurrentUser(options) {
this._makeRequest('/users/me', {
method: 'GET',
sessionToken: options.sessionToken,
success: (response) => {
// Store current user
localStorage['Parse/' + this.appId + '/currentUser'] = JSON.stringify(response);
this.current = this.getUserModel(); // Make sure the user model is updated
this.events.emit('sessionGained');
options.success(response);
},
error: (e) => {
if (e.code === 209) {
delete localStorage['Parse/' + this.appId + '/currentUser'];
this.events.emit('sessionLost');
}
options.error(e.error);
}
});
}
verifyEmail(options) {
this._makeRequest(
'/apps/' + this.appId + '/verify_email?username=' + options.username + '&token=' + options.token,
{
method: 'GET',
success: (response) => {
if (response.indexOf('Successfully verified your email') !== -1) {
options.success();
} else if (response.indexOf('Invalid Verification Link')) {
options.error('Invalid verification token');
} else {
options.error('Failed to verify email');
}
},
error: (e) => {
options.error(e.error);
}
}
);
}
sendEmailVerification(options) {
this._makeRequest('/verificationEmailRequest', {
method: 'POST',
content: { email: options.email },
success: (response) => {
options.success();
},
error: (e) => {
options.error(e.error);
}
});
}
resetPassword(options) {
this._makeRequest('/apps/' + this.appId + '/request_password_reset', {
method: 'POST',
content: {
username: options.username,
token: options.token,
new_password: options.newPassword
},
success: (response) => {
if (
response.indexOf('Password successfully reset') !== -1 ||
response.indexOf('Successfully updated your password') !== -1
) {
options.success();
} else if (response.indexOf('Invalid Link')) {
options.error('Invalid verification token');
} else {
options.error('Failed to verify email');
}
},
error: (e) => {
options.error(e.error);
}
});
}
requestPasswordReset(options) {
this._makeRequest('/requestPasswordReset', {
method: 'POST',
content: { email: options.email },
success: (response) => {
options.success();
},
error: (e) => {
options.error(e.error);
}
});
}
getCurrentUser() {
var _cu = localStorage['Parse/' + this.appId + '/currentUser'];
if (_cu !== undefined) return JSON.parse(_cu);
}
getUserModel() {
const _cu = this.getCurrentUser();
if (_cu !== undefined) {
delete _cu.sessionToken;
delete _cu.ACL;
delete _cu.className;
delete _cu.__type;
return CloudStore._fromJSON(_cu, '_User');
}
}
}
UserService.forScope = (modelScope) => {
// On the viewer, always return main scope
return UserService.instance;
};
var _instance;
Object.defineProperty(UserService, 'instance', {
get: function () {
if (_instance === undefined) _instance = new UserService();
return _instance;
}
});
NoodlRuntime.Services.UserService = UserService;
module.exports = UserService;

View File

@@ -0,0 +1,116 @@
'use strict';
const { Node, EdgeTriggeredInput } = require('@noodl/runtime');
const UserService = require('./userservice');
var VerifyEmailNodeDefinition = {
name: 'net.noodl.user.VerifyEmail',
docs: 'https://docs.noodl.net/nodes/data/user/verify-email',
displayNodeName: 'Verify Email',
category: 'Cloud Services',
color: 'data',
deprecated: true, // Use cloud functions
initialize: function () {
var internal = this._internal;
},
getInspectInfo() {},
outputs: {
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Error',
getter: function () {
return this._internal.error;
}
}
},
inputs: {
verify: {
displayName: 'Do',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleVerifyEmail();
}
},
token: {
type: 'string',
displayName: 'Token',
group: 'General',
set: function (value) {
this._internal.token = value;
}
},
username: {
type: 'string',
displayName: 'Username',
group: 'General',
set: function (value) {
this._internal.username = value;
}
}
},
methods: {
setError: function (err) {
this._internal.error = err;
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(
this.nodeScope.componentOwner.name,
this.id,
'user-verify-email-warning',
{
message: err,
showGlobally: true
}
);
}
},
clearWarnings() {
if (this.context.editorConnection) {
this.context.editorConnection.clearWarning(
this.nodeScope.componentOwner.name,
this.id,
'user-verify-email-warning'
);
}
},
scheduleVerifyEmail: function () {
const internal = this._internal;
if (this.logOutScheduled === true) return;
this.logOutScheduled = true;
this.scheduleAfterInputsHaveUpdated(() => {
this.logOutScheduled = false;
UserService.instance.verifyEmail({
token: this._internal.token,
username: this._internal.username,
success: () => {
this.sendSignalOnOutput('success');
},
error: (e) => {
this.setError(e);
}
});
});
}
}
};
module.exports = {
node: VerifyEmailNodeDefinition,
setup: function (context, graphModel) {}
};

View File

@@ -0,0 +1,42 @@
'use strict';
const ValueChangedNode = {
name: 'Value Changed',
docs: 'https://docs.noodl.net/nodes/logic/value-changed',
category: 'Logic',
initialize: function () {
this._internal.lastValue = undefined;
this._internal.changeCount = 0;
},
getInspectInfo() {
if (this._internal.changeCount) {
return 'Triggered ' + this._internal.changeCount + (this._internal.changeCount === 1 ? ' time' : ' times');
}
return 'Not triggered';
},
inputs: {
value: {
type: '*',
displayName: 'Input',
set: function (value) {
if (this._internal.lastValue === value) {
return;
}
this._internal.changeCount++;
this.sendSignalOnOutput('valueChanged');
this._internal.lastValue = value;
}
}
},
outputs: {
valueChanged: {
type: 'signal',
displayName: 'Value Changed'
}
}
};
module.exports = {
node: ValueChangedNode
};

View File

@@ -0,0 +1,20 @@
'use strict';
const VariableBase = require('@noodl/runtime/src/nodes/std-library/variables/variablebase');
module.exports = {
node: VariableBase.createDefinition({
name: 'Color',
docs: 'https://docs.noodl.net/nodes/data/color',
startValue: '#f1f2f4',
nodeDoubleClickAction: {
focusPort: 'value'
},
type: {
name: 'color'
},
cast: function (value) {
return value;
}
})
};

View File

@@ -0,0 +1,125 @@
import { Circle } from '../../components/visual/Circle';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
const CircleNode = {
name: 'Circle',
docs: 'https://docs.noodl.net/nodes/basic-elements/circle',
connectionPanel: {
groupPriority: [
'General',
'Fill',
'Stroke',
'Dimensions',
'Style',
'Actions',
'Events',
'Mounted',
'Margin and padding',
'Pointer Events',
'Hover Events'
]
},
getReactComponent() {
return Circle;
},
noodlNodeAsProp: true,
allowChildren: false,
defaultCss: {
flexShrink: 0,
position: 'relative',
display: 'flex'
},
inputProps: {
size: {
displayName: 'Size',
default: '100',
group: 'Dimension',
type: {
name: 'number'
},
index: 10,
allowVisualStates: true
},
fillEnabled: {
group: 'Fill',
displayName: 'Fill',
default: true,
type: 'boolean',
index: 20,
allowVisualStates: true
},
fillColor: {
group: 'Fill',
displayName: 'Fill Color',
default: 'red',
type: 'color',
index: 21,
allowVisualStates: true
},
strokeEnabled: {
index: 23,
group: 'Stroke',
default: false,
displayName: 'Stroke',
type: 'boolean',
allowVisualStates: true
},
strokeWidth: {
index: 24,
group: 'Stroke',
displayName: 'Stroke Width',
default: 10,
type: {
name: 'number'
},
allowVisualStates: true
},
strokeColor: {
index: 25,
group: 'Stroke',
displayName: 'Stroke Color',
type: 'color',
default: 'black',
allowVisualStates: true
},
strokeLineCap: {
index: 26,
group: 'Stroke',
displayName: 'Line Cap',
type: {
name: 'enum',
enums: [
{ label: 'Butt', value: 'butt' },
{ label: 'Round', value: 'round' }
]
},
default: 'butt',
allowVisualStates: true
},
startAngle: {
displayName: 'Start Angle',
type: 'number',
default: 0,
group: 'Style',
index: 198,
allowVisualStates: true
},
endAngle: {
displayName: 'End Angle',
type: 'number',
default: 360,
group: 'Style',
index: 199,
allowVisualStates: true
}
}
};
NodeSharedPortDefinitions.addTransformInputs(CircleNode);
NodeSharedPortDefinitions.addMarginInputs(CircleNode);
NodeSharedPortDefinitions.addSharedVisualInputs(CircleNode);
NodeSharedPortDefinitions.addAlignInputs(CircleNode);
NodeSharedPortDefinitions.addPointerEventOutputs(CircleNode);
export default createNodeFromReactComponent(CircleNode);

View File

@@ -0,0 +1,136 @@
import { Columns } from '../../components/visual/Columns';
import { createNodeFromReactComponent } from '../../react-component-node';
const ColumnsNode = {
name: 'net.noodl.visual.columns',
displayName: 'Columns',
docs: 'https://docs.noodl.net/nodes/basic-elements/columns',
allowChildren: true,
noodlNodeAsProp: true,
connectionPanel: {
groupPriority: [
'General',
'Style',
'Actions',
'Events',
'States',
'Mounted',
'Label',
'Label Text Style',
'Hover Events',
'Pointer Events',
'Focus Events'
]
},
initialize() {
this.props.layoutString = '1 2 1';
this.props.minWidth = 0;
this.props.marginX = 16;
this.props.marginY = 16;
this.props.direction = 'row';
this.props.justifyContent = 'flex-start';
},
getReactComponent() {
return Columns;
},
inputs: {
layoutString: {
group: 'Layout Settings',
displayName: 'Layout String',
type: 'string',
default: '1 2 1',
set(value) {
this.props.layoutString = value;
if (typeof value !== 'string') {
this.context.editorConnection.sendWarning(
this.nodeScope.componentOwner.name,
this.id,
'layout-type-warning',
{
message: 'Layout String needs to be a string.'
}
);
} else {
this.context.editorConnection.clearWarning(
this.nodeScope.componentOwner.name,
this.id,
'layout-type-warning'
);
}
this.forceUpdate();
}
}
},
inputProps: {
marginX: {
group: 'Layout Settings',
displayName: 'Horizontal Gap',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: 16
},
marginY: {
group: 'Layout Settings',
displayName: 'Vertical Gap',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: 16
},
minWidth: {
group: 'Constraints',
displayName: 'Min Column Width',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: 0
},
direction: {
group: 'Layout Settings',
displayName: 'Layout Direction',
type: {
name: 'enum',
enums: [
{
label: 'Horizontal',
value: 'row'
},
{
label: 'Vertical',
value: 'column'
}
]
},
default: 'row'
},
justifyContent: {
group: 'Justify Content',
displayName: 'Justify Content',
type: {
name: 'enum',
enums: [
{ label: 'Start', value: 'flex-start' },
{ label: 'End', value: 'flex-end' },
{ label: 'Center', value: 'center' }
],
alignComp: 'align-items'
},
default: 'flex-start'
}
}
};
export default createNodeFromReactComponent(ColumnsNode);

View File

@@ -0,0 +1,86 @@
const _refCount = new Map(); //node id to ref count
const CSSDefinition = {
name: 'CSS Definition',
docs: 'https://docs.noodl.net/nodes/utilities/css-definition',
category: 'CustomCode',
color: 'javascript',
nodeDoubleClickAction: {
focusPort: 'Style'
},
initialize: function () {
var internal = this._internal;
internal.style = '';
const styleId = this.getStyleRefId();
this.addDeleteListener(() => {
_refCount.set(styleId, _refCount.get(styleId) - 1);
if (_refCount.get(styleId) === 0) {
this.removeStyleDeclaration();
_refCount.delete(styleId);
}
});
if (!_refCount.has(styleId)) {
_refCount.set(styleId, 0);
}
_refCount.set(styleId, _refCount.get(styleId) + 1);
},
inputs: {
style: {
index: 4005,
type: { name: 'string', allowEditOnly: true, codeeditor: 'css' },
displayName: 'Style',
group: 'Content',
default: '',
set: function (value) {
this.updateStyle(value);
}
}
},
outputs: {},
methods: {
getStyleRefId: function () {
return 'style_' + this.id;
},
removeStyleDeclaration: function () {
// Add SSR Support
if (typeof document === 'undefined') return;
var styleRefId = this.getStyleRefId();
var styleObj = document.getElementById(styleRefId);
if (styleObj !== null) {
styleObj.parentNode.removeChild(styleObj);
}
},
updateStyle: function (style) {
// Add SSR Support
if (typeof document === 'undefined') return;
var internal = this._internal;
var styleRefId = this.getStyleRefId();
internal.style = style;
if (style !== null) {
var styleObj = document.getElementById(styleRefId);
if (styleObj === null) {
styleObj = document.createElement('style');
styleObj.id = styleRefId;
styleObj.type = 'text/css';
document.head.appendChild(styleObj);
}
styleObj.innerHTML = '\n' + style + '\n';
} else {
this.removeStyleDeclaration();
}
}
}
};
module.exports = {
node: CSSDefinition
};

View File

@@ -0,0 +1,169 @@
import { Drag } from '../../components/visual/Drag';
import { createNodeFromReactComponent } from '../../react-component-node';
const DragNode = {
name: 'Drag',
docs: 'https://docs.noodl.net/nodes/utilities/drag',
allowChildren: true,
noodlNodeAsProp: true,
getReactComponent() {
return Drag;
},
initialize() {
this._internal.snapPositionX = 0;
this._internal.snapPositionY = 0;
this._internal.snapDurationX = 300;
this._internal.snapDurationY = 300;
},
inputs: {
'snapToPositionX.do': {
group: 'Snap To Position X',
displayName: 'Do',
editorName: 'Do|Snap To Position X',
type: 'signal',
valueChangedToTrue() {
this.scheduleAfterInputsHaveUpdated(() => {
const { snapPositionX, snapDurationX } = this._internal;
this.innerReactComponentRef && this.innerReactComponentRef.snapToPositionX(snapPositionX, snapDurationX);
});
}
},
'snapToPositionX.value': {
default: 0,
group: 'Snap To Position X',
displayName: 'Value',
editorName: 'Value|Snap To Position X',
type: 'number',
set(value) {
this._internal.snapPositionX = value;
}
},
'snapToPositionX.duration': {
default: 300,
group: 'Snap To Position X',
displayName: 'Duration',
editorName: 'Duration|Snap To Position X',
type: 'number',
set(value) {
this._internal.snapDurationX = value;
}
},
'snapToPositionY.do': {
group: 'Snap To Position Y',
displayName: 'Do',
editorName: 'Do|Snap To Position Y',
type: 'signal',
valueChangedToTrue() {
this.scheduleAfterInputsHaveUpdated(() => {
const { snapPositionY, snapDurationY } = this._internal;
this.innerReactComponentRef && this.innerReactComponentRef.snapToPositionY(snapPositionY, snapDurationY);
});
}
},
'snapToPositionY.value': {
default: 0,
group: 'Snap To Position Y',
displayName: 'Value',
editorName: 'Value|Snap To Position Y',
type: 'number',
set(value) {
this._internal.snapPositionY = value;
}
},
'snapToPositionY.duration': {
default: 300,
group: 'Snap To Position Y',
displayName: 'Duration',
editorName: 'Duration|Snap To Position Y',
type: 'number',
set(value) {
this._internal.snapDurationY = value;
}
}
},
inputProps: {
enabled: {
group: 'Drag',
displayName: 'Enabled',
type: 'boolean',
default: true
},
axis: {
group: 'Drag',
displayName: 'Axis',
type: {
name: 'enum',
enums: [
{ label: 'X', value: 'x' },
{ label: 'Y', value: 'y' },
{ label: 'Both', value: 'both' }
]
},
default: 'x'
},
useParentBounds: {
group: 'Drag',
displayName: 'Constrain to parent',
type: 'boolean',
default: true
},
inputPositionX: {
displayName: 'Start Drag X',
type: {
name: 'number'
}
},
inputPositionY: {
displayName: 'Start Drag Y',
type: {
name: 'number'
}
},
scale: {
displayName: 'Scale',
default: 1.0,
type: {
name: 'number'
}
}
},
outputProps: {
onStart: {
group: 'Signals',
type: 'signal',
displayName: 'Drag Started'
},
onStop: {
group: 'Signals',
type: 'signal',
displayName: 'Drag Ended'
},
onDrag: {
group: 'Signals',
type: 'signal',
displayName: 'Drag Moved'
},
positionX: {
group: 'Values',
displayName: 'Drag X',
type: 'number'
},
positionY: {
group: 'Values',
displayName: 'Drag Y',
type: 'number'
},
deltaX: {
group: 'Values',
displayName: 'Delta X',
type: 'number'
},
deltaY: {
group: 'Values',
displayName: 'Delta Y',
type: 'number'
}
}
};
export default createNodeFromReactComponent(DragNode);

View File

@@ -0,0 +1,443 @@
import { Group } from '../../components/visual/Group';
import { flexDirectionValues } from '../../constants/flex';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
import { createTooltip } from '../../tooltips';
const GroupNode = {
name: 'Group',
docs: 'https://docs.noodl.net/nodes/basic-elements/group',
connectionPanel: {
groupPriority: ['General', 'Style', 'Events', 'Mounted', 'Hover Events', 'Pointer Events', 'Focus', 'Scroll']
},
initialize() {
this._internal = {
scrollElementDuration: 500,
scrollIndexDuration: 500,
scrollIndex: 0
};
this.props.layout = 'column';
},
getReactComponent() {
return Group;
},
noodlNodeAsProp: true,
visualStates: [
{ name: 'neutral', label: 'Neutral' },
{ name: 'hover', label: 'Hover' }
],
defaultCss: {
display: 'flex',
position: 'relative',
flexDirection: 'column'
},
inputs: {
flexDirection: {
//don't rename for backwards compat
index: 12,
displayName: 'Layout',
group: 'Layout',
type: {
name: 'enum',
enums: [
{ label: 'None', value: 'none' },
{ label: 'Vertical', value: 'column' },
{ label: 'Horizontal', value: 'row' }
]
},
default: 'column',
set(value) {
this.props.layout = value;
if (value !== 'none') {
this.setStyle({ flexDirection: value });
} else {
this.removeStyle(['flexDirection']);
}
if (this.context.editorConnection) {
// Send warning if the value is wrong
if (value !== 'none' && !flexDirectionValues.includes(value)) {
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'layout-warning', {
message: 'Invalid Layout value has to be a valid flex-direction value.'
});
} else {
this.context.editorConnection.clearWarning(this.nodeScope.componentOwner.name, this.id, 'layout-warning');
}
}
this.forceUpdate();
}
},
'scrollToIndex.do': {
displayName: 'Scroll To Index - Do',
group: 'Scroll To Index',
type: 'signal',
index: 505,
valueChangedToTrue() {
this.scheduleAfterInputsHaveUpdated(() => {
if (!this.innerReactComponentRef) return;
const childIndex = this._internal.scrollIndex;
const duration = this._internal.scrollIndexDuration;
this.innerReactComponentRef.scrollToIndex(childIndex, duration);
});
}
},
'scrollToElement.do': {
displayName: 'Scroll To Element - Do',
group: 'Scroll To Element',
type: 'signal',
index: 500,
valueChangedToTrue() {
if (!this.innerReactComponentRef) return;
this.scheduleAfterInputsHaveUpdated(() => {
const element = this._internal.scrollElement;
const duration = this._internal.scrollElementDuration;
this.innerReactComponentRef.scrollToElement(element, duration);
});
}
},
'scrollToElement.element': {
displayName: 'Scroll To Element - Element',
group: 'Scroll To Element',
type: 'reference',
index: 501,
set(value) {
this._internal.scrollElement = value;
}
},
'scrollToElement.duration': {
displayName: 'Scroll To Element - Duration',
group: 'Scroll To Element',
type: 'number',
default: 500,
index: 502,
set(value) {
this._internal.scrollElementDuration = value;
}
},
'scrollToIndex.index': {
displayName: 'Scroll To Index - Index',
group: 'Scroll To Index',
type: 'number',
default: 0,
index: 506,
set(value) {
this._internal.scrollIndex = value;
}
},
'scrollToIndex.duration': {
displayName: 'Scroll To Index - Duration',
group: 'Scroll To Index',
type: 'number',
default: 500,
index: 507,
set(value) {
this._internal.scrollIndexDuration = value;
}
},
focus: {
displayName: 'Focus',
type: 'signal',
group: 'Focus',
valueChangedToTrue() {
this.context.setNodeFocused(this, true);
}
}
},
inputProps: {
clip: {
index: 19,
displayName: 'Clip Content',
type: 'boolean',
group: 'Layout',
default: false
},
scrollEnabled: {
index: 54,
group: 'Scroll',
displayName: 'Enable Scroll',
type: 'boolean',
default: false
},
scrollSnapEnabled: {
index: 55,
displayName: 'Snap',
group: 'Scroll',
type: 'boolean',
default: false
},
scrollSnapToEveryItem: {
index: 56,
displayName: 'Snap To Every Item',
group: 'Scroll',
type: 'boolean',
default: false
},
showScrollbar: {
index: 57,
displayName: 'Show Scrollbar',
group: 'Scroll',
type: 'boolean',
default: false
},
scrollBounceEnabled: {
index: 58,
displayName: 'Bounce at boundaries',
group: 'Scroll',
type: 'boolean',
default: true
},
nativeScroll: {
index: 60,
group: 'Scroll',
displayName: 'Native platform scroll',
type: 'boolean',
default: true
},
as: {
index: 100000,
group: 'Advanced HTML',
displayName: 'Tag',
type: {
name: 'enum',
enums: [
{ label: '<div>', value: 'div' },
{ label: '<section>', value: 'section' },
{ label: '<article>', value: 'article' },
{ label: '<aside>', value: 'aside' },
{ label: '<nav>', value: 'nav' },
{ label: '<header>', value: 'header' },
{ label: '<footer>', value: 'footer' },
{ label: '<main>', value: 'main' },
{ label: '<span>', value: 'span' }
]
},
default: 'div'
}
},
inputCss: {
alignItems: {
index: 13,
group: 'Align and justify content',
displayName: 'Align Items',
type: {
name: 'enum',
enums: [
{ label: 'Start', value: 'flex-start' },
{ label: 'End', value: 'flex-end' },
{ label: 'Center', value: 'center' }
],
alignComp: 'align-items'
},
default: 'flex-start'
},
justifyContent: {
index: 14,
group: 'Align and justify content',
displayName: 'Justify Content',
type: {
name: 'enum',
enums: [
{ label: 'Start', value: 'flex-start' },
{ label: 'End', value: 'flex-end' },
{ label: 'Center', value: 'center' },
{ label: 'Space Between', value: 'space-between' },
{ label: 'Space Around', value: 'space-around' },
{ label: 'Space Evenly', value: 'space-evenly' }
],
alignComp: 'justify-content'
},
default: 'flex-start',
applyDefault: false
},
flexWrap: {
index: 15,
displayName: 'Multi Line Wrap',
group: 'Layout',
type: {
name: 'enum',
enums: [
{ label: 'Off', value: 'nowrap' },
{ label: 'On', value: 'wrap' },
{ label: 'On Reverse', value: 'wrap-reverse' }
]
},
default: 'nowrap',
onChange(value) {
this.props.flexWrap = value;
this.forceUpdate(); //scroll direction needs to be recomputed
},
applyDefault: false
},
alignContent: {
index: 16,
group: 'Layout',
displayName: 'Align Content',
type: {
name: 'enum',
enums: [
{ label: 'Start', value: 'flex-start' },
{ label: 'End', value: 'flex-end' },
{ label: 'Center', value: 'center' },
{ label: 'Space Between', value: 'space-between' },
{ label: 'Space Around', value: 'space-around' },
{ label: 'Space Evenly', value: 'space-evenly' }
],
alignComp: 'align-content'
}
// default: 'flex-start'
},
rowGap: {
index: 17,
displayName: 'Vertical Gap',
group: 'Layout',
type: {
name: 'number',
units: ['px', '%', 'em'],
defaultUnit: 'px'
},
default: 0,
applyDefault: false
},
columnGap: {
index: 18,
displayName: 'Horizontal Gap',
group: 'Layout',
type: {
name: 'number',
units: ['px', '%', 'em'],
defaultUnit: 'px'
},
default: 0,
applyDefault: false
},
backgroundColor: {
index: 201,
displayName: 'Background Color',
group: 'Style',
type: 'color',
default: 'transparent',
applyDefault: false,
allowVisualStates: true
}
},
outputProps: {
onScrollPositionChanged: {
displayName: 'Scroll Position',
type: 'number',
group: 'Scroll'
},
onScrollStart: {
displayName: 'Scroll Start',
type: 'signal',
group: 'Scroll'
},
onScrollEnd: {
displayName: 'Scroll End',
type: 'signal',
group: 'Scroll'
}
},
outputs: {
focused: {
displayName: 'Focused',
type: 'signal',
group: 'Focus'
},
focusLost: {
displayName: 'Focus Lost',
type: 'signal',
group: 'Focus'
}
},
dynamicports: [
{
condition: 'flexDirection != none',
inputs: ['scrollEnabled']
},
{
condition: 'flexDirection != none AND scrollEnabled = true',
inputs: ['nativeScroll']
},
{
condition: 'flexDirection != none AND scrollEnabled = true AND nativeScroll = false',
inputs: [
'scrollBounceEnabled',
'scrollSnapEnabled',
'showScrollbar',
'scrollToElement.do',
'scrollToElement.element',
'scrollToElement.duration',
'scrollToIndex.do',
'scrollToIndex.index',
'scrollToIndex.duration'
]
},
{
condition: 'flexDirection != none AND scrollEnabled = true AND scrollSnapEnabled = true',
inputs: ['scrollSnapToEveryItem']
},
{
condition: 'flexDirection != none',
inputs: ['flexWrap']
},
{
condition: 'flexWrap = wrap OR flexWrap = wrap-reverse',
inputs: ['alignContent']
},
{
condition: 'flexDirection = row OR flexWrap = wrap OR flexWrap = wrap-reverse',
inputs: ['columnGap']
},
{
condition: 'flexDirection = column OR flexWrap = wrap OR flexWrap = wrap-reverse',
inputs: ['rowGap']
}
],
methods: {
_focus() {
this.sendSignalOnOutput('focused');
},
_blur() {
this.sendSignalOnOutput('focusLost');
}
}
};
NodeSharedPortDefinitions.addDimensions(GroupNode);
NodeSharedPortDefinitions.addTransformInputs(GroupNode);
NodeSharedPortDefinitions.addSharedVisualInputs(GroupNode);
NodeSharedPortDefinitions.addPaddingInputs(GroupNode);
NodeSharedPortDefinitions.addMarginInputs(GroupNode);
NodeSharedPortDefinitions.addAlignInputs(GroupNode);
NodeSharedPortDefinitions.addPointerEventOutputs(GroupNode);
NodeSharedPortDefinitions.addBorderInputs(GroupNode);
NodeSharedPortDefinitions.addShadowInputs(GroupNode);
function defineTooltips(node) {
node.inputProps.clip.tooltip = createTooltip({
title: 'Clip content',
body: 'Controls if elements that are too big to fit will be clipped',
images: [
{ src: 'clip-enabled.svg', label: 'Enabled' },
{ src: 'clip-disabled.svg', label: 'Disabled' }
]
});
node.inputCss.flexWrap.tooltip = createTooltip({
title: 'Multiline wrap',
body: "Elements will wrap to the next line when there's not enough space",
images: [
{ src: 'multiline-h.svg', body: 'Using a horizontal layout' },
{ src: 'multiline-v.svg', body: 'Using a vertical layout' }
]
});
}
// eslint-disable-next-line no-undef
if (!Noodl.runDeployed) {
defineTooltips(GroupNode);
}
export default createNodeFromReactComponent(GroupNode);

View File

@@ -0,0 +1,45 @@
import { Icon } from '../../components/visual/Icon';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
const IconNode = {
name: 'net.noodl.visual.icon',
displayName: 'Icon',
docs: 'https://docs.noodl.net/nodes/basic-elements/icon',
allowChildren: false,
noodlNodeAsProp: true,
connectionPanel: {
groupPriority: [
'General',
'Style',
'Actions',
'Events',
'States',
'Mounted',
'Hover Events',
'Pointer Events',
'Focus Events'
]
},
getReactComponent() {
return Icon;
}
};
NodeSharedPortDefinitions.addAlignInputs(IconNode);
NodeSharedPortDefinitions.addTransformInputs(IconNode);
NodeSharedPortDefinitions.addPaddingInputs(IconNode, {
defaults: {
paddingTop: 5,
paddingRight: 5,
paddingBottom: 5,
paddingLeft: 5
}
});
NodeSharedPortDefinitions.addMarginInputs(IconNode);
NodeSharedPortDefinitions.addIconInputs(IconNode, {
hideEnableIconInput: true,
defaults: { useIcon: true }
});
NodeSharedPortDefinitions.addSharedVisualInputs(IconNode);
export default createNodeFromReactComponent(IconNode);

View File

@@ -0,0 +1,133 @@
import { getAbsoluteUrl } from '@noodl/runtime/src/utils';
import { Image } from '../../components/visual/Image';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
const ImageNode = {
name: 'Image',
docs: 'https://docs.noodl.net/nodes/basic-elements/image',
noodlNodeAsProp: true,
visualStates: [
{ name: 'neutral', label: 'Neutral' },
{ name: 'hover', label: 'Hover' }
],
connectionPanel: {
groupPriority: [
'General',
'Image',
'Style',
'Actions',
'Events',
'Mounted',
'Pointer Events',
'Hover Events',
'Dimensions',
'Margin and padding'
]
},
initialize() {
this.props.default = '';
},
getReactComponent() {
return Image;
},
getInspectInfo() {
if (this.props.dom.srcSet) {
return this.props.dom.srcSet;
} else if (this.props.dom.src) {
const src = this.props.dom.src.toString();
return [
{ type: 'text', value: src },
{ type: 'image', value: src }
];
}
},
allowChildren: false,
defaultCss: {
display: 'block',
flexShrink: 0
},
inputCss: {
objectFit: {
displayName: 'Image Fit',
group: 'Dimensions',
type: {
name: 'enum',
enums: [
{ label: 'Fill', value: 'fill' },
{ label: 'Contain', value: 'contain' },
{ label: 'Cover', value: 'cover' },
{ label: 'None', value: 'none' },
{ label: 'Scale Down', value: 'scale-down' }
]
},
default: 'contain',
allowVisualStates: true
}
},
dynamicports: [
{
condition: 'sizeMode = explicit',
inputs: ['objectFit']
}
],
inputs: {
src: {
displayName: 'Source',
group: 'Image',
propPath: 'dom',
type: {
name: 'image'
},
index: 30,
allowVisualStates: true,
set(url) {
this.props.dom.src = getAbsoluteUrl(url);
this.forceUpdate();
}
}
},
inputProps: {
srcSet: {
displayName: 'Source Set',
group: 'Image',
propPath: 'dom',
type: {
name: 'string'
},
index: 31,
allowVisualStates: true
},
alt: {
displayName: 'Alternate text',
tooltip: "The alt text is used by screen readers, or if the image can't be downloaded or displayed",
type: 'string',
propPath: 'dom',
index: 1000,
default: ''
}
},
outputProps: {
onLoad: {
displayName: 'On Load',
propPath: 'dom',
type: 'signal',
group: 'Events'
}
}
};
NodeSharedPortDefinitions.addDimensions(ImageNode, {
defaultSizeMode: 'contentSize',
contentLabel: 'Image'
});
NodeSharedPortDefinitions.addTransformInputs(ImageNode);
NodeSharedPortDefinitions.addMarginInputs(ImageNode);
NodeSharedPortDefinitions.addSharedVisualInputs(ImageNode);
NodeSharedPortDefinitions.addAlignInputs(ImageNode);
NodeSharedPortDefinitions.addPointerEventOutputs(ImageNode);
NodeSharedPortDefinitions.addBorderInputs(ImageNode);
NodeSharedPortDefinitions.addShadowInputs(ImageNode);
export default createNodeFromReactComponent(ImageNode);

View File

@@ -0,0 +1,169 @@
import { Text } from '../../components/visual/Text';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
import { createTooltip } from '../../tooltips';
const TextNode = {
name: 'Text',
docs: 'https://docs.noodl.net/nodes/basic-elements/text',
visualStates: [
{ name: 'neutral', label: 'Neutral' },
{ name: 'hover', label: 'Hover' }
],
allowChildren: false,
noodlNodeAsProp: true,
usePortAsLabel: 'text',
portLabelTruncationMode: 'length',
connectionPanel: {
groupPriority: ['General', 'Text', 'Text Style', 'Style', 'Events', 'Mounted', 'Hover Events', 'Pointer Events']
},
nodeDoubleClickAction: {
focusPort: 'text'
},
getReactComponent() {
return Text;
},
getInspectInfo() {
return this.props.text;
},
defaultCss: {
position: 'relative',
display: 'flex'
},
inputProps: {
text: {
index: 19,
group: 'Text',
displayName: 'Text',
default: 'Text',
type: {
name: 'string',
multiline: true
}
},
as: {
index: 100000,
group: 'Advanced HTML',
displayName: 'Tag',
type: {
name: 'enum',
enums: [
{ label: '<div>', value: 'div' },
{ label: '<h1>', value: 'h1' },
{ label: '<h2>', value: 'h2' },
{ label: '<h3>', value: 'h3' },
{ label: '<h4>', value: 'h4' },
{ label: '<h5>', value: 'h5' },
{ label: '<h6>', value: 'h6' },
{ label: '<p>', value: 'p' },
{ label: '<span>', value: 'span' }
// { label: '<a>', value: 'a' },
]
},
default: 'div'
}
},
inputCss: {
wordBreak: {
index: 27,
group: 'Text',
displayName: 'Word Break',
applyDefault: false,
type: {
name: 'enum',
enums: [
{ label: 'Normal', value: 'normal' },
{ label: 'Break All', value: 'break-all' }
]
},
default: 'normal'
}
},
inputs: {
textAlignX: {
group: 'Text Alignment',
index: 13,
displayName: 'Text Horizontal Align',
type: {
name: 'enum',
enums: [
{ label: 'left', value: 'left' },
{ label: 'center', value: 'center' },
{ label: 'right', value: 'right' }
],
alignComp: 'justify'
},
default: 'left',
set(value) {
switch (value) {
case 'left':
this.setStyle({ textAlign: 'left', justifyContent: 'flex-start' });
break;
case 'center':
this.setStyle({ textAlign: 'center', justifyContent: 'center' });
break;
case 'right':
this.setStyle({ textAlign: 'right', justifyContent: 'flex-end' });
break;
}
}
},
textAlignY: {
group: 'Text Alignment',
index: 14,
displayName: 'Text Vertical Align',
type: {
name: 'enum',
enums: [
{ label: 'Top', value: 'top' },
{ label: 'Center', value: 'center' },
{ label: 'Bottom', value: 'bottom' }
],
alignComp: 'vertical'
},
default: 'top',
set(value) {
switch (value) {
case 'top':
this.setStyle({ alignItems: 'flex-start' });
break;
case 'center':
this.setStyle({ alignItems: 'center' });
break;
case 'bottom':
this.setStyle({ alignItems: 'flex-end' });
break;
}
}
}
}
};
NodeSharedPortDefinitions.addDimensions(TextNode, {
defaultSizeMode: 'contentHeight',
contentLabel: 'Text'
});
NodeSharedPortDefinitions.addTextStyleInputs(TextNode);
NodeSharedPortDefinitions.addAlignInputs(TextNode);
NodeSharedPortDefinitions.addTransformInputs(TextNode);
NodeSharedPortDefinitions.addMarginInputs(TextNode);
NodeSharedPortDefinitions.addSharedVisualInputs(TextNode);
NodeSharedPortDefinitions.addPointerEventOutputs(TextNode);
function defineTooltips(node) {
node.inputCss.wordBreak.tooltip = createTooltip({
title: 'Word break',
body: [
'Control where line breaks are allowed',
'- Normal: Break on spaces and other whitespace characters',
'- Break All: Allow line breaks between any two characters, including inside words'
]
});
}
// eslint-disable-next-line no-undef
if (!Noodl.runDeployed) {
defineTooltips(TextNode);
}
export default createNodeFromReactComponent(TextNode);

View File

@@ -0,0 +1,237 @@
import { getAbsoluteUrl } from '@noodl/runtime/src/utils';
import { Video } from '../../components/visual/Video';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
const VideoNode = {
name: 'Video',
docs: 'https://docs.noodl.net/nodes/basic-elements/video',
connectionPanel: {
groupPriority: [
'General',
'Video',
'Video Actions',
'Style',
'Actions',
'Events',
'Mounted',
'Playback',
'Pointer Events',
'Hover Events',
'Dimensions',
'Margin and padding'
]
},
getReactComponent() {
return Video;
},
allowChildren: false,
noodlNodeAsProp: true,
defaultCss: {
display: 'block'
},
inputs: {
srcObject: {
displayName: 'Source Object',
group: 'Video',
type: 'mediastream',
default: null,
set(value) {
this.innerReactComponentRef && this.innerReactComponentRef.setSourceObject(value);
}
},
play: {
type: 'signal',
group: 'Video Actions',
displayName: 'Play',
tooltip: {
standard: 'Play the video'
},
valueChangedToTrue() {
this.innerReactComponentRef && this.innerReactComponentRef.play();
}
},
restart: {
type: 'signal',
group: 'Video Actions',
displayName: 'Restart',
tooltip: {
standard: 'Restart the video from the beginning'
},
valueChangedToTrue() {
this.innerReactComponentRef && this.innerReactComponentRef.restart();
}
},
pause: {
type: 'boolean',
group: 'Video Actions',
displayName: 'Pause',
valueChangedToTrue() {
this.innerReactComponentRef && this.innerReactComponentRef.pause();
}
},
reset: {
type: 'boolean',
group: 'Video Actions',
displayName: 'Reset',
valueChangedToTrue() {
this.innerReactComponentRef && this.innerReactComponentRef.reset();
}
},
src: {
displayName: 'Source',
group: 'Video',
type: 'string',
set(src) {
this.props.dom.src = getAbsoluteUrl(src);
this.forceUpdate();
}
},
poster: {
displayName: 'Poster',
group: 'Video',
type: 'image',
set(src) {
this.props.dom.poster = getAbsoluteUrl(src);
this.forceUpdate();
}
}
},
inputProps: {
autoplay: {
displayName: 'Autoplay',
propPath: 'dom',
group: 'Video',
type: 'boolean'
},
controls: {
displayName: 'Controls',
propPath: 'dom',
group: 'Video',
type: 'boolean'
},
volume: {
displayName: 'Volume',
propPath: 'dom',
group: 'Video',
type: 'number',
default: 1
},
muted: {
displayName: 'Muted',
propPath: 'dom',
group: 'Video',
type: 'boolean'
},
loop: {
displayName: 'Loop',
propPath: 'dom',
group: 'Video',
type: 'boolean'
},
objectPositionX: {
displayName: 'Video Position X',
group: 'Video Layout',
type: {
name: 'number',
units: ['%', 'px'],
defaultUnit: '%'
},
default: 50
},
objectPositionY: {
displayName: 'Video Position Y',
group: 'Video Layout',
type: {
name: 'number',
units: ['%', 'px'],
defaultUnit: '%'
},
default: 50
}
},
inputCss: {
objectFit: {
displayName: 'Object Fit',
group: 'Video Layout',
type: {
name: 'enum',
enums: [
{
label: 'Contain',
value: 'contain'
},
{
label: 'Cover',
value: 'cover'
},
{
label: 'Fill',
value: 'fill'
},
{
label: 'None',
value: 'none'
}
]
},
default: 'contain'
}
},
outputProps: {
onCanPlay: {
type: 'signal',
group: 'Events',
displayName: 'On Can Play'
},
onTimeUpdate: {
group: 'Playback',
displayName: 'Playback Position',
type: 'number',
propPath: 'dom',
getValue(event) {
return event.target.currentTime;
}
},
onPlay: {
group: 'Events',
displayName: 'On Play',
type: 'signal',
propPath: 'dom'
},
onPause: {
group: 'Events',
displayName: 'On Pause',
type: 'signal',
propPath: 'dom'
},
onVideoElementCreated: {
type: 'domelement',
displayName: 'DOM Element'
},
videoWidth: {
group: 'Playback',
type: 'number',
displayName: 'Video Width'
},
videoHeight: {
group: 'Playback',
type: 'number',
displayName: 'Video Height'
}
}
};
NodeSharedPortDefinitions.addDimensions(VideoNode, {
defaultSizeMode: 'contentSize',
contentLabel: 'Video'
});
NodeSharedPortDefinitions.addTransformInputs(VideoNode);
NodeSharedPortDefinitions.addMarginInputs(VideoNode);
NodeSharedPortDefinitions.addSharedVisualInputs(VideoNode);
NodeSharedPortDefinitions.addAlignInputs(VideoNode);
NodeSharedPortDefinitions.addPointerEventOutputs(VideoNode);
NodeSharedPortDefinitions.addBorderInputs(VideoNode);
export default createNodeFromReactComponent(VideoNode);