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,21 @@
MIT License
Copyright (c) 2024 Future Platforms AB
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,3 @@
import NoodlViewerReact from './noodl-viewer-react';
window.Noodl._viewerReact = NoodlViewerReact;

View File

@@ -0,0 +1,4 @@
import NoodlViewerReact, { ssrSetupRuntime } from './noodl-viewer-react';
export { NoodlViewerReact, ssrSetupRuntime };
globalThis.NoodlSSR = { createElement: NoodlViewerReact.createElement, ssrSetupRuntime };

View File

@@ -0,0 +1,4 @@
import NoodlViewerReact from './noodl-viewer-react';
window.ELECTRON_DISABLE_SECURITY_WARNINGS = true;
window.Noodl._viewerReact = NoodlViewerReact;

View File

@@ -0,0 +1,5 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

View File

@@ -0,0 +1,66 @@
import React from 'react';
import ReactDOM from 'react-dom';
import NoodlRuntime from '@noodl/runtime';
import registerPolyfills from './src/polyfills';
import Viewer, { ssrSetupRuntime } from './src/viewer.jsx';
registerPolyfills();
function createArgs() {
// Support SSR
if (typeof window === 'undefined') {
return {
type: 'browser',
platform: {
requestUpdate: (callback) => setImmediate(callback),
getCurrentTime: () => 0,
objectToString: (o) => JSON.stringify(o, null, 2)
},
componentFilter: (c) => !c.name.startsWith('/#__cloud__/')
};
}
return {
type: 'browser',
platform: {
requestUpdate: (callback) => window.requestAnimationFrame(callback),
getCurrentTime: () => window.performance.now(),
objectToString: (o) => JSON.stringify(o, null, 2)
},
componentFilter: (c) => !c.name.startsWith('/#__cloud__/')
};
}
export { ssrSetupRuntime };
export default {
render(element, noodlModules, { isLocal = false }) {
const runtimeArgs = createArgs();
if (isLocal) {
runtimeArgs.platform.isRunningLocally = () => true;
}
const noodlRuntime = new NoodlRuntime(runtimeArgs);
ReactDOM.render(React.createElement(Viewer, { noodlRuntime, noodlModules }, null), element);
},
renderDeployed(element, noodlModules, projectData) {
// React SSR adds a 'data-reactroot' attribute on the root element to be able to hydrate the app.
if (element.children.length > 0 && !!element.children[0].hasAttribute('data-reactroot')) {
ReactDOM.hydrate(this.createElement(noodlModules, projectData), element);
} else {
ReactDOM.render(this.createElement(noodlModules, projectData), element);
}
},
/** This can be called for server side rendering too. */
createElement(noodlModules, projectData) {
const noodlRuntime = new NoodlRuntime({
...createArgs(),
runDeployed: true
});
return React.createElement(Viewer, { noodlRuntime, noodlModules, projectData }, null);
}
};

View File

@@ -0,0 +1,49 @@
{
"name": "@noodl/noodl-viewer-react",
"version": "2.7.0",
"main": "noodl-viewer-react",
"scripts": {
"start": "webpack --config webpack-configs/webpack.dev.js",
"build": "webpack --config webpack-configs/webpack.prod.js",
"test": "jest"
},
"dependencies": {
"@better-scroll/core": "^2.0.0-beta.6",
"@better-scroll/mouse-wheel": "^2.0.0-beta.6",
"@better-scroll/nested-scroll": "^2.4.2",
"@better-scroll/scroll-bar": "^2.0.0-beta.6",
"@noodl/runtime": "file:../noodl-runtime",
"bezier-easing": "^1.1.1",
"buffer": "^6.0.3",
"core-js": "^3.12.1",
"events": "^3.3.0",
"lodash.difference": "^4.5.0",
"lodash.isequal": "^4.5.0",
"react-draggable": "^4.4.5",
"react-rnd": "^10.3.7",
"stream-browserify": "^3.0.0",
"timers-browserify": "^2.0.12",
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@babel/core": "^7.14.0",
"@babel/plugin-proposal-object-rest-spread": "^7.18.9",
"@babel/preset-env": "^7.14.1",
"@babel/preset-react": "^7.18.6",
"@types/jest": "^27.5.1",
"babel-loader": "^8.2.2",
"clean-webpack-plugin": "^1.0.0",
"copy-webpack-plugin": "^4.6.0",
"css-loader": "^5.0.0",
"jest": "^28.1.0",
"style-loader": "^2.0.0",
"ts-jest": "^28.0.3",
"ts-loader": "^9.4.3",
"typescript": "^5.1.3",
"webpack": "^5.74.0",
"webpack-bundle-analyzer": "^4.4.2",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^3.11.2",
"webpack-merge": "^5.7.3"
}
}

View File

@@ -0,0 +1,73 @@
const NoodlRuntime = require('@noodl/runtime');
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));
}
const cloudfunctions = {
async run(functionName, params) {
return new Promise((resolve, reject) => {
const cloudServices = NoodlRuntime.instance.getMetaData('cloudservices');
if (cloudServices === undefined) {
reject('No cloud services defined in this project.');
return;
}
const appId = cloudServices.appId;
const endpoint = NoodlRuntime.instance.editorConnection.isRunningLocally()
? `http://${window.location.hostname}:8577`
: cloudServices.endpoint;
_makeRequest('/functions/' + encodeURIComponent(functionName), {
appId,
endpoint,
content: params,
method: 'POST',
success: (res) => {
resolve(res ? res.result : undefined);
},
error: (err) => {
reject(err);
}
});
});
}
};
module.exports = cloudfunctions;

View File

@@ -0,0 +1,23 @@
const CloudStore = require("@noodl/runtime/src/api/cloudstore");
const CloudFile = require("@noodl/runtime/src/api/cloudfile");
const files = {
async upload(file, options) {
return new Promise((resolve, reject) => {
CloudStore.instance.uploadFile({
file,
onUploadProgress: (p) => {
options && options.onProgress && options.onProgress(p);
},
success: (response) => {
resolve(new CloudFile(response));
},
error: (e) => {
reject(e);
},
});
});
},
};
module.exports = files;

View File

@@ -0,0 +1,48 @@
const { RouterHandler } = require('../nodes/navigation/router-handler');
const NoodlRuntime = require('@noodl/runtime');
const navigation = {
async showPopup(componentPath, params) {
return new Promise((resolve) => {
navigation._noodlRuntime.context.showPopup(componentPath, params, {
onClosePopup: (action, results) => {
resolve({
action: action.replace('closeAction-', ''),
parameters: results
});
}
});
});
},
navigate(routerName, targetPageName, params) {
RouterHandler.instance.navigate(routerName, {
target: targetPageName,
params: params
});
},
navigateToPath(path, options) {
let hashPath, urlPath;
var navigationPathType = NoodlRuntime.instance.getProjectSettings()['navigationPathType'];
if (navigationPathType === undefined || navigationPathType === 'hash') hashPath = path;
else urlPath = path;
var query = [];
if (options && options.query !== undefined) {
for (let key in options.query) {
query.push(key + '=' + options.query[key]);
}
}
var compiledUrl =
(urlPath !== undefined ? urlPath : '') +
(query.length >= 1 ? '?' + query.join('&') : '') +
(hashPath !== undefined ? '#' + hashPath : '');
window.history.pushState({}, '', compiledUrl);
dispatchEvent(new PopStateEvent('popstate', {}));
}
};
module.exports = navigation;

View File

@@ -0,0 +1,73 @@
const IS_BROWSER = typeof document !== 'undefined';
export class SeoApi {
private _title = '';
private _meta: Record<string, string> = {};
/** Returns the current document title. */
get title(): string {
return IS_BROWSER ? document.title : this._title;
}
/** Set the document title. */
setTitle(value: string) {
this._title = value;
if (IS_BROWSER) {
document.title = value;
}
}
/** Returns all the current meta-tags */
get meta(): Readonly<SeoApi['_meta']> {
return this._meta;
}
/** Clear all the meta-tags. */
clearMeta(): void {
if (IS_BROWSER) {
// Remove all the meta-tags, technically this is useless when running client-side.
Object.keys(this._meta).forEach((key) => {
const metaTag = document.querySelector(`meta[name="${key}"]`);
if (metaTag) {
metaTag.remove();
}
});
}
this._meta = {};
}
/** Returns a specific meta-tag by name. */
getMeta(key: string) {
// NOTE: We are not querying if the meta-tag exist, maybe something we would like to do?
return this._meta[key];
}
/**
* Set a meta-tag.
*
* @param key The 'name' and/or the 'property' key used for the meta-tags.
* @param value The meta-tag content; if undefined, the meta-tag is removed.
*/
setMeta(key: string, value: string | undefined): void {
this._meta[key] = value;
if (IS_BROWSER) {
const metaTag = document.querySelector(`meta[name="${key}"]`);
if (metaTag) {
if (!value) {
metaTag.remove();
} else {
metaTag.setAttribute('content', value);
}
} else if (value) {
const newMetaTag = document.createElement('meta');
newMetaTag.setAttribute('name', key);
newMetaTag.setAttribute('property', key);
newMetaTag.setAttribute('content', value);
document.head.appendChild(newMetaTag);
}
}
}
}

View File

@@ -0,0 +1,178 @@
const UserService = require('../nodes/std-library/user/userservice')
const users = {
async logIn(options) {
return new Promise((resolve,reject) => {
UserService.instance.logIn({
username:options.username,
password:options.password,
success:() => {
resolve()
},
error:(e) => {
reject(e)
}
})
})
},
async signUp(options) {
return new Promise((resolve,reject) => {
UserService.instance.signUp({
username:options.username,
password:options.password,
email:options.email,
properties:options.properties,
success:() => {
resolve()
},
error:(e) => {
reject(e)
}
})
})
},
async become(sessionToken) {
return new Promise((resolve,reject) => {
UserService.instance.fetchCurrentUser({
sessionToken,
success:() => {
resolve()
},
error:(e) => {
reject(e)
}
})
})
},
// Deprecated use cloud functions instead
/* async requestPasswordReset(options) {
return new Promise((resolve,reject) => {
UserService.instance.requestPasswordReset({
email:options.email,
success:() => {
resolve()
},
error:(e) => {
reject(e)
}
})
})
},
async resetPassword(options) {
return new Promise((resolve,reject) => {
UserService.instance.resetPassword({
token:options.token,
username:options.username,
newPassword:options.newPassword,
success:() => {
resolve()
},
error:(e) => {
reject(e)
}
})
})
},
async sendEmailVerification(options) {
return new Promise((resolve,reject) => {
UserService.instance.sendEmailVerification({
email:options.email,
success:() => {
resolve()
},
error:(e) => {
reject(e)
}
})
})
},
async verifyEmail(options) {
return new Promise((resolve,reject) => {
UserService.instance.verifyEmail({
username:options.username,
token:options.token,
success:() => {
resolve()
},
error:(e) => {
reject(e)
}
})
})
},*/
on(event,cb) {
UserService.instance.on(event,cb)
},
off(event,cb) {
UserService.instance.off(event,cb)
},
}
const _currentUser = {
async logOut() {
return new Promise((resolve,reject) => {
UserService.instance.logOut({
success:() => {
resolve()
},
error:(e) => {
reject(e)
}
})
})
},
async save() {
return new Promise((resolve,reject) => {
const props = Object.assign({},_currentUser.Properties.data)
UserService.instance.setUserProperties({
properties:props,
success:() => {
resolve()
},
error:(e) => {
reject(e)
}
})
})
},
async fetch() {
return new Promise((resolve,reject) => {
UserService.instance.fetchCurrentUser({
success:() => {
resolve()
},
error:(e) => {
reject(e)
}
})
})
}
}
Object.defineProperty(users, 'Current', {
get: function() {
const _user = UserService.instance.current;
if(_user === undefined) return;
else {
_currentUser.email = _user.email;
_currentUser.username = _user.username;
_currentUser.id = _user.id;
_currentUser.emailVerified = _user.emailVerified;
_currentUser.Properties = _user
return _currentUser
}
}
});
module.exports = users

View File

