/** * Tools Plugin * * Provides the "Tools" button group in the toolbar. The group contains: * - A native split button whose menu is populated at runtime by frontend plugins * via `addMenuItems()` / `clearMenuItems()`. * - Any additional split buttons or controls added by other plugins via * `addButton()` / `removeButton()` (e.g. the backend-plugins plugin). * * The button group is hidden when all its children are hidden, and visible * as soon as any child becomes visible. * * Items added to the native menu can optionally be grouped under a named category * label. The label is created automatically the first time a category is used, and * is hidden automatically via MutationObserver when all items in that category are * hidden by the calling plugin. */ /** * @import { PluginContext } from '../modules/plugin-context.js' * @import { ApplicationState } from '../state.js' * @import { toolsGroupPart } from '../templates/tools-button.types.js' */ import { Plugin } from '../modules/plugin-base.js' import ep from '../extension-points.js' import { registerTemplate, createSingleFromTemplate } from '../modules/ui-system.js' // Register template await registerTemplate('tools-button', 'tools-button.html') class ToolsPlugin extends Plugin { /** @param {PluginContext} context */ constructor(context) { super(context, { name: 'tools', deps: ['logger'] }) } static extensionPoints = [ep.toolbar.contentItems]; /** * Extension point handler for `ep.toolbar.contentItems`. * Called by ToolbarPlugin during start() to collect this plugin's toolbar contribution. * Creates the tools button and wires the scoped UI on first call. * @returns {Array<{element: HTMLElement, priority: number, position: string}>} */ [ep.toolbar.contentItems]() { const buttonElement = createSingleFromTemplate('tools-button') this.#ui = this.createUi(buttonElement) return [{ element: buttonElement, priority: 0, position: "right" }] } /** @type {HTMLElement & toolsGroupPart} */ #ui = null /** @type {HTMLElement[]} */ #uncategorizedItems = [] /** * @type {Map} */ #categoryGroups = new Map() /** MutationObserver that hides/shows category labels when items are toggled. */ #observer = new MutationObserver(() => { this.#updateAllCategoryLabels() this.#updateNativeDropdown() this.#updateGroupVisibility() }) /** @returns {Element} The native frontend tools menu element */ get menu() { return this.#ui.toolsDropdown.toolsMenu } /** * @param {ApplicationState} _state */ async install(_state) { await super.install(_state) const logger = this.getDependency('logger') logger.debug(`Installing plugin "tools"`) } async start() { this.getDependency('logger').debug(`Starting plugin "tools"`) } /** * Add menu items to the native frontend dropdown. * @param {HTMLElement[]} elements - Items to add * @param {string} [category] - Optional category name. A formatted label is inserted * automatically and hidden via MutationObserver when all its items are hidden. */ addMenuItems(elements, category) { const menu = this.#ui.toolsDropdown.toolsMenu if (category) { let group = this.#categoryGroups.get(category) if (!group) { let divider = null if (menu.children.length > 0) { divider = document.createElement('sl-divider') menu.appendChild(divider) } const label = document.createElement('small') label.textContent = this.#formatCategoryName(category) menu.appendChild(label) group = { label, divider, items: [] } this.#categoryGroups.set(category, group) } elements.forEach(el => { menu.appendChild(el) group.items.push(el) this.#observer.observe(el, { attributes: true, attributeFilter: ['style'] }) }) } else { elements.forEach(el => { menu.appendChild(el) this.#uncategorizedItems.push(el) this.#observer.observe(el, { attributes: true, attributeFilter: ['style'] }) }) } this.#updateAllCategoryLabels() this.#updateNativeDropdown() } /** * Clear all items (and category labels) from the native frontend dropdown. */ clearMenuItems() { this.#observer.disconnect() const menu = this.#ui.toolsDropdown.toolsMenu menu.innerHTML = '' this.#uncategorizedItems.length = 0 this.#categoryGroups.clear() this.#updateNativeDropdown() } /** * Add a button or split-button element to the tools button group. * @param {HTMLElement} element */ addButton(element) { this.#ui.appendChild(element) this.#updateGroupVisibility() } /** * Remove a button or split-button element from the tools button group. * @param {HTMLElement} element */ removeButton(element) { if (element.parentNode === this.#ui) { this.#ui.removeChild(element) } this.#updateGroupVisibility() } /** * Show/hide the tools button group based on child visibility. */ updateVisibility() { this.#updateGroupVisibility() } /** Show/hide category labels based on whether their items are all hidden. */ #updateAllCategoryLabels() { this.#categoryGroups.forEach(group => { const allHidden = group.items.every(el => el.style.display === 'none') group.label.style.display = allHidden ? 'none' : '' if (group.divider) group.divider.style.display = allHidden ? 'none' : '' }) } /** Show/hide the native dropdown based on whether any tracked menu item is visible. */ #updateNativeDropdown() { const dropdown = this.#ui.toolsDropdown const allItems = [...this.#uncategorizedItems, ...[...this.#categoryGroups.values()].flatMap(g => g.items)] const hasVisibleItem = allItems.some(el => el.style.display !== 'none') dropdown.style.display = hasVisibleItem ? '' : 'none' this.#updateGroupVisibility() } /** Show the button group when at least one child is visible; hide it otherwise. */ #updateGroupVisibility() { const hasVisibleChild = Array.from(this.#ui.children).some(el => el.style.display !== 'none') this.#ui.style.display = hasVisibleChild ? 'inline-flex' : 'none' } /** * Format a category key into a display label. * @param {string} category * @returns {string} */ #formatCategoryName(category) { return category .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(' ') } } export default ToolsPlugin