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.

761 lines (674 loc) 35.9 kB
// @ts-nocheck 'use strict' mermaid.initialize({ startOnLoad: false, theme: 'dark', }) // eslint-disable-line no-undef window.$docsify = { loadSidebar: '.config/sidebar.md', name: 'UIBUILDER Documentation v7', repo: 'TotallyInformation/node-red-contrib-uibuilder', // coverpage: true, coverpage: '.config/coverpage.md', onlyCover: false, // homepage: 'README.md', executeScript: true, // loadNavbar: true, // mergeNavbar: true, autoHeader: false, logo: '/images/node-blue.svg', auto2top: true, alias: { // Moved pages '/dev/socket-js.*': '/dev/server-libs/socket.md', '/dev/tilib-js.*': '/dev/server-libs/tilib.md', '/dev/uiblib-js.*': '/dev/server-libs/uiblib.md', '/dev/web-js.*': '/dev/server-libs/web.md', '/roadmap/': '/roadmap/readme.md', '.*?/.config/(.*)': '/.config/$1', '.*?/images/(.*)': '/images/$1', '.*?/changelog': 'https://raw.githubusercontent.com/TotallyInformation/node-red-contrib-uibuilder/main/CHANGELOG.md', '.*?/uibhome': 'https://raw.githubusercontent.com/TotallyInformation/node-red-contrib-uibuilder/main/README.md', '/docs/(.*)': '/$1', }, subMaxLevel: 1, search: { depth: 3, noData: 'No results!', placeholder: 'Search...', }, pagination: { crossChapter: true, crossChapterText: true, }, // notFoundPage: true, notFoundPage: '.config/404.md', // toc: { // // tocMaxLevel: 5, // // target: 'h2, h3, h4, h5' // // -- -- // // scope: '.markdown-section', // // headings: 'h2, h3, h4, h5', // // title: 'Table of Contents', // // https://github.com/justintien/docsify-plugin-toc // tocMaxLevel: 3, // target: 'h2, h3', // ignoreHeaders: ['<!-- {docsify-ignore} -->', '<!-- {docsify-ignore-all} -->'], // }, mermaidConfig: { querySelector: '.mermaid', }, plugins: [ // Tips plugin - displays random or specific tips from tips folder function tipsPlugin(hook, vm) { const tipsCache = new Map() const tipsList = [] const rotatingTips = new Set() let rotateInterval = null const tipsFiles = [ 'Browser and Node-RED are different contexts.md', 'Compare uibuilder with Dashboard 2.md', 'Creating a Single Page App.md', 'Front-end templates.md', 'Messages to the UI are automatically filtered.md', 'No-code output is low-code.md', 'Offline clients.md', 'Send messages to Node-RED from the browser.md', 'Send to UI from a function node.md', 'uibuilder node outputs.md', 'Where are my files.md' ] // get current page url const currentPage = `${window.location.origin}${window.location.pathname}` // get current hash const currentHash = window.location.hash.replace('#/', '').split('/') currentHash.pop() // remove last element (the page) let prefix = '/' if (currentHash.length > 0) { prefix = '../'.repeat(currentHash.length) } const tipsFolder = `${prefix}tips/` /** Safely get a single tip file * @param {string} filename - The tip filename to load * @returns {string} The markdown content or error message */ const getSingleTipFile = (filename) => { try { const url = `${currentPage}/${tipsFolder}${encodeURIComponent(filename)}` // Use XMLHttpRequest for better browser compatibility const xhr = new XMLHttpRequest() xhr.open('GET', url, false) xhr.send() if (xhr.status === 200) { const content = xhr.responseText // console.log('Tips plugin: Loaded tip file', url, content) // Remove front matter and extract tip content return content.replace(/^---[\s\S]*?---\n/, '').trim() // console.log('Tips plugin: frontMatter?', window.$docsify.frontMatter.parseMarkdown(content)) // return content } return `**Tip Not Found**: Could not load tip file: "tips/${filename}"` } catch (error) { console.warn(`Failed to load tip file: "tips/${filename}"`, error) return `**Tip Not Found**: Could not load tip file: "tips/${filename}" (${error.message || 'Unknown error'})` } } // Load all tips from the tips folder const loadTips = async () => { if (tipsList.length > 0) return tipsList try { for (const file of tipsFiles) { if (!tipsCache.has(file)) { try { // Use XMLHttpRequest for better browser compatibility const xhr = new XMLHttpRequest() xhr.open('GET', `${tipsFolder}${encodeURIComponent(file)}`, false) xhr.send() if (xhr.status === 200) { const content = xhr.responseText // Remove front matter and extract tip content const tipContent = content.replace(/^---[\s\S]*?---\n/, '').trim() const tipTitle = file.replace('.md', '') tipsCache.set(file, { title: tipTitle, content: tipContent, }) tipsList.push({ file, title: tipTitle, content: tipContent, }) } } catch (error) { console.warn(`Failed to load tip: ${file}`, error) } } } } catch (error) { console.warn('Failed to load tips:', error) } return tipsList } /** Get a random tip from the available tips * @returns {object|null} Random tip object or null if no tips available */ const getRandomTip = () => { if (tipsFiles.length === 0) return null return tipsFiles[Math.floor(Math.random() * tipsFiles.length)] } /** Start the rotation timer for rotating tips */ const startRotationTimer = () => { if (rotateInterval) return // Already running rotateInterval = setInterval(() => { rotatingTips.forEach((tipId) => { // console.log('Rotating tip:', tipId) const randomTip = getRandomTip() if (randomTip) { const tipMarkdown = getSingleTipFile(randomTip) // Replace the DOM content of the div having an id of the tipId, with HTML from tipMarkdown const tipElement = document.getElementById(tipId) if (tipElement) { // Convert markdown to HTML using Docsify's internal function tipElement.innerHTML = ` <div class="alert callout tip"> <p class="title"><span class="icon icon-tip"></span>Tip <span style="color:darkgrey;font-size:small;">&nbsp;&nbsp;(Changes every 15sec)</span></p> <p> <em>${randomTip.replace('.md', '').replace('uibuilder', '<span class="uib-name"><span class="uib-red">UI</span>BUILDER</span>')}</em> </p><p> ${window.marked(tipMarkdown)} </p> </div> ` } } }) }, 15000) // 60 seconds = 1 minute } // Process tip shortcodes in markdown hook.beforeEach(function (content) { // Ensure content is a string if (typeof content !== 'string') { console.warn('Tips plugin: content is not a string, skipping processing') return content } // Look for tip shortcodes: [tip] or [tip:filename] or [tip:random] or [tip:rotate] const tipRegex = /\[tip(?::([^\]]+))?\]/g // eslint-disable-line security/detect-unsafe-regex let match const replacements = [] while ((match = tipRegex.exec(content)) !== null) { const [fullMatch, tipParam] = match replacements.push({ match: fullMatch, param: tipParam, }) } if (replacements.length > 0) { // loadTips() for (const { match, param, } of replacements) { let tipMarkdown = '' let tipType = 'tip' let tipTitle = '' let tipFile = '' const tipId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` // eslint-disable-line @stylistic/newline-per-chained-call if (!param || param === 'random') { // Show random tip using Docsify include const randomTip = getRandomTip() if (randomTip) { tipType = 'random-tip' tipFile = randomTip tipTitle = randomTip.replace('.md', '') tipMarkdown = getSingleTipFile(randomTip) } } else if (param === 'rotate') { // Show rotating tip with unique ID (will be handled by afterEach for rotation) const randomTip = getRandomTip() if (randomTip) { tipType = 'rotating-tip' tipFile = randomTip tipTitle = randomTip.replace('.md', '') rotatingTips.add(`${tipType}-${tipId}`) tipMarkdown = getSingleTipFile(randomTip) } } else { // Show specific tip by filename if (param.endsWith('.md')) { tipFile = param tipTitle = param.replace('.md', '') } else { tipFile = `${param}.md` tipTitle = param } tipMarkdown = getSingleTipFile(tipFile) } content = content.replace(match, ` <div id="${tipType}-${tipId}" class="doctips doc${tipType}" title="${tipTitle}"> <div class="alert callout tip"> <p class="title"><span class="icon icon-tip"></span>Tip <span style="color:darkgrey;font-size:small;">&nbsp;&nbsp;(Changes every 15sec)</span></p> <p> <em>${tipTitle}</em> </p><p> ${tipMarkdown} </p> </div> </div> ` ) } } return content }) /* <div id="rotating-tip-1757693356981-9gqwlggao" class="doctips docrotating-tip" title="Compare uibuilder with Dashboard 2"> <div class="alert callout tip"> <p class="title"><span class="icon icon-tip"></span>Tip</p> <p> <em><span class="uib-name"><span class="uib-red">UI</span>BUILDER</span> node outputs</em> </p> <p></p> <p> Blah blah </p> <p></p> </div> </div> */ // Start rotation timer after page content is loaded hook.doneEach(() => { if (rotatingTips.size > 0) { startRotationTimer() } }) }, // My custom plugin function ti(hook, vm) { // console.log({hook,vm}) const orgName = 'Julian Knight (Totally Information)' const orgUrl = 'https://it.knightnet.org.uk' const footer = [ '<hr/>', '<footer>', '<span>', `Copyright &copy; ${(new Date()).getFullYear()}`, // per-page - (c) and date ` <a href="${orgUrl}">${orgName}</a>.`, '', // updated date - {docsify-updated} variable could have been used '</span> ', '', ' <span>Published with <a href="https://docsify.js.org/" target="_blank">docsify</a>.</span> ', '</footer>' ] // Runs against the raw markdown for each page hook.beforeEach(function (content) { // content = content.replace(/-UIBUILDER-/g, '<span class="uib-name"><span class="uib-red">UI</span>BUILDER</span>') let mydate = new Date() let strYr = mydate.getFullYear() let yearFrom = 2017 let yearTo = strYr footer[5] = '' if (vm.frontmatter) { // vm only exists per page, requires plugin const fm = vm.frontmatter // #region --- Add front-matter (YAML) standard metadata to each page if present --- if (fm.description) { content = `${fm.description}\n\n${content}` // Update the output page's description meta tag const desc = document.querySelector('meta[name="description"]') if (desc) desc.setAttribute('content', fm.description) } let statusTxt = '' if (fm.status) { statusTxt += `<b>Status</b>: ${fm.status}. ` } if (fm.since) { statusTxt += `<b>Since</b>: UIBUILDER ${fm.since}. ` } if (statusTxt !== '') content = `> ${statusTxt}\n\n${content}` if (fm.title) { content = `# ${fm.title}\n\n${content}` } // #endregion --- --- // #region --- Add page specific (c) and last updated date to each page if available from YAML front-matter --- if (fm.created) { // uib docs/Obsidian mydate = new Date(fm.created) yearFrom = mydate.getFullYear() } else if (fm.date) { // Hugo mydate = new Date(fm.date) yearFrom = mydate.getFullYear() } if (fm.updated) { // Obsidian mydate = new Date(fm.updated) yearTo = mydate.getFullYear() } else if (fm.lastUpdated) { // uib/IT Stds docs mydate = new Date(fm.lastUpdated) yearTo = mydate.getFullYear() } else if (fm.Lastmod) { // Hugo mydate = new Date(fm.Lastmod) yearTo = mydate.getFullYear() } if (yearFrom === yearTo && yearFrom !== Number(strYr)) { strYr = yearFrom } else if (yearFrom !== yearTo) { strYr = yearFrom + '-' + yearTo } footer[5] = ` Updated ${mydate.toLocaleString('en-GB', { dateStyle: 'medium', })}.` // #endregion --- --- } // ---- End of if front-matter ---- // footer[3] = `Copyright &copy; ${strYr}` return content }) // ------- End of Custom Plugin ------- // // Runs against the rendered HTML for each page hook.afterEach(function (html, next) { // html = html.replace(/UIBUILDER/g, '<span class="uib-name"><span class="uib-red">UI</span>BUILDER</span>') next(html + footer.join('')) }) hook.doneEach(() => { // Make top-level sidebar list items with nested lists collapsible const sidebar = document.querySelector('.sidebar-nav > ul') if (sidebar) { const STORAGE_KEY = 'uib-docs-sidebar-state' /** Get saved sidebar state from localStorage * @returns {object} Saved state object or empty object */ const getSavedState = () => { try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {} } catch (e) { return {} } } /** Save sidebar state to localStorage * @param {string} sectionId - The section identifier * @param {boolean} isOpen - Whether the section is open */ const saveState = (sectionId, isOpen) => { try { const state = getSavedState() state[sectionId] = isOpen localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) } catch (e) { console.warn('Failed to save sidebar state:', e) } } /** Generate a stable ID from section text content * @param {HTMLElement} summaryEl - The summary element * @returns {string} A stable identifier for the section */ const getSectionId = (summaryEl) => { // Use text content, trimmed and lowercased, as the key return summaryEl.textContent .trim().toLowerCase() .replace(/\s+/g, '-') } const savedState = getSavedState() // Get all top-level list items const topLevelItems = sidebar.querySelectorAll(':scope > li') topLevelItems.forEach((li) => { const nestedList = li.querySelector(':scope > ul') // Only convert items that have a nested list (sections with children) if (nestedList) { // Check if already converted to details if (li.querySelector(':scope > details')) return // Get the text/link content (everything before the nested ul) const summaryContent = [] const childNodes = Array.from(li.childNodes) for (const node of childNodes) { if (node === nestedList) break summaryContent.push(node.cloneNode(true)) } // Create details/summary structure const details = document.createElement('details') const summary = document.createElement('summary') summaryContent.forEach(node => summary.appendChild(node)) // Generate section ID and check saved state const sectionId = getSectionId(summary) // Default to open if no saved state exists const isOpen = Object.prototype.hasOwnProperty.call(savedState, sectionId) ? savedState[sectionId] : true details.open = isOpen // Add toggle event listener to save state details.addEventListener('toggle', () => { saveState(sectionId, details.open) }) details.appendChild(summary) details.appendChild(nestedList) // Clear the li and add the details element li.innerHTML = '' li.appendChild(details) } }) } // Scroll active link into view const activeLink = document.querySelector('.sidebar-nav li.active') if (activeLink) { activeLink.scrollIntoView({ behavior: 'smooth', block: 'center', }) } }) // Invoked on each page load after new HTML has been appended to the DOM // hook.doneEach(() => { // // replace the <title> tag // document.title = document.title.replace(/<title>(.*?)<\/title>/, '<title>UIBUILDER: $1') // }) // Hooks: [ "init", "mounted", "beforeEach", "afterEach", "doneEach", "ready" ] }, // Sidebar enhancements: resizable divider + Navigation/TOC tab switching function sidebarEnhancementsPlugin(hook) { const STORAGE_KEY_WIDTH = 'uib-docs-sidebar-width' const STORAGE_KEY_TAB = 'uib-docs-sidebar-tab' const SIDEBAR_MIN = 160 const SIDEBAR_MAX = 640 // ---- Resize Handle ---- /** Apply a sidebar width in pixels, optionally persisting it * @param {number} widthPx - Width in pixels * @param {boolean} [persist] - Whether to save to localStorage (Default: false) */ const applySidebarWidth = (widthPx, persist = false) => { document.documentElement.style.setProperty('--sidebar-width', `${widthPx}px`) const resizeHandle = document.querySelector('.sidebar-resize-handle') if (resizeHandle) resizeHandle.setAttribute('aria-valuenow', String(Math.round(widthPx))) if (persist) localStorage.setItem(STORAGE_KEY_WIDTH, `${widthPx}px`) } hook.mounted(() => { // Restore saved sidebar width const savedWidth = localStorage.getItem(STORAGE_KEY_WIDTH) if (savedWidth) document.documentElement.style.setProperty('--sidebar-width', savedWidth) // Create the draggable resize handle between sidebar and content const handle = document.createElement('div') handle.className = 'sidebar-resize-handle' handle.setAttribute('role', 'separator') handle.setAttribute('tabindex', '0') handle.setAttribute('aria-label', 'Sidebar resize handle. Use left/right arrow keys or mouse to resize.') handle.setAttribute('title', 'Sidebar resize handle. Use left/right arrow keys or mouse to resize.') handle.setAttribute('aria-orientation', 'vertical') handle.setAttribute('aria-valuemin', String(SIDEBAR_MIN)) handle.setAttribute('aria-valuemax', String(SIDEBAR_MAX)) const initialWidth = savedWidth ? parseInt(savedWidth, 10) : SIDEBAR_MIN handle.setAttribute('aria-valuenow', String(initialWidth)) document.body.appendChild(handle) let isDragging = false let dragStartX = 0 let dragStartWidth = 0 handle.addEventListener('pointerdown', (e) => { const sidebar = document.querySelector('.sidebar') if (!sidebar) return isDragging = true dragStartX = e.clientX dragStartWidth = sidebar.getBoundingClientRect().width handle.classList.add('is-dragging') handle.setPointerCapture(e.pointerId) e.preventDefault() }) handle.addEventListener('pointermove', (e) => { if (!isDragging) return const newWidth = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, dragStartWidth + (e.clientX - dragStartX))) applySidebarWidth(newWidth) }) handle.addEventListener('keydown', (e) => { const sidebar = document.querySelector('.sidebar') if (!sidebar) return const currentWidth = sidebar.getBoundingClientRect().width let newWidth = currentWidth if (e.key === 'ArrowRight') { newWidth = Math.min(SIDEBAR_MAX, currentWidth + 10) } else if (e.key === 'ArrowLeft') { newWidth = Math.max(SIDEBAR_MIN, currentWidth - 10) } else if (e.key === 'Home') { newWidth = SIDEBAR_MIN } else if (e.key === 'End') { newWidth = SIDEBAR_MAX } else { return } e.preventDefault() applySidebarWidth(newWidth, true) }) handle.addEventListener('pointerup', () => { if (!isDragging) return isDragging = false handle.classList.remove('is-dragging') const sidebar = document.querySelector('.sidebar') if (sidebar) applySidebarWidth(sidebar.getBoundingClientRect().width, true) }) }) // ---- Sidebar Tabs (Navigation / Page TOC) ---- /** Activate a sidebar tab and show/hide panels accordingly * @param {'nav'|'toc'} tab - Which tab to show */ const activateTab = (tab) => { const navBtn = document.querySelector('.sidebar-tab-btn[data-tab="nav"]') const tocBtn = document.querySelector('.sidebar-tab-btn[data-tab="toc"]') const sidebarNav = document.querySelector('.sidebar .sidebar-nav') const tocPanel = document.querySelector('.sidebar-toc-panel') if (!navBtn || !tocBtn || !sidebarNav || !tocPanel) return const isNav = tab === 'nav' navBtn.classList.toggle('active', isNav) tocBtn.classList.toggle('active', !isNav) navBtn.setAttribute('aria-selected', String(isNav)) tocBtn.setAttribute('aria-selected', String(!isNav)) sidebarNav.setAttribute('aria-hidden', String(!isNav)) tocPanel.setAttribute('aria-hidden', String(isNav)) } /** Scroll to a heading by ID without triggering a Docsify page re-render. * Setting window.location.hash causes Docsify's router to re-render the * whole page. Instead we scroll directly and update the URL via history API. * @param {string} id - The heading element ID to scroll to */ const scrollToHeading = (id) => { const target = document.getElementById(id) if (!target) return target.scrollIntoView({ behavior: 'smooth', block: 'start', }) const pageBase = window.location.hash.split('?')[0] || '#/' history.pushState(null, '', `${pageBase}?id=${id}`) } /** Rebuild the page TOC from h2/h3 headings in the rendered content. * H2s that have H3 children are wrapped in <details><summary> to match * the collapsible style of the Navigation panel. */ const buildToc = () => { const tocPanel = document.querySelector('.sidebar-toc-panel') if (!tocPanel) return const mainContent = document.querySelector('.markdown-section') const headings = mainContent ? Array.from(mainContent.querySelectorAll('h2, h3')) : [] tocPanel.innerHTML = '' if (headings.length === 0) { const empty = document.createElement('p') empty.className = 'sidebar-toc-empty' empty.textContent = 'No headings on this page.' tocPanel.appendChild(empty) return } // Pre-scan to know which H2s have H3 children so we can decide // whether to use <details><summary> or a plain <button>. const h2HasChildren = new Map() let lastH2Key = null headings.forEach((h) => { if (h.tagName === 'H2') { lastH2Key = h.id || h.textContent.trim() h2HasChildren.set(lastH2Key, false) } else if (lastH2Key !== null) { h2HasChildren.set(lastH2Key, true) } }) const ul = document.createElement('ul') let currentDetails = null let currentSubUl = null headings.forEach((heading) => { // Strip trailing '#' link anchors added by docsify-themeable const label = heading.textContent.replace(/\s*#\s*$/, '').trim() const id = heading.id if (heading.tagName === 'H2') { currentSubUl = null const h2Key = id || label const hasChildren = h2HasChildren.get(h2Key) const li = document.createElement('li') if (hasChildren) { // Wrap in <details><summary> to match Navigation panel style const details = document.createElement('details') details.open = true const summary = document.createElement('summary') summary.className = 'sidebar-toc-link sidebar-toc-h2' summary.textContent = label if (id) summary.addEventListener('click', () => scrollToHeading(id)) details.appendChild(summary) currentDetails = details li.appendChild(details) } else { const btn = document.createElement('button') btn.type = 'button' btn.className = 'sidebar-toc-link sidebar-toc-h2' btn.textContent = label if (id) btn.addEventListener('click', () => scrollToHeading(id)) currentDetails = null li.appendChild(btn) } ul.appendChild(li) } else { const li = document.createElement('li') const btn = document.createElement('button') btn.type = 'button' btn.className = 'sidebar-toc-link sidebar-toc-h3' btn.textContent = label if (id) btn.addEventListener('click', () => scrollToHeading(id)) li.appendChild(btn) if (!currentSubUl) { currentSubUl = document.createElement('ul') ;(currentDetails || ul).appendChild(currentSubUl) } currentSubUl.appendChild(li) } }) tocPanel.appendChild(ul) } // Visual ordering is handled entirely by CSS flex `order` values on // .sidebar's children (see index.css). This means we can simply append // our elements to .sidebar without caring about insertion timing or // what other plugins do — CSS guarantees the correct visual sequence: // .app-name → .search → .sidebar-tabs → .sidebar-toc-panel → .sidebar-nav hook.doneEach(() => { const sidebarEl = document.querySelector('.sidebar') if (!sidebarEl) return // Create and append elements once; reuse them on subsequent page loads. if (!sidebarEl.querySelector('.sidebar-tabs')) { const tabsContainer = document.createElement('div') tabsContainer.className = 'sidebar-tabs' tabsContainer.setAttribute('role', 'tablist') tabsContainer.setAttribute('aria-label', 'Sidebar view') const navBtn = document.createElement('button') navBtn.className = 'sidebar-tab-btn' navBtn.dataset.tab = 'nav' navBtn.textContent = 'Navigation' navBtn.setAttribute('role', 'tab') navBtn.setAttribute('type', 'button') const tocBtn = document.createElement('button') tocBtn.className = 'sidebar-tab-btn' tocBtn.dataset.tab = 'toc' tocBtn.textContent = 'Page TOC' tocBtn.setAttribute('role', 'tab') tocBtn.setAttribute('type', 'button') tabsContainer.appendChild(navBtn) tabsContainer.appendChild(tocBtn) sidebarEl.appendChild(tabsContainer) tabsContainer.addEventListener('click', (e) => { const btn = e.target.closest('[data-tab]') if (!btn) return activateTab(btn.dataset.tab) localStorage.setItem(STORAGE_KEY_TAB, btn.dataset.tab) }) } if (!sidebarEl.querySelector('.sidebar-toc-panel')) { const tocPanel = document.createElement('div') tocPanel.className = 'sidebar-toc-panel' tocPanel.setAttribute('role', 'tabpanel') tocPanel.setAttribute('aria-label', 'Table of contents') sidebarEl.appendChild(tocPanel) } buildToc() activateTab(localStorage.getItem(STORAGE_KEY_TAB) || 'nav') }) }, ], }