mirror of
https://github.com/noodlapp/noodl.git
synced 2026-01-11 23:02:53 +01:00
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:
21
packages/noodl-viewer-react/LICENSE
Normal file
21
packages/noodl-viewer-react/LICENSE
Normal 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.
|
||||
3
packages/noodl-viewer-react/index.deploy.js
Normal file
3
packages/noodl-viewer-react/index.deploy.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import NoodlViewerReact from './noodl-viewer-react';
|
||||
|
||||
window.Noodl._viewerReact = NoodlViewerReact;
|
||||
4
packages/noodl-viewer-react/index.ssr.js
Normal file
4
packages/noodl-viewer-react/index.ssr.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import NoodlViewerReact, { ssrSetupRuntime } from './noodl-viewer-react';
|
||||
|
||||
export { NoodlViewerReact, ssrSetupRuntime };
|
||||
globalThis.NoodlSSR = { createElement: NoodlViewerReact.createElement, ssrSetupRuntime };
|
||||
4
packages/noodl-viewer-react/index.viewer.js
Normal file
4
packages/noodl-viewer-react/index.viewer.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import NoodlViewerReact from './noodl-viewer-react';
|
||||
|
||||
window.ELECTRON_DISABLE_SECURITY_WARNINGS = true;
|
||||
window.Noodl._viewerReact = NoodlViewerReact;
|
||||
5
packages/noodl-viewer-react/jest.config.js
Normal file
5
packages/noodl-viewer-react/jest.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
};
|
||||
66
packages/noodl-viewer-react/noodl-viewer-react.js
Normal file
66
packages/noodl-viewer-react/noodl-viewer-react.js
Normal 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);
|
||||
}
|
||||
};
|
||||
49
packages/noodl-viewer-react/package.json
Normal file
49
packages/noodl-viewer-react/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
73
packages/noodl-viewer-react/src/api/cloudfunctions.js
Normal file
73
packages/noodl-viewer-react/src/api/cloudfunctions.js
Normal 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;
|
||||
23
packages/noodl-viewer-react/src/api/files.js
Normal file
23
packages/noodl-viewer-react/src/api/files.js
Normal 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;
|
||||
48
packages/noodl-viewer-react/src/api/navigation.js
Normal file
48
packages/noodl-viewer-react/src/api/navigation.js
Normal 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;
|
||||
73
packages/noodl-viewer-react/src/api/seo.ts
Normal file
73
packages/noodl-viewer-react/src/api/seo.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
178
packages/noodl-viewer-react/src/api/users.js
Normal file
178
packages/noodl-viewer-react/src/api/users.js
Normal 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
|
||||
271
packages/noodl-viewer-react/src/assets/style.css
Normal file
271
packages/noodl-viewer-react/src/assets/style.css
Normal 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;
|
||||
}
|
||||
47
packages/noodl-viewer-react/src/async-queue.js
Normal file
47
packages/noodl-viewer-react/src/async-queue.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './NoHomeError';
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Button';
|
||||
@@ -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 = {};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Checkbox'
|
||||
@@ -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 = {};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './RadioButton';
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './RadioButtonGroup';
|
||||
@@ -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 = {};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Select';
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Slider';
|
||||
@@ -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 = {};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './TextInput';
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Page';
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Circle';
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Columns';
|
||||
@@ -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 = {};
|
||||
159
packages/noodl-viewer-react/src/components/visual/Drag/Drag.tsx
Normal file
159
packages/noodl-viewer-react/src/components/visual/Drag/Drag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Drag'
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Group';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Icon'
|
||||
@@ -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 = {};
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Image';
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Text';
|
||||
@@ -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 = {};
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Video';
|
||||
1
packages/noodl-viewer-react/src/constants/flex.js
Normal file
1
packages/noodl-viewer-react/src/constants/flex.js
Normal file
@@ -0,0 +1 @@
|
||||
export const flexDirectionValues = ['row', 'row-reverse', 'column', 'column-reverse', 'inherit', 'initial', 'revert', 'unset'];
|
||||
@@ -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;
|
||||
75
packages/noodl-viewer-react/src/dom-boundingbox-oberver.js
Normal file
75
packages/noodl-viewer-react/src/dom-boundingbox-oberver.js
Normal 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;
|
||||
}
|
||||
}
|
||||
58
packages/noodl-viewer-react/src/easecurves.js
Normal file
58
packages/noodl-viewer-react/src/easecurves.js
Normal 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;
|
||||
97
packages/noodl-viewer-react/src/fontloader.js
Normal file
97
packages/noodl-viewer-react/src/fontloader.js
Normal 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;
|
||||
45
packages/noodl-viewer-react/src/graph-warnings.js
Normal file
45
packages/noodl-viewer-react/src/graph-warnings.js
Normal 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);
|
||||
}
|
||||
}
|
||||
11
packages/noodl-viewer-react/src/guid.js
Normal file
11
packages/noodl-viewer-react/src/guid.js
Normal 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;
|
||||
159
packages/noodl-viewer-react/src/highlighter.js
Normal file
159
packages/noodl-viewer-react/src/highlighter.js
Normal 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);
|
||||
}
|
||||
155
packages/noodl-viewer-react/src/inspector.js
Normal file
155
packages/noodl-viewer-react/src/inspector.js
Normal 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;
|
||||
}
|
||||
}
|
||||
165
packages/noodl-viewer-react/src/layout.js
Normal file
165
packages/noodl-viewer-react/src/layout.js
Normal 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 || '');
|
||||
}
|
||||
}
|
||||
};
|
||||
21
packages/noodl-viewer-react/src/mergedeep.js
Normal file
21
packages/noodl-viewer-react/src/mergedeep.js
Normal 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);
|
||||
}
|
||||
1583
packages/noodl-viewer-react/src/node-shared-port-definitions.js
Normal file
1583
packages/noodl-viewer-react/src/node-shared-port-definitions.js
Normal file
File diff suppressed because it is too large
Load Diff
113
packages/noodl-viewer-react/src/node-transitions.js
Normal file
113
packages/noodl-viewer-react/src/node-transitions.js
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user