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.

653 lines (652 loc) 30.1 kB
import { getDefaultWindowFrameRoot, getDefaultWindowFrameStyle, saveCssFile, } from '../global/CssFile.mjs'; import { RootEvents } from '../global/Events.mjs'; import { areElementsColliding, moveBodyContentTo } from '../global/Utils.mjs'; import TinyElectronClient from './TinyElectronClient.mjs'; /** * TinyWindowFrameManager * * A powerful and fully customizable window frame manager for Electron applications * that use frameless windows (`frame: false`). It replaces the native title bar with * a fully configurable HTML/CSS/JS interface, allowing total control over window borders, * title positioning, icons, menus, and control buttons (minimize, maximize, close). * * ✅ Features: * - Fully draggable top bar and borders. * - Highly customizable buttons: change order, position (left/right), and icons. * - Configurable window title alignment: left, center, or right. * - Dynamic menu sections on left and right, with support for custom elements and buttons. * - Automatic handling of Electron window events: minimize, maximize, unmaximize, focus, blur, fullscreen. * - Fully responsive to window state changes (maximized, fullscreen, focus). * - Dynamic CSS styling with optional default styles, or supply your own CSS. * - Change internal icons and class names via constructor or dynamically. * - Clean, semantic, and framework-free: pure HTML, CSS, and JavaScript. * * 🚀 Usage example: * ```js * const frame = new TinyWindowFrameManager({ * client: myElectronClient, * titlePosition: 'center', * buttonsPosition: 'right', * buttonsMap: ['minimize', 'maximize', 'close'], * icons: { minimize: '-', maximize: '▢', unmaximize: '▣', close: '✖' }, * classes: { focus: 'win-focus', blur: 'win-blur', fullscreen: 'win-fullscreen', maximized: 'win-maximized' }, * }); * * frame.setTitle('My Tiny App'); * frame.setIcon('icon.png'); * frame.addMenuButton('Settings', { onClick: () => openSettings(), position: 'right' }); * ``` * * 🔧 Requirements: * - Works only with Electron windows that are frameless (`frame: false`), transparent if desired, and `titleBarStyle: 'hidden'`. * - Requires a client controller (like TinyElectronClient) that communicates with Electron's main process to handle window actions. * * 💡 Tip: * You can completely replace or override the CSS with your own themes, animations, and layout changes. * * @class */ class TinyWindowFrameManager { #windowRoot = ''; #minimizeIcon = ''; #maximizeIcon = ''; #unmaximizeIcon = ''; #closeIcon = ''; #blurClass = ''; #focusClass = ''; #fullscreenClass = ''; #maximizedClass = ''; #options = { buttonsPosition: '', titlePosition: '', /** @type {string[]} */ buttonsMap: [], }; /** @type {TinyElectronClient} */ #client; /** * Represents all HTML elements related to the custom window frame structure. * This includes the main container, title bar, borders, menus, and control buttons. * * @typedef {Object} FrameElements * @property {HTMLDivElement} rootContent - The main content container inside the window frame. * @property {HTMLDivElement} root - The root container for the entire frame structure. * @property {HTMLDivElement} frame - The outer frame that wraps the window content and borders. * @property {HTMLDivElement} top - The top bar container that holds title, icon, menus, and buttons. * @property {HTMLDivElement} menuLeft - The left-aligned menu container in the top bar. * @property {HTMLDivElement} menuRight - The right-aligned menu container in the top bar. * @property {HTMLDivElement} icon - The container for the application icon displayed in the top bar. * @property {HTMLDivElement} title - The container for the window title text. * @property {HTMLDivElement} topLeft - The left corner border of the top bar (used for styling or resizing). * @property {HTMLDivElement} topCenter - The center border of the top bar (typically stretches between left and right corners). * @property {HTMLDivElement} topRight - The right corner border of the top bar. * @property {Object} buttons - The group of window control buttons (minimize, maximize, close). * @property {HTMLDivElement} buttons.root - The container that holds all control buttons. * @property {HTMLButtonElement} buttons.maximize - The button to maximize or restore the window. * @property {HTMLButtonElement} buttons.minimize - The button to minimize the window. * @property {HTMLButtonElement} buttons.close - The button to close the window. */ /** @type {FrameElements} */ #elements; /** @type {Record<string, HTMLStyleElement>} */ #styles = {}; /** * Generates a formatted element name based on the given parameters. * * @param {string} [name=''] - The base name. If provided without leading space, a space will be prepended. * @param {string} [extra=''] - Extra string appended after the window root (optional). * @param {string} [extra2=''] - Extra string prepended at the beginning (optional). * @returns {string} The fully formatted element name. * @throws {TypeError} If any argument is not a string. */ getElementName(name = '', extra = '', extra2 = '') { if (typeof name !== 'string') throw new TypeError('The "name" argument must be a string.'); if (typeof extra !== 'string') throw new TypeError('The "extra" argument must be a string.'); if (typeof extra2 !== 'string') throw new TypeError('The "extra2" argument must be a string.'); return `${extra2.length > 0 ? `${extra2} ` : ''}#${this.#windowRoot}${extra}${name.length > 0 && name.startsWith(' ') ? name : ` ${name}`}`; } /** * Creates an instance of the custom window frame with a draggable title bar, * customizable buttons, class names, icons, and dynamic styling that integrates with Electron. * * @param {Object} [options] - Configuration options for the window frame. * @param {boolean} [options.applyDefaultStyles=true] - If true, applies the default CSS styles automatically. * @param {'left'|'right'} [options.buttonsPosition='right'] - Defines the position of the window control buttons (minimize, maximize, close). * @param {'left'|'center'|'right'} [options.titlePosition='center'] - Defines the position of the window title in the top bar. * @param {string[]} [options.buttonsMap=['minimize', 'maximize', 'close']] - Determines which buttons appear and their order. Valid values are 'minimize', 'maximize', and 'close'. * @param {TinyElectronClient} [options.client] - The Electron client interface that handles window events like minimize, maximize, close, and focus. * @param {string} [options.windowRoot='window-root'] - The ID for the root container. * @param {Object} [options.icons] - Custom innerHTML icons for buttons. * @param {string} [options.icons.minimize='🗕'] * @param {string} [options.icons.maximize='🗖'] * @param {string} [options.icons.unmaximize='🗗'] * @param {string} [options.icons.close='🗙'] * @param {Object} [options.classes] - Custom class names for window states. * @param {string} [options.classes.blur='electron-blur'] * @param {string} [options.classes.focus='electron-focus'] * @param {string} [options.classes.fullscreen='electron-fullscreen'] * @param {string} [options.classes.maximized='electron-maximized'] */ constructor({ applyDefaultStyles = true, buttonsPosition = 'right', titlePosition = 'center', buttonsMap = ['minimize', 'maximize', 'close'], client, windowRoot = 'window-root', icons = { minimize: '🗕', maximize: '🗖', unmaximize: '🗗', close: '🗙', }, classes = { blur: 'electron-blur', focus: 'electron-focus', fullscreen: 'electron-fullscreen', maximized: 'electron-maximized', }, } = {}) { // Validate client if (!(client instanceof TinyElectronClient)) throw new Error(`Invalid client instance. Expected instance of TinyElectronClient. Received: ${client}`); // Validate types if (typeof applyDefaultStyles !== 'boolean') throw new TypeError(`applyDefaultStyles must be a boolean. Received: ${applyDefaultStyles}`); if (!['left', 'right'].includes(buttonsPosition)) throw new Error(`buttonsPosition must be 'left' or 'right'. Received: ${buttonsPosition}`); if (!['left', 'center', 'right'].includes(titlePosition)) throw new Error(`titlePosition must be 'left', 'center' or 'right'. Received: ${titlePosition}`); if (!Array.isArray(buttonsMap) || !buttonsMap.every((btn) => ['minimize', 'maximize', 'close'].includes(btn))) throw new Error(`buttonsMap must be an array containing any combination of 'minimize', 'maximize', 'close'. Received: ${JSON.stringify(buttonsMap)}`); if (typeof windowRoot !== 'string' || !windowRoot.trim()) throw new Error('windowRoot must be a non-empty string.'); if (typeof icons !== 'object') throw new Error('icons must be a object.'); const requiredIcons = ['minimize', 'maximize', 'unmaximize', 'close']; requiredIcons.forEach((key) => { // @ts-ignore if (typeof icons[key] !== 'string' || !icons[key].trim()) { throw new Error(`Icon "${key}" must be a non-empty string.`); } }); if (typeof classes !== 'object') throw new Error('classes must be a object.'); const requiredClasses = ['blur', 'focus', 'fullscreen', 'maximized']; requiredClasses.forEach((key) => { // @ts-ignore if (typeof classes[key] !== 'string' || !classes[key].trim()) { throw new Error(`Class "${key}" must be a non-empty string.`); } }); this.#windowRoot = windowRoot; // @ts-ignore this.#minimizeIcon = icons.minimize; // @ts-ignore this.#maximizeIcon = icons.maximize; // @ts-ignore this.#unmaximizeIcon = icons.unmaximize; // @ts-ignore this.#closeIcon = icons.close; // @ts-ignore this.#blurClass = classes.blur; // @ts-ignore this.#focusClass = classes.focus; // @ts-ignore this.#fullscreenClass = classes.fullscreen; // @ts-ignore this.#maximizedClass = classes.maximized; this.#options.buttonsPosition = buttonsPosition; this.#options.titlePosition = titlePosition; this.#options.buttonsMap = buttonsMap; this.#client = client; const root = document.createElement('div'); root.id = this.#windowRoot; const frame = document.createElement('div'); frame.classList.add('custom-window-frame'); const content = document.createElement('div'); content.classList.add('window-content'); const rootContent = document.createElement('div'); rootContent.id = 'root'; content.prepend(rootContent); // Top bar const top = document.createElement('div'); top.classList.add('frame-top'); // Subcontainers const topLeft = document.createElement('div'); topLeft.classList.add('frame-top-left'); const topCenter = document.createElement('div'); topCenter.classList.add('frame-top-center'); const topRight = document.createElement('div'); topRight.classList.add('frame-top-right'); // Menu const menuRight = document.createElement('div'); menuRight.classList.add('frame-menu'); const menuLeft = document.createElement('div'); menuLeft.classList.add('frame-menu'); // Icon const icon = document.createElement('div'); icon.classList.add('frame-icon'); // Title const title = document.createElement('div'); title.classList.add('frame-title'); title.textContent = 'Application Title'; // Buttons const buttons = document.createElement('div'); /** @type {Record<string, HTMLButtonElement>} */ const btn = { maximize: document.createElement('button'), minimize: document.createElement('button'), close: document.createElement('button'), }; btn.minimize.classList.add('btn-minimize'); btn.minimize.innerHTML = this.#minimizeIcon; btn.maximize.classList.add('btn-maximize'); btn.maximize.innerHTML = this.#maximizeIcon; btn.close.classList.add('btn-close'); btn.close.innerHTML = this.#closeIcon; buttons.classList.add('frame-buttons'); for (const value of this.#options.buttonsMap) { if (btn[value] instanceof HTMLElement) buttons.appendChild(btn[value]); else throw new Error(`Invalid button name "${value}" in buttonsMap. Allowed values are: minimize, maximize, close.`); } /** * @param {boolean} [isMaximized=this.#client.isMaximized()] * @returns {boolean} */ const changeMaximizeIcon = (isMaximized = this.#client.isMaximized()) => { if (!isMaximized) { document.body.classList.remove(this.#maximizedClass); btn.maximize.innerHTML = this.#maximizeIcon; } else { document.body.classList.add(this.#maximizedClass); btn.maximize.innerHTML = this.#unmaximizeIcon; } return isMaximized; }; btn.minimize.addEventListener('click', () => this.#client.minimize()); btn.close.addEventListener('click', () => this.#client.close()); btn.maximize.addEventListener('click', () => { const isMaximized = changeMaximizeIcon(); if (isMaximized) this.#client.unmaximize(); else this.#client.maximize(); }); /** * Build top sections respecting icon always at the end * * @param {'left'|'right'} side * @param {Array<HTMLElement|null>} items */ const buildSection = (side, items) => { const section = side === 'left' ? topLeft : topRight; items.forEach((item) => { if (item !== null) section.appendChild(item); }); return section; }; buildSection('left', [ this.#options.titlePosition === 'left' ? title : null, this.#options.buttonsPosition === 'left' ? buttons : null, menuLeft, ]); buildSection('right', [ menuRight, this.#options.titlePosition === 'right' ? title : null, this.#options.buttonsPosition === 'right' ? buttons : null, ]); if (this.#options.titlePosition === 'center') topCenter.appendChild(title); top.append(topLeft, topCenter, topRight); frame.append(top); root.append(frame, content); moveBodyContentTo(rootContent); document.body.prepend(root); this.#elements = { rootContent, root, frame, top, menuLeft, menuRight, icon, title, topLeft, topCenter, topRight, buttons: { root: buttons, maximize: btn.maximize, minimize: btn.minimize, close: btn.close, }, }; if (window.innerHeight === screen.height && window.innerWidth === screen.width) document.body.classList.add(this.#fullscreenClass); if (document.hasFocus()) document.body.classList.add(this.#focusClass); else document.body.classList.add(this.#blurClass); changeMaximizeIcon(); client.on(RootEvents.IsMaximized, (isMaximized) => changeMaximizeIcon(isMaximized)); client.on(RootEvents.IsFullScreen, (isFullScreen) => { if (isFullScreen) document.body.classList.add(this.#fullscreenClass); else document.body.classList.remove(this.#fullscreenClass); }); const gainFocus = () => { document.body.classList.add(this.#focusClass); document.body.classList.remove(this.#blurClass); }; const loseFocus = () => { document.body.classList.remove(this.#focusClass); document.body.classList.add(this.#blurClass); }; client.on(RootEvents.IsFocused, (isFocused) => { if (isFocused) gainFocus(); else loseFocus(); }); // Listen when the window loses focus window.addEventListener('blur', () => loseFocus()); // Listen when the window gains focus window.addEventListener('focus', () => gainFocus()); window.addEventListener('mousedown', () => { if (!document.hasFocus()) gainFocus(); }); // Check menu visibility initially this.#checkMenuVisibility(); // Init default style this.#applyDefaultStyles(applyDefaultStyles); // Effects const resizeEffect = () => { const itemsLeft = this.#elements.menuLeft.querySelectorAll(':scope > *'); const itemsRight = this.#elements.menuRight.querySelectorAll(':scope > *'); /** @param {Element} item */ const collisionTester = (item) => { if (areElementsColliding(item, this.#elements.topCenter)) { if (!item.classList.contains('hide')) { item.classList.add('hide'); // @ts-ignore item.blur(); } } else if (item.classList.contains('hide')) { item.classList.remove('hide'); } }; itemsLeft.forEach(collisionTester); itemsRight.forEach(collisionTester); collisionTester(this.#elements.icon); }; this.#client.on(RootEvents.Resize, resizeEffect); this.#client.on(RootEvents.Resized, resizeEffect); this.#client.on(RootEvents.WillResize, resizeEffect); } /** * Save CSS content into a .css file. * * @param {string} directory - The folder path where the file will be saved. * @param {'default'|'root'} filename - The name of the css content. * @returns {Promise<void>} * @throws {TypeError} If directory is not a valid non-empty string. * @throws {TypeError} If filename is not 'default' or 'root'. * @throws {Error} If CSS content for the given filename does not exist. * @throws {Error} If the file cannot be written. */ saveCssFileStructure(directory, filename) { if (typeof directory !== 'string' || !directory.trim()) throw new TypeError('The "directory" argument must be a non-empty string.'); if (filename !== 'default' && filename !== 'root') throw new TypeError('The "filename" argument must be either "default" or "root".'); const style = this.#styles[filename]; if (!style || !style.textContent) throw new Error(`No CSS content found for "${filename}".`); return saveCssFile(directory, `electron-${filename}.css`, style.textContent); } /** * Apply the default CSS styles for the window frame. * This includes the base styles for the frame container and optional theme styling. * * @param {boolean} [applyDefaultStyles=false] - If true, applies the full default styling. If false, applies only the root structure. */ #applyDefaultStyles(applyDefaultStyles = false) { const root = document.createElement('style'); root.id = 'electron-window-root-style'; root.textContent = getDefaultWindowFrameRoot(); document.head.prepend(root); this.#styles.root = root; if (!applyDefaultStyles) return; const style = document.createElement('style'); style.id = 'electron-window-style'; style.textContent = getDefaultWindowFrameStyle({ getElementName: (className, extra, extra2) => this.getElementName(className, extra, extra2), fullscreenClass: this.#fullscreenClass, blurClass: this.#blurClass, maximizedClass: this.#maximizedClass, }); document.head.prepend(style); this.#styles.default = style; } /** * Check the visibility of left and right menus. * Hides the menu if it has no children and shows if it has at least one. * 🔥 Internal use only. */ #checkMenuVisibility() { const menuRight = this.#elements.menuRight; menuRight.style.display = menuRight.children.length === 0 ? 'none' : 'flex'; const menuLeft = this.#elements.menuLeft; menuLeft.style.display = menuLeft.children.length === 0 ? 'none' : 'flex'; } /** * Get the HTML container element by its name. * * @param {'rootContent'|'root'|'frame'|'top'|'menuLeft'|'menuRight'|'icon'|'title'|'topLeft'|'topCenter'|'topRight'} name - The name of the element to retrieve. * @returns {HTMLDivElement} - The corresponding HTMLDivElement. * @throws {Error} If the element name is invalid. */ getHtml(name = 'rootContent') { if (typeof name !== 'string' || !(name in this.#elements) || !(this.#elements[name] instanceof HTMLElement)) throw new Error(`Invalid element name "${name}". Must be one of: ${Object.keys(this.#elements).join(', ')}`); return this.#elements[name]; } /** * Get one of the window control buttons (minimize, maximize, close) or the buttons container. * * @param {'root'|'maximize'|'minimize'|'close'} name - The name of the button or container. * @returns {HTMLButtonElement|HTMLDivElement} - The corresponding button or container. * @throws {Error} If the button name is invalid. */ getButtonHtml(name) { if (typeof name !== 'string' || !(name in this.#elements.buttons) || !(this.#elements.buttons[name] instanceof HTMLButtonElement || this.#elements.buttons[name] instanceof HTMLDivElement)) throw new Error(`Invalid button name "${name}". Must be one of: ${Object.keys(this.#elements.buttons).join(', ')}`); return this.#elements.buttons[name]; } /** * Add a custom HTML element to the left or right menu bar. * * @param {HTMLElement} element - The HTML element to add. * @param {'left'|'right'} [position='left'] - The position of the menu to insert the element. */ addMenuCustomElement(element, position = 'left') { const menu = this.getMenuElement(position); menu.appendChild(element); this.#checkMenuVisibility(); } /** * Show the menu bar (left or right) with a fade-in effect. * * @param {'left'|'right'} [position='left'] - The menu position to show. */ showMenu(position = 'left') { const menu = this.getMenuElement(position); menu.classList.remove('menu-fade-out'); menu.classList.add('menu-fade-in'); menu.style.display = 'flex'; } /** * Hide the menu bar (left or right) with a fade-out effect. * * @param {'left'|'right'} [position='left'] - Defines which menu to hide. * @param {number} [fadeOutTime=200] - Duration of the fade-out effect in milliseconds. Must be a non-negative number. * @throws {TypeError} If fadeOutTime is not a valid number. * @throws {Error} If position is invalid. */ hideMenu(position = 'left', fadeOutTime = 200) { // Validate fadeOutTime if (typeof fadeOutTime !== 'number' || !Number.isFinite(fadeOutTime) || fadeOutTime < 0) throw new TypeError(`fadeOutTime must be a non-negative number. Received: ${fadeOutTime}`); const menu = this.getMenuElement(position); menu.classList.remove('menu-fade-in'); menu.classList.add('menu-fade-out'); setTimeout(() => { menu.style.display = 'none'; }, fadeOutTime); } /** * Get the menu DOM element (left or right). * * @param {'left'|'right'} [position='left'] - The position of the menu. * @returns {HTMLDivElement} - The menu container element. * @throws {Error} - If the position is invalid. */ getMenuElement(position = 'left') { if (position !== 'left' && position !== 'right') throw new Error(`Invalid menu position "${position}". Allowed values are 'left' or 'right'.`); const menu = position === 'left' ? this.#elements.menuLeft : position === 'right' ? this.#elements.menuRight : null; if (!menu) throw new Error(`Invalid menu position "${position}" element.`); return menu; } /** * 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. * @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 } = {}) { 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; 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; } /** * Remove a menu button by its ID or by passing the HTMLElement itself. * * @param {string|HTMLElement} idOrElement - The button ID (from `data-menu-id`) or the button element itself. * @param {'left'|'right'} [position='left'] - Menu position to target. * @throws {TypeError} If idOrElement is not a string or HTMLElement. * @throws {Error} If position is invalid. */ removeMenuButton(idOrElement, position = 'left') { const menu = this.getMenuElement(position); if (typeof idOrElement === 'string') { const el = menu.querySelector(`[data-menu-id="${idOrElement}"]`); if (el) el.remove(); } else if (idOrElement instanceof HTMLElement) { menu.contains(idOrElement) && idOrElement.remove(); } else throw new TypeError(`idOrElement must be a string or HTMLElement. Received: ${idOrElement}`); this.#checkMenuVisibility(); } /** * Remove all elements from the menu bar (left or right). * * @param {'left'|'right'} [position='left'] - The menu position to clear. * @throws {Error} If position is invalid. */ clearMenu(position = 'left') { const menu = this.getMenuElement(position); menu.innerHTML = ''; this.#checkMenuVisibility(); } /** * Change the window title text. * * @param {string} text - The new title to display. * @throws {TypeError} If text is not a string. */ setTitle(text) { if (typeof text !== 'string') throw new TypeError(`Title must be a string. Received: ${text}`); this.#elements.title.textContent = text; } /** * Set or update the window icon image. * * @param {string} url - The URL of the image to use as the icon. Pass an empty string to remove the icon. * @throws {TypeError} If url is not a string. */ setIcon(url) { if (typeof url !== 'string') throw new TypeError(`Icon URL must be a string. Received: ${url}`); if (url.length > 0) { this.#elements.icon.style.backgroundImage = `url(${url})`; this.#elements.topLeft.prepend(this.#elements.icon); } else this.removeIcon(); } /** * Remove the current window icon from the title bar. */ removeIcon() { if (this.#elements.topLeft.contains(this.#elements.icon)) this.#elements.icon.remove(); this.#elements.icon.style.backgroundImage = ''; } /** * Apply custom CSS to the document. * * @param {string} css - The CSS rules as a string. * @returns {HTMLStyleElement} - The created <style> element appended to the document head. * @throws {TypeError} If css is not a string. */ applyCustomStyle(css) { if (typeof css !== 'string') throw new TypeError(`CSS must be a string. Received: ${css}`); const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); return style; } } export default TinyWindowFrameManager;