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,181 lines (1,032 loc) 86 kB
// @ts-nocheck /* Creates HTML UI's based on a standardised data input. Works stand-alone, with uibuilder or with Node.js/jsdom. See: https://totallyinformation.github.io/node-red-contrib-uibuilder/#/client-docs/config-driven-ui Author: Julian Knight (Totally Information), March 2023 License: Apache 2.0 Copyright (c) 2022-2025 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. */ // Namespaces - See https://stackoverflow.com/a/52572048/1309986 // const NAMESPACES = { // svg: 'http://www.w3.org/2000/svg', // html: 'http://www.w3.org/1999/xhtml', // xml: 'http://www.w3.org/XML/1998/namespace', // xlink: 'http://www.w3.org/1999/xlink', // xmlns: 'http://www.w3.org/2000/xmlns/' // sic for the final slash... // } const Ui = class Ui { //#region --- Class variables --- version = '7.2.0-src' // List of tags and attributes not in sanitise defaults but allowed in uibuilder. sanitiseExtraTags = ['uib-var'] sanitiseExtraAttribs = ['variable', 'report', 'undefined'] /** Reference to DOM window - must be passed in the constructor * Allows for use of this library/class with `jsdom` in Node.JS as well as the browser. * @type {Window} */ static win /** Reference to the DOM top-level window.document for convenience - set in constructor @type {Document} */ static doc /** Log function - passed in constructor or will be a dummy function * @type {Function} */ static log /** Options for Markdown-IT if available (set in constructor) */ static mdOpts /** Reference to pre-loaded Markdown-IT library */ static md /** Optional Markdown-IT Plugins */ ui_md_plugins //#endregion --- class variables --- /** Called when `new Ui(...)` is called * @param {globalThis} win Either the browser global window or jsdom dom.window * @param {Function} [extLog] A function that returns a function for logging * @param {Function} [jsonHighlight] A function that returns a highlighted HTML of JSON input */ constructor(win, extLog, jsonHighlight) { // window must be passed in as an arg to the constructor // Should either be the global window for a browser or `dom.window` for jsdom in Node.js // @ts-ignore if (win) Ui.win = win else { // Ui.log(0, 'Ui:constructor', 'Current environment does not include `window`, UI functions cannot be used.')() // return throw new Error('Ui:constructor. Current environment does not include `window`, UI functions cannot be used.') } // For convenience Ui.doc = Ui.win.document // If a suitable function not passed in, create a dummy one if (extLog) Ui.log = extLog else Ui.log = function() { return function() {} } // If a JSON HTML highlighting function passed then use it, else a dummy fn if (jsonHighlight) this.syntaxHighlight = jsonHighlight else this.syntaxHighlight = function() {} // If Markdown-IT pre-loaded, then configure it now if (Ui.win['markdownit']) { Ui.mdOpts = { html: true, xhtmlOut: false, linkify: true, _highlight: true, _strict: false, _view: 'html', langPrefix: 'language-', // NB: the highlightjs (hljs) library must be loaded before markdown-it for this to work highlight: function(str, lang) { // https://highlightjs.org if (lang && window['hljs'] && window['hljs'].getLanguage(lang)) { try { return `<pre class=""> <code class="hljs border">${window['hljs'].highlight(str, { language: lang, ignoreIllegals: true, }).value}</code></pre>` } finally { } // eslint-disable-line no-empty } return `<pre class="hljs border"><code>${Ui.md.utils.escapeHtml(str).trim()}</code></pre>` }, } Ui.md = Ui.win['markdownit'](Ui.mdOpts) } } //#region ---- Internal Methods ---- _markDownIt() { // If Markdown-IT pre-loaded, then configure it now if (!Ui.win['markdownit']) return // If plugins not yet defined, check if uibuilder has set them if (!this.ui_md_plugins && Ui.win['uibuilder'] && Ui.win['uibuilder'].ui_md_plugins) this.ui_md_plugins = Ui.win['uibuilder'].ui_md_plugins Ui.mdOpts = { html: true, xhtmlOut: false, linkify: true, _highlight: true, _strict: false, _view: 'html', langPrefix: 'language-', // NB: the highlightjs (hljs) library must be loaded before markdown-it for this to work highlight: function(str, lang) { if (window['hljs']) { if (lang && window['hljs'].getLanguage(lang)) { try { return `<pre><code class="hljs border language-${lang}" data-language="${lang}" title="Source language: '${lang}'">${window['hljs'].highlight(str, { language: lang, ignoreIllegals: true, }).value}</code></pre>` } finally { } // eslint-disable-line no-empty } else { try { const high = window['hljs'].highlightAuto(str) return `<pre><code class="hljs border language-${high.language}" data-language="${high.language}" title="Source language estimated by HighlightJS: '${high.language}'">${high.value}</code></pre>` } finally { } // eslint-disable-line no-empty } } return `<pre><code class="border">${Ui.md.utils.escapeHtml(str).trim()}</code></pre>` }, } Ui.md = Ui.win['markdownit'](Ui.mdOpts) // Ui.md.use(Ui.win.markdownitTaskLists, {enabled: true}) if (this.ui_md_plugins) { if (!Array.isArray(this.ui_md_plugins)) { Ui.log('error', 'Ui:_markDownIt:plugins', 'Could not load plugins, ui_md_plugins is not an array')() return } this.ui_md_plugins.forEach( plugin => { if (typeof plugin === 'string') { Ui.md.use(Ui.win[plugin]) } else { const name = Object.keys(plugin)[0] Ui.md.use(Ui.win[name], plugin[name]) } }) } } /** Show a browser notification if the browser and the user allows it * @param {object} config Notification config data * @returns {Promise} Resolves on close or click event, returns the event. */ _showNotification(config) { if ( config.topic && !config.title ) config.title = config.topic if ( !config.title ) config.title = 'uibuilder notification' if ( config.payload && !config.body ) config.body = config.payload if ( !config.body ) config.body = ' No message given.' // Wrap in try/catch since Chrome Android may throw an error try { const notify = new Notification(config.title, config) return new Promise( (resolve, reject) => { // Doesn't ever seem to fire (at least in Chromium) notify.addEventListener('close', ev => { // @ts-ignore ev.currentTarget.userAction = 'close' resolve(ev) }) notify.addEventListener('click', ev => { // @ts-ignore ev.currentTarget.userAction = 'click' resolve(ev) }) notify.addEventListener('error', ev => { // @ts-ignore ev.currentTarget.userAction = 'error' reject(ev) }) }) } catch (e) { return Promise.reject(new Error('Browser refused to create a Notification')) } } // Vue dynamic inserts Don't really work ... // _uiAddVue(ui, isRecurse) { // // must be Vue // // must have only 1 root element // const compToAdd = ui.components[0] // const newEl = Ui.doc.createElement(compToAdd.type) // if (!compToAdd.slot && ui.payload) compToAdd.slot = ui.payload // this._uiComposeComponent(newEl, compToAdd) // // If nested components, go again - but don't pass payload to sub-components // if (compToAdd.components) { // this._uiExtendEl(newEl, compToAdd.components) // } // console.log('MAGIC: ', this.magick, newEl, newEl.outerHTML)() // this.set('magick', newEl.outerHTML) // // if (compToAdd.id) newEl.setAttribute('ref', compToAdd.id) // // if (elParent.id) newEl.setAttribute('data-parent', elParent.id) // } // TODO Add check if ID already exists // TODO Allow single add without using components array /** Handle incoming msg._ui add requests * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object * @param {boolean} isRecurse Is this a recursive call? */ _uiAdd(ui, isRecurse) { Ui.log('trace', 'Ui:_uiManager:add', 'Starting _uiAdd')() // Vue dynamic inserts Don't really work ... // if (this.#isVue && !isRecurse) { // this._uiAddVue(ui, false) // return // } ui.components.forEach((compToAdd, i) => { Ui.log('trace', `Ui:_uiAdd:components-forEach:${i}`, 'Component to add: ', compToAdd)() /** @type {*} Create the new component - some kind of HTML element */ let newEl switch (compToAdd.type) { // If trying to insert raw html, wrap in a div case 'html': { compToAdd.ns = 'html' newEl = Ui.doc.createElement('div') break } // If trying to insert raw svg, need to create in namespace case 'svg': { compToAdd.ns = 'svg' newEl = Ui.doc.createElementNS('http://www.w3.org/2000/svg', 'svg') break } default: { compToAdd.ns = 'dom' newEl = Ui.doc.createElement(compToAdd.type) break } } if (!compToAdd.slot && ui.payload) compToAdd.slot = ui.payload // const parser = new DOMParser() // const newDoc = parser.parseFromString(compToAdd.slot, 'text/html') // console.log(compToAdd, newDoc.body)() this._uiComposeComponent(newEl, compToAdd) /** @type {HTMLElement} Where to add the new element? */ let elParent if (compToAdd.parentEl) { elParent = compToAdd.parentEl } else if (ui.parentEl) { elParent = ui.parentEl } else if (compToAdd.parent) { elParent = Ui.doc.querySelector(compToAdd.parent) } else if (ui.parent) { elParent = Ui.doc.querySelector(ui.parent) } if (!elParent) { Ui.log('info', 'Ui:_uiAdd', 'No parent found, adding to body')() elParent = Ui.doc.querySelector('body') } if (compToAdd.position && compToAdd.position === 'first') { // Insert new el before the first child of the parent. Ref: https://developer.mozilla.org/en-US/docs/Web/API/Node/insertBefore#example_3 elParent.insertBefore(newEl, elParent.firstChild) } else if (compToAdd.position && Number.isInteger(Number(compToAdd.position))) { elParent.insertBefore(newEl, elParent.children[compToAdd.position]) } else { // Append to the required parent elParent.appendChild(newEl) } // If nested components, go again - but don't pass payload to sub-components if (compToAdd.components) { // this._uiAdd({ // method: ui.method, // parentEl: newEl, // components: compToAdd.components, // }, true) this._uiExtendEl(newEl, compToAdd.components, compToAdd.ns) } }) } // --- end of _uiAdd --- /** Enhance an HTML element that is being composed with ui data * such as ID, attribs, event handlers, custom props, etc. * @param {*} el HTML Element to enhance * @param {*} comp Individual uibuilder ui component spec */ _uiComposeComponent(el, comp) { // Add attributes if (comp.attributes) { Object.keys(comp.attributes).forEach((attrib) => { if (attrib === 'class' && Array.isArray(comp.attributes[attrib])) comp.attributes[attrib].join(' ') Ui.log('trace', '_uiComposeComponent:attributes-forEach', `Attribute: '${attrib}', value: '${comp.attributes[attrib]}'`)() // For values, set the actual value as well since the attrib only changes the DEFAULT value if (attrib === 'value') el.value = comp.attributes[attrib] if (attrib.startsWith('xlink:')) el.setAttributeNS('http://www.w3.org/1999/xlink', attrib, comp.attributes[attrib]) else el.setAttribute(attrib, comp.attributes[attrib]) }) } // ID if set if (comp.id) el.setAttribute('id', comp.id) // If an SVG tag, ensure we have the appropriate namespaces added if (comp.type === 'svg') { el.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns', 'http://www.w3.org/2000/svg') el.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xlink', 'http://www.w3.org/1999/xlink') } // Add event handlers if (comp.events) { Object.keys(comp.events).forEach((type) => { // @ts-ignore I'm forever getting this wrong! if (type.toLowerCase === 'onclick') type = 'click' // Add the event listener try { el.addEventListener(type, (evt) => { // Use new Function to ensure that esbuild works: https://esbuild.github.io/content-types/#direct-eval (new Function('evt', `${comp.events[type]}(evt)`))(evt) }) // newEl.setAttribute( 'onClick', `${comp.events[type]}()` ) } catch (err) { Ui.log('error', 'Ui:_uiComposeComponent', `Add event '${type}' for element '${comp.type}': Cannot add event handler. ${err.message}`)() } }) } // Add custom properties to the dataset if (comp.properties) { Object.keys(comp.properties).forEach((prop) => { // TODO break a.b into sub properties el[prop] = comp.properties[prop] // Auto-dispatch events if changing value or changed since DOM does not do this automatically if (['value', 'checked'].includes(prop)) { el.dispatchEvent(new Event('input')) el.dispatchEvent(new Event('change')) } }) } //#region Add Slot content to innerHTML if (comp.slot) { this.replaceSlot(el, comp.slot) } //#endregion // TODO Add multi-slot capability (default slot must always be processed first as innerHTML is replaced) //#region Add Slot Markdown content to innerHTML IF marked library is available if (comp.slotMarkdown) { this.replaceSlotMarkdown(el, comp) } //#endregion } /** Extend an HTML Element with appended elements using ui components * NOTE: This fn follows a strict hierarchy of added components. * @param {HTMLElement} parentEl The parent HTML Element we want to append to * @param {*} components The ui component(s) we want to add * @param {string} [ns] Optional. The namespace to use. */ _uiExtendEl(parentEl, components, ns = '') { components.forEach((compToAdd, i) => { Ui.log('trace', `Ui:_uiExtendEl:components-forEach:${i}`, compToAdd)() /** @type {HTMLElement} Create the new component */ let newEl compToAdd.ns = ns if (compToAdd.ns === 'html') { newEl = parentEl // newEl.outerHTML = compToAdd.slot // parentEl.innerHTML = compToAdd.slot this.replaceSlot(parentEl, compToAdd.slot) } else if (compToAdd.ns === 'svg') { newEl = Ui.doc.createElementNS('http://www.w3.org/2000/svg', compToAdd.type) // Updates newEl this._uiComposeComponent(newEl, compToAdd) parentEl.appendChild(newEl) } else { newEl = Ui.doc.createElement(compToAdd.type === 'html' ? 'div' : compToAdd.type) // Updates newEl this._uiComposeComponent(newEl, compToAdd) parentEl.appendChild(newEl) } // If nested components, go again - but don't pass payload to sub-components if (compToAdd.components) { this._uiExtendEl(newEl, compToAdd.components, compToAdd.ns) } }) } // TODO Add more error handling and parameter validation /** Handle incoming _ui load requests * Can load JavaScript modules, JavaScript scripts and CSS. * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object */ _uiLoad(ui) { // Self-loading ECMA Modules (e.g. web components) if (ui.components) { if (!Array.isArray(ui.components)) ui.components = [ui.components] ui.components.forEach(async component => { // NOTE: This happens asynchronously but we don't wait import(component) }) } // Remote Scripts if (ui.srcScripts) { if (!Array.isArray(ui.srcScripts)) ui.srcScripts = [ui.srcScripts] ui.srcScripts.forEach(script => { this.loadScriptSrc(script) }) } // Scripts passed as text if (ui.txtScripts) { if (!Array.isArray(ui.txtScripts)) ui.txtScripts = [ui.txtScripts] this.loadScriptTxt(ui.txtScripts.join('\n')) } // Remote Stylesheets if (ui.srcStyles) { if (!Array.isArray(ui.srcStyles)) ui.srcStyles = [ui.srcStyles] ui.srcStyles.forEach(sheet => { this.loadStyleSrc(sheet) }) } // Styles passed as text if (ui.txtStyles) { if (!Array.isArray(ui.txtStyles)) ui.txtStyles = [ui.txtStyles] this.loadStyleTxt(ui.txtStyles.join('\n')) } } // --- end of _uiLoad --- /** Handle incoming _ui messages and loaded UI JSON files * Called from start() * @param {*} msg Standardised msg object containing a _ui property object */ _uiManager(msg) { if (!msg._ui) return // Make sure that _ui is an array if (!Array.isArray(msg._ui)) msg._ui = [msg._ui] msg._ui.forEach((ui, i) => { if (ui.mode && !ui.method) ui.method = ui.mode if (!ui.method) { Ui.log('error', 'Ui:_uiManager', `No method defined for msg._ui[${i}]. Ignoring. `, ui)() return } ui.payload = msg.payload ui.topic = msg.topic switch (ui.method) { case 'add': { this._uiAdd(ui, false) break } case 'remove': { this._uiRemove(ui, false) break } case 'removeAll': { this._uiRemove(ui, true) break } case 'replace': { this._uiReplace(ui) break } case 'update': { this._uiUpdate(ui) break } case 'load': { this._uiLoad(ui) break } case 'reload': { this._uiReload() break } case 'notify': { this.showDialog('notify', ui, msg) break } case 'alert': { this.showDialog('alert', ui, msg) break } default: { Ui.log('error', 'Ui:_uiManager', `Invalid msg._ui[${i}].method (${ui.method}). Ignoring`)() break } } }) } // --- end of _uiManager --- /** Handle a reload request */ _uiReload() { Ui.log('trace', 'Ui:uiManager:reload', 'reloading')() location.reload() } // TODO Add better tests for failures (see comments) /** Handle incoming _ui remove requests * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object * @param {boolean} all Optional, default=false. If true, will remove ALL found elements, otherwise only the 1st is removed */ _uiRemove(ui, all = false) { ui.components.forEach((compToRemove) => { let els if (all !== true) els = [Ui.doc.querySelector(compToRemove)] else els = Ui.doc.querySelectorAll(compToRemove) els.forEach(el => { try { el.remove() } catch (err) { // Could not remove. Cannot read properties of null <= no need to report this one // Could not remove. Failed to execute 'querySelector' on 'Ui.doc': '##testbutton1' is not a valid selector Ui.log('trace', 'Ui:_uiRemove', `Could not remove. ${err.message}`)() } }) }) } // --- end of _uiRemove --- /** Handle incoming _ui replace requests * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object */ _uiReplace(ui) { Ui.log('trace', 'Ui:_uiReplace', 'Starting')() ui.components.forEach((compToReplace, /** @type {number} */ i) => { Ui.log('trace', `Ui:_uiReplace:components-forEach:${i}`, 'Component to replace: ', compToReplace)() /** @type {HTMLElement} */ let elToReplace // Either the id, CSS selector, name or type (element type) must be given in order to identify the element to change. FIRST element matching is updated. if (compToReplace.id) { elToReplace = Ui.doc.getElementById(compToReplace.id) // .querySelector(`#${compToReplace.id}`) } else if (compToReplace.selector || compToReplace.select) { elToReplace = Ui.doc.querySelector(compToReplace.selector) } else if (compToReplace.name) { elToReplace = Ui.doc.querySelector(`[name="${compToReplace.name}"]`) } else if (compToReplace.type) { elToReplace = Ui.doc.querySelector(compToReplace.type) } Ui.log('trace', `Ui:_uiReplace:components-forEach:${i}`, 'Element to replace: ', elToReplace)() // Nothing was found so ADD the element instead if (elToReplace === undefined || elToReplace === null) { Ui.log('trace', `Ui:_uiReplace:components-forEach:${i}:noReplace`, 'Cannot find the DOM element. Adding instead.', compToReplace)() this._uiAdd({ components: [compToReplace], }, false) return } /** @type {*} Create the new component - some kind of HTML element */ let newEl switch (compToReplace.type) { // If trying to insert raw html, wrap in a div case 'html': { compToReplace.ns = 'html' newEl = Ui.doc.createElement('div') break } // If trying to insert raw svg, need to create in namespace case 'svg': { compToReplace.ns = 'svg' newEl = Ui.doc.createElementNS('http://www.w3.org/2000/svg', 'svg') break } default: { compToReplace.ns = 'dom' newEl = Ui.doc.createElement(compToReplace.type) break } } // Updates the newEl and maybe the ui this._uiComposeComponent(newEl, compToReplace) // Replace the current element elToReplace.replaceWith(newEl) // If nested components, go again - but don't pass payload to sub-components if (compToReplace.components) { this._uiExtendEl(newEl, compToReplace.components, compToReplace.ns) } }) } // --- end of _uiReplace --- // TODO Allow single add without using components array // TODO Allow sub-components // TODO Add multi-slot capability /** Handle incoming _ui update requests * @param {*} ui Standardised msg._ui property object. Note that payload and topic are appended to this object */ _uiUpdate(ui) { Ui.log('trace', 'UI:_uiUpdate:update', 'Starting _uiUpdate', ui)() // We allow an update not to actually need to spec a component if (!ui.components) ui.components = [Object.assign({}, ui)] ui.components.forEach((compToUpd, i) => { Ui.log('trace', '_uiUpdate:components-forEach', `Start loop #${i}`, compToUpd)() /** @type {NodeListOf<Element>} */ let elToUpd // If a parent element is passed, use that as the update target (only allowed internally) // Otherwise either the id, CSS selector, name or type (element type) must be given in order to identify the element to change. ALL elements matching are updated. if (compToUpd.parentEl) { elToUpd = compToUpd.parentEl } else if (compToUpd.id) { // NB We don't use get by id because this way the code is simpler later on elToUpd = Ui.doc.querySelectorAll(`#${compToUpd.id}`) } else if (compToUpd.selector || compToUpd.select) { elToUpd = Ui.doc.querySelectorAll(compToUpd.selector) } else if (compToUpd.name) { elToUpd = Ui.doc.querySelectorAll(`[name="${compToUpd.name}"]`) } else if (compToUpd.type) { elToUpd = Ui.doc.querySelectorAll(compToUpd.type) } // @ts-ignore Nothing was found so give up if (elToUpd === undefined || elToUpd.length < 1) { Ui.log('warn', 'Ui:_uiManager:update', 'Cannot find the DOM element. Ignoring.', compToUpd)() return } Ui.log('trace', '_uiUpdate:components-forEach', `Element(s) to update. Count: ${elToUpd.length}`, elToUpd)() // If slot not specified but payload is, use the payload in the slot if (!compToUpd.slot && compToUpd.payload) compToUpd.slot = compToUpd.payload // Might have >1 element to update - so update them all elToUpd.forEach((el, j) => { Ui.log('trace', '_uiUpdate:components-forEach', `Updating element #${j}`, el)() this._uiComposeComponent(el, compToUpd) // Try to go down another level of nesting if needed // ! NOT CONVINCED THIS ACTUALLY WORKS ! if (compToUpd.components) { Ui.log('trace', '_uiUpdate:nested-component', `Element #${j} - nested-component`, compToUpd, el)() const nc = { _ui: [], } compToUpd.components.forEach((nestedComp, k) => { const method = nestedComp.method || compToUpd.method || ui.method if (nestedComp.method) delete nestedComp.method if (!Array.isArray(nestedComp)) nestedComp = [nestedComp] // nestedComp.parentEl = el // nestedComp.components = [nestedComp] Ui.log('trace', '_uiUpdate:nested-component', `Element #${j} - nested-component #${k}`, nestedComp)() nc._ui.push( { method: method, parentEl: el, components: nestedComp, }) }) Ui.log('trace', '_uiUpdate:nested-component', `Element #${j} - nested-component new manager`, nc)() this._uiManager(nc) } }) // If nested components, apply to every found element - but don't pass payload to sub-components // if (compToUpd.components) { // compToUpd.components.forEach((el, k) => { // Ui.log('trace', '_uiUpdate:nested-component', `Updating nested-component #${k}`, el)() // this._uiUpdate({ // method: el.method || ui.method, // parentEl: el, // components: el.components, // }) // }) // } }) } // --- end of _uiUpdate --- //#endregion ---- -------- ---- //#region ---- External Methods ---- /** 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 * @param {"el"|"text"|"html"|"attributes"|"attr"} [output] Optional. What type of output to return. Defaults to "el", the DOM element reference * @param {HTMLElement} [context] Optional. The context to search within. Defaults to the document. Must be a DOM element. * @returns {HTMLElement|string|Array|null} Selected HTML DOM element, innerText, innerHTML, attribute list or null */ $(cssSelector, output, context) { if (!context) context = Ui.doc if (!output) output = 'el' // if context is not a valid htmlelement, return null if (!context || !context.nodeType) { Ui.log(1, 'Uib:$', `Invalid context element. Must be a valid HTML element.`, context)() return null } /** @type {HTMLElement} Some kind of HTML element */ let el = (context).querySelector(cssSelector) // if no element found or is not a valid htmlelement, return null if (!el || !el.nodeType) { Ui.log(1, 'Uib:$', `No element found or element is not an HTML element for CSS selector ${cssSelector}`)() return null } if ( el.nodeName === 'TEMPLATE' ) { el = el.content.firstElementChild if (!el) { Ui.log(0, 'Uib:$', `Template selected for CSS selector ${cssSelector} but it is empty`)() return null } } let out try { switch (output.toLowerCase()) { case 'text': { out = el.innerText break } case 'html': { out = el.innerHTML break } case 'attr': case 'attributes': { out = {} for (const attr of el.attributes) { out[attr.name] = attr.value } break } default: { out = el break } } } catch (e) { out = el Ui.log(1, 'Uib:$', `Could not process output type "${output}" for CSS selector ${cssSelector}, returned the DOM element. ${e.message}`, e)() } return out } /** 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 * @param {HTMLElement} [context] Optional. The context to search within. Defaults to the document. Must be a DOM element. * @returns {HTMLElement[]} Array of DOM elements/nodes. Array is empty if selector is not found. */ $$(cssSelector, context) { if (!context) context = Ui.doc // if context is not a valid htmlelement, return null if (!context || !context.nodeType) { Ui.log(1, 'Uib:$$', `Invalid context element. Must be a valid HTML element.`, context)() return null } return Array.from((context).querySelectorAll(cssSelector)) } /** 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(classNames, el) { if (!Array.isArray(classNames)) classNames = [classNames] if (el) el.classList.add(...classNames) } /** Apply a source template tag to a target html element * NOTES: * - Any attributes are only applied to the 1ST ELEMENT of the template content. Use a wrapper div if you need to apply to multiple elements. * - When using 'wrap' mode, the target content is placed into the template's 1ST <slot> only (if present). * - styles in ALL templates are accessible to all templates & impact the whole page. * - 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 {string} sourceId The HTML ID of the source element * @param {string} targetId The HTML ID of the target element * @param {object} config Configuration options * @param {boolean=} config.onceOnly If true, the source will be adopted (the source is moved) * @param {object=} config.attributes A set of key:value pairs that will be applied as attributes to the 1ST ELEMENT ONLY of the target * @param {'insert'|'replace'|'wrap'} config.mode How to apply the template. Default is 'insert'. 'replace' will replace the targets innerHTML. 'wrap' is like 'replace' but will put any target content into the template's 1ST <slot> (if present). */ applyTemplate(sourceId, targetId, config) { if (!config) config = {} if (!config.onlyOnce) config.onlyOnce = false if (!config.mode) config.mode = 'insert' const template = Ui.doc.getElementById(sourceId) if (!template || template.tagName !== 'TEMPLATE') { Ui.log('error', 'Ui:applyTemplate', `Source must be a <template>. id='${sourceId}'`)() return } const target = Ui.doc.getElementById(targetId) if (!target) { Ui.log('error', 'Ui:applyTemplate', `Target not found: id='${targetId}'`)() return } const targetContent = target.innerHTML ?? '' if (targetContent && config.mode === 'replace') { Ui.log('warn', 'Ui:applyTemplate', `Target element is not empty, content is replaced. id='${targetId}'`)() } let templateContent if (config.onceOnly === true) templateContent = Ui.doc.adoptNode(template.content) // NB content.childElementCount = 0 after adoption else templateContent = Ui.doc.importNode(template.content, true) if (templateContent) { // Apply config.attributes to the 1ST ELEMENT ONLY of the template content if (config.attributes) { const el = templateContent.firstElementChild Object.keys(config.attributes).forEach( attrib => { // Apply each attribute and value el.setAttribute(attrib, config.attributes[attrib]) }) } if (config.mode === 'insert') { target.appendChild(templateContent) } else if (config.mode === 'replace') { target.innerHTML = '' target.appendChild(templateContent) } else if (config.mode === 'wrap') { target.innerHTML = '' target.appendChild(templateContent) if (targetContent) { const slot = target.getElementsByTagName('slot') if (slot.length > 0) { slot[0].innerHTML = targetContent } } } } else { Ui.log('warn', 'Ui:applyTemplate', `No valid content found in template`)() } } /** 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) { if (!mdText) return '' if (!Ui.win['markdownit']) return mdText if (!Ui.md) this._markDownIt() // To handle case where the library is late loaded // Convert from markdown to HTML try { return Ui.md.render(mdText.trim()) } catch (e) { Ui.log(0, 'uibuilder:convertMarkdown', `Could not render Markdown. ${e.message}`, e)() return '<p class="border error">Could not render Markdown<p>' } } /** 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') * @returns {Promise<any>} Status */ async include(url, uiOptions) { // TODO: src, id, parent must all be a strings if (!fetch) { Ui.log(0, 'Ui:include', 'Current environment does not include `fetch`, skipping.')() return 'Current environment does not include `fetch`, skipping.' } if (!url) { Ui.log(0, 'Ui:include', 'url parameter must be provided, skipping.')() return 'url parameter must be provided, skipping.' } if (!uiOptions || !uiOptions.id) { Ui.log(0, 'Ui:include', 'uiOptions parameter MUST be provided and must contain at least an `id` property, skipping.')() return 'uiOptions parameter MUST be provided and must contain at least an `id` property, skipping.' } // Try to get the content via the URL let response try { response = await fetch(url) } catch (error) { Ui.log(0, 'Ui:include', `Fetch of file '${url}' failed. `, error.message)() return error.message } if (!response.ok) { Ui.log(0, 'Ui:include', `Fetch of file '${url}' failed. Status='${response.statusText}'`)() return response.statusText } // Work out what type of data we got const contentType = await response.headers.get('content-type') let type = null if (contentType) { if (contentType.includes('text/html')) { type = 'html' } else if (contentType.includes('application/json')) { type = 'json' } else if (contentType.includes('multipart/form-data')) { type = 'form' } else if (contentType.includes('image/')) { type = 'image' } else if (contentType.includes('video/')) { type = 'video' } else if (contentType.includes('application/pdf')) { type = 'pdf' } else if (contentType.includes('text/plain')) { type = 'text' } // else type = null } // Create the HTML to include on the page based on type let slot = '' let txtReturn = 'Include successful' let data switch (type) { case 'html': { data = await response.text() slot = data break } case 'json': { data = await response.json() slot = '<pre class="syntax-highlight">' slot += this.syntaxHighlight(data) slot += '</pre>' break } case 'form': { data = await response.formData() slot = '<pre class="syntax-highlight">' slot += this.syntaxHighlight(data) slot += '</pre>' break } case 'image': { data = await response.blob() slot = `<img src="${URL.createObjectURL(data)}">` if (Ui.win['DOMPurify']) { txtReturn = 'Include successful. BUT DOMPurify loaded which may block its use.' Ui.log('warn', 'Ui:include:image', txtReturn)() } break } case 'video': { data = await response.blob() slot = `<video controls autoplay><source src="${URL.createObjectURL(data)}"></video>` if (Ui.win['DOMPurify']) { txtReturn = 'Include successful. BUT DOMPurify loaded which may block its use.' Ui.log('warn', 'Ui:include:video', txtReturn)() } break } case 'pdf': case 'text': default: { data = await response.blob() slot = `<iframe style="resize:both;width:inherit;height:inherit;" src="${URL.createObjectURL(data)}">` if (Ui.win['DOMPurify']) { txtReturn = 'Include successful. BUT DOMPurify loaded which may block its use.' Ui.log('warn', `Ui:include:${type}`, txtReturn)() } break } } // Wrap it all in a <div id="..." class="included"> uiOptions.type = 'div' uiOptions.slot = slot if (!uiOptions.parent) uiOptions.parent = 'body' if (!uiOptions.attributes) uiOptions.attributes = { class: 'included', } // Use uibuilder's standard ui processing to turn the instructions into HTML this._uiReplace({ components: [ uiOptions ], }) Ui.log('trace', `Ui:include:${type}`, txtReturn)() return txtReturn } // ---- End of include() ---- // /** 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) { const newScript = Ui.doc.createElement('script') newScript.src = url newScript.async = false Ui.doc.head.appendChild(newScript) } /** 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) { const newScript = Ui.doc.createElement('script') newScript.async = false newScript.textContent = textFn Ui.doc.head.appendChild(newScript) } /** 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) { const newStyle = Ui.doc.createElement('link') newStyle.href = url newStyle.rel = 'stylesheet' newStyle.type = 'text/css' Ui.doc.head.appendChild(newStyle) } /** 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) { const newStyle = Ui.doc.createElement('style') newStyle.textContent = textFn Ui.doc.head.appendChild(newStyle) } /** Load a dynamic UI from a JSON web reponse * @param {string} url URL that will return the ui JSON */ loadui(url) { if (!fetch) { Ui.log(0, 'Ui:loadui', 'Current environment does not include `fetch`, skipping.')() return } if (!url) { Ui.log(0, 'Ui:loadui', 'url parameter must be provided, skipping.')() return } fetch(url) .then(response => { if (response.ok === false) { // Ui.log('warn', 'Ui:loadui:then1', `Could not load '${url}'. Status ${response.status}, Error: ${response.statusText}`)() throw new Error(`Could not load '${url}'. Status ${response.status}, Error: ${response.statusText}`) } Ui.log('trace', 'Ui:loadui:then1', `Loaded '${url}'. Status ${response.status}, ${response.statusText}`)() // Did we get json? const contentType = response.headers.get('content-type') if (!contentType || !contentType.includes('application/json')) { throw new TypeError(`Fetch '${url}' did not return JSON, ignoring`) } // Returns parsed json to next .then return response.json() }) .then(data => { if (data !== undefined) { Ui.log('trace', 'Ui:loadui:then2', 'Parsed JSON successfully obtained')() // Call the _uiManager this._uiManager({ _ui: data, }) return true } return false }) .catch(err => { Ui.log('warn', 'Ui:loadui:catch', 'Error. ', err)() }) } // --- end of loadui /** ! NOT COMPLETE Move an element from one position to another * @param {object} opts Options * @param {string} opts.sourceSelector Required, CSS Selector that identifies the element to be moved * @param {string} opts.targetSelector Required, CSS Selector that identifies the element to be moved */ moveElement(opts) { const { sourceSelector, targetSelector, moveType, position, } = opts const sourceEl = document.querySelector(sourceSelector) if (!sourceEl) { Ui.log(0, 'Ui:moveElement', 'Source element not found')() return } const targetEl = document.querySelector(targetSelector) if (!targetEl) { Ui.log(0, 'Ui:moveElement', 'Target element not found')() return } } /** Get standard data from a DOM node. * @param {*} node DOM node to examine * @param {string} cssSelector Identify the DOM element to get data from * @returns {object} Standardised data object */ nodeGet(node, cssSelector) { const thisOut = { id: node.id === '' ? undefined : node.id, name: node.name, children: node.childNodes.length, type: node.nodeName, attributes: undefined, isUserInput: node.validity ? true : false,