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,293 lines (1,123 loc) β€’ 63.3 kB
// @ts-nocheck /* eslint-disable jsdoc/no-undefined-types */ /* eslint-disable n/no-unsupported-features/node-builtins */ // ^ This file is browser code, not Node.js - localStorage is a browser API // Mermaid is bundled locally (no CDN). startOnLoad is disabled because page // content arrives asynchronously from the server after the socket connects. // mermaid.run() is called explicitly in postDataUpdate() after each content // injection so that <pre class="mermaid"> elements are always processed, // whether on initial load or after SPA navigation. import mermaid from './mermaid.esm.min.js' // mermaid.initialize({ startOnLoad: false, theme: 'dark', }) // mermaid.initialize({ startOnLoad: false, theme: 'redux-dark-color', }) mermaid.initialize({ startOnLoad: false, theme: 'dark', darkMode: true, }) const clientVersion = '7.7.4-src' // NB: This is replaced with the actual version during the build process by bin/build.mjs /** The uibuilder.pageData object is set on load and when navigating * You can use it to do your own processing if desired * window.pageData is passed in the initial page load, we use that * to drive the initial set. * On navigation or server page update notifications, we get the updated * page data from the server in a control msg. * The updatePageData() fn is used for further updates of the managed * variable to ensure consistency. */ let pageData = window.pageData uibuilder.set('pageData', pageData) // The uibuilder log display is controlled by the uibuilder.logLevel variable const log = uibuilder.log // The base URL must be specified via a <base> tag in the document head // It is used to resolve relative links for SPA navigation. // It is also set on page load as window.baseUrl. It does not change. const baseUrl = window.baseUrl console.info(`πŸŒπŸ•ΈοΈ[markweb:client load] Base URL: "${baseUrl}". Client version: ${clientVersion}`) // console.info('πŸŒπŸ•ΈοΈ[markweb:client load] Page Data:', pageData) // Get references to commonly used elements const elContent = document.querySelector('[data-fmvar="content"]') const elSearchInput = /** @type {HTMLInputElement} */ (document.getElementById('search-input')) const elSearchResults = document.getElementById('search-results') const elSearchQuery = document.getElementById('search-query') const elSearchCount = document.getElementById('search-count') const elSearchDetails = document.getElementById('search-details') // Note: elShowMeta is NOT cached here because it may be recreated during navigation // #region --- Utility Functions --- /** Escape HTML to prevent XSS - returns escaped text (HTML removed) * @param {string} text Input text * @returns {string} Escaped text */ function escapeHtml(text) { const div = document.createElement('div') div.textContent = text return div.innerHTML } // Normalize: remove trailing slashes, .md extension, leading slashes, and double slashes const normalizePath = (p) => { if (!p) return '' return p .replace(/\/+/g, '/') // collapse multiple slashes .replace(/^\//, '') // remove leading slash .replace(/\/$/, '') // remove trailing slash .replace(/\.md$/, '') // remove .md extension .replace(/\/index$/, '') // remove trailing /index .toLowerCase() } /** Update the uibuilder pageData store * Also reflect back to window.pageData and the local pageData object for consistency. Log the change * @param {object} attributes The page attributes to set * @param {object} additionalInfo Any additional info to merge into pageData (e.g. search query) * @returns {object} The updated pageData object */ function updatePageData(attributes, additionalInfo = {}) { const pgData = { ...additionalInfo, ...attributes, } uibuilder.set('pageData', pgData ) window.pageData = pgData pageData = pgData // console.log(`uibuilder.pageData has changed (From: ${pgData.from}): `, pgData) // NB: <show-meta> elements will update themselves return pgData } /** Make the page title act as a "back to top" control and clear any hash fragment * @returns {void} */ function setupPageTitleResetLink() { const pageTitleLink = document.getElementById('page-title-link') if (!pageTitleLink || pageTitleLink.dataset.hashResetBound === 'true') return pageTitleLink.dataset.hashResetBound = 'true' pageTitleLink.addEventListener('click', (event) => { event.preventDefault() const cleanUrl = `${window.location.pathname}${window.location.search}` const state = (history.state && typeof history.state === 'object') ? { ...history.state, } : {} if (Object.prototype.hasOwnProperty.call(state, 'hash')) delete state.hash history.replaceState(state, '', cleanUrl) window.scrollTo({ top: 0, behavior: 'auto', }) const scrollContainer = document.querySelector('main > section') || document.documentElement scrollContainer.scrollTo({ top: 0, behavior: 'auto', }) }) } // #endregion --- Utility Functions --- // #region --- Sidebar Functionality --- /** Sidebar controller class - handles toggle, resize, tabs, TOC generation, and state persistence */ class SidebarController { isResizing = false constructor() { /** @type {HTMLElement|null} */ this.elRoot = document.getElementById('markweb') if (!this.elRoot) return /** @type {HTMLElement|null} */ this.sidebar = document.getElementById('sidebar') if (!this.sidebar) return /** @type {HTMLButtonElement|null} */ this.elToggle = /** @type {HTMLButtonElement} */ (document.getElementById('sidebar-toggle')) /** @type {HTMLInputElement|null} */ this.toggleInput = this.elToggle.getElementsByTagName('input')[0] /** @type {HTMLElement|null} */ this.resizer = document.getElementById('sidebar-resizer') /** @type {HTMLElement|null} */ this.navTab = document.getElementById('sidebar-tab-nav') /** @type {HTMLElement|null} */ this.tocTab = document.getElementById('sidebar-tab-toc') /** The navigation panel @type {HTMLElement|null} */ this.navPanel = document.getElementById('sidebar-panel-nav') /** @type {HTMLElement|null} */ this.tocPanel = document.getElementById('sidebar-panel-toc') /** @type {HTMLElement|null} */ this.tocContainer = document.getElementById('sidebar-toc') /** @type {HTMLInputElement|null} */ this.searchInput = /** @type {HTMLInputElement} */ (document.getElementById('sidebar-search-input')) /** @type {HTMLElement|null} */ this.searchResults = document.getElementById('sidebar-search-results') this.storageKeyOpen = 'uib-sidebar-open' this.storageKeyWidth = 'uib-sidebar-width' this.storageKeyCollapsed = 'uib-sidebar-collapsed' this.init() } init() { this.restoreState() this.setupToggle() this.setupResizer() this.setupTabs() this.setupSearch() this.generateTOC() this.setupScrollSpy() this.observeContentChanges() this.restoreCollapsedStates() this.highlightCurrentPage() } /** Watch the body content element for DOM changes and rebuild the TOC automatically. * This ensures the TOC stays in sync whenever content is reloaded for any reason. */ observeContentChanges() { const contentEl = document.querySelector('main section') if (!contentEl) return this._contentObserver = new MutationObserver(() => { this.generateTOC() }) this._contentObserver.observe(contentEl, { childList: true, subtree: true, }) } /** Restore sidebar open/closed and width state from localStorage */ restoreState() { // Restore open state or default to open if not set this.openClose(localStorage.getItem(this.storageKeyOpen) ?? 'true', true) // Restore width // const savedWidth = localStorage.getItem(this.storageKeyWidth) // if (savedWidth) { // this.sidebar.style.setProperty('--sidebar-width', savedWidth) // } } /** Setup toggle button event */ setupToggle() { if (!this.elToggle) return this.toggleInput .addEventListener('click', (evt) => { this.openClose(!evt.target.checked, false) }) } openClose(open, changeInput = false) { if (!this.sidebar || !this.elToggle) return if (open === true || open === 'true') { this.sidebar.classList.remove('closed') this.sidebar.dataset.open = 'true' this.elToggle.setAttribute('aria-expanded', 'true') localStorage.setItem(this.storageKeyOpen, 'true') if (changeInput) this.toggleInput.checked = false } else { this.sidebar.classList.add('closed') this.sidebar.dataset.open = 'false' this.elToggle.setAttribute('aria-expanded', 'false') localStorage.setItem(this.storageKeyOpen, 'false') if (changeInput) this.toggleInput.checked = true } } /** Setup resizer drag functionality */ setupResizer() { if (!this.resizer) return this.resizer.addEventListener('mousedown', (e) => { // Don't start resize if clicking on the toggle if (e.target.closest('label') || e.target.closest('input')) { return } this.isResizing = true document.body.style.cursor = 'col-resize' document.body.style.userSelect = 'none' }) document.addEventListener('mousemove', (e) => { if (!this.isResizing) return // Calculate new width based on mouse position const newWidth = e.clientX // Set min/max constraints const minWidth = 0 const maxWidth = 9999 if (newWidth >= minWidth && newWidth <= maxWidth) { this.elRoot.style.setProperty('--sidebar-min-width', `${newWidth}px`) this.elRoot.style.setProperty('--sidebar-max-width', `${newWidth}px`) } }) document.addEventListener('mouseup', () => { if (this.isResizing) { this.isResizing = false document.body.style.cursor = '' document.body.style.userSelect = '' } }) // Keyboard support: arrow keys resize, Shift multiplies step by 5 this.resizer.setAttribute('tabindex', '0') this.resizer.setAttribute('role', 'separator') this.resizer.setAttribute('aria-orientation', 'vertical') this.resizer.setAttribute('aria-label', 'Sidebar resize handle. Use left/right arrow keys to resize.') this.resizer.addEventListener('keydown', (e) => { if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return e.preventDefault() const step = e.shiftKey ? 50 : 10 // Measure the column width the same way the mouse handler does: the resizer's // left edge relative to the root container equals the sidebar column width. const currentWidth = this.resizer.getBoundingClientRect().left - this.elRoot.getBoundingClientRect().left const newWidth = currentWidth + (e.key === 'ArrowRight' ? step : -step) if (newWidth >= 0 && newWidth <= 9999) { this.elRoot.style.setProperty('--sidebar-min-width', `${newWidth}px`) this.elRoot.style.setProperty('--sidebar-max-width', `${newWidth}px`) } }) } /** Setup tab switching */ setupTabs() { if (!this.navTab || !this.tocTab) return const switchTab = (activeTab, activePanel, inactiveTab, inactivePanel) => { activeTab.classList.add('active') activeTab.setAttribute('aria-selected', 'true') activePanel.hidden = false activePanel.classList.add('active') inactiveTab.classList.remove('active') inactiveTab.setAttribute('aria-selected', 'false') inactivePanel.hidden = true inactivePanel.classList.remove('active') } this.navTab.addEventListener('click', () => { switchTab(this.navTab, this.navPanel, this.tocTab, this.tocPanel) }) this.tocTab.addEventListener('click', () => { switchTab(this.tocTab, this.tocPanel, this.navTab, this.navPanel) }) } /** Setup sidebar search functionality */ setupSearch() { if (!this.searchInput || !this.searchResults) return let searchTimeout = null this.searchInput.addEventListener('input', (e) => { if (searchTimeout) clearTimeout(searchTimeout) const query = /** @type {HTMLInputElement} */ (e.target).value.trim() if (query.length < 2) { this.searchResults.hidden = true return } // Debounce search searchTimeout = setTimeout(() => { this.searchResults.innerHTML = '<div style="padding: 0.5rem; color: var(--text3);">Searching...</div>' this.searchResults.hidden = false // Send search request to server uibuilder.sendCtrl({ uibuilderCtrl: 'internal', controlType: 'search', query: query, source: 'sidebar', }) }, 300) }) } /** Display sidebar search results * @param {Array<{ title: string, path: string }>} results Search results */ displaySearchResults(results) { if (!this.searchResults) return if (!results || results.length === 0) { this.searchResults.innerHTML = '<div style="padding: 0.5rem; color: var(--text3);">No results found</div>' return } let html = '' results.forEach((item) => { html += `<a href="${escapeHtml(item.path)}">${escapeHtml(item.title)}</a>` }) this.searchResults.innerHTML = html } /** Generate table of contents from page headings with collapsible sections */ generateTOC() { const contentEl = document.querySelector('main section') if (!contentEl) return const headings = contentEl.querySelectorAll('h2, h3, h4, h5, h6') if (headings.length === 0) { uibuilder.set('sidebar-toc', '<p style="padding: 0.5rem; color: var(--text3); font-size: 0.875rem;">No headings found</p>') return } // Build a hierarchical structure from flat headings list const getLevel = tag => parseInt(tag.charAt(1), 10) // Create a tree structure const root = { children: [], level: 1, } const stack = [root] headings.forEach((heading) => { const id = heading.id || heading.textContent.toLowerCase().replace(/\s+/g, '-') .replace(/[^\w-]/g, '') if (!heading.id) heading.id = id const level = getLevel(heading.tagName) const node = { id, text: heading.textContent, level, children: [], } // Pop stack until we find a parent with lower level while (stack.length > 1 && stack[stack.length - 1].level >= level) { stack.pop() } // Add to current parent stack[stack.length - 1].children.push(node) stack.push(node) }) // Render the tree with details/summary for nodes with children const renderTree = (nodes) => { if (!nodes || nodes.length === 0) return '' let html = '<ul>' nodes.forEach((node) => { const hasChildren = node.children && node.children.length > 0 const levelClass = `toc-h${node.level}` if (hasChildren) { html += `<li class="${levelClass}"> <details open data-toc-id="${node.id}"> <summary><a href="#${node.id}">${escapeHtml(node.text)}</a></summary> ${renderTree(node.children)} </details> </li>` } else { html += `<li class="${levelClass}"><a href="#${node.id}">${escapeHtml(node.text)}</a></li>` } }) html += '</ul>' return html } uibuilder.set('sidebar-toc', renderTree(root.children)) this.setupScrollSpy() } /** Set up an IntersectionObserver to highlight the TOC entry for the heading currently in view */ setupScrollSpy() { // Tear down any previous observer if (this._scrollSpyObserver) { this._scrollSpyObserver.disconnect() this._scrollSpyObserver = null } const contentEl = document.querySelector('main section') if (!contentEl) return const headings = contentEl.querySelectorAll('h2, h3, h4, h5, h6') if (headings.length === 0) return // Use a top-margin offset so headings are considered "active" once near the top of the viewport this._scrollSpyObserver = new IntersectionObserver((entries) => { for (const entry of entries) { if (entry.isIntersecting) { this._highlightTocEntry(entry.target.id) } } }, { rootMargin: '0px 0px -70% 0px', threshold: 0, }) headings.forEach((heading) => { if (heading.id) this._scrollSpyObserver.observe(heading) }) } /** Highlight the TOC link that matches the given heading id * @param {string} id The heading element id to highlight */ _highlightTocEntry(id) { // const activeLinkClass = 'toc-active' const activeLinkClass = 'active-link' if (!this.tocContainer) return // Remove existing highlight this.tocContainer.querySelectorAll(`.${activeLinkClass}`).forEach((el) => { el.classList.remove(activeLinkClass) }) // Find the matching link and highlight its parent li or summary const link = this.tocContainer.querySelector(`a[href="#${CSS.escape(id)}"]`) if (!link) return const summary = link.closest('summary') if (summary) { summary.classList.add(activeLinkClass) } else { link.closest('li')?.classList.add(activeLinkClass) } } /** Restore collapsed state of details elements from localStorage */ restoreCollapsedStates() { const saved = localStorage.getItem(this.storageKeyCollapsed) // Listen for toggle events to save state this.sidebar.addEventListener('toggle', (e) => { const target = /** @type {HTMLElement} */ (e.target) if (target.tagName !== 'DETAILS') return this.saveCollapsedState() }, true) if (!saved) return try { const collapsedPaths = JSON.parse(saved) if (!Array.isArray(collapsedPaths)) return const details = this.sidebar.querySelectorAll('details[data-path]') details.forEach((detail) => { const path = /** @type {HTMLDetailsElement} */ (detail).dataset.path // Open all by default, close those in the saved collapsed list if (collapsedPaths.includes(path)) { detail.removeAttribute('open') } else { detail.setAttribute('open', '') } }) } catch (e) { // Ignore parse errors } } /** Save collapsed state of details elements to localStorage */ saveCollapsedState() { const details = this.sidebar.querySelectorAll('details[data-path]') const collapsedPaths = [] details.forEach((detail) => { if (!detail.hasAttribute('open')) { collapsedPaths.push(/** @type {HTMLDetailsElement} */ (detail).dataset.path) } }) localStorage.setItem(this.storageKeyCollapsed, JSON.stringify(collapsedPaths)) } /** Highlight current page in sidebar navigation */ highlightCurrentPage() { const activeLinkClass = 'active-link' let currentPath = window.location.pathname if (currentPath.startsWith(baseUrl)) { currentPath = currentPath.slice(baseUrl.length) } currentPath = normalizePath(currentPath) // Remove existing highlights this.sidebar.querySelectorAll(`.${activeLinkClass}`).forEach((el) => { el.classList.remove(activeLinkClass) }) // Find and highlight current page link const links = this.sidebar.querySelectorAll('.sidebar-panel a[href]') links.forEach((link) => { const href = link.getAttribute('href') if (normalizePath(href) === currentPath) { // Check if link is inside a summary const summary = link.closest('summary') if (summary) { summary.classList.add(activeLinkClass) } else { link.closest('li')?.classList.add(activeLinkClass) } // Expand parent details elements let parent = link.closest('details') while (parent) { parent.setAttribute('open', '') parent = parent.parentElement?.closest('details') } } }) } /** Update sidebar after navigation * @param {object} data Page data from navigation */ update(data) { this.generateTOC() this.highlightCurrentPage() } } // Initialize sidebar controller let sidebarController = null const initSidebar = () => { sidebarController = new SidebarController() } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initSidebar) } else { initSidebar() } // #endregion --- Sidebar Functionality --- // #region --- Code block language labels --- /** Extract language from code elements' language-* class and set it on their parent pre for CSS labelling */ function promoteCodeLanguageAttrs() { document.querySelectorAll('pre > code[class*="language-"]').forEach((code) => { const pre = code.parentElement if (pre && !pre.dataset.language) { const match = code.className.match(/\blanguage-(\S+)/) if (match) pre.dataset.language = match[1] } }) } // Run on initial load promoteCodeLanguageAttrs() // Re-run whenever main content changes const contentObserverTarget = document.querySelector('main section') || elContent if (contentObserverTarget) { new MutationObserver(promoteCodeLanguageAttrs) .observe(contentObserverTarget, { childList: true, subtree: true, }) } // #endregion --- Code block language labels --- // TODO: Future feature // #region --- Checkbox Event Delegation --- /** Handle checkbox clicks using event delegation to support dynamically added checkboxes. * @param {MouseEvent} evt - The click event */ const handleCheckboxClick = (evt) => { const target = /** @type {HTMLElement} */ (evt.target) if (target.matches('input[type="checkbox"]')) { const checkbox = /** @type {HTMLInputElement} */ (target) const identifier = checkbox.id || checkbox.name || checkbox.className || 'unnamed' const checked = checkbox.checked // Find associated label text (via for attribute or wrapping label) let labelText = '' if (checkbox.id) { const labelFor = document.querySelector(`label[for="${checkbox.id}"]`) if (labelFor) labelText = labelFor.textContent?.trim() || '' } if (!labelText) { const parentLabel = checkbox.closest('label') if (parentLabel) labelText = parentLabel.textContent?.trim() || '' } console.log(`Checkbox clicked: "${identifier}" - label: "${labelText}" - checked: ${checked}`) uibuilder.send({ topic: 'markweb-checkbox-click', checkboxId: identifier, label: labelText, checked: checked, }) } } // Attach delegated event listener for all checkboxes (including dynamically added ones) // ! Disabled for now since this cannot (yet) update the source markdown // ! nor does it have a local state store to update checkbox states. // document.addEventListener('click', handleCheckboxClick) // #endregion --- Checkbox Event Delegation --- // #region --- SPA Navigation --- /** @type {HTMLElement|null} */ let nav = null /** @type {HTMLElement|null} */ let header = null /** @type {HTMLElement|null} */ let burger = null /** @type {number} */ let headerBottom = 0 /** @type {boolean} */ let isCollapsed = false /** @type {{ key: string, ts: number }} */ let lastNavigateRequest = { key: '', ts: 0, } /** Create the burger icon element * @returns {HTMLElement} The burger button element */ const navCreateBurger = () => { const btn = document.createElement('button') btn.className = 'menu-burger' btn.setAttribute('aria-label', 'Toggle navigation menu') btn.setAttribute('aria-expanded', 'false') btn.innerHTML = '<span></span><span></span><span></span>' return btn } /** Update header bottom position (for resize handling) */ const navHorizontalUpdateHeaderPosn = () => { if (header) { const rect = header.getBoundingClientRect() headerBottom = rect.bottom + window.scrollY } } /** Handle scroll - collapse/expand nav based on scroll position */ const navHorizontalScroll = () => { if (!nav || !burger) return const scrollY = window.scrollY const shouldCollapse = scrollY > headerBottom if (shouldCollapse !== isCollapsed) { isCollapsed = shouldCollapse nav.classList.toggle('collapsed', shouldCollapse) burger.classList.toggle('visible', shouldCollapse) // Close menu when un-collapsing if (!shouldCollapse) { nav.classList.remove('open') burger.classList.remove('open') burger.setAttribute('aria-expanded', 'false') } } } /** Toggle menu open/closed state */ const navBurgerToggleMenu = () => { if (!nav || !burger) return const isOpen = nav.classList.toggle('open') burger.classList.toggle('open', isOpen) burger.setAttribute('aria-expanded', String(isOpen)) } /** Close the menu */ const navBurgerCloseMenu = () => { if (!nav || !burger) return nav.classList.remove('open') burger.classList.remove('open') burger.setAttribute('aria-expanded', 'false') } /** Handle clicks outside the menu to close it * @param {MouseEvent} evt Click event */ const navBurgerClickOutside = (evt) => { if (!nav || !burger) return if (!isCollapsed || !nav.classList.contains('open')) return const target = /** @type {HTMLElement} */ (evt.target) if (!nav.contains(target) && !burger.contains(target)) { navBurgerCloseMenu() } } /** Handle mouse leaving the menu area */ const navBurgerMouseLeave = () => { if (isCollapsed && nav?.classList.contains('open')) { navBurgerCloseMenu() } } /** Initialise the navigation behaviour */ const navHorizontalInit = () => { nav = document.querySelector('nav.horizontal') if (!nav) return header = document.querySelector('header') // Create and insert burger button after nav burger = navCreateBurger() nav.insertAdjacentElement('afterend', burger) // Calculate initial header position navHorizontalUpdateHeaderPosn() // Event listeners window.addEventListener('scroll', navHorizontalScroll, { passive: true, }) window.addEventListener('resize', () => { navHorizontalUpdateHeaderPosn() navHorizontalScroll() }, { passive: true, }) burger.addEventListener('click', navBurgerToggleMenu) burger.addEventListener('touchstart', (evt) => { evt.preventDefault() navBurgerToggleMenu() }, { passive: false, }) // Close on click outside document.addEventListener('click', navBurgerClickOutside) // Close on mouse leave (with delay for UX) let leaveTimeout = null nav.addEventListener('mouseleave', () => { leaveTimeout = setTimeout(navBurgerMouseLeave, 300) }) nav.addEventListener('mouseenter', () => { if (leaveTimeout) { clearTimeout(leaveTimeout) leaveTimeout = null } }) burger.addEventListener('mouseenter', () => { if (leaveTimeout) { clearTimeout(leaveTimeout) leaveTimeout = null } }) // Hover to open when collapsed burger.addEventListener('mouseenter', () => { if (isCollapsed && !nav.classList.contains('open')) { navBurgerToggleMenu() } }) // Initial check navHorizontalScroll() } // Initialise when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', navHorizontalInit) } else { navHorizontalInit() } /** Initialize multi-level navigation menu with mobile support * Handles burger toggle, submenu expansion, keyboard navigation, and edge detection */ // function initMenu() { // const nav = document.querySelector('.horizontal') // const menuToggle = nav?.querySelector('.menu-toggle') // const routemenu = nav?.querySelector('.routemenu') // if (!nav || !menuToggle || !routemenu) return // // Mobile: Toggle menu visibility // menuToggle.addEventListener('click', () => { // const isExpanded = menuToggle.getAttribute('aria-expanded') === 'true' // // @ts-ignore // menuToggle.setAttribute('aria-expanded', !isExpanded) // // @ts-ignore // nav.setAttribute('aria-expanded', !isExpanded) // }) // // Mobile: Handle submenu toggle on click for items with children // routemenu.addEventListener('click', (e) => { // const isMobile = window.matchMedia('(max-width: 768px)').matches // if (!isMobile) return // // @ts-ignore // const link = e.target.closest('a') // const li = link?.closest('li') // const hasSubmenu = li?.querySelector(':scope > ul') // if (link && hasSubmenu) { // e.preventDefault() // li.classList.toggle('submenu-open') // // Close sibling submenus // const siblings = li.parentElement.querySelectorAll(':scope > li.submenu-open') // siblings.forEach((sibling) => { // if (sibling !== li) sibling.classList.remove('submenu-open') // }) // } // }) // // Desktop: Detect edge overflow and flip submenus // const checkSubmenuOverflow = () => { // const submenus = routemenu.querySelectorAll('ul ul') // submenus.forEach((submenu) => { // submenu.classList.remove('flip-left') // const rect = submenu.getBoundingClientRect() // if (rect.right > window.innerWidth) { // submenu.classList.add('flip-left') // } // }) // } // // Check overflow on hover // routemenu.addEventListener('mouseenter', checkSubmenuOverflow, true) // window.addEventListener('resize', () => { // // Reset mobile menu state on resize // if (!window.matchMedia('(max-width: 768px)').matches) { // nav.setAttribute('aria-expanded', 'false') // menuToggle.setAttribute('aria-expanded', 'false') // routemenu.querySelectorAll('.submenu-open').forEach((el) => { // el.classList.remove('submenu-open') // }) // } // checkSubmenuOverflow() // }) // // Keyboard navigation // routemenu.addEventListener('keydown', (e) => { // const focusedItem = document.activeElement // const li = focusedItem?.closest('li') // const hasSubmenu = li?.querySelector(':scope > ul') // const isInSubmenu = focusedItem?.closest('ul ul') // // @ts-ignore // switch (e.key) { // case 'ArrowDown': // e.preventDefault() // if (isInSubmenu) { // // Move to next sibling in submenu // const nextItem = li?.nextElementSibling?.querySelector('a') // nextItem?.focus() // } else if (hasSubmenu) { // // Open submenu and focus first item // const firstSubItem = hasSubmenu.querySelector('a') // firstSubItem?.focus() // } // break // case 'ArrowUp': // e.preventDefault() // if (isInSubmenu) { // const prevItem = li?.previousElementSibling?.querySelector('a') // if (prevItem) { // prevItem.focus() // } else { // // Go back to parent // const parentLink = li?.closest('ul') // ?.closest('li') // ?.querySelector(':scope > a') // // @ts-ignore // parentLink?.focus() // } // } // break // case 'ArrowRight': // e.preventDefault() // if (!isInSubmenu) { // // Move to next top-level item // const nextTopItem = li?.nextElementSibling?.querySelector('a') // nextTopItem?.focus() // } else if (hasSubmenu) { // // Open nested submenu // const firstSubItem = hasSubmenu.querySelector('a') // firstSubItem?.focus() // } // break // case 'ArrowLeft': // e.preventDefault() // if (isInSubmenu) { // // Go back to parent // const parentLink = li?.closest('ul') // ?.closest('li') // ?.querySelector(':scope > a') // // @ts-ignore // parentLink?.focus() // } else { // // Move to previous top-level item // const prevTopItem = li?.previousElementSibling?.querySelector('a') // prevTopItem?.focus() // } // break // case 'Escape': // // Close any open submenus and mobile menu // nav.setAttribute('aria-expanded', 'false') // menuToggle.setAttribute('aria-expanded', 'false') // // @ts-ignore // menuToggle.focus() // break // case 'Enter': // case ' ': // if (hasSubmenu && window.matchMedia('(max-width: 768px)').matches) { // e.preventDefault() // li.classList.toggle('submenu-open') // } // break // } // }) // // Close mobile menu when clicking outside // document.addEventListener('click', (e) => { // // @ts-ignore // if (!nav.contains(e.target) && nav.getAttribute('aria-expanded') === 'true') { // nav.setAttribute('aria-expanded', 'false') // menuToggle.setAttribute('aria-expanded', 'false') // } // }) // } /** Update active navigation state and parent highlighting * @param {string} [currentPath] The current page path (defaults to window.location.pathname) */ // function updateActiveNavState(currentPath) { // let pathname = currentPath || window.location.pathname // // Normalize pathname: if starts with baseUrl, keep as-is; else remove "./" prefix and add baseUrl // if (!pathname.startsWith(baseUrl)) { // pathname = baseUrl + '/' + pathname.replace(/^\.\//, '') // } // // Clear all active and parent-active classes first // document.querySelectorAll('.routemenu a.active').forEach((a) => { // a.classList.remove('active') // }) // document.querySelectorAll('.routemenu li[class*="parent-active"]').forEach((li) => { // li.classList.remove('parent-active-1', 'parent-active-2', 'parent-active-3') // }) // // Find matching link // let activeLink = null // document.querySelectorAll('.routemenu a').forEach((a) => { // const href = a.getAttribute('href') // if (!href) return // // Normalize linkPath: if starts with baseUrl, keep as-is; else remove "./" prefix and add baseUrl // let linkPath = href // if (!linkPath.startsWith(baseUrl)) { // linkPath = baseUrl + '/' + linkPath.replace(/^\.\//, '') // } // if (normalizePath(pathname) === normalizePath(linkPath)) { // activeLink = a // a.classList.add('active') // } // }) // // TODO Consider having a single ancestor highlight for multiple matches // // Apply parent-active classes bubbling up from active item // if (activeLink) { // // @ts-ignore // let parentLi = activeLink.closest('li')?.parentElement?.closest('li') // let level = 1 // while (parentLi && level <= 3) { // parentLi.classList.add(`parent-active-${level}`) // parentLi = parentLi.parentElement?.closest('li') // level++ // } // } // } /** Update page data after navigation * @param {object} data The page data returned from server */ function postDataUpdate(data) { // Pseudo-send updated page data to local uib store // delete data.body // We don't need body in uibuilder store uibuilder.set('pageData', data ) // const toUrl = data.toUrl || window.location.href const toUrl = data.path // Update elements with data-fmvar based on response data document.querySelectorAll('[data-fmvar]').forEach((el) => { // const attr = el.getAttribute('data-fmvar') if (el.dataset.fmvar === undefined) return const fmvar = el.dataset.fmvar // Skip body as it is handled separately if (fmvar === 'body') return // Look for the var in the data - if it exists, update the element if (data[fmvar] !== undefined) { // Optional marker to denote meta tags that should update their 'content' attribute instead of innerHTML/textContent const isContent = !!el.hasAttribute('content') // If el has attribute 'data-replace', switch el to be el's parent element if (el.hasAttribute('data-replace')) { const parent = el.parentElement if (parent) { el = parent } } // Handle meta tags if (isContent) { el.setAttribute('content', data[fmvar]) } else { // ! TODO: Consider whether we want to use textContent or innerHTML here - if the data can contain HTML, we need innerHTML, but this opens up XSS risks if the data is not sanitized. For now, we'll assume the server sends safe HTML in the data. // Everything else // el.textContent = data[fmvar] el.innerHTML = data[fmvar] } } }) } /** Handle SPA navigation * @param {string} toUrl The URL to navigate to * @param {boolean} [addToHistory] Whether to add this navigation to browser history (default: true) */ async function navigate(toUrl, addToHistory = true) { elContent?.classList.add('loading') // Hide search results when navigating // elSearchResults.hidden = true // elSearchInput.value = '' // #region --- Normalize toUrl --- // Remove origin from toUrl if present const origin = window.location.origin toUrl = toUrl.replace(origin, '') // console.log('>> πŸŒπŸ•ΈοΈ[markweb:navigate] Normalized toUrl (origin removed if present):', {origin, toUrl, trace: uibuilder.stack()}) // Remove baseUrl from toUrl if present at start if (toUrl.startsWith(baseUrl)) { toUrl = toUrl.slice(baseUrl.length) } // Extract and preserve hash fragment for client-side scrolling after content loads let hashFragment = '' if (toUrl.includes('#')) { const hashIndex = toUrl.indexOf('#') hashFragment = toUrl.slice(hashIndex) toUrl = toUrl.slice(0, hashIndex) } // #endregion --- Normalize toUrl --- // Prevent duplicate in-flight navigate requests for the same target. // This can happen when different ctrl topics trigger a refresh for the same page. const navKey = `${normalizePath(toUrl)}|${hashFragment}|${addToHistory ? '1' : '0'}` const now = Date.now() if (lastNavigateRequest.key === navKey && (now - lastNavigateRequest.ts) < 500) { console.debug('Skipping duplicate navigate request:', { toUrl, hashFragment, addToHistory, }) return } lastNavigateRequest = { key: navKey, ts: now, } // console.log(`>> πŸŒπŸ•ΈοΈ[markweb:navigate] Navigating `, { uibuilderCtrl: 'internal', controlType: 'navigate', toUrl: toUrl, addToHistory: addToHistory, hashFragment: hashFragment, } ) // Ask server for new page content via uibuilder control message (see onChange handler below) // Pass hashFragment so client can scroll to it after content loads uibuilder.sendCtrl({ uibuilderCtrl: 'internal', controlType: 'navigate', toUrl: toUrl, addToHistory: addToHistory, hashFragment: hashFragment, }) } /** Intercept clicks on links * All internal links (without ":" in href) are handled via SPA navigation. * Relative links are all assumed to be relative to baseUrl. * External links (with ":") are not intercepted. */ document.addEventListener('click', (e) => { // @ts-ignore const link = e.target.closest('a') if (link) { const href = link.getAttribute('href') // If href is external (contains a ":"), do not intercept if (!href || href.includes(':')) return e.preventDefault() // If href starts with "#" (anchor link), handle scrolling with history support if (href.startsWith('#')) { const targetId = href.slice(1) const targetElement = document.getElementById(targetId) if (targetElement) { targetElement.scrollIntoView({ behavior: 'smooth', block: 'start', }) // Always push a new history entry for each anchor click so that the back button // navigates through each visited anchor position individually. // The popstate handler handles the case where the element is not in the DOM // (e.g. after navigating away) by re-loading the page with the hash fragment. const currentPath = location.pathname + location.search history.pushState({ hash: href, path: currentPath, }, '', currentPath + href) } return } if (href.startsWith('./')) navigate(baseUrl + href) else { if (href.startsWith('/')) navigate(baseUrl + href) else navigate(baseUrl + '/' + href) // Don't pushState here since navigation might actually fail. } } }) // Handle browser back/forward - Track to avoid pushing state during popstate handling let isHandlingPopstate = false window.addEventListener('popstate', (evt) => { console.log('popstate', !!evt.state?.hash, evt.state) // Handle hash-only navigation (anchor links) if (evt.state?.hash) { const targetId = evt.state.hash.slice(1) const targetElement = document.getElementById(targetId) if (targetElement) { // Element exists in current content - we're on the right page, just scroll targetElement.scrollIntoView({ behavior: 'smooth', block: 'start', }) } else { // Element not found - the user navigated back to a page+anchor entry but the // content for that page isn't loaded yet. Navigate to load it; the response // handler will scroll to the hash once content is rendered. isHandlingPopstate = true navigate(window.location.pathname + window.location.search + window.location.hash, false) } } else if (evt.state?.path) { // Check whether this is a back/forward to the same page content (e.g. the user pressed // back after clicking a first anchor, returning to the no-hash state for the current page). // In that case, just scroll to top β€” no server round-trip needed. const targetPath = evt.state.path.split('#')[0] const loadedPath = pageData?.path ? (baseUrl.replace(/\/$/, '') + pageData.path) : '' if (loadedPath && normalizePath(targetPath.replace(baseUrl, '')) === normalizePath(loadedPath.replace(baseUrl, ''))) { const scrollContainer = document.querySelector('main > section') || document.documentElement scrollContainer.scrollTo({ top: 0, behavior: 'smooth', }) } else { // Different page β€” trigger SPA navigation isHandlingPopstate = true navigate(evt.state.path, false) } } }) // Set initial state let initialPath = location.href // Remove origin initialPath = initialPath.replace(window.location.origin, '') // If the initialPath === baseURL without trailing slash, set to baseURL if (initialPath === baseUrl.replace(/\/$/, '')) { initialPath = baseUrl } setupPageTitleResetLink() // console.log('Setting initial history state:', initialPath, { path: initialPath, status: 'initial load', }) history.replaceState({ path: initialPath, status: 'initial load', }, '', initialPath) // If the page loaded with a hash, scroll to that element after content is ready if (location.hash) { const scrollToHash = () => { const targetId = location.hash.slice(1) const targetElement = document.getElementById(targetId) if (targetElement) { targetElement.scrollIntoView({ behavior: 'smooth', block: 'start', }) } } // Use requestAnimationFrame to ensure DOM is ready, with a small delay for content load requestAnimationFrame(() => { setTimeout(scrollToHash, 100) }) } // #endregion --- SPA Navigation --- // #region --- Search functionality --- let searchTimeout = null // Add event listener to close button if (elSearchResults) { const closeButtons = elSearchResults.querySelectorAll('.search-close') closeButtons.forEach((btnClose) => { btnClose.addEventListener('click', () => { elSearchResults.hidden = true elSearchInput.value = '' elSearchDetails.innerHTML = '' elSearchInput.focus() }) }) } /** Process and display search results * @param {Array<{ title: string, path: string, snippet: string, score?: number }>} data Search results data * @param {string} query The search query */ function doResults(data, query) { if (elSearchQuery) elSearchQuery.textContent = escapeHtml(query) // @ts-ignore if (elSearchCount) elSearchCount.textContent = data.length if (data.length === 0) { elSearchDetails.innerHTML = `<p class="no-results">No results found for "${escapeHtml(query)}"</p>` elSearchResults.hidden = false return } // Determine the current page path for highlighting let currentPath = window.location.pathname if (currentPath.startsWith(baseUrl)) { currentPath = currentPath.slice(baseUrl.length) } currentPath = normalizePath(currentPath) let html = '' data.forEach((item) => { // Check if this result matches the current page const itemPath = normalizePath(item.path) const isCurrentPage = currentPath === itemPath const activeClass = isCurrentPage ? ' search-result-active' : '' // Build tooltip with score if available const scoreTooltip = item.score !== undefined ? `title="Search score: ${item.score.toFixed(2)}"` : '' html += ` <a class="search-result${activeClass}" href="${escapeHtml(item.path)}" ${scoreTooltip}> <strong>${escapeHtml(item.title)}</strong> <small>${escapeHtml(item.snippet)}</small> </a> ` }) if (elSearchDetails) elSearchDetails.innerHTML = html if (elSearchResults) elSearchResults.hidden = false } /** Update search result highlighting based on current page */ function updateSearchResultHighlight() { if (elSearchResults.hidden) return let currentPath = window.location.pathname if (currentPath.startsWith(baseUrl)) { currentPath = currentPath.slice(baseUrl.length) } currentPath = normalizePath(currentPath) const resultLinks = elSearchDetails.querySelectorAll('.search-result') resultLinks.forEach((link) => { const href = link.getAttribute('href') if (!href) return const linkPath = normalizePath(href) if (currentPath === linkPath) { link.classList.add('search-result-active') } else { link.classList.remove('search-result-active') } }) } /** Handle input event on search input - ask server for search results * Sends an "internal" control message to uibuilder to request search */ if (elSearchInput) elSearchInput.