UNPKG

node-red-contrib-uibuilder

Version:

Easily create data-driven web UI's for Node-RED. Single- & Multi-page. Multiple UI's. Work with existing web development workflows or mix and match with no-code/low-code features.

1,180 lines (1,047 loc) 146 kB
// @ts-nocheck /** * @kind module * @module uibuilder * @description The client-side Front-End JavaScript for uibuilder in HTML Module form * It provides a number of global objects that can be used in your own javascript. * see the docs folder `./docs/uibuilder.module.md` for details of how to use this fully. * * Please use the default index.js file for your own code and leave this as-is. * See Uib._meta for client version string * @version 1.0.0 * @license Apache-2.0 * @author Julian Knight (Totally Information) * @copyright (c) 2022-2025 Julian Knight (Totally Information) */ //#region --- Type Defs --- // /** * A string containing HTML markup * @typedef {string} html */ //#endregion --- Type Defs --- // // We need the Socket.IO & ui libraries & the uib-var component --- // // @ts-ignore - Note: Only works when using esbuild to bundle import Ui from './ui' import io from 'socket.io-client' // eslint-disable-line import/no-named-as-default import UibVar from '../components/uib-var' import UibMeta from '../components/uib-meta' import ApplyTemplate from '../components/apply-template' // import { dom } from './tinyDom' // Incorporate the logger module - NB: This sets a global `log` object for use if it can. // import logger from './logger' const version = '7.2.0-src' //#region --- Module-level utility functions --- // // Detect whether the loaded library is minified or not const isMinified = !(/param/).test(function (param) { }) // TODO - switch to logger module //#region --- print/console - debugging output functions --- // /** Custom logging. e.g. log(2, 'here:there', 'jiminy', {fred:'jim'})() * @returns {Function} Log function @example log(2, 'here:there', 'jiminy', {fred:'jim'})() */ function log() { // Get the args const args = Array.from(arguments) // 1st arg is the log level/type let level = args.shift() let strLevel switch (level) { case 'trace': case 5: { if (log.level < 5) break level = 5 // make sure level is numeric strLevel = 'trace' break } case 'debug': case 4: { if (log.level < 4) break level = 4 strLevel = 'debug' break } case 'log': case 3: { if (log.level < 3) break level = 3 strLevel = 'log' break } case 'info': case '': case 2: { if (log.level < 2) break level = 2 strLevel = 'info' break } case 'warn': case 1: { if (log.level < 1) break level = 1 strLevel = 'warn' break } case 'error': case 'err': case 0: { if (log.level < 0) break level = 0 strLevel = 'error' break } default: { level = -1 break } } // If set to something unknown, no log output if (strLevel === undefined) return function () { } // 2nd arg is a heading that will be colour highlighted const head = args.shift() // Bind back to console.log (could use console[strLevel] but some levels ignore some formatting, use console.xxx directly or dedicated fn) return Function.prototype.bind.call( console[log.LOG_STYLES[strLevel].console], console, `%c${log.LOG_STYLES[strLevel].pre}${strLevel}%c [${head}]`, `${log.LOG_STYLES.level} ${log.LOG_STYLES[strLevel].css}`, `${log.LOG_STYLES.head} ${log.LOG_STYLES[strLevel].txtCss}`, ...args ) } // Nice console styling log.LOG_STYLES = { // 0 error: { css: 'background: red; color: black;', txtCss: 'color: red; ', pre: '⛔ ', console: 'error', // or trace }, // 1 warn: { css: 'background: darkorange; color: black;', txtCss: 'color: darkorange; ', pre: '⚠ ', console: 'warn', }, // 2 info: { css: 'background: aqua; color: black;', txtCss: 'color: aqua;', pre: '❗ ', console: 'info', }, // 3 log: { css: 'background: grey; color: yellow;', txtCss: 'color: grey;', pre: '', console: 'log', }, // 4 debug: { css: 'background: chartreuse; color: black;', txtCss: 'color: chartreuse;', pre: '', console: 'debug', }, // 5 trace: { css: 'background: indigo; color: yellow;', txtCss: 'color: hotpink;', pre: '', console: 'log', }, names: ['error', 'warn', 'info', 'log', 'debug', 'trace'], reset: 'color: inherit;', head: 'font-weight:bold; font-style:italic;', level: 'font-weight:bold; border-radius: 3px; padding: 2px 5px; display:inline-block;', } /** Default log level - Warn (@since v7.1.0) */ log.default = 1 let ll // Check if the script element was found and get the data-log-level attribute (only numeric levels allowed here) let scriptElement try { scriptElement = document.currentScript ll = scriptElement.getAttribute('logLevel') } catch (e) {} // Otherwise check if the import url (for ESM only) has a logLevel query param if (ll === undefined) { try { const url = new URL(import.meta.url).searchParams ll = url.get('logLevel') } catch (e) {} } // If either found, check numeric and set default level if so if (ll !== undefined) { ll = Number(ll) if (isNaN(ll)) { console.warn( `[Uib:constructor] Cannot set logLevel to "${scriptElement?.getAttribute('logLevel')}". Defaults to 0 (error).`) log.default = 0 } else log.default = ll } // Set current level to default log.level = log.default // log.default = isMinified ? 0 : 1 // When using minified lib, assume production and only log errors otherwise also log warn //#endregion /** A hack to dynamically load a remote module and wait until it is loaded * @param {string} url The URL of the module to load * @returns {object|null} Either the result object or null (if the load fails) */ // function loadModule(url) { // eslint-disable-line no-unused-vars // let done // import(url) // .then(res => { // log('debug', '>> then >>', res)() // done = res // }) // .catch(err => { // console.error(`[uibuilder:loadModule] Could not load module ${url}`, err) // done = null // }) // // eslint-disable-next-line no-empty // while (!done) { } // eslint-disable-line no-unmodified-loop-condition // return done // } /** Convert JSON to Syntax Highlighted HTML * @param {object} json A JSON/JavaScript Object * @returns {html} Object reformatted as highlighted HTML */ function syntaxHighlight(json) { if (json === undefined) { json = '<span class="undefined">undefined</span>' } else { try { json = JSON.stringify(json, undefined, 4) // json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') // eslint-disable-line newline-per-chained-call json = json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, function (match) { let cls = 'number' if ((/^"/).test(match)) { if ((/:$/).test(match)) { cls = 'key' } else { cls = 'string' } } else if ((/true|false/).test(match)) { cls = 'boolean' } else if ((/null/).test(match)) { cls = 'null' } return `<span class="${cls}">${match}</span>` }) } catch (e) { json = `Syntax Highlight ERROR: ${e.message}` } } return json } /** msg._ui handling functions */ const _ui = new Ui(window, log, syntaxHighlight) //#endregion --- Module-level utility functions --- // /** Define and export the Uib class - note that an instance of the class is also exported in the wrap-up * @typicalname uibuilder */ export const Uib = class Uib { //#region --- Static variables --- static _meta = { version: version, type: 'module', displayName: 'uibuilder', } //#endregion ---- ---- ---- ---- //#region private class vars // How many times has the loaded instance connected to Socket.IO (detect if not a new load?) connectedNum = 0 // event listener callbacks by property name // #events = {} // Socket.IO channel names _ioChannels = { control: 'uiBuilderControl', client: 'uiBuilderClient', server: 'uiBuilder', } /** setInterval holder for pings @type {function|undefined} */ #pingInterval // onChange event callbacks #propChangeCallbacks = {} // onTopic event callbacks #msgRecvdByTopicCallbacks = {} // Is Vue available? isVue = false // What version? Set in startup if Vue is loaded. Won't always work vueVersion = undefined /** setInterval id holder for Socket.IO checkConnect * @type {number|null} */ #timerid = null // Holds the reference ID for the internal msg change event handler so that it can be cancelled #MsgHandler // Placeholder for io.socket - can't make a # var until # fns allowed in all browsers _socket // Placeholder for an observer that watches the whole DOM for changes - can't make a # var until # fns allowed in all browsers _htmlObserver // Has showMsg been turned on? #isShowMsg = false // Has showStatus been turned on? #isShowStatus = false // If true, URL hash changes send msg back to node-red. Controlled by watchUrlHash() #sendUrlHash = false // Used to help create unique element ID's if one hasn't been provided, increment on use #uniqueElID = 0 // Externally accessible command functions (NB: Case must match) - remember to update _uibCommand for new commands #extCommands = [ 'elementExists', 'get', 'getManagedVarList', 'getWatchedVars', 'htmlSend', 'include', 'navigate', 'scrollTo', 'set', 'showMsg', 'showStatus', 'uiGet', 'uiWatch', 'watchUrlHash', ] /** @type {Object<string, string>} Managed uibuilder variables */ #managedVars = {} // What status variables to show via showStatus() #showStatus = { online: { 'var': 'online', 'label': 'Online?', 'description': 'Is the browser online?', }, ioConnected: { 'var': 'ioConnected', 'label': 'Socket.IO connected?', 'description': 'Is Socket.IO connected?', }, connectedNum: { 'var': 'connectedNum', 'label': '# reconnections', 'description': 'How many times has Socket.IO had to reconnect since last page load?', }, clientId: { 'var': 'clientId', 'label': 'Client ID', 'description': 'Static client unique id set in Node-RED. Only changes when browser is restarted.', }, tabId: { 'var': 'tabId', 'label': 'Browser tab ID', 'description': 'Static unique id for the browser\'s current tab', }, cookies: { 'var': 'cookies', 'label': 'Cookies', 'description': 'Cookies set in Node-RED', }, httpNodeRoot: { 'var': 'httpNodeRoot', 'label': 'httpNodeRoot', 'description': 'From Node-RED\' settings.js, affects URL\'s. May be wrong for pages in sub-folders', }, pageName: { 'var': 'pageName', 'label': 'Page name', 'description': 'Actual name of this page', }, ioNamespace: { 'var': 'ioNamespace', 'label': 'SIO namespace', 'description': 'Socket.IO namespace - unique to each uibuilder node instance', }, // ioPath: { 'var': 'ioPath', 'label': 'SIO path', 'description': '', }, // no longer needed in the modern client socketError: { 'var': 'socketError', 'label': 'Socket error', 'description': 'If the Socket.IO connection has failed, says why', }, msgsSent: { 'var': 'msgsSent', 'label': '# msgs sent', 'description': 'How many standard messages have been sent to Node-RED?', }, msgsReceived: { 'var': 'msgsReceived', 'label': '# msgs received', 'description': 'How many standard messages have been received from Node-RED?', }, msgsSentCtrl: { 'var': 'msgsSentCtrl', 'label': '# control msgs sent', 'description': 'How many control messages have been sent to Node-RED?', }, msgsCtrlReceived: { 'var': 'msgsCtrlReceived', 'label': '# control msgs received', 'description': 'How many control messages have been received from Node-RED?', }, originator: { 'var': 'originator', 'label': 'Node Originator', 'description': 'If the last msg from Node-RED was from a `uib-sender` node, this will be its node id so that messasges can be returned to it', }, topic: { 'var': 'topic', 'label': 'Default topic', 'description': 'Optional default topic to be included in outgoing standard messages', }, started: { 'var': 'started', 'label': 'Has uibuilder client started?', 'description': 'Whether `uibuilder.start()` ran successfully. This should self-run and should not need to be run manually', }, version: { 'var': 'version', 'label': 'uibuilder client version', 'description': 'The version of the loaded uibuilder client library', }, serverTimeOffset: { 'var': 'serverTimeOffset', 'label': 'Server time offset (Hrs)', 'description': 'The number of hours difference between the Node-red server and the client', }, } // Track ui observers (see uiWatch) #uiObservers = {} // List of uib specific attributes that will be watched and processed dynamically uibAttribs = ['uib-topic', 'data-uib-topic'] #uibAttrSel = `[${this.uibAttribs.join('], [')}]` //#endregion //#region public class vars // TODO Move to proper getters //#region ---- Externally read-only (via .get method) ---- // // version - moved to _meta /** Client ID set by uibuilder on connect */ clientId = '' /** The collection of cookies provided by uibuilder */ cookies = {} /** Copy of last control msg object received from sever */ ctrlMsg = {} /** Is Socket.IO client connected to the server? */ ioConnected = false // Is the library running from a minified version? isMinified = isMinified // Is the browser tab containing this page visible or not? isVisible = false // Remember the last page (re)load/navigation type: navigate, reload, back_forward, prerender lastNavType = '' // Max msg size that can be sent over Socket.IO - updated by "client connect" msg receipt maxHttpBufferSize = 1048576 /** Last std msg received from Node-RED */ msg = {} /** number of messages sent to server since page load */ msgsSent = 0 /** number of messages received from server since page load */ msgsReceived = 0 /** number of control messages sent to server since page load */ msgsSentCtrl = 0 /** number of control messages received from server since page load */ msgsCtrlReceived = 0 /** Is the client online or offline? */ online = navigator.onLine /** last control msg object sent via uibuilder.send() @since v2.0.0-dev3 */ sentCtrlMsg = {} /** last std msg object sent via uibuilder.send() */ sentMsg = {} /** placeholder to track time offset from server, see fn socket.on(ioChannels.server ...) */ serverTimeOffset = null /** placeholder for a socket error message */ socketError = null // tab identifier from session storage tabId = '' // Actual name of current page (set in constructor) pageName = null // Is the DOMPurify library loaded? Updated in start() purify = false // Is the Markdown-IT library loaded? Updated in start() markdown = false // Current URL hash. Initial set is done from start->watchHashChanges via a set to make it watched urlHash = location.hash //#endregion ---- ---- ---- ---- // // TODO Move to proper getters/setters //#region ---- Externally Writable (via .set method, read via .get method) ---- // /** Default originator node id - empty string by default * @type {string} */ originator = '' /** Default topic - used by send if set and no topic provided * @type {(string|undefined)} */ topic = undefined /** Either undefined or a reference to a uib router instance * Set by uibrouter, do not set manually. */ uibrouterinstance /** Set by uibrouter, do not set manually */ uibrouter_CurrentRoute //#endregion ---- ---- ---- ---- // //#region ---- These are unlikely to be needed externally: ---- autoSendReady = true httpNodeRoot = '' // Node-RED setting (via cookie) ioNamespace = '' ioPath = '' retryFactor = 1.5 // starting delay factor for subsequent reconnect attempts retryMs = 2000 // starting retry ms period for manual socket reconnections workaround storePrefix = 'uib_' // Prefix for all uib-related localStorage started = false // NOTE: These can only change when a client (re)connects socketOptions = { path: this.ioPath, // https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API // https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API // https://socket.io/get-started/webtransport // NOTE: webtransport requires HTTP/3 and TLS. HTTP/2 & 3 not yet available in Node.js // transports: ['polling', 'websocket', 'webtransport'], transports: ['polling', 'websocket'], // Using callback so that they are updated automatically on (re)connect // Only put things in here that will be valid for a websocket connected session auth: (cb) => { cb({ clientVersion: version, clientId: this.clientId, pathName: window.location.pathname, urlParams: Object.fromEntries(new URLSearchParams(location.search)), pageName: this.pageName, tabId: this.tabId, lastNavType: this.lastNavType, connectedNum: ++this.connectedNum, // Used to calculate the diff between the server and client connection timestamps - reported if >1 minute browserConnectTimestamp: (new Date()).toISOString(), }) }, transportOptions: { // Can only set headers when polling polling: { extraHeaders: { 'x-clientid': `${Uib._meta.displayName}; ${Uib._meta.type}; ${Uib._meta.version}; ${this.clientId}`, }, }, }, } //#endregion -- not external -- //#endregion --- End of variables --- //#region ------- Getters and Setters ------- // // Change logging level dynamically (affects both console. and print.) set logLevel(level) { log.level = level; console.log('%c❗ info%c [logLevel]', `${log.LOG_STYLES.level} ${log.LOG_STYLES.info.css}`, `${log.LOG_STYLES.head} ${log.LOG_STYLES.info.txtCss}`, `Set to ${level} (${log.LOG_STYLES.names[level]})`) /* changeLogLevel(level)*/ } get logLevel() { return log.level } get meta() { return Uib._meta } /** Function to set uibuilder properties to a new value - works on any property except _* or #* * Also triggers any event listeners. * Example: this.set('msg', {topic:'uibuilder', payload:42}); * @param {string} prop Any uibuilder property who's name does not start with a _ or # * @param {*} val The set value of the property or a string declaring that a protected property cannot be changed * @param {boolean} [store] If true, the variable is also saved to the browser localStorage if possible * @param {boolean} [autoload] If true & store is true, on load, uib will try to restore the value from the store automatically * @returns {*} Input value */ set(prop, val, store = false, autoload = false) { // Check for excluded properties - we don't want people to set these // if (this.#excludedSet.indexOf(prop) !== -1) { if (prop.startsWith('_') || prop.startsWith('#')) { log('warn', 'Uib:set', `Cannot use set() on protected property "${prop}"`)() return `Cannot use set() on protected property "${prop}"` } // Check for an old value const oldVal = this[prop] ?? undefined // We must add the var to the uibuilder object this[prop] = val // Keep track of all managed variables this.#managedVars[prop] = prop // If requested, save to store if (store === true) this.setStore(prop, val, autoload) log('trace', 'Uib:set', `prop set - prop: ${prop}, val: `, val, ` store: ${store}, autoload: ${autoload}`)() // Trigger this prop's event callbacks (listeners which are set by this.onChange) // this.emit(prop, val) // trigger an event on the prop name, pass both the name and value to the event details this._dispatchCustomEvent('uibuilder:propertyChanged', { 'prop': prop, 'value': val, 'oldValue': oldVal, 'store': store, 'autoload': autoload, }) this._dispatchCustomEvent(`uibuilder:propertyChanged:${prop}`, { 'prop': prop, 'value': val, 'oldValue': oldVal, 'store': store, 'autoload': autoload, }) return val } /** Function to get the value of a uibuilder property * Example: uibuilder.get('msg') * @param {string} prop The name of the property to get as long as it does not start with a _ or # * @returns {*|undefined} The current value of the property */ get(prop) { if (prop.startsWith('_') || prop.startsWith('#')) { log('warn', 'Uib:get', `Cannot use get() on protected property "${prop}"`)() return } if (prop === 'version') return Uib._meta.version if (prop === 'msgsCtrl') return this.msgsCtrlReceived if (prop === 'reconnections') return this.connectedNum if (this[prop] === undefined) { log('warn', 'Uib:get', `get() - property "${prop}" is undefined`)() } return this[prop] } /** Write to localStorage if possible. console error output if can't write * Also uses this.storePrefix * @example * uibuilder.setStore('fred', 42) * console.log(uibuilder.getStore('fred')) * @param {string} id localStorage var name to be used (prefixed with 'uib_') * @param {*} value value to write to localstore * @param {boolean} [autoload] If true, on load, uib will try to restore the value from the store * @returns {boolean} True if succeeded else false */ setStore(id, value, autoload = false) { let autoVars = {} if (autoload === true) { try { autoVars = this.getStore('_uibAutoloadVars') || {} } catch (e) {} } if (typeof value === 'object') { try { value = JSON.stringify(value) } catch (e) { log('error', 'Uib:setStore', 'Cannot stringify object, not storing. ', e)() return false } } try { localStorage.setItem(this.storePrefix + id, value) if (autoload) { autoVars[id] = id try { localStorage.setItem(this.storePrefix + '_uibAutoloadVars', JSON.stringify(autoVars)) } catch (e) { log('error', 'Uib:setStore', 'Cannot save autoload list. ', e)() } } this._dispatchCustomEvent('uibuilder:propertyStored', { 'prop': id, 'value': value, 'autoload': autoload, }) return true } catch (e) { log('error', 'Uib:setStore', 'Cannot write to localStorage. ', e)() return false } } // --- end of setStore --- // /** Attempt to get and re-hydrate a key value from localStorage * Note that all uib storage is automatically prefixed using this.storePrefix * @param {*} id The key of the value to attempt to retrieve * @returns {*|null|undefined} The re-hydrated value of the key or null if key not found, undefined on error */ getStore(id) { try { // @ts-ignore return JSON.parse(localStorage.getItem(this.storePrefix + id)) } catch (e) { } try { return localStorage.getItem(this.storePrefix + id) } catch (e) { return undefined } } /** Remove a given id from the uib keys in localStorage * @param {*} id The key to remove */ removeStore(id) { try { localStorage.removeItem(this.storePrefix + id) } catch (e) { } } /** Returns a list of uibuilder properties (variables) that can be watched with onChange * @returns {Object<string,string>} List of uibuilder managed variables */ getManagedVarList() { return this.#managedVars } getWatchedVars() { return Object.keys(this.#propChangeCallbacks) } //#endregion ------- -------- ------- // //#region ------- Our own event handling system ---------- // /** Standard fn to create a custom event with details & dispatch it * @param {string} title The event name * @param {*} details Any details to pass to event output */ _dispatchCustomEvent(title, details) { const event = new CustomEvent(title, { detail: details, }) document.dispatchEvent(event) } // See the this.#propChangeCallbacks & msgRecvdByTopicCallbacks private vars /** Register on-change event listeners for uibuilder tracked properties * Make it possible to register a function that will be run when the property changes. * Note that you can create listeners for non-existant properties * @example uibuilder.onChange('msg', (msg) => { console.log('uibuilder.msg changed! It is now: ', msg) }) * * @param {string} prop The property of uibuilder that we want to monitor * @param {Function} callback The function that will run when the property changes, parameter is the new value of the property after change * @returns {number} A reference to the callback to cancel, save and pass to uibuilder.cancelChange if you need to remove a listener */ onChange(prop, callback) { // Note: Property does not have to exist yet // console.debug(`[Uib:onchange] pushing new callback (event listener) for property: ${prop}`) // Create a new array or add to the array of callback functions for the property in the events object // if (this.#events[prop]) { // this.#events[prop].push(callback) // } else { // this.#events[prop] = [callback] // } // Make sure we have an object to receive the saved callback, update the latest reference number if (!this.#propChangeCallbacks[prop]) this.#propChangeCallbacks[prop] = { _nextRef: 1, } else this.#propChangeCallbacks[prop]._nextRef++ const nextCbRef = this.#propChangeCallbacks[prop]._nextRef // Register the callback function. It is saved so that we can remove the event listener if we need to const propChangeCallback = this.#propChangeCallbacks[prop][nextCbRef] = function propChangeCallback(e) { // If the prop name matches the 1st arg in the onChange fn: if (prop === e.detail.prop) { const value = e.detail.value // console.warn('[Uib:onChange:evt] uibuilder:propertyChanged. ', e.detail) // Set the callback fn's `this` and its single argument to the msg callback.call(value, value) } } document.addEventListener('uibuilder:propertyChanged', propChangeCallback) return nextCbRef } // ---- End of onChange() ---- // cancelChange(prop, cbRef) { document.removeEventListener('uibuilder:propertyChanged', this.#propChangeCallbacks[prop][cbRef]) delete this.#propChangeCallbacks[prop][cbRef] // this.#propChangeCallbacks[topic]._nextRef-- // Don't bother, let the ref# increase } /** Register a change callback for a specific msg.topic * Similar to onChange but more convenient if needing to differentiate by msg.topic. * @example let otRef = uibuilder.onTopic('mytopic', function(){ console.log('Received a msg with msg.topic=`mytopic`. msg: ', this) }) * To cancel a change listener: uibuilder.cancelTopic('mytopic', otRef) * * @param {string} topic The msg.topic we want to listen for * @param {Function} callback The function that will run when an appropriate msg is received. `this` inside the callback as well as the cb's single argument is the received msg. * @returns {number} A reference to the callback to cancel, save and pass to uibuilder.cancelTopic if you need to remove a listener */ onTopic(topic, callback) { // Make sure we have an object to receive the saved callback, update the latest reference number if (!this.#msgRecvdByTopicCallbacks[topic]) this.#msgRecvdByTopicCallbacks[topic] = { _nextRef: 1, } else this.#msgRecvdByTopicCallbacks[topic]._nextRef++ const nextCbRef = this.#msgRecvdByTopicCallbacks[topic]._nextRef // Register the callback function. It is saved so that we can remove the event listener if we need to const msgRecvdEvtCallback = this.#msgRecvdByTopicCallbacks[topic][nextCbRef] = function msgRecvdEvtCallback(e) { const msg = e.detail // console.log('[Uib:onTopic:evt] uibuilder:stdMsgReceived where topic matches. ', e.detail) if (msg.topic === topic) { // Set the callback fn's `this` and its single argument to the msg callback.call(msg, msg) } } document.addEventListener('uibuilder:stdMsgReceived', msgRecvdEvtCallback) return nextCbRef } cancelTopic(topic, cbRef) { document.removeEventListener('uibuilder:stdMsgReceived', this.#msgRecvdByTopicCallbacks[topic][cbRef]) delete this.#msgRecvdByTopicCallbacks[topic][cbRef] // this.#msgRecvdCallbacks[topic]._nextRef-- // Don't bother, let the ref# increase } /** Trigger event listener for a given property * Called when uibuilder.set is used * * @param {*} prop The property for which to run the callback functions * arguments: Additional arguments contain the value to pass to the event callback (e.g. newValue) */ // emit(prop) { // var evt = this.#events[prop] // if (!evt) { // return // } // var args = Array.prototype.slice.call(arguments, 1) // for (var i = 0; i < evt.length; i++) { // evt[i].apply(this, args) // } // log('trace', 'Uib:emit', `${evt.length} listeners run for prop ${prop} `)() // } /** Forcibly removes all event listeners from the events array * Use if you need to re-initialise the environment */ // clearEventListeners() { // this.#events = [] // } // ---- End of clearEventListeners() ---- // /** Clear a single property event listeners * @param {string} prop The property of uibuilder for which we want to clear the event listener */ // clearListener(prop) { // if (this.#events[prop]) delete this.#events[prop] // } //#endregion ---------- End of event handling system ---------- // //#region ------- General Utility Functions -------- // /** Check supplied msg from server for a timestamp - if received, work out & store difference to browser time * @param {object} receivedMsg A message object recieved from Node-RED * @returns {void} Updates self.serverTimeOffset if different to previous value */ _checkTimestamp(receivedMsg) { if (Object.prototype.hasOwnProperty.call(receivedMsg, 'serverTimestamp')) { const serverTimestamp = new Date(receivedMsg.serverTimestamp) // @ts-ignore const offset = Math.round(((new Date()) - serverTimestamp) / 3600000) // in ms /3.6m to get hours if (offset !== this.serverTimeOffset) { log('trace', `Uib:checkTimestamp:${this._ioChannels.server} (server)`, `Offset changed to: ${offset} from: ${this.serverTimeOffset}`)() this.set('serverTimeOffset', offset) } } } /** Set up an event listener to watch for hash changes * and set the watchable urlHash variable */ _watchHashChanges() { this.set('urlHash', location.hash) window.addEventListener('hashchange', (event) => { this.set('urlHash', location.hash) if (this.#sendUrlHash === true) { this.send({ topic: 'hashChange', payload: location.hash, newHash: this.keepHashFromUrl(event.newURL), oldHash: this.keepHashFromUrl(event.oldURL), }) } }) } /** Returns a new array containing the intersection of the 2 input arrays * @param {Array} a1 Array to check * @param {Array} a2 Array to intersect * @returns {Array} The intersection of the 2 arrays (may be an empty array) */ arrayIntersect(a1, a2) { return a1.filter(uName => a2.includes(uName)) } /** Copies a uibuilder variable to the browser clipboard * @param {string} varToCopy The name of the uibuilder variable to copy to the clipboard */ copyToClipboard(varToCopy) { let data = '' try { data = JSON.stringify(this.get(varToCopy)) } catch (e) { log('error', 'copyToClipboard', `Could not copy "${varToCopy}" to clipboard.`, e.message)() } navigator.clipboard.writeText(data) } // --- End of copyToClipboard --- // /** Does the chosen CSS Selector currently exist? * Automatically sends a msg back to Node-RED unless turned off. * @param {string} cssSelector Required. CSS Selector to examine for visibility * @param {boolean} [msg] Optional, default=true. If true also sends a message back to Node-RED * @returns {boolean} True if the element exists */ elementExists(cssSelector, msg = true) { const el = document.querySelector(cssSelector) let exists = false if (el !== null) exists = true if (msg === true) { this.send({ payload: exists, info: `Element "${cssSelector}" ${exists ? 'exists' : 'does not exist'}`, }) } return exists } // --- End of elementExists --- // /** Format a number using the INTL standard library - compatible with uib-var filter function * @param {number} value Number to format * @param {number} decimalPlaces Number of decimal places to include. Default=no default * @param {string} intl standard locale spec, e.g. "ja-JP" or "en-GB". Default=navigator.language * @param {object} opts INTL library options object. Optional * @returns {string} formatted number * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString */ formatNumber(value, decimalPlaces, intl, opts) { if (isNaN(value)) { log('error', 'formatNumber', `Value must be a number. Value type: "${typeof value}"`)() return 'NaN' } if (!opts) opts = {} if (!intl) intl = navigator.language ? navigator.language : 'en-GB' if (decimalPlaces) { opts.minimumFractionDigits = decimalPlaces opts.maximumFractionDigits = decimalPlaces } let out try { out = Number(value).toLocaleString(intl, opts) } catch (e) { log('error', 'formatNumber', `${e.message}. value=${value}, dp=${decimalPlaces}, intl="${intl}", opts=${JSON.stringify(opts)}`)() return 'NaN' } return out } /** Attempt to get rough size of an object * @param {*} obj Any serialisable object * @returns {number|undefined} Rough size of object in bytes or undefined */ getObjectSize(obj) { let size try { const jsonString = JSON.stringify(obj) // Encode the string to a Uint8Array and measure its length const encoder = new TextEncoder() const uint8Array = encoder.encode(jsonString) size = uint8Array.length } catch (e) { log('error', 'uibuilder:getObjectSize', 'Could not stringify, cannot determine size', obj, e)() } return size } /** Returns true if a uibrouter instance is loaded, otherwise returns false * @returns {boolean} true if uibrouter instance loaded else false */ hasUibRouter() { return !!this.uibrouterinstance } /** Only keep the URL Hash & ignoring query params * @param {string} url URL to extract the hash from * @returns {string} Just the route id */ keepHashFromUrl(url) { if (!url) return '' return '#' + url.replace(/^.*#(.*)/, '$1').replace(/\?.*$/, '') } log() { log(...arguments)() } /** Makes a null or non-object into an object. If thing is already an object. * If not null, moves "thing" to {payload:thing} * @param {*} thing Thing to check * @param {string} [property] property that "thing" is moved to if not null and not an object. Default='payload' * @returns {!object} _ */ makeMeAnObject(thing, property) { if (!property) property = 'payload' if (typeof property !== 'string') { log('warn', 'uibuilder:makeMeAnObject', `WARNING: property parameter must be a string and not: ${typeof property}`)() property = 'payload' } let out = {} if ( thing !== null && thing.constructor.name === 'Object' ) { out = thing } else if (thing !== null) { out[property] = thing } return out } // --- End of make me an object --- // /** Navigate to a new page or a new route (hash) * @param {string} url URL to navigate to. Can be absolute or relative (to current page) or just a hash for a route change * @returns {Location} The new window.location string */ navigate(url) { if (url) window.location.href = url return window.location } // ! TODO change ui uib-* attributes to use this /** Convert a string attribute into an variable/constant reference * Used to resolve data sources in attributes * @param {string} path The string path to resolve, must be relative to the `window` global scope * @returns {*} The resolved data source or null */ resolveDataSource(path) { try { const parts = path.split(/[\.\[\]\'\"]/).filter(Boolean) let data = window for (const part of parts) { data = data?.[part] } return data } catch (error) { // console.error('Error resolving data source:', error) log('error', 'uibuilder:resolveDataSource', `Error resolving data source "${path}", returned 'null'. ${error.message}`)() return null } } /** Fast but accurate number rounding (https://stackoverflow.com/a/48764436/1309986 solution 2) * Half away from zero method (AKA "commercial" rounding), most common type * @param {number} num The number to be rounded * @param {number} decimalPlaces Number of DP's to round to * @returns {number} Rounded number */ round(num, decimalPlaces) { const p = Math.pow(10, decimalPlaces || 0) const n = (num * p) * (1 + Number.EPSILON) return Math.round(n) / p } /** Set the default originator. Set to '' to ignore. Used with uib-sender. * @param {string} [originator] A Node-RED node ID to return the message to */ setOriginator(originator = '') { this.set('originator', originator) } // ---- End of setOriginator ---- // /** HTTP Ping/Keep-alive - makes a call back to uibuilder's ExpressJS server and receives a 204 response * Can be used to keep sessions alive. * @example * uibuilder.setPing(2000) // repeat every 2 sec. Re-issue with ping(0) to turn off repeat. * uibuilder.onChange('ping', function(data) { * console.log('pinger', data) * }) * @param {number} ms Repeat interval in ms */ setPing(ms = 0) { const oReq = new XMLHttpRequest() oReq.addEventListener('load', () => { const headers = (oReq.getAllResponseHeaders()).split('\r\n') const elapsedTime = Number(new Date()) - Number((oReq.responseURL.split('='))[1]) this.set('ping', { success: !!((oReq.status === 201) || (oReq.status === 204)), // true if one of the listed codes else false status: oReq.status, headers: headers, url: oReq.responseURL, elapsedTime: elapsedTime, }) }) if (this.#pingInterval) { clearInterval(this.#pingInterval) this.#pingInterval = undefined } oReq.open('GET', `${this.httpNodeRoot}/uibuilder/ping?t=${Number(new Date())}`) oReq.send() if (ms > 0) { this.#pingInterval = setInterval(() => { oReq.open('GET', `${this.httpNodeRoot}/uibuilder/ping?t=${Number(new Date())}`) oReq.send() }, ms) } } // ---- End of ping ---- // /** Convert JSON to Syntax Highlighted HTML * @param {object} json A JSON/JavaScript Object * @returns {html} Object reformatted as highlighted HTML */ syntaxHighlight(json) { return syntaxHighlight(json) } // --- End of syntaxHighlight --- // /** Returns true/false or a default value for truthy/falsy and other values * @param {string|number|boolean|*} val The value to test * @param {any} deflt Default value to use if the value is not truthy/falsy * @returns {boolean|any} The truth! Or the default */ truthy(val, deflt) { let ret if (['on', 'On', 'ON', 'true', 'True', 'TRUE', '1', true, 1].includes(val)) ret = true else if (['off', 'Off', 'OFF', 'false', 'False', 'FALSE', '0', false, 0].includes(val)) ret = false else ret = deflt return ret } /** Joins all arguments as a URL string * see http://stackoverflow.com/a/28592528/3016654 * since v1.0.10, fixed potential double // issue * arguments {string} URL fragments * @returns {string} _ */ urlJoin() { const paths = Array.prototype.slice.call(arguments) const url = '/' + paths.map(function (e) { return e.replace(/^\/|\/$/g, '') }) .filter(function (e) { return e }) .join('/') return url.replace('//', '/') } // ---- End of urlJoin ---- // /** Turn on/off/toggle sending URL hash changes back to Node-RED * @param {string|number|boolean|undefined} [toggle] Optional on/off/etc * @returns {boolean} True if we will send a msg to Node-RED on a hash change */ watchUrlHash(toggle) { this.#sendUrlHash = this.truthy(toggle, this.#sendUrlHash !== true) return this.#sendUrlHash } /** DEPRECATED FOR NOW - wasn't working properly. * Is the chosen CSS Selector currently visible to the user? NB: Only finds the FIRST element of the selection. * Requires IntersectionObserver (available to all mainstream browsers from early 2019) * Automatically sends a msg back to Node-RED. * Requires the element to already exist. * @returns {false} False if not visible */ elementIsVisible() { const info = 'elementIsVisible has been temporarily DEPRECATED as it was not working correctly and a fix is complex' log('error', 'uib:elementIsVisible', info)() this.send({ payload: 'elementIsVisible has been temporarily DEPRECATED as it was not working correctly and a fix is complex', }) return false } // --- End of elementIsVisible --- // //#endregion -------- -------- -------- // //#region ------- UI handlers --------- // //#region -- Direct to _ui -- // ! NOTE: Direct assignments change the target `this` to here. Use with caution // However, also note that the window/jsdom and the window.document // references are now static in _ui so not impacted by this. /** Simplistic jQuery-like document CSS query selector, returns an HTML Element * NOTE that this fn returns the element itself. Use $$ to get the properties of 1 or more elements. * If the selected element is a <template>, returns the first child element. * type {HTMLElement} * @param {string} cssSelector A CSS Selector that identifies the element to return * @returns {HTMLElement|null} Selected HTML element or null */ $ = _ui.$ /** CSS query selector that returns ALL found selections. Matches the Chromium DevTools feature of the same name. * NOTE that this fn returns an array showing the PROPERTIES of the elements whereas $ returns the element itself * @param {string} cssSelector A CSS Selector that identifies the elements to return * @returns {HTMLElement[]} Array of DOM elements/nodes. Array is empty if selector is not found. */ $$ = _ui.$$ /** Reference to the full ui library */ $ui = _ui /** Add 1 or several class names to an element * @param {string|string[]} classNames Single or array of classnames * @param {HTMLElement} el HTML Element to add class(es) to */ addClass = _ui.addClass /** Apply a source template tag to a target html element * NOTES: * - styles in ALL templates are accessible to all templates. * - scripts in templates are run AT TIME OF APPLICATION (so may run multiple times). * - scripts in templates are applied in order of application, so variables may not yet exist if defined in subsequent templates * @param {HTMLElement} source The source element * @param {HTMLElement} target The target element * @param {boolean} onceOnly If true, the source will be adopted (the source is moved) */ applyTemplate = _ui.applyTemplate /** Column metadata object definition * @typedef columnDefinition * @property {number} index The column index number * @property {boolean} hasName Whether the column has a defined name or not * @property {string} title The title of the column. Shown in the table header row * @property {string=} name Optional. A defined column name that will be added as the `data-col-name` to all cells in the column if defined * @property {string|number=} key Optional. A key value (currently unused) * @property {"string"|"date"|"number"|"html"=} dataType FOR FUTURE USE. Optional. What type of data will this column contain? * @property {boolean=} editable FOR FUTURE USE. Optional. Can cells in this column be edited? */ /** Builds an HTML table from an array (or object) of objects * 1st row is used for columns. * If an object of objects, inner keys are used to populate th/td `data-col-name` attribs. * @param {Array<object>|object} data Input data array or object * @param {object} opts Table options * @param {Array<columnDefinition>=} opts.cols Column metadata. If not provided will be derived from 1st row of data * @returns {HTMLTableElement|HTMLParagraphElement} Output HTML Element */ buildHtmlTable(data, opts={}) { return _ui.buildHtmlTable(data, opts) } /** Directly add a table to a parent element. * @param {Array<object>|Array<Array>|object} data Input data array or object. Object of objects gives named rows. Array of objects named cols. Array of arrays no naming. * @param {object} [opts] Build options * @param {Array<columnDefinition>=} opts.cols Column metadata. If not provided will be derived from 1st row of data * @param {HTMLElement|string} opts.parent Default=body. The table will be added as a child instead of returned. May be an actual HTML element or a CSS Selector * @param {boolean=} opts.allowHTML Optional, default=false. If true, allows HTML cell content, otherwise only allows text. Always sanitise HTML inputs */ createTable(data=[], opts={parent: 'body',}) { _ui.createTable(data, opts) } /** Converts markdown text input to HTML if the Markdown-IT library is loaded * Otherwise simply returns the text * @param {string} mdText The input markdown string * @returns {string} HTML (if Markdown-IT library loaded and parse successful) or original text */ convertMarkdown(mdText) { return _ui.convertMarkdown(mdText) } /** ASYNC: Include HTML fragment, img, video, text, json, form data, pdf or anything else from an external file or API * Wraps the included object in a div tag. * PDF's, text or unknown MIME types are also wrapped in an iFrame. * @param {string} url The URL of the source file to include * @param {object} uiOptions Object containing properties recognised by the _uiReplace function. Must at least contain an id * param {string} uiOptions.id The HTML ID given to the wrapping DIV tag * param {string} uiOptions.parentSelector The CSS selector for a parent element to insert the new HTML under (defaults to 'body') */ async include(url, uiOptions) { await _ui.include(url, uiOption