node-red-contrib-uibuilder
Version:
Easily create data-driven web UI's for Node-RED using any (or no) front-end library.
1,199 lines (1,056 loc) • 137 kB
JavaScript
// @ts-nocheck
/* This is the 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
Copyright (c) 2022-2024 Julian Knight (Totally Information)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
//#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'
import UibVar from '../components/uib-var'
import UibMeta from '../components/uib-meta'
import ApplyTemplate from '../components/apply-template'
const version = '7.0.4-src'
// TODO Add option to allow log events to be sent back to Node-RED as uib ctrl msgs
//#region --- Module-level utility functions --- //
// Detect whether the loaded library is minified or not
const isMinified = !(/param/).test(function (param) { })
//#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.prototype.slice.call(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 - Error */
log.default = 0
let ll
// Check if the script element was found and get the data-log-level attribute (only numeric levels allowed here)
try {
const 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, '&').replace(/</g, '<').replace(/>/g, '>') // 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) { // eslint-disable-line prefer-named-capture-group
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 {{[key: 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({ // eslint-disable-line n/no-callback-literal
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}"`
}
// 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, '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 {{[key: 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='payload'] property that "thing" is moved to if not null and not an object
* @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
}
/** 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 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
buildHtmlTable = _ui.buildHtmlTable
/** 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
}
/** 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, uiOptions)
}
/** Attach a new remote script to the end of HEAD synchronously
* NOTE: It takes too long for most scripts to finish loading
* so this is pretty useless to work with the dynamic UI features directly.
* @param {string} url The url to be used in the script src attribute
*/
loadScriptSrc(url) {
_ui.loadScriptSrc(url)
}
/** Attach a new remote stylesheet link to the end of HEAD synchronously
* NOTE: It takes too long for most scripts to finish loading
* so this is pretty useless to work with the dynamic UI features directly.
* @param {string} url The url to be used in the style link href attribute
*/
loadStyleSrc(url) {
_ui.loadStyleSrc(url)
}
/** Attach a new text script to the end of HEAD synchronously
* NOTE: It takes too long for most scripts to finish loading
* so this is pretty useless to work with the dynamic UI features directly.
* @param {string} textFn The text to be loaded as a script
*/
loadScriptTxt(textFn) {
_ui.loadScriptTxt(textFn)
}
/** Attach a new text stylesheet to the end of HEAD synchronously
* NOTE: It takes too long for most scripts to finish loading
* so this is pretty useless to work with the dynamic UI features directly.
* @param {string} textFn The text to be loaded as a stylesheet
*/
loadStyleTxt(textFn) {
_ui.loadStyleTxt(textFn)
}
/** Load a dynamic UI from a JSON web reponse
* @param {string} url URL that will return the ui JSON
*/
loadui(url) {
_ui.loadui(url)
}
/** Remove All, 1 or more class names from an element
* @param {undefined|null|""|string|string[]} classNames Single or array of classnames. If undefined, "" or null, remove all classes
* @param {HTMLElement} el HTML Element to add class(es) to
*/
removeClass = _ui.removeClass
/** Replace or add an HTML element's slot from text or an HTML string
* WARNING: Executes <script> tags! And will process <style> tags.
* Will use DOMPurify if that library has been loaded to window.
* param {*} ui Single entry from the msg._ui property
* @param {Element} el Reference to the element that we want to update
* @param {*} slot The slot content we are trying to add/replace (defaults to empty string)
*/
replaceSlot(el, slot) {
_ui.replaceSlot(el, slot)
}
/** Replace or add an HTML element's slot from a Markdown string
* Only does something if the markdownit library has been loaded to window.
* Will use DOMPurify if that library has been loaded to window.
* @param {Element} el Reference to the element that we want to update
* @param {*} component The component we are trying to add/replace
*/
replaceSlotMarkdown(el, component) {
_ui.replaceSlotMarkdown(el, component)
}
/** Sanitise HTML to make it safe - if the DOMPurify library is