@@ -0,0 +1,271 @@
.ndl-visual-text {
font-weight: 400;
margin: 0;
}
.ndl-controls-pointer,
.ndl-controls-pointer * {
cursor: pointer;
}
.ndl-controls-abs-center {
position: absolute;
transform: translate(-50%, -50%);
left: 50%;
top: 50%;
}
/** Button **/
.ndl-controls-button {
outline: none;
border: none;
color: white;
background-color: black;
padding: 5px 20px 5px 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.ndl-controls-button:disabled {
cursor: default;
}
/** Deprecated checkbox **/
.ndl-controls-checkbox {
margin: 0;
border: 1px solid #000000;
border-radius: 0;
background-color: transparent;
-webkit-appearance: none;
outline: none;
cursor: pointer;
}
.ndl-controls-checkbox:checked {
background-color: #000000;
}
.ndl-controls-checkbox:disabled {
cursor: default;
}
/** Deprecated radio button **/
.ndl-controls-radiobutton {
margin: 0;
border: 1px solid #000000;
border-radius: 16px;
background-color: transparent;
-webkit-appearance: none;
outline: none;
width: 32px;
height: 32px;
display: block;
cursor: pointer;
}
.ndl-controls-radiobutton:checked {
background-color: #000000;
}
.ndl-controls-radiobutton:disabled {
cursor: default;
}
/** New radio button **/
.ndl-controls-radio-2 {
opacity: 0;
position: absolute;
margin: 0;
}
.ndl-controls-radio-2:disabled {
cursor: default;
}
/** New checkbox **/
.ndl-controls-checkbox-2 {
opacity: 0;
position: absolute;
margin: 0;
}
.ndl-controls-checkbox-2:disabled {
cursor: default;
}
/** Options **/
.ndl-controls-select {
-webkit-appearance: none;
appearance: none;
background-color: transparent;
border: 1px solid #000000;
border-radius: 0;
padding: 0 1em 0 0;
margin: 0;
width: 100%;
font-family: inherit;
font-size: inherit;
cursor: inherit;
line-height: inherit;
/* Stack above custom arrow */
z-index: 1;
/* Remove dropdown arrow in IE10 & IE11
@link https://www.filamentgroup.com/lab/select-css.html */
/* &::-ms-expand {
display: none;
}*/
/* Remove focus outline, will add on alternate element */
outline: none;
}
.ndl-controls-select:disabled {
cursor: default;
}
/** Range **/
.ndl-controls-range2 {
margin: 0;
padding: 0;
-webkit-appearance: none;
appearance: none;
outline: none;
border: none;
background: transparent;
cursor: pointer;
}
.ndl-controls-range2::-webkit-slider-thumb {
height: 100%;
border: 0;
-webkit-appearance: none;
appearance: none;
}
.ndl-controls-range2::-moz-range-thumb {
height: 100%;
border: 0;
-webkit-appearance: none;
appearance: none;
}
.ndl-controls-range2::-webkit-slider-runnable-track {
border: none;
height: 100%;
}
.ndl-controls-range2::-moz-range-track {
border: none;
height: 100%;
}
.ndl-controls-range2:disabled {
cursor: default;
}
/* Old range */
.ndl-controls-range {
margin: 0;
padding: 0;
-webkit-appearance: none;
outline: none;
border: none;
background-color: transparent;
cursor: pointer;
}
.ndl-controls-range::-webkit-slider-thumb {
width: 16px;
height: 16px;
background: #000000;
border: 0;
border-radius: 8px;
cursor: pointer;
-webkit-appearance: none;
margin-top: -5px;
}
.ndl-controls-range::-moz-range-thumb {
width: 16px;
height: 16px;
background: #000000;
border: none;
border-radius: 8px;
cursor: pointer;
}
.ndl-controls-range::-ms-thumb {
width: 16px;
height: 16px;
background: #000000;
border: none;
border-radius: 8px;
cursor: pointer;
margin-top: 0px;
/*Needed to keep the Edge thumb centred*/
}
.ndl-controls-range::-webkit-slider-runnable-track {
background: #f0f0f0;
border: none;
width: 100%;
height: 6px;
cursor: pointer;
top: 4px;
margin-top: 5px;
}
.ndl-controls-range:focus::-webkit-slider-runnable-track {
background: #f0f0f0;
}
.ndl-controls-range::-moz-range-track {
background: #f0f0f0;
border: none;
width: 100%;
height: 6px;
cursor: pointer;
}
.ndl-controls-range::-ms-track {
background: transparent;
border-color: transparent;
border-width: 5px 0;
color: transparent;
width: 100%;
height: 6px;
cursor: pointer;
}
.ndl-controls-range::-ms-fill-lower {
background: #f0f0f0;
border: none;
}
.ndl-controls-range::-ms-fill-upper {
background: #f0f0f0;
border: none;
}
/** Field Set **/
.ndl-controls-fieldset {
outline: none;
border: none;
margin: 0;
padding: 0;
}
/** Text Input **/
.ndl-controls-textinput {
outline: none;
border-style: none;
background-color: transparent;
}
.ndl-controls-textinput::placeholder {
color: inherit;
opacity: 0.5;
}

View File

@@ -0,0 +1,47 @@
export default class ASyncQueue {
constructor() {
this.queue = [];
this.pendingPromise = false;
}
enqueue(promise) {
return new Promise((resolve, reject) => {
this.queue.push({
promise,
resolve,
reject
});
this.dequeue();
});
}
dequeue() {
if (this.workingOnPromise) {
return false;
}
const item = this.queue.shift();
if (!item) {
return false;
}
try {
this.workingOnPromise = true;
item
.promise()
.then((value) => {
this.workingOnPromise = false;
item.resolve(value);
this.dequeue();
})
.catch((err) => {
this.workingOnPromise = false;
item.reject(err);
this.dequeue();
});
} catch (err) {
this.workingOnPromise = false;
item.reject(err);
this.dequeue();
}
return true;
}
}

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import React from 'react';
import { NoHomeError } from './NoHomeError';
export default {
title: 'Common/NoHomeError',
component: NoHomeError,
argTypes: {}
} as ComponentMeta<typeof NoHomeError>;
const Template: ComponentStory<typeof NoHomeError> = () => <NoHomeError />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,34 @@
import React from 'react';
export function NoHomeError() {
return (
<div
style={{
overflowY: 'auto',
padding: '24px',
fontFamily: 'Open Sans',
fontSize: '16px',
width: '100vw',
height: '100vh',
backgroundColor: '#F57569'
}}
>
<div style={{ marginBottom: '50px', fontWeight: 'bold', display: 'flex', alignItems: 'center' }}>
<span>ERROR</span>
<img src="ndl_assets/noodl-logo-black.svg" style={{ marginLeft: 'auto' }} />
</div>
<div style={{ margin: '0 auto', alignItems: 'center', display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: '24px', textAlign: 'center', marginBottom: '50px' }}>
No <img src="ndl_assets/home-icon.svg" style={{ marginRight: '-6px' }} />{' '}
<span style={{ fontWeight: 'bold' }}>HOME</span> component selected
</div>
<div style={{ textAlign: 'center' }}>
Click <span style={{ fontWeight: 'bold' }}>Make home</span> as shown below.
</div>
<img style={{ marginTop: '24px' }} srcSet="ndl_assets/make-home-instructions@2x.png 2x" />
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export * from './NoHomeError';

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import React from 'react';
import { Button } from './Button';
export default {
title: 'CATEGORY_HERE/Button',
component: Button,
argTypes: {}
} as ComponentMeta<typeof Button>;
const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,110 @@
import React from 'react';
import Layout from '../../../layout';
import Utils from '../../../nodes/controls/utils';
import { Noodl, Slot } from '../../../types';
export interface ButtonProps extends Noodl.ReactProps {
enabled: boolean;
buttonType: 'button' | 'submit';
textStyle: Noodl.TextStyle;
useLabel: boolean;
label: string;
labelSpacing: string;
labeltextStyle: Noodl.TextStyle;
useIcon: boolean;
iconPlacement: 'left' | 'right';
iconSpacing: string;
iconSourceType: 'image' | 'icon';
iconImageSource: Noodl.Image;
iconIconSource: Noodl.Icon;
iconSize: string;
iconColor: Noodl.Color;
onClick: () => void;
children: Slot;
}
export function Button(props: ButtonProps) {
let style: React.CSSProperties = { ...props.style };
Layout.size(style, props);
Layout.align(style, props);
if (props.textStyle !== undefined) {
// Apply text style
style = Object.assign({}, props.textStyle, style);
style.color = props.noodlNode.context.styles.resolveColor(style.color);
}
function _renderIcon() {
const iconStyle: React.CSSProperties = {};
if (props.useLabel) {
if (props.iconPlacement === 'left' || props.iconPlacement === undefined) {
iconStyle.marginRight = props.iconSpacing;
} else {
iconStyle.marginLeft = props.iconSpacing;
}
}
if (props.iconSourceType === 'image' && props.iconImageSource !== undefined) {
iconStyle.width = props.iconSize;
iconStyle.height = props.iconSize;
return <img alt="" src={props.iconImageSource} style={iconStyle} />;
} else if (props.iconSourceType === 'icon' && props.iconIconSource !== undefined) {
iconStyle.fontSize = props.iconSize;
iconStyle.color = props.iconColor;
if (props.iconIconSource.codeAsClass === true) {
return (
<span className={[props.iconIconSource.class, props.iconIconSource.code].join(' ')} style={iconStyle}></span>
);
} else {
return (
<span className={props.iconIconSource.class} style={iconStyle}>
{props.iconIconSource.code}
</span>
);
}
}
return null;
}
let className = 'ndl-controls-button';
if (props.className) className = className + ' ' + props.className;
let content = null;
if (props.useLabel && props.useIcon) {
content = (
<>
{props.iconPlacement === 'left' ? _renderIcon() : null}
{String(props.label)}
{props.iconPlacement === 'right' ? _renderIcon() : null}
</>
);
} else if (props.useLabel) {
content = String(props.label);
} else if (props.useIcon) {
content = _renderIcon();
}
return (
<button
className={className}
disabled={!props.enabled}
{...Utils.controlEvents(props)}
type={props.buttonType}
style={style}
onClick={props.onClick}
>
{content}
{props.children}
</button>
);
}

View File

@@ -0,0 +1 @@
export * from './Button';

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import React from 'react';
import { Checkbox } from './Checkbox';
export default {
title: 'CATEGORY_HERE/Checkbox',
component: Checkbox,
argTypes: {}
} as ComponentMeta<typeof Checkbox>;
const Template: ComponentStory<typeof Checkbox> = (args) => <Checkbox {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,148 @@
import React, { useEffect, useState } from 'react';
import Layout from '../../../layout';
import Utils from '../../../nodes/controls/utils';
import { Noodl } from '../../../types';
export interface CheckboxProps extends Noodl.ReactProps {
id: string;
enabled: boolean;
checked: boolean;
useLabel: boolean;
label: string;
labelSpacing: string;
labeltextStyle: Noodl.TextStyle;
useIcon: boolean;
iconPlacement: 'left' | 'right';
iconSpacing: string;
iconSourceType: 'image' | 'icon';
iconImageSource: Noodl.Image;
iconIconSource: Noodl.Icon;
iconSize: string;
iconColor: Noodl.Color;
checkedChanged: (checked: boolean) => void;
}
export function Checkbox(props: CheckboxProps) {
const [checked, setChecked] = useState(props.checked);
// Report initial values when mounted
useEffect(() => {
setChecked(!!props.checked);
}, []);
useEffect(() => {
setChecked(!!props.checked);
}, [props.checked]);
const style: React.CSSProperties = { ...props.style };
if (props.parentLayout === 'none') {
style.position = 'absolute';
}
Layout.align(style, props);
const inputProps = {
id: props.id,
className: [props.className, 'ndl-controls-checkbox-2'].join(' '),
disabled: !props.enabled,
style: {
width: props.styles.checkbox.width,
height: props.styles.checkbox.height
}
};
const inputWrapperStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
position: 'relative',
...props.styles.checkbox
};
if (!props.useLabel) {
Object.assign(inputProps, Utils.controlEvents(props));
Object.assign(inputWrapperStyle, style);
}
function _renderIcon() {
if (props.iconSourceType === 'image' && props.iconImageSource !== undefined)
return (
<img
alt=""
src={props.iconImageSource}
style={{
width: props.iconSize,
height: props.iconSize,
position: 'absolute'
}}
/>
);
else if (props.iconSourceType === 'icon' && props.iconIconSource !== undefined) {
const style: React.CSSProperties = {
fontSize: props.iconSize,
color: props.iconColor,
position: 'absolute'
};
if (props.iconIconSource.codeAsClass === true) {
return (
<span className={[props.iconIconSource.class, props.iconIconSource.code].join(' ')} style={style}></span>
);
} else {
return (
<span className={props.iconIconSource.class} style={style}>
{props.iconIconSource.code}
</span>
);
}
}
return null;
}
const checkbox = (
<div className="ndl-controls-pointer" style={inputWrapperStyle} noodl-style-tag="checkbox">
{props.useIcon ? _renderIcon() : null}
<input
type="checkbox"
{...inputProps}
checked={checked}
onChange={(e) => {
setChecked(e.target.checked);
props.checkedChanged && props.checkedChanged(e.target.checked);
}}
/>
</div>
);
if (props.useLabel) {
const labelStyle: React.CSSProperties = {
marginLeft: props.labelSpacing,
...props.labeltextStyle,
...props.styles.label
};
if (!props.enabled) {
labelStyle.cursor = 'default';
}
labelStyle.color = props.noodlNode.context.styles.resolveColor(labelStyle.color);
return (
<div style={{ display: 'flex', alignItems: 'center', ...style }} {...Utils.controlEvents(props)}>
{checkbox}
<label className="ndl-controls-pointer" style={labelStyle} htmlFor={props.id} noodl-style-tag="label">
{props.label}
</label>
</div>
);
} else {
return checkbox;
}
}

View File

@@ -0,0 +1 @@
export * from './Checkbox'

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import React from 'react';
import { RadioButton } from './RadioButton';
export default {
title: 'Controls/Radio Button',
component: RadioButton,
argTypes: {}
} as ComponentMeta<typeof RadioButton>;
const Template: ComponentStory<typeof RadioButton> = (args) => <RadioButton {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,157 @@
import React, { useContext } from 'react';
import RadioButtonContext from '../../../contexts/radiobuttoncontext';
import Layout from '../../../layout';
import Utils from '../../../nodes/controls/utils';
import { Noodl } from '../../../types';
export interface RadioButtonProps extends Noodl.ReactProps {
id: string;
enabled: boolean;
value: string;
useLabel: boolean;
label: string;
labelSpacing: string;
labeltextStyle: Noodl.TextStyle;
useIcon: boolean;
iconPlacement: 'left' | 'right';
iconSpacing: string;
iconSourceType: 'image' | 'icon';
iconImageSource: Noodl.Image;
iconIconSource: Noodl.Icon;
iconSize: string;
iconColor: Noodl.Color;
fillSpacing: string;
checkedChanged: (value: boolean) => void;
}
function isPercentage(size /* string */) {
return size && size[size.length - 1] === '%';
}
export function RadioButton(props: RadioButtonProps) {
const radioButtonGroup = useContext(RadioButtonContext);
const style = { ...props.style };
if (props.parentLayout === 'none') {
style.position = 'absolute';
}
Layout.align(style, props);
props.checkedChanged && props.checkedChanged(radioButtonGroup ? radioButtonGroup.selected === props.value : false);
const inputProps = {
id: props.id,
disabled: !props.enabled,
className: [props.className, 'ndl-controls-radio-2'].join(' '),
style: {
width: props.styles.radio.width,
height: props.styles.radio.height
}
};
const inputWrapperStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
flexShrink: 0,
...props.styles.radio
};
if (props.useLabel) {
if (isPercentage(props.styles.radio.width)) {
delete inputWrapperStyle.width;
inputWrapperStyle.flexGrow = 1;
}
} else {
Object.assign(inputProps, Utils.controlEvents(props));
Object.assign(inputWrapperStyle, style);
}
function _renderIcon() {
if (props.iconSourceType === 'image' && props.iconImageSource !== undefined)
return <img alt="" src={props.iconImageSource} style={{ width: props.iconSize, height: props.iconSize }} />;
else if (props.iconSourceType === 'icon' && props.iconIconSource !== undefined) {
const style = { fontSize: props.iconSize, color: props.iconColor };
if (props.iconIconSource.codeAsClass === true) {
return (
<span
className={['ndl-controls-abs-center', props.iconIconSource.class, props.iconIconSource.code].join(' ')}
style={style}
></span>
);
} else {
return (
<span className={['ndl-controls-abs-center', props.iconIconSource.class].join(' ')} style={style}>
{props.iconIconSource.code}
</span>
);
}
}
return null;
}
const fillStyle: React.CSSProperties = {
left: props.fillSpacing,
right: props.fillSpacing,
top: props.fillSpacing,
bottom: props.fillSpacing,
backgroundColor: props.styles.fill.backgroundColor,
borderRadius: 'inherit',
position: 'absolute'
};
const radioButton = (
<div className="ndl-controls-pointer" style={inputWrapperStyle} noodl-style-tag="radio">
<div style={fillStyle} noodl-style-tag="fill" />
{props.useIcon ? _renderIcon() : null}
<input
type="radio"
name={radioButtonGroup ? radioButtonGroup.name : undefined}
{...inputProps}
checked={radioButtonGroup ? radioButtonGroup.selected === props.value : false}
onChange={(e) => {
radioButtonGroup && radioButtonGroup.checkedChanged && radioButtonGroup.checkedChanged(props.value);
}}
/>
</div>
);
if (props.useLabel) {
const labelStyle = {
marginLeft: props.labelSpacing,
...props.labeltextStyle,
...props.styles.label,
cursor: props.enabled ? undefined : 'default'
};
labelStyle.color = props.noodlNode.context.styles.resolveColor(labelStyle.color);
const wrapperStyle = { display: 'flex', alignItems: 'center', ...style };
if (isPercentage(props.styles.radio.width)) {
wrapperStyle.width = props.styles.radio.width;
}
if (isPercentage(props.styles.radio.height)) {
wrapperStyle.height = props.styles.radio.height;
}
return (
<div style={wrapperStyle} {...Utils.controlEvents(props)}>
{radioButton}
<label className="ndl-controls-pointer" style={labelStyle} htmlFor={props.id} noodl-style-tag="label">
{props.label}
</label>
</div>
);
} else {
return radioButton;
}
}

View File

@@ -0,0 +1 @@
export * from './RadioButton';

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import React from 'react';
import { RadioButtonGroup } from './RadioButtonGroup';
export default {
title: 'Controls/RadioButtonGroup',
component: RadioButtonGroup,
argTypes: {}
} as ComponentMeta<typeof RadioButtonGroup>;
const Template: ComponentStory<typeof RadioButtonGroup> = (args) => <RadioButtonGroup {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,45 @@
import React, { useEffect, useState } from 'react';
import RadioButtonContext from '../../../contexts/radiobuttoncontext';
import Layout from '../../../layout';
import { Noodl, Slot } from '../../../types';
export interface RadioButtonGroupProps extends Noodl.ReactProps {
name: string;
value: string;
valueChanged?: (value: string) => void;
children: Slot;
}
export function RadioButtonGroup(props: RadioButtonGroupProps) {
const [selected, setSelected] = useState(props.value);
const context = {
selected: selected,
name: props.name,
checkedChanged: (value) => {
setSelected(value);
props.valueChanged && props.valueChanged(value);
}
};
useEffect(() => {
setSelected(props.value);
}, [props.value]);
const style: React.CSSProperties = { ...props.style };
Layout.size(style, props);
Layout.align(style, props);
let className = 'ndl-controls-radiobuttongroup';
if (props.className) className = className + ' ' + props.className;
return (
<RadioButtonContext.Provider value={context}>
<div className={className} style={style}>
{props.children}
</div>
</RadioButtonContext.Provider>
);
}

View File

@@ -0,0 +1 @@
export * from './RadioButtonGroup';

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import React from 'react';
import { Select } from './Select';
export default {
title: 'Controls/Select',
component: Select,
argTypes: {}
} as ComponentMeta<typeof Select>;
const Template: ComponentStory<typeof Select> = (args) => <Select {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,197 @@
import React, { useEffect, useState } from 'react';
import type { TSFixme } from '../../../../typings/global';
import Layout from '../../../layout';
import Utils from '../../../nodes/controls/utils';
import { Noodl } from '../../../types';
export interface SelectProps extends Noodl.ReactProps {
id: string;
value: string;
enabled: boolean;
textStyle: Noodl.TextStyle;
items: TSFixme;
placeholder: string;
placeholderOpacity: string;
useIcon: boolean;
iconPlacement: 'left' | 'right';
iconSpacing: string;
iconSourceType: 'image' | 'icon';
iconImageSource: Noodl.Image;
iconIconSource: Noodl.Icon;
iconSize: string;
iconColor: Noodl.Color;
useLabel: boolean;
label: string;
labelSpacing: string;
labeltextStyle: Noodl.TextStyle;
onClick: () => void;
valueChanged: (value: string) => void;
}
export function Select(props: SelectProps) {
const [value, setValue] = useState(props.value);
useEffect(() => {
setValue(props.value);
props.valueChanged(props.value);
}, [props.value, props.items]);
let style = { ...props.style };
Layout.size(style, props);
Layout.align(style, props);
if (props.textStyle !== undefined) {
// Apply text style
style = Object.assign({}, props.textStyle, style);
style.color = props.noodlNode.context.styles.resolveColor(style.color);
}
// Hide label if there is no selected value, of if value is not in the items array
const selectedIndex = !props.items || value === undefined ? -1 : props.items.findIndex((i) => i.Value === value);
const { height, ...otherStyles } = style;
function _renderIcon() {
if (props.iconSourceType === 'image' && props.iconImageSource !== undefined)
return <img alt="" src={props.iconImageSource} style={{ width: props.iconSize, height: props.iconSize }}></img>;
else if (props.iconSourceType === 'icon' && props.iconIconSource !== undefined) {
const style: React.CSSProperties = { fontSize: props.iconSize, color: props.iconColor };
if (props.iconPlacement === 'left' || props.iconPlacement === undefined) style.marginRight = props.iconSpacing;
else style.marginLeft = props.iconSpacing;
if (props.iconIconSource.codeAsClass === true) {
return (
<span className={[props.iconIconSource.class, props.iconIconSource.code].join(' ')} style={style}></span>
);
} else {
return (
<span className={props.iconIconSource.class} style={style}>
{props.iconIconSource.code}
</span>
);
}
}
return null;
}
const inputProps = {
id: props.id,
className: props.className,
style: {
inset: 0,
opacity: 0,
position: 'absolute',
textTransform: 'inherit',
cursor: props.enabled ? '' : 'default',
'-webkit-appearance': 'none' //this makes styling possible on Safari, otherwise the size will be incorrect as it will use the native styling
},
onClick: props.onClick
};
const inputWrapperStyle = {
display: 'flex',
alignItems: 'center',
...props.styles.inputWrapper,
cursor: props.enabled ? '' : 'default'
};
const heightInPercent = height && height[String(height).length - 1] === '%';
if (props.useLabel) {
if (heightInPercent) {
inputWrapperStyle.flexGrow = 1;
} else {
inputWrapperStyle.height = height;
}
} else {
Object.assign(inputWrapperStyle, otherStyles);
inputWrapperStyle.height = height;
}
let options = [];
if (props.items) {
options = props.items.map((i) => (
<option key={i.Value} value={i.Value} disabled={i.Disabled === 'true' || i.Disabled === true ? true : undefined}>
{i.Label}
</option>
));
// options.unshift();
}
let label = null;
if (selectedIndex >= 0 && selectedIndex < props.items.items.length) {
label = <span>{props.items.items[selectedIndex].Label}</span>;
} else if (props.placeholder) {
label = <span style={{ opacity: props.placeholderOpacity }}>{props.placeholder}</span>;
}
//A hidden first option is preselected and added to the list of options, it makes it possible to select the first item in the dropdown
const inputWrapper = (
<div className="ndl-controls-pointer" style={inputWrapperStyle} noodl-style-tag="inputWrapper">
{props.useIcon && props.iconPlacement === 'left' ? _renderIcon() : null}
<div
style={{
width: '100%',
height: '100%',
alignItems: 'center',
display: 'flex'
}}
>
{label}
</div>
{props.useIcon && props.iconPlacement === 'right' ? _renderIcon() : null}
<select
{...inputProps}
disabled={!props.enabled}
value={options.find((i) => i.props.value === value) ? value : undefined}
{...Utils.controlEvents(props)}
onChange={(e) => {
setValue(e.target.value);
props.valueChanged && props.valueChanged(e.target.value);
}}
>
<option value="" disabled selected hidden />
{options}
</select>
</div>
);
if (props.useLabel) {
const outerWrapperStyle: React.CSSProperties = {
...otherStyles,
display: 'flex',
flexDirection: 'column'
};
if (heightInPercent) {
outerWrapperStyle.height = height;
}
return (
<div style={outerWrapperStyle}>
<label
htmlFor={props.id}
style={{
...props.labeltextStyle,
...props.styles.label,
marginBottom: props.labelSpacing
}}
noodl-style-tag="label"
>
{props.label}
</label>
{inputWrapper}
</div>
);
} else {
return inputWrapper;
}
}

View File

@@ -0,0 +1 @@
export * from './Select';

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import React from 'react';
import { Slider } from './Slider';
export default {
title: 'Controls/Slider',
component: Slider,
argTypes: {}
} as ComponentMeta<typeof Slider>;
const Template: ComponentStory<typeof Slider> = (args) => <Slider {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,183 @@
import React, { useEffect, useState } from 'react';
import Layout from '../../../layout';
import Utils from '../../../nodes/controls/utils';
import { Noodl } from '../../../types';
export interface SliderProps extends Noodl.ReactProps {
_nodeId: string;
id: string;
enabled: boolean;
value: number;
min: number;
max: number;
step: number;
thumbHeight: string;
thumbWidth: string;
thumbColor: string;
trackHeight: string;
trackActiveColor: string;
trackColor: string;
onClick: () => void;
updateOutputValue: (value: number) => void;
}
function _styleTemplate(_class: string, props: SliderProps) {
return `.${_class}::-webkit-slider-thumb {
width: ${props.thumbWidth};
}`;
}
function setBorderStyle(style: React.CSSProperties, prefix: string, props: SliderProps) {
function setBorder(style: React.CSSProperties, group: string) {
const width = `${prefix}Border${group}Width`;
const color = `${prefix}Border${group}Color`;
const borderStyle = `${prefix}Border${group}Style`;
const w = props[width] || props[`${prefix}BorderWidth`];
if (w !== undefined) style[`border${group}Width`] = w;
const c = (style[color] = props[color] || props[`${prefix}BorderColor`]);
if (c !== undefined) style[`border${group}Color`] = c;
const s = props[borderStyle] || props[`${prefix}BorderStyle`];
if (s !== undefined) style[`border${group}Style`] = s;
}
setBorder(style, 'Top');
setBorder(style, 'Right');
setBorder(style, 'Bottom');
setBorder(style, 'Left');
}
function setBorderRadius(style: React.CSSProperties, prefix: string, props: SliderProps) {
const radius = props[`${prefix}BorderRadius`];
const tl = props[`${prefix}BorderTopLeftRadius`] || radius;
const tr = props[`${prefix}BorderTopRightRadius`] || radius;
const br = props[`${prefix}BorderBottomRightRadius`] || radius;
const bl = props[`${prefix}BorderBottomLeftRadius`] || radius;
style.borderRadius = `${tl} ${tr} ${br} ${bl}`;
}
function setShadow(style: React.CSSProperties, prefix: string, props: SliderProps) {
if (!props[`${prefix}BoxShadowEnabled`]) return;
const inset = props[`${prefix}BoxShadowInset`];
const x = props[`${prefix}BoxShadowOffsetX`];
const y = props[`${prefix}BoxShadowOffsetY`];
const blur = props[`${prefix}BoxShadowBlurRadius`];
const spread = props[`${prefix}BoxShadowSpreadRadius`];
const color = props[`${prefix}BoxShadowColor`];
style.boxShadow = `${inset ? 'inset ' : ''}${x} ${y} ${blur} ${spread} ${color}`;
}
function hasUnitPx(value: string) {
return value && value[value.length - 1] === 'x';
}
export function Slider(props: SliderProps) {
const [value, setValue] = useState(props.value);
useEffect(() => {
onValueChanged(props.value);
}, [props.value]);
function onValueChanged(value: number) {
setValue(value);
props.updateOutputValue(value);
}
const style: React.CSSProperties = { ...props.style };
Layout.size(style, props);
Layout.align(style, props);
const instanceClassId = 'ndl-controls-range-' + props._nodeId;
Utils.updateStylesForClass(instanceClassId, props, _styleTemplate);
const className = `ndl-controls-range2 ${instanceClassId} ${props.className ? props.className : ''} `;
const inputProps: React.InputHTMLAttributes<HTMLInputElement> = {
id: props.id,
style: {
width: '100%',
opacity: 0
},
onClick: props.onClick,
min: props.min,
max: props.max
};
if (props.step) {
inputProps.step = props.step;
}
//make the input as tall as the tallest element, track or thumb, so the entire area becomes interactive
//Makes it possible to design sliders with thin tracks and tall thumbs
if (hasUnitPx(props.thumbHeight)) {
if (hasUnitPx(props.trackHeight)) {
const thumbHeight = Number(props.thumbHeight.slice(0, -2));
const trackHeight = Number(props.trackHeight.slice(0, -2));
inputProps.style.height = Math.max(thumbHeight, trackHeight) + 'px';
} else {
inputProps.style.height = props.thumbHeight;
}
} else {
inputProps.style.height = props.trackHeight;
}
const divStyle = {
display: 'flex',
alignItems: 'center',
...style
};
const valueFactor = (value - props.min) / (props.max - props.min);
const trackStyle: React.CSSProperties = {
position: 'absolute',
width: '100%',
height: props.trackHeight,
background: `linear-gradient(to right, ${props.trackActiveColor} 0%, ${props.trackActiveColor} ${
valueFactor * 100
}%, ${props.trackColor} ${valueFactor * 100}%, ${props.trackColor} 100%)`
};
setBorderStyle(trackStyle, 'track', props);
setBorderRadius(trackStyle, 'track', props);
setShadow(trackStyle, 'track', props);
const thumbStyle: React.CSSProperties = {
position: 'absolute',
left: 'calc((100% - ' + props.thumbWidth + ') * ' + valueFactor + ')',
width: props.thumbWidth,
height: props.thumbHeight,
backgroundColor: props.thumbColor
};
setBorderStyle(thumbStyle, 'thumb', props);
setBorderRadius(thumbStyle, 'thumb', props);
setShadow(thumbStyle, 'thumb', props);
return (
<div style={divStyle}>
<div style={trackStyle} />
<div style={thumbStyle} />
<input
className={className}
{...Utils.controlEvents(props)}
type="range"
{...inputProps}
value={value}
disabled={!props.enabled}
onChange={(e) => onValueChanged(Number(e.target.value))}
/>
</div>
);
}

View File

@@ -0,0 +1 @@
export * from './Slider';

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import React from 'react';
import { TextInput } from './TextInput';
export default {
title: 'CATEGORY_HERE/TextInput',
component: TextInput,
argTypes: {}
} as ComponentMeta<typeof TextInput>;
const Template: ComponentStory<typeof TextInput> = (args) => <TextInput {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,264 @@
import React from 'react';
import Layout from '../../../layout';
import Utils from '../../../nodes/controls/utils';
import { Noodl, Slot } from '../../../types';
//this stops a text field from being unfocused by the clickHandler in the viewer that handles focus globally.
//The specific case is when a mouseDown is registered in the input, but the mouseUp is outside.
//It'll trigger a focus change that'll blur the input field, which is annyoing when you're selecting text
function preventGlobalFocusChange(e) {
e.stopPropagation();
window.removeEventListener('click', preventGlobalFocusChange, true);
}
export interface TextInputProps extends Noodl.ReactProps {
id: string;
type: 'text' | 'textArea' | 'email' | 'number' | 'password' | 'url';
textStyle: Noodl.TextStyle;
enabled: boolean;
placeholder: string;
maxLength: number;
startValue: string;
value: string;
useLabel: boolean;
label: string;
labelSpacing: string;
labeltextStyle: Noodl.TextStyle;
useIcon: boolean;
iconPlacement: 'left' | 'right';
iconSpacing: string;
iconSourceType: 'image' | 'icon';
iconImageSource: Noodl.Image;
iconIconSource: Noodl.Icon;
iconSize: string;
iconColor: Noodl.Color;
onTextChanged?: (value: string) => void;
onEnter?: () => void;
children: Slot;
}
// Based on (HTMLTextAreaElement | HTMLInputElement)
type InputRef = (HTMLTextAreaElement | HTMLInputElement) & {
noodlNode?: Noodl.ReactProps['noodlNode'];
};
type State = {
value: string;
};
export class TextInput extends React.Component<TextInputProps, State> {
ref: React.MutableRefObject<InputRef>;
constructor(props: TextInputProps) {
super(props);
this.state = {
value: props.startValue
} satisfies State;
this.ref = React.createRef();
}
setText(value: string) {
this.setState({ value });
this.props.onTextChanged && this.props.onTextChanged(value);
}
componentDidMount() {
//plumbing for the focused signals
this.ref.current.noodlNode = this.props.noodlNode;
this.setText(this.props.startValue);
}
render() {
const style: React.CSSProperties = { ...this.props.style };
Layout.size(style, this.props);
Layout.align(style, this.props);
if (style.opacity === 0) {
style.pointerEvents = 'none';
}
const { height, ...otherStylesTmp } = style;
// otherStylesTmp is not React.CSSProperties, reassigning it will correct the type.
const otherStyles: React.CSSProperties = otherStylesTmp;
const props = this.props;
const _renderIcon = () => {
if (props.iconSourceType === 'image' && props.iconImageSource !== undefined)
return (
<img
alt=""
src={props.iconImageSource}
style={{
width: props.iconSize,
height: props.iconSize
}}
onClick={() => this.focus()}
/>
);
else if (props.iconSourceType === 'icon' && props.iconIconSource !== undefined) {
const style: React.CSSProperties = {
userSelect: 'none',
fontSize: props.iconSize,
color: props.iconColor
};
if (props.iconPlacement === 'left' || props.iconPlacement === undefined) style.marginRight = props.iconSpacing;
else style.marginLeft = props.iconSpacing;
if (props.iconIconSource.codeAsClass === true) {
return (
<span className={[props.iconIconSource.class, props.iconIconSource.code].join(' ')} style={style}></span>
);
} else {
return (
<span className={props.iconIconSource.class} style={style}>
{props.iconIconSource.code}
</span>
);
}
}
return null;
};
let className = 'ndl-controls-textinput ' + props.id;
if (props.className) className = className + ' ' + props.className;
let inputContent;
const inputStyles: React.CSSProperties = {
...props.textStyle,
...props.styles.input,
width: '100%',
height: '100%'
};
inputStyles.color = props.noodlNode.context.styles.resolveColor(inputStyles.color);
const inputProps = {
id: props.id,
value: this.state.value,
...Utils.controlEvents(props),
disabled: !props.enabled,
style: inputStyles,
className,
placeholder: props.placeholder,
maxLength: props.maxLength,
onChange: (e) => this.onChange(e)
};
if (props.type !== 'textArea') {
inputContent = (
<input
ref={(ref) => (this.ref.current = ref)}
type={this.props.type}
{...inputProps}
onKeyDown={(e) => this.onKeyDown(e)}
onMouseDown={() => window.addEventListener('click', preventGlobalFocusChange, true)}
noodl-style-tag="input"
/>
);
} else {
inputProps.style.resize = 'none'; //disable user resizing
inputContent = (
<textarea
ref={(ref) => (this.ref.current = ref)}
{...inputProps}
onKeyDown={(e) => this.onKeyDown(e)}
noodl-style-tag="input"
/>
);
}
const inputWrapperStyle = {
display: 'flex',
alignItems: 'center',
...props.styles.inputWrapper
};
const heightInPercent = height && height[String(height).length - 1] === '%';
if (props.useLabel) {
if (heightInPercent) {
inputWrapperStyle.flexGrow = 1;
} else {
inputWrapperStyle.height = height;
}
} else {
Object.assign(inputWrapperStyle, otherStyles);
inputWrapperStyle.height = height;
}
if (props.type !== 'textArea') {
inputWrapperStyle.alignItems = 'center';
}
const inputWithWrapper = (
<div style={inputWrapperStyle} noodl-style-tag="inputWrapper">
{props.useIcon && props.iconPlacement === 'left' ? _renderIcon() : null}
{inputContent}
{props.useIcon && props.iconPlacement === 'right' ? _renderIcon() : null}
</div>
);
if (props.useLabel) {
otherStyles.display = 'flex';
otherStyles.flexDirection = 'column';
if (heightInPercent) otherStyles.height = height;
const labelStyle: React.CSSProperties = {
...props.labeltextStyle,
...props.styles.label,
marginBottom: props.labelSpacing
};
labelStyle.color = props.noodlNode.context.styles.resolveColor(labelStyle.color);
return (
<div style={otherStyles}>
<label htmlFor={props.id} style={labelStyle} noodl-style-tag="label">
{props.label}
</label>
{inputWithWrapper}
</div>
);
} else {
return inputWithWrapper;
}
}
onKeyDown(e) {
if (e.key === 'Enter' || e.which === 13) {
this.props.onEnter && this.props.onEnter();
}
}
onChange(event) {
const value = event.target.value;
this.setText(value);
}
focus() {
this.ref.current && this.ref.current.focus();
}
blur() {
this.ref.current && this.ref.current.blur();
}
hasFocus() {
return document.activeElement === this.ref.current;
}
}

View File

@@ -0,0 +1 @@
export * from './TextInput';

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Page } from './Page';
export default {
title: 'Navigation/Page',
component: Page,
argTypes: {},
} as ComponentMeta<typeof Page>;
const Template: ComponentStory<typeof Page> = (args) => <Page {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,158 @@
import React from 'react';
import type { TSFixme } from '../../../../typings/global';
import Layout from '../../../layout';
import { Noodl, Slot } from '../../../types';
type MetaTag = {
isProperty: boolean;
key: string;
displayName: string;
editorName?: string;
group: string;
type?: string;
popout?: TSFixme;
};
const ogPopout = {
group: 'seo-og',
label: 'Open Graph',
parentGroup: 'Experimental SEO'
};
const twitterPopout = {
group: 'seo-twitter',
label: 'Twitter',
parentGroup: 'Experimental SEO'
};
export const META_TAGS: MetaTag[] = [
{
isProperty: true,
key: 'description',
displayName: 'Description',
group: 'Experimental SEO'
},
{
isProperty: true,
key: 'robots',
displayName: 'Robots',
group: 'Experimental SEO'
},
{
isProperty: true,
key: 'og:title',
displayName: 'Title',
editorName: 'OG Title',
group: 'General',
popout: ogPopout
},
{
isProperty: true,
key: 'og:description',
displayName: 'Description',
editorName: 'OG Description',
group: 'General',
popout: ogPopout
},
{
isProperty: true,
key: 'og:url',
displayName: 'Url',
editorName: 'OG Url',
group: 'General',
popout: ogPopout
},
{
isProperty: true,
key: 'og:type',
displayName: 'Type',
editorName: 'OG Type',
group: 'General',
popout: ogPopout
},
{
isProperty: true,
key: 'og:image',
displayName: 'Image',
editorName: 'OG Image',
group: 'Image',
popout: ogPopout
},
{
isProperty: true,
key: 'og:image:width',
displayName: 'Image Width',
editorName: 'OG Image Width',
group: 'Image',
popout: ogPopout
},
{
isProperty: true,
key: 'og:image:height',
displayName: 'Image Height',
editorName: 'OG Image Height',
group: 'Image',
popout: ogPopout
},
{
isProperty: false,
key: 'twitter:card',
displayName: 'Card',
editorName: 'Twitter Card',
group: 'General',
popout: twitterPopout
},
{
isProperty: false,
key: 'twitter:title',
displayName: 'Title',
editorName: 'Twitter Title',
group: 'General',
popout: twitterPopout
},
{
isProperty: false,
key: 'twitter:description',
displayName: 'Description',
editorName: 'Twitter Description',
group: 'General',
popout: twitterPopout
},
{
isProperty: false,
key: 'twitter:image',
displayName: 'Image',
editorName: 'Twitter Image',
group: 'General',
popout: twitterPopout
}
];
type MetaTagKey = typeof META_TAGS[number]['key'];
export interface PageProps extends Noodl.ReactProps {
metatags?: Record<MetaTagKey, string>;
children: Slot;
}
export function Page(props: PageProps) {
const { style, children } = props;
Layout.size(style, props);
Layout.align(style, props);
// Allow changing the metatags from inputs
META_TAGS.forEach((item) => {
const value = props.metatags && props.metatags[item.key];
// @ts-expect-error Noodl is globally defined.
Noodl.SEO.setMeta(item.key, value);
});
return (
<div style={style} className={props.className}>
{children}
</div>
);
}

View File

@@ -0,0 +1 @@
export * from './Page';

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import React from 'react';
import { Circle } from './Circle';
export default {
title: 'CATEGORY_HERE/Circle',
component: Circle,
argTypes: {}
} as ComponentMeta<typeof Circle>;
const Template: ComponentStory<typeof Circle> = (args) => <Circle {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,130 @@
import React from 'react';
import Layout from '../../../layout';
import PointerListeners from '../../../pointerlisteners';
import { Noodl } from '../../../types';
export interface CircleProps extends Noodl.ReactProps {
size: number;
startAngle: number;
endAngle: number;
fillEnabled: boolean;
fillColor: Noodl.Color;
strokeEnabled: boolean;
strokeColor: Noodl.Color;
strokeWidth: number;
strokeLineCap: 'butt' | 'round';
dom;
}
function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians)
};
}
function filledArc(x, y, radius, startAngle, endAngle) {
if (endAngle % 360 === startAngle % 360) {
endAngle -= 0.0001;
}
const start = polarToCartesian(x, y, radius, endAngle);
const end = polarToCartesian(x, y, radius, startAngle);
const arcSweep = endAngle - startAngle <= 180 ? '0' : '1';
return [
'M',
start.x,
start.y,
'A',
radius,
radius,
0,
arcSweep,
0,
end.x,
end.y,
'L',
x,
y,
'L',
start.x,
start.y
].join(' ');
}
function arc(x, y, radius, startAngle, endAngle) {
if (endAngle % 360 === startAngle % 360) {
endAngle -= 0.0001;
}
const start = polarToCartesian(x, y, radius, endAngle);
const end = polarToCartesian(x, y, radius, startAngle);
const arcSweep = endAngle - startAngle <= 180 ? '0' : '1';
return ['M', start.x, start.y, 'A', radius, radius, 0, arcSweep, 0, end.x, end.y].join(' ');
}
export class Circle extends React.Component<CircleProps> {
constructor(props: CircleProps) {
super(props);
}
render() {
//SVG can only do strokes centered on a path, and we want to render it inside.
//We'll do it manually by adding another path on top of the filled circle
let fill;
let stroke;
const r = this.props.size / 2;
const { startAngle, endAngle } = this.props;
if (this.props.fillEnabled) {
const r = this.props.size / 2;
fill = <path d={filledArc(r, r, r, startAngle, endAngle)} fill={this.props.fillColor} />;
}
if (this.props.strokeEnabled) {
const { strokeColor, strokeWidth, strokeLineCap } = this.props;
const strokeRadius = r - this.props.strokeWidth / 2;
const path = arc(r, r, strokeRadius, startAngle, endAngle);
stroke = (
<path
d={path}
stroke={strokeColor}
strokeWidth={strokeWidth}
fill="transparent"
strokeLinecap={strokeLineCap}
/>
);
}
const style = { ...this.props.style };
Layout.size(style, this.props);
Layout.align(style, this.props);
if (style.opacity === 0) {
style.pointerEvents = 'none';
}
//the SVG element lack some properties like offsetLeft, offsetTop that the drag node depends on.
//Let's wrap it in a div to make it work properly
return (
<div className={this.props.className} {...this.props.dom} {...PointerListeners(this.props)} style={style}>
<svg xmlns="http://www.w3.org/2000/svg" width={this.props.size} height={this.props.size}>
{fill}
{stroke}
</svg>
</div>
);
}
}

View File

@@ -0,0 +1 @@
export * from './Circle';

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import React from 'react';
import { Columns } from './Columns';
export default {
title: 'CATEGORY_HERE/Columns',
component: Columns,
argTypes: {}
} as ComponentMeta<typeof Columns>;
const Template: ComponentStory<typeof Columns> = (args) => <Columns {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,166 @@
import React, { useRef, useState, useEffect } from 'react';
import { ForEachComponent } from '../../../nodes/std-library/data/foreach';
import { Noodl, Slot } from '../../../types';
export interface ColumnsProps extends Noodl.ReactProps {
marginX: string;
marginY: string;
justifyContent: 'flex-start' | 'flex-end' | 'center';
direction: 'row' | 'column';
minWidth: string;
layoutString: string;
children: Slot;
}
function calcAutofold(layout, minWidths, containerWidth, marginX) {
const first = _calcAutofold(layout, minWidths, containerWidth, marginX);
const second = _calcAutofold(first.layout, minWidths, containerWidth, marginX);
if (first.totalFractions === second.totalFractions) {
return first;
} else {
return calcAutofold(second.layout, minWidths, containerWidth, marginX);
}
}
function _calcAutofold(layout, minWidth, containerWidth, marginX) {
const totalFractions = layout.reduce((a, b) => a + b, 0);
const fractionSize = 100 / totalFractions;
const rowWidth = layout.reduce(
(acc, curr, i) => {
return {
expected: acc.expected + (fractionSize / 100) * containerWidth * curr,
min: acc.min + parseFloat(minWidth) + marginX
};
},
{ expected: 0, min: 0, max: null }
);
const newLayout = layout;
if (rowWidth.expected < rowWidth.min) {
newLayout.pop();
}
const newTotalFractions = newLayout.reduce((a, b) => a + b, 0);
const newFractionSize = 100 / newTotalFractions;
const newColumnAmount = newLayout.length;
return {
layout: newLayout,
totalFractions: newTotalFractions,
fractionSize: newFractionSize,
columnAmount: newColumnAmount
};
}
export function Columns(props: ColumnsProps) {
if (!props.children) return null;
let columnLayout = null;
const containerRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(null);
useEffect(() => {
const container = containerRef?.current;
if (!container) return;
const observer = new ResizeObserver(() => {
const container = containerRef.current;
if (!container) return;
setContainerWidth(container.offsetWidth);
});
observer.observe(container);
return () => {
observer.disconnect();
};
}, []);
switch (typeof props.layoutString) {
case 'string':
columnLayout = props.layoutString.trim();
break;
case 'number':
columnLayout = String(props.layoutString).trim();
break;
default:
columnLayout = null;
}
if (!columnLayout) {
return <>{props.children}</>;
}
// all data for childrens width calculation
const targetLayout = columnLayout.split(' ').map((number) => parseInt(number));
// constraints
const { layout, columnAmount, fractionSize } = calcAutofold(
targetLayout,
props.minWidth,
containerWidth,
props.marginX
);
let children = [];
let forEachComponent = null;
// ForEachCompoent breaks the layout but is needed to send onMount/onUnmount
if (!Array.isArray(props.children)) {
children = [props.children];
} else {
children = props.children.filter((child) => child.type !== ForEachComponent);
forEachComponent = props.children.find((child) => child.type === ForEachComponent);
}
return (
<div
className={['columns-container', props.className].join(' ')}
ref={containerRef}
style={{
marginTop: parseFloat(props.marginY) * -1,
marginLeft: parseFloat(props.marginX) * -1,
display: 'flex',
flexWrap: 'wrap',
alignItems: 'stretch',
justifyContent: props.justifyContent,
flexDirection: props.direction,
width: `calc(100% + (${parseFloat(props.marginX)}px)`,
boxSizing: 'border-box',
...props.style
}}
>
{forEachComponent && forEachComponent}
{children.map((child, i) => {
return (
<div
className="column-item"
key={i}
style={{
boxSizing: 'border-box',
paddingTop: props.marginY,
paddingLeft: props.marginX,
width: layout[i % columnAmount] * fractionSize + '%',
flexShrink: 0,
flexGrow: 0,
minWidth: props.minWidth
// maxWidths needs some more thought
//maxWidth: getMinMaxInputValues(maxWidths, columnAmount, props.marginX, i)
}}
>
{React.cloneElement(child)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1 @@
export * from './Columns';

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Drag } from './Drag';
export default {
title: 'CATEGORY_HERE/Drag',
component: Drag,
argTypes: {},
} as ComponentMeta<typeof Drag>;
const Template: ComponentStory<typeof Drag> = (args) => <Drag {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,159 @@
import React from 'react';
import Draggable from 'react-draggable';
import EaseCurves from '../../../easecurves';
import { Noodl } from '../../../types';
export interface DragProps extends Noodl.ReactProps {
inputPositionX: number;
inputPositionY: number;
enabled: boolean;
scale: number;
axis: 'x' | 'y' | 'both';
useParentBounds: boolean;
onStart?: () => void;
onStop?: () => void;
onDrag?: () => void;
positionX?: (value: number) => void;
positionY?: (value: number) => void;
deltaX?: (value: number) => void;
deltaY?: (value: number) => void;
}
function setDragValues(event, props) {
props.positionX && props.positionX(event.x);
props.positionY && props.positionY(event.y);
props.deltaX && props.deltaX(event.deltaX);
props.deltaY && props.deltaY(event.deltaY);
}
type State = {
x: number
y: number
}
export class Drag extends React.Component<DragProps, State> {
snapToPositionXTimer: any;
snapToPositionYTimer: any;
constructor(props: DragProps) {
super(props);
this.state = { x: 0, y: 0 } satisfies State;
}
snapToPosition({ timerScheduler, propCallback, duration, axis, endValue }) {
const _this = this;
return timerScheduler
.createTimer({
duration: duration === undefined ? 300 : duration,
startValue: this.state[axis],
endValue: endValue,
ease: EaseCurves.easeOut,
onRunning: function (t) {
const value = this.ease(this.startValue, this.endValue, t);
// @ts-expect-error Either x or y...
_this.setState({ [axis]: value });
propCallback && propCallback(value);
}
})
.start();
}
componentDidMount() {
const x = this.props.inputPositionX ? this.props.inputPositionX : 0;
const y = this.props.inputPositionY ? this.props.inputPositionY : 0;
this.setState({ x, y });
setDragValues({ x, y, deltaX: 0, deltaY: 0 }, this.props);
}
UNSAFE_componentWillReceiveProps(nextProps: DragProps) {
const props = this.props;
if (props.inputPositionX !== nextProps.inputPositionX) {
this.setState({ x: nextProps.inputPositionX });
props.positionX && props.positionX(nextProps.inputPositionX);
props.deltaX && props.deltaX(nextProps.inputPositionX - props.inputPositionX);
}
if (props.inputPositionY !== nextProps.inputPositionY) {
this.setState({ y: nextProps.inputPositionY });
props.positionY && props.positionY(nextProps.inputPositionY);
props.deltaY && props.deltaY(nextProps.inputPositionY - props.inputPositionY);
}
}
snapToPositionX(x, duration) {
if (this.state.x === x) return;
this.snapToPositionXTimer && this.snapToPositionXTimer.stop();
this.snapToPositionXTimer = this.snapToPosition({
timerScheduler: this.props.noodlNode.context.timerScheduler,
propCallback: this.props.positionX,
duration,
axis: 'x',
endValue: x
});
}
snapToPositionY(y, duration) {
if (this.state.y === y) return;
this.snapToPositionYTimer && this.snapToPositionYTimer.stop();
this.snapToPositionYTimer = this.snapToPosition({
timerScheduler: this.props.noodlNode.context.timerScheduler,
propCallback: this.props.positionY,
duration,
axis: 'y',
endValue: y
});
}
render() {
const props = this.props;
const bounds = props.useParentBounds ? 'parent' : undefined;
let child;
if (React.Children.count(props.children) > 0) {
child = React.Children.toArray(props.children)[0];
} else {
return null;
}
return (
<Draggable
axis={props.axis}
bounds={bounds}
disabled={props.enabled === false}
scale={props.scale || 0}
position={{ x: this.state.x, y: this.state.y }}
onStart={(e, data) => {
setDragValues(data, props);
props.onStart && props.onStart();
this.snapToPositionXTimer && this.snapToPositionXTimer.stop();
this.snapToPositionYTimer && this.snapToPositionYTimer.stop();
}}
onStop={(e, data) => {
if (props.axis === 'x' || props.axis === 'both') {
this.setState({ x: data.x });
}
if (props.axis === 'y' || props.axis === 'both') {
this.setState({ y: data.y });
}
props.positionX && props.positionX(data.x);
props.positionY && props.positionY(data.y);
props.onStop && props.onStop();
}}
onDrag={(e, data) => {
setDragValues(data, props);
props.onDrag && props.onDrag();
}}
>
{React.cloneElement(child, { parentLayout: props.parentLayout })}
</Draggable>
);
}
}

View File

@@ -0,0 +1 @@
export * from './Drag'

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import React from 'react';
import { Group } from './Group';
export default {
title: 'CATEGORY_HERE/Group',
component: Group,
argTypes: {}
} as ComponentMeta<typeof Group>;
const Template: ComponentStory<typeof Group> = (args) => <Group {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,283 @@
import BScroll from '@better-scroll/core';
import MouseWheel from '@better-scroll/mouse-wheel';
import ScrollBar from '@better-scroll/scroll-bar';
import React from 'react';
import ReactDOM from 'react-dom';
import Layout from '../../../layout';
import PointerListeners from '../../../pointerlisteners';
import { Noodl } from '../../../types';
import NestedScroll from './scroll-plugins/nested-scroll-plugin';
import patchedMomentum from './scroll-plugins/patched-momentum-scroll';
import Slide from './scroll-plugins/slide-scroll-plugin';
BScroll.use(ScrollBar);
BScroll.use(NestedScroll);
BScroll.use(MouseWheel);
BScroll.use(Slide);
export interface GroupProps extends Noodl.ReactProps {
as?: keyof JSX.IntrinsicElements | React.ComponentType<unknown>;
scrollSnapEnabled: boolean;
showScrollbar: boolean;
scrollEnabled: boolean;
nativeScroll: boolean;
scrollSnapToEveryItem: boolean;
flexWrap: 'nowrap' | 'wrap' | 'wrap-reverse';
scrollBounceEnabled: boolean;
clip: boolean;
layout: 'none' | 'row' | 'column';
dom;
onScrollPositionChanged?: (value: number) => void;
onScrollStart?: () => void;
onScrollEnd?: () => void;
}
type ScrollRef = HTMLDivElement & { noodlNode?: Noodl.ReactProps['noodlNode'] };
export class Group extends React.Component<GroupProps> {
scrollNeedsToInit: boolean;
scrollRef: React.RefObject<ScrollRef>;
iScroll?: BScroll;
constructor(props: GroupProps) {
super(props);
this.scrollNeedsToInit = false;
this.scrollRef = React.createRef();
}
componentDidMount() {
if (this.props.scrollEnabled && this.props.nativeScroll !== true) {
this.setupIScroll();
}
//plumbing for the focused signals
this.scrollRef.current.noodlNode = this.props.noodlNode;
}
componentWillUnmount() {
if (this.iScroll) {
this.iScroll.destroy();
this.iScroll = undefined;
}
this.props.noodlNode.context.setNodeFocused(this.props.noodlNode, false);
}
componentDidUpdate() {
if (this.scrollNeedsToInit) {
this.setupIScroll();
this.scrollNeedsToInit = false;
}
if (this.iScroll) {
setTimeout(() => {
this.iScroll && this.iScroll.refresh();
}, 0);
}
}
scrollToIndex(index, duration) {
if (this.iScroll) {
const child = this.scrollRef.current.children[0].children[index] as HTMLElement;
if (child) {
this.iScroll.scrollToElement(child, duration, 0, 0);
}
} else {
const child = this.scrollRef.current.children[index];
child &&
child.scrollIntoView({
behavior: 'smooth'
});
}
}
scrollToElement(noodlChild, duration) {
if (!noodlChild) return;
// eslint-disable-next-line react/no-find-dom-node
const element = ReactDOM.findDOMNode(noodlChild.getRef()) as HTMLElement;
if (element && element.scrollIntoView) {
if (this.iScroll) {
this.iScroll.scrollToElement(element, duration, 0, 0);
} else {
element.scrollIntoView({
behavior: 'smooth'
});
}
}
}
setupIScroll() {
const { scrollSnapEnabled } = this.props;
const scrollDirection = this.getScrollDirection();
const snapOptions = {
disableSetWidth: true,
disableSetHeight: true,
loop: false
};
const domElement = this.scrollRef.current;
this.iScroll = new BScroll(domElement, {
bounceTime: 500,
swipeBounceTime: 300,
scrollbar: this.props.showScrollbar ? {} : undefined,
momentum: scrollSnapEnabled ? !this.props.scrollSnapToEveryItem : true,
bounce: this.props.scrollBounceEnabled && !(scrollSnapEnabled && snapOptions.loop),
scrollX: scrollDirection === 'x' || scrollDirection === 'both',
scrollY: scrollDirection === 'y' || scrollDirection === 'both',
slide: scrollSnapEnabled ? snapOptions : undefined,
probeType: this.props.onScrollPositionChanged ? 3 : 1,
click: true,
nestedScroll: true,
//disable CSS animation, they can cause a flicker on iOS,
//and cause problems with probing the scroll position during an animation
useTransition: false
});
//the scroll behavior when doing a momentum scroll that reaches outside the bounds
//does a slow and unpleasant animation. Let's patch it to make it behave more like iScroll.
const scroller = this.iScroll.scroller;
// @ts-expect-error momentum does exist
scroller.scrollBehaviorX && (scroller.scrollBehaviorX.momentum = patchedMomentum.bind(scroller.scrollBehaviorX));
// @ts-expect-error momentum does exist
scroller.scrollBehaviorY && (scroller.scrollBehaviorY.momentum = patchedMomentum.bind(scroller.scrollBehaviorY));
//refresh the scroll view in case a child has changed height, e.g. an image loaded
//seem to be very performant, no observed problem so far
this.iScroll.on('beforeScrollStart', () => {
this.iScroll.refresh();
});
this.iScroll.on('scrollStart', () => {
this.props.onScrollStart && this.props.onScrollStart();
});
this.iScroll.on('scrollEnd', () => {
this.props.onScrollEnd && this.props.onScrollEnd();
});
if (this.props.onScrollPositionChanged) {
this.iScroll.on('scroll', () => {
this.props.onScrollPositionChanged(scrollDirection === 'x' ? -this.iScroll.x : -this.iScroll.y);
});
}
}
UNSAFE_componentWillReceiveProps(nextProps: GroupProps) {
const scrollHasUpdated =
this.props.scrollSnapEnabled !== nextProps.scrollSnapEnabled ||
this.props.onScrollPositionChanged !== nextProps.onScrollPositionChanged ||
this.props.onScrollStart !== nextProps.onScrollStart ||
this.props.onScrollEnd !== nextProps.onScrollEnd ||
this.props.showScrollbar !== nextProps.showScrollbar ||
this.props.scrollEnabled !== nextProps.scrollEnabled ||
this.props.nativeScroll !== nextProps.nativeScroll ||
this.props.scrollSnapToEveryItem !== nextProps.scrollSnapToEveryItem ||
this.props.layout !== nextProps.layout ||
this.props.flexWrap !== nextProps.flexWrap ||
this.props.scrollBounceEnabled !== nextProps.scrollBounceEnabled;
if (scrollHasUpdated) {
if (this.iScroll) {
this.iScroll.destroy();
this.iScroll = undefined;
}
this.scrollNeedsToInit = nextProps.scrollEnabled && !nextProps.nativeScroll;
}
}
renderIScroll() {
const { flexDirection, flexWrap } = this.props.style;
const childStyle: React.CSSProperties = {
display: 'inline-flex',
flexShrink: 0,
flexDirection,
flexWrap,
touchAction: 'none'
// pointerEvents: this.state.isScrolling ? 'none' : undefined
};
if (flexDirection === 'row') {
if (flexWrap === 'wrap') {
childStyle.width = '100%';
} else {
childStyle.height = '100%';
}
} else {
if (flexWrap === 'wrap') {
childStyle.height = '100%';
} else {
childStyle.width = '100%';
}
}
return (
<div className="scroll-wrapper-internal" style={childStyle}>
{this.props.children}
</div>
);
}
getScrollDirection(): 'x' | 'y' | 'both' {
// TODO: This never returns both, why?
if (this.props.flexWrap === 'wrap' || this.props.flexWrap === 'wrap-reverse') {
return this.props.layout === 'row' ? 'y' : 'x';
}
return this.props.layout === 'row' ? 'x' : 'y';
}
render() {
const {
as: Component = 'div',
...props
} = this.props;
const children = props.scrollEnabled && !props.nativeScroll ? this.renderIScroll() : props.children;
const style = { ...props.style };
Layout.size(style, props);
Layout.align(style, props);
if (props.clip) {
style.overflowX = 'hidden';
style.overflowY = 'hidden';
}
if (props.scrollEnabled && props.nativeScroll) {
const scrollDirection = this.getScrollDirection();
if (scrollDirection === 'y') {
style.overflowY = 'auto';
} else if (scrollDirection === 'x') {
style.overflowX = 'auto';
} else if (scrollDirection === 'both') {
style.overflowX = 'auto';
style.overflowY = 'auto';
}
}
if (style.opacity === 0) {
style.pointerEvents = 'none';
}
return (
<Component
// @ts-expect-error Lets hope that the type passed here is always static!
className={props.className}
{...props.dom}
{...PointerListeners(props)}
style={style}
ref={this.scrollRef}
>
{children}
</Component>
);
}
}

View File

@@ -0,0 +1 @@
export * from './Group';

View File

@@ -0,0 +1,212 @@
/*!
* better-scroll / nested-scroll
* (c) 2016-2020 ustbhuangyi
* Released under the MIT License.
*/
// Slightly modified to for Noodl to fix a problem:
// - click bug. Click could be disabled and never enabled again when scrolling
// - click bug 2. Click option didn't support the scroller being moved around in the dom tree
// It assumed the nested scroll relationships always stay the same
// (so doesn't work with delta updates)
var compatibleFeatures = {
duplicateClick: function (_a) {
var parentScroll = _a[0],
childScroll = _a[1];
// no need to make childScroll's click true
if (parentScroll.options.click && childScroll.options.click) {
childScroll.options.click = false;
}
},
nestedScroll: function (scrollsPair) {
var parentScroll = scrollsPair[0],
childScroll = scrollsPair[1];
var parentScrollX = parentScroll.options.scrollX;
var parentScrollY = parentScroll.options.scrollY;
var childScrollX = childScroll.options.scrollX;
var childScrollY = childScroll.options.scrollY;
// vertical nested in vertical scroll and horizontal nested in horizontal
// otherwise, no need to handle.
if (parentScrollX === childScrollX || parentScrollY === childScrollY) {
scrollsPair.forEach(function (scroll, index) {
var oppositeScroll = scrollsPair[(index + 1) % 2];
scroll.on('scrollStart', function () {
if (oppositeScroll.pending) {
oppositeScroll.stop();
oppositeScroll.resetPosition();
}
setupData(oppositeScroll);
oppositeScroll.disable();
});
scroll.on('touchEnd', function () {
oppositeScroll.enable();
});
});
childScroll.on('scrollStart', function () {
if (checkBeyondBoundary(childScroll)) {
childScroll.disable();
parentScroll.enable();
}
});
}
}
};
var NestedScroll = /** @class */ (function () {
function NestedScroll(scroll) {
var singleton = NestedScroll.nestedScroll;
if (!(singleton instanceof NestedScroll)) {
singleton = NestedScroll.nestedScroll = this;
singleton.stores = [];
}
singleton.setup(scroll);
singleton.addHooks(scroll);
return singleton;
}
NestedScroll.prototype.setup = function (scroll) {
this.appendBScroll(scroll);
this.handleContainRelationship();
this.handleCompatible();
};
NestedScroll.prototype.addHooks = function (scroll) {
var _this = this;
scroll.on('destroy', function () {
_this.teardown(scroll);
});
};
NestedScroll.prototype.teardown = function (scroll) {
this.removeBScroll(scroll);
this.handleContainRelationship();
this.handleCompatible();
};
NestedScroll.prototype.appendBScroll = function (scroll) {
this.stores.push(scroll);
};
NestedScroll.prototype.removeBScroll = function (scroll) {
var index = this.stores.indexOf(scroll);
if (index === -1) return;
scroll.wrapper.isBScrollContainer = undefined;
this.stores.splice(index, 1);
};
NestedScroll.prototype.handleContainRelationship = function () {
// bs's length <= 1
var stores = this.stores;
if (stores.length <= 1) {
// there is only a childBScroll left.
if (stores[0] && stores[0].__parentInfo) {
stores[0].__parentInfo = undefined;
}
return;
}
var outerBS;
var outerBSWrapper;
var innerBS;
var innerBSWrapper;
// Need two layers of "For loop" to calculate parent-child relationship
for (var i = 0; i < stores.length; i++) {
outerBS = stores[i];
outerBSWrapper = outerBS.wrapper;
for (var j = 0; j < stores.length; j++) {
innerBS = stores[j];
innerBSWrapper = innerBS.wrapper;
// same bs
if (outerBS === innerBS) continue;
// now start calculating
if (!innerBSWrapper.contains(outerBSWrapper)) continue;
// now innerBS contains outerBS
// no parentInfo yet
if (!outerBS.__parentInfo) {
outerBS.__parentInfo = {
parent: innerBS,
depth: calculateDepths(outerBSWrapper, innerBSWrapper)
};
} else {
// has parentInfo already!
// just judge the "true" parent by depth
// we regard the latest node as parent, not the furthest
var currentDepths = calculateDepths(outerBSWrapper, innerBSWrapper);
var prevDepths = outerBS.__parentInfo.depth;
// refresh currentBS as parentScroll
if (prevDepths > currentDepths) {
outerBS.__parentInfo = {
parent: innerBS,
depth: currentDepths
};
}
}
}
}
};
NestedScroll.prototype.handleCompatible = function () {
var pairs = this.availableBScrolls();
var keys = ['duplicateClick', 'nestedScroll'];
pairs.forEach(function (pair) {
keys.forEach(function (key) {
compatibleFeatures[key](pair);
});
});
};
NestedScroll.prototype.availableBScrolls = function () {
var ret = [];
ret = this.stores
.filter(function (bs) {
return !!bs.__parentInfo;
})
.map(function (bs) {
return [bs.__parentInfo.parent, bs];
});
return ret;
};
NestedScroll.pluginName = 'nestedScroll';
return NestedScroll;
})();
function calculateDepths(childNode, parentNode) {
var depth = 0;
var parent = childNode.parentNode;
while (parent && parent !== parentNode) {
depth++;
parent = parent.parentNode;
}
return depth;
}
function checkBeyondBoundary(scroll) {
var _a = hasScroll(scroll),
hasHorizontalScroll = _a.hasHorizontalScroll,
hasVerticalScroll = _a.hasVerticalScroll;
var _b = scroll.scroller,
scrollBehaviorX = _b.scrollBehaviorX,
scrollBehaviorY = _b.scrollBehaviorY;
var hasReachLeft = scroll.x >= scroll.minScrollX && scrollBehaviorX.movingDirection === -1;
var hasReachRight = scroll.x <= scroll.maxScrollX && scrollBehaviorX.movingDirection === 1;
var hasReachTop = scroll.y >= scroll.minScrollY && scrollBehaviorY.movingDirection === -1;
var hasReachBottom = scroll.y <= scroll.maxScrollY && scrollBehaviorY.movingDirection === 1;
if (hasVerticalScroll) {
return hasReachTop || hasReachBottom;
} else if (hasHorizontalScroll) {
return hasReachLeft || hasReachRight;
}
return false;
}
function setupData(scroll) {
var _a = hasScroll(scroll),
hasHorizontalScroll = _a.hasHorizontalScroll,
hasVerticalScroll = _a.hasVerticalScroll;
var _b = scroll.scroller,
actions = _b.actions,
scrollBehaviorX = _b.scrollBehaviorX,
scrollBehaviorY = _b.scrollBehaviorY;
actions.startTime = +new Date();
if (hasVerticalScroll) {
scrollBehaviorY.startPos = scrollBehaviorY.currentPos;
} else if (hasHorizontalScroll) {
scrollBehaviorX.startPos = scrollBehaviorX.currentPos;
}
}
function hasScroll(scroll) {
return {
hasHorizontalScroll: scroll.scroller.scrollBehaviorX.hasScroll,
hasVerticalScroll: scroll.scroller.scrollBehaviorY.hasScroll
};
}
export default NestedScroll;

View File

@@ -0,0 +1,30 @@
module.exports = function patchedMomentum(current, start, time, lowerMargin, upperMargin, wrapperSize, options) {
if (options === void 0) {
options = this.options;
}
var distance = current - start;
var speed = Math.abs(distance) / time;
var deceleration = options.deceleration,
swipeBounceTime = options.swipeBounceTime,
swipeTime = options.swipeTime;
var momentumData = {
destination: current + (speed / deceleration) * (distance < 0 ? -1 : 1),
duration: swipeTime,
rate: 15
};
this.hooks.trigger(this.hooks.eventTypes.momentum, momentumData, distance);
if (momentumData.destination < lowerMargin) {
momentumData.destination = wrapperSize
? Math.max(lowerMargin - wrapperSize / 4, lowerMargin - (wrapperSize / momentumData.rate) * speed)
: lowerMargin;
momentumData.duration = Math.abs(momentumData.destination - current) / speed;
} else if (momentumData.destination > upperMargin) {
momentumData.destination = wrapperSize
? Math.min(upperMargin + wrapperSize / 4, upperMargin + (wrapperSize / momentumData.rate) * speed)
: upperMargin;
// momentumData.duration = swipeBounceTime;
momentumData.duration = Math.abs(momentumData.destination - current) / speed;
}
momentumData.destination = Math.round(momentumData.destination);
return momentumData;
};

View File

@@ -0,0 +1,940 @@
/*!
* better-scroll / slide
* (c) 2016-2020 ustbhuangyi
* Released under the MIT License.
*/
//adapted to Noodl's more dynamic delta update environment:
// - A scroll refresh doesn't reset the current slide page position
// - Horizontal slider doesn't break when there are no children
function warn(msg) {
console.error('[BScroll warn]: ' + msg);
}
// ssr support
var inBrowser = typeof window !== 'undefined';
var ua = inBrowser && navigator.userAgent.toLowerCase();
var isWeChatDevTools = ua && /wechatdevtools/.test(ua);
var isAndroid = ua && ua.indexOf('android') > 0;
function extend(target) {
var rest = [];
for (var _i = 1; _i < arguments.length; _i++) {
rest[_i - 1] = arguments[_i];
}
for (var i = 0; i < rest.length; i++) {
var source = rest[i];
for (var key in source) {
target[key] = source[key];
}
}
return target;
}
function fixInboundValue(x, min, max) {
if (x < min) {
return min;
}
if (x > max) {
return max;
}
return x;
}
var elementStyle = inBrowser && document.createElement('div').style;
var vendor = (function () {
if (!inBrowser) {
return false;
}
var transformNames = {
webkit: 'webkitTransform',
Moz: 'MozTransform',
O: 'OTransform',
ms: 'msTransform',
standard: 'transform'
};
for (var key in transformNames) {
if (elementStyle[transformNames[key]] !== undefined) {
return key;
}
}
return false;
})();
function prefixStyle(style) {
if (vendor === false) {
return style;
}
if (vendor === 'standard') {
if (style === 'transitionEnd') {
return 'transitionend';
}
return style;
}
return vendor + style.charAt(0).toUpperCase() + style.substr(1);
}
var cssVendor = vendor && vendor !== 'standard' ? '-' + vendor.toLowerCase() + '-' : '';
var transform = prefixStyle('transform');
var transition = prefixStyle('transition');
var hasPerspective = inBrowser && prefixStyle('perspective') in elementStyle;
var style = {
transform: transform,
transition: transition,
transitionTimingFunction: prefixStyle('transitionTimingFunction'),
transitionDuration: prefixStyle('transitionDuration'),
transitionDelay: prefixStyle('transitionDelay'),
transformOrigin: prefixStyle('transformOrigin'),
transitionEnd: prefixStyle('transitionEnd')
};
function getRect(el) {
if (el instanceof window.SVGElement) {
var rect = el.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height
};
} else {
return {
top: el.offsetTop,
left: el.offsetLeft,
width: el.offsetWidth,
height: el.offsetHeight
};
}
}
function prepend(el, target) {
var firstChild = target.firstChild;
if (firstChild) {
before(el, firstChild);
} else {
target.appendChild(el);
}
}
function before(el, target) {
target.parentNode.insertBefore(el, target);
}
function removeChild(el, child) {
el.removeChild(child);
}
var ease = {
// easeOutQuint
swipe: {
style: 'cubic-bezier(0.23, 1, 0.32, 1)',
fn: function (t) {
return 1 + --t * t * t * t * t;
}
},
// easeOutQuard
swipeBounce: {
style: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
fn: function (t) {
return t * (2 - t);
}
},
// easeOutQuart
bounce: {
style: 'cubic-bezier(0.165, 0.84, 0.44, 1)',
fn: function (t) {
return 1 - --t * t * t * t;
}
}
};
var DEFAULT_INTERVAL = 100 / 60;
var windowCompat = inBrowser && window;
function noop() {}
var requestAnimationFrame = (function () {
if (!inBrowser) {
/* istanbul ignore if */
return noop;
}
return (
windowCompat.requestAnimationFrame ||
windowCompat.webkitRequestAnimationFrame ||
windowCompat.mozRequestAnimationFrame ||
windowCompat.oRequestAnimationFrame ||
// if all else fails, use setTimeout
function (callback) {
return window.setTimeout(callback, (callback.interval || DEFAULT_INTERVAL) / 2); // make interval as precise as possible.
}
);
})();
var cancelAnimationFrame = (function () {
if (!inBrowser) {
/* istanbul ignore if */
return noop;
}
return (
windowCompat.cancelAnimationFrame ||
windowCompat.webkitCancelAnimationFrame ||
windowCompat.mozCancelAnimationFrame ||
windowCompat.oCancelAnimationFrame ||
function (id) {
window.clearTimeout(id);
}
);
})();
var PagesPos = /** @class */ (function () {
function PagesPos(scroll, slideOpt) {
this.scroll = scroll;
this.slideOpt = slideOpt;
this.slideEl = null;
this.init();
}
PagesPos.prototype.init = function () {
var scrollerIns = this.scroll.scroller;
var scrollBehaviorX = scrollerIns.scrollBehaviorX;
var scrollBehaviorY = scrollerIns.scrollBehaviorY;
var wrapper = getRect(scrollerIns.wrapper);
var scroller = getRect(scrollerIns.content);
this.wrapperWidth = wrapper.width;
this.wrapperHeight = wrapper.height;
this.scrollerHeight = scrollBehaviorY.hasScroll ? scroller.height : wrapper.height;
this.scrollerWidth = scrollBehaviorX.hasScroll ? scroller.width : wrapper.width;
var stepX = this.slideOpt.stepX || this.wrapperWidth;
var stepY = this.slideOpt.stepY || this.wrapperHeight;
var slideEls = scrollerIns.content;
var el = this.slideOpt.el;
if (typeof el === 'string') {
this.slideEl = slideEls.querySelectorAll(el);
}
this.pages = this.slideEl ? this.computePagePosInfoByEl(this.slideEl) : this.computePagePosInfo(stepX, stepY);
this.xLen = this.pages ? this.pages.length : 0;
this.yLen = this.pages && this.pages[0] ? this.pages[0].length : 0;
};
PagesPos.prototype.hasInfo = function () {
if (!this.pages || !this.pages.length) {
return false;
}
return true;
};
PagesPos.prototype.getPos = function (x, y) {
return this.pages[x] ? this.pages[x][y] : null;
};
PagesPos.prototype.getNearestPage = function (x, y) {
if (!this.hasInfo()) {
return;
}
var pageX = 0;
var pageY = 0;
var l = this.pages.length;
for (; pageX < l - 1; pageX++) {
if (x >= this.pages[pageX][0].cx) {
break;
}
}
l = this.pages[pageX].length;
for (; pageY < l - 1; pageY++) {
if (y >= this.pages[0][pageY].cy) {
break;
}
}
return {
pageX: pageX,
pageY: pageY
};
};
PagesPos.prototype.computePagePosInfo = function (stepX, stepY) {
var pages = [];
var x = 0;
var y;
var cx;
var cy;
var i = 0;
var l;
var maxScrollPosX = this.scroll.scroller.scrollBehaviorX.maxScrollPos;
var maxScrollPosY = this.scroll.scroller.scrollBehaviorY.maxScrollPos;
cx = Math.round(stepX / 2);
cy = Math.round(stepY / 2);
while (x > -this.scrollerWidth) {
pages[i] = [];
l = 0;
y = 0;
while (y > -this.scrollerHeight) {
pages[i][l] = {
x: Math.max(x, maxScrollPosX),
y: Math.max(y, maxScrollPosY),
width: stepX,
height: stepY,
cx: x - cx,
cy: y - cy
};
y -= stepY;
l++;
}
x -= stepX;
i++;
}
return pages;
};
PagesPos.prototype.computePagePosInfoByEl = function (el) {
var pages = [];
var x = 0;
var y = 0;
var cx;
var cy;
var i = 0;
var l = el.length;
var m = 0;
var n = -1;
var rect;
var maxScrollX = this.scroll.scroller.scrollBehaviorX.maxScrollPos;
var maxScrollY = this.scroll.scroller.scrollBehaviorY.maxScrollPos;
for (; i < l; i++) {
rect = getRect(el[i]);
if (i === 0 || rect.left <= getRect(el[i - 1]).left) {
m = 0;
n++;
}
if (!pages[m]) {
pages[m] = [];
}
x = Math.max(-rect.left, maxScrollX);
y = Math.max(-rect.top, maxScrollY);
cx = x - Math.round(rect.width / 2);
cy = y - Math.round(rect.height / 2);
pages[m][n] = {
x: x,
y: y,
width: rect.width,
height: rect.height,
cx: cx,
cy: cy
};
if (x > maxScrollX) {
m++;
}
}
return pages;
};
return PagesPos;
})();
var PageInfo = /** @class */ (function () {
function PageInfo(scroll, slideOpt) {
this.scroll = scroll;
this.slideOpt = slideOpt;
}
PageInfo.prototype.init = function () {
this.currentPage = {
x: 0,
y: 0,
pageX: 0,
pageY: 0
};
this.pagesPos = new PagesPos(this.scroll, this.slideOpt);
this.checkSlideLoop();
};
PageInfo.prototype.changeCurrentPage = function (newPage) {
this.currentPage = newPage;
};
PageInfo.prototype.change2safePage = function (pageX, pageY) {
if (!this.pagesPos.hasInfo()) {
return;
}
if (pageX >= this.pagesPos.xLen) {
pageX = this.pagesPos.xLen - 1;
} else if (pageX < 0) {
pageX = 0;
}
if (pageY >= this.pagesPos.yLen) {
pageY = this.pagesPos.yLen - 1;
} else if (pageY < 0) {
pageY = 0;
}
var _a = this.pagesPos.getPos(pageX, pageY);
return {
pageX: pageX,
pageY: pageY,
x: _a ? _a.x : 0,
y: _a ? _a.y : 0
};
};
PageInfo.prototype.getInitPage = function () {
var initPageX = this.loopX ? 1 : 0;
var initPageY = this.loopY ? 1 : 0;
return {
pageX: initPageX,
pageY: initPageY
};
};
PageInfo.prototype.getRealPage = function (page) {
var fixedPage = function (page, realPageLen) {
var pageIndex = [];
for (var i = 0; i < realPageLen; i++) {
pageIndex.push(i);
}
pageIndex.unshift(realPageLen - 1);
pageIndex.push(0);
return pageIndex[page];
};
var currentPage = page ? extend({}, page) : extend({}, this.currentPage);
if (this.loopX) {
currentPage.pageX = fixedPage(currentPage.pageX, this.pagesPos.xLen - 2);
}
if (this.loopY) {
currentPage.pageY = fixedPage(currentPage.pageY, this.pagesPos.yLen - 2);
}
return {
pageX: currentPage.pageX,
pageY: currentPage.pageY
};
};
PageInfo.prototype.getPageSize = function () {
return this.pagesPos.getPos(this.currentPage.pageX, this.currentPage.pageY);
};
PageInfo.prototype.realPage2Page = function (x, y) {
if (!this.pagesPos.hasInfo()) {
return;
}
var lastX = this.pagesPos.xLen - 1;
var lastY = this.pagesPos.yLen - 1;
var firstX = 0;
var firstY = 0;
if (this.loopX) {
x += 1;
firstX = firstX + 1;
lastX = lastX - 1;
}
if (this.loopY) {
y += 1;
firstY = firstY + 1;
lastY = lastY - 1;
}
x = fixInboundValue(x, firstX, lastX);
y = fixInboundValue(y, firstY, lastY);
return {
realX: x,
realY: y
};
};
PageInfo.prototype.nextPage = function () {
return this.changedPageNum('positive' /* Positive */);
};
PageInfo.prototype.prevPage = function () {
return this.changedPageNum('negative' /* Negative */);
};
PageInfo.prototype.nearestPage = function (x, y, directionX, directionY) {
var pageInfo = this.pagesPos.getNearestPage(x, y);
if (!pageInfo) {
return {
x: 0,
y: 0,
pageX: 0,
pageY: 0
};
}
var pageX = pageInfo.pageX;
var pageY = pageInfo.pageY;
var newX;
var newY;
if (pageX === this.currentPage.pageX) {
pageX += directionX;
pageX = fixInboundValue(pageX, 0, this.pagesPos.xLen - 1);
}
if (pageY === this.currentPage.pageY) {
pageY += directionY;
pageY = fixInboundValue(pageInfo.pageY, 0, this.pagesPos.yLen - 1);
}
newX = this.pagesPos.getPos(pageX, 0).x;
newY = this.pagesPos.getPos(0, pageY).y;
return {
x: newX,
y: newY,
pageX: pageX,
pageY: pageY
};
};
PageInfo.prototype.getLoopStage = function () {
if (!this.needLoop) {
return 'middle' /* Middle */;
}
if (this.loopX) {
if (this.currentPage.pageX === 0) {
return 'head' /* Head */;
}
if (this.currentPage.pageX === this.pagesPos.xLen - 1) {
return 'tail' /* Tail */;
}
}
if (this.loopY) {
if (this.currentPage.pageY === 0) {
return 'head' /* Head */;
}
if (this.currentPage.pageY === this.pagesPos.yLen - 1) {
return 'tail' /* Tail */;
}
}
return 'middle' /* Middle */;
};
PageInfo.prototype.resetLoopPage = function () {
if (this.loopX) {
if (this.currentPage.pageX === 0) {
return {
pageX: this.pagesPos.xLen - 2,
pageY: this.currentPage.pageY
};
}
if (this.currentPage.pageX === this.pagesPos.xLen - 1) {
return {
pageX: 1,
pageY: this.currentPage.pageY
};
}
}
if (this.loopY) {
if (this.currentPage.pageY === 0) {
return {
pageX: this.currentPage.pageX,
pageY: this.pagesPos.yLen - 2
};
}
if (this.currentPage.pageY === this.pagesPos.yLen - 1) {
return {
pageX: this.currentPage.pageX,
pageY: 1
};
}
}
};
PageInfo.prototype.isSameWithCurrent = function (page) {
if (page.pageX !== this.currentPage.pageX || page.pageY !== this.currentPage.pageY) {
return false;
}
return true;
};
PageInfo.prototype.changedPageNum = function (direction) {
var x = this.currentPage.pageX;
var y = this.currentPage.pageY;
if (this.slideX) {
x = direction === 'negative' /* Negative */ ? x - 1 : x + 1;
}
if (this.slideY) {
y = direction === 'negative' /* Negative */ ? y - 1 : y + 1;
}
return {
pageX: x,
pageY: y
};
};
PageInfo.prototype.checkSlideLoop = function () {
this.needLoop = this.slideOpt.loop;
if (this.pagesPos.xLen > 1) {
this.slideX = true;
}
if (this.pagesPos.pages[0] && this.pagesPos.yLen > 1) {
this.slideY = true;
}
this.loopX = this.needLoop && this.slideX;
this.loopY = this.needLoop && this.slideY;
if (this.slideX && this.slideY) {
warn('slide does not support two direction at the same time.');
}
};
return PageInfo;
})();
var sourcePrefix = 'plugins.slide';
var propertiesMap = [
{
key: 'next',
name: 'next'
},
{
key: 'prev',
name: 'prev'
},
{
key: 'goToPage',
name: 'goToPage'
},
{
key: 'getCurrentPage',
name: 'getCurrentPage'
}
];
var propertiesConfig = propertiesMap.map(function (item) {
return {
key: item.key,
sourceKey: sourcePrefix + '.' + item.name
};
});
var Slide = /** @class */ (function () {
function Slide(scroll) {
this.scroll = scroll;
this.resetLooping = false;
this.isTouching = false;
this.scroll.proxy(propertiesConfig);
this.scroll.registerType(['slideWillChange']);
this.slideOpt = this.scroll.options.slide;
this.page = new PageInfo(scroll, this.slideOpt);
this.hooksFn = [];
this.willChangeToPage = {
pageX: 0,
pageY: 0
};
this.init();
}
Slide.prototype.init = function () {
var _this = this;
var slide = this.slideOpt;
var slideEls = this.scroll.scroller.content;
var lazyInitByRefresh = false;
if (slide.loop) {
var children = slideEls.children;
if (children.length > 1) {
this.cloneSlideEleForLoop(slideEls);
lazyInitByRefresh = true;
} else {
// Loop does not make any sense if there is only one child.
slide.loop = false;
}
}
var shouldRefreshByWidth = this.setSlideWidth(slideEls);
var shouldRefreshByHeight = this.setSlideHeight(this.scroll.scroller.wrapper, slideEls);
var shouldRefresh = shouldRefreshByWidth || shouldRefreshByHeight;
var scrollHooks = this.scroll.hooks;
var scrollerHooks = this.scroll.scroller.hooks;
this.registorHooks(scrollHooks, 'refresh', this.initSlideState);
this.registorHooks(scrollHooks, 'destroy', this.destroy);
this.registorHooks(scrollerHooks, 'momentum', this.modifyScrollMetaHandler);
// scrollEnd handler should be called before customized handlers
this.registorHooks(this.scroll, 'scrollEnd', this.amendCurrentPage);
this.registorHooks(scrollerHooks, 'beforeStart', this.setTouchFlag);
this.registorHooks(scrollerHooks, 'scroll', this.scrollMoving);
this.registorHooks(scrollerHooks, 'resize', this.resize);
// for mousewheel event
if (this.scroll.eventTypes.mousewheelMove && this.scroll.eventTypes.mousewheelEnd) {
this.registorHooks(this.scroll, 'mousewheelMove', function () {
// prevent default action of mousewheelMove
return true;
});
this.registorHooks(this.scroll, 'mousewheelEnd', function (delta) {
if (delta.directionX === 1 /* Positive */ || delta.directionY === 1 /* Positive */) {
_this.next();
}
if (delta.directionX === -1 /* Negative */ || delta.directionY === -1 /* Negative */) {
_this.prev();
}
});
}
if (slide.listenFlick !== false) {
this.registorHooks(scrollerHooks, 'flick', this.flickHandler);
}
if (!lazyInitByRefresh && !shouldRefresh) {
this.initSlideState();
} else {
this.scroll.refresh();
}
};
Slide.prototype.resize = function () {
var _this = this;
var slideEls = this.scroll.scroller.content;
var slideWrapper = this.scroll.scroller.wrapper;
clearTimeout(this.resizeTimeout);
this.resizeTimeout = window.setTimeout(function () {
_this.clearSlideWidth(slideEls);
_this.clearSlideHeight(slideEls);
_this.setSlideWidth(slideEls);
_this.setSlideHeight(slideWrapper, slideEls);
_this.scroll.refresh();
}, this.scroll.options.resizePolling);
return true;
};
Slide.prototype.next = function (time, easing) {
var _a = this.page.nextPage(),
pageX = _a.pageX,
pageY = _a.pageY;
this.goTo(pageX, pageY, time, easing);
};
Slide.prototype.prev = function (time, easing) {
var _a = this.page.prevPage(),
pageX = _a.pageX,
pageY = _a.pageY;
this.goTo(pageX, pageY, time, easing);
};
Slide.prototype.goToPage = function (x, y, time, easing) {
var pageInfo = this.page.realPage2Page(x, y);
if (!pageInfo) {
return;
}
this.goTo(pageInfo.realX, pageInfo.realY, time, easing);
};
Slide.prototype.getCurrentPage = function () {
return this.page.getRealPage();
};
Slide.prototype.nearestPage = function (x, y) {
var scrollBehaviorX = this.scroll.scroller.scrollBehaviorX;
var scrollBehaviorY = this.scroll.scroller.scrollBehaviorY;
var triggerThreshold = true;
if (
Math.abs(x - scrollBehaviorX.absStartPos) <= this.thresholdX &&
Math.abs(y - scrollBehaviorY.absStartPos) <= this.thresholdY
) {
triggerThreshold = false;
}
if (!triggerThreshold) {
return this.page.currentPage;
}
return this.page.nearestPage(
fixInboundValue(x, scrollBehaviorX.maxScrollPos, scrollBehaviorX.minScrollPos),
fixInboundValue(y, scrollBehaviorY.maxScrollPos, scrollBehaviorY.minScrollPos),
scrollBehaviorX.direction,
scrollBehaviorY.direction
);
};
Slide.prototype.destroy = function () {
var slideEls = this.scroll.scroller.content;
if (this.slideOpt.loop) {
var children = slideEls.children;
if (children.length > 2) {
removeChild(slideEls, children[children.length - 1]);
removeChild(slideEls, children[0]);
}
}
this.hooksFn.forEach(function (item) {
var hooks = item[0];
var hooksName = item[1];
var handlerFn = item[2];
if (hooks.eventTypes[hooksName]) {
hooks.off(hooksName, handlerFn);
}
});
this.hooksFn.length = 0;
};
Slide.prototype.initSlideState = function () {
const prevPage = this.page.currentPage;
this.page.init();
if (prevPage) {
this.page.currentPage = prevPage;
} else {
var initPage = this.page.getInitPage();
this.goTo(initPage.pageX, initPage.pageY, 0);
}
this.initThreshold();
};
Slide.prototype.initThreshold = function () {
var slideThreshold = this.slideOpt.threshold || 0.1;
if (slideThreshold % 1 === 0) {
this.thresholdX = slideThreshold;
this.thresholdY = slideThreshold;
} else {
var pageSize = this.page.getPageSize();
if (pageSize) {
this.thresholdX = Math.round(pageSize.width * slideThreshold);
this.thresholdY = Math.round(pageSize.height * slideThreshold);
}
}
};
Slide.prototype.cloneSlideEleForLoop = function (slideEls) {
var children = slideEls.children;
prepend(children[children.length - 1].cloneNode(true), slideEls);
slideEls.appendChild(children[1].cloneNode(true));
};
Slide.prototype.amendCurrentPage = function () {
this.isTouching = false;
if (!this.slideOpt.loop) {
return;
}
// triggered by resetLoop
if (this.resetLooping) {
this.resetLooping = false;
return;
}
// fix bug: scroll two page or even more page at once and fetch the boundary.
// In this case, momentum won't be trigger, so the pageIndex will be wrong and won't be trigger reset.
var isScrollToBoundary = false;
if (
this.page.loopX &&
(this.scroll.x === this.scroll.scroller.scrollBehaviorX.minScrollPos ||
this.scroll.x === this.scroll.scroller.scrollBehaviorX.maxScrollPos)
) {
isScrollToBoundary = true;
}
if (
this.page.loopY &&
(this.scroll.y === this.scroll.scroller.scrollBehaviorY.minScrollPos ||
this.scroll.y === this.scroll.scroller.scrollBehaviorY.maxScrollPos)
) {
isScrollToBoundary = true;
}
if (isScrollToBoundary) {
var scrollBehaviorX = this.scroll.scroller.scrollBehaviorX;
var scrollBehaviorY = this.scroll.scroller.scrollBehaviorY;
var newPos = this.page.nearestPage(
fixInboundValue(this.scroll.x, scrollBehaviorX.maxScrollPos, scrollBehaviorX.minScrollPos),
fixInboundValue(this.scroll.y, scrollBehaviorY.maxScrollPos, scrollBehaviorY.minScrollPos),
0,
0
);
var newPage = {
x: newPos.x,
y: newPos.y,
pageX: newPos.pageX,
pageY: newPos.pageY
};
if (!this.page.isSameWithCurrent(newPage)) {
this.page.changeCurrentPage(newPage);
}
}
var changePage = this.page.resetLoopPage();
if (changePage) {
this.resetLooping = true;
this.goTo(changePage.pageX, changePage.pageY, 0);
return true; // stop trigger chain
}
// amend willChangeToPage, because willChangeToPage maybe wrong when sliding quickly
this.pageWillChangeTo(this.page.currentPage);
};
Slide.prototype.shouldSetWidthHeight = function (checkType) {
var checkMap = {
width: ['scrollX', 'disableSetWidth'],
height: ['scrollY', 'disableSetHeight']
};
var checkOption = checkMap[checkType];
if (!this.scroll.options[checkOption[0]]) {
return false;
}
if (this.slideOpt[checkOption[1]]) {
return false;
}
return true;
};
Slide.prototype.clearSlideWidth = function (slideEls) {
if (!this.shouldSetWidthHeight('width')) {
return;
}
var children = slideEls.children;
for (var i = 0; i < children.length; i++) {
var slideItemDom = children[i];
slideItemDom.removeAttribute('style');
}
slideEls.removeAttribute('style');
};
Slide.prototype.setSlideWidth = function (slideEls) {
if (!this.shouldSetWidthHeight('width')) {
return false;
}
var children = slideEls.children;
var slideItemWidth = children[0].clientWidth;
for (var i = 0; i < children.length; i++) {
var slideItemDom = children[i];
slideItemDom.style.width = slideItemWidth + 'px';
}
slideEls.style.width = slideItemWidth * children.length + 'px';
return true;
};
Slide.prototype.clearSlideHeight = function (slideEls) {
if (!this.shouldSetWidthHeight('height')) {
return;
}
var children = slideEls.children;
for (var i = 0; i < children.length; i++) {
var slideItemDom = children[i];
slideItemDom.removeAttribute('style');
}
slideEls.removeAttribute('style');
};
// height change will not effect minScrollY & maxScrollY
Slide.prototype.setSlideHeight = function (slideWrapper, slideEls) {
if (!this.shouldSetWidthHeight('height')) {
return false;
}
var wrapperHeight = slideWrapper.clientHeight;
var children = slideEls.children;
for (var i = 0; i < children.length; i++) {
var slideItemDom = children[i];
slideItemDom.style.height = wrapperHeight + 'px';
}
slideEls.style.height = wrapperHeight * children.length + 'px';
return true;
};
Slide.prototype.goTo = function (pageX, pageY, time, easing) {
if (pageY === void 0) {
pageY = 0;
}
var newPageInfo = this.page.change2safePage(pageX, pageY);
if (!newPageInfo) {
return;
}
var scrollEasing = easing || this.slideOpt.easing || ease.bounce;
var posX = newPageInfo.x;
var posY = newPageInfo.y;
var deltaX = posX - this.scroll.scroller.scrollBehaviorX.currentPos;
var deltaY = posY - this.scroll.scroller.scrollBehaviorY.currentPos;
if (!deltaX && !deltaY) {
return;
}
time = time === undefined ? this.getAnimateTime(deltaX, deltaY) : time;
this.page.changeCurrentPage({
x: posX,
y: posY,
pageX: newPageInfo.pageX,
pageY: newPageInfo.pageY
});
this.pageWillChangeTo(this.page.currentPage);
this.scroll.scroller.scrollTo(posX, posY, time, scrollEasing);
};
Slide.prototype.flickHandler = function () {
var scrollBehaviorX = this.scroll.scroller.scrollBehaviorX;
var scrollBehaviorY = this.scroll.scroller.scrollBehaviorY;
var deltaX = scrollBehaviorX.currentPos - scrollBehaviorX.startPos;
var deltaY = scrollBehaviorY.currentPos - scrollBehaviorY.startPos;
var time = this.getAnimateTime(deltaX, deltaY);
this.goTo(
this.page.currentPage.pageX + scrollBehaviorX.direction,
this.page.currentPage.pageY + scrollBehaviorY.direction,
time
);
};
Slide.prototype.getAnimateTime = function (deltaX, deltaY) {
if (this.slideOpt.speed) {
return this.slideOpt.speed;
}
return Math.max(Math.max(Math.min(Math.abs(deltaX), 1000), Math.min(Math.abs(deltaY), 1000)), 300);
};
Slide.prototype.modifyScrollMetaHandler = function (scrollMeta) {
var newPos = this.nearestPage(scrollMeta.newX, scrollMeta.newY);
scrollMeta.time = this.getAnimateTime(scrollMeta.newX - newPos.x, scrollMeta.newY - newPos.y);
scrollMeta.newX = newPos.x;
scrollMeta.newY = newPos.y;
scrollMeta.easing = this.slideOpt.easing || ease.bounce;
this.page.changeCurrentPage({
x: scrollMeta.newX,
y: scrollMeta.newY,
pageX: newPos.pageX,
pageY: newPos.pageY
});
this.pageWillChangeTo(this.page.currentPage);
};
Slide.prototype.scrollMoving = function (point) {
if (this.isTouching) {
var newPos = this.nearestPage(point.x, point.y);
this.pageWillChangeTo(newPos);
}
};
Slide.prototype.pageWillChangeTo = function (newPage) {
var changeToPage = this.page.getRealPage(newPage);
if (changeToPage.pageX === this.willChangeToPage.pageX && changeToPage.pageY === this.willChangeToPage.pageY) {
return;
}
this.willChangeToPage = changeToPage;
this.scroll.trigger('slideWillChange', this.willChangeToPage);
};
Slide.prototype.setTouchFlag = function () {
this.isTouching = true;
};
Slide.prototype.registorHooks = function (hooks, name, handler) {
hooks.on(name, handler, this);
this.hooksFn.push([hooks, name, handler]);
};
Slide.pluginName = 'slide';
return Slide;
})();
export default Slide;

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Icon } from './Icon';
export default {
title: 'CATEGORY_HERE/Icon',
component: Icon,
argTypes: {},
} as ComponentMeta<typeof Icon>;
const Template: ComponentStory<typeof Icon> = (args) => <Icon {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,53 @@
import React from 'react';
import Layout from '../../../layout';
import { Noodl } from '../../../types';
export interface IconProps extends Noodl.ReactProps {
iconSourceType: 'image' | 'icon';
iconImageSource: Noodl.Image;
iconIconSource: Noodl.Icon;
iconSize: string;
iconColor: Noodl.Color;
}
export function Icon(props: IconProps) {
const style: React.CSSProperties = { userSelect: 'none', ...props.style };
Layout.size(style, props);
Layout.align(style, props);
function _renderIcon() {
const style: React.CSSProperties = {};
if (props.iconSourceType === 'image' && props.iconImageSource !== undefined) {
style.width = props.iconSize;
style.height = props.iconSize;
return <img alt="" src={props.iconImageSource} style={style} />;
} else if (props.iconSourceType === 'icon' && props.iconIconSource !== undefined) {
style.fontSize = props.iconSize;
style.color = props.iconColor;
style.lineHeight = 1;
return (
<div style={{ lineHeight: 0 }}>
{props.iconIconSource.codeAsClass === true ? (
<span className={[props.iconIconSource.class, props.iconIconSource.code].join(' ')} style={style}></span>
) : (
<span className={props.iconIconSource.class} style={style}>
{props.iconIconSource.code}
</span>
)}
</div>
);
}
return null;
}
let className = 'ndl-visual-icon';
if (props.className) className = className + ' ' + props.className;
return (
<div className={className} style={style}>
{_renderIcon()}
</div>
);
}

View File

@@ -0,0 +1 @@
export * from './Icon'

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import React from 'react';
import { Image } from './Image';
export default {
title: 'CATEGORY_HERE/Image',
component: Image,
argTypes: {}
} as ComponentMeta<typeof Image>;
const Template: ComponentStory<typeof Image> = (args) => <Image {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,34 @@
import React from 'react';
import Layout from '../../../layout';
import PointerListeners from '../../../pointerlisteners';
import { Noodl } from '../../../types';
export interface ImageProps extends Noodl.ReactProps {
dom: {
alt?: string;
src: string;
onLoad?: () => void;
};
}
export function Image(props: ImageProps) {
const style = { ...props.style };
Layout.size(style, props);
Layout.align(style, props);
if (style.opacity === 0) {
style.pointerEvents = 'none';
}
if (props.dom?.src?.startsWith('/')) {
// @ts-expect-error missing Noodl typings
const baseUrl = Noodl.Env['BaseUrl'];
if (baseUrl) {
props.dom.src = baseUrl + props.dom.src.substring(1);
}
}
return <img className={props.className} {...props.dom} {...PointerListeners(props)} style={style} />;
}

View File

@@ -0,0 +1 @@
export * from './Image';

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import React from 'react';
import { Text } from './Text';
export default {
title: 'CATEGORY_HERE/Text',
component: Text,
argTypes: {}
} as ComponentMeta<typeof Text>;
const Template: ComponentStory<typeof Text> = (args) => <Text {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,58 @@
import React from 'react';
import Layout from '../../../layout';
import PointerListeners from '../../../pointerlisteners';
import { Noodl } from '../../../types';
export interface TextProps extends Noodl.ReactProps {
as?: keyof JSX.IntrinsicElements | React.ComponentType<unknown>;
textStyle: Noodl.TextStyle;
text: string;
sizeMode?: Noodl.SizeMode;
width?: string;
height?: string;
fixedWidth?: boolean;
fixedHeight?: boolean;
// Extra Attributes
dom: Record<string, unknown>;
}
export function Text(props: TextProps) {
const { as: Component = 'div' } = props;
const style = {
...props.textStyle,
...props.style
};
Layout.size(style, props);
Layout.align(style, props);
style.color = props.noodlNode.context.styles.resolveColor(style.color);
// Respect '\n' in the string
if (props.sizeMode === 'contentSize' || props.sizeMode === 'contentWidth') {
style.whiteSpace = 'pre';
} else {
style.whiteSpace = 'pre-wrap';
style.overflowWrap = 'anywhere';
}
if (style.opacity === 0) {
style.pointerEvents = 'none';
}
return (
<Component
className={['ndl-visual-text', props.className].join(' ')}
{...props.dom}
{...PointerListeners(props)}
style={style}
>
{String(props.text)}
</Component>
);
}

View File

@@ -0,0 +1 @@
export * from './Text';

View File

@@ -0,0 +1,15 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import React from 'react';
import { Video } from './Video';
export default {
title: 'CATEGORY_HERE/Video',
component: Video,
argTypes: {}
} as ComponentMeta<typeof Video>;
const Template: ComponentStory<typeof Video> = (args) => <Video {...args} />;
export const Common = Template.bind({});
Common.args = {};

View File

@@ -0,0 +1,167 @@
import React from 'react';
import Layout from '../../../layout';
import PointerListeners from '../../../pointerlisteners';
import { Noodl } from '../../../types';
export interface VideoProps extends Noodl.ReactProps {
objectPositionX: string;
objectPositionY: string;
dom: Exclude<CachedVideoProps, 'innerRef' | 'onCanPlay'>;
onCanPlay?: () => void;
videoWidth?: (value: number) => void;
videoHeight?: (value: number) => void;
onVideoElementCreated?: (video) => void;
}
export interface CachedVideoProps {
className?: string;
style?: React.CSSProperties;
muted?: boolean;
loop?: boolean;
volume?: number;
autoplay?: boolean;
controls?: boolean;
src: string;
innerRef: (video: HTMLVideoElement) => void;
onCanPlay: () => void;
}
class CachedVideo extends React.PureComponent<CachedVideoProps> {
video: HTMLVideoElement;
shouldComponentUpdate(nextProps: CachedVideoProps) {
if (this.video) {
this.video.muted = nextProps.muted;
this.video.loop = nextProps.loop;
this.video.volume = nextProps.volume;
this.video.autoplay = nextProps.autoplay;
this.video.controls = nextProps.controls;
}
return true;
}
render() {
let src = this.props.src ? this.props.src.toString() : undefined;
if (src) {
if (src.indexOf('#t=') === -1) {
src += '#t=0.01'; //force Android to render the first frame
}
if (src.startsWith('/')) {
// @ts-expect-error missing Noodl typings
const baseUrl = Noodl.Env['BaseUrl'];
if (baseUrl) {
src = baseUrl + src.substring(1);
}
}
}
return (
<video
{...this.props}
playsInline={true}
src={src}
{...PointerListeners(this.props)}
ref={(video) => {
this.video = video;
this.props.innerRef(video);
}}
/>
);
}
}
export class Video extends React.Component<VideoProps> {
wantToPlay: boolean;
canPlay: boolean;
video: HTMLVideoElement;
constructor(props: VideoProps) {
super(props);
this.wantToPlay = false;
this.canPlay = false;
}
componentWillUnmount() {
this.canPlay = false;
}
setSourceObject(src) {
if (this.video.srcObject !== src) {
this.video.srcObject = src;
this.canPlay = false; //wait for can play event
}
}
play() {
this.wantToPlay = true;
if (this.canPlay) {
this.video.play();
}
}
restart() {
this.wantToPlay = true;
if (this.canPlay) {
this.video.currentTime = 0;
this.video.play();
}
}
pause() {
this.wantToPlay = false;
this.video && this.video.pause();
}
reset() {
this.wantToPlay = false;
if (this.video) {
this.video.currentTime = 0;
this.video.pause();
}
}
render() {
const props = this.props;
const style = {
...props.style
};
Layout.size(style, props);
Layout.align(style, props);
if (style.opacity === 0) {
style.pointerEvents = 'none';
}
style.objectPosition = `${props.objectPositionX} ${props.objectPositionY}`;
return (
<CachedVideo
{...props.dom}
className={props.className}
style={style}
innerRef={(video) => {
this.video = video;
this.props.onVideoElementCreated && this.props.onVideoElementCreated(video);
}}
onCanPlay={() => {
this.canPlay = true;
if (this.wantToPlay) {
this.video.play();
}
this.props.onCanPlay && this.props.onCanPlay();
this.props.videoWidth && this.props.videoWidth(this.video.videoWidth);
this.props.videoHeight && this.props.videoHeight(this.video.videoHeight);
}}
/>
);
}
}

View File

@@ -0,0 +1 @@
export * from './Video';

View File

@@ -0,0 +1 @@
export const flexDirectionValues = ['row', 'row-reverse', 'column', 'column-reverse', 'inherit', 'initial', 'revert', 'unset'];

View File

@@ -0,0 +1,15 @@
import React from 'react';
export interface IRadioButtonContext {
name: string;
selected: string;
checkedChanged?: (value: string) => void;
}
const RadioButtonContext = React.createContext<IRadioButtonContext>({
name: undefined,
selected: undefined,
checkedChanged: undefined
});
export default RadioButtonContext;

View File

@@ -0,0 +1,75 @@
export default class DOMBoundingBoxObserver {
constructor(callback, pollDelay) {
this.isRunning = false;
this.numBoundingBoxObservers = 0;
this.callback = callback;
this.pollDelay = pollDelay;
}
addObserver() {
this.numBoundingBoxObservers++;
if (!this.isRunning) {
this._startObserver();
}
}
removeObserver() {
this.numBoundingBoxObservers--;
if (this.numBoundingBoxObservers === 0) {
this._stopObserver();
}
}
setTarget(target) {
this.target = target;
if (this.numBoundingBoxObservers > 0 && !this.isRunning) {
this._startObserver();
}
}
_startObserver() {
if (this.isRunning) return;
if (!this.target) return;
this.isRunning = true;
let boundingRect = {};
const observer = () => {
if (!this.target) {
this.isRunning = false;
return;
}
const bb = this.target.getBoundingClientRect();
if (boundingRect.x !== bb.x) {
this.callback('x', bb);
}
if (boundingRect.y !== bb.y) {
this.callback('y', bb);
}
if (boundingRect.width !== bb.width) {
this.callback('width', bb);
}
if (boundingRect.height !== bb.height) {
this.callback('height', bb);
}
boundingRect = bb;
if (this.isRunning) {
if (this.pollDelay) {
setTimeout(observer, this.pollDelay);
} else {
//poll as quickly as possible
window.requestAnimationFrame(observer);
}
}
};
window.requestAnimationFrame(observer);
}
_stopObserver() {
this.isRunning = false;
}
}

View File

@@ -0,0 +1,58 @@
'use strict';
var EaseCurves = {
easeOutQuartic: function (start, end, t) {
t--;
return -(end - start) * (t * t * t * t - 1.0) + start;
},
easeInQuartic: function (start, end, t) {
return (end - start) * (t * t * t * t) + start;
},
easeInOutQuartic: function (start, end, t) {
t *= 2.0;
if (t < 1.0) {
return ((end - start) / 2.0) * t * t * t * t + start;
}
t -= 2.0;
return (-(end - start) / 2.0) * (t * t * t * t - 2.0) + start;
},
easeOutCubic: function (start, end, t) {
t--;
return (end - start) * (t * t * t + 1.0) + start;
},
easeInCubic: function (start, end, t) {
return (end - start) * (t * t * t) + start;
},
easeInOutCubic: function (start, end, t) {
t *= 2.0;
if (t < 1.0) {
return ((end - start) / 2.0) * t * t * t + start;
}
t -= 2.0;
return ((end - start) / 2.0) * (t * t * t + 2.0) + start;
},
easeOutQuadratic: function (start, end, t) {
return -(end - start) * t * (t - 2.0) + start;
},
easeInQuadratic: function (start, end, t) {
return (end - start) * (t * t) + start;
},
easeInOutQuadratic: function (start, end, t) {
t *= 2.0;
if (t < 1.0) {
return ((end - start) / 2.0) * t * t + start;
}
t -= 1.0;
return (-(end - start) / 2.0) * (t * (t - 2) - 1.0) + start;
},
linear: function (start, end, t) {
return start + (end - start) * t;
}
};
//default interpolation curves
EaseCurves.easeIn = EaseCurves.easeInCubic;
EaseCurves.easeOut = EaseCurves.easeOutCubic;
EaseCurves.easeInOut = EaseCurves.easeInOutCubic;
module.exports = EaseCurves;

View File

@@ -0,0 +1,97 @@
'use strict';
const { getAbsoluteUrl } = require('@noodl/runtime/src/utils');
function FontLoader() {
this.loadedFontFamilies = {};
this.fontCssFamiliesAdded = {};
this.fontCallbacks = {};
var self = this;
['Arial', 'Arial Black', 'Courier New', 'Helvetica', 'Impact', 'Lucida Console', 'Tahoma', 'Times New Roman'].forEach(
function (fontFamily) {
self.loadedFontFamilies[fontFamily] = true;
}
);
}
function removeFileEnding(url) {
return url.replace(/\.[^/.]+$/, '');
}
FontLoader.prototype.loadFont = function (fontURL) {
// Support SSR
if (typeof document === 'undefined') return;
fontURL = getAbsoluteUrl(fontURL);
//get file name without path and file ending
var family = removeFileEnding(fontURL).split('/').pop();
//check if it's already loaded
if (this.loadedFontFamilies[family]) {
this.fontCallbacks[family] &&
this.fontCallbacks[family].forEach(function (callback) {
callback();
});
return;
}
//check if font is already being loaded, we're just waiting for the callback
if (this.fontCssFamiliesAdded[family]) {
return;
}
this.fontCssFamiliesAdded[family] = true;
var newStyle = document.createElement('style');
newStyle.type = 'text/css';
let baseUrl = Noodl.Env["BaseUrl"] || '/';
if (fontURL.startsWith('/')) {
fontURL = fontURL.substring(1);
}
newStyle.appendChild(
document.createTextNode("@font-face { font-family: '" + family + "'; src: url('" + baseUrl + fontURL + "'); }\n")
);
document.head.appendChild(newStyle);
// Support SSR
if (typeof window !== 'undefined') {
var self = this;
const WebFontLoader = require('webfontloader');
WebFontLoader.load({
timeout: 1000 * 600, //10 minutes in case the bandwidth is reeeeeeally low
custom: {
families: [family]
},
fontactive: function (family) {
self.loadedFontFamilies[family] = true;
if (self.fontCallbacks[family]) {
self.fontCallbacks[family].forEach(function (callback) {
callback();
});
}
}
});
}
};
FontLoader.prototype.callWhenFontIsActive = function (family, callback) {
if (this.loadedFontFamilies[family]) {
callback();
return;
}
if (!this.fontCallbacks[family]) {
this.fontCallbacks[family] = [];
}
this.fontCallbacks[family].push(callback);
};
FontLoader.instance = new FontLoader();
module.exports = FontLoader;

View File

@@ -0,0 +1,45 @@
export default class GraphWarnings {
constructor(graphModel, editorConnection) {
this.graphModel = graphModel;
this.editorConnection = editorConnection;
this.graphModel.getAllComponents().forEach((c) => this._bindComponentModel(c));
this.graphModel.on('componentAdded', (c) => this._bindComponentModel(c), this);
this.graphModel.on('componentRemoved', (c) => c.removeListenersWithRef(this), this);
}
_bindComponentModel(c) {
c.on('rootAdded', () => this._evaluateWarnings(c), this);
c.on(
'rootRemoved',
(root) => {
this.editorConnection.clearWarning(c.name, root, 'multiple-visual-roots-warning');
this._evaluateWarnings(c);
},
this
);
this._evaluateWarnings(c);
}
_evaluateWarnings(c) {
const roots = c.getRoots();
if (roots.lenth === 0) return;
this.editorConnection.clearWarning(c.name, roots[0], 'multiple-visual-roots-warning');
for (let i = 1; i < roots.length; i++) {
this.editorConnection.sendWarning(c.name, roots[i], 'multiple-visual-roots-warning', {
message: "This node is detached from the main node tree<br>and won't be rendered",
level: 'info'
});
}
}
dispose() {
this.graphModel.getAllComponents().forEach((c) => {
c.removeListenersWithRef(this);
});
this.graphModel.removeListenersWithRef(this);
}
}

View File

@@ -0,0 +1,11 @@
//adapted from http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript
function guid() {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
}
module.exports = guid;

View File

@@ -0,0 +1,159 @@
import ReactDOM from 'react-dom';
export class Highlighter {
constructor(noodlRuntime) {
this.highlightedNodes = new Map();
this.selectedNodes = new Map();
this.noodlRuntime = noodlRuntime;
this.isUpdatingHighlights = false;
//create the div that holds the highlight and selection UI
const div = document.createElement('div');
div.style.width = '100%';
div.style.height = '100%';
div.style.top = '0';
div.style.left = '0';
div.style.overflow = 'hidden';
div.style.position = 'fixed';
div.style.zIndex = '1000000000';
div.style.pointerEvents = 'none';
document.body.appendChild(div);
this.highlightRootDiv = div;
this.windowBorderDiv = this.createHighlightDiv();
this.windowBorderDiv.style.position = 'absolute';
this.windowBorderDiv.style.top = '0';
this.windowBorderDiv.style.left = '0';
this.windowBorderDiv.style.boxShadow = 'inset 0 0 0 3px #2CA7BA';
this.windowBorderDiv.style.opacity = '1.0';
this.windowBorderDiv.style.width = '100vw';
this.windowBorderDiv.style.height = '100vh';
}
createHighlightDiv() {
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.top = '0';
div.style.left = '0';
div.style.outline = '2px solid #2CA7BA';
div.style.opacity = '1.0';
return div;
}
setWindowSelected(enabled) {
return; //disable this feature for now, needs some iteration
/*if (enabled) {
this.highlightRootDiv.appendChild(this.windowBorderDiv);
} else {
this.windowBorderDiv.parentNode && this.windowBorderDiv.parentNode.removeChild(this.windowBorderDiv);
}*/
}
updateHighlights() {
const items = Array.from(this.highlightedNodes.entries()).concat(Array.from(this.selectedNodes.entries()));
for (const item of items) {
const domNode = item[0].getRef() && ReactDOM.findDOMNode(item[0].getRef());
if (!domNode) {
//user has deleted this node, just remove it
this.highlightedNodes.delete(item[0]);
item[1].remove();
continue;
}
const rect = domNode.getBoundingClientRect();
const highlight = item[1];
highlight.style.transform = `translateX(${rect.x}px) translateY(${rect.y}px)`;
highlight.style.width = rect.width + 'px';
highlight.style.height = rect.height + 'px';
}
this.isUpdatingHighlights = this.highlightedNodes.size > 0 || this.selectedNodes.size > 0;
if (this.isUpdatingHighlights) {
requestAnimationFrame(this.updateHighlights.bind(this));
}
}
highlightNodesWithId(nodeId) {
//gather all nodes with a DOM node we can highlight, that aren't already highlighted
const nodes = getNodes(this.noodlRuntime, nodeId)
.filter((node) => node.getRef)
.filter((node) => !this.highlightedNodes.has(node));
for (const node of nodes) {
const highlight = this.createHighlightDiv();
this.highlightRootDiv.appendChild(highlight);
this.highlightedNodes.set(node, highlight);
}
if ((this.selectedNodes.size > 0 || this.highlightedNodes.size > 0) && !this.isUpdatingHighlights) {
this.updateHighlights();
}
}
disableHighlight() {
for (const item of this.highlightedNodes) {
const highlight = item[1];
if (highlight) {
highlight.remove();
}
}
this.highlightedNodes.clear();
}
selectNodesWithId(nodeId) {
//we don't track when nodes are created, so if there's no root component, wait a while and then highlight so we can get all the instances
//TODO: track nodes as they're created so newly created nodes can be selected if their IDs match
if (!this.noodlRuntime.rootComponent) {
this.noodlRuntime.eventEmitter.once('rootComponentUpdated', () => {
setTimeout(() => {
this.selectNodesWithId(nodeId);
}, 300);
});
}
const nodes = getNodes(this.noodlRuntime, nodeId)
.filter((node) => node.getRef)
.filter((node) => !this.selectedNodes.has(node));
for (const node of nodes) {
const selection = this.createHighlightDiv();
this.highlightRootDiv.appendChild(selection);
this.selectedNodes.set(node, selection);
}
if (this.selectedNodes.size > 0) {
this.setWindowSelected(false);
}
if ((this.selectedNodes.size > 0 || this.highlightedNodes.size > 0) && !this.isUpdatingHighlights) {
this.updateHighlights();
}
return nodes;
}
deselectNodes() {
for (const item of this.selectedNodes) {
const selection = item[1];
if (selection) {
selection.remove();
}
}
this.selectedNodes.clear();
}
}
function getNodes(noodlRuntime, nodeId) {
if (!noodlRuntime.rootComponent) {
return [];
}
return noodlRuntime.rootComponent.nodeScope.getNodesWithIdRecursive(nodeId);
}

View File

@@ -0,0 +1,155 @@
export default class Inspector {
constructor({ onInspect, onHighlight, onDisableHighlight }) {
this.onMouseMove = (e) => {
onDisableHighlight();
const noodlNode = this.findNoodlNode(e.target);
if (noodlNode) {
document.body.style.cursor = 'pointer';
onHighlight(noodlNode.id);
} else {
document.body.style.cursor = 'initial';
}
e.stopPropagation();
};
this.onClick = (e) => {
onDisableHighlight();
const noodlNode = this.findNoodlNode(e.target);
if (noodlNode) {
onInspect([noodlNode.id]);
}
e.stopPropagation();
e.preventDefault();
//not sure how to stop React input elements from getting focus, so blurring the potential element tha got focus on click
if (document.activeElement) {
document.activeElement.blur();
}
};
this.onContextMenu = (e) => {
const nodeIds = document
.elementsFromPoint(e.clientX, e.clientY)
.map((dom) => this.findNoodlNode(dom))
.filter((node) => !!node)
.map((node) => node.id);
if (nodeIds.length) {
onInspect(nodeIds);
}
e.stopPropagation();
e.preventDefault();
//not sure how to stop React input elements from getting focus, so blurring the potential element tha got focus on click
if (document.activeElement) {
document.activeElement.blur();
}
};
this.onMouseOut = (e) => {
onDisableHighlight();
};
this.blockEvent = (e) => {
e.stopPropagation();
};
this.onDisableHighlight = onDisableHighlight;
}
setComponent(component) {
this.component = component;
}
enable() {
//blur active element, if any
if (document.activeElement) {
document.activeElement.blur();
}
//get events from capture phase, before they tunnel down the tree
document.addEventListener('mouseenter', this.blockEvent, true);
document.addEventListener('mouseover', this.blockEvent, true);
document.addEventListener('mousedown', this.blockEvent, true);
document.addEventListener('mouseup', this.blockEvent, true);
document.addEventListener('mousemove', this.onMouseMove, true);
document.addEventListener('mouseout', this.onMouseOut, true);
document.addEventListener('click', this.onClick, true);
document.addEventListener('contextmenu', this.onContextMenu, true);
}
disable() {
document.body.style.cursor = 'initial';
document.removeEventListener('mouseenter', this.blockEvent, true);
document.removeEventListener('mouseover', this.blockEvent, true);
document.removeEventListener('mousedown', this.blockEvent, true);
document.removeEventListener('mouseup', this.blockEvent, true);
document.removeEventListener('mousemove', this.onMouseMove, true);
document.removeEventListener('mouseout', this.onMouseOut, true);
document.removeEventListener('click', this.onClick, true);
document.removeEventListener('contextmenu', this.onContextMenu, true);
this.onDisableHighlight();
}
findNoodlNode(dom) {
//walk the dom tree upwards until a dom element with react state is found
let domFiber;
while (!domFiber && dom) {
const key = Object.keys(dom).find((key) => key.startsWith('__reactInternalInstance$'));
domFiber = dom[key];
if (!domFiber) dom = dom.parentElement;
}
//found none
if (!domFiber) {
return undefined;
}
const GetCompFiber = (fiber) => {
let parentFiber = fiber.return;
while (parentFiber && typeof parentFiber.type == 'string') {
parentFiber = parentFiber.return;
}
return parentFiber;
};
//found a react node, now walk the react tree until a noodl node is found
//(identified by having a noodlNode prop)
let compFiber = GetCompFiber(domFiber);
while (compFiber && (!compFiber.stateNode || !compFiber.stateNode.props || !compFiber.stateNode.props.noodlNode)) {
compFiber = GetCompFiber(compFiber);
}
const noodlNode = compFiber ? compFiber.stateNode.props.noodlNode : undefined;
if (!noodlNode) return;
if (this.component) {
let node = noodlNode;
while (node) {
if (node.parentNodeScope) {
if (node.parentNodeScope.componentOwner.name === this.component.name) {
return node;
}
node = node.parentNodeScope.componentOwner;
} else {
if (node.nodeScope.componentOwner.name === this.component.name) {
return node;
}
node = node.nodeScope.componentOwner;
}
}
return node;
}
return noodlNode;
}
}

View File

@@ -0,0 +1,165 @@
function isPercentage(size) {
return size && size[size.length - 1] === '%';
}
function getPercentage(size) {
return Number(size.slice(0, -1));
}
function getSizeWithMargins(size, startMargin, endMargin) {
if (!startMargin && !endMargin) {
return size;
}
let css = `calc(${size}`;
if (startMargin) {
css += ` - ${startMargin}`;
}
if (endMargin) {
css += ` - ${endMargin}`;
}
css += ')';
return css;
}
export default {
size(style, props) {
if (props.parentLayout === 'none') {
style.position = 'absolute';
}
if (props.sizeMode === 'explicit') {
style.width = props.width;
style.height = props.height;
} else if (props.sizeMode === 'contentHeight') {
style.width = props.width;
} else if (props.sizeMode === 'contentWidth') {
style.height = props.height;
}
style.flexShrink = 0;
if (props.parentLayout === 'row' && style.position === 'relative') {
if (isPercentage(style.width) && !props.fixedWidth) {
style.flexGrow = getPercentage(style.width);
style.flexShrink = 1;
}
if (isPercentage(style.height) && !props.fixedHeight) {
style.height = getSizeWithMargins(style.height, style.marginTop, style.marginBottom);
}
} else if (props.parentLayout === 'column' && style.position === 'relative') {
if (isPercentage(style.width) && !props.fixedWidth) {
style.width = getSizeWithMargins(style.width, style.marginLeft, style.marginRight);
}
if (isPercentage(style.height) && !props.fixedHeight) {
style.flexGrow = getPercentage(style.height);
style.flexShrink = 1;
}
} else if (style.position !== 'relative') {
if (isPercentage(style.width)) {
style.width = getSizeWithMargins(style.width, style.marginLeft, style.marginRight);
}
if (isPercentage(style.height)) {
style.height = getSizeWithMargins(style.height, style.marginTop, style.marginBottom);
}
}
},
align(style, props) {
const { position } = style;
let { alignX, alignY } = props;
//Elements with position absolute get's a default alignment.
//This keeps backward compability, and makes sense for most use cases of
//absolutely positioned elements
if (position !== 'relative') {
alignX = alignX || 'left';
alignY = alignY || 'top';
}
let transform = '';
const parentFlexDirection = props.parentLayout || 'column';
if (alignX) {
if (position !== 'relative') {
if (alignX === 'left') {
style.left = 0;
} else if (alignX === 'center') {
style.left = '50%';
transform += 'translateX(-50%) ';
} else {
style.right = 0;
}
} else if (position === 'relative' && parentFlexDirection === 'row') {
switch (alignX) {
case 'left':
style.marginRight = style.marginRight ? style.marginRight : 'auto';
break;
case 'center':
style.marginRight = style.marginRight ? style.marginRight : 'auto';
style.marginLeft = style.marginLeft ? style.marginLeft : 'auto';
break;
case 'right':
style.marginLeft = style.marginLeft ? style.marginLeft : 'auto';
break;
}
} else if (position === 'relative' && parentFlexDirection === 'column') {
switch (alignX) {
case 'left':
style.alignSelf = 'flex-start';
break;
case 'center':
style.alignSelf = 'center';
break;
case 'right':
style.alignSelf = 'flex-end';
break;
}
}
}
if (alignY) {
if (position !== 'relative') {
if (alignY === 'top') {
style.top = 0;
} else if (alignY === 'center') {
style.top = '50%';
transform += 'translateY(-50%)';
} else {
style.bottom = 0;
}
} else if (position === 'relative' && parentFlexDirection === 'column') {
switch (alignY) {
case 'top':
style.marginBottom = style.marginBottom ? style.marginBottom : 'auto';
break;
case 'center':
style.marginTop = style.marginTop ? style.marginTop : 'auto';
style.marginBottom = style.marginBottom ? style.marginBottom : 'auto';
break;
case 'bottom':
style.marginTop = style.marginTop ? style.marginTop : 'auto';
break;
}
} else if (position === 'relative' && parentFlexDirection === 'row') {
switch (alignY) {
case 'top':
style.alignSelf = 'flex-start';
break;
case 'center':
style.alignSelf = 'center';
break;
case 'bottom':
style.alignSelf = 'flex-end';
break;
}
}
}
if (transform) {
style.transform = transform + (style.transform || '');
}
}
};

View File

@@ -0,0 +1,21 @@
function isObject(item) {
return item && typeof item === 'object' && !Array.isArray(item);
}
export default function mergeDeep(target, ...sources) {
if (!sources.length) return target;
const source = sources.shift();
if (isObject(target) && isObject(source)) {
for (const key in source) {
if (isObject(source[key])) {
if (!target[key]) Object.assign(target, { [key]: {} });
mergeDeep(target[key], source[key]);
} else {
Object.assign(target, { [key]: source[key] });
}
}
}
return mergeDeep(target, ...sources);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
import BezierEasing from 'bezier-easing';
const EaseCurves = require('./easecurves');
export default function transitionParameter(node, name, endValue, transition) {
if (node._transitions && node._transitions[name]) {
node._transitions[name].stop();
delete node._transitions[name];
}
const startValue = node.getInputValue(name);
const input = node.getInput(name);
let animation;
if (input && input.type === 'color') {
animation = colorAnimation(
node.context.styles.resolveColor(startValue),
node.context.styles.resolveColor(endValue)
);
} else if (typeof startValue === 'number' && typeof endValue === 'number') {
animation = numberAnimation(startValue, endValue);
} else if (
typeof startValue === 'object' &&
startValue.hasOwnProperty('value') &&
typeof endValue === 'object' &&
endValue.hasOwnProperty('value')
) {
animation = numberAnimation(startValue.value, endValue.value);
}
if (animation) {
if (!node._transitions) node._transitions = {};
const ease = BezierEasing(transition.curve);
node._transitions[name] = node.context.timerScheduler.createTimer({
duration: transition.dur,
onRunning: (t) => {
const v = animation(ease.get(t));
node.queueInput(name, v);
},
onFinish: () => {
delete node._transitions[name];
}
});
node._transitions[name].start();
} else {
//no transition supported for this parameter type, so just set it
node.queueInput(name, endValue);
}
}
function numberAnimation(start, end) {
return (t) => {
return EaseCurves.linear(start, end, t);
};
}
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]);
}
function colorAnimation(start, end) {
const rgba0 = [0, 0, 0, 255];
setRGBA(rgba0, start);
const rgba1 = [0, 0, 0, 255];
setRGBA(rgba1, end);
if (!start || start === 'transparent') {
rgba0[0] = rgba1[0];
rgba0[1] = rgba1[1];
rgba0[2] = rgba1[2];
}
if (!end || end === 'transparent') {
rgba1[0] = rgba0[0];
rgba1[1] = rgba0[1];
rgba1[2] = rgba0[2];
}
const rgba = [0, 0, 0, 0];
return (t) => {
rgba[0] = Math.floor(EaseCurves.linear(rgba0[0], rgba1[0], t));
rgba[1] = Math.floor(EaseCurves.linear(rgba0[1], rgba1[1], t));
rgba[2] = Math.floor(EaseCurves.linear(rgba0[2], rgba1[2], t));
rgba[3] = Math.floor(EaseCurves.linear(rgba0[3], rgba1[3], t));
return rgbaToHex(rgba);
};
}

View File

@@ -0,0 +1,435 @@
import React, { useEffect } from 'react';
import FontLoader from '../../fontloader';
import guid from '../../guid';
import Layout from '../../layout';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
import Utils from './utils';
// --------------------------------------------------------------------------------------
// Button
// --------------------------------------------------------------------------------------
function Button(props) {
// On mount
useEffect(() => {
props.focusChanged && props.focusChanged(false);
props.hoverChanged && props.hoverChanged(false);
props.pressedChanged && props.pressedChanged(false);
}, []);
var style = { ...props.style };
Layout.size(style, props);
Layout.align(style, props);
if (props.textStyle !== undefined) {
// Apply text style
style = Object.assign({}, props.textStyle, style);
}
if (props.boxShadowEnabled) {
style.boxShadow = `${props.boxShadowInset ? 'inset ' : ''}${props.boxShadowOffsetX} ${props.boxShadowOffsetY} ${
props.boxShadowBlurRadius
} ${props.boxShadowSpreadRadius} ${props.boxShadowColor}`;
}
let className = 'ndl-controls-button';
if (props.className) className = className + ' ' + props.className;
return (
<button
className={className}
disabled={!props.enabled}
{...Utils.controlEvents(props)}
type={props.buttonType}
style={style}
onClick={props.onClick}
>
{props.label}
{props.children}
</button>
);
}
var ButtonNode = {
name: 'Button',
docs: 'https://docs.noodl.net/nodes/visual/button',
allowChildren: true,
noodlNodeAsProp: true,
initialize() {
this.outputPropValues.hoverState = this.outputPropValues.focusState = this.outputPropValues.pressedState = false;
this.props.id = this._internal.controlId = 'input-' + guid();
this.props.enabled = this._internal.enabled = true;
},
getReactComponent() {
return Button;
},
inputs: {
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.forceUpdate();
this.flagOutputDirty('enabled');
}
}
},
// Text style
textStyle: {
index: 20,
type: 'textStyle',
group: 'Text',
displayName: 'Text Style',
default: 'None',
set(value) {
this.props.textStyle = this.context.styles.getTextStyle(value);
this.forceUpdate();
}
},
fontFamily: {
index: 21,
type: 'font',
group: 'Text',
displayName: 'Font Family',
set(value) {
if (value) {
let family = value;
if (family.split('.').length > 1) {
family = family.replace(/\.[^/.]+$/, '');
family = family.split('/').pop();
}
this.setStyle({ fontFamily: family });
} else {
this.removeStyle(['fontFamily']);
}
if (this.props.textStyle) {
this.forceUpdate();
}
}
}
/* 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;
}
}
}*/
},
outputs: {
controlId: {
type: 'string',
displayName: 'Control Id',
group: 'General',
getter: function () {
return this._internal.controlId;
}
},
enabled: {
type: 'boolean',
displayName: 'Enabled',
group: 'States',
getter: function () {
return this._internal.enabled;
}
}
},
inputCss: {
fontSize: {
index: 21,
group: 'Text',
displayName: 'Font Size',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
onChange() {
if (this.props.textStyle) {
this.forceUpdate();
}
}
},
color: {
index: 24,
group: 'Text',
displayName: 'Color',
type: 'color',
default: '#FFFFFF'
},
backgroundColor: {
index: 100,
displayName: 'Background Color',
group: 'Style',
type: 'color',
default: '#000000'
},
// Padding
paddingLeft: {
index: 64,
group: 'Margin and padding',
default: 20,
applyDefault: false,
displayName: 'Pad Left',
type: { name: 'number', units: ['px'], defaultUnit: 'px', marginPaddingComp: 'padding-left' }
},
paddingRight: {
index: 65,
group: 'Margin and padding',
default: 20,
applyDefault: false,
displayName: 'Pad Right',
type: { name: 'number', units: ['px'], defaultUnit: 'px', marginPaddingComp: 'padding-right' }
},
paddingTop: {
index: 66,
group: 'Margin and padding',
displayName: 'Pad Top',
default: 5,
applyDefault: false,
type: { name: 'number', units: ['px'], defaultUnit: 'px', marginPaddingComp: 'padding-top' }
},
paddingBottom: {
index: 67,
group: 'Margin and padding',
displayName: 'Pad Bottom',
default: 5,
applyDefault: false,
type: { name: 'number', units: ['px'], defaultUnit: 'px', marginPaddingComp: 'padding-bottom' }
},
// Border styles
borderRadius: {
index: 202,
displayName: 'Border Radius',
group: 'Style',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: 0,
applyDefault: false
},
borderStyle: {
index: 203,
displayName: 'Border Style',
group: 'Style',
type: {
name: 'enum',
enums: [
{ label: 'None', value: 'none' },
{ label: 'Solid', value: 'solid' },
{ label: 'Dotted', value: 'dotted' },
{ label: 'Dashed', value: 'dashed' }
]
},
default: 'none',
applyDefault: false
},
borderWidth: {
index: 204,
displayName: 'Border Width',
group: 'Style',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: 0,
applyDefault: false
},
borderColor: {
index: 205,
displayName: 'Border Color',
group: 'Style',
type: 'color',
default: '#000000'
}
},
inputProps: {
label: {
type: 'string',
displayName: 'Label',
group: 'General',
default: 'Label'
},
buttonType: {
type: {
name: 'enum',
enums: [
{ label: 'Button', value: 'button' },
{ label: 'Submit', value: 'submit' }
]
},
displayName: 'Type',
default: 'button',
group: 'General'
},
// Box shadow
boxShadowEnabled: {
index: 250,
group: 'Box Shadow',
displayName: 'Shadow Enabled',
type: 'boolean',
default: false
},
boxShadowOffsetX: {
index: 251,
group: 'Box Shadow',
displayName: 'Offset X',
default: 0,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowOffsetY: {
index: 252,
group: 'Box Shadow',
displayName: 'Offset Y',
default: 0,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowBlurRadius: {
index: 253,
group: 'Box Shadow',
displayName: 'Blur Radius',
default: 5,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowSpreadRadius: {
index: 254,
group: 'Box Shadow',
displayName: 'Spread Radius',
default: 2,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowInset: {
index: 255,
group: 'Box Shadow',
displayName: 'Inset',
type: 'boolean',
default: false
},
boxShadowColor: {
index: 256,
group: 'Box Shadow',
displayName: 'Shadow Color',
type: 'color',
default: 'rgba(0,0,0,0.2)'
}
},
outputProps: {
// Click
onClick: {
displayName: 'Click',
group: 'Events',
type: 'signal'
}
},
dynamicports: [
{
condition: 'boxShadowEnabled = true',
inputs: [
'boxShadowOffsetX',
'boxShadowOffsetY',
'boxShadowInset',
'boxShadowBlurRadius',
'boxShadowSpreadRadius',
'boxShadowColor'
]
}
],
methods: {}
};
NodeSharedPortDefinitions.addDimensions(ButtonNode, {
defaultSizeMode: 'contentSize',
contentLabel: 'Content',
useDimensionConstraints: false
});
NodeSharedPortDefinitions.addAlignInputs(ButtonNode);
NodeSharedPortDefinitions.addTransformInputs(ButtonNode);
//NodeSharedPortDefinitions.addPaddingInputs(ButtonNode);
NodeSharedPortDefinitions.addMarginInputs(ButtonNode);
NodeSharedPortDefinitions.addSharedVisualInputs(ButtonNode);
Utils.addControlEventsAndStates(ButtonNode);
ButtonNode = createNodeFromReactComponent(ButtonNode);
ButtonNode.setup = function (context, graphModel) {
graphModel.on('nodeAdded.Button', function (node) {
if (node.parameters.fontFamily && node.parameters.fontFamily.split('.').length > 1) {
FontLoader.instance.loadFont(node.parameters.fontFamily);
}
node.on('parameterUpdated', function (event) {
if (event.name === 'fontFamily' && event.value) {
if (event.value.split('.').length > 1) {
FontLoader.instance.loadFont(event.value);
}
}
});
});
};
export default ButtonNode;

View File

@@ -0,0 +1,329 @@
import React, { useEffect, useState } from 'react';
import guid from '../../guid';
import Layout from '../../layout';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
import Utils from './utils';
function _styleTemplate(_class, props) {
return `
.${_class}:checked {
background-color: ${props.checkedBackgroundColor};
}
`;
}
// --------------------------------------------------------------------------------------
// CheckBox
// --------------------------------------------------------------------------------------
function CheckBox(props) {
const [checked, setChecked] = useState(props.checked);
// Report initial values when mounted
useEffect(() => {
setChecked(!!props.checked);
}, []);
useEffect(() => {
setChecked(!!props.checked);
}, [props.checked]);
var style = { ...props.style };
Layout.size(style, props);
Layout.align(style, props);
if (props.boxShadowEnabled) {
style.boxShadow = `${props.boxShadowInset ? 'inset ' : ''}${props.boxShadowOffsetX} ${props.boxShadowOffsetY} ${
props.boxShadowBlurRadius
} ${props.boxShadowSpreadRadius} ${props.boxShadowColor}`;
}
const tagProps = { id: props.id, style: style };
Utils.updateStylesForClass(
'ndl-controls-checkbox-' + props._nodeId,
{ checkedBackgroundColor: props.checkedBackgroundColor },
_styleTemplate
);
let className = 'ndl-controls-checkbox-' + props._nodeId + ' ndl-controls-checkbox';
if (props.className) className = className + ' ' + props.className;
return (
<input
className={className}
type="checkbox"
{...tagProps}
{...Utils.controlEvents(props)}
checked={checked}
disabled={!props.enabled}
onChange={(e) => {
setChecked(e.target.checked);
props.checkedChanged && props.checkedChanged(e.target.checked);
}}
></input>
);
}
var CheckBoxNode = {
name: 'Checkbox',
displayName: 'Checkbox',
docs: 'https://docs.noodl.net/nodes/visual/checkbox',
allowChildren: false,
noodlNodeAsProp: true,
initialize() {
this.props.sizeMode = 'explicit';
this.props.id = this._internal.controlId = 'input-' + guid();
this.props.enabled = this._internal.enabled = true;
this.props.checked = this._internal.checked = false;
this.props._nodeId = this.id;
this.outputPropValues.hoverState = this.outputPropValues.focusState = this.outputPropValues.pressedState = false;
this.props.checkedChanged = (checked) => {
const changed = this._internal.checked !== checked;
this._internal.checked = checked;
if (changed) {
this.flagOutputDirty('checked');
this.sendSignalOnOutput('onChange');
}
};
},
getReactComponent() {
return CheckBox;
},
inputs: {
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.forceUpdate();
this.flagOutputDirty('enabled');
}
}
},
checked: {
type: 'boolean',
displayName: 'Checked',
group: 'General',
default: false,
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');
}
}
}
},
outputs: {
controlId: {
type: 'string',
displayName: 'Control Id',
group: 'General',
getter: function () {
return this._internal.controlId;
}
},
enabled: {
type: 'boolean',
displayName: 'Enabled',
group: 'States',
getter: function () {
return this._internal.enabled;
}
},
checked: {
type: 'boolean',
displayName: 'Checked',
group: 'States',
getter: function () {
return this._internal.checked;
}
},
// Change
onChange: {
displayName: 'Changed',
group: 'Events',
type: 'signal'
}
},
inputProps: {
width: {
index: 11,
group: 'Dimensions',
displayName: 'Width',
type: {
name: 'number',
units: ['%', 'px', 'vw'],
defaultUnit: 'px'
},
default: 32
},
height: {
index: 12,
group: 'Dimensions',
displayName: 'Height',
type: {
name: 'number',
units: ['%', 'px', 'vh'],
defaultUnit: 'px'
},
default: 32
},
// Styles
checkedBackgroundColor: {
displayName: 'Background color',
group: 'Checked Style',
type: { name: 'color', allowEditOnly: true },
default: '#000000'
},
// Box shadow
boxShadowEnabled: {
index: 250,
group: 'Box Shadow',
displayName: 'Shadow Enabled',
type: 'boolean',
default: false
},
boxShadowOffsetX: {
index: 251,
group: 'Box Shadow',
displayName: 'Offset X',
default: 0,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowOffsetY: {
index: 252,
group: 'Box Shadow',
displayName: 'Offset Y',
default: 0,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowBlurRadius: {
index: 253,
group: 'Box Shadow',
displayName: 'Blur Radius',
default: 5,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowSpreadRadius: {
index: 254,
group: 'Box Shadow',
displayName: 'Spread Radius',
default: 2,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowInset: {
index: 255,
group: 'Box Shadow',
displayName: 'Inset',
type: 'boolean',
default: false
},
boxShadowColor: {
index: 256,
group: 'Box Shadow',
displayName: 'Shadow Color',
type: 'color',
default: 'rgba(0,0,0,0.2)'
}
},
inputCss: {
backgroundColor: {
index: 201,
displayName: 'Background Color',
group: 'Style',
type: 'color'
},
// Border styles
borderRadius: {
index: 202,
displayName: 'Border Radius',
group: 'Style',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: 2,
applyDefault: false
},
borderStyle: {
index: 203,
displayName: 'Border Style',
group: 'Style',
type: {
name: 'enum',
enums: [
{ label: 'None', value: 'none' },
{ label: 'Solid', value: 'solid' },
{ label: 'Dotted', value: 'dotted' },
{ label: 'Dashed', value: 'dashed' }
]
},
default: 'solid',
applyDefault: false
},
borderWidth: {
index: 204,
displayName: 'Border Width',
group: 'Style',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: 1,
applyDefault: false
},
borderColor: {
index: 205,
displayName: 'Border Color',
group: 'Style',
type: 'color',
default: '#000000'
}
},
outputProps: {},
methods: {}
};
NodeSharedPortDefinitions.addAlignInputs(CheckBoxNode);
NodeSharedPortDefinitions.addTransformInputs(CheckBoxNode);
NodeSharedPortDefinitions.addMarginInputs(CheckBoxNode);
NodeSharedPortDefinitions.addSharedVisualInputs(CheckBoxNode);
Utils.addControlEventsAndStates(CheckBoxNode);
CheckBoxNode = createNodeFromReactComponent(CheckBoxNode);
export default CheckBoxNode;

View File

@@ -0,0 +1,88 @@
import React from 'react';
import { flexDirectionValues } from '../../constants/flex';
import Layout from '../../layout';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
function FieldSet(props) {
var style = { ...props.style };
Layout.size(style, props);
Layout.align(style, props);
let className = 'ndl-controls-fieldset';
if (props.className) className = className + ' ' + props.className;
return (
<fieldset className={className} style={style}>
{props.children}
</fieldset>
);
}
var FieldSetNode = {
name: 'Field Set',
docs: 'https://docs.noodl.net/nodes/visual/fieldset',
allowChildren: true,
noodlNodeAsProp: true,
deprecated: true,
initialize() {},
defaultCss: {
display: 'flex',
position: 'relative',
flexDirection: 'column'
},
getReactComponent() {
return FieldSet;
},
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();
}
}
},
inputProps: {},
outputProps: {}
};
NodeSharedPortDefinitions.addDimensions(FieldSetNode, { defaultSizeMode: 'contentSize', contentLabel: 'Content' });
NodeSharedPortDefinitions.addAlignInputs(FieldSetNode);
NodeSharedPortDefinitions.addTransformInputs(FieldSetNode);
NodeSharedPortDefinitions.addMarginInputs(FieldSetNode);
NodeSharedPortDefinitions.addPaddingInputs(FieldSetNode);
NodeSharedPortDefinitions.addSharedVisualInputs(FieldSetNode);
FieldSetNode = createNodeFromReactComponent(FieldSetNode);
export default FieldSetNode;

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { flexDirectionValues } from '../../constants/flex';
import Layout from '../../layout';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
function Form(props) {
var style = { ...props.style };
Layout.size(style, props);
Layout.align(style, props);
let className = 'ndl-controls-form';
if (props.className) className = className + ' ' + props.className;
return (
<form
className={className}
style={style}
onSubmit={(e) => {
e.preventDefault();
props.onSubmit && props.onSubmit();
}}
>
{props.children}
</form>
);
}
var FormNode = {
name: 'Form',
docs: 'https://docs.noodl.net/nodes/visual/form',
allowChildren: true,
noodlNodeAsProp: true,
deprecated: true,
initialize() {},
defaultCss: {
display: 'flex',
position: 'relative',
flexDirection: 'column'
},
getReactComponent() {
return Form;
},
inputs: {
flexDirection: {
//don't rename for backwards compat
index: 11,
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();
}
}
},
inputProps: {},
outputProps: {
onSubmit: { type: 'signal', displayName: 'Submit', group: 'Events' }
}
};
NodeSharedPortDefinitions.addDimensions(FormNode, { defaultSizeMode: 'contentSize', contentLabel: 'Content' });
NodeSharedPortDefinitions.addAlignInputs(FormNode);
NodeSharedPortDefinitions.addTransformInputs(FormNode);
NodeSharedPortDefinitions.addMarginInputs(FormNode);
NodeSharedPortDefinitions.addPaddingInputs(FormNode);
NodeSharedPortDefinitions.addSharedVisualInputs(FormNode);
FormNode = createNodeFromReactComponent(FormNode);
export default FormNode;

View File

@@ -0,0 +1,60 @@
import React from 'react';
import Layout from '../../layout';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
function Label(props) {
var style = { ...props.style };
Layout.size(style, props);
Layout.align(style, props);
if (props.textStyle !== undefined) {
// Apply text style
style = Object.assign({}, props.textStyle, style);
}
const tagProps = {
for: props.for,
onClick: props.onClick
};
let className = 'ndl-controls-label';
if (props.className) className = className + ' ' + props.className;
return (
<label className={className} style={style} {...tagProps}>
{props.text}
</label>
);
}
const LabelNode = {
name: 'Label',
displayName: 'Label',
docs: 'https://docs.noodl.net/nodes/visual/label',
allowChildren: true,
noodlNodeAsProp: true,
deprecated: true,
getReactComponent() {
return Label;
},
defaultCss: {
position: 'relative',
display: 'flex'
},
inputProps: {
for: { type: 'string', displayName: 'For', group: 'General' },
text: { type: 'string', displayName: 'Text', group: 'General' }
}
};
NodeSharedPortDefinitions.addDimensions(LabelNode, { defaultSizeMode: 'contentSize', contentLabel: 'Content' });
NodeSharedPortDefinitions.addAlignInputs(LabelNode);
NodeSharedPortDefinitions.addTextStyleInputs(LabelNode);
NodeSharedPortDefinitions.addTransformInputs(LabelNode);
//NodeSharedPortDefinitions.addPaddingInputs(LabelNode);
NodeSharedPortDefinitions.addMarginInputs(LabelNode);
NodeSharedPortDefinitions.addSharedVisualInputs(LabelNode);
NodeSharedPortDefinitions.addBorderInputs(LabelNode);
export default createNodeFromReactComponent(LabelNode);

View File

@@ -0,0 +1,413 @@
import React, { useEffect, useState } from 'react';
import FontLoader from '../../fontloader';
import guid from '../../guid';
import Layout from '../../layout';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
import Utils from './utils';
function Options(props) {
const [value, setValue] = useState(props.value);
// Must update value output on both "mount" and when it's changed
useEffect(() => {
setValue(props.value);
}, []);
useEffect(() => {
setValue(props.value);
}, [props.value]);
var style = { ...props.style };
Layout.size(style, props);
Layout.align(style, props);
if (props.textStyle !== undefined) {
// Apply text style
style = Object.assign({}, props.textStyle, style);
}
if (props.boxShadowEnabled) {
style.boxShadow = `${props.boxShadowInset ? 'inset ' : ''}${props.boxShadowOffsetX} ${props.boxShadowOffsetY} ${
props.boxShadowBlurRadius
} ${props.boxShadowSpreadRadius} ${props.boxShadowColor}`;
}
// Hide label if there is no selected value, of if value is not in the items array
const selectedIndex =
value === undefined || value === ''
? -1
: props.items === undefined
? -1
: props.items.findIndex((i) => i.Value === value);
const tagProps = { id: props.id, style: style, onClick: props.onClick };
let className = 'ndl-controls-select';
if (props.className) className = className + ' ' + props.className;
return (
<select
className={className}
ref={(el) => {
if (el) el.selectedIndex = selectedIndex;
}}
{...tagProps}
disabled={!props.enabled}
value={value}
{...Utils.controlEvents(props)}
onChange={(e) => {
setValue(e.target.value);
props.valueChanged && props.valueChanged(e.target.value);
}}
>
{props.items !== undefined
? props.items.map((i) => (
<option
value={i.Value}
disabled={i.Disabled === 'true' || i.Disabled === true ? true : undefined}
selected={i.Value === value}
>
{i.Label}
</option>
))
: null}
</select>
);
}
var OptionsNode = {
name: 'Options',
displayName: 'Options',
docs: 'https://docs.noodl.net/nodes/visual/options',
allowChildren: false,
noodlNodeAsProp: true,
initialize: function () {
this._itemsChanged = () => {
this.forceUpdate();
};
this.props.id = this._internal.controlId = 'input-' + guid();
this.props.enabled = this._internal.enabled = true;
this.outputPropValues.hoverState = this.outputPropValues.focusState = this.outputPropValues.pressedState = false;
this.props.valueChanged = (value) => {
const changed = this._internal.value !== value;
this._internal.value = value;
if (changed) {
this.flagOutputDirty('value');
this.sendSignalOnOutput('onChange');
}
};
},
getReactComponent() {
return Options;
},
inputs: {
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.forceUpdate();
this.flagOutputDirty('enabled');
}
}
},
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;
}
},
value: {
type: '*',
displayName: 'Value',
group: 'General',
set: function (value) {
if (value !== undefined && typeof value !== 'string') {
if (value.toString !== undefined) value = value.toString();
else return;
}
const changed = value !== this._internal.value;
this.props.value = this._internal.value = value;
if (changed) {
this.forceUpdate();
this.flagOutputDirty('value');
}
}
},
// Text style
textStyle: {
index: 20,
type: 'textStyle',
group: 'Text',
displayName: 'Text Style',
default: 'None',
set(value) {
this.props.textStyle = this.context.styles.getTextStyle(value);
this.forceUpdate();
}
},
fontFamily: {
index: 21,
type: 'font',
group: 'Text',
displayName: 'Font Family',
set(value) {
if (value) {
let family = value;
if (family.split('.').length > 1) {
family = family.replace(/\.[^/.]+$/, '');
family = family.split('/').pop();
}
this.setStyle({ fontFamily: family });
} else {
this.removeStyle(['fontFamily']);
}
if (this.props.textStyle) {
this.forceUpdate();
}
}
}
},
outputs: {
controlId: {
type: 'string',
displayName: 'Control Id',
group: 'General',
getter: function () {
return this._internal.controlId;
}
},
enabled: {
type: 'boolean',
displayName: 'Enabled',
group: 'States',
getter: function () {
return this._internal.enabled;
}
},
value: {
type: 'string',
displayName: 'Value',
group: 'States',
getter: function () {
return this._internal.value;
}
},
onChange: {
type: 'signal',
displayName: 'Changed',
group: 'Events'
}
},
inputCss: {
fontSize: {
index: 21,
group: 'Text',
displayName: 'Font Size',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
onChange() {
if (this.props.textStyle) {
this.forceUpdate();
}
}
},
color: {
index: 24,
group: 'Text',
displayName: 'Color',
type: 'color'
},
backgroundColor: {
index: 100,
displayName: 'Background Color',
group: 'Style',
type: 'color',
default: 'transparent'
},
// Border styles
borderRadius: {
index: 202,
displayName: 'Border Radius',
group: 'Style',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: 0,
applyDefault: false
},
borderStyle: {
index: 203,
displayName: 'Border Style',
group: 'Style',
type: {
name: 'enum',
enums: [
{ label: 'None', value: 'none' },
{ label: 'Solid', value: 'solid' },
{ label: 'Dotted', value: 'dotted' },
{ label: 'Dashed', value: 'dashed' }
]
},
default: 'solid',
applyDefault: false
},
borderWidth: {
index: 204,
displayName: 'Border Width',
group: 'Style',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: 1,
applyDefault: false
},
borderColor: {
index: 205,
displayName: 'Border Color',
group: 'Style',
type: 'color',
default: '#000000'
}
},
inputProps: {
// Box shadow
boxShadowEnabled: {
index: 250,
group: 'Box Shadow',
displayName: 'Shadow Enabled',
type: 'boolean',
default: false
},
boxShadowOffsetX: {
index: 251,
group: 'Box Shadow',
displayName: 'Offset X',
default: 0,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowOffsetY: {
index: 252,
group: 'Box Shadow',
displayName: 'Offset Y',
default: 0,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowBlurRadius: {
index: 253,
group: 'Box Shadow',
displayName: 'Blur Radius',
default: 5,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowSpreadRadius: {
index: 254,
group: 'Box Shadow',
displayName: 'Spread Radius',
default: 2,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowInset: {
index: 255,
group: 'Box Shadow',
displayName: 'Inset',
type: 'boolean',
default: false
},
boxShadowColor: {
index: 256,
group: 'Box Shadow',
displayName: 'Shadow Color',
type: 'color',
default: 'rgba(0,0,0,0.2)'
}
},
outputProps: {},
dynamicports: [
{
condition: 'boxShadowEnabled = true',
inputs: [
'boxShadowOffsetX',
'boxShadowOffsetY',
'boxShadowInset',
'boxShadowBlurRadius',
'boxShadowSpreadRadius',
'boxShadowColor'
]
}
],
methods: {}
};
NodeSharedPortDefinitions.addDimensions(OptionsNode, { defaultSizeMode: 'contentSize', contentLabel: 'Content' });
NodeSharedPortDefinitions.addAlignInputs(OptionsNode);
NodeSharedPortDefinitions.addTransformInputs(OptionsNode);
NodeSharedPortDefinitions.addPaddingInputs(OptionsNode);
NodeSharedPortDefinitions.addMarginInputs(OptionsNode);
NodeSharedPortDefinitions.addSharedVisualInputs(OptionsNode);
Utils.addControlEventsAndStates(OptionsNode);
OptionsNode = createNodeFromReactComponent(OptionsNode);
OptionsNode.setup = function (context, graphModel) {
graphModel.on('nodeAdded.Options', function (node) {
if (node.parameters.fontFamily && node.parameters.fontFamily.split('.').length > 1) {
FontLoader.instance.loadFont(node.parameters.fontFamily);
}
node.on('parameterUpdated', function (event) {
if (event.name === 'fontFamily' && event.value) {
if (event.value.split('.').length > 1) {
FontLoader.instance.loadFont(event.value);
}
}
});
});
};
export default OptionsNode;

View File

@@ -0,0 +1,299 @@
import React, { useContext } from 'react';
import RadioButtonContext from '../../contexts/radiobuttoncontext';
import guid from '../../guid';
import Layout from '../../layout';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
import Utils from './utils';
function _styleTemplate(_class, props) {
return `
.${_class}:checked {
background-color: ${props.checkedBackgroundColor};
}
`;
}
function RadioButton(props) {
const radioButtonGroup = useContext(RadioButtonContext);
var style = { ...props.style };
Layout.size(style, props);
Layout.align(style, props);
if (props.boxShadowEnabled) {
style.boxShadow = `${props.boxShadowInset ? 'inset ' : ''}${props.boxShadowOffsetX} ${props.boxShadowOffsetY} ${
props.boxShadowBlurRadius
} ${props.boxShadowSpreadRadius} ${props.boxShadowColor}`;
}
const tagProps = { id: props.id, style: style };
props.checkedChanged && props.checkedChanged(radioButtonGroup ? radioButtonGroup.selected === props.value : false);
Utils.updateStylesForClass(
'ndl-controls-radiobutton-' + props._nodeId,
{ checkedBackgroundColor: props.checkedBackgroundColor },
_styleTemplate
);
let className = 'ndl-controls-radiobutton-' + props._nodeId + ' ndl-controls-radiobutton';
if (props.className) className = className + ' ' + props.className;
return (
<input
className={className}
{...Utils.controlEvents(props)}
type="radio"
name={radioButtonGroup ? radioButtonGroup.name : undefined}
{...tagProps}
disabled={!props.enabled}
checked={radioButtonGroup ? radioButtonGroup.selected === props.value : false}
onChange={(e) => {
radioButtonGroup && radioButtonGroup.checkedChanged && radioButtonGroup.checkedChanged(props.value);
}}
></input>
);
}
var RadioButtonNode = {
name: 'Radio Button',
displayName: 'Radio Button',
docs: 'https://docs.noodl.net/nodes/visual/radiobutton',
allowChildren: false,
noodlNodeAsProp: true,
initialize() {
this.props.sizeMode = 'explicit';
this.props.id = this._internal.controlId = 'input-' + guid();
this.props.enabled = this._internal.enabled = true;
this.props._nodeId = this.id;
this._internal.checked = false;
this.outputPropValues.hoverState = this.outputPropValues.focusState = this.outputPropValues.pressedState = false;
this.props.checkedChanged = (checked) => {
const changed = this._internal.checked !== checked;
this._internal.checked = checked;
if (changed) {
this.flagOutputDirty('checked');
}
};
},
getReactComponent() {
return RadioButton;
},
inputs: {
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.forceUpdate();
this.flagOutputDirty('enabled');
}
}
}
},
outputs: {
controlId: {
type: 'string',
displayName: 'Control Id',
group: 'General',
getter: function () {
return this._internal.controlId;
}
},
enabled: {
type: 'boolean',
displayName: 'Enabled',
group: 'States',
getter: function () {
return this._internal.enabled;
}
},
checked: {
type: 'boolean',
displayName: 'Checked',
group: 'States',
getter: function () {
return this._internal.checked;
}
}
},
inputProps: {
value: { type: 'string', displayName: 'Value', group: 'General' },
width: {
index: 11,
group: 'Dimensions',
displayName: 'Width',
type: {
name: 'number',
units: ['%', 'px', 'vw'],
defaultUnit: 'px'
},
default: 32
},
height: {
index: 12,
group: 'Dimensions',
displayName: 'Height',
type: {
name: 'number',
units: ['%', 'px', 'vh'],
defaultUnit: 'px'
},
default: 32
},
// Styles
checkedBackgroundColor: {
displayName: 'Background color',
group: 'Checked Style',
type: { name: 'color', allowEditOnly: true },
default: '#000000'
},
// Box shadow
boxShadowEnabled: {
index: 250,
group: 'Box Shadow',
displayName: 'Shadow Enabled',
type: 'boolean',
default: false
},
boxShadowOffsetX: {
index: 251,
group: 'Box Shadow',
displayName: 'Offset X',
default: 0,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowOffsetY: {
index: 252,
group: 'Box Shadow',
displayName: 'Offset Y',
default: 0,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowBlurRadius: {
index: 253,
group: 'Box Shadow',
displayName: 'Blur Radius',
default: 5,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowSpreadRadius: {
index: 254,
group: 'Box Shadow',
displayName: 'Spread Radius',
default: 2,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowInset: {
index: 255,
group: 'Box Shadow',
displayName: 'Inset',
type: 'boolean',
default: false
},
boxShadowColor: {
index: 256,
group: 'Box Shadow',
displayName: 'Shadow Color',
type: 'color',
default: 'rgba(0,0,0,0.2)'
}
},
inputCss: {
backgroundColor: {
index: 201,
displayName: 'Background Color',
group: 'Style',
type: 'color'
},
// Border styles
borderRadius: {
index: 202,
displayName: 'Border Radius',
group: 'Style',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: 16,
applyDefault: false
},
borderStyle: {
index: 203,
displayName: 'Border Style',
group: 'Style',
type: {
name: 'enum',
enums: [
{ label: 'None', value: 'none' },
{ label: 'Solid', value: 'solid' },
{ label: 'Dotted', value: 'dotted' },
{ label: 'Dashed', value: 'dashed' }
]
},
default: 'solid',
applyDefault: false
},
borderWidth: {
index: 204,
displayName: 'Border Width',
group: 'Style',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: 1,
applyDefault: false
},
borderColor: {
index: 205,
displayName: 'Border Color',
group: 'Style',
type: 'color',
default: '#000000'
}
},
outputProps: {},
methods: {}
};
NodeSharedPortDefinitions.addAlignInputs(RadioButtonNode);
NodeSharedPortDefinitions.addTransformInputs(RadioButtonNode);
NodeSharedPortDefinitions.addMarginInputs(RadioButtonNode);
NodeSharedPortDefinitions.addSharedVisualInputs(RadioButtonNode);
Utils.addControlEventsAndStates(RadioButtonNode);
RadioButtonNode = createNodeFromReactComponent(RadioButtonNode);
export default RadioButtonNode;

View File

@@ -0,0 +1,390 @@
import React, { useEffect, useState } from 'react';
import guid from '../../guid';
import Layout from '../../layout';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
import Utils from './utils';
function _styleTemplate(_class, props) {
return `
.${_class}::-webkit-slider-thumb {
width: ${props.thumbWidth};
height: ${props.thumbHeight};
background: ${props.thumbColor};
border: 0;
border-radius: ${props.thumbRadius};
cursor: pointer;
-webkit-appearance: none;
margin-top:calc(${props.trackHeight}/2 - ${props.thumbHeight}/2);
}
.${_class}::-moz-range-thumb {
width: ${props.thumbWidth};
height: ${props.thumbHeight};
background: ${props.thumbColor};
border: none;
border-radius: ${props.thumbRadius};
cursor: pointer;
}
.${_class}::-ms-thumb {
width: ${props.thumbWidth};
height: ${props.thumbHeight};
background: ${props.thumbColor};
border: none;
border-radius: ${props.thumbRadius};
cursor: pointer;
margin-top: 0px;
}
.${_class}::-webkit-slider-runnable-track {
background: ${props.trackColor};
border: none;
width: 100%;
height: ${props.trackHeight};
cursor: pointer;
margin-top:0px;
}
.${_class}:focus::-webkit-slider-runnable-track {
background: ${props.trackColor};
}
.${_class}::-moz-range-track {
background: ${props.trackColor};
border: none;
width: 100%;
height: ${props.trackHeight};
cursor: pointer;
}
.${_class}::-ms-track {
background: transparent;
border:none;
color: transparent;
width: 100%;
height: ${props.trackHeight};
cursor: pointer;
}
.${_class}::-ms-fill-lower {
background: ${props.trackColor};
border: none;
}
.${_class}::-ms-fill-upper {
background: ${props.trackColor};
border: none;
}
`;
}
// --------------------------------------------------------------------------------------
// Range
// --------------------------------------------------------------------------------------
function Range(props) {
const [value, setValue] = useState(props.value);
// Report initial values when mounted
useEffect(() => {
setValue(props.value);
}, []);
useEffect(() => {
setValue(props.value);
}, [props.value]);
var style = { ...props.style };
Layout.size(style, props);
Layout.align(style, props);
const tagProps = { id: props.id, min: props.min, max: props.max, step: props.step, style: style };
Utils.updateStylesForClass('ndl-controls-range-' + props._nodeId, props, _styleTemplate);
let className = 'ndl-controls-range-' + props._nodeId + ' ndl-controls-range';
if (props.className) className = className + ' ' + props.className;
return (
<input
className={className}
{...Utils.controlEvents(props)}
type="range"
{...tagProps}
value={value}
disabled={!props.enabled}
onChange={(e) => {
setValue(e.target.value);
props.valueChanged && props.valueChanged(e.target.value);
}}
></input>
);
}
var RangeNode = {
name: 'Range',
docs: 'https://docs.noodl.net/nodes/visual/range',
allowChildren: false,
noodlNodeAsProp: true,
initialize() {
this.props.sizeMode = 'explicit';
this.props.id = this._internal.controlId = 'input-' + guid();
this.props.enabled = this._internal.enabled = true;
this._internal.value = this.props.value = this.props.min;
this.props._nodeId = this.id;
this.props.valueChanged = (value) => {
value = typeof value === 'string' ? parseFloat(value) : value;
const valueChanged = this._internal.value !== value;
this._internal.value = value;
this._updateValuePercent(value);
if (valueChanged) {
this.flagOutputDirty('value');
this.sendSignalOnOutput('onChange');
}
};
this.props.valueChanged(this.props.value); // Update initial values
this.outputPropValues.hoverState = this.outputPropValues.focusState = this.outputPropValues.pressedState = false;
},
getReactComponent() {
return Range;
},
inputs: {
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.forceUpdate();
this.flagOutputDirty('enabled');
}
}
},
value: {
type: 'string',
displayName: 'Value',
group: 'General',
set: function (value) {
const changed = value !== this._internal.value;
this.props.value = this._internal.value = value;
this._updateValuePercent(value);
if (changed) {
this.forceUpdate();
this.flagOutputDirty('value');
}
}
}
},
outputs: {
controlId: {
type: 'string',
displayName: 'Control Id',
group: 'General',
getter: function () {
return this._internal.controlId;
}
},
enabled: {
type: 'boolean',
displayName: 'Enabled',
group: 'States',
getter: function () {
return this._internal.enabled;
}
},
value: {
type: 'number',
displayName: 'Value',
group: 'States',
getter: function () {
return this._internal.value;
}
},
valuePercent: {
type: 'number',
displayName: 'Value Percent',
group: 'States',
getter: function () {
return this._internal.valuePercent;
}
},
onChange: {
type: 'signal',
displayName: 'Changed',
group: 'Events'
}
},
inputProps: {
min: { type: 'number', displayName: 'Min', group: 'General', default: 0 },
max: { type: 'number', displayName: 'Max', group: 'General', default: 100 },
step: { type: 'number', displayName: 'Step', group: 'General', default: 1 },
width: {
index: 11,
group: 'Dimensions',
displayName: 'Width',
type: {
name: 'number',
units: ['%', 'px', 'vw'],
defaultUnit: '%'
},
default: 100
},
height: {
index: 12,
group: 'Dimensions',
displayName: 'Height',
type: {
name: 'number',
units: ['%', 'px', 'vh'],
defaultUnit: '%'
},
default: 100
},
// Styles
thumbWidth: {
group: 'Thumb Style',
displayName: 'Width',
type: {
name: 'number',
units: ['px', 'vw', '%'],
defaultUnit: 'px',
allowEditOnly: true
},
default: 16
},
thumbHeight: {
group: 'Thumb Style',
displayName: 'Height',
type: {
name: 'number',
units: ['px', 'vh', '%'],
defaultUnit: 'px',
allowEditOnly: true
},
default: 16
},
thumbRadius: {
group: 'Thumb Style',
displayName: 'Radius',
type: {
name: 'number',
units: ['px', '%'],
defaultUnit: 'px',
allowEditOnly: true
},
default: 8
},
thumbColor: {
group: 'Thumb Style',
displayName: 'Color',
type: { name: 'color', allowEditOnly: true },
default: '#000000'
},
trackHeight: {
group: 'Track Style',
displayName: 'Height',
type: {
name: 'number',
units: ['px', 'vh', '%'],
defaultUnit: 'px',
allowEditOnly: true
},
default: 6
},
trackColor: {
group: 'Track Style',
displayName: 'Color',
type: { name: 'color', allowEditOnly: true },
default: '#f0f0f0'
}
},
inputCss: {
backgroundColor: {
index: 201,
displayName: 'Background Color',
group: 'Style',
type: 'color'
},
// Border styles
borderRadius: {
index: 202,
displayName: 'Border Radius',
group: 'Style',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: 1,
applyDefault: false
},
borderStyle: {
index: 203,
displayName: 'Border Style',
group: 'Style',
type: {
name: 'enum',
enums: [
{ label: 'None', value: 'none' },
{ label: 'Solid', value: 'solid' },
{ label: 'Dotted', value: 'dotted' },
{ label: 'Dashed', value: 'dashed' }
]
},
default: 'none',
applyDefault: false
},
borderWidth: {
index: 204,
displayName: 'Border Width',
group: 'Style',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: 1,
applyDefault: false
},
borderColor: {
index: 205,
displayName: 'Border Color',
group: 'Style',
type: 'color',
default: '#000000'
}
},
outputProps: {},
methods: {
_updateValuePercent(value) {
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');
}
}
};
NodeSharedPortDefinitions.addAlignInputs(RangeNode);
NodeSharedPortDefinitions.addTransformInputs(RangeNode);
NodeSharedPortDefinitions.addMarginInputs(RangeNode);
NodeSharedPortDefinitions.addSharedVisualInputs(RangeNode);
Utils.addControlEventsAndStates(RangeNode);
RangeNode = createNodeFromReactComponent(RangeNode);
export default RangeNode;

View File

@@ -0,0 +1,461 @@
import React from 'react';
import FontLoader from '../../fontloader';
import guid from '../../guid';
import Layout from '../../layout';
import NodeSharedPortDefinitions from '../../node-shared-port-definitions';
import { createNodeFromReactComponent } from '../../react-component-node';
import Utils from './utils';
//this stops a text field from being unfocused by the clickHandler in the viewer that handles focus globally.
//The specific case is when a mouseDown is registered in the input, but the mouseUp is outside.
//It'll trigger a focus change that'll blur the input field, which is annyoing when you're selecting text
function preventGlobalFocusChange(e) {
e.stopPropagation();
window.removeEventListener('click', preventGlobalFocusChange, true);
}
class TextFieldComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
value: props.startValue
};
this.ref = React.createRef();
}
setText(value) {
this.setState({ value });
this.props.onTextChanged && this.props.onTextChanged(value);
}
componentDidMount() {
//plumbing for the focused signals
this.ref.current.noodlNode = this.props.noodlNode;
this.setText(this.props.startValue);
}
render() {
const style = { ...this.props.textStyle, ...this.props.style };
Layout.size(style, this.props);
Layout.align(style, this.props);
if (style.opacity === 0) {
style.pointerEvents = 'none';
}
const props = this.props;
const sharedProps = {
id: props.id,
value: this.state.value,
...Utils.controlEvents(props),
disabled: !props.enabled,
style,
className: props.className,
placeholder: props.placeholder,
onChange: (e) => this.onChange(e)
};
if (this.props.type !== 'textArea') {
return (
<input
ref={this.ref}
type={this.props.type}
{...sharedProps}
onKeyDown={(e) => this.onKeyDown(e)}
onMouseDown={() => window.addEventListener('click', preventGlobalFocusChange, true)}
/>
);
} else {
sharedProps.style.resize = 'none'; //disable user resizing
return <textarea ref={this.ref} {...sharedProps} onKeyDown={(e) => this.onKeyDown(e)} />;
}
}
onKeyDown(e) {
if (e.key === 'Enter' || e.which === 13) {
this.props.onEnter && this.props.onEnter();
}
}
onChange(event) {
const value = event.target.value;
this.setText(value);
}
focus() {
this.ref.current && this.ref.current.focus();
}
blur() {
this.ref.current && this.ref.current.blur();
}
}
const TextInput = {
name: 'Text Input',
docs: 'https://docs.noodl.net/nodes/visual/text-input',
allowChildren: false,
noodlNodeAsProp: true,
getReactComponent() {
return TextFieldComponent;
},
defaultCss: {
outline: 'none',
borderStyle: 'solid',
padding: 0
},
initialize() {
this.props.startValue = '';
this.props.id = this._internal.controlId = 'input-' + guid();
this.props.enabled = this._internal.enabled = true;
this.outputPropValues.hoverState = this.outputPropValues.focusState = this.outputPropValues.pressedState = false;
},
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'
}
},
/* disabled: {
group: 'Text',
index: 23,
displayName: 'Disabled',
propPath: 'dom',
default: false,
type: 'boolean'
}*/
// Box shadow
boxShadowEnabled: {
index: 250,
group: 'Box Shadow',
displayName: 'Shadow Enabled',
type: 'boolean',
default: false
},
boxShadowOffsetX: {
index: 251,
group: 'Box Shadow',
displayName: 'Offset X',
default: 0,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowOffsetY: {
index: 252,
group: 'Box Shadow',
displayName: 'Offset Y',
default: 0,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowBlurRadius: {
index: 253,
group: 'Box Shadow',
displayName: 'Blur Radius',
default: 5,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowSpreadRadius: {
index: 254,
group: 'Box Shadow',
displayName: 'Spread Radius',
default: 2,
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
}
},
boxShadowInset: {
index: 255,
group: 'Box Shadow',
displayName: 'Inset',
type: 'boolean',
default: false
},
boxShadowColor: {
index: 256,
group: 'Box Shadow',
displayName: 'Shadow Color',
type: 'color',
default: 'rgba(0,0,0,0.2)'
}
},
inputs: {
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.forceUpdate();
this.flagOutputDirty('enabled');
}
}
},
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);
}
}
},
textStyle: {
index: 19,
type: 'textStyle',
group: 'Text',
displayName: 'Text Style',
default: 'None',
set(value) {
this.props.textStyle = this.context.styles.getTextStyle(value);
this.forceUpdate();
}
},
fontFamily: {
index: 20,
type: 'font',
group: 'Text',
displayName: 'Font Family',
set(value) {
if (value) {
let family = value;
if (family.split('.').length > 1) {
family = family.replace(/\.[^/.]+$/, '');
family = family.split('/').pop();
}
this.setStyle({ fontFamily: family });
} else {
this.removeStyle(['fontFamily']);
}
if (this.props.textStyle) {
this.forceUpdate();
}
}
},
clear: {
type: 'signal',
group: 'Actions',
displayName: 'Clear',
valueChangedToTrue() {
this.setText('');
}
},
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);
}
}
},
inputCss: {
fontSize: {
index: 21,
group: 'Text',
displayName: 'Font Size',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
onChange() {
if (this.props.textStyle) {
this.forceUpdate();
}
}
},
color: {
index: 99,
type: 'color',
displayName: 'Font Color',
group: 'Style'
},
backgroundColor: {
index: 100,
displayName: 'Background Color',
group: 'Style',
type: 'color',
default: 'transparent'
},
borderColor: {
index: 101,
displayName: 'Border Color',
group: 'Style',
type: 'color',
default: 'black'
},
borderWidth: {
index: 102,
displayName: 'Border Width',
group: 'Style',
type: {
name: 'number',
units: ['px'],
defaultUnit: 'px'
},
default: 0
}
},
outputs: {
controlId: {
type: 'string',
displayName: 'Control Id',
group: 'General',
getter: function () {
return this._internal.controlId;
}
},
enabled: {
type: 'boolean',
displayName: 'Enabled',
group: 'States',
getter: function () {
return this._internal.enabled;
}
}
},
outputProps: {
// Value
onTextChanged: {
group: 'Value',
displayName: 'Text',
type: 'string'
},
// Events
onEnter: {
group: 'Events',
displayName: 'On Enter',
type: 'signal'
}
},
dynamicports: [
{
condition: 'boxShadowEnabled = true',
inputs: [
'boxShadowOffsetX',
'boxShadowOffsetY',
'boxShadowInset',
'boxShadowBlurRadius',
'boxShadowSpreadRadius',
'boxShadowColor'
]
}
],
methods: {
_focus() {
if (!this.innerReactComponentRef) return;
this.innerReactComponentRef.focus();
},
_blur() {
if (!this.innerReactComponentRef) return;
this.innerReactComponentRef.blur();
},
setText(text) {
this.props.startValue = text;
if (this.innerReactComponentRef) {
//the text component is mounted, and will signal the onTextChanged output
this.innerReactComponentRef.setText(text);
} else if (this.outputPropValues['onTextChanged'] !== text) {
//text component isn't mounted, set the output manually
this.outputPropValues['onTextChanged'] = text;
this.flagOutputDirty('onTextChanged');
}
}
}
};
NodeSharedPortDefinitions.addDimensions(TextInput, { defaultSizeMode: 'contentSize', contentLabel: 'Text' });
NodeSharedPortDefinitions.addAlignInputs(TextInput);
NodeSharedPortDefinitions.addTransformInputs(TextInput);
NodeSharedPortDefinitions.addPaddingInputs(TextInput);
NodeSharedPortDefinitions.addMarginInputs(TextInput);
NodeSharedPortDefinitions.addSharedVisualInputs(TextInput);
Utils.addControlEventsAndStates(TextInput);
const definition = createNodeFromReactComponent(TextInput);
definition.setup = function (context, graphModel) {
graphModel.on('nodeAdded.Text Input', function (node) {
if (node.parameters.fontFamily && node.parameters.fontFamily.split('.').length > 1) {
FontLoader.instance.loadFont(node.parameters.fontFamily);
}
node.on('parameterUpdated', function (event) {
if (event.name === 'fontFamily' && event.value) {
if (event.value.split('.').length > 1) {
FontLoader.instance.loadFont(event.value);
}
}
});
});
};
export default definition;

