/* +-------------------------------------------------------------------------+ | Copyright (C) 2004-2026 The Cacti Group | | | | This program is free software; you can redistribute it and/or | | modify it under the terms of the GNU General Public License | | as published by the Free Software Foundation; either version 2 | | of the License, or (at your option) any later version. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY; without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU General Public License for more details. | +-------------------------------------------------------------------------+ | Cacti: The Complete RRDTool-based Graphing Solution | +-------------------------------------------------------------------------+ | This code is designed, written, and maintained by the Cacti Group. See | | about.php and/or the AUTHORS file for specific developer information. | +-------------------------------------------------------------------------+ | http://www.cacti.net/ | +-------------------------------------------------------------------------+ */ window.midwinter = window.midwinter || {}; midwinter.navigationBox = { _plugins: {}, /** * registers a plugin by its namespace path * @param {string} path - string path to the plugin */ registerPlugin: function (path) { const parts = path.split('.'); const plugin = parts.reduce((obj, key) => (obj && obj[key] ? obj[key] : null), window); if (plugin && typeof plugin.getDefaultConfig === 'function') { // Check if the plugin is actually allowed to run (e.g., dependency check) const config = plugin.getDefaultConfig(); if (config !== null) { const type = parts.pop(); this._plugins[type] = plugin; // console.log(`[Plugin] Registered: ${type}`); } else { // Plugin aborted registration itself due to missing dependencies console.warn(`[Plugin] Registration aborted for ${path} (dependencies not met).`); } } }, buildConfigs: function (boxDefinitions) { return boxDefinitions.map(def => { if (def.type && this._plugins[def.type]) { // get the default config from the plugin const config = this._plugins[def.type].getDefaultConfig(def.overrides || {}); if (config) { // we just ensure the type is set so add() can pick it up config.pluginType = def.type; } return config; } return def; }).filter(config => config !== null); }, refreshPlugins: function() { Object.keys(this._plugins).forEach(type => { const plugin = this._plugins[type]; // Only target boxes that have the data-refresh attribute set to true $(`[data-plugin="${type}"][data-refresh="true"]`).each((index, el) => { const $box = $(el); if (typeof plugin.content === 'function') { const content = plugin.content($box); if (content !== null && content !== undefined) { // Plugins may return live widgets; sanitize string markup without // rebuilding DOM objects or losing their event handlers. $box.children('.navBox-content').empty().append( typeof content === 'string' ? DOMPurify.sanitize(content) : content ); } } if (typeof plugin.init === 'function') { plugin.init($box); } }); }); } } class cactiNavigation { #refreshStorage = false; #prefix = 'mdw'; #storageKey = 'navigationBox'; target; #nav = { refs: {}, session: {}, storage: typeof Storages !== 'undefined' ? Storages.localStorage : null }; #navOptions = { container: `${this.#prefix}-SideBarContainer`, dock: { enabled: true, wrapper: `${this.#prefix}-Main`, top: true, left: true, right: true, bottom: true }, overlay: {enabled: true, left: true, right: true}, window: {enabled: true, wrapper: `${this.#prefix}-Windows`} }; constructor(options = {}) { /* identify caller */ this.target = new.target; /* restore local storage */ this._restoreLocalStorage(); /* setup limited to parent instance only */ if (this.target === cactiNavigation) { this._validateOptions(options, this.#navOptions); this.#nav.session = $.extend(true, {}, this.#navOptions, this.#nav.session); this._checkContainers(); this._initDockResizables(); this._applySavedSplits(); this._refreshLocalStorage(this.#refreshStorage); } } _restoreLocalStorage() { if (this.#nav.storage?.isSet(this.#storageKey)) { try { const data = this.#nav.storage.get(this.#storageKey); this.#nav.session = typeof data === 'string' ? JSON.parse(data) : data; // Handle potential double stringification if (typeof this.#nav.session === 'string') { this.#nav.session = JSON.parse(this.#nav.session); } } catch (e) { console.error("Navigation: Storage data corrupted."); } } if (!this._isObject(this.#nav.session)) { this.#nav.session = $.extend(true, {}, this.#navOptions); this._refreshLocalStorage(true); } } _refreshLocalStorage(force = false) { if (force === true && this.#nav.storage) { this.#nav.storage.set(this.#storageKey, JSON.stringify(this.#nav.session)); } } _checkContainers() { const session = this.#nav.session; this._ensureContainer(session.container); if (session.dock.enabled) { const $wrapper = $(`#${session.dock.wrapper}`); if ($wrapper.length) { const positions = ["top", "left", "right", "bottom"]; for (const [pos, value] of Object.entries(session.dock)) { if (positions.includes(pos) && value === true) { const name = `${this.#prefix}-Dock${this._ucFirst(pos)}`; const dirs = ['left', 'right'].includes(pos) ? ['Top', 'Bottom'] : ['Left', 'Right']; const innerHtml = dirs.map(d => `
`).join(''); this._ensureContainer(name, $wrapper, innerHtml); } } } else { session.dock.enabled = false; this.#refreshStorage = true; } } // restore dock with / height const savedSizes = this.#nav.session.dockSizes || {}; for (const [dockId, size] of Object.entries(savedSizes)) { const $dock = $(`#${this.#prefix}-Dock${dockId}`); if ($dock.length) { const dimension = (dockId === 'Top' || dockId === 'Bottom') ? 'height' : 'width'; $dock.css(dimension, size); } } } _initDockResizables() { const prefix = this.#prefix; const self = this; const docks = [ { id: 'Top', handle: 's', dim: 'height' }, { id: 'Bottom', handle: 'n', dim: 'height' }, { id: 'Left', handle: 'e', dim: 'width' }, { id: 'Right', handle: 'w', dim: 'width' } ]; docks.forEach(dock => { const $el = $(`#${prefix}-Dock${dock.id}`); if (!$el.length) return; $el.resizable({ handles: dock.handle, stop: function(event, ui) { const newVal = ui.size[dock.dim] + 'px'; if (!self.#nav.session.dockSizes) self.#nav.session.dockSizes = {}; self.#nav.session.dockSizes[dock.id] = newVal; self._refreshLocalStorage(true); } }); }); // built dock container split $(`.${prefix}-DockInnerTop, .${prefix}-DockInnerLeft`).each(function() { const $this = $(this); const isVerticalSplit = $this.hasClass(`${prefix}-DockInnerTop`); const handle = isVerticalSplit ? 's' : 'e'; const dimension = isVerticalSplit ? 'height' : 'width'; $this.resizable({ handles: handle, resize: function(event, ui) { const $parent = $this.parent(); const parentSize = isVerticalSplit ? $parent.innerHeight() : $parent.innerWidth(); const currentSize = isVerticalSplit ? $this.outerHeight() : $this.outerWidth(); // calculate split percentage value of split position in relation to outer width or height // this is important to keep relation if user resizes the browser window (e.g. switch to fullscreen) let percent = (currentSize / parentSize) * 100; percent = Math.max(10, Math.min(90, percent)); // Safety-Margin // fix position in DOM $this.css(dimension, percent + '%'); $this.siblings().css(dimension, (100 - percent) + '%'); // keep layout persistent const dockId = $parent.attr('id'); if (!self.#nav.session.dockSplits) self.#nav.session.dockSplits = {}; self.#nav.session.dockSplits[dockId] = percent; }, stop: function(event, ui) { // avoid updating local storage too often self._refreshLocalStorage(true); } }); }); } _applySavedSplits() { const splits = this.getConfig('dockSplits'); if (!splits || !this._isObject(splits)) return; for (const [dockId, percent] of Object.entries(splits)) { const $dock = $(`#${dockId}`); if (!$dock.length) continue; const isVerticalDock = dockId.includes('Left') || dockId.includes('Right'); const firstClass = isVerticalDock ? 'Top' : 'Left'; const secondClass = isVerticalDock ? 'Bottom' : 'Right'; const dimension = isVerticalDock ? 'height' : 'width'; const $first = $dock.find(`.${this.#prefix}-DockInner${firstClass}`); const $second = $dock.find(`.${this.#prefix}-DockInner${secondClass}`); if ($first.length && $second.length) { $first.css(dimension, percent + '%'); $second.css(dimension, (100 - percent) + '%'); } } } _ensureContainer(id, parent = 'body', content = '') { let $el = $(`#${id}`); if (!$el.length) { $el = $(`
${content}
`).appendTo(parent); } return $el; } _validateOptions(options, defaults) { if (!this._isObject(options)) return; for (const [key, value] of Object.entries(options)) { if (defaults.hasOwnProperty(key)) { const defaultType = typeof defaults[key]; const valueType = typeof value; // Allow type match OR allow an object if the default was a string // (to support our {on, off} icon/tooltip logic) if (defaultType === valueType || (defaultType === 'string' && valueType === 'object')) { if (this._isObject(value) && this._isObject(defaults[key])) { // Deep merge for nested objects (like 'dock' or 'layout') this._validateOptions(options[key], defaults[key]); } else { // Direct assignment for primitives or our special toggle-objects defaults[key] = value; if (this.target === cactiNavigation) { this.#refreshStorage = true; } } } else { console.warn(`Navigation: Type mismatch for property '${key}'. Expected ${defaultType}, got ${valueType}.`); } } } } /* helper & utility methods */ _readConfigOption(obj, option = '', defVal = false) { const session = this.#nav.session; if (session.hasOwnProperty(obj)) { if (option === '') return session[obj]; if (session[obj].hasOwnProperty(option)) return session[obj][option]; } return defVal; } _isFunction(funcName) { if (!funcName || funcName === '') return false; // check local instance method if (typeof this[funcName] === 'function') return true; // helper to resolve path in a root object const resolvePath = (path, root) => { if (!root) return undefined; return path.split('.').reduce((prev, curr) => prev ? prev[curr] : undefined, root); }; // check in registry if (typeof registry !== 'undefined') { if (typeof resolvePath(funcName, registry) === 'function') return true; } // check in window scope (supporting dot notation) return typeof resolvePath(funcName, window) === 'function'; } _runFunction(path, args = null) { if (!path || typeof path !== 'string') return ''; try { let func = undefined; let context = null; // search in current class instance if (typeof this[path] === 'function') { func = this[path]; context = this; } // helper to resolve path and keep track of the parent (context) const resolvePath = (root) => { let parent = null; const target = path.split('.').reduce((obj, key) => { parent = obj; return (obj && typeof obj === 'object') ? obj[key] : undefined; }, root); return { target, parent }; }; // search in registry if (typeof func !== 'function' && typeof registry !== 'undefined') { const res = resolvePath(registry); func = res.target; context = res.parent; } // fallback: window scope if (typeof func !== 'function') { const res = resolvePath(window); func = res.target; context = res.parent; } // execution if (typeof func === 'function') { // use apply to maintain context and support multiple arguments (like key, opt) const finalArgs = Array.isArray(args) ? args : [args]; return func.apply(context, finalArgs); } else { console.warn(`Navigation: Path '${path}' not found.`); } } catch (e) { console.error(`Navigation: Error executing function at path '${path}':`, e); } return ''; } _runKeyEvent(event) { // Only trigger on Enter (13) or Space (32) if (!(event.keyCode === 13 || event.keyCode === 32)) return; event.preventDefault(); if (!(event.originalEvent?.repeat || event.repeat)) $(event.currentTarget).trigger('click'); } _isObject(obj) { return obj !== null && typeof obj === 'object' && !Array.isArray(obj); } _ucFirst(str) { return str.charAt(0).toUpperCase() + str.slice(1); } _getBox(helper) { return $(`[class^="${this.#prefix}-ConsoleNavigationBox"][data-helper="${helper}"]`); } _getButton(helper) { return $(`[class^="compact_nav_icon"][data-helper="${helper}"]`); } getConfig(path = '') { return path.split('.').reduce((obj, key) => obj?.[key], this.#nav.session); } _updateNavigationBox($box, status = null) { if (typeof status === "boolean") { $box.toggleClass('visible', status); } else { $box.toggleClass('visible'); } } _updateNavigationButton(helper, status = false) { // uses the existing _getButton helper this._getButton(helper).toggleClass('selected', status); } _hideSiblings($navigationBox) { const helper = $navigationBox.attr('data-helper'); // find all other visible boxes in the same container and hide them $navigationBox.parent().find(`[class^='${this.#prefix}-ConsoleNavigationBox']:not([data-helper='${helper}'])`) .each((_, el) => { const $el = $(el); $el.removeClass('visible').attr('data-status', 'closed'); this._updateNavigationButton($el.attr('data-helper'), false); // ensure persistence window.dispatchEvent(new CustomEvent('box:stateChanged', { detail: { helper: $el.attr('data-helper'), attributes: ['status'] } })); }); } _checkDockInnerSiblings($destination) { const $sibling = $destination.siblings().eq(0); // combined check: Box must be logically OPEN and visually VISIBLE const hasChildVisible = $destination.find('[data-status="open"]').length > 0; const hasNieceVisible = $sibling.find('[data-status="open"]').length > 0; $sibling.toggleClass('noSibling', !hasChildVisible && hasNieceVisible); $destination.toggleClass('noSibling', hasChildVisible && !hasNieceVisible); } checkConfigurationIntegrity(boxConfigs, btnConfigs) { const boxHelpers = boxConfigs.map(b => b.helper); const btnHelpers = btnConfigs.map(b => b.helper); // CRITICAL: Check for Boxes that have no Button to open them boxHelpers.forEach(helper => { if (!btnHelpers.includes(helper)) { console.error(`Navigation Integrity Error: Box '${helper}' is defined but has no corresponding Button. It will be unreachable in the UI!`); } }); // INFO: Buttons without boxes are allowed (standalone actions) btnHelpers.forEach(helper => { if (!boxHelpers.includes(helper)) { console.info(`Navigation Info: Button '${helper}' is a standalone action (no box linked).`); } }); } toggleBox(helper, action = 'toggle', filter = null) { const $box = this._getBox(helper); if (!$box.length) return; const style = $box.attr('data-style'); const isExclusive = $box.attr('data-exclusive') !== 'false'; // Default to true const currentStatus = $box.attr('data-status'); const isVisible = currentStatus === 'open'; // determine if we are about to open the box const opening = (action === 'force_open' || (action === 'toggle' && !isVisible)); if (opening) { if (style === 'dock') { this._hideSiblings($box); } else if (isExclusive) { this.closeAllBoxes(helper); // Close other overlays } $box.addClass('visible'); $box.attr('data-status', 'open'); this._updateNavigationButton(helper, true); // Handle search filter if provided if (filter) { const $searchField = $box.find("input[name='navBox-header-search']"); $box.find('.navBox-header-search').removeClass('hide'); if (filter !== 'reset') { $searchField.focus().val(filter).trigger('input'); } else { $searchField.val('').trigger('input').blur(); } } } else { // closing logic $box.removeClass('visible'); $box.attr('data-status', 'closed'); this._updateNavigationButton(helper, false); } // Always check siblings for dock style if (style === 'dock') { this._checkDockInnerSiblings($box.parent()); } if (style === 'dock' || style === 'window') { // synchronize internal cache and storage of a boxes with static style type window.dispatchEvent(new CustomEvent('box:stateChanged', { detail: { helper: helper, attributes: ['status'] } })); } } closeAllBoxes(excludeHelper = null) { // Only close overlays, as docked boxes are usually permanent $(`[class*="ConsoleNavigationBox"][data-style="overlay"].visible`).each((_, el) => { const helper = $(el).attr('data-helper'); if (helper !== excludeHelper) { this.toggleBox(helper, 'force_close'); } }); } setBoxPresence(helper, visible) { const $box = this._getBox(helper); if (!$box.length) return; const currentStatus = $box.attr('data-status'); // 'open' or 'closed' const isUserOpen = (currentStatus === 'open'); if (visible) { // CONTENT IS THERE: // Only show if the user actually wants it open (respect storage) if (isUserOpen) { this._updateNavigationBox($box, true); } } else { // NO CONTENT: // Hide visually, but DO NOT change data-status or fire events this._updateNavigationBox($box, false); } // Always update Dock layout if it's a dock style if ($box.attr('data-style') === 'dock') { this._checkDockInnerSiblings($box.parent()); } } } class cactiButton extends cactiNavigation { constructor() { super(); } add(options = {}) { const config = { title : '', helper : '', tooltip : '', iconClass : '', destination : '', onclick : 'auto', param : 'on', enabled : true, }; // validate and merge this._validateOptions(options, config); // early exit if button is disabled (e.g., permissions) if (config.enabled === false) return this; // handle potential object for toggling icons const icon = typeof config.iconClass === 'object' ? config.iconClass.off : config.iconClass; const tooltip = typeof config.tooltip === 'object' ? config.tooltip.off : config.tooltip; // use 'toggleConsoleNavigationBox' if set to 'auto' const clickFunc = config.onclick === 'auto' ? 'toggleConsoleNavigationBox' : config.onclick; // build HTML const containerHtml = `
`; const $destination = $(config.destination); if (!$destination.length) { console.warn(`Button: Destination '${config.destination}' not found for '${config.helper}'.`); return this; } // Avoid Duplicates and Append let $button = $destination.find(`[data-helper="${config.helper}"]`); if ($button.length === 0) { $button = $(containerHtml).appendTo($destination); // Bind Events if (this._isFunction(clickFunc)) { $button.on("click", { param: config.param }, (e) => { // First priority: Method on this instance (like toggleConsoleNavigationBox) if (typeof this[clickFunc] === 'function') { this[clickFunc](e); } // Second priority: Global window function else if (typeof window[clickFunc] === 'function') { window[clickFunc](e); } }); $button.on('keydown', (e) => this._runKeyEvent(e)); } } return this; } show(helper) { this._getButton(helper).removeClass('hide'); return this; } hide(helper) { this._getButton(helper).addClass('hide'); return this; } toggleConsoleNavigationBox(event) { const $caller = $(event.currentTarget); const helper = $caller.attr('data-helper'); const param = event.data?.param; // 'on', 'force_open', 'force_close' const filter = event.data?.filter; /* hide any open context menus */ $('.context-menu-list').trigger('contextmenu:hide'); // map the param to our new toggleBox actions let action = 'toggle'; if (param === 'force_open') action = 'force_open'; if (param === 'force_close') action = 'force_close'; if (param === 'on' && $caller.hasClass('selected')) action = 'force_close'; if (param === 'on' && !$caller.hasClass('selected')) action = 'force_open'; // execute central logic this.toggleBox(helper, action, filter); // re-initialize resizable if box just became visible const $box = this._getBox(helper); if ($box.hasClass('visible') && $box.attr('data-style') === 'overlay' && typeof $.fn.resizable === 'function') { $box.resizable({ handles: 'e' }); } } } class cactiBox extends cactiNavigation { #searchTimer = null; constructor(options = {}) { super(); // dynamic prefix detection this.prefix = this.getConfig('container').split('-')[0]; // merge options over defaults const config = { className: 'ConsoleNavigationBox', ...options }; // same classes for all boxes this.baseClass = `${this.prefix}${config.className}`; this.className = config.className; // unique storage key this.storageKey = `navigationBoxStates`; // cache local storage const saved = localStorage.getItem(this.storageKey); this.allSavedStates = saved ? JSON.parse(saved) : {}; // listen to our parent for synchronization window.addEventListener('box:stateChanged', (e) => { const { helper, attributes } = e.detail; this.#saveBoxState(helper, attributes); }); } add(options = {}) { let config = { title : 'box_title', helper : 'helper', buttons : { close: true, search: '', menu: true }, content : '', contentLoader : '', contextMenuItems: '', layout : { style: 'overlay', dock: 'top-left', overlay: 'auto', align: 'left', height: 'full', exclusive: true }, header : '', control : '', controlLoader : '', status : 'closed', initCallback : '', pluginType : '', isRefreshable : false, }; // validate and merge this._validateOptions(options, config); // persistence check const saveStates = this.allSavedStates[config.helper]; if (saveStates) { // overwrite layout values with stored ones config.status = saveStates.status || config.status; config.layout.height = saveStates.height || config.layout.height; config.layout.width = saveStates.width || config.layout.width; config.layout.align = saveStates.align || config.layout.align; config.layout.style = saveStates.style || config.layout.style; config.layout.dock = saveStates.dock || config.layout.dock; config.layout.overlay = saveStates.overlay || config.layout.overlay; config.layout.exclusive = saveStates.exclusive || config.layout.exclusive; // check if we have a style specif width value stored const styleSpecificWidth = saveStates[`width_${config.layout.style}`]; if (styleSpecificWidth) { config.width = styleSpecificWidth; } else { config.width = saveStates.width || config.width; } } if (!config.header) config.header = config.title; const { buttons, helper, layout, title, header, width } = config; // generate UI components const btnSearch = buttons.search ? `` : ''; const inputSearch = buttons.search ? `` : ''; const btnMenu = buttons.menu ? `` : ''; const btnClose = buttons.close ? `` : ''; const rightButtons = (btnMenu + btnClose) || ''; // assemble container const containerHtml = `
`; return this.#build(config, containerHtml); } #build(config, containerHtml) { const destinationId = this._readConfigOption('container'); const $box = $(containerHtml).appendTo(`#${destinationId}`); // Load after the shell exists so plugins can return live DOM nodes and // retain their event handlers instead of being converted to strings. const content = config.content || (config.contentLoader ? this._runFunction(config.contentLoader, $box) : ''); const control = config.control || (config.controlLoader ? this._runFunction(config.controlLoader, $box) : ''); $box.children('.navBox-content').append(content); $box.children('.navBox-control').append(control); // register close button if (config.buttons.close) { $box.find('[data-action="close"]').on('click', () => { this.toggleBox(config.helper, 'force_close'); }); } // register menu button if (config.buttons.menu) { $.contextMenu({ // ensure memory isolation selector: `.${this.prefix}-${this.className}[data-helper="${config.helper}"] .context-menu`, trigger: 'left', autoHide: false, items: this.#prepareContextMenuItems(config) }); } // register search button if (config.buttons.search) { const $input = $box.find('input[name="navBox-header-search"]'); const $searchBtn = $box.find('[data-action="search"]'); $searchBtn.on('click', function(e) { const isPressed = $(this).attr('aria-pressed') === 'true'; const $header = $(this).closest('.navBox-header'); $(this).attr('aria-pressed', !isPressed); $header.toggleClass('search-active', !isPressed); if (!isPressed) { setTimeout(() => $input.focus(), 100); } else { $input.val('').trigger('input').blur(); } e.preventDefault(); }); const searchPath = config.buttons.search; // debounce logic to keep CPU utilization low const runDebouncedSearch = this.#debounce((query) => { this._runFunction(searchPath, { query: query, helper: config.helper, $box: $box }); }, 250); $input.on("input", (e) => { const query = $(e.currentTarget).val(); // Trigger the debounced function instead of running it immediately runDebouncedSearch(query); }); } // save box states if states are not in cache ( and not in local storage, too) if(!this.allSavedStates[config.helper]) { this.#saveBoxState(config.helper); } // execute initCallback if defined (e.g., to start first navigationBox plugin 'tree') if (config.initCallback) { this._runFunction(config.initCallback, $box); } // return the jQuery object return $box; } #prepareContextMenuItems(config) { // define basic menu structure let menuItems = { "search": { name: "Search", icon: "ti ti-search", callback: (key, opt) => this.#setFocusSearch(opt) } }; // recursive helper to process nested items const processItems = (items) => { let processed = {}; for (let [key, item] of Object.entries(items)) { // handle separators if (typeof item === 'string') { processed[key] = item; continue; } // create copy to avoid reference issues let newItem = { ...item }; const safeKey = `plugin_${key}`; // Plugin labels are text. Explicit HTML items use the shared // reviewed boundary and fall back to a text node if unavailable. newItem.isHtmlName = false; if (newItem.type === 'html') { newItem.html = window.CactiRenderer && typeof window.CactiRenderer.menuFragment === 'function' ? window.CactiRenderer.menuFragment(newItem.html) : document.createTextNode(typeof newItem.html === 'string' ? newItem.html : ''); } // process sub-items if they exist if (newItem.items && typeof newItem.items === 'object') { newItem.items = processItems(newItem.items); } // bind string-based callbacks to our safe runner if (typeof newItem.callback === 'string') { const funcPath = newItem.callback; newItem.visible = () => this._isFunction(funcPath); newItem.callback = (key, opt) => this._runFunction(funcPath, [key, opt]); } processed[safeKey] = newItem; } return processed; }; // process and merge menu items added by plugins if (config.contextMenuItems && Object.keys(config.contextMenuItems).length > 0) { menuItems["sep_plugin"] = "---------"; const pluginItems = processItems(config.contextMenuItems); menuItems = { ...menuItems, ...pluginItems }; } // add remaining default options menuItems["sep_style"] = "---------"; menuItems["style"] = { name: "Display Style", items: { "dock": { name: "Dock", icon: "ti ti-layout-sidebar", disabled: (key, opt) => this.#isDisabled('style', key, opt), callback: (key, opt) => this.#setDisplayStyle(key, opt), visible: (key, opt) => this._readConfigOption(key, 'enabled') }, "overlay": { name: "Sidebar", icon: "ti ti-layout-sidebar-filled", disabled: (key, opt) => this.#isDisabled('style', key, opt), callback: (key, opt) => this.#setDisplayStyle(key, opt), visible: (key, opt) => this._readConfigOption(key, 'enabled') }, "window": { name: "Window", icon: "ti ti-box-model-2", disabled: (key, opt) => this.#isDisabled('style', key, opt), callback: (key, opt) => this.#setDisplayStyle(key, opt), visible: (key, opt) => this._readConfigOption(key, 'enabled') } } } menuItems["sep_dock"] = "---------"; menuItems["dock"] = { name: "Dock", items: { "left-top": { name: "Top Left", icon: "ti ti-box-align-top-left", disabled: (key, opt) => this.#isDisabled('dock',key, opt), callback: (key, opt) => this.#setDockPosition('left-top', opt), visible: (key, opt) => this._readConfigOption('dock', 'top') }, "right-top": { name: "Top Right", icon: "ti ti-box-align-top-right", disabled: (key, opt) => this.#isDisabled('dock',key, opt), callback: (key, opt) => this.#setDockPosition('right-top', opt), visible: (key, opt) => this._readConfigOption('dock', 'top') }, "top-left": { name: "Left Top", icon: "ti ti-box-align-left", disabled: (key, opt) => this.#isDisabled('dock', key, opt), callback: (key, opt) => this.#setDockPosition('top-left', opt), visible: (key, opt) => this._readConfigOption('dock', 'left') }, "bottom-left": { name: "Left Bottom", icon: "ti ti-box-align-left", disabled: (key, opt) => this.#isDisabled('dock',key, opt), callback: (key, opt) => this.#setDockPosition('bottom-left', opt), visible: (key, opt) => this._readConfigOption('dock', 'left') }, "top-right": { name: "Right Top", icon: "ti ti-box-align-right", disabled: (key, opt) => this.#isDisabled('dock',key, opt), callback: (key, opt) => this.#setDockPosition('top-right', opt), visible: (key, opt) => this._readConfigOption('dock', 'right') }, "bottom-right": { name: "Right Bottom ", icon: "ti ti-box-align-right", disabled: (key, opt) => this.#isDisabled('dock',key, opt), callback: (key, opt) => this.#setDockPosition('bottom-right', opt), visible: (key, opt) => this._readConfigOption('dock', 'right') }, "left-bottom": { name: "Bottom Left", icon: "ti ti-box-align-bottom-left", disabled: (key, opt) => this.#isDisabled('dock',key, opt), callback: (key, opt) => this.#setDockPosition('left-bottom', opt), visible: (key, opt) => this._readConfigOption('dock', 'bottom') }, "right-bottom": { name: "Bottom Right", icon: "ti ti-box-align-bottom-right", disabled: (key, opt) => this.#isDisabled('dock',key, opt), callback: (key, opt) => this.#setDockPosition('right-bottom', opt), visible: (key, opt) => this._readConfigOption('dock', 'bottom') }, }, visible: (key, opt) => this._readConfigOption(key, 'enabled') }; menuItems["overlay"] = { name: "Sidebar", disabled: (key, opt) => !this.#isDisabled('style',key, opt), items: { "left": { name: "Left", icon: "ti ti-layout-sidebar-left-collapse", disabled: (key, opt) => this.#isDisabled('align',key, opt), callback: (key, opt) => this.#setDataAttribute('align', key, opt) }, "right": { name: "Right", icon: "ti ti-layout-sidebar-right-collapse", disabled: (key, opt) => this.#isDisabled('align',key, opt), callback: (key, opt) => this.#setDataAttribute('align', key, opt) }, "sep": "---------", "manual": { name: "Manual", icon: "ti ti-arrow-bar-both", disabled: (key, opt) => this.#isDisabled('overlay',key, opt), callback: (key, opt) => this.#setOverlaySize(key, opt) }, "sep1": "---------", "auto": { name: "Auto", icon: "ti ti-arrow-autofit-width", disabled: (key, opt) => this.#isDisabled('overlay',key, opt), callback: (key, opt) => this.#setOverlaySize(key, opt) }, "maximum": { name: "Maximum", icon: "ti ti-arrow-bar-to-right-dashed", disabled: (key, opt) => this.#isDisabled('overlay',key, opt), callback: (key, opt) => this.#setOverlaySize(key, opt) }, "minimum": { name: "Minimum", icon: "ti ti-arrow-bar-to-left-dashed", disabled: (key, opt) => this.#isDisabled('overlay',key, opt), callback: (key, opt) => this.#setOverlaySize(key, opt) }, }, visible: (key, opt) => this._readConfigOption(key, 'enabled') }; menuItems["sep_footer"] = "---------"; menuItems["hide"] = { name: "Close", icon: "ti ti-x", callback: function() { return true; } }; return menuItems; } #setDisplayStyle(mode, opt) { let box = opt.$trigger.parents(`div.${this.prefix}-${this.className}`)[0]; if (!box) return; box.dataset.style = mode; const $box = opt.$trigger.closest('[class*="ConsoleNavigationBox"]'); //$box.attr("data-style", mode); mode === "dock" ? this.#moveToDock($box) : this.#moveToSideBar($box); // ensure persistence const helper = box.dataset.helper; this.#saveBoxState(helper); } #setDockPosition(mode, opt) { let box = opt.$trigger.parents(`div.${this.prefix}-${this.className}`)[0]; if (!box) return; box.dataset.dock = mode; this.#setDisplayStyle("dock", opt); } #moveToDock($box, restoral = null) { const [inner, outer] = $box.attr("data-dock").split('-'); const prefix = $box.attr('class').split('-')[0]; const destinationSelector = `#${prefix}-Dock${this._ucFirst(outer)} > .${prefix}-DockInner${this._ucFirst(inner)}`; const $destination = $(destinationSelector); const $oldParent = $box.parent(); $box.detach().appendTo($destination); if (typeof restoral !== "boolean") { console.log('here') this._updateNavigationButton($box, true); this._hideSiblings($box); if ($oldParent.is(`[class*="DockInner"]`)) { this._checkDockInnerSiblings($oldParent); } } this._checkDockInnerSiblings($destination); } #moveToSideBar($box) { const $oldParent = $box.parent(); const containerId = this.getConfig('container'); $box.detach().appendTo(`#${containerId}`); if ($oldParent.is(`[class*="DockInner"]`)) this._checkDockInnerSiblings($oldParent); this._hideSiblings($box); } #setFocusSearch(opt) { let box = opt.$trigger.parents(`div.${this.prefix}-${this.className}`)[0]; if (!box) return; const searchBtn = box.querySelector('[data-action="search"]'); if (searchBtn) { searchBtn.click(); } } #setDataAttribute(attr, value, opt) { let box = opt.$trigger.parents(`div.${this.prefix}-${this.className}`)[0]; if (!box) return; box.dataset[attr] = value; // ensure persistence const helper = box.dataset.helper; this.#saveBoxState( helper,[attr] ); } #isDisabled(attr, value, opt) { const box = opt.$trigger.closest(`div.${this.prefix}-${this.className}`)[0]; if (!box) return; return value === box.dataset[attr]; } #setOverlaySize(mode, opt) { const box = opt.$trigger.closest(`div.${this.prefix}-${this.className}`)[0]; if (!box) return; box.dataset.overlay = mode; switch (mode) { case "manual": box.dataset.width = '0'; break; case "auto": box.dataset.width = 'auto' box.style.width = 'auto'; break; case "maximum": box.dataset.width = '0'; box.style.width = 'auto'; break; case "minimum": box.dataset.width = '0'; box.style.width = 'min-content'; break; default: } // ensure persistence const helper = box.dataset.helper; this.#saveBoxState(helper, ['style', 'overlay', 'width']); } #saveBoxState(helper, attributes = null) { const boxSelector = `[class*="NavigationBox"][data-helper="${helper}"]`; const box = document.querySelector(boxSelector); if (!box) return; let currentData = { ...(this.allSavedStates[helper] || {}) }; if (attributes && Array.isArray(attributes)) { // update only dedicated attributes attributes.forEach(attr => { if (box.dataset[attr] !== undefined) { currentData[attr] = box.dataset[attr]; } }); } else { // update all attributes (except title) const newData = { ...box.dataset }; delete newData.title; // merge new over old data currentData = { ...currentData, ...newData }; } // cache style specific width const currentStyle = box.dataset.style; const currentWidth = box.dataset.width; if (currentStyle && currentWidth) { currentData[`width_${currentStyle}`] = currentWidth; } // updated internal cache this.allSavedStates[helper] = currentData; // finally update local storage localStorage.setItem(this.storageKey, JSON.stringify(this.allSavedStates)); } #debounce(callback, delay = 250) { return (...args) => { clearTimeout(this.#searchTimer); this.#searchTimer = setTimeout(() => { callback.apply(this, args); }, delay); }; } searchToHighlight(data) { const query = typeof data?.query === 'string' ? data.query : ''; const $container = data.$box ? data.$box.find('.navBox-content') : this._getBox(data.helper).find('.navBox-content'); if (!$container.length) return; $container.find('mark[data-cacti-navigation-match]').each(function() { const parent = this.parentNode; this.replaceWith(...this.childNodes); parent.normalize(); }); const literal = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp(literal, 'iu'); $container.find('li.menuitem').each(function() { $(this).toggleClass('hide', query !== '' && !pattern.test(this.textContent)); }); if (!query) return; const walker = document.createTreeWalker($container[0], NodeFilter.SHOW_TEXT); const nodes = []; while (walker.nextNode()) { if (!walker.currentNode.parentElement.closest('script, style, textarea')) { nodes.push(walker.currentNode); } } nodes.forEach(node => { const text = node.textContent; const matches = Array.from(text.matchAll(new RegExp(literal, 'giu'))); if (!matches.length) return; const fragment = document.createDocumentFragment(); let offset = 0; matches.forEach(match => { fragment.append(document.createTextNode(text.slice(offset, match.index))); const mark = document.createElement('mark'); mark.setAttribute('data-cacti-navigation-match', ''); mark.textContent = match[0]; fragment.append(mark); offset = match.index + match[0].length; }); fragment.append(document.createTextNode(text.slice(offset))); node.replaceWith(fragment); }); } restore(helper) { // fetch box-specific state from 'navigationBoxStates' const savedBox = this.allSavedStates[helper]; // exit if no state exists if (!savedBox) return; // determine the current layout style or fallback to overlay const currentStyle = savedBox.style || 'overlay'; // check if the layout style is globally enabled in the navigation config const isStyleEnabled = this.getConfig(`${currentStyle}.enabled`); if (!isStyleEnabled) { console.warn(`Navigation: cannot restore ${helper} as ${currentStyle} mode is globally disabled.`); return; } const $box = this._getBox(helper); if (!$box.length) return; // apply saved width dimensions for the specific style const savedWidth = savedBox[`width_${currentStyle}`] || savedBox.width; if (savedWidth && savedWidth !== '0' && savedWidth !== 'auto' && savedWidth !== 'undefined') { $box.css('width', savedWidth); } // prevent dynamic boxes ( overlay / window ) from opening automatically during startup if (currentStyle !== 'dock') return; // verify if the specific dock position is allowed by the global configuration if (currentStyle === 'dock') { const [inner, outer] = (savedBox.dock || '').split('-'); const isDockPosEnabled = this.getConfig(`dock.${outer}`); if (!isDockPosEnabled) { console.warn(`Navigation: dock position '${outer}' is globally disabled. skipping restore for ${helper}.`); return; } } // move the box to its designated dock using restoral mode to keep siblings visible if (currentStyle === 'dock') { this.#moveToDock($box, true); } // synchronize the visual state of the box and its corresponding button if (savedBox.status === 'open') { this._updateNavigationBox($box, true); this._updateNavigationButton(helper, true); } } }