UNPKG

tiny-electron-essentials

Version:

A lightweight and modular utility library for Electron apps, offering simplified window management, tray support, IPC channels, and custom frameless window styling.

262 lines (261 loc) 12.3 kB
// @ts-nocheck /** * @typedef {Object} MenuDropdown * @property {string} label - The text label displayed for this menu item. * @property {(this: GlobalEventHandlers, ev: MouseEvent) => any} [onClick] - * The function to execute when this item is clicked. Optional if 'items' is provided. * @property {MenuDropdown[]} [items] - * A list of child menu items for creating submenus. * If provided, this item becomes a parent to a nested dropdown. */ class TinyWindowFrameManager { /** * Add a button to the menu bar with optional dropdown and submenus. * * @param {string} label - The text label of the button. * @param {Object} [settings={}] - Button settings. * @param {(this: GlobalEventHandlers, ev: MouseEvent) => any} [settings.onClick] - Click event handler for the button. * @param {'left'|'right'} [settings.position='left'] - Menu position where the button will be placed. * @param {string} [settings.id] - Optional identifier. * @param {number} [settings.dropdownHideTimeout=400] - Dropdown auto hide timeout. * @param {MenuDropdown[]} [settings.items] - Dropdown items or submenus. * @returns {HTMLButtonElement} - The created button element. * @throws {TypeError} If label is not a string. * @throws {TypeError} If onClick is not a function. * @throws {Error} If position is invalid. */ addMenuButton(label, { onClick, position = 'left', id, items, dropdownHideTimeout = 400 } = {}) { if (typeof label !== 'string' || !label.trim()) throw new TypeError(`Label must be a non-empty string. Received: ${label}`); // Prepare data const menu = this.getMenuElement(position); const btn = document.createElement('button'); btn.classList.add('menu-button'); btn.textContent = label; if (id) btn.dataset.menuId = id; // Has dropdown if (Array.isArray(items) && items.length > 0) { const { dropdown, closeDropdown } = this.createDropdown({ direction: position, hideTimeout: dropdownHideTimeout, onClose: () => updateDropdown(false), items, }); btn.classList.add('has-dropdown'); btn.appendChild(dropdown); /** @param {boolean} isVisible */ const updateDropdown = (isVisible) => { if (!dropdown) return; const bounds = dropdown.getBoundingClientRect(); // const absoluteTop = bounds.top + window.scrollY; const absoluteLeft = bounds.left + window.scrollX; dropdown.style.left = isVisible ? `${absoluteLeft - 10}px` : ''; if (isVisible) { btn.classList.add('active'); dropdown.style.left = `${absoluteLeft - 10}px`; } else { dropdown.style.left = ''; btn.classList.remove('active'); } }; // Button click btn.addEventListener('click', (e) => { e.stopPropagation(); if (!dropdown) return; const isVisible = dropdown.style.display === 'flex'; document.querySelectorAll('.menu-dropdown').forEach((d) => { if (d instanceof HTMLElement) d.style.display = 'none'; }); dropdown.style.display = isVisible ? 'none' : 'flex'; updateDropdown(!isVisible); }); document.addEventListener('click', () => { closeDropdown(); updateDropdown(false); }); } // Simple click else if (typeof onClick === 'function') btn.onclick = onClick; // Nothing else throw new TypeError(`onClick must be a function if no dropdown items are provided.`); // Complete menu.appendChild(btn); this.#checkMenuVisibility(); return btn; } /** * Internal: Create a dropdown container. * * @param {Object} [settings={}] - Dropdown settings. * @param {() => void} [settings.onClose] - Dropdown onClose. * @param {MenuDropdown[]} [settings.items] - List of items or submenus. * @param {'left'|'right'} [settings.direction] - Dropdown opening direction. * @param {number} [settings.hideTimeout=400] - Dropdown auto hide timeout. * @param {number} [settings.layer=1] - Dropdown layer. * @param {() => number} [settings.topBase=() => 0] - Dropdown height base. * @param {(() => void)|null} [settings.secondCloseDropdown=null] - Second dropdown closer. * @returns {{ dropdown: HTMLDivElement; closeDropdown: () => void }} */ createDropdown({ items, onClose, direction = 'right', hideTimeout = 400, layer = 1, topBase = () => 0, secondCloseDropdown = null, } = {}) { // Validate direction if (direction !== 'left' && direction !== 'right') throw new TypeError(`Invalid direction "${direction}". Expected "left" or "right".`); // Validate hideTimeout if (typeof hideTimeout !== 'number' || !Number.isFinite(hideTimeout) || hideTimeout < 0) throw new TypeError(`"hideTimeout" must be a non-negative number.`); // Validate secondCloseDropdown if (secondCloseDropdown !== null && typeof secondCloseDropdown !== 'function') throw new TypeError(`"secondCloseDropdown" must be a function or null.`); // Validate items if (!Array.isArray(items)) throw new TypeError(`"items" must be an array of MenuDropdown objects.`); const dropdown = document.createElement('div'); dropdown.classList.add('menu-dropdown'); if (typeof secondCloseDropdown === 'function') dropdown.classList.add('sub-menu-dropdown'); dropdown.style.flexDirection = 'column'; dropdown.style.display = 'none'; dropdown.style.position = 'absolute'; dropdown.style.top = '100%'; /** @type {{ exec: (() => void)|null, index: number }} */ const stopOtherSubDropdown = { exec: null, index: -1 }; /** @type {(() => void)[]} */ const closesDropdown = []; const closeDropdown = () => { if (dropdown) { dropdown.style.display = 'none'; dropdown.style.left = ''; } for (const callback of closesDropdown) callback(); if (typeof secondCloseDropdown === 'function') secondCloseDropdown(); if (typeof onClose === 'function') onClose(); }; // Items list items.forEach((item, index) => { if (typeof item !== 'object' || !item.label) return; const el = document.createElement('button'); el.textContent = item.label; el.classList.add('menu-item'); el.classList.add(`layer-${layer}`); // More items if (Array.isArray(item.items) && item.items.length > 0) { const { dropdown: subDropdown, closeDropdown: closeSubDropdown } = this.createDropdown({ secondCloseDropdown: closeDropdown, topBase: () => { const elBounds = el.getBoundingClientRect(); return elBounds.top + elBounds.height; }, layer: layer + 1, items: item.items, direction, hideTimeout, }); el.classList.add('has-submenu'); if (typeof secondCloseDropdown === 'function') el.classList.add('sub-has-submenu'); el.appendChild(subDropdown); // Menu event click /** * @param {boolean} isVisible * @param {boolean} [isSpecial] */ const updateDropdown = (isVisible, isSpecial = false) => { if (!subDropdown) return; const dropdownBounds = dropdown.getBoundingClientRect(); const elBounds = el.getBoundingClientRect(); const executeSpecial = () => { if (isSpecial || !stopOtherSubDropdown.exec || stopOtherSubDropdown.index === index) return; stopOtherSubDropdown.exec(); }; if (isVisible) { subDropdown.style.left = `${dropdownBounds.width - 2}px`; const base = layer <= 1 ? 0 : Number(topBase() / 2) - Number(10 * layer); subDropdown.style.top = `${elBounds.top - base - elBounds.height - 10}px`; executeSpecial(); stopOtherSubDropdown.exec = () => hideDropdown(true); stopOtherSubDropdown.index = index; } else { subDropdown.style.left = ''; subDropdown.style.top = ''; executeSpecial(); stopOtherSubDropdown.index = -1; stopOtherSubDropdown.exec = null; } }; closesDropdown.push(() => updateDropdown(false)); /** @type {NodeJS.Timeout|null} */ let hideTimeoutFunc = null; const clearHideTimeout = () => { if (hideTimeoutFunc) { clearTimeout(hideTimeoutFunc); hideTimeoutFunc = null; } }; /** @param {boolean} [isSpecial] */ const hideDropdown = (isSpecial = false) => { if (stopOtherSubDropdown.index !== -1 && stopOtherSubDropdown.index !== index) return; if (subDropdown) subDropdown.style.display = 'none'; updateDropdown(false, isSpecial); }; el.addEventListener('click', (e) => e.stopPropagation()); el.addEventListener('mouseenter', () => { clearHideTimeout(); subDropdown.style.display = 'flex'; updateDropdown(true); }); el.addEventListener('mouseleave', (e) => { // Check if mouse leaves both the main item and the dropdown itself const toElement = e.relatedTarget; clearHideTimeout(); // @ts-ignore if (!subDropdown.contains(toElement) && toElement !== el) { hideTimeoutFunc = setTimeout(() => hideDropdown(), hideTimeout); } }); subDropdown.addEventListener('mouseenter', () => clearHideTimeout()); subDropdown.addEventListener('mouseleave', (e) => { const toElement = e.relatedTarget; clearHideTimeout(); // @ts-ignore if (!el.contains(toElement) && !subDropdown.contains(toElement)) { hideTimeoutFunc = setTimeout(() => hideDropdown(), hideTimeout); } }); document.addEventListener('click', () => { closeSubDropdown(); updateDropdown(false); }); } // Single event click else if (typeof item.onClick === 'function') { el.addEventListener('click', (e) => { e.stopPropagation(); // @ts-ignore item.onClick(e); closeDropdown(); }); } // Complete dropdown.appendChild(el); }); // Complete return { dropdown, closeDropdown }; } } export {};