View File

@@ -0,0 +1,273 @@
import PointerListeners from '../../pointerlisteners';
function _shallowCompare(o1, o2) {
for (var p in o1) {
if (o1.hasOwnProperty(p)) {
if (o1[p] !== o2[p]) {
return false;
}
}
}
for (var 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
var style = document.createElement('style');
style.innerHTML = _styleTemplate(_class, props);
document.head.appendChild(style);
_styleSheets[_class] = { style, props: Object.assign({}, props) };
}
}
function addInputCss(definition, inputs) {
if (!definition.inputCss) {
definition.inputCss = {};
}
if (!definition.defaultCss) {
definition.defaultCss = {};
}
for (const name in inputs) {
definition.inputCss[name] = inputs[name];
if (inputs[name].hasOwnProperty('default') && inputs[name].applyDefault !== false) {
definition.defaultCss[name] = inputs[name].default;
}
}
}
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 addDynamicInputPorts(definition, condition, inputs) {
if (!definition.dynamicports) {
definition.dynamicports = [];
}
definition.dynamicports.push({ condition, inputs });
}
function addOutputProps(definition, values) {
mergeAttribute(definition, 'outputProps', values);
}
function addControlEventsAndStates(definition) {
addInputProps(definition, {
blockTouch: {
index: 450,
displayName: 'Block Pointer Events',
type: 'boolean'
}
});
addOutputProps(definition, {
// Focus
focusState: {
displayName: 'Focused',
group: 'States',
type: 'boolean',
props: {
onFocus() {
this.outputPropValues.focusState = true;
this.flagOutputDirty('focusState');
this.hasOutput('onFocus') && this.sendSignalOnOutput('onFocus');
},
onBlur() {
this.outputPropValues.focusState = false;
this.flagOutputDirty('focusState');
this.hasOutput('onBlur') && this.sendSignalOnOutput('onBlur');
}
}
},
onFocus: {
displayName: 'Focused',
group: 'Events',
type: 'signal',
props: {
onFocus() {
this.outputPropValues.focusState = true;
this.flagOutputDirty('focusState');
this.sendSignalOnOutput('onFocus');
}
}
},
onBlur: {
displayName: 'Blurred',
group: 'Events',
type: 'signal',
props: {
onBlur() {
this.outputPropValues.focusState = false;
this.flagOutputDirty('focusState');
this.sendSignalOnOutput('onBlur');
}
}
},
// Hover
hoverState: {
displayName: 'Hover',
group: 'States',
type: 'boolean',
props: {
onMouseOver() {
this.outputPropValues.hoverState = true;
this.flagOutputDirty('hoverState');
this.hasOutput('hoverStart') && this.sendSignalOnOutput('hoverStart');
},
onMouseLeave() {
this.outputPropValues.hoverState = false;
this.flagOutputDirty('hoverState');
this.hasOutput('hoverEnd') && this.sendSignalOnOutput('hoverEnd');
}
}
},
hoverStart: {
displayName: 'Hover Start',
group: 'Events',
type: 'signal',
props: {
onMouseOver() {
this.outputPropValues.hoverState = true;
this.flagOutputDirty('hoverState');
this.sendSignalOnOutput('hoverStart');
}
}
},
hoverEnd: {
displayName: 'Hover End',
group: 'Events',
type: 'signal',
props: {
onMouseLeave() {
this.outputPropValues.hoverState = false;
this.flagOutputDirty('hoverState');
this.sendSignalOnOutput('hoverEnd');
}
}
},
// Pressed
pressedState: {
displayName: 'Pressed',
group: 'States',
type: 'boolean',
props: {
onMouseDown() {
this.outputPropValues.pressedState = true;
this.flagOutputDirty('pressedState');
this.hasOutput('pointerDown') && this.sendSignalOnOutput('pointerDown');
},
onTouchStart() {
this.outputPropValues.pressedState = true;
this.flagOutputDirty('pressedState');
this.hasOutput('pointerDown') && this.sendSignalOnOutput('pointerDown');
},
onMouseUp() {
this.outputPropValues.pressedState = false;
this.flagOutputDirty('pressedState');
this.hasOutput('pointerUp') && this.sendSignalOnOutput('pointerUp');
},
onTouchEnd() {
this.outputPropValues.pressedState = false;
this.flagOutputDirty('pressedState');
this.hasOutput('pointerUp') && this.sendSignalOnOutput('pointerUp');
},
onTouchCancel() {
this.outputPropValues.pressedState = false;
this.flagOutputDirty('pressedState');
this.hasOutput('pointerUp') && this.sendSignalOnOutput('pointerUp');
}
}
},
pointerDown: {
displayName: 'Pointer Down',
group: 'Events',
type: 'signal',
props: {
onMouseDown() {
this.outputPropValues.pressedState = true;
this.flagOutputDirty('pressedState');
this.sendSignalOnOutput('pointerDown');
},
onTouchStart() {
this.outputPropValues.pressedState = true;
this.flagOutputDirty('pressedState');
this.sendSignalOnOutput('pointerDown');
}
}
},
pointerUp: {
displayName: 'Pointer Up',
group: 'Events',
type: 'signal',
props: {
onMouseUp() {
this.outputPropValues.pressedState = false;
this.flagOutputDirty('pressedState');
this.sendSignalOnOutput('pointerUp');
},
onTouchEnd() {
this.outputPropValues.pressedState = false;
this.flagOutputDirty('pressedState');
this.sendSignalOnOutput('pointerUp');
},
onTouchCancel() {
this.outputPropValues.pressedState = false;
this.flagOutputDirty('pressedState');
this.sendSignalOnOutput('pointerUp');
}
}
}
});
}
function controlEvents(props) {
return Object.assign(
{},
{
onFocus: props.onFocus,
onBlur: props.onBlur
},
PointerListeners(props)
);
}
export default {
updateStylesForClass,
addControlEventsAndStates,
controlEvents
};

View File

@@ -0,0 +1,543 @@
'use strict';
const EaseCurves = require('../../easecurves'),
BezierEasing = require('bezier-easing');
function SubAnimation(args) {
this.name = args.name;
this.startValue = 0;
this.endValue = 0;
this.currentValue = undefined;
this.startMode = 'implicit';
this.ease = args.ease;
this.node = args.node;
this.hasSampledStartValue = false;
var self = this;
this.animation = args.node.context.timerScheduler.createTimer({
startValue: 0,
endValue: 0,
onRunning: function (t) {
var value = self.ease(this.startValue, this.endValue, t);
self.setCurrentValue(value);
}
});
this.animation.startValue = 0;
this.animation.endValue = 0;
}
Object.defineProperties(SubAnimation.prototype, {
setCurrentValue: {
value: function (value) {
this.currentValue = value;
this.node.flagOutputDirty(this.name);
}
},
play: {
value: function (start, end) {
if (start === undefined) {
console.log('Animation warning, start value is undefined');
start = 0;
}
if (end === undefined) {
console.error('Animation error, start:', start, 'end:', end);
return;
}
var animation = this.animation;
animation.startValue = start;
this.setCurrentValue(start);
animation.endValue = end;
animation.duration = this.node._internal.duration;
animation.start();
}
},
playToEnd: {
value: function () {
if (this.hasConnections() === false) {
return;
}
this.updateStartValue();
this.play(this.getTargetsCurrentValue(), this.endValue);
}
},
playToStart: {
value: function () {
if (this.hasConnections() === false) {
return;
}
this.updateStartValue();
this.play(this.getTargetsCurrentValue(), this.startValue);
}
},
replayToEnd: {
value: function () {
if (this.hasConnections() === false) {
return;
}
this.updateStartValue(); //in case animation doesn't have an explicit start value set
this.play(this.startValue, this.endValue);
}
},
replayToStart: {
value: function () {
if (this.hasConnections() === false) {
return;
}
this.play(this.endValue, this.startValue);
}
},
hasConnections: {
value: function () {
return this.node.getOutput(this.name).hasConnections();
}
},
getTargetsCurrentValue: {
value: function () {
var valueConnections = this.node.getOutput(this.name).connections;
//TODO: this will only work for the first connection
const value = valueConnections[0].node.getInputValue(valueConnections[0].inputPortName);
return value instanceof Object && value.hasOwnProperty('value') ? value.value : value;
}
},
updateStartValue: {
value: function () {
if (this.startMode !== 'implicit' || this.hasSampledStartValue) {
return;
}
this.hasSampledStartValue = true;
this.startValue = this.getTargetsCurrentValue();
}
},
stop: {
value: function () {
this.animation.stop();
this.setCurrentValue(undefined);
}
},
jumpToStart: {
value: function () {
this.animation.stop();
this.setCurrentValue(this.startValue);
}
},
jumpToEnd: {
value: function () {
this.animation.stop();
this.setCurrentValue(this.endValue);
}
}
});
var easeEnum = [
{ 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' }
];
var defaultDuration = 300;
var AnimationNode = {
name: 'Animation',
docs: 'https://docs.noodl.net/nodes/animation/animation',
shortDesc: 'Node that can animate any number of values, with different types of easing curves.',
category: 'Animation',
deprecated: true,
initialize: function () {
var internal = this._internal;
internal.duration = defaultDuration;
internal.ease = EaseCurves.easeOut;
internal._isPlayingToEnd = false;
internal.animations = [];
internal.cubicBezierPoints = [0, 0, 0, 0];
internal.cubicBezierFunction = undefined;
var self = this;
internal.animation = this.context.timerScheduler.createTimer({
onFinish: function () {
if (internal._isPlayingToEnd === false) {
self.sendSignalOnOutput('hasReachedStart');
} else {
self.sendSignalOnOutput('hasReachedEnd');
}
}
});
},
inputs: {
duration: {
index: 0,
type: 'number',
displayName: 'Duration (ms)',
group: 'Animation Properties',
default: defaultDuration,
set: function (value) {
this._internal.duration = value;
}
},
easingCurve: {
index: 10,
type: {
name: 'enum',
enums: easeEnum
},
group: 'Animation Properties',
displayName: 'Easing Curve',
default: 'easeOut',
set: function (value) {
var easeCurve;
if (value === 'cubicBezier') {
this.updateCubicBezierFunction();
easeCurve = this._internal.cubicBezierFunction;
} else {
easeCurve = EaseCurves[value];
}
this._internal.ease = easeCurve;
}
},
playToEnd: {
index: 20,
group: 'Play',
displayName: 'To End',
editorName: 'Play To End',
valueChangedToTrue: function () {
this._internal._isPlayingToEnd = true;
var animation = this._internal.animation,
animations = this._internal.animations,
self = this;
this.scheduleAfterInputsHaveUpdated(function () {
animation.duration = self._internal.duration;
animation.start();
for (var i = 0; i < animations.length; i++) {
animations[i].ease = self._internal.ease;
animations[i].playToEnd();
}
});
}
},
playToStart: {
index: 21,
group: 'Play',
displayName: 'To Start',
editorName: 'Play To Start',
valueChangedToTrue: function () {
this._internal._isPlayingToEnd = false;
var animation = this._internal.animation,
animations = this._internal.animations,
self = this;
this.scheduleAfterInputsHaveUpdated(function () {
animation.duration = self._internal.duration;
animation.start();
for (var i = 0; i < animations.length; i++) {
animations[i].ease = self._internal.ease;
animations[i].playToStart();
}
});
}
},
replayToEnd: {
index: 22,
group: 'Play',
displayName: 'From Start To End',
editorName: 'Play From Start To End',
valueChangedToTrue: function () {
var animation = this._internal.animation,
animations = this._internal.animations,
self = this;
this._internal._isPlayingToEnd = true;
this.scheduleAfterInputsHaveUpdated(function () {
animation.duration = self._internal.duration;
animation.start();
for (var i = 0; i < animations.length; i++) {
animations[i].ease = self._internal.ease;
animations[i].replayToEnd();
}
});
}
},
replayToStart: {
index: 23,
group: 'Play',
displayName: 'From End To Start',
editorName: 'Play From End To Start',
valueChangedToTrue: function () {
this._internal._isPlayingToEnd = false;
var animation = this._internal.animation,
animations = this._internal.animations,
self = this;
this.scheduleAfterInputsHaveUpdated(function () {
animation.duration = self._internal.duration;
animation.start();
for (var i = 0; i < animations.length; i++) {
animations[i].ease = self._internal.ease;
animations[i].replayToStart();
}
});
}
},
stop: {
index: 60,
group: 'Instant Actions',
displayName: 'Stop',
valueChangedToTrue: function () {
var animation = this._internal.animation,
animations = this._internal.animations;
animation.stop();
for (var i = 0; i < animations.length; i++) {
animations[i].stop();
}
}
},
jumpToStart: {
index: 61,
group: 'Instant Actions',
displayName: 'Jump To Start',
valueChangedToTrue: function () {
var animations = this._internal.animations;
for (var i = 0; i < animations.length; i++) {
animations[i].jumpToStart();
}
this.sendSignalOnOutput('hasReachedStart');
}
},
jumpToEnd: {
index: 62,
group: 'Instant Actions',
displayName: 'Jump To End',
valueChangedToTrue: function () {
var animations = this._internal.animations;
for (var i = 0; i < animations.length; i++) {
animations[i].jumpToEnd();
}
this.sendSignalOnOutput('hasReachedEnd');
}
},
cubicBezierP1X: {
displayName: 'P1 X',
group: 'Cubic Bezier',
type: {
name: 'number'
},
index: 11,
set: function (value) {
this._internal.cubicBezierPoints[0] = Math.min(1, Math.max(0, value));
this.updateCubicBezierFunction();
}
},
cubicBezierP1Y: {
displayName: 'P1 Y',
group: 'Cubic Bezier',
type: {
name: 'number'
},
index: 12,
set: function (value) {
this._internal.cubicBezierPoints[1] = value;
this.updateCubicBezierFunction();
}
},
cubicBezierP2X: {
displayName: 'P2 X',
group: 'Cubic Bezier',
type: {
name: 'number'
},
index: 13,
set: function (value) {
this._internal.cubicBezierPoints[2] = Math.min(1, Math.max(0, value));
this.updateCubicBezierFunction();
}
},
cubicBezierP2Y: {
displayName: 'P2 Y',
group: 'Cubic Bezier',
type: {
name: 'number'
},
index: 14,
set: function (value) {
this._internal.cubicBezierPoints[3] = value;
this.updateCubicBezierFunction();
}
}
},
outputs: {
hasReachedStart: {
type: 'signal',
group: 'Signals',
displayName: 'Has Reached Start'
},
hasReachedEnd: {
type: 'signal',
group: 'Signals',
displayName: 'Has Reached End'
}
},
dynamicports: [
{
condition: 'easingCurve = cubicBezier',
inputs: ['cubicBezierP1X', 'cubicBezierP1Y', 'cubicBezierP2X', 'cubicBezierP2Y']
},
//animation outputs
{
name: 'expand/basic',
indexStep: 100,
template: [
{
name: '{{portname}}.startMode',
type: {
name: 'enum',
enums: [
{
value: 'explicit',
label: 'Explicit'
},
{
value: 'implicit',
label: 'Implicit'
}
],
allowEditOnly: true
},
plug: 'input',
group: '{{portname}} Animation',
displayName: 'Start Mode',
default: 'implicit',
index: 1000
},
{
name: '{{portname}}.endValue',
type: 'number',
plug: 'input',
group: '{{portname}} Animation',
displayName: 'End Value',
editorName: 'End Value | {{portname}} ',
default: 0,
index: 1002
}
]
},
{
name: 'expand/basic',
condition: "'{{portname}}.startMode' = explicit",
indexStep: 100,
template: [
{
name: '{{portname}}.startValue',
plug: 'input',
type: 'number',
displayName: 'Start Value',
editorName: 'Start Value | {{portname}}',
group: '{{portname}} Animation',
default: 0,
index: 1001
}
]
}
],
panels: [
{
name: 'PortEditor',
title: 'Animations',
plug: 'output',
type: { name: 'number' },
group: 'Animation Values'
}
],
prototypeExtensions: {
updateCubicBezierFunction: {
value: function () {
var points = this._internal.cubicBezierPoints;
var cubicBezierEase = BezierEasing(points);
this._internal.cubicBezierFunction = function (start, end, t) {
return EaseCurves.linear(start, end, cubicBezierEase.get(t));
};
this._internal.ease = this._internal.cubicBezierFunction;
}
},
_registerAnimationGroup: {
value: function (name) {
var subAnimation = new SubAnimation({
node: this,
ease: this._internal.ease,
name: name
});
this._internal.animations.push(subAnimation);
var inputs = {};
inputs[name + '.' + 'startMode'] = {
set: function (value) {
subAnimation.startMode = value;
}
};
inputs[name + '.' + 'startValue'] = {
default: 0,
set: function (value) {
subAnimation.startValue = value;
}
};
inputs[name + '.' + 'endValue'] = {
default: 0,
set: function (value) {
subAnimation.endValue = value;
}
};
this.registerInputs(inputs);
this.registerOutput(name, {
getter: function () {
return subAnimation.currentValue;
}
});
}
},
registerInputIfNeeded: {
value: function (name) {
if (this.hasInput(name)) {
return;
}
var dotIndex = name.indexOf('.'),
animationName = name.substr(0, dotIndex);
if (this.hasOutput(animationName)) {
return;
}
this._registerAnimationGroup(animationName);
}
},
registerOutputIfNeeded: {
value: function (name) {
if (this.hasOutput(name)) {
return;
}
this._registerAnimationGroup(name);
}
}
}
};
module.exports = {
node: AnimationNode
};

View File

@@ -0,0 +1,256 @@
'use strict';
const { Node } = require('@noodl/runtime');
const Model = require('@noodl/runtime/src/model');
const ComponentState = {
name: 'Component State',
displayNodeName: 'Component Object',
category: 'Component Utilities',
color: 'component',
docs: 'https://docs.noodl.net/nodes/component-utilities/component-object',
deprecated: true,
initialize: function () {
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');
};
const model = Model.get('componentState' + this.nodeScope.componentOwner.getInstanceId());
this._internal.model = model;
model.on('change', this._internal.onModelChangedCallback);
// if(this.isInputConnected('fetch') === false)
// this.fetch();
},
getInspectInfo() {
const data = this._internal.model.data;
return 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) {}
},
store: {
displayName: 'Set',
group: 'Actions',
valueChangedToTrue() {
this.scheduleStore();
}
},
fetch: {
displayName: 'Fetch',
group: 'Actions',
valueChangedToTrue() {
this.scheduleFetch();
}
}
},
outputs: {
changed: {
type: 'signal',
displayName: 'Changed',
group: 'Events'
},
fetched: {
type: 'signal',
displayName: 'Fetched',
group: 'Events'
},
stored: {
type: 'signal',
displayName: 'Stored',
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.inputValues) {
internal.model.set(i, internal.inputValues[i], { resolve: true });
}
this.sendSignalOnOutput('stored');
});
},
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;
}
const split = name.split('-');
const propertyName = split[split.length - 1];
if (name.startsWith('value-')) {
this.registerInput(name, {
set(value) {
this._internal.inputValues[propertyName] = value;
if (this.isInputConnected('store') === false)
// Lazy set
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: ComponentState,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
graphModel.on('nodeAdded.Component State', (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);
}
});
});
}
};

View File

@@ -0,0 +1,332 @@
'use strict';
const { Node } = require('@noodl/runtime');
var Model = require('@noodl/runtime/src/model'),
Collection = require('@noodl/runtime/src/collection');
var CollectionNode = {
name: 'Collection',
docs: 'https://docs.noodl.net/nodes/data/array',
displayNodeName: 'Array',
shortDesc: 'A collection of models, mainly used together with a For Each Node.',
category: 'Data',
usePortAsLabel: 'collectionId',
color: 'data',
deprecated: true, // Use new array node
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() {
if (this._internal.collection) {
return 'Count: ' + this._internal.collection.size();
}
},
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);
});
}
}
},
modifyId: {
type: { name: 'string', allowConnectionsOnly: true },
displayName: 'Item Id',
group: 'Modify',
set: function (value) {
this._internal.modifyId = value;
}
},
/* modifyModel: {
type: {name:'object',
allowConnectionsOnly:true},
displayName:'Item',
group:'Modify',
set:function(value) {
if(!(value instanceof Model)) return;
this._internal.modifyId = value.getId();
},
}, */
store: {
displayName: 'Set',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleStore();
}
},
add: {
displayName: 'Add',
group: 'Modify',
valueChangedToTrue: function () {
var _this = this;
var internal = this._internal;
this.scheduleAfterInputsHaveUpdated(function () {
if (internal.modifyId === undefined) return;
if (internal.collection === undefined && this.isInputConnected('fetch') === false)
_this.setCollection(Collection.get()); // Create a new empty collection if we don't have one yet
if (internal.collection === undefined) return;
var model = Model.get(internal.modifyId);
internal.collection.add(model);
_this.sendSignalOnOutput('modified');
});
}
},
remove: {
displayName: 'Remove',
group: 'Modify',
valueChangedToTrue: function () {
var _this = this;
var internal = this._internal;
this.scheduleAfterInputsHaveUpdated(function () {
if (internal.modifyId === undefined) return;
if (internal.collection === undefined && this.isInputConnected('fetch') === false)
_this.setCollection(Collection.get()); // Create a new empty collection if we don't have one yet
if (internal.collection === undefined) return;
var model = Model.get(internal.modifyId);
internal.collection.remove(model);
_this.sendSignalOnOutput('modified');
});
}
},
clear: {
displayName: 'Clear',
group: 'Modify',
valueChangedToTrue: function () {
var _this = this;
var internal = this._internal;
this.scheduleAfterInputsHaveUpdated(function () {
if (internal.collection === undefined && this.isInputConnected('fetch') === false)
_this.setCollection(Collection.get()); // Create a new empty collection if we don't have one yet
if (internal.collection === undefined) return;
internal.collection.set([]);
_this.sendSignalOnOutput('modified');
_this.sendSignalOnOutput('count');
});
}
},
fetch: {
displayName: 'Fetch',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleSetCollection();
}
},
new: {
displayName: 'New',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleNew();
}
}
},
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;
}
},
modified: {
group: 'Events',
type: 'signal',
displayName: 'Modified'
},
changed: {
group: 'Events',
type: 'signal',
displayName: 'Changed'
},
stored: {
group: 'Events',
type: 'signal',
displayName: 'Stored'
},
fetched: {
group: 'Events',
type: 'signal',
displayName: 'Fetched'
},
created: {
group: 'Events',
type: 'signal',
displayName: 'Created'
}
},
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);
_this.sendSignalOnOutput('stored');
});
},
_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();
});
},
scheduleNew: function () {
var _this = this;
if (this.hasScheduledNew) return;
this.hasScheduledNew = true;
var internal = this._internal;
this.scheduleAfterInputsHaveUpdated(function () {
_this.hasScheduledNew = false;
_this.setCollection(Collection.get());
// If we have a source collection, copy items
if (internal.sourceCollection) internal.collection.set(internal.sourceCollection);
_this.sendSignalOnOutput('created');
});
},
_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,717 @@
'use strict';
const { Node, EdgeTriggeredInput } = require('@noodl/runtime');
const Model = require('@noodl/runtime/src/model'),
Collection = require('@noodl/runtime/src/collection'),
CloudStore = require('@noodl/runtime/src/api/cloudstore'),
JavascriptNodeParser = require('@noodl/runtime/src/javascriptnodeparser');
function _convertFilterOp(filter, options) {
const keys = Object.keys(filter);
if (keys.length === 0) return {};
if (keys.length !== 1) return options.error('Filter must only have one key found ' + keys.join(','));
const res = {};
const key = keys[0];
if (filter['and'] !== undefined && Array.isArray(filter['and'])) {
res['$and'] = filter['and'].map((f) => _convertFilterOp(f, options));
} else if (filter['or'] !== undefined && Array.isArray(filter['or'])) {
res['$or'] = filter['or'].map((f) => _convertFilterOp(f, options));
} else if (filter['idEqualTo'] !== undefined) {
res['objectId'] = { $eq: filter['idEqualTo'] };
} else if (filter['idContainedIn'] !== undefined) {
res['objectId'] = { $in: filter['idContainedIn'] };
} else if (filter['relatedTo'] !== undefined) {
var modelId = filter['relatedTo']['id'];
if (modelId === undefined) return options.error('Must provide id in relatedTo filter');
var relationKey = filter['relatedTo']['key'];
if (relationKey === undefined) return options.error('Must provide key in relatedTo filter');
var m = Model.get(modelId);
res['$relatedTo'] = {
object: {
__type: 'Pointer',
objectId: modelId,
className: m._class
},
key: relationKey
};
} else if (typeof filter[key] === 'object') {
const opAndValue = filter[key];
if (opAndValue['equalTo'] !== undefined) res[key] = { $eq: opAndValue['equalTo'] };
else if (opAndValue['notEqualTo'] !== undefined) res[key] = { $ne: opAndValue['notEqualTo'] };
else if (opAndValue['lessThan'] !== undefined) res[key] = { $lt: opAndValue['lessThan'] };
else if (opAndValue['greaterThan'] !== undefined) res[key] = { $gt: opAndValue['greaterThan'] };
else if (opAndValue['lessThanOrEqualTo'] !== undefined) res[key] = { $lte: opAndValue['lessThanOrEqualTo'] };
else if (opAndValue['greaterThanOrEqualTo'] !== undefined) res[key] = { $gte: opAndValue['greaterThanOrEqualTo'] };
else if (opAndValue['exists'] !== undefined) res[key] = { $exists: opAndValue['exists'] };
else if (opAndValue['containedIn'] !== undefined) res[key] = { $in: opAndValue['containedIn'] };
else if (opAndValue['notContainedIn'] !== undefined) res[key] = { $nin: opAndValue['notContainedIn'] };
else if (opAndValue['pointsTo'] !== undefined) {
var m = Model.get(opAndValue['pointsTo']);
if (CloudStore._collections[options.collectionName])
var schema = CloudStore._collections[options.collectionName].schema;
var targetClass =
schema && schema.properties && schema.properties[key] ? schema.properties[key].targetClass : undefined;
var type = schema && schema.properties && schema.properties[key] ? schema.properties[key].type : undefined;
if (type === 'Relation') {
res[key] = {
__type: 'Pointer',
objectId: opAndValue['pointsTo'],
className: targetClass
};
} else {
if (Array.isArray(opAndValue['pointsTo']))
res[key] = {
$in: opAndValue['pointsTo'].map((v) => {
return { __type: 'Pointer', objectId: v, className: targetClass };
})
};
else
res[key] = {
$eq: {
__type: 'Pointer',
objectId: opAndValue['pointsTo'],
className: targetClass
}
};
}
} else if (opAndValue['matchesRegex'] !== undefined) {
res[key] = {
$regex: opAndValue['matchesRegex'],
$options: opAndValue['options']
};
} else if (opAndValue['text'] !== undefined && opAndValue['text']['search'] !== undefined) {
var _v = opAndValue['text']['search'];
if (typeof _v === 'string') res[key] = { $text: { $search: { $term: _v, $caseSensitive: false } } };
else
res[key] = {
$text: {
$search: {
$term: _v.term,
$language: _v.language,
$caseSensitive: _v.caseSensitive,
$diacriticSensitive: _v.diacriticSensitive
}
}
};
}
} else {
options.error('Unrecognized filter keys ' + keys.join(','));
}
return res;
}
var DbCollectionNode = {
name: 'DbCollection',
docs: 'https://docs.noodl.net/nodes/cloud-services/collection',
displayNodeName: 'Query Collection',
shortDesc: 'A database collection.',
category: 'Cloud Services',
usePortAsLabel: 'collectionName',
color: 'data',
deprecated: true, // Use Query Records
initialize: function () {
var _this = this;
var collectionChangedScheduled = false;
this._internal.collectionChangedCallback = function () {
//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('modified');
_this.flagOutputDirty('count');
_this.flagOutputDirty('firstItemId');
// _this.flagOutputDirty('firstItem');
collectionChangedScheduled = false;
});
};
this._internal.storageSettings = {};
},
inputs: {},
outputs: {
id: {
type: 'string',
displayName: 'Name',
group: 'General',
getter: function () {
return this._internal.name;
}
},
items: {
type: 'array',
displayName: 'Result',
group: 'General',
getter: function () {
return this._internal.collection;
}
},
firstItemId: {
type: 'string',
displayName: 'First Item Id',
group: 'General',
getter: function () {
if (this._internal.collection) {
var firstItem = this._internal.collection.get(0);
if (firstItem !== undefined) return firstItem.getId();
}
}
},
/* firstItem: {
type: 'object',
displayName: 'First Item',
group: 'General',
getter: function () {
if (this._internal.collection) {
return this._internal.collection.get(0);
}
}
}, */
count: {
type: 'number',
displayName: 'Count',
group: 'General',
getter: function () {
return this._internal.collection ? this._internal.collection.size() : 0;
}
},
modified: {
group: 'Events',
type: 'signal',
displayName: 'Modified'
},
fetched: {
group: 'Events',
type: 'signal',
displayName: 'Fetched'
},
failure: {
group: 'Events',
type: 'signal',
displayName: 'Failure'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Events',
getter: function () {
return this._internal.error;
}
}
},
prototypeExtensions: {
setCollectionName: function (name) {
this._internal.name = name;
// this.invalidateCollection();
this.flagOutputDirty('id');
},
setCollection: function (collection) {
this.bindCollection(collection);
this.flagOutputDirty('firstItemId');
// this.flagOutputDirty('firstItem');
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();
},
setError: function (err) {
this._internal.err = err;
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
},
fetch: function () {
var internal = this._internal;
if (this.context.editorConnection) {
if (this._internal.name === undefined) {
this.context.editorConnection.sendWarning(this.nodeScope.componentOwner.name, this.id, 'query-collection', {
message: 'No collection specified for query'
});
} else {
this.context.editorConnection.clearWarning(this.nodeScope.componentOwner.name, this.id, 'query-collection');
}
}
if (internal.fetchScheduled) return;
internal.fetchScheduled = true;
this.scheduleAfterInputsHaveUpdated(() => {
internal.fetchScheduled = false;
const _c = Collection.get();
const f = this.getStorageFilter();
CloudStore.instance.query({
collection: this._internal.name,
where: f.where,
sort: f.sort,
limit: this.getStorageLimit(),
skip: this.getStorageSkip(),
success: (results) => {
if (results !== undefined) {
_c.set(
results.map((i) => {
var m = CloudStore._fromJSON(i, this._internal.name);
// Remove from collection if model is deleted
m.on('delete', () => _c.remove(m));
return m;
})
);
}
this.setCollection(_c);
this.sendSignalOnOutput('fetched');
},
error: (err) => {
this.setCollection(_c);
this.setError(err || 'Failed to fetch.');
}
});
});
},
getStorageFilter: function () {
const storageSettings = this._internal.storageSettings;
if (storageSettings['storageFilterType'] === undefined || storageSettings['storageFilterType'] === 'simple') {
// Create simple filter
if (storageSettings['storageFilter']) {
const filters = storageSettings['storageFilter'].split(',');
var _filters = [];
filters.forEach(function (f) {
const _filter = {};
var op = '$' + (storageSettings['storageFilterOp-' + f] || 'eq');
_filter[f] = {};
_filter[f][op] = storageSettings['storageFilterValue-' + f];
_filters.push(_filter);
});
var _where = _filters.length > 1 ? { $and: _filters } : _filters[0];
}
if (storageSettings['storageSort']) {
const sort = storageSettings['storageSort'].split(',');
var _sort = [];
sort.forEach(function (s) {
_sort.push((storageSettings['storageSort-' + s] === 'descending' ? '-' : '') + s);
});
}
return {
where: _where,
sort: _sort
};
} else if (storageSettings['storageFilterType'] === 'json') {
// JSON filter
if (!this._internal.filterFunc) {
try {
var filterCode = storageSettings['storageJSONFilter'];
// Parse out variables
filterCode = filterCode.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, ''); // Remove comments
this._internal.filterVariables = filterCode.match(/\$[A-Za-z0-9]+/g) || [];
var args = ['filter', 'where', 'sort', 'Inputs']
.concat(this._internal.filterVariables)
.concat([filterCode]);
this._internal.filterFunc = Function.apply(null, args);
} catch (e) {
this._internal.filterFunc = undefined;
console.log('Error while parsing filter script: ' + e);
}
}
if (!this._internal.filterFunc) return;
var _filter = {},
_sort = [],
_this = this;
// Collect filter variables
var _filterCb = function (f) {
_filter = _convertFilterOp(f, {
collectionName: _this._internal.name,
error: function (err) {
_this.context.editorConnection.sendWarning(
_this.nodeScope.componentOwner.name,
_this.id,
'query-collection-filter',
{
message: err
}
);
}
});
};
var _sortCb = function (s) {
_sort = s;
};
// Extract inputs
const inputs = {};
for (let key in storageSettings) {
if (key.startsWith('storageFilterValue-'))
inputs[key.substring('storageFilterValue-'.length)] = storageSettings[key];
}
var filterFuncArgs = [_filterCb, _filterCb, _sortCb, inputs]; // One for filter, one for where
this._internal.filterVariables.forEach((v) => {
filterFuncArgs.push(storageSettings['storageFilterValue-' + v.substring(1)]);
});
// Run the code to get the filter
try {
this._internal.filterFunc.apply(this, filterFuncArgs);
} catch (e) {
console.log('Error while running filter script: ' + e);
}
return { where: _filter, sort: _sort };
}
},
getStorageLimit: function () {
const storageSettings = this._internal.storageSettings;
if (!storageSettings['storageEnableLimit']) return;
else return storageSettings['storageLimit'] || 10;
},
getStorageSkip: function () {
const storageSettings = this._internal.storageSettings;
if (!storageSettings['storageEnableLimit']) return;
else return storageSettings['storageSkip'] || 0;
},
registerOutputIfNeeded: function (name) {
if (this.hasOutput(name)) {
return;
}
this.registerOutput(name, {
getter: userOutputGetter.bind(this, name)
});
},
registerInputIfNeeded: function (name) {
var _this = this;
if (this.hasInput(name)) {
return;
}
const dynamicSignals = {
storageFetch: this.fetch.bind(this)
};
if (dynamicSignals[name])
return this.registerInput(name, {
set: EdgeTriggeredInput.createSetter({
valueChangedToTrue: dynamicSignals[name]
})
});
const dynamicSetters = {
collectionName: this.setCollectionName.bind(this)
};
if (dynamicSetters[name])
return this.registerInput(name, {
set: dynamicSetters[name]
});
this.registerInput(name, {
set: userInputSetter.bind(this, name)
});
}
}
};
function userOutputGetter(name) {
/* jshint validthis:true */
return this._internal.storageSettings[name];
}
function userInputSetter(name, value) {
/* jshint validthis:true */
this._internal.storageSettings[name] = value;
}
const _defaultJSONQuery =
'// Write your query script here, check out the reference documentation for examples\n' + 'where({ })\n';
function updatePorts(nodeId, parameters, editorConnection, dbCollections) {
var ports = [];
ports.push({
name: 'collectionName',
type: {
name: 'enum',
enums:
dbCollections !== undefined
? dbCollections.map((c) => {
return { value: c.name, label: c.name };
})
: [],
allowEditOnly: true
},
displayName: 'Collecton Name',
plug: 'input',
group: 'General'
});
ports.push({
name: 'storageFilterType',
type: {
name: 'enum',
allowEditOnly: true,
enums: [
{ value: 'simple', label: 'Simple' },
{ value: 'json', label: 'Advanced' }
]
},
displayName: 'Filter',
default: 'simple',
plug: 'input',
group: 'General'
});
// Limit
ports.push({
type: 'boolean',
plug: 'input',
group: 'Limit',
name: 'storageEnableLimit',
displayName: 'Use limit'
});
if (parameters['storageEnableLimit']) {
ports.push({
type: 'number',
default: 10,
plug: 'input',
group: 'Limit',
name: 'storageLimit',
displayName: 'Limit'
});
ports.push({
type: 'number',
default: 0,
plug: 'input',
group: 'Limit',
name: 'storageSkip',
displayName: 'Skip'
});
}
ports.push({
type: 'signal',
plug: 'input',
group: 'Storage',
name: 'storageFetch',
displayName: 'Fetch'
});
// Simple query
if (parameters['storageFilterType'] === undefined || parameters['storageFilterType'] === 'simple') {
ports.push({
type: { name: 'stringlist', allowEditOnly: true },
plug: 'input',
group: 'Filter',
name: 'storageFilter',
displayName: 'Filter'
});
const filterOps = {
string: [
{ value: 'eq', label: 'Equals' },
{ value: 'ne', label: 'Not Equals' }
],
boolean: [
{ value: 'eq', label: 'Equals' },
{ value: 'ne', label: 'Not Equals' }
],
number: [
{ value: 'eq', label: 'Equals' },
{ value: 'ne', 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['storageFilter']) {
var filters = parameters['storageFilter'].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: 'storageFilterType-' + f
});
var type = parameters['storageFilterType-' + 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: 'storageFilterOp-' + f
});
ports.push({
type: type || 'string',
plug: 'input',
group: f + ' filter',
displayName: 'Value',
editorName: f + ' Filter Value',
name: 'storageFilterValue-' + f
});
});
}
ports.push({
type: { name: 'stringlist', allowEditOnly: true },
plug: 'input',
group: 'Sort',
name: 'storageSort',
displayName: 'Sort'
});
// Sorting inputs
if (parameters['storageSort']) {
var filters = parameters['storageSort'].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: 'storageSort-' + f
});
});
}
}
// JSON query
else if (parameters['storageFilterType'] === 'json') {
ports.push({
type: { name: 'string', allowEditOnly: true, codeeditor: 'javascript' },
plug: 'input',
group: 'Filter',
name: 'storageJSONFilter',
default: _defaultJSONQuery,
displayName: 'Filter'
});
var filter = parameters['storageJSONFilter'];
if (filter) {
filter = filter.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, ''); // Remove comments
var variables = filter.match(/\$[A-Za-z0-9]+/g);
if (variables) {
const unique = {};
variables.forEach((v) => {
unique[v] = true;
});
Object.keys(unique).forEach((p) => {
ports.push({
name: 'storageFilterValue-' + p.substring(1),
displayName: p.substring(1),
group: 'Filter Values',
plug: 'input',
type: { name: '*', allowConnectionsOnly: true }
});
});
}
// Support variables with the "Inputs."" syntax
JavascriptNodeParser.parseAndAddPortsFromScript(filter, ports, {
inputPrefix: 'storageFilterValue-',
inputGroup: 'Filter Values',
inputType: { name: '*', allowConnectionsOnly: true },
skipOutputs: true
});
}
}
editorConnection.sendDynamicPorts(nodeId, ports);
}
module.exports = {
node: DbCollectionNode,
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) {
if (event.name.startsWith('storage')) {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel.getMetaData('dbCollections'));
}
});
graphModel.on('metadataChanged.dbCollections', function (data) {
CloudStore.invalidateCollections();
updatePorts(node.id, node.parameters, context.editorConnection, data);
});
graphModel.on('metadataChanged.cloudservices', function (data) {
CloudStore.instance._initCloudServices();
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.DbCollection', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('DbCollection')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -0,0 +1,797 @@
'use strict';
const { Node, EdgeTriggeredInput } = require('@noodl/runtime');
const isEqual = require('lodash.isequal');
var Model = require('@noodl/runtime/src/model');
const CloudStore = require('@noodl/runtime/src/api/cloudstore');
var modelPortsHash = {},
previousProperties = {};
var ModelNodeDefinition = {
name: 'DbModel',
docs: 'https://docs.noodl.net/nodes/cloud-services/model',
displayNodeName: 'Model',
shortDesc: 'Database model',
category: 'Cloud Services',
usePortAsLabel: '$ndlCollectionName',
color: 'data',
deprecated: true, // Use record node
initialize: function () {
var internal = this._internal;
internal.inputValues = {};
internal.relationModelIds = {};
var _this = this;
this._internal.onModelChangedCallback = function (args) {
if (_this.isInputConnected('fetch')) return;
if (_this.hasOutput(args.name)) _this.flagOutputDirty(args.name);
if (_this.hasOutput('changed-' + args.name)) _this.sendSignalOnOutput('changed-' + args.name);
_this.sendSignalOnOutput('changed');
};
},
getInspectInfo() {
const model = this._internal.model;
if (!model) return '[No Model]';
return [
{ type: 'text', value: 'Id: ' + model.getId() },
{ type: 'value', value: model.data }
];
},
outputs: {
id: {
type: 'string',
displayName: 'Id',
group: 'General',
getter: function () {
return this._internal.model ? this._internal.model.getId() : this._internal.modelId;
}
},
saved: {
type: 'signal',
displayName: 'Saved',
group: 'Events'
},
stored: {
type: 'signal',
displayName: 'Stored',
group: 'Events'
},
created: {
type: 'signal',
displayName: 'Created',
group: 'Events'
},
fetched: {
type: 'signal',
displayName: 'Fetched',
group: 'Events'
},
changed: {
type: 'signal',
displayName: 'Changed',
group: 'Events'
},
deleted: {
type: 'signal',
displayName: 'Deleted',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
},
error: {
type: 'string',
displayName: 'Error',
group: 'Events',
getter: function () {
return this._internal.error;
}
}
},
inputs: {
modelId: {
type: { name: 'string', allowConnectionsOnly: true },
displayName: 'Id',
group: 'General',
set: function (value) {
if (value instanceof Model) value = value.getId(); // Can be passed as model as well
this._internal.modelId = value; // Wait to fetch data
if (this.isInputConnected('fetch') === false) this.setModelID(value);
else {
this.flagOutputDirty('id');
}
}
},
properties: {
type: { name: 'stringlist', allowEditOnly: true },
displayName: 'Properties',
group: 'Properties',
set: function (value) {}
},
fetch: {
displayName: 'Fetch',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleFetch();
}
},
store: {
displayName: 'Set',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleStore();
}
},
save: {
displayName: 'Save',
group: 'Actions',
valueChangedToTrue: function () {
this.storageSave();
}
},
delete: {
displayName: 'Delete',
group: 'Actions',
valueChangedToTrue: function () {
this.storageDelete();
}
},
new: {
displayName: 'New',
group: 'Actions',
valueChangedToTrue: function () {
this.storageNew();
}
},
insert: {
displayName: 'Insert',
group: 'Actions',
valueChangedToTrue: function () {
this.storageInsert();
// this.storageSave();
}
}
},
methods: {
setCollectionID: function (id) {
this._internal.collectionId = id;
this.clearWarnings();
},
setModelID: function (id) {
var model = Model.get(id);
// this._internal.modelIsNew = false;
this.setModel(model);
},
setModel: function (model) {
if (this._internal.model)
// Remove old listener if existing
this._internal.model.off('change', this._internal.onModelChangedCallback);
this._internal.model = model;
this.flagOutputDirty('id');
model.on('change', this._internal.onModelChangedCallback);
// We have a new model, mark all outputs as dirty
for (var key in model.data) {
if (this.hasOutput(key)) this.flagOutputDirty(key);
}
this.sendSignalOnOutput('fetched');
},
_onNodeDeleted: function () {
Node.prototype._onNodeDeleted.call(this);
if (this._internal.model) this._internal.model.off('change', this._internal.onModelChangedCallback);
},
scheduleOnce: function (type, cb) {
const _this = this;
const _type = 'hasScheduled' + type;
if (this._internal[_type]) return;
this._internal[_type] = true;
this.scheduleAfterInputsHaveUpdated(function () {
_this._internal[_type] = false;
cb();
});
},
_hasChangesPending: function () {
const internal = this._internal;
var model = internal.model;
for (var key in internal.inputValues) {
if (isEqual(model.data[key], internal.inputValues[key])) return true;
}
return false;
},
scheduleFetch: function () {
var _this = this;
const internal = this._internal;
if (!this.checkWarningsBeforeCloudOp()) return;
this.scheduleOnce('Fetch', function () {
if (internal.modelId === undefined || internal.modelId === '') return; // Don't do fetch if no id
CloudStore.instance.fetch({
collection: internal.collectionId,
objectId: internal.modelId, // Get the objectId part of the model id
success: function (response) {
var model = CloudStore._fromJSON(response, internal.collectionId);
if (internal.model !== model) {
// Check if we need to change model
if (internal.model)
// Remove old listener if existing
internal.model.off('change', internal.onModelChangedCallback);
internal.model = model;
model.on('change', internal.onModelChangedCallback);
}
_this.flagOutputDirty('id');
delete response.objectId;
for (var key in response) {
// model.set(key,response[key]);
if (_this.hasOutput(key)) _this.flagOutputDirty(key);
}
_this.sendSignalOnOutput('fetched');
},
error: function (err) {
_this.setError(err || 'Failed to fetch.');
}
});
});
},
scheduleStore: function () {
var _this = this;
var internal = this._internal;
if (!internal.model) return;
if (!this.checkWarningsBeforeCloudOp()) return;
this.scheduleOnce('Store', function () {
for (var i in internal.inputValues) {
internal.model.set(i, internal.inputValues[i], { resolve: true });
}
_this.sendSignalOnOutput('stored');
});
},
storageSave: function () {
const _this = this;
const internal = this._internal;
if (!this.checkWarningsBeforeCloudOp()) return;
//console.log('dbmodel save scheduled')
this.scheduleOnce('StorageSave', function () {
if (!internal.model) return;
var model = internal.model;
//console.log('dbmodel save hasChanges='+_this._hasChangesPending())
//if(!_this._internal.modelIsNew && !_this._hasChangesPending()) return; // No need to save, no changes pending
for (var i in internal.inputValues) {
model.set(i, internal.inputValues[i], { resolve: true });
}
CloudStore.instance.save({
collection: internal.collectionId,
objectId: model.getId(), // Get the objectId part of the model id
data: model.data,
success: function (response) {
for (var key in response) {
model.set(key, response[key]);
}
// _this._internal.modelIsNew = false; // If the model was a new model, it is now saved
_this.sendSignalOnOutput('saved');
},
error: function (err) {
_this.setError(err || 'Failed to save.');
}
});
});
},
storageDelete: function () {
const _this = this;
if (!this._internal.model) return;
const internal = this._internal;
if (!this.checkWarningsBeforeCloudOp()) return;
this.scheduleOnce('StorageDelete', function () {
CloudStore.instance.delete({
collection: internal.collectionId,
objectId: internal.model.getId(), // Get the objectId part of the model id,
success: function () {
internal.model.notify('delete'); // Notify that this model has been deleted
_this.sendSignalOnOutput('deleted');
},
error: function (err) {
_this.setError(err || 'Failed to delete.');
}
});
});
},
storageInsert: function () {
const _this = this;
const internal = this._internal;
if (!this.checkWarningsBeforeCloudOp()) return;
this.scheduleOnce('StorageInsert', function () {
var _data = _this._getModelInitData();
CloudStore.instance.create({
collection: internal.collectionId,
data: _data,
success: function (data) {
// Successfully created
const m = CloudStore._fromJSON(data, internal.collectionId);
_this.setModel(m);
_this.sendSignalOnOutput('created');
_this.sendSignalOnOutput('saved');
},
error: function (err) {
_this.setError(err || 'Failed to insert.');
}
});
});
},
checkWarningsBeforeCloudOp() {
//clear all errors first
this.clearWarnings();
if (!this._internal.collectionId) {
this.setError('No collection name specified');
return false;
}
return true;
},
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, 'storage-op-warning', {
message: err,
showGlobally: true
});
}
},
clearWarnings() {
if (this.context.editorConnection) {
this.context.editorConnection.clearWarning(this.nodeScope.componentOwner.name, this.id, 'storage-op-warning');
}
},
onRelationAdd: function (key) {
const _this = this;
const internal = this._internal;
this.scheduleOnce('StorageAddRelation', function () {
if (!internal.model) return;
var model = internal.model;
var targetModelId = internal.relationModelIds[key];
if (targetModelId === undefined) return;
CloudStore.instance.addRelation({
collection: internal.collectionId,
objectId: model.getId(),
key: key,
targetObjectId: targetModelId,
targetClass: Model.get(targetModelId)._class,
success: function (response) {
for (var _key in response) {
model.set(_key, response[_key]);
}
// Successfully added relation
_this.sendSignalOnOutput('$relation-added-' + key);
},
error: function (err) {
_this.setError(err || 'Failed to add relation.');
}
});
});
},
onRelationRemove: function (key) {
const _this = this;
const internal = this._internal;
this.scheduleOnce('StorageRemoveRelation', function () {
if (!internal.model) return;
var model = internal.model;
var targetModelId = internal.relationModelIds[key];
if (targetModelId === undefined) return;
CloudStore.instance.removeRelation({
collection: internal.collectionId,
objectId: model.getId(),
key: key,
targetObjectId: targetModelId,
targetClass: Model.get(targetModelId)._class,
success: function (response) {
for (var _key in response) {
model.set(_key, response[_key]);
}
// Successfully removed relation
_this.sendSignalOnOutput('$relation-removed-' + key);
},
error: function (err) {
_this.setError(err || 'Failed to remove relation.');
}
});
});
},
setRelationModelId: function (key, modelId) {
this._internal.relationModelIds[key] = modelId;
},
registerOutputIfNeeded: function (name) {
if (this.hasOutput(name)) {
return;
}
if (name.startsWith('$relation-added-'))
return this.registerOutput(name, {
getter: function () {
/** No needed for signals */
}
});
if (name.startsWith('$relation-removed-'))
return this.registerOutput(name, {
getter: function () {
/** No needed for signals */
}
});
this.registerOutput(name, {
getter: userOutputGetter.bind(this, name)
});
},
_getModelInitData: function () {
var internal = this._internal;
var _data = {};
// First copy values from inputs
for (var i in internal.inputValues) {
_data[i] = internal.inputValues[i];
}
// Then run initialize code
if (this._internal.modelInitCode) {
try {
var initCode = new Function('initialize', this._internal.modelInitCode);
initCode(function (data) {
for (var key in data) {
if (typeof data[key] === 'function') _data[key] = data[key]();
else _data[key] = data[key];
}
});
} catch (e) {
console.log('Error while initializing model: ' + e);
}
}
return _data;
},
setModelInitCode: function (code) {
this._internal.modelInitCode = code;
},
registerInputIfNeeded: function (name) {
var _this = this;
if (this.hasInput(name)) {
return;
}
// Relation inputs
if (name.startsWith('$relation-add-'))
return this.registerInput(name, {
set: EdgeTriggeredInput.createSetter({
valueChangedToTrue: this.onRelationAdd.bind(this, name.substring('$relation-add-'.length))
})
});
if (name.startsWith('$relation-remove-'))
return this.registerInput(name, {
set: EdgeTriggeredInput.createSetter({
valueChangedToTrue: this.onRelationRemove.bind(this, name.substring('$relation-remove-'.length))
})
});
if (name.startsWith('$relation-modelid-'))
return this.registerInput(name, {
set: this.setRelationModelId.bind(this, name.substring('$relation-modelid-'.length))
});
const dynamicSignals = {};
if (dynamicSignals[name])
return this.registerInput(name, {
set: EdgeTriggeredInput.createSetter({
valueChangedToTrue: dynamicSignals[name]
})
});
const dynamicSetters = {
$ndlCollectionName: this.setCollectionID.bind(this),
$ndlModelInitCode: this.setModelInitCode.bind(this)
// '$ndlModelValidationCode':this.setModelValidationCode.bind(this),
};
if (dynamicSetters[name])
return this.registerInput(name, {
set: dynamicSetters[name]
});
this.registerInput(name, {
set: userInputSetter.bind(this, name)
});
}
}
};
function userOutputGetter(name) {
/* jshint validthis:true */
return this._internal.model ? this._internal.model.get(name, { resolve: true }) : undefined;
}
function userInputSetter(name, value) {
//console.log('dbmodel setter:',name,value)
/* jshint validthis:true */
this._internal.inputValues[name] = value;
}
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;
}
const defaultStorageInitCode =
'initialize({\n' +
'\t// Here you can initialize new models\n' +
"\t//myProperty:'Some init value',\n" +
"\t//anotherProperty:function() { return 'Some other value' }\n" +
'})\n';
/*const defaultStorageValidateCode = "validation({\n" +
"\t// Here you add validation specifications for your model properties.\n" +
"\t//myProperty: { required:true, length:4 },\n" +
"\t//anotherProperty: function(value) {\n" +
"\t//\tif(value !== 'someValue) return 'Error message'\n" +
"\t//}\n" +
"})\n";*/
function updatePorts(nodeId, parameters, editorConnection, dbCollections) {
var ports = [];
// Add value outputs
var properties = parameters.properties;
if (properties) {
properties = properties ? properties.split(',') : undefined;
for (var i in properties) {
var p = properties[i];
ports.push({
type: {
name: '*',
allowConnectionsOnly: true
},
plug: 'input/output',
group: 'Properties',
name: p
});
ports.push({
type: 'signal',
plug: 'output',
group: 'Events',
displayName: 'Changed ' + p,
name: 'changed-' + p
});
}
var propertyRenamed = detectRename(previousProperties[nodeId], properties);
previousProperties[nodeId] = properties;
if (propertyRenamed) {
var renamed = {
plug: 'input/output',
patterns: ['{{*}}'],
before: propertyRenamed.before,
after: propertyRenamed.after
};
}
}
ports.push({
name: '$ndlCollectionName',
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.$ndlCollectionName && dbCollections) {
// Fetch ports from collection keys
var c = dbCollections.find((c) => c.name === parameters.$ndlCollectionName);
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') {
// Ports for adding / removing relation
ports.push({
type: 'signal',
plug: 'input',
group: key + ' Relation',
name: '$relation-add-' + key,
displayName: 'Add',
editorName: key + ' | Add'
});
ports.push({
type: 'signal',
plug: 'input',
group: key + ' Relation',
name: '$relation-remove-' + key,
displayName: 'Remove',
editorName: key + ' | Remove'
});
ports.push({
type: { name: 'string', allowConnectionsOnly: true },
plug: 'input',
group: key + ' Relation',
name: '$relation-modelid-' + key,
displayName: 'Model Id',
editorName: key + ' | Model Id'
});
ports.push({
type: 'signal',
plug: 'output',
group: key + ' Relation',
name: '$relation-removed-' + key,
displayName: 'Removed',
editorName: key + ' | Removed'
});
ports.push({
type: 'signal',
plug: 'output',
group: key + ' Relation',
name: '$relation-added-' + key,
displayName: 'Added',
editorName: key + ' | Added'
});
} else {
// Other schema type ports
ports.push({
type: {
name: '*',
allowConnectionsOnly: true
},
plug: 'input/output',
group: 'Properties',
name: key
});
ports.push({
type: 'signal',
plug: 'output',
group: 'Events',
displayName: 'Changed ' + key,
name: 'changed-' + key
});
}
}
}
}
// Storage ports
ports.push({
name: '$ndlModelInitCode',
displayName: 'Initialize',
group: 'Scripts',
type: {
name: 'string',
allowEditOnly: true,
codeeditor: 'javascript'
},
default: defaultStorageInitCode,
plug: 'input'
});
/* ports.push({
name:'$ndlModelValidationCode',
displayName: "Validate",
group: "Storage scripts",
"type": {
name: "string",
allowEditOnly: true,
codeeditor: "javascript"
},
default: defaultStorageValidateCode,
plug:'input'
}) */
var hash = JSON.stringify(ports);
if (modelPortsHash[nodeId] !== hash) {
// Make sure we don't resend the same port data
modelPortsHash[nodeId] = hash;
editorConnection.sendDynamicPorts(nodeId, ports, { renamed: renamed });
}
}
module.exports = {
node: ModelNodeDefinition,
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) {
CloudStore.invalidateCollections();
updatePorts(node.id, node.parameters, context.editorConnection, data);
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.DbModel', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('DbModel')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -0,0 +1,371 @@
'use strict';
const { Node } = require('@noodl/runtime');
var Model = require('@noodl/runtime/src/model');
//var previousProperties = {};
var ModelNodeDefinition = {
name: 'Model',
docs: 'https://docs.noodl.net/nodes/data/object',
displayNodeName: 'Object',
shortDesc:
'Stores any amount of properties and can be used standalone or together with Collections and For Each nodes.',
category: 'Data',
usePortAsLabel: 'modelId',
color: 'data',
deprecated: true, // Use new model node
initialize: function () {
var internal = this._internal;
internal.inputValues = {};
var _this = this;
this._internal.onModelChangedCallback = function (args) {
if (_this.isInputConnected('fetch') === true) return;
if (_this.hasOutput(args.name)) _this.flagOutputDirty(args.name);
if (_this.hasOutput('changed-' + args.name)) _this.sendSignalOnOutput('changed-' + args.name);
_this.sendSignalOnOutput('changed');
};
},
getInspectInfo() {
const model = this._internal.model;
if (!model) return '[No Object]';
const modelInfo = [{ type: 'text', value: 'Id: ' + model.getId() }];
const data = this._internal.model.data;
return modelInfo.concat(
Object.keys(data).map((key) => {
return { type: 'text', value: key + ': ' + data[key] };
})
);
},
outputs: {
id: {
type: 'string',
displayName: 'Id',
group: 'General',
getter: function () {
return this._internal.model ? this._internal.model.getId() : this._internal.modelId;
}
},
/* currentModel: {
type: 'object',
displayName: 'Object',
group: 'General',
getter: function () {
return this._internal.model;
}
},*/
stored: {
type: 'signal',
displayName: 'Stored',
group: 'Events'
},
changed: {
type: 'signal',
displayName: 'Changed',
group: 'Events'
},
fetched: {
type: 'signal',
displayName: 'Fetched',
group: 'Events'
},
created: {
type: 'signal',
displayName: 'Created',
group: 'Events'
}
},
inputs: {
modelId: {
type: {
name: 'string',
identifierOf: 'ModelName',
identifierDisplayName: 'Object Ids'
},
displayName: 'Id',
group: 'General',
set: function (value) {
if (value instanceof Model) value = value.getId(); // Can be passed as model as well
this._internal.modelId = value; // Wait to fetch data
if (this.isInputConnected('fetch') === false) this.setModelID(value);
else {
this.flagOutputDirty('id');
}
}
},
/* model:{
type:'object',
displayName:'Object',
group: 'General',
set: function (value) {
if(value === undefined) return;
if(value === this._internal.model) return;
if(!(value instanceof Model) && typeof value === 'object') {
// This is a regular JS object, convert to model
value = Model.create(value);
}
this._internal.modelId = value.getId();
if(this.isInputConnected('fetch') === false)
this.setModelID(this._internal.modelId);
else {
this.flagOutputDirty('id');
}
}
},*/
properties: {
type: { name: 'stringlist', allowEditOnly: true },
displayName: 'Properties',
group: 'Properties',
set: function (value) {}
},
new: {
displayName: 'New',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleNew();
}
},
store: {
displayName: 'Set',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleStore();
}
},
fetch: {
displayName: 'Fetch',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleSetModel();
}
},
clear: {
displayName: 'Clear',
group: 'Actions',
valueChangedToTrue: function () {
var internal = this._internal;
if (!internal.model) return;
for (var i in internal.inputValues) {
internal.model.set(i, undefined, { resolve: true });
}
}
}
},
prototypeExtensions: {
scheduleStore: function () {
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 });
}
this.sendSignalOnOutput('stored');
});
},
scheduleNew: function () {
if (this.hasScheduledNew) return;
this.hasScheduledNew = true;
var internal = this._internal;
this.scheduleAfterInputsHaveUpdated(() => {
this.hasScheduledNew = false;
const newModel = Model.get();
for (var i in internal.inputValues) {
newModel.set(i, internal.inputValues[i], { resolve: true });
}
this.setModel(newModel);
this.sendSignalOnOutput('created');
this.sendSignalOnOutput('stored');
});
},
scheduleSetModel: function () {
if (this.hasScheduledSetModel) return;
this.hasScheduledSetModel = true;
var internal = this._internal;
this.scheduleAfterInputsHaveUpdated(() => {
this.hasScheduledSetModel = false;
this.setModelID(this._internal.modelId);
});
},
setModelID: function (id) {
var model = Model.get(id);
this.setModel(model);
this.sendSignalOnOutput('fetched');
},
setModel: function (model) {
if (this._internal.model)
// Remove old listener if existing
this._internal.model.off('change', this._internal.onModelChangedCallback);
this._internal.model = model;
this.flagOutputDirty('id');
model.on('change', this._internal.onModelChangedCallback);
// We have a new model, mark all outputs as dirty
for (var key in model.data) {
if (this.hasOutput(key)) this.flagOutputDirty(key);
}
},
_onNodeDeleted: function () {
Node.prototype._onNodeDeleted.call(this);
if (this._internal.model) this._internal.model.off('change', this._internal.onModelChangedCallback);
},
registerOutputIfNeeded: function (name) {
if (this.hasOutput(name)) {
return;
}
this.registerOutput(name, {
getter: userOutputGetter.bind(this, name)
});
},
registerInputIfNeeded: function (name) {
var _this = this;
if (this.hasInput(name)) {
return;
}
this.registerInput(name, {
set: userInputSetter.bind(this, name)
});
}
}
};
function userOutputGetter(name) {
/* jshint validthis:true */
return this._internal.model ? this._internal.model.get(name, { resolve: true }) : undefined;
}
function userInputSetter(name, value) {
/* jshint validthis:true */
this._internal.inputValues[name] = value;
// Store on change if no connection to store or new
if (this.isInputConnected('store') === false && this.isInputConnected('new') === false) {
const model = this._internal.model;
const valueChanged = model ? model.get(name) !== value : true;
if (valueChanged) {
this.scheduleStore();
}
}
}
/*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;
}*/
/*const defaultStorageInitCode = "initialize({\n"+
"\t// Here you can initialize new models\n"+
"\tmyProperty:'Some init value',\n"+
"\tanotherProperty:function() { return 'Some other value')\n"+
"})\n";
const defaultStorageValidateCode = "validation({\n"+
"\t// Here you add validation specifications for your model properties.\n"+
"\tmyProperty: { required:true, length:4 },\n"+
"\tanotherProperty: function(value) {\n"+
"\t\tif(value !== 'someValue) return 'Error message'\n"+
"\t}\n"+
"})\n";*/
function updatePorts(nodeId, parameters, editorConnection) {
var ports = [];
// Add value outputs
var properties = parameters.properties;
if (properties) {
properties = properties ? properties.split(',') : undefined;
for (var i in properties) {
var p = properties[i];
ports.push({
type: {
name: '*',
allowConnectionsOnly: true
},
plug: 'input/output',
group: 'Properties',
name: p
});
ports.push({
type: 'signal',
plug: 'output',
group: 'Changed Events',
displayName: p + ' Changed',
name: 'changed-' + p
});
}
/* var propertyRenamed = detectRename(previousProperties[nodeId], properties);
previousProperties[nodeId] = properties;
if (propertyRenamed) {
var renamed = {
plug: 'input/output',
patterns: ['{{*}}'],
before: propertyRenamed.before,
after: propertyRenamed.after
};
}*/
}
editorConnection.sendDynamicPorts(nodeId, ports, {
detectRenamed: {
plug: 'input/output'
}
});
}
module.exports = {
node: ModelNodeDefinition,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
graphModel.on('nodeAdded.Model', 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,142 @@
'use strict';
const { Node } = require('@noodl/runtime');
var Model = require('@noodl/runtime/src/model');
var VariableNodeDefinition = {
name: 'Variable',
docs: 'https://docs.noodl.net/nodes/data/variable',
category: 'Data',
usePortAsLabel: 'name',
color: 'data',
deprecated: true, // use newvariable instead
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;
}
},
stored: {
type: 'signal',
displayName: 'Stored',
group: 'Events'
},
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');
}
}
},
store: {
displayName: 'Set',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleStore();
}
},
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;
if (this.isInputConnected('store') === false) {
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);
this.sendSignalOnOutput('stored');
});
},
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,82 @@
'use strict';
const { Node } = require('@noodl/runtime');
const GlobalsNode = {
name: 'Globals',
shortDesc: 'A node used to communicate values across the project.',
category: 'Utilities',
color: 'component',
deprecated: true, // use variable instead
initialize: function () {
this._internal.listeners = [];
},
panels: [
{
name: 'PortEditor',
context: ['select', 'connectTo', 'connectFrom'],
title: 'Globals',
plug: 'input/output',
type: {
name: '*'
}
}
],
prototypeExtensions: {
_onNodeDeleted: function () {
Node.prototype._onNodeDeleted.call(this);
var globalsEmitter = this.context.globalsEventEmitter;
for (var i = 0; i < this._internal.listeners.length; i++) {
var listener = this._internal.listeners[i];
globalsEmitter.removeListener(listener.name, listener.listener);
}
this._internal.listeners = [];
},
_newOutputValueReceived: {
value: function (name) {
this._cachedInputValues[name] = this.context.globalValues[name];
this.flagOutputDirty(name);
}
},
registerInputIfNeeded: {
value: function (name) {
var self = this;
if (this.hasInput(name)) {
return;
}
this.registerInput(name, {
set: self.context.setGlobalValue.bind(self.context, name)
});
}
},
registerOutputIfNeeded: {
value: function (name) {
if (this.hasOutput(name)) {
return;
}
var newOutputValueReceivedCallback = this._newOutputValueReceived.bind(this, name);
var globalsEmitter = this.context.globalsEventEmitter;
this._internal.listeners.push({
name: name,
listener: newOutputValueReceivedCallback
});
globalsEmitter.on(name, newOutputValueReceivedCallback);
this.registerOutput(name, {
getter: function () {
return this.context.globalValues[name];
}
});
}
}
}
};
module.exports = {
node: GlobalsNode
};

Some files were not shown because too many files have changed in this diff Show More