// ==UserScript== // @name Quick-Text-Buttons // @namespace https://github.com/p65536 // @version 3.0.0 // @license MIT // @description Adds customizable buttons to paste predefined text into the input field on ChatGPT/Gemini. // @icon https://raw.githubusercontent.com/p65536/p65536/main/images/qtb.svg // @author p65536 // @match https://chatgpt.com/* // @match https://gemini.google.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @run-at document-idle // @noframes // ==/UserScript== (() => { 'use strict'; // ================================================================================= // SECTION: Script-Specific Definitions (DO NOT COPY TO OTHER PLATFORM) // ================================================================================= const OWNERID = 'p65536'; const APPID = 'qtbux'; const APPNAME = 'Quick Text Buttons'; const LOG_PREFIX = `[${APPID.toUpperCase()}]`; // ================================================================================= // SECTION: Logging Utility // Description: Centralized logging interface for consistent log output across modules. // Handles log level control, message formatting, and console API wrapping. // ================================================================================= class Logger { /** @property {object} levels - Defines the numerical hierarchy of log levels. */ static levels = { error: 0, warn: 1, info: 2, log: 3, debug: 4, }; /** @property {string} level - The current active log level. */ static level = 'log'; // Default level /** * Defines the available badge styles. * @property {object} styles */ static styles = { BASE: 'color: white; padding: 2px 6px; border-radius: 4px; font-weight: bold;', RED: 'background: #dc3545;', YELLOW: 'background: #ffc107; color: black;', GREEN: 'background: #28a745;', BLUE: 'background: #007bff;', GRAY: 'background: #6c757d;', ORANGE: 'background: #fd7e14;', PINK: 'background: #e83e8c;', PURPLE: 'background: #6f42c1;', CYAN: 'background: #17a2b8; color: black;', TEAL: 'background: #20c997; color: black;', }; /** * Maps log levels to default badge styles. * @private */ static _defaultStyles = { error: this.styles.RED, warn: this.styles.YELLOW, info: this.styles.BLUE, log: this.styles.GREEN, debug: this.styles.GRAY, }; /** * Sets the current log level. * @param {string} level The new log level. Must be one of 'error', 'warn', 'info', 'log', 'debug'. */ static setLevel(level) { if (Object.prototype.hasOwnProperty.call(this.levels, level)) { this.level = level; } else { // Use default style (empty string) for the badge this._out('warn', 'INVALID LEVEL', '', `Invalid log level "${level}". Valid levels are: ${Object.keys(this.levels).join(', ')}. Level not changed.`); } } /** * Internal method to output logs if the level permits. * @private * @param {string} level - The log level ('error', 'warn', 'info', 'log', 'debug'). * @param {string} badgeText - The text inside the badge. If empty, no badge is shown. * @param {string} badgeStyle - The background-color style (from Logger.styles). If empty, uses default. * @param {...any} args - The messages to log. */ static _out(level, badgeText, badgeStyle, ...args) { if (this.levels[this.level] >= this.levels[level]) { const consoleMethod = console[level] || console.log; if (badgeText !== '') { // Badge mode: Use %c formatting let style = badgeStyle; if (style === '') { style = this._defaultStyles[level] || this.styles.GRAY; } const combinedStyle = `${this.styles.BASE} ${style}`; consoleMethod( `%c${LOG_PREFIX}%c %c${badgeText}%c`, 'font-weight: bold;', // Style for the prefix 'color: inherit;', // Reset for space combinedStyle, // Style for the badge 'color: inherit;', // Reset for the rest of the message ...args ); } else { // No badge mode: Direct output for better object inspection consoleMethod(LOG_PREFIX, ...args); } } } /** * Internal method to start a log group if the level permits (debug or higher). * @private * @param {'group'|'groupCollapsed'} method - The console method to use. * @param {string} badgeText * @param {string} badgeStyle * @param {...any} args */ static _groupOut(method, badgeText, badgeStyle, ...args) { if (this.levels[this.level] >= this.levels.debug) { const consoleMethod = console[method]; if (badgeText !== '') { let style = badgeStyle; if (style === '') { style = this.styles.GRAY; } const combinedStyle = `${this.styles.BASE} ${style}`; consoleMethod(`%c${LOG_PREFIX}%c %c${badgeText}%c`, 'font-weight: bold;', 'color: inherit;', combinedStyle, 'color: inherit;', ...args); } else { consoleMethod(LOG_PREFIX, ...args); } } } /** * @param {string} badgeText * @param {string} badgeStyle * @param {...any} args */ static error(badgeText, badgeStyle, ...args) { this._out('error', badgeText, badgeStyle, ...args); } /** * @param {string} badgeText * @param {string} badgeStyle * @param {...any} args */ static warn(badgeText, badgeStyle, ...args) { this._out('warn', badgeText, badgeStyle, ...args); } /** * @param {string} badgeText * @param {string} badgeStyle * @param {...any} args */ static info(badgeText, badgeStyle, ...args) { this._out('info', badgeText, badgeStyle, ...args); } /** * @param {string} badgeText * @param {string} badgeStyle * @param {...any} args */ static log(badgeText, badgeStyle, ...args) { this._out('log', badgeText, badgeStyle, ...args); } /** * Logs messages for debugging. Only active in 'debug' level. * @param {string} badgeText * @param {string} badgeStyle * @param {...any} args */ static debug(badgeText, badgeStyle, ...args) { this._out('debug', badgeText, badgeStyle, ...args); } /** * Starts a timer for performance measurement. Only active in 'debug' level. * @param {string} label The label for the timer. */ static time(label) { if (this.levels[this.level] >= this.levels.debug) { console.time(`${LOG_PREFIX} ${label}`); } } /** * Ends a timer and logs the elapsed time. Only active in 'debug' level. * @param {string} label The label for the timer, must match the one used in time(). */ static timeEnd(label) { if (this.levels[this.level] >= this.levels.debug) { console.timeEnd(`${LOG_PREFIX} ${label}`); } } /** * Starts a log group. Only active in 'debug' level. * @param {string} badgeText * @param {string} badgeStyle * @param {...any} args The title for the log group. */ static group(badgeText, badgeStyle, ...args) { this._groupOut('group', badgeText, badgeStyle, ...args); } /** * Starts a collapsed log group. Only active in 'debug' level. * @param {string} badgeText * @param {string} badgeStyle * @param {...any} args The title for the log group. */ static groupCollapsed(badgeText, badgeStyle, ...args) { this._groupOut('groupCollapsed', badgeText, badgeStyle, ...args); } /** * Closes the current log group. Only active in 'debug' level. * @returns {void} */ static groupEnd() { if (this.levels[this.level] >= this.levels.debug) { console.groupEnd(); } } } // Alias for ease of use const LOG_STYLES = Logger.styles; // ================================================================================= // SECTION: Execution Guard // Description: Prevents the script from being executed multiple times per page. // ================================================================================= class ExecutionGuard { // A shared key for all scripts from the same author to avoid polluting the window object. static #GUARD_KEY = `__${OWNERID}_guard__`; // A specific key for this particular script. static #APP_KEY = `${APPID}_executed`; /** * Checks if the script has already been executed on the page. * @returns {boolean} True if the script has run, otherwise false. */ static hasExecuted() { return window[this.#GUARD_KEY]?.[this.#APP_KEY] || false; } /** * Sets the flag indicating the script has now been executed. */ static setExecuted() { window[this.#GUARD_KEY] = window[this.#GUARD_KEY] || {}; window[this.#GUARD_KEY][this.#APP_KEY] = true; } } // ================================================================================= // SECTION: Configuration and Constants // Description: Defines default settings, global constants, and CSS selectors. // ================================================================================= const CONSTANTS = { CONFIG_KEY: `${APPID}_config`, CONFIG_SIZE_RECOMMENDED_LIMIT_BYTES: 5 * 1024 * 1024, // 5MB CONFIG_SIZE_LIMIT_BYTES: 10 * 1024 * 1024, // 10MB ID_PREFIX: `${APPID}-id-`, Z_INDICES: { SETTINGS_PANEL: 11000, TEXT_LIST: 20001, }, TIMING: { TIMEOUTS: { POST_NAVIGATION_DOM_SETTLE: 200, HIDE_DELAY_MS: 250, }, }, // Platform-specific path exclusions URL_EXCLUSIONS: { chatgpt: [/^\/codex/, /^\/gpts/, /^\/apps/], gemini: [/^\/gems/], }, PLATFORM: { CHATGPT: { ID: 'chatgpt', HOST: 'chatgpt.com', }, GEMINI: { ID: 'gemini', HOST: 'gemini.google.com', }, }, SELECTORS: { chatgpt: { // Reference element for button positioning (Parent container) INSERTION_ANCHOR: 'form[data-type="unified-composer"] div[class*="[grid-area:leading]"]', // Actual input element for text insertion INPUT_TARGET: 'div.ProseMirror#prompt-textarea', // Explicit settings for layout strategy ANCHOR_PADDING_LEFT: null, // No padding adjustment needed INSERT_METHOD: 'prepend', }, gemini: { // Reference element for button positioning - Main text input wrapper (Stable parent) INSERTION_ANCHOR: 'input-area-v2 .text-input-field', // Actual input element for text insertion INPUT_TARGET: 'rich-textarea .ql-editor', // Settings for absolute positioning strategy // Button occupies 48px (left:8px + width:40px). 52px provides a 4px gap. ANCHOR_PADDING_LEFT: '52px', INSERT_METHOD: 'append', }, }, MODAL_TYPES: { JSON: 'json', TEXT_EDITOR: 'textEditor', }, }; const EVENTS = { CONFIG_SIZE_EXCEEDED: `${APPID}:configSizeExceeded`, CONFIG_SAVE_SUCCESS: `${APPID}:configSaveSuccess`, REOPEN_MODAL: `${APPID}:reOpenModal`, CONFIG_UPDATED: `${APPID}:configUpdated`, UI_REPOSITION: `${APPID}:uiReposition`, NAVIGATION_START: `${APPID}:navigationStart`, NAVIGATION: `${APPID}:navigation`, }; // ================================================================================= // SECTION: Style System & Definitions // Description: Centralizes all CSS generation logic, class name definitions, and DOM injection mechanics. // ================================================================================= /** * @class StyleDefinitions * @description Manages pure style definitions, class names, and CSS generation logic. */ class StyleDefinitions { static ICONS = (() => { const COMMON_PROPS = { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor', }; return { up: { tag: 'svg', props: { ...COMMON_PROPS }, children: [{ tag: 'path', props: { d: 'M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z' } }], }, down: { tag: 'svg', props: { ...COMMON_PROPS }, children: [{ tag: 'path', props: { d: 'M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z' } }], }, delete: { tag: 'svg', props: { ...COMMON_PROPS }, children: [{ tag: 'path', props: { d: 'm256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z' } }], }, insert: { tag: 'svg', props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 0 24 24', width: '24px', fill: 'currentColor' }, children: [ { tag: 'path', props: { d: 'M0 0h24v24H0V0z', fill: 'none' } }, { tag: 'path', props: { d: 'M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z', }, }, ], }, dragHandle: { tag: 'svg', props: { ...COMMON_PROPS }, children: [ { tag: 'path', props: { d: 'M349.85-524.85q-14.52 0-24.68-10.16-10.17-10.17-10.17-24.69t10.17-24.68q10.16-10.17 24.68-10.17t24.69 10.17q10.16 10.16 10.16 24.68t-10.16 24.69q-10.17 10.16-24.69 10.16Zm260.3,0q-14.52 0-24.68-10.16-10.17-10.17-10.17-24.69t10.17-24.68q10.16-10.17 24.68-10.17t24.69 10.17q10.16 10.16 10.16 24.68t-10.16 24.69q-10.17 10.16-24.69 10.16Zm-260.3-170q-14.52 0-24.68-10.17-10.17-10.16-10.17-24.68t10.17-24.69q10.16-10.16 24.68-10.16t24.69 10.16q10.16 10.17 10.16 24.69t-10.16 24.68q-10.17 10.17-24.69 10.17Zm260.3,0q-14.52 0-24.68-10.17-10.17-10.16-10.17-24.68t10.17-24.69q10.16-10.16 24.68-10.16t24.69 10.16q10.16 10.17 10.16 24.69t-10.16 24.68q-10.17 10.17-24.69 10.17Zm-260.3,340q-14.52 0-24.68-10.17-10.17-10.16-10.17-24.68t10.17-24.69q10.16-10.16 24.68-10.16t24.69 10.16q10.16 10.17 10.16 24.69t-10.16 24.68q-10.17 10.17-24.69 10.17Zm260.3,0q-14.52 0-24.68-10.17-10.17-10.16-10.17-24.68t10.17-24.69q10.16-10.16 24.68-10.16t24.69 10.16q10.16 10.17 10.16 24.69t-10.16 24.68q-10.17 10.17-24.69 10.17Z', }, }, ], }, }; })(); static COMMON_CLASSES = (() => { const prefix = `${APPID}-common`; return { modalButton: `${prefix}-btn`, primaryBtn: `${prefix}-btn-primary`, pushRightBtn: `${prefix}-btn-push-right`, // Form Elements formField: `${prefix}-form-field`, inputWrapper: `${prefix}-input-wrapper`, toggleSwitch: `${prefix}-toggle`, toggleSlider: `${prefix}-toggle-slider`, selectInput: `${prefix}-select`, // Layouts submenuRow: `${prefix}-row`, submenuFieldset: `${prefix}-fieldset`, submenuSeparator: `${prefix}-separator`, settingsNote: `${prefix}-note`, // Notification conflictText: `${prefix}-conflict-text`, conflictReloadBtnId: `${prefix}-conflict-reload-btn`, warningBanner: `${prefix}-warning-banner`, }; })(); static MODAL_CLASSES = (() => { const prefix = `${APPID}-modal`; return { dialog: `${prefix}-dialog`, box: `${prefix}-box`, header: `${prefix}-header`, content: `${prefix}-content`, footer: `${prefix}-footer`, footerMessage: `${prefix}-footer-message`, buttonGroup: `${prefix}-button-group`, }; })(); static SETTINGS_PANEL_CLASSES = (() => { const prefix = `${APPID}-settings-panel`; return { panel: `${prefix}-container`, topRow: `${prefix}-top-row`, }; })(); static VARS = { // Backgrounds MODAL_BG: `--${APPID}-modal-bg`, PANEL_BG: `--${APPID}-panel-bg`, INPUT_BG: `--${APPID}-input-bg`, // Text Colors TEXT_PRIMARY: `--${APPID}-text-primary`, TEXT_SECONDARY: `--${APPID}-text-secondary`, TEXT_DANGER: `--${APPID}-text-danger`, TEXT_WARNING: `--${APPID}-text-warning`, TEXT_ACCENT: `--${APPID}-text-accent`, // Borders BORDER_DEFAULT: `--${APPID}-border-default`, BORDER_MEDIUM: `--${APPID}-border-medium`, BORDER_LIGHT: `--${APPID}-border-light`, // Buttons (Standard) BTN_BG: `--${APPID}-btn-bg`, BTN_HOVER_BG: `--${APPID}-btn-hover-bg`, BTN_TEXT: `--${APPID}-btn-text`, BTN_BORDER: `--${APPID}-btn-border`, // Toggle Switch TOGGLE_BG_OFF: `--${APPID}-toggle-bg-off`, TOGGLE_BG_ON: `--${APPID}-toggle-bg-on`, TOGGLE_KNOB: `--${APPID}-toggle-knob`, // Components specific (Text List & Tabs) LIST_BG: `--${APPID}-list-bg`, LIST_SHADOW: `--${APPID}-list-shadow`, TAB_BG: `--${APPID}-tab-bg`, TAB_TEXT: `--${APPID}-tab-text`, TAB_BORDER: `--${APPID}-tab-border`, TAB_HOVER_BG: `--${APPID}-tab-hover-bg`, TAB_ACTIVE_BG: `--${APPID}-tab-active-bg`, TAB_ACTIVE_BORDER: `--${APPID}-tab-active-border`, TAB_ACTIVE_OUTLINE: `--${APPID}-tab-active-outline`, OPTION_BG: `--${APPID}-option-bg`, OPTION_TEXT: `--${APPID}-option-text`, OPTION_BORDER: `--${APPID}-option-border`, OPTION_HOVER_BG: `--${APPID}-option-hover-bg`, OPTION_HOVER_BORDER: `--${APPID}-option-hover-border`, OPTION_HOVER_OUTLINE: `--${APPID}-option-hover-outline`, // Insert Button INSERT_BTN_COLOR: `--${APPID}-insert-btn-color`, INSERT_BTN_HOVER_BG: `--${APPID}-insert-btn-hover-bg`, INSERT_BTN_POSITION: `--${APPID}-insert-btn-position`, INSERT_BTN_LEFT: `--${APPID}-insert-btn-left`, INSERT_BTN_BOTTOM: `--${APPID}-insert-btn-bottom`, INSERT_BTN_SIZE: `--${APPID}-insert-btn-size`, // Anchor Layout ANCHOR_PADDING_LEFT: `--${APPID}-anchor-padding-left`, ANCHOR_GAP: `--${APPID}-anchor-gap`, // Theme Modal Specific DELETE_BTN_TEXT: `--${APPID}-delete-btn-text`, DELETE_BTN_BG: `--${APPID}-delete-btn-bg`, DELETE_BTN_HOVER_TEXT: `--${APPID}-delete-btn-hover-text`, DELETE_BTN_HOVER_BG: `--${APPID}-delete-btn-hover-bg`, DND_INDICATOR: `--${APPID}-dnd-indicator`, }; static PLATFORM_THEMES = { chatgpt: { [this.VARS.MODAL_BG]: 'var(--main-surface-primary)', [this.VARS.PANEL_BG]: 'var(--sidebar-surface-primary)', [this.VARS.INPUT_BG]: 'var(--bg-primary)', [this.VARS.TEXT_PRIMARY]: 'var(--text-primary)', [this.VARS.TEXT_SECONDARY]: 'var(--text-secondary)', [this.VARS.TEXT_DANGER]: 'var(--text-danger)', [this.VARS.TEXT_WARNING]: '#FFD54F', [this.VARS.TEXT_ACCENT]: 'var(--text-accent)', [this.VARS.BORDER_DEFAULT]: 'var(--border-default)', [this.VARS.BORDER_MEDIUM]: 'var(--border-medium)', [this.VARS.BORDER_LIGHT]: 'var(--border-light)', [this.VARS.BTN_BG]: 'var(--interactive-bg-tertiary-default)', [this.VARS.BTN_HOVER_BG]: 'var(--interactive-bg-secondary-hover)', [this.VARS.BTN_TEXT]: 'var(--text-primary)', [this.VARS.BTN_BORDER]: 'var(--border-default)', [this.VARS.TOGGLE_BG_OFF]: 'var(--bg-primary)', [this.VARS.TOGGLE_BG_ON]: 'var(--text-accent)', [this.VARS.TOGGLE_KNOB]: 'var(--text-primary)', [this.VARS.LIST_BG]: 'var(--main-surface-primary)', [this.VARS.LIST_SHADOW]: 'var(--drop-shadow-md, 0 3px 3px #0000001f)', [this.VARS.TAB_BG]: 'var(--interactive-bg-tertiary-default)', [this.VARS.TAB_TEXT]: 'var(--text-primary)', [this.VARS.TAB_BORDER]: 'var(--border-light)', [this.VARS.TAB_HOVER_BG]: 'var(--interactive-bg-secondary-hover)', [this.VARS.TAB_ACTIVE_BG]: 'var(--interactive-bg-secondary-hover)', [this.VARS.TAB_ACTIVE_BORDER]: 'var(--border-default)', [this.VARS.TAB_ACTIVE_OUTLINE]: 'var(--border-default)', [this.VARS.OPTION_BG]: 'var(--interactive-bg-tertiary-default)', [this.VARS.OPTION_TEXT]: 'var(--text-primary)', [this.VARS.OPTION_BORDER]: 'var(--border-default)', [this.VARS.OPTION_HOVER_BG]: 'var(--interactive-bg-secondary-hover)', [this.VARS.OPTION_HOVER_BORDER]: 'var(--border-default)', [this.VARS.OPTION_HOVER_OUTLINE]: 'var(--border-default)', [this.VARS.INSERT_BTN_COLOR]: 'var(--text-primary)', [this.VARS.INSERT_BTN_HOVER_BG]: 'var(--interactive-bg-secondary-hover)', [this.VARS.INSERT_BTN_POSITION]: 'static', [this.VARS.INSERT_BTN_LEFT]: 'auto', [this.VARS.INSERT_BTN_BOTTOM]: 'auto', [this.VARS.INSERT_BTN_SIZE]: 'calc(var(--spacing)*9)', [this.VARS.ANCHOR_PADDING_LEFT]: '0', [this.VARS.ANCHOR_GAP]: '2px', [this.VARS.DELETE_BTN_TEXT]: 'var(--interactive-label-danger-secondary-default)', [this.VARS.DELETE_BTN_BG]: 'var(--interactive-bg-danger-secondary-default)', [this.VARS.DELETE_BTN_HOVER_TEXT]: 'var(--interactive-label-danger-secondary-hover)', [this.VARS.DELETE_BTN_HOVER_BG]: 'var(--interactive-bg-secondary-hover)', [this.VARS.DND_INDICATOR]: 'var(--text-accent)', }, gemini: { [this.VARS.MODAL_BG]: 'var(--gem-sys-color--surface-container-highest)', [this.VARS.PANEL_BG]: 'var(--gem-sys-color--surface-container-highest)', [this.VARS.INPUT_BG]: 'var(--gem-sys-color--surface-container-low)', [this.VARS.TEXT_PRIMARY]: 'var(--gem-sys-color--on-surface)', [this.VARS.TEXT_SECONDARY]: 'var(--gem-sys-color--on-surface-variant)', [this.VARS.TEXT_DANGER]: 'var(--gem-sys-color--error)', [this.VARS.TEXT_WARNING]: '#FFD54F', [this.VARS.TEXT_ACCENT]: 'var(--gem-sys-color--primary)', [this.VARS.BORDER_DEFAULT]: 'var(--gem-sys-color--outline)', [this.VARS.BORDER_MEDIUM]: 'var(--gem-sys-color--outline)', [this.VARS.BORDER_LIGHT]: 'var(--gem-sys-color--outline-low)', [this.VARS.BTN_BG]: 'var(--gem-sys-color--surface-container-high)', [this.VARS.BTN_HOVER_BG]: 'var(--gem-sys-color--surface-container-higher)', [this.VARS.BTN_TEXT]: 'var(--gem-sys-color--on-surface-variant)', [this.VARS.BTN_BORDER]: 'var(--gem-sys-color--outline)', [this.VARS.TOGGLE_BG_OFF]: 'var(--gem-sys-color--surface-container)', [this.VARS.TOGGLE_BG_ON]: 'var(--gem-sys-color--primary)', [this.VARS.TOGGLE_KNOB]: 'var(--gem-sys-color--on-primary-container)', [this.VARS.LIST_BG]: 'var(--gem-sys-color--surface-container-high)', [this.VARS.LIST_SHADOW]: '0 4px 12px rgb(0 0 0 / 0.25)', [this.VARS.TAB_BG]: 'var(--gem-sys-color--surface-container)', [this.VARS.TAB_TEXT]: 'var(--gem-sys-color--on-surface-variant)', [this.VARS.TAB_BORDER]: 'var(--gem-sys-color--outline)', [this.VARS.TAB_HOVER_BG]: 'var(--gem-sys-color--secondary-container)', [this.VARS.TAB_ACTIVE_BG]: 'var(--gem-sys-color--surface-container-higher)', [this.VARS.TAB_ACTIVE_BORDER]: 'var(--gem-sys-color--primary)', [this.VARS.TAB_ACTIVE_OUTLINE]: 'var(--gem-sys-color--primary)', [this.VARS.OPTION_BG]: 'var(--gem-sys-color--surface-container)', [this.VARS.OPTION_TEXT]: 'var(--gem-sys-color--on-surface-variant)', [this.VARS.OPTION_BORDER]: 'var(--gem-sys-color--outline)', [this.VARS.OPTION_HOVER_BG]: 'var(--gem-sys-color--secondary-container)', [this.VARS.OPTION_HOVER_BORDER]: 'var(--gem-sys-color--outline)', [this.VARS.OPTION_HOVER_OUTLINE]: 'var(--gem-sys-color--primary)', [this.VARS.INSERT_BTN_COLOR]: 'var(--mat-icon-button-icon-color, var(--mat-sys-on-surface-variant))', [this.VARS.INSERT_BTN_HOVER_BG]: 'color-mix(in srgb, var(--mat-icon-button-state-layer-color) 8%, transparent)', [this.VARS.INSERT_BTN_POSITION]: 'absolute', [this.VARS.INSERT_BTN_LEFT]: '8px', [this.VARS.INSERT_BTN_BOTTOM]: '12px', [this.VARS.INSERT_BTN_SIZE]: '40px', [this.VARS.ANCHOR_PADDING_LEFT]: '52px', [this.VARS.ANCHOR_GAP]: '0', [this.VARS.DELETE_BTN_TEXT]: 'var(--gem-sys-color--on-error-container)', [this.VARS.DELETE_BTN_BG]: 'var(--gem-sys-color--error-container)', [this.VARS.DELETE_BTN_HOVER_TEXT]: 'var(--gem-sys-color--on-error-container)', [this.VARS.DELETE_BTN_HOVER_BG]: 'color-mix(in srgb, var(--gem-sys-color--on-error-container) 15%, var(--gem-sys-color--error-container))', [this.VARS.DND_INDICATOR]: 'var(--gem-sys-color--primary)', }, }; static getPlatformVariables(platformId) { const theme = this.PLATFORM_THEMES[platformId]; if (!theme) return ''; return Object.entries(theme) .map(([key, value]) => `${key}: ${value};`) .join('\n'); } static getCommon() { const key = 'common'; const cls = StyleDefinitions.COMMON_CLASSES; const v = StyleDefinitions.VARS; const cssGenerator = (classes) => ` /* Buttons */ .${classes.modalButton} { background: var(${v.BTN_BG}); border: 1px solid var(${v.BTN_BORDER}); border-radius: var(--radius-md, 5px); color: var(${v.BTN_TEXT}); cursor: pointer; font-size: 13px; padding: 5px 16px; transition: background 0.12s, color 0.12s, opacity 0.12s; display: flex; align-items: center; justify-content: center; white-space: nowrap; min-width: 80px; } .${classes.modalButton}:hover { background: var(${v.BTN_HOVER_BG}) !important; border-color: var(${v.BTN_BORDER}); } .${classes.modalButton}:disabled { background: var(${v.BTN_BG}) !important; cursor: not-allowed; opacity: 0.5; } .${classes.primaryBtn} { background-color: #1a73e8 !important; color: #ffffff !important; border: 1px solid transparent !important; } .${classes.primaryBtn}:hover { background-color: #1557b0 !important; } .${classes.pushRightBtn} { margin-left: auto !important; } /* Fieldset & Layout */ .${classes.submenuFieldset} { border: 1px solid var(${v.BORDER_DEFAULT}); border-radius: 4px; padding: 8px 12px 12px; margin: 0 0 12px 0; min-width: 0; } .${classes.submenuFieldset} legend { padding: 0 4px; font-weight: 500; color: var(${v.TEXT_SECONDARY}); } .${classes.submenuRow} { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; } .${classes.submenuRow} label { white-space: nowrap; flex-shrink: 0; } .${classes.submenuRow} select { width: 50%; } .${classes.submenuSeparator} { border-top: 1px solid var(${v.BORDER_LIGHT}); margin: 12px 0; } .${classes.settingsNote} { font-size: 0.85em; color: var(${v.TEXT_SECONDARY}); text-align: left; margin-top: 8px; padding: 0 4px; } /* Notification Banner */ .${classes.warningBanner} { background-color: var(--bg-danger, #ffe6e6); color: var(${v.TEXT_DANGER}); padding: 8px 12px; margin-bottom: 12px; border: 1px solid var(--border-danger, #ffcdd2); border-radius: 4px; font-size: 0.9em; line-height: 1.4; white-space: pre-wrap; } /* Form Inputs */ .${classes.selectInput} { width: 100%; box-sizing: border-box; background: var(${v.INPUT_BG}); border: 1px solid var(${v.BORDER_DEFAULT}); color: var(${v.TEXT_PRIMARY}); border-radius: 4px; padding: 4px 6px; } /* Toggle Switch */ .${classes.toggleSwitch} { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; } .${classes.toggleSwitch} input { opacity: 0; width: 0; height: 0; } .${classes.toggleSlider} { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(${v.TOGGLE_BG_OFF}); transition: .3s; border-radius: 22px; } .${classes.toggleSlider}:before { position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; background-color: var(${v.TOGGLE_KNOB}); transition: .3s; border-radius: 50%; } .${classes.toggleSwitch} input:checked + .${classes.toggleSlider} { background-color: var(${v.TOGGLE_BG_ON}); } .${classes.toggleSwitch} input:checked + .${classes.toggleSlider}:before { transform: translateX(18px); } `; return { key, classes: cls, vars: {}, generator: cssGenerator }; } static getModal() { const key = 'modal'; const cls = StyleDefinitions.MODAL_CLASSES; const common = StyleDefinitions.COMMON_CLASSES; const v = StyleDefinitions.VARS; const cssGenerator = (classes) => ` dialog.${classes.dialog} { padding: 0; border: none; background: transparent; max-width: 100vw; max-height: 100vh; overflow: visible; } dialog.${classes.dialog}::backdrop { background: rgb(0 0 0 / 0.5); pointer-events: auto; } .${classes.box} { display: flex; flex-direction: column; background: var(${v.MODAL_BG}); color: var(${v.TEXT_PRIMARY}); border: 1px solid var(${v.BORDER_DEFAULT}); border-radius: 8px; box-shadow: 0 4px 16px rgb(0 0 0 / 0.2); } .${classes.header}, .${classes.footer} { flex-shrink: 0; padding: 12px 16px; } .${classes.header} { font-size: 1.1em; font-weight: 600; border-bottom: 1px solid var(${v.BORDER_DEFAULT}); } .${classes.content} { flex-grow: 1; padding: 16px; overflow-y: auto; } .${classes.footer} { display: flex; justify-content: space-between; align-items: center; gap: 16px; border-top: 1px solid var(${v.BORDER_DEFAULT}); } .${classes.footerMessage} { flex-grow: 1; font-size: 0.9em; } .${classes.buttonGroup} { display: flex; gap: 8px; } /* Conflict Notification */ .${classes.footerMessage}.${common.conflictText} { color: var(${v.TEXT_DANGER}); display: flex; align-items: center; } .${classes.footerMessage} #${common.conflictReloadBtnId} { border-color: var(${v.TEXT_DANGER}); } `; return { key, classes: cls, vars: {}, generator: cssGenerator }; } static getSettingsPanel() { const key = 'settings-panel'; const cls = StyleDefinitions.SETTINGS_PANEL_CLASSES; const common = StyleDefinitions.COMMON_CLASSES; const v = StyleDefinitions.VARS; const cssGenerator = (classes) => ` #${classes.panel} { position: fixed; width: min(340px, 95vw); max-height: 85vh; overflow-y: auto; overscroll-behavior: contain; background: var(${v.PANEL_BG}); color: var(${v.TEXT_PRIMARY}); border-radius: 0.5rem; box-shadow: 0 4px 20px 0 rgb(0 0 0 / 15%); padding: 12px; z-index: ${CONSTANTS.Z_INDICES.SETTINGS_PANEL}; border: 1px solid var(${v.BORDER_MEDIUM}); font-size: 0.9em; } .${classes.topRow} { display: flex; gap: 12px; } .${classes.topRow} .${common.submenuFieldset} { flex: 1 1 0px; } `; return { key, classes: cls, vars: {}, generator: cssGenerator }; } static getTextList() { const key = 'text-list'; const prefix = `${APPID}-text-list`; const classes = { list: `${prefix}-container`, profileBar: `${prefix}-profile-bar`, profileName: `${prefix}-profile-name`, navBtn: `${prefix}-nav-btn`, rotateLeft: `${prefix}-rotate-left`, rotateRight: `${prefix}-rotate-right`, tabs: `${prefix}-tabs`, separator: `${prefix}-separator`, tab: `${prefix}-tab`, options: `${prefix}-options`, option: `${prefix}-option`, }; const v = StyleDefinitions.VARS; const cssGenerator = (cls) => ` #${cls.list} { position: fixed; z-index: ${CONSTANTS.Z_INDICES.TEXT_LIST}; display: none; width: min(500px, 95vw); padding: 4px 8px; border-radius: var(--radius-md, 4px); background: var(${v.LIST_BG}); color: var(${v.TEXT_PRIMARY}); border: 1px solid var(${v.BORDER_MEDIUM}); box-shadow: var(${v.LIST_SHADOW}); display: flex; flex-direction: column; box-sizing: border-box; } .${cls.profileBar} { display: flex; align-items: center; justify-content: space-between; margin: 4px 0; padding: 4px 0; border-top: 1px solid var(${v.BORDER_DEFAULT}); border-bottom: 1px solid var(${v.BORDER_DEFAULT}); flex: 0 0 auto; gap: 8px; } .${cls.profileName} { flex-grow: 1; text-align: center; font-weight: bold; font-size: 0.95em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 8px; color: var(${v.TEXT_PRIMARY}); cursor: pointer; border-radius: 4px; transition: background 0.2s; } .${cls.profileName}:hover { background: var(${v.OPTION_HOVER_BG}); } .${cls.navBtn} { background: transparent; border: none; color: var(${v.TEXT_PRIMARY}); cursor: pointer; padding: 2px; border-radius: 4px; display: flex; align-items: center; justify-content: center; min-width: 24px; height: 24px; opacity: 0.7; transition: opacity 0.2s, background 0.2s; } .${cls.navBtn}:hover { opacity: 1; background: var(${v.OPTION_HOVER_BG}); } .${cls.rotateLeft} { transform: rotate(-90deg); } .${cls.rotateRight} { transform: rotate(90deg); } .${cls.tabs} { display: flex; margin: 4px 0; flex: 0 0 auto; } .${cls.separator} { height: 1px; margin: 4px 0; background: var(${v.BORDER_DEFAULT}); flex: 0 0 auto; } .${cls.tab} { flex: 1 1 0; min-width: 0; max-width: 90px; margin-right: 4px; padding: 4px 0; border-radius: var(--radius-md, 4px); font-size: 12px; text-align: center; background: var(${v.TAB_BG}); color: var(${v.TAB_TEXT}); border: 1px solid var(${v.TAB_BORDER}); cursor: pointer; transition: background 0.15s; } .${cls.tab}.active { background: var(${v.TAB_ACTIVE_BG}); border-color: var(${v.TAB_ACTIVE_BORDER}); outline: 2px solid var(${v.TAB_ACTIVE_OUTLINE}); } .${cls.tab}:hover { background: var(${v.TAB_HOVER_BG}); } .${cls.options} { flex: 1 1 auto; overflow-y: auto; min-height: 0; overscroll-behavior: contain; } .${cls.option} { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; width: 100%; margin: 4px 0; padding: 4px; font-size: 13px; text-align: left; border-radius: var(--radius-md, 5px); background: var(${v.OPTION_BG}); color: var(${v.OPTION_TEXT}); border: 1px solid var(${v.OPTION_BORDER}); cursor: pointer; } .${cls.option}:hover, .${cls.option}:focus { background: var(${v.OPTION_HOVER_BG}) !important; border-color: var(${v.OPTION_HOVER_BORDER}) !important; outline: 2px solid var(${v.OPTION_HOVER_OUTLINE}); } .${cls.option}.active { background: var(${v.TAB_ACTIVE_BG}); border-color: var(${v.TAB_ACTIVE_BORDER}); font-weight: bold; } `; return { key, classes, vars: {}, generator: cssGenerator }; } static getInsertButton() { const key = 'insert-btn'; const prefix = `${APPID}-insert`; const classes = { buttonId: `${CONSTANTS.ID_PREFIX}insert-btn`, anchorStyled: `${prefix}-anchor-styled`, }; const v = StyleDefinitions.VARS; const cssGenerator = (cls) => ` #${cls.buttonId} { /* Dynamic Layout via Vars */ position: var(${v.INSERT_BTN_POSITION}) !important; left: var(${v.INSERT_BTN_LEFT}); bottom: var(${v.INSERT_BTN_BOTTOM}); width: var(${v.INSERT_BTN_SIZE}); height: var(${v.INSERT_BTN_SIZE}); margin: 0 !important; /* Visuals */ display: flex; background: transparent; border: none; border-radius: 50%; color: var(${v.INSERT_BTN_COLOR}); /* Base */ font-size: 16px; cursor: pointer; box-shadow: var(--drop-shadow-xs, 0 1px 1px #0000000d); transition: background 0.12s, border-color 0.12s, box-shadow 0.12s; align-items: center; justify-content: center; padding: 0; pointer-events: auto !important; } #${cls.buttonId}:hover { background: var(${v.INSERT_BTN_HOVER_BG}); } /* Anchor Styling */ .${cls.anchorStyled} { position: relative; display: flex; align-items: center; gap: var(${v.ANCHOR_GAP}); padding-left: var(${v.ANCHOR_PADDING_LEFT}) !important; } `; return { key, classes, vars: {}, generator: cssGenerator }; } static getTextEditorModal() { const key = 'text-editor'; const prefix = `${APPID}-text-editor`; const classes = { // Header Controls headerControls: `${prefix}-header-controls`, headerRow: `${prefix}-header-row`, renameArea: `${prefix}-rename-area`, actionArea: `${prefix}-action-area`, mainActions: `${prefix}-main-actions`, renameActions: `${prefix}-rename-actions`, deleteConfirmGroup: `${prefix}-delete-confirm-group`, deleteConfirmLabel: `${prefix}-delete-confirm-label`, deleteConfirmBtnYes: `${prefix}-delete-confirm-btn-yes`, // Content Layout modalContent: `${prefix}-modal-content`, scrollableArea: `${prefix}-scrollable-area`, // Text Item textItem: `${prefix}-text-item`, dragHandle: `${prefix}-drag-handle`, itemControls: `${prefix}-text-item-controls`, // Buttons moveBtn: `${prefix}-move-btn`, deleteBtn: `${prefix}-delete-btn`, }; const v = StyleDefinitions.VARS; const common = StyleDefinitions.COMMON_CLASSES; const cssGenerator = (cls) => ` /* Header Layout */ .${cls.headerControls} { display: flex; flex-direction: column; gap: 12px; } .${cls.headerRow} { display: grid; /* Label | Flexible Input | Action Buttons */ grid-template-columns: 5.5rem 1fr auto; gap: 8px; align-items: center; } @media (max-width: 800px) { .${cls.headerRow} { grid-template-columns: 1fr; gap: 8px; } .${cls.headerRow} > label { text-align: left; } .${cls.headerRow} > .${cls.renameArea} { grid-column: 1; } .${cls.headerRow} > .${cls.actionArea} { grid-column: 1; } } .${cls.headerRow}.is-disabled { opacity: 0.5; pointer-events: none; } .${cls.headerRow} > label { grid-column: 1; text-align: right; color: var(${v.TEXT_SECONDARY}); font-size: 0.9em; } .${cls.headerRow} > .${cls.renameArea} { grid-column: 2; min-width: 180px; } .${cls.headerRow} > .${cls.actionArea} { grid-column: 3; display: grid; grid-template-columns: 1fr; align-items: center; } .${cls.actionArea} > * { grid-area: 1 / 1; width: 100%; display: flex; align-items: center; } .${cls.mainActions}, .${cls.renameActions} { justify-content: flex-start; gap: 8px; flex-wrap: nowrap; } .${cls.deleteConfirmGroup} { justify-content: space-between; gap: 8px; } /* Delete Confirmation Styles */ .${cls.deleteConfirmLabel} { color: var(${v.TEXT_DANGER}); font-style: italic; white-space: nowrap; } .${cls.deleteConfirmBtnYes} { background-color: var(${v.DELETE_BTN_BG}) !important; color: var(${v.DELETE_BTN_TEXT}) !important; } .${cls.deleteConfirmBtnYes}:hover { background-color: var(${v.DELETE_BTN_HOVER_BG}) !important; color: var(${v.DELETE_BTN_HOVER_TEXT}) !important; filter: brightness(0.85); } /* Content Layout */ .${cls.modalContent} { display: flex; flex-direction: column; gap: 12px; height: 60vh; min-height: 200px; overflow: hidden; } .${cls.scrollableArea} { flex-grow: 1; overflow-y: auto; padding: 4px 8px 4px 4px; display: flex; flex-direction: column; gap: 12px; border: 1px solid var(${v.BORDER_DEFAULT}); border-radius: 4px; background: var(${v.INPUT_BG}); transition: opacity 0.2s; } .${cls.scrollableArea}.is-disabled { pointer-events: none; opacity: 0.5; } /* Text Item (Drag & Drop) */ .${cls.textItem} { display: flex; align-items: flex-start; gap: 8px; padding: 4px; border-radius: 4px; border-top: 2px solid transparent; border-bottom: 2px solid transparent; transition: background-color 0.2s, border-color 0.1s; } .${cls.textItem}.dragging { opacity: 0.4; background-color: rgb(255 255 255 / 0.1); } .${cls.textItem}.drag-over-top { border-top: 2px solid var(${v.DND_INDICATOR}); } .${cls.textItem}.drag-over-bottom { border-bottom: 2px solid var(${v.DND_INDICATOR}); } .${cls.dragHandle} { display: flex; align-items: center; justify-content: center; width: 24px; flex-shrink: 0; align-self: center; cursor: grab; color: var(${v.TEXT_SECONDARY}); opacity: 0.6; } .${cls.dragHandle}:hover { opacity: 1; } .${cls.dragHandle}:active { cursor: grabbing; } .${cls.textItem} textarea { flex-grow: 1; resize: none; min-height: 80px; max-height: 250px; overflow-y: auto; font-family: monospace; } .${cls.textItem}.dragging textarea { pointer-events: none; } /* Item Controls */ .${cls.itemControls} { display: flex; flex-direction: column; gap: 4px; } .${common.modalButton}.${cls.moveBtn} { line-height: 1; min-width: 24px; padding: 4px; height: 24px; width: 24px; } .${common.modalButton}.${cls.deleteBtn} { line-height: 1; min-width: 24px; padding: 4px; height: 24px; width: 24px; font-size: 16px; color: var(${v.TEXT_DANGER}); } /* Validation & Common Overrides within this modal */ .is-invalid { border-color: var(${v.TEXT_DANGER}) !important; } /* Generic Input Styles for this Modal */ .${StyleDefinitions.MODAL_CLASSES.box} input, .${StyleDefinitions.MODAL_CLASSES.box} select, .${StyleDefinitions.MODAL_CLASSES.box} textarea { width: 100%; box-sizing: border-box; background: var(${v.INPUT_BG}); color: var(${v.TEXT_PRIMARY}); border: 1px solid var(${v.BORDER_DEFAULT}); border-radius: 4px; padding: 4px 6px; } `; return { key, classes, vars: {}, generator: cssGenerator }; } static getJsonModal() { const key = 'json-modal'; const prefix = `${APPID}-json`; const classes = { modalRoot: `${prefix}-root`, statusContainer: `${prefix}-status-container`, content: `${prefix}-content`, editor: `${prefix}-editor`, statusRow: `${prefix}-status-row`, msg: `${prefix}-modal-msg`, sizeInfo: `${prefix}-modal-size-info`, }; const v = StyleDefinitions.VARS; const common = StyleDefinitions.COMMON_CLASSES; const modalClasses = StyleDefinitions.MODAL_CLASSES; const cssGenerator = (cls) => ` /* Footer Layout Adjustments - Scoped to JSON Modal */ /* Hide footer message only when it does NOT have the conflict warning class */ .${cls.modalRoot} .${modalClasses.footerMessage}:not(.${common.conflictText}) { display: none !important; } /* Allow wrapping in footer to prevent overflow when warning message is displayed */ .${cls.modalRoot} .${modalClasses.footer} { flex-wrap: wrap; } .${cls.modalRoot} .${modalClasses.buttonGroup} { width: 100%; } /* Utility classes override for this modal context */ .${common.modalButton}.${common.pushRightBtn} { margin-left: auto !important; } .${common.primaryBtn} { background-color: #1a73e8 !important; color: #ffffff !important; border: 1px solid transparent !important; } .${common.primaryBtn}:hover { background-color: #1557b0 !important; } .${cls.content} { display: flex; flex-direction: column; gap: 6px; min-width: 0; width: 100%; } .${cls.modalRoot} .${cls.editor} { width: 100% !important; height: 200px; min-width: 0 !important; max-width: 100%; resize: none; box-sizing: border-box !important; margin: 0 !important; font-family: monospace; font-size: 13px; border: 1px solid var(${v.BORDER_DEFAULT}); background: var(${v.INPUT_BG}); color: var(${v.TEXT_PRIMARY}); padding: 6px; border-radius: 4px; } .${cls.statusRow} { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; } .${cls.msg} { color: var(${v.TEXT_DANGER}); font-size: 0.9em; flex: 1; min-height: 1.2em; word-break: break-word; } .${cls.sizeInfo} { color: var(${v.TEXT_SECONDARY}); font-size: 0.85em; white-space: normal; word-break: break-all; text-align: right; margin-top: 2px; flex-shrink: 0; } `; return { key, classes, vars: {}, generator: cssGenerator }; } static getShortcutModal() { const key = 'shortcut-modal'; const prefix = `${APPID}-shortcut`; const classes = { grid: `${prefix}-grid`, keyGroup: `${prefix}-key-group`, key: `${prefix}-key`, desc: `${prefix}-desc`, sectionHeader: `${prefix}-section-header`, }; const v = StyleDefinitions.VARS; const cssGenerator = (cls) => ` .${cls.grid} { display: grid; grid-template-columns: max-content 1fr; gap: 8px 16px; align-items: baseline; padding: 4px; } .${cls.sectionHeader} { grid-column: 1 / -1; font-weight: bold; color: var(${v.TEXT_SECONDARY}); border-bottom: 1px solid var(${v.BORDER_LIGHT}); margin-top: 12px; margin-bottom: 4px; padding-bottom: 2px; font-size: 0.9em; } .${cls.sectionHeader}:first-child { margin-top: 0; } .${cls.keyGroup} { text-align: right; white-space: nowrap; } .${cls.key} { display: inline-block; padding: 2px 6px; font-family: monospace; font-size: 0.9em; line-height: 1.2; color: var(${v.TEXT_PRIMARY}); background-color: var(${v.BTN_BG}); border: 1px solid var(${v.BORDER_DEFAULT}); border-radius: 4px; box-shadow: 0 1px 1px rgb(0 0 0 / 0.1); min-width: 1.2em; text-align: center; } .${cls.desc} { color: var(${v.TEXT_PRIMARY}); font-size: 0.95em; } `; return { key, classes, vars: {}, generator: cssGenerator }; } } /** * @class StyleManager * @description Centralizes the creation, injection, and management of CSS style elements. */ class StyleManager { static _handles = new Map(); /** * @param {string} id The ID of the style element. * @param {string} cssContent The CSS content to inject. */ static _inject(id, cssContent) { let style = document.getElementById(id); if (!style) { const newStyle = document.createElement('style'); newStyle.id = id; style = newStyle; const target = document.head || document.documentElement; if (target) { target.appendChild(style); } } if (style) { style.textContent = cssContent; } } /** * Requests a style handle for the given definition provider. * @param {() => object} defProvider Function that returns the style definition. * @returns {{id: string, prefix: string, classes: object, vars: object}} The style handle. */ static request(defProvider) { if (!this._handles.has(defProvider)) { const def = defProvider(); const id = `${APPID}-style-${def.key}`; const prefix = `${APPID}-${def.key}`; const cssContent = def.generator(def.classes); this._inject(id, cssContent); this._handles.set(defProvider, { id, prefix, classes: def.classes, vars: def.vars }); } return this._handles.get(defProvider); } /** * Injects platform-specific CSS variables into the root element. * @param {string} platformId */ static injectPlatformVariables(platformId) { const css = StyleDefinitions.getPlatformVariables(platformId); if (css) { this._inject(`${APPID}-platform-vars`, `body { ${css} }`); } } } // prettier-ignore const DEFAULT_CONFIG = { options: { enable_shortcut: true, insert_before_newline: false, insert_after_newline: false, insertion_position: 'cursor', // 'start', 'cursor', 'end' trigger_mode: 'click', // 'click', 'hover' activeProfileName: 'Default', }, developer: { logger_level: 'log', // 'error', 'warn', 'info', 'log', 'debug' }, texts: [ { name: 'Default', categories: [ { name: 'Structured', items: [ 'Explain this step by step.', 'Summarize this using bullet points.', 'Provide the answer in a table format.' ], }, { name: 'Refine', items: [ 'Can you clarify this point with a concrete example?', 'Rephrase this in a more concise and technical manner.', 'List the assumptions you are making in this explanation.' ], }, { name: 'Coding', items: [ 'Show a minimal reproducible example.', 'Explain this from a performance and maintainability perspective.', 'Point out potential edge cases or pitfalls.' ], }, { name: 'Twist', items: [ 'Explain this as if you were teaching a beginner.', 'Explain this to an expert in one paragraph.', 'Give an alternative perspective or unconventional approach.' ], }, { name: 'Image-gen', items: [ 'Based on all of our previous conversations, generate an image of me as you imagine. Make it super-realistic. Please feel free to fill in any missing information with your own imagination. Do not ask follow-up questions; generate the image immediately.', 'Based on all of our previous conversations, generate an image of my ideal partner (opposite sex) as you imagine. Make it super-realistic. Please feel free to fill in any missing information with your own imagination. Do not ask follow-up questions; generate the image immediately.', 'Based on all of our previous conversations, generate an image of a person who is the exact opposite of my ideal partner. Make it super-realistic. Please feel free to fill in any missing information with your own imagination. Do not ask follow-up questions; generate the image immediately.', ], }, ], }, ], }; // ================================================================================= // SECTION: Platform-Specific Adapter // Description: Centralizes all platform-specific logic, such as selectors and // DOM manipulation strategies. // ================================================================================= const PlatformAdapters = { General: { getPlatformDetails() { const { host } = location; // ChatGPT if (host.includes(CONSTANTS.PLATFORM.CHATGPT.HOST)) { return { platformId: CONSTANTS.PLATFORM.CHATGPT.ID, selectors: CONSTANTS.SELECTORS.chatgpt, }; } // Gemini if (host.includes(CONSTANTS.PLATFORM.GEMINI.HOST)) { return { platformId: CONSTANTS.PLATFORM.GEMINI.ID, selectors: CONSTANTS.SELECTORS.gemini, }; } // invalid return null; }, /** * Checks if the current page URL is on the exclusion list for this platform. * @returns {boolean} True if the page should be excluded, otherwise false. */ isExcludedPage() { const platform = this.getPlatformDetails(); if (!platform) return false; const exclusions = CONSTANTS.URL_EXCLUSIONS[platform.platformId] || []; const pathname = window.location.pathname; return exclusions.some((pattern) => pattern.test(pathname)); }, /** * Finds the editor element and delegates the text insertion task to the EditorController. * @param {string} text The text to insert. * @param {object} options The insertion options. */ insertText(text, options = {}) { const platform = this.getPlatformDetails(); if (!platform) { Logger.error('PLATFORM', LOG_STYLES.RED, 'Platform details not found.'); return; } // Use INPUT_TARGET for text insertion logic const editor = document.querySelector(platform.selectors.INPUT_TARGET); if (!editor || !(editor instanceof HTMLElement)) { Logger.error('DOM ERROR', LOG_STYLES.RED, 'Input element not found via selector:', platform.selectors.INPUT_TARGET); return; } // Delegate the complex insertion logic to the specialized controller. EditorController.insertText(text, editor, options, platform.platformId); }, }, UI: { repositionInsertButton(insertButton) { if (!insertButton?.element) return; withLayoutCycle({ measure: () => { // Read phase const platform = PlatformAdapters.General.getPlatformDetails(); if (!platform) return { anchor: null }; const anchor = document.querySelector(platform.selectors.INSERTION_ANCHOR); if (!(anchor instanceof HTMLElement)) return { anchor: null }; // Retrieve configuration for positioning // Configuration now handled via CSS classes, but we verify method logic here const insertMethod = platform.selectors.INSERT_METHOD; if (!insertMethod) { Logger.warn('LAYOUT', LOG_STYLES.YELLOW, 'INSERT_METHOD is not defined for this platform.'); } // Ghost Detection Logic const existingBtn = document.getElementById(insertButton.element.id); const isGhost = existingBtn && existingBtn !== insertButton.element; // Check if button is already inside const isInside = !isGhost && anchor.contains(insertButton.element); // Check specific position validity let isAtCorrectPosition = isInside; if (isInside) { if (insertMethod === 'append') { isAtCorrectPosition = anchor.lastElementChild === insertButton.element; } else if (insertMethod === 'prepend') { isAtCorrectPosition = anchor.firstElementChild === insertButton.element; } } return { anchor, isGhost, existingBtn, shouldInject: !isAtCorrectPosition, insertMethod, }; }, mutate: (measured) => { // Write phase // Guard: Component might be destroyed during async wait if (!insertButton || !insertButton.element) { return; } if (!measured || !measured.anchor) { insertButton.element.style.display = 'none'; return; } const { anchor, isGhost, existingBtn, shouldInject, insertMethod } = measured; if (!anchor.isConnected) { Logger.debug('UI RETRY', LOG_STYLES.CYAN, 'Anchor detached. Retrying reposition.'); EventBus.publish(EVENTS.UI_REPOSITION); return; } // 1. Ghost Buster if (isGhost && existingBtn) { Logger.warn('GHOST BUSTER', '', 'Detected non-functional ghost button. Removing...'); existingBtn.remove(); } // 2. Injection if (shouldInject || isGhost) { // Add marker class to apply flex/relative styles defined in CSS // Retrieve class name dynamically from StyleDefinitions const styledClass = StyleDefinitions.getInsertButton().classes.anchorStyled; if (!anchor.classList.contains(styledClass)) { anchor.classList.add(styledClass); } // Insert based on explicit method if (insertMethod === 'append') { anchor.appendChild(insertButton.element); } else if (insertMethod === 'prepend') { anchor.prepend(insertButton.element); } Logger.debug('UI INJECTION', LOG_STYLES.GREEN, `Button injected into Anchor (${insertMethod}).`); } insertButton.element.style.display = ''; }, }); }, }, Observer: { /** * Returns an array of platform-specific observer initialization functions. * @returns {Function[]} An array of functions to be called by ObserverManager. */ // prettier-ignore getInitializers() { return [ this.triggerInitialPlacement, ]; }, /** * @private * @description triggers the initial button placement when the anchor element is detected. * @returns {() => void} Cleanup function to remove the listener and hide the UI. */ triggerInitialPlacement() { const platform = PlatformAdapters.General.getPlatformDetails(); if (!platform) return () => {}; const selector = platform.selectors.INSERTION_ANCHOR; const handleAnchorAppearance = () => { EventBus.publish(EVENTS.UI_REPOSITION); }; sentinel.on(selector, handleAnchorAppearance); // Initial check in case the element is already present const initialInputArea = document.querySelector(selector); if (initialInputArea instanceof HTMLElement) { handleAnchorAppearance(); } return () => { sentinel.off(selector, handleAnchorAppearance); // Ensure the button is hidden when the observer is cleaned up (e.g., on excluded pages) const btnId = `${CONSTANTS.ID_PREFIX}insert-btn`; const btn = document.getElementById(btnId); if (btn) { btn.style.display = 'none'; } }; }, }, }; // ================================================================================= // SECTION: Editor Controller // Description: Handles all direct DOM manipulation and logic for rich text editors. // ================================================================================= class EditorController { /** * Inserts text for rich text editors (ChatGPT/Gemini) using a full replacement strategy. * @param {string} text The text to insert. * @param {HTMLElement} editor The target editor element. * @param {object} options The insertion options. * @param {string} platformId The ID of the current platform ('chatgpt' or 'gemini'). */ static insertText(text, editor, options, platformId) { const executeInsertion = () => { editor.focus(); const selection = window.getSelection(); // Check if selection is valid and within the editor const hasValidSelection = selection && selection.rangeCount > 0 && editor.contains(selection.anchorNode); let range; if (hasValidSelection) { range = selection.getRangeAt(0); } else { // Fallback: Create a range at the end if invalid range = document.createRange(); range.selectNodeContents(editor); range.collapse(false); // Update selection to match this new range so subsequent calls work if (selection) { selection.removeAllRanges(); selection.addRange(range); } } // 1. Get existing text, handling ChatGPT's restored state let existingText; const paragraphs = Array.from(editor.childNodes).filter((n) => n.nodeName === 'P'); // A restored state in ChatGPT is characterized by a single

containing newlines. const isRestoredState = platformId === CONSTANTS.PLATFORM.CHATGPT.ID && paragraphs.length === 1 && paragraphs[0].textContent.includes('\n'); if (isRestoredState) { // For the restored state, get text content directly from the single paragraph. existingText = paragraphs[0].textContent; } else { // For the normal multi-

state, use the standard parsing logic. existingText = this._getTextFromEditor(editor, platformId); } let cursorPos = 0; // Determine insertion position based on options and validity of selection if (options.insertion_position === 'cursor' && hasValidSelection) { cursorPos = this._getCursorPositionInText(editor, platformId); } else if (options.insertion_position === 'start') { cursorPos = 0; } else { // 'end' or fallback for invalid selection cursorPos = existingText.length; } // 2. Prepare the text to be inserted let textToInsert = text; if (options.insert_before_newline) textToInsert = '\n' + textToInsert; if (options.insert_after_newline) textToInsert += '\n'; // 3. Construct the final, complete text and new cursor position const finalText = existingText.slice(0, cursorPos) + textToInsert + existingText.slice(cursorPos); const newCursorPos = cursorPos + textToInsert.length; // 4. Build a single DOM fragment for the entire new content const finalFragment = this._createTextFragmentForEditor(finalText, platformId); // 5. Replace editor content safely using Range API // We select all contents again to ensure complete replacement range.selectNodeContents(editor); range.deleteContents(); range.insertNode(finalFragment); // 6. Set the cursor to the end of the inserted text this._setCursorPositionByOffset(editor, newCursorPos); // 7. Platform-specific cleanup if (platformId === CONSTANTS.PLATFORM.GEMINI.ID) { editor.classList.remove('ql-blank'); } // 8. Dispatch events to notify the editor of the change editor.dispatchEvent(new Event('input', { bubbles: true, composed: true })); editor.dispatchEvent(new Event('change', { bubbles: true, composed: true })); }; // Branch: Focus Check if (document.activeElement && editor.contains(document.activeElement)) { // Branch A: Already focused -> Execute immediately (Sync) executeInsertion(); } else { // Branch B: Not focused -> Focus and wait for next frame (Async) editor.focus(); requestAnimationFrame(() => executeInsertion()); } } /** * Retrieves the plain text content from the editor (ChatGPT or Gemini). * @param {HTMLElement} editor The target editor element. * @param {string} platformId The ID of the current platform. * @returns {string} The plain text content. * @private */ static _getTextFromEditor(editor, platformId) { // ChatGPT if (platformId === CONSTANTS.PLATFORM.CHATGPT.ID && editor.querySelector('p.placeholder')) { return ''; } // Gemini's initial state is


, which should be treated as empty. if (platformId === CONSTANTS.PLATFORM.GEMINI.ID && editor.childNodes.length === 1 && editor.firstChild instanceof HTMLElement && editor.firstChild.nodeName === 'P' && editor.firstChild.innerHTML === '
') { return ''; } const lines = []; for (const p of editor.childNodes) { if (p.nodeName !== 'P') continue; const isStructuralEmptyLine = p.childNodes.length === 1 && p.firstChild && p.firstChild.nodeName === 'BR'; let isEmptyLine = false; if (isStructuralEmptyLine) { if (platformId === CONSTANTS.PLATFORM.CHATGPT.ID) { // For ChatGPT, the class must also match for it to be a true empty line paragraph. if (p.firstChild instanceof HTMLElement) { isEmptyLine = p.firstChild.className === 'ProseMirror-trailingBreak'; } } else { // For Gemini, the structure alone is sufficient. isEmptyLine = true; } } if (isEmptyLine) { lines.push(''); } else { lines.push(p.textContent); } } return lines.join('\n'); } /** * Calculates the cursor's character offset within the plain text representation of the editor. * @param {HTMLElement} editor The editor element. * @param {string} platformId The ID of the current platform. * @returns {number} The character offset of the cursor. * @private */ static _getCursorPositionInText(editor, platformId) { const selection = window.getSelection(); if (!selection.rangeCount) return 0; const range = selection.getRangeAt(0); if (!editor.contains(range.startContainer)) return 0; const preCaretRange = range.cloneRange(); preCaretRange.selectNodeContents(editor); preCaretRange.setEnd(range.startContainer, range.startOffset); const tempDiv = document.createElement('div'); tempDiv.appendChild(preCaretRange.cloneContents()); const textBeforeCursor = this._getTextFromEditor(tempDiv, platformId); return textBeforeCursor.length; } /** * Creates a DocumentFragment based on the editor's expected

structure. * @param {string} text The plain text to convert, with newlines as \n. * @param {string} platformId The ID of the current platform. * @returns {DocumentFragment} The constructed fragment. * @private */ static _createTextFragmentForEditor(text, platformId) { const fragment = document.createDocumentFragment(); const lines = text.split('\n'); lines.forEach((line) => { const p = document.createElement('p'); if (line === '') { const br = document.createElement('br'); if (platformId === CONSTANTS.PLATFORM.CHATGPT.ID) { // ChatGPT br.className = 'ProseMirror-trailingBreak'; } p.appendChild(br); } else { p.appendChild(document.createTextNode(line)); } fragment.appendChild(p); }); return fragment; } /** * Sets the cursor position within the editor based on a character offset. * @param {HTMLElement} editor The editor element. * @param {number} offset The target character offset from a plain text representation (with \n). * @private */ static _setCursorPositionByOffset(editor, offset) { const selection = window.getSelection(); if (!selection) return; const range = document.createRange(); let charCount = 0; /** @type {Node} */ let lastNode = editor; // Fallback node const paragraphs = Array.from(editor.childNodes).filter((n) => n.nodeName === 'P'); for (let i = 0; i < paragraphs.length; i++) { const p = paragraphs[i]; lastNode = p; const treeWalker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT, null); let textNode = null; while ((textNode = treeWalker.nextNode())) { lastNode = textNode; const nodeLength = textNode.textContent.length; if (charCount + nodeLength >= offset) { range.setStart(textNode, offset - charCount); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); return; // Position found and set. } charCount += nodeLength; } // After processing a paragraph, account for the newline character, // but only if it's not the last paragraph. if (i < paragraphs.length - 1) { if (charCount === offset) { // This case handles when the cursor position is exactly at the newline. // We place the cursor at the end of the current paragraph. range.selectNodeContents(p); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); return; } charCount++; // Increment for the newline } } // If the offset is beyond all text, place cursor at the end of the last node. range.selectNodeContents(lastNode); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } } // ================================================================================= // SECTION: Event-Driven Architecture (Pub/Sub) // Description: A event bus for decoupled communication between classes. // ================================================================================= const EventBus = { events: {}, uiWorkQueue: [], isUiWorkScheduled: false, _logAggregation: {}, // prettier-ignore _aggregatedEvents: new Set([ EVENTS.UI_REPOSITION, EVENTS.NAVIGATION, ]), _aggregationDelay: 500, // ms /** * Subscribes a listener to an event using a unique key. * If a subscription with the same event and key already exists, it will be overwritten. * @param {string} event The event name. * @param {Function} listener The callback function. * @param {string} key A unique key for this subscription (e.g., 'ClassName.methodName'). */ subscribe(event, listener, key) { if (!key) { Logger.error('', '', 'EventBus.subscribe requires a unique key.'); return; } if (!this.events[event]) { this.events[event] = new Map(); } this.events[event].set(key, listener); }, /** * Subscribes a listener that will be automatically unsubscribed after one execution. * @param {string} event The event name. * @param {Function} listener The callback function. * @param {string} key A unique key for this subscription. */ once(event, listener, key) { if (!key) { Logger.error('', '', 'EventBus.once requires a unique key.'); return; } const onceListener = (...args) => { this.unsubscribe(event, key); listener(...args); }; this.subscribe(event, onceListener, key); }, /** * Unsubscribes a listener from an event using its unique key. * @param {string} event The event name. * @param {string} key The unique key used during subscription. */ unsubscribe(event, key) { if (!this.events[event] || !key) { return; } this.events[event].delete(key); if (this.events[event].size === 0) { delete this.events[event]; } }, /** * Publishes an event, calling all subscribed listeners with the provided data. * @param {string} event The event name. * @param {...any} args The data to pass to the listeners. */ publish(event, ...args) { if (!this.events[event]) { return; } if (Logger.levels[Logger.level] >= Logger.levels.debug) { // --- Aggregation logic START --- if (this._aggregatedEvents.has(event)) { if (!this._logAggregation[event]) { this._logAggregation[event] = { timer: null, count: 0 }; } const aggregation = this._logAggregation[event]; aggregation.count++; clearTimeout(aggregation.timer); aggregation.timer = setTimeout(() => { const finalCount = this._logAggregation[event]?.count || 0; if (finalCount > 0) { Logger.debug('EventBus', LOG_STYLES.PURPLE, `Event Published: ${event} (x${finalCount})`); } delete this._logAggregation[event]; }, this._aggregationDelay); // Execute subscribers for the aggregated event, but without the verbose individual logs. [...this.events[event].values()].forEach((listener) => { try { listener(...args); } catch (e) { Logger.error('', '', `EventBus error in listener for event "${event}":`, e); } }); return; // End execution here for aggregated events in debug mode. } // --- Aggregation logic END --- // In debug mode, provide detailed logging for NON-aggregated events. const subscriberKeys = [...this.events[event].keys()]; Logger.groupCollapsed('EventBus', LOG_STYLES.PURPLE, `Event Published: ${event}`); if (args.length > 0) { console.log(' - Payload:', ...args); } else { console.log(' - Payload: (No data)'); } // Displaying subscribers helps in understanding the event's impact. if (subscriberKeys.length > 0) { console.log(' - Subscribers:\n' + subscriberKeys.map((key) => ` > ${key}`).join('\n')); } else { console.log(' - Subscribers: (None)'); } // Iterate with keys for better logging this.events[event].forEach((listener, key) => { try { // Log which specific subscriber is being executed Logger.debug('', LOG_STYLES.PURPLE, `-> Executing: ${key}`); listener(...args); } catch (e) { // Enhance error logging with the specific subscriber key Logger.error('LISTENER ERROR', LOG_STYLES.RED, `Listener "${key}" failed for event "${event}":`, e); } }); Logger.groupEnd(); } else { // Iterate over a copy of the values in case a listener unsubscribes itself. [...this.events[event].values()].forEach((listener) => { try { listener(...args); } catch (e) { Logger.error('LISTENER ERROR', LOG_STYLES.RED, `Listener failed for event "${event}":`, e); } }); } }, /** * Queues a function to be executed on the next animation frame. * Batches multiple UI updates into a single repaint cycle. * @param {Function} workFunction The function to execute. */ queueUIWork(workFunction) { this.uiWorkQueue.push(workFunction); if (!this.isUiWorkScheduled) { this.isUiWorkScheduled = true; requestAnimationFrame(this._processUIWorkQueue.bind(this)); } }, /** * @private * Processes all functions in the UI work queue. */ _processUIWorkQueue() { // Prevent modifications to the queue while processing. const queueToProcess = [...this.uiWorkQueue]; this.uiWorkQueue.length = 0; for (const work of queueToProcess) { try { work(); } catch (e) { Logger.error('UI QUEUE ERROR', LOG_STYLES.RED, 'Error in queued UI work:', e); } } this.isUiWorkScheduled = false; }, }; /** * Creates a unique, consistent event subscription key for EventBus. * @param {object} context The `this` context of the subscribing class instance. * @param {string} eventName The full event name from the EVENTS constant. * @returns {string} A key in the format 'ClassName.purpose'. */ function createEventKey(context, eventName) { // Extract a meaningful 'purpose' from the event name const parts = eventName.split(':'); const purpose = parts.length > 1 ? parts.slice(1).join('_') : parts[0]; let contextName = 'UnknownContext'; if (context && context.constructor && context.constructor.name) { contextName = context.constructor.name; } return `${contextName}.${purpose}`; } // ================================================================================= // SECTION: Utility Functions // ================================================================================= /** * Schedules a function to run when the browser is idle. * Returns a cancel function to abort the scheduled task. * In environments without `requestIdleCallback`, this runs asynchronously immediately (1ms delay) to prevent blocking, * effectively ignoring the `timeout` constraint by satisfying it instantly. * @param {(deadline: IdleDeadline) => void} callback The function to execute. * @param {number} timeout The maximum time to wait for idle before forcing execution. * @returns {() => void} A function to cancel the scheduled task. */ function runWhenIdle(callback, timeout) { if ('requestIdleCallback' in window) { const id = window.requestIdleCallback(callback, { timeout }); return () => window.cancelIdleCallback(id); } else { // Fallback: Execute almost immediately (1ms) to avoid blocking. // This satisfies the "run by timeout" contract trivially since 1ms < timeout. const id = setTimeout(() => { // Provide a minimal IdleDeadline-like object. // timeRemaining() returns 50ms to simulate a fresh frame. callback({ didTimeout: false, timeRemaining: () => 50, }); }, 1); return () => clearTimeout(id); } } /** * @param {Function} func * @param {number} delay * @param {boolean} useIdle * @returns {((...args: any[]) => void) & { cancel: () => void }} */ function debounce(func, delay, useIdle) { let timerId = null; let cancelIdle = null; const cancel = () => { if (timerId !== null) { clearTimeout(timerId); timerId = null; } if (cancelIdle) { cancelIdle(); cancelIdle = null; } }; const debounced = function (...args) { cancel(); timerId = setTimeout(() => { timerId = null; // Timer finished if (useIdle) { // Calculate idle timeout based on delay: clamp(delay * 4, 200, 2000) // This ensures short delays don't wait too long, while long delays are capped. const idleTimeout = Math.min(Math.max(delay * 4, 200), 2000); // Schedule idle callback and store the cancel function // Explicitly receive 'deadline' to match runWhenIdle signature cancelIdle = runWhenIdle((deadline) => { cancelIdle = null; // Idle callback finished func.apply(this, args); }, idleTimeout); } else { func.apply(this, args); } }, delay); }; debounced.cancel = cancel; return debounced; } /** * Helper function to check if an item is a non-array object. * @param {unknown} item The item to check. * @returns {item is Record} */ function isObject(item) { return !!(item && typeof item === 'object' && !Array.isArray(item)); } /** * Creates a deep copy of a JSON-serializable object. * @template T * @param {T} obj The object to clone. * @returns {T} The deep copy of the object. */ function deepClone(obj) { try { return structuredClone(obj); } catch (e) { Logger.error('CLONE FAILED', '', 'deepClone failed. Data contains non-clonable items.', e); throw e; } } /** * Recursively resolves the configuration by overlaying source properties onto the target object. * The target object is mutated. This handles recursive updates for nested objects but overwrites arrays/primitives. * * [MERGE BEHAVIOR] * Keys present in 'source' but missing in 'target' are ignored. * The 'target' object acts as a schema; it must contain all valid keys. * * @param {object} target The target object (e.g., a deep copy of default config). * @param {object} source The source object (e.g., user config). * @returns {object} The mutated target object. */ function resolveConfig(target, source) { for (const key in source) { // Security: Prevent prototype pollution if (key === '__proto__' || key === 'constructor' || key === 'prototype') { continue; } if (Object.prototype.hasOwnProperty.call(source, key)) { // Strict check: Ignore keys that do not exist in the target (default config). if (!Object.prototype.hasOwnProperty.call(target, key)) { continue; } const sourceVal = source[key]; const targetVal = target[key]; if (isObject(sourceVal) && isObject(targetVal)) { // If both are objects, recurse resolveConfig(targetVal, sourceVal); } else if (typeof sourceVal !== 'undefined') { // Otherwise, overwrite or set the value from the source target[key] = sourceVal; } } } return target; } /** * Proposes a unique name by appending a suffix if the base name already exists in a given set. * Supports both an array of strings and an array of objects with a 'name' property. * @param {string} baseName The initial name to check. * @param {Set|Array|Array} existingItems Collection of existing names or objects. * @returns {string} A unique name. */ function proposeUniqueName(baseName, existingItems) { const lowerNames = new Set(); const items = Array.isArray(existingItems) ? existingItems : Array.from(existingItems); // Normalize input to a set of lowercase strings items.forEach((item) => { if (typeof item === 'string') { lowerNames.add(item.toLowerCase()); } else if (item && typeof item === 'object' && typeof item.name === 'string') { lowerNames.add(item.name.toLowerCase()); } }); if (!lowerNames.has(baseName.trim().toLowerCase())) { return baseName; } let proposedName = `${baseName} Copy`; if (!lowerNames.has(proposedName.trim().toLowerCase())) { return proposedName; } let counter = 2; while (true) { proposedName = `${baseName} Copy ${counter}`; if (!lowerNames.has(proposedName.trim().toLowerCase())) { return proposedName; } counter++; } } /** * @description A utility to prevent layout thrashing by separating DOM reads (measure) * from DOM writes (mutate). The mutate function is executed in the next animation frame. * @param {{ * measure: () => T, * mutate: (data: T) => void * }} param0 - An object containing the measure and mutate functions. * @returns {Promise} A promise that resolves after the mutate function has completed. * @template T */ function withLayoutCycle({ measure, mutate }) { return new Promise((resolve, reject) => { let measuredData; // Phase 1: Synchronously read all required layout properties from the DOM. try { measuredData = measure(); } catch (e) { Logger.error('LAYOUT ERROR', LOG_STYLES.RED, 'Error during measure phase:', e); reject(e); return; } // Phase 2: Schedule the DOM mutations to run in the next animation frame. requestAnimationFrame(() => { try { mutate(measuredData); resolve(); } catch (e) { Logger.error('LAYOUT ERROR', LOG_STYLES.RED, 'Error during mutate phase:', e); reject(e); } }); }); } /** * @typedef {Node|string|number|boolean|null|undefined} HChild */ /** * Creates a DOM element using a hyperscript-style syntax. * @param {string} tag - Tag name with optional ID/class (e.g., "div#app.container", "my-element"). * @param {object | HChild | HChild[]} [propsOrChildren] - Attributes object or children. * @param {HChild | HChild[]} [children] - Children (if props are specified). * @returns {HTMLElement | SVGElement} The created DOM element. */ function h(tag, propsOrChildren, children) { const SVG_NS = 'http://www.w3.org/2000/svg'; const match = tag.match(/^([a-z0-9-]+)(#[\w-]+)?((\.[\w-]+)*)$/i); if (!match) throw new Error(`Invalid tag syntax: ${tag}`); const [, tagName, id, classList] = match; const isSVG = ['svg', 'circle', 'rect', 'path', 'g', 'line', 'text', 'use', 'defs', 'clipPath'].includes(tagName); const el = isSVG ? document.createElementNS(SVG_NS, tagName) : document.createElement(tagName); if (id) el.id = id.slice(1); if (classList) { const classes = classList.replace(/\./g, ' ').trim(); if (classes) { el.classList.add(...classes.split(/\s+/)); } } let props = {}; let childrenArray; if (propsOrChildren && Object.prototype.toString.call(propsOrChildren) === '[object Object]') { props = propsOrChildren; childrenArray = children; } else { childrenArray = propsOrChildren; } // --- Start of Attribute/Property Handling --- const directProperties = new Set(['value', 'checked', 'selected', 'readOnly', 'disabled', 'multiple', 'textContent']); const urlAttributes = new Set(['href', 'src', 'action', 'formaction']); const safeProtocols = new Set(['https:', 'http:', 'mailto:', 'tel:', 'blob:', 'data:']); for (const [key, value] of Object.entries(props)) { // 0. Handle `ref` callback (highest priority after props parsing). if (key === 'ref' && typeof value === 'function') { value(el); } // 1. Security check for URL attributes. else if (urlAttributes.has(key)) { const url = String(value); try { const parsedUrl = new URL(url); // Throws if not an absolute URL. if (safeProtocols.has(parsedUrl.protocol)) { el.setAttribute(key, url); } else { el.setAttribute(key, '#'); Logger.warn('UNSAFE URL', LOG_STYLES.YELLOW, `Blocked potentially unsafe protocol "${parsedUrl.protocol}" in attribute "${key}":`, url); } } catch { el.setAttribute(key, '#'); Logger.warn('INVALID URL', LOG_STYLES.YELLOW, `Blocked invalid or relative URL in attribute "${key}":`, url); } } // 2. Direct property assignments. else if (directProperties.has(key)) { el[key] = value; } // 3. Other specialized handlers. else if (key === 'style' && typeof value === 'object') { Object.assign(el.style, value); } else if (key === 'dataset' && typeof value === 'object') { for (const [dataKey, dataVal] of Object.entries(value)) { el.dataset[dataKey] = dataVal; } } else if (key.startsWith('on')) { if (typeof value === 'function') { el.addEventListener(key.slice(2).toLowerCase(), value); } } else if (key === 'className') { const classes = String(value).trim(); if (classes) { el.classList.add(...classes.split(/\s+/)); } } else if (key.startsWith('aria-')) { el.setAttribute(key, String(value)); } // 4. Default attribute handling. else if (value !== false && value !== null && typeof value !== 'undefined') { el.setAttribute(key, value === true ? '' : String(value)); } } // --- End of Attribute/Property Handling --- const fragment = document.createDocumentFragment(); /** * Appends a child node or text to the document fragment. * @param {HChild} child - The child to append. */ function append(child) { if (child === null || child === false || typeof child === 'undefined') return; if (typeof child === 'string' || typeof child === 'number') { fragment.appendChild(document.createTextNode(String(child))); } else if (Array.isArray(child)) { child.forEach(append); } else if (child instanceof Node) { fragment.appendChild(child); } else { throw new Error('Unsupported child type'); } } append(childrenArray); el.appendChild(fragment); if (el instanceof HTMLElement || el instanceof SVGElement) { return el; } throw new Error('Created element is not a valid HTMLElement or SVGElement'); } /** * Recursively builds a DOM element from a definition object using the h() function. * @param {object} def The definition object for the element. * @returns {HTMLElement | SVGElement | null} The created DOM element. */ function createIconFromDef(def) { if (!def) return null; const children = def.children ? def.children.map((child) => createIconFromDef(child)) : []; return h(def.tag, def.props, children); } /** * A helper function to safely retrieve a nested property from an object using a dot-notation string. * @param {object} obj The object to query. * @param {string} path The dot-separated path to the property. * @returns {any} The value of the property, or undefined if not found. */ function getPropertyByPath(obj, path) { if (!obj || typeof path !== 'string') { return undefined; } return path.split('.').reduce((o, k) => (o === undefined || o === null ? undefined : o[k]), obj); } /** * Sets a nested property on an object using a dot-notation path. * @param {object} obj The object to modify. * @param {string} path The dot-separated path to the property. * @param {any} value The value to set. * @returns {boolean} True if successful, false if the path was invalid or blocked. */ function setPropertyByPath(obj, path, value) { if (!obj || typeof obj !== 'object') { Logger.warn('OBJ GUARD', LOG_STYLES.YELLOW, `setPropertyByPath: Target object is invalid (type: ${typeof obj}).`); return false; } if (!path) return false; const keys = path.split('.'); let current = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (!key) { Logger.warn('OBJ GUARD', LOG_STYLES.YELLOW, `setPropertyByPath: Invalid empty key in path "${path}".`); return false; } // Security: Prevent prototype pollution if (key === '__proto__' || key === 'constructor' || key === 'prototype') { Logger.warn('OBJ GUARD', LOG_STYLES.YELLOW, `setPropertyByPath: Blocked forbidden key "${key}" in path "${path}".`); return false; } // If the property exists, validate that it is an object (and not null) to allow nesting. if (current[key] !== undefined && current[key] !== null) { if (typeof current[key] !== 'object') { Logger.warn('OBJ GUARD', LOG_STYLES.YELLOW, `setPropertyByPath: Cannot set property "${keys[i + 1]}" on non-object value at "${keys.slice(0, i + 1).join('.')}" in path "${path}".`); return false; } } else { // Only create a new object if the property is null or undefined. // This prevents overwriting existing values (like arrays) with empty objects. current[key] = {}; } current = current[key]; } const lastKey = keys[keys.length - 1]; if (!lastKey) { Logger.warn('OBJ GUARD', LOG_STYLES.YELLOW, `setPropertyByPath: Invalid empty key at end of path "${path}".`); return false; } // Security: Prevent prototype pollution on the last key if (lastKey === '__proto__' || lastKey === 'constructor' || lastKey === 'prototype') { Logger.warn('OBJ GUARD', LOG_STYLES.YELLOW, `setPropertyByPath: Blocked forbidden key "${lastKey}" in path "${path}".`); return false; } current[lastKey] = value; return true; } // ================================================================================= // SECTION: Base Manager // Description: Provides common lifecycle and event subscription management. // ================================================================================= /** * @class BaseManager * @description Provides common lifecycle and event subscription management capabilities. * Implements the Template Method pattern for init/destroy cycles. */ class BaseManager { constructor() { /** @type {Array<{event: string, key: string}>} */ this.subscriptions = []; this.isInitialized = false; this.isDestroyed = false; /** @type {Promise|null} */ this._initPromise = null; } /** * Initializes the manager. * Prevents double initialization and supports async hooks. * @param {...any} args Arguments to pass to the hook method. * @returns {Promise} */ async init(...args) { if (this.isInitialized) return; // Guard against concurrent initialization if (this._initPromise) { await this._initPromise; return; } this.isDestroyed = false; this._initPromise = (async () => { await this._onInit(...args); // Check if destroyed during async init if (!this.isDestroyed) { this.isInitialized = true; } })(); try { await this._initPromise; } finally { this._initPromise = null; } } /** * Destroys the manager and cleans up resources. * Idempotent: safe to call multiple times. */ destroy() { if (this.isDestroyed) return; this.isDestroyed = true; this.isInitialized = false; // 1. Hook for subclass specific cleanup this._onDestroy(); // 2. Unsubscribe from all events // Create a snapshot to safely iterate even if unsubscribing modifies the original array const subsToUnsubscribe = [...this.subscriptions]; // Immediately clear the array to prevent re-entry or access during cleanup this.subscriptions = []; // Iterate in reverse order (LIFO) to respect dependencies for (let i = subsToUnsubscribe.length - 1; i >= 0; i--) { const { event, key } = subsToUnsubscribe[i]; EventBus.unsubscribe(event, key); } } /** * Registers a platform-specific listener. * Exposes subscription capability to adapters safely. * @param {string} event * @param {Function} callback */ registerPlatformListener(event, callback) { this._subscribe(event, callback); } /** * Registers a one-time platform-specific listener. * Exposes single subscription capability to adapters safely. * @param {string} event * @param {Function} callback */ registerPlatformListenerOnce(event, callback) { this._subscribeOnce(event, callback); } /** * Hook method for initialization logic. * @protected * @param {...any} args */ _onInit(...args) { // To be implemented by subclasses } /** * Hook method for cleanup logic. * @protected */ _onDestroy() { // To be implemented by subclasses } /** * Helper to subscribe to EventBus and track the subscription for cleanup. * Appends the listener name and a unique suffix to the key to avoid conflicts. * @protected * @param {string} event * @param {Function} listener */ _subscribe(event, listener) { const baseKey = createEventKey(this, event); // Use function name for debugging aid, fallback to 'anonymous' const listenerName = listener.name || 'anonymous'; // Generate a short random suffix to guarantee uniqueness even for anonymous functions const uniqueSuffix = Math.random().toString(36).substring(2, 7); const key = `${baseKey}_${listenerName}_${uniqueSuffix}`; EventBus.subscribe(event, listener, key); this.subscriptions.push({ event, key }); } /** * Helper to subscribe to EventBus once and track the subscription for cleanup. * Appends the listener name and a unique suffix to the key to avoid conflicts. * @protected * @param {string} event * @param {Function} listener */ _subscribeOnce(event, listener) { const baseKey = createEventKey(this, event); // Use function name for debugging aid, fallback to 'anonymous' const listenerName = listener.name || 'anonymous'; // Generate a short random suffix to guarantee uniqueness even for anonymous functions const uniqueSuffix = Math.random().toString(36).substring(2, 7); const key = `${baseKey}_${listenerName}_${uniqueSuffix}`; // Wrapper to cleanup the subscription record after firing const wrappedListener = (...args) => { this._removeSubscriptionRecord(key); listener(...args); }; EventBus.once(event, wrappedListener, key); this.subscriptions.push({ event, key }); } /** * Helper to unsubscribe from specific events dynamically. * @protected * @param {string} event The event name. * @param {string} [keyPrefix] Optional prefix to filter specific listeners (e.g. 'ClassName.purpose'). */ _unsubscribe(event, keyPrefix) { const fullKeyPrefix = keyPrefix || createEventKey(this, event); // Find matching subscriptions const toRemove = this.subscriptions.filter((sub) => sub.event === event && sub.key.startsWith(fullKeyPrefix)); // Unsubscribe and remove from list toRemove.forEach((sub) => { EventBus.unsubscribe(sub.event, sub.key); this._removeSubscriptionRecord(sub.key); }); } /** * Internal helper to remove a subscription record from the array by key. * @private * @param {string} key */ _removeSubscriptionRecord(key) { this.subscriptions = this.subscriptions.filter((sub) => sub.key !== key); } } // ================================================================================= // SECTION: UI Construction System (ReactiveStore / FormEngine / SchemaBuilder) // ================================================================================= /** * @abstract * @class UIComponentBase * @extends BaseManager * @description Base class for a UI component with lifecycle management. */ class UIComponentBase extends BaseManager { constructor(callbacks = {}) { super(); this.callbacks = callbacks; this.element = null; this.storeSubscriptions = []; } /** * @abstract * Renders the component's DOM structure. Must be implemented by subclasses. * @returns {HTMLElement|void} */ render() { throw new Error('Component must implement render method.'); } /** * Adds a store subscription handle (unsubscribe function) to be managed by the component. * @param {() => void} unsub - The unsubscribe function returned by store.subscribe. */ addStoreSubscription(unsub) { if (typeof unsub === 'function') { this.storeSubscriptions.push(unsub); } } /** * Executes all registered store subscription handles and clears the list. * Can be called manually (e.g. on modal close) or automatically on destroy. */ clearStoreSubscriptions() { this.storeSubscriptions.forEach((unsub) => unsub()); this.storeSubscriptions = []; } /** * Lifecycle hook for cleanup. * Removes the component's element from the DOM. * @protected * @override */ _onDestroy() { this.clearStoreSubscriptions(); this.element?.remove(); this.element = null; } } /** * @class ReactiveStore * @description A simple reactive store implementing the Pub/Sub pattern. * It allows setting and getting values using dot-notation paths and notifies listeners on changes. * * [NOTIFICATION BEHAVIOR] * - Notifications are triggered strictly for the exact path being modified. * - Changes do NOT bubble up to parent paths. * - Subscribers are responsible for determining if a change affects them (e.g., using startsWith checks). */ class ReactiveStore { constructor(initialState) { this.state = deepClone(initialState); this.listeners = new Set(); } /** * Retrieves a value from the store by path. * @param {string} path - The dot-notation path to the property. * @returns {any} The value at the specified path. */ get(path) { return getPropertyByPath(this.state, path); } /** * Sets a value in the store at the specified path. * Notifies listeners if the value has effectively changed. * @param {string} path - The dot-notation path to the property. * @param {any} value - The new value to set. */ set(path, value) { // Validate path to prevent errors in split logic or setPropertyByPath if (!path || typeof path !== 'string') { Logger.warn('Store', '', `ReactiveStore.set: Invalid path "${path}"`); return; } const currentValue = this.get(path); // Use Object.is for strict equality check (handles NaN, -0/+0 correctly) if (Object.is(currentValue, value)) return; // Only notify if the update was successful if (setPropertyByPath(this.state, path, value)) { // Notify only the specific path that changed. this.notify(path); } } /** * Returns a deep copy of the current full state object. * @returns {object} */ getData() { return deepClone(this.state); } /** * Replaces the entire state with a new object and notifies listeners. * Triggers notifications for all top-level keys in both old and new states. * @param {object} newState - The new full state object. Must be a valid object (null is ignored). */ replaceState(newState) { if (!newState || typeof newState !== 'object') { Logger.warn('Store', '', 'ReactiveStore.replaceState: Invalid state object.'); return; } const oldState = this.state; this.state = deepClone(newState); // Notify for the union of keys in old and new states to ensure deleted keys are handled const keys = new Set([...Object.keys(oldState || {}), ...Object.keys(this.state || {})]); keys.forEach((key) => { this.notify(key); }); } /** * Subscribes to store updates. * WARNING: The state object passed to the callback is a direct reference to the store's internal state. * Do not mutate the state object directly within the callback. Use store.set() for updates. * @param {function(object, string): void} callback - The function to call on update. Receives (state, changedPath). * @returns {function(): void} A function to unsubscribe. */ subscribe(callback) { if (typeof callback !== 'function') { Logger.warn('Store', '', 'ReactiveStore.subscribe: Callback must be a function.'); return () => {}; } this.listeners.add(callback); return () => this.listeners.delete(callback); } /** * @private * Notifies all subscribers of a change. * @param {string} changedPath - The path that was updated. */ notify(changedPath) { for (const listener of this.listeners) { try { listener(this.state, changedPath); } catch (e) { Logger.error('Store', '', 'ReactiveStore listener failed:', e); } } } } /** * @class ComponentRegistry * @description Registry for UI components used by FormEngine. */ class ComponentRegistry { static components = new Map(); /** * Registers a component definition. * @param {string} type - Component type name. * @param {object} def - Component definition containing render logic. */ static register(type, def) { this.components.set(type, def); } /** * Retrieves a component definition. * @param {string} type * @returns {object|undefined} */ static get(type) { return this.components.get(type); } } /** * @class FormEngine * @description A reactive form engine that binds a schema to a ReactiveStore. * Handles DOM generation, event binding, and dynamic property updates. */ class FormEngine { /** * @param {ReactiveStore} store - The data store. * @param {Array|object} schema - The UI schema. * @param {object} context - Context object containing styles, etc. */ constructor(store, schema, context) { this.store = store; this.schema = Array.isArray(schema) ? schema : [schema]; this.context = context; this.elements = new Map(); // Map this.unsubscribers = []; this.boundEventListeners = []; // Bind methods this._handleInput = this._handleInput.bind(this); this._handleStoreUpdate = this._handleStoreUpdate.bind(this); } /** * Renders the form definition into a DocumentFragment. * @returns {DocumentFragment} */ render() { const fragment = document.createDocumentFragment(); this._renderRecursive(this.schema, fragment); // Setup reactivity after rendering this.unsubscribers.push(this.store.subscribe(this._handleStoreUpdate)); // Initial evaluation of dynamic properties this._evaluateDynamicProperties(this.store.getData()); return fragment; } destroy() { this.unsubscribers.forEach((unsub) => unsub()); this.unsubscribers = []; // Clean up event listeners explicitly to prevent memory leaks this.boundEventListeners.forEach(({ element, type, handler }) => { element.removeEventListener(type, handler); }); this.boundEventListeners = []; // Execute component-specific cleanup logic for (const { element, node } of this.elements.values()) { const component = ComponentRegistry.get(node.type); if (component && typeof component.destroy === 'function') { try { component.destroy(element); } catch (e) { Logger.warn('FormEngine', LOG_STYLES.YELLOW, `Error destroying component "${node.type}"`, e); } } } this.elements.clear(); } /** * @private */ _renderRecursive(schemaNodes, parent) { for (const node of schemaNodes) { const component = ComponentRegistry.get(node.type); if (!component) { Logger.warn('FormEngine', '', `Unknown component type "${node.type}"`); continue; } // Render the component const element = component.render(node, this.context, this); if (element) { const nodeId = node.id || `_node_${Math.random().toString(36).slice(2)}`; node._internalId = nodeId; this.elements.set(nodeId, { element, node }); // Bind events if the component is interactive if (node.configKey) { this._bindInteractiveElement(element, node); } // Recursively render children if applicable // Some components might handle children rendering internally (e.g. specialized containers), // but the default behavior is to append them. if (node.children && !component.handlesChildren) { const container = component.getContentContainer ? component.getContentContainer(element) : element; this._renderRecursive(node.children, container); } parent.appendChild(element); } } } /** * @private */ _bindInteractiveElement(rootElement, node) { const component = ComponentRegistry.get(node.type); // Skip default binding if component handles it manually if (component && component.manualBinding) { // Just trigger initial update to sync UI with Store const value = this.store.get(node.configKey); if (component.onUpdate) { component.onUpdate(rootElement, value, this.context, node); } return; } // Find the actual input element. let input = rootElement.matches('input, select, textarea') ? rootElement : rootElement.querySelector('input, select, textarea'); if (!input) { input = rootElement; } // Retrieve initial value once const initialValue = this.store.get(node.configKey); // Check if input is a file input using selector match (safer than type property) const isFileInput = input.matches('input[type="file"]'); // Set initial value (skip for file inputs to avoid security errors) if (!isFileInput) { this._setElementValue(input, initialValue, node); } // Initial UI update (for auxiliary displays like slider values) if (component && component.onUpdate) { component.onUpdate(input, initialValue, this.context, node); } // Listen for changes const handler = (e) => this._handleInput(e, node); // Determine correct event type to avoid double subscription let useChangeEvent = false; if (input.tagName === 'SELECT') { useChangeEvent = true; } else if (input.matches('input[type="checkbox"], input[type="radio"], input[type="file"]')) { useChangeEvent = true; } const eventType = useChangeEvent ? 'change' : 'input'; input.addEventListener(eventType, handler); this.boundEventListeners.push({ element: input, type: eventType, handler }); } /** * @private */ _handleInput(e, node) { const target = e.target; let value; if (target.type === 'checkbox') { value = target.checked; } else if (target.type === 'number') { const floatVal = parseFloat(target.value); // Convert NaN (invalid input or empty string) to null for safety value = Number.isNaN(floatVal) ? null : floatVal; } else { // Convert empty string to null for text inputs value = target.value === '' ? null : target.value; } // Execute transformation hook if defined (UI Value -> Store Value) if (node.transformValue) { value = node.transformValue(value); } this.store.set(node.configKey, value); // Execute side-effect hook if defined if (node.onChange) { node.onChange(value, this.store.getData()); } } _setElementValue(element, value, node) { // Apply transformation from Store Value -> UI Input Value if defined let inputValue = value; if (node && node.toInputValue) { inputValue = node.toInputValue(value); } if (element.type === 'checkbox') { element.checked = !!inputValue; } else if (element.type === 'number') { // Handle null/undefined for numeric inputs if (inputValue === null || inputValue === undefined) { // Check for dataset defaults or specific logic (can be extended) element.value = element.min || 0; } else { element.value = inputValue; } } else { element.value = inputValue === null || inputValue === undefined ? '' : inputValue; } } /** * @private */ _handleStoreUpdate(state, changedPath) { // 1. Update bound values for (const { element, node } of this.elements.values()) { // Check for exact match, parent path match (changedPath is parent), OR child path match (changedPath is child) // This ensures bi-directional updates: // - Parent change updates children (e.g. store.set('user', ...) updates 'user.name' input) // - Child change updates parent (e.g. store.set('user.name', ...) updates 'user' bound component) if (node.configKey && typeof changedPath === 'string' && (node.configKey === changedPath || node.configKey.startsWith(changedPath + '.') || changedPath.startsWith(node.configKey + '.'))) { const component = ComponentRegistry.get(node.type); const newValue = this.store.get(node.configKey); // Handle manual binding components (e.g. padding slider) // These components manage their own internal state updates via onUpdate if (component && component.manualBinding) { if (component.onUpdate) { component.onUpdate(element, newValue, this.context, node); } continue; } // Default behavior for standard inputs const input = element.matches('input, select, textarea') ? element : element.querySelector('input, select, textarea'); if (input) { // Only update DOM if different to prevent cursor jumping if (input.type === 'checkbox') { if (input.checked !== !!newValue) input.checked = !!newValue; } else { // Calculate expected UI value to compare let expectedValue = newValue; if (node.toInputValue) { expectedValue = node.toInputValue(newValue); } // Loose equality check to allow string/number conversions // eslint-disable-next-line eqeqeq if (input.value != expectedValue) this._setElementValue(input, newValue, node); } // Trigger UI update hook if (component && component.onUpdate) { component.onUpdate(input, newValue, this.context, node); } } else if (component && component.onUpdate) { component.onUpdate(element, newValue, this.context, node); } } } // 2. Evaluate dynamic properties (visibility, disabled state) this._evaluateDynamicProperties(state); } /** * @private */ _evaluateDynamicProperties(state) { for (const { element, node } of this.elements.values()) { // Visibility if (node.visibleIf) { const isVisible = node.visibleIf(state); element.style.display = isVisible ? '' : 'none'; } // Disabled state if (node.disabledIf) { const isDisabled = node.disabledIf(state); const targets = element.matches('input, select, textarea, button') ? [element] : element.querySelectorAll('input, select, textarea, button'); targets.forEach((t) => (t.disabled = isDisabled)); // Style adjustments for containers if (isDisabled) { element.classList.add('is-disabled'); element.style.opacity = '0.5'; element.style.pointerEvents = 'none'; } else { element.classList.remove('is-disabled'); element.style.opacity = ''; element.style.pointerEvents = ''; } } } } } const SchemaBuilder = { create(type, id, props = {}) { return { type, id, ...props }; }, Group(label, children, options = {}) { return { type: 'group', label, children, ...options }; }, Row(children, options = {}) { return { type: 'row', children, ...options }; }, Separator(options = {}) { return { type: 'separator', ...options }; }, Container(children, options = {}) { return { type: 'container', children, ...options }; }, Label(text, options = {}) { return { type: 'label', text, ...options }; }, Text(key, label, options = {}) { return { type: 'text', configKey: key, label, ...options }; }, TextArea(key, label, options = {}) { return { type: 'textarea', configKey: key, label, ...options }; }, Toggle(key, label, options = {}) { return { type: 'toggle', configKey: key, label, ...options }; }, Select(key, label, optionValues, options = {}) { return { type: 'select', configKey: key, label, options: optionValues, ...options }; }, Button(id, text, onClick, options = {}) { return { type: 'button', id, text, onClick, ...options }; }, CodeEditor(key, options = {}) { return { type: 'code-editor', configKey: key, ...options }; }, TextDisplay(key, options = {}) { return { type: 'text-display', configKey: key, ...options }; }, }; /** * Registers standard UI components to the registry. */ function registerFormComponents() { ComponentRegistry.register('group', { render(node, context) { const cls = context.styles; const className = node.className ? `${cls.submenuFieldset} ${node.className}` : cls.submenuFieldset; return h('fieldset', { className }, [h('legend', { title: node.title }, node.label)]); }, }); ComponentRegistry.register('row', { render(node, context) { const cls = context.styles; let className = cls.submenuRow; if (node.className) { const mapped = cls[node.className]; // Check for mapped alias (e.g. topRow) className = mapped ? mapped : `${className} ${node.className}`; } return h('div', { className }); }, }); ComponentRegistry.register('separator', { render(node, context) { return h('div', { className: context.styles.submenuSeparator }); }, }); ComponentRegistry.register('container', { render(node, context) { const cls = context.styles; let className = ''; if (node.className) { const mapped = cls[node.className]; className = mapped || node.className; } return h('div', { className }); }, }); ComponentRegistry.register('label', { render(node) { return h('label', { htmlFor: node.for, title: node.title }, node.text); }, }); ComponentRegistry.register('select', { render(node, context) { const cls = context.styles; const select = h('select', { id: node.id, title: node.title, className: cls.selectInput }); const options = resolveSelectOptions(node, context?.store?.getData?.()); populateSelectOptions(select, options); if (node.label) { return h('div', {}, [h('label', { title: node.title }, node.label), select]); } return select; }, onUpdate(element, value, context, node) { const select = element.matches('select') ? element : element.querySelector('select'); if (!select) return; const options = resolveSelectOptions(node, context?.store?.getData?.()); populateSelectOptions(select, options); select.value = value; if (!options.find((opt) => opt.value === value) && options.length > 0 && context?.store) { context.store.set(node.configKey, options[0].value); } }, }); ComponentRegistry.register('toggle', { render(node, context) { const cls = context.styles; return h('label', { className: cls.toggleSwitch, title: node.title }, [h('input', { type: 'checkbox', id: node.id }), h('span', { className: cls.toggleSlider })]); }, }); ComponentRegistry.register('button', { render(node, context) { const cls = context.styles; const btnClass = node.className ? `${cls.modalButton} ${node.className}` : cls.modalButton; return h( 'button', { id: node.id, className: btnClass, title: node.title, type: 'button', style: { width: node.fullWidth ? '100%' : 'auto' }, onclick: (e) => { e.preventDefault(); if (node.onClick) node.onClick(e); }, }, node.text ); }, }); ComponentRegistry.register('text', { render(node, context) { const cls = context.styles; // Reuse selectInput style for text inputs to ensure consistency const input = h('input', { type: 'text', id: node.id, title: node.title, className: cls.selectInput }); if (node.label) { return h('div', {}, [h('label', { title: node.title }, node.label), input]); } return input; }, }); ComponentRegistry.register('textarea', { render(node, context) { const cls = context.styles; // Reuse selectInput style for textareas to ensure consistency const textarea = h('textarea', { id: node.id, rows: node.rows || 3, title: node.title, className: cls.selectInput }); if (node.label) { return h('div', {}, [h('label', { title: node.title }, node.label), textarea]); } return textarea; }, }); ComponentRegistry.register('code-editor', { manualBinding: true, render(node, context, engine) { // Use class from context if available, or fallback const cls = context.styles; return h('textarea', { className: cls.editor || `${APPID}-json-editor`, spellcheck: false, oninput: (e) => { engine.store.set(node.configKey, e.target.value); if (node.onChange) node.onChange(e.target.value, engine.store.getData()); }, }); }, onUpdate(element, value) { if (!(element instanceof HTMLTextAreaElement)) return; if (document.activeElement === element) return; element.value = value || ''; }, }); ComponentRegistry.register('text-display', { manualBinding: true, render(node, context) { // Map className using context.styles if possible let className = node.className || ''; if (context.styles && context.styles[node.className]) { className = context.styles[node.className]; } else if (node.className) { // Fallback to raw class name if not in map className = node.className; } return h('div', { className }); }, onUpdate(element, value) { if (value && typeof value === 'object') { element.textContent = value.text || ''; element.style.color = value.color || ''; element.style.fontWeight = value.bold ? 'bold' : 'normal'; element.title = value.title || ''; } else { element.textContent = value || ''; element.style.color = ''; element.style.fontWeight = 'normal'; element.title = ''; } }, }); ComponentRegistry.register('text-list', { manualBinding: true, // Required: Control own rendering render(node, context) { const cls = context.styles; // Return container only. Content is rendered in onUpdate. return h(`div.${cls.scrollableArea}`, { dataset: { fingerprint: '' }, }); }, onUpdate(element, value, context, node) { // Initialization check if (!context?.textEditor || !value) return; const { currentTexts, listFingerprint, focusedIndex } = value; const prevFingerprint = element.dataset.fingerprint; // Lazy Sync: Re-render only when Fingerprint (structure) changes // Text input (input event) does not change Fingerprint, so this block is skipped if (prevFingerprint === String(listFingerprint)) { return; } // --- Re-render process --- element.replaceChildren(); // Clear (CSP safe) element.dataset.fingerprint = listFingerprint; const cls = context.styles; currentTexts.forEach((text, index) => { const textItem = h(`div.${cls.textItem}`, { 'data-index': index }, [ // Drag handle h(`div.${cls.dragHandle}`, { title: 'Drag to reorder', draggable: 'true' }, [createIconFromDef(StyleDefinitions.ICONS.dragHandle)]), // Textarea h('textarea', { 'data-index': index, rows: 3, value: text, // Initial value // Input event: Update Store value but do NOT update Fingerprint (Avoid re-render) oninput: (e) => { context.textEditor._handleTextPixelInput(index, e.target.value); // Auto resize e.target.style.height = 'auto'; e.target.style.height = `${e.target.scrollHeight}px`; }, // Track focus to determine insertion point for 'New' button onfocus: () => { context.store.set('editor.focusedIndex', index); }, }), // Controls h(`div.${cls.itemControls}`, [ h(`button.${cls.modalButton}.${cls.moveBtn}.move-up-btn`, { title: 'Move up', disabled: index === 0 }, [createIconFromDef(StyleDefinitions.ICONS.up)]), h(`button.${cls.modalButton}.${cls.moveBtn}.move-down-btn`, { title: 'Move down', disabled: index === currentTexts.length - 1 }, [createIconFromDef(StyleDefinitions.ICONS.down)]), h(`button.${cls.modalButton}.${cls.deleteBtn}.delete-btn`, { title: 'Delete' }, [createIconFromDef(StyleDefinitions.ICONS.delete)]), ]), ]); element.appendChild(textItem); }); // Auto resize (for initial display) requestAnimationFrame(() => { element.querySelectorAll('textarea').forEach((ta) => { ta.style.height = 'auto'; ta.style.height = `${ta.scrollHeight}px`; }); // Restore focus if (focusedIndex >= 0 && focusedIndex < currentTexts.length) { const target = element.querySelector(`textarea[data-index="${focusedIndex}"]`); if (target) { target.focus(); // Set cursor to end const len = target.value.length; target.setSelectionRange(len, len); } } }); }, }); ComponentRegistry.register('text-editor-header', { manualBinding: true, render(node, context) { return context?.textEditor?._createHeaderControls() || null; }, onUpdate(element, value, context, node) { if (!context?.textEditor || !value) return; // --- Add guard to prevent unnecessary re-renders --- const { activeProfile, activeCategory, mode, targetType, lastUpdated } = value; const newRenderState = JSON.stringify({ p: activeProfile, c: activeCategory, m: mode, t: targetType, u: lastUpdated }); if (element.dataset.renderState === newRenderState) { return; } element.dataset.renderState = newRenderState; // --------------------------------------- const { cachedConfig } = context.textEditor; if (!cachedConfig) return; const cls = context.styles; const isAnyRenaming = mode === 'rename'; const isAnyDeleting = mode === 'delete_confirm'; const isAnyActionInProgress = isAnyRenaming || isAnyDeleting; // Get profile and category keys const profiles = cachedConfig.texts || []; const profileKeys = profiles.map((p) => p.name); const activeProfileObj = profiles.find((p) => p.name === activeProfile); const categories = activeProfileObj ? activeProfileObj.categories : []; const categoryKeys = categories.map((c) => c.name); // Update logic per row const updateRow = (type, keys, activeKey) => { const row = element.querySelector(`.${cls.headerRow}[data-type="${type}"]`); if (!row) return; const isRenamingThis = isAnyRenaming && targetType === type; const isDeletingThis = isAnyDeleting && targetType === type; const isTargetDisabled = isAnyActionInProgress && targetType !== type; row.classList.toggle('is-disabled', isTargetDisabled); const select = row.querySelector('select'); const renameInput = row.querySelector('input[type="text"]'); const mainActions = row.querySelector(`.${cls.mainActions}`); const renameActions = row.querySelector(`.${cls.renameActions}`); const deleteConfirmGroup = row.querySelector(`.${cls.deleteConfirmGroup}`); // Toggle Select / Input display select.style.display = isRenamingThis ? 'none' : 'block'; renameInput.style.display = isRenamingThis ? 'block' : 'none'; // Toggle button area display mainActions.style.visibility = !isRenamingThis && !isDeletingThis ? 'visible' : 'hidden'; renameActions.style.display = isRenamingThis ? 'flex' : 'none'; deleteConfirmGroup.style.display = isDeletingThis ? 'flex' : 'none'; // Update Select options (only when not renaming) if (!isRenamingThis) { const currentScroll = select.scrollTop; // Check for diff update (simple) const newSignature = keys.join('|'); if (select.dataset.signature !== newSignature || select.value !== activeKey) { select.textContent = ''; keys.forEach((key, index) => select.appendChild(h('option', { value: key }, `${index + 1}. ${key}`))); select.dataset.signature = newSignature; } select.value = activeKey; select.scrollTop = currentScroll; } else { renameInput.value = activeKey; } // Button activity control const upBtn = row.querySelector(`#${APPID}-${type}-up-btn`); const downBtn = row.querySelector(`#${APPID}-${type}-down-btn`); const deleteBtn = row.querySelector(`#${APPID}-${type}-delete-btn`); const newBtn = row.querySelector(`#${APPID}-${type}-new-btn`); const copyBtn = row.querySelector(`#${APPID}-${type}-copy-btn`); const renameBtn = row.querySelector(`#${APPID}-${type}-rename-btn`); const index = keys.indexOf(activeKey); if (upBtn) upBtn.disabled = isAnyActionInProgress || index <= 0; if (downBtn) downBtn.disabled = isAnyActionInProgress || index >= keys.length - 1; if (deleteBtn) deleteBtn.disabled = isAnyActionInProgress || keys.length <= 1; if (newBtn) newBtn.disabled = isAnyActionInProgress; if (copyBtn) copyBtn.disabled = isAnyActionInProgress; if (renameBtn) renameBtn.disabled = isAnyActionInProgress; }; updateRow('profile', profileKeys, activeProfile); updateRow('category', categoryKeys, activeCategory); // Control the entire modal (footer buttons, etc.) const modalElement = context.textEditor.modal.element; const scrollArea = modalElement.querySelector(`.${cls.scrollableArea}`); if (scrollArea) scrollArea.classList.toggle('is-disabled', isAnyActionInProgress); const setDisabled = (selector, disabled) => { const el = modalElement.querySelector(selector); if (el) el.disabled = disabled; }; setDisabled(`#${APPID}-text-new-btn`, isAnyActionInProgress); setDisabled(`#${APPID}-editor-modal-apply-btn`, isAnyActionInProgress); setDisabled(`#${APPID}-editor-modal-save-btn`, isAnyActionInProgress); setDisabled(`#${APPID}-editor-modal-cancel-btn`, isAnyActionInProgress); }, }); } function resolveSelectOptions(node, data) { if (typeof node.options === 'function') { return normalizeOptions(node.options(data)); } return normalizeOptions(node.options || []); } function normalizeOptions(options) { return (options || []).map((opt) => { if (typeof opt === 'string') { return { value: opt, label: opt }; } return { value: opt.value, label: opt.label ?? opt.value }; }); } function populateSelectOptions(select, options) { const signature = JSON.stringify(options); if (select.dataset.optionsSignature === signature) return; select.dataset.optionsSignature = signature; select.textContent = ''; options.forEach((opt) => select.appendChild(h('option', { value: opt.value }, opt.label))); } registerFormComponents(); // ================================================================================= // SECTION: Configuration Processor // Description: Handles validation, sanitization, and merging of configuration data. // ================================================================================= const ConfigProcessor = { /** * Processes and sanitizes an entire configuration object. * @param {object|null} userConfig The user configuration object (partial or full). * @returns {object} The complete, sanitized configuration object. */ process(userConfig) { // 1. Start with a deep copy of the defaults. const completeConfig = deepClone(DEFAULT_CONFIG); if (isObject(userConfig)) { // 2. Merge user config into default config. // resolveConfig enforces the structure of completeConfig (DEFAULT_CONFIG), // automatically ignoring keys like 'system' that don't exist in the default. resolveConfig(completeConfig, userConfig); } // 3. Sanitize specific structures (Deep validation & Migration for texts) // Even if 'texts' was overwritten with an old object format by resolveConfig, // _sanitizeTexts handles the migration to array format. completeConfig.texts = this._sanitizeTexts(completeConfig.texts); // 4. Validate Options (Consistency check) this._validateOptions(completeConfig); return completeConfig; }, /** * Migrates old object-based texts structure to new array-based structure. * @param {object} oldTexts The old texts object. * @returns {Array} The new texts array. */ _migrateTexts(oldTexts) { if (!isObject(oldTexts)) return []; return Object.keys(oldTexts).map((profileName) => { const profileObj = oldTexts[profileName] || {}; const categories = Object.keys(profileObj).map((catName) => ({ name: catName, items: Array.isArray(profileObj[catName]) ? profileObj[catName] : [], })); return { name: profileName, categories: categories, }; }); }, /** * Sanitizes the nested texts structure. * Handles migration from Object to Array and enforces unique names. * @param {object|Array} texts * @returns {Array} Sanitized texts array */ _sanitizeTexts(texts) { // 1. Migration: Convert object to array if necessary const profiles = Array.isArray(texts) ? texts : this._migrateTexts(texts); // 2. Sanitize contents const sanitizedProfiles = []; const usedProfileNames = new Set(); for (const profile of profiles) { if (!isObject(profile)) continue; let pName = String(profile.name || 'Untitled Profile').trim(); if (!pName) pName = 'Untitled Profile'; // Unique Profile Name Enforcement if (usedProfileNames.has(pName)) { let counter = 2; while (usedProfileNames.has(`${pName} ${counter}`)) counter++; pName = `${pName} ${counter}`; } usedProfileNames.add(pName); // Sanitize Categories const rawCategories = Array.isArray(profile.categories) ? profile.categories : []; const sanitizedCategories = []; const usedCatNames = new Set(); for (const cat of rawCategories) { if (!isObject(cat)) continue; let cName = String(cat.name || 'Untitled Category').trim(); if (!cName) cName = 'Untitled Category'; // Unique Category Name Enforcement if (usedCatNames.has(cName)) { let counter = 2; while (usedCatNames.has(`${cName} ${counter}`)) counter++; cName = `${cName} ${counter}`; } usedCatNames.add(cName); // Sanitize Items const rawItems = Array.isArray(cat.items) ? cat.items : []; const items = rawItems.map((item) => { if (typeof item !== 'string') { Logger.warn('SANITIZER', LOG_STYLES.YELLOW, `Sanitizing invalid text entry: Item in category "${cName}" was not a string.`); return String(item); } return item; }); sanitizedCategories.push({ name: cName, items: items, }); } // Ensure there's at least one category if (sanitizedCategories.length === 0) { Logger.warn('SANITIZER', LOG_STYLES.YELLOW, `Profile "${pName}" has no categories. Adding a default category.`); sanitizedCategories.push({ name: 'New Category', items: [] }); } sanitizedProfiles.push({ name: pName, categories: sanitizedCategories, }); } // Ensure there's at least one profile. if (sanitizedProfiles.length === 0) { Logger.warn('', '', 'Configuration resulted in no profiles after sanitization. Restoring default texts structure.'); // Restore from default config (already array structure) return deepClone(DEFAULT_CONFIG.texts); } return sanitizedProfiles; }, /** * Validates and corrects option values based on the sanitized config. * @param {object} config */ _validateOptions(config) { // Ensure options object exists if (!config.options) { config.options = deepClone(DEFAULT_CONFIG.options); } // Validate activeProfileName (against Array structure) const activeProfileName = config.options.activeProfileName; const profileExists = config.texts.some((p) => p.name === activeProfileName); if (!profileExists) { const fallback = config.texts.length > 0 ? config.texts[0].name : null; Logger.info('CONFIG', LOG_STYLES.YELLOW, `Active profile "${activeProfileName || 'undefined'}" not found. Falling back to "${fallback}".`); config.options.activeProfileName = fallback; } // Validate logger_level const validLevels = Object.keys(Logger.levels); if (!config.developer || !validLevels.includes(config.developer.logger_level)) { Logger.warn('CONFIG', LOG_STYLES.YELLOW, `Invalid or missing logger_level. Resetting to default '${DEFAULT_CONFIG.developer.logger_level}'.`); if (!config.developer) config.developer = {}; config.developer.logger_level = DEFAULT_CONFIG.developer.logger_level; } }, }; // ================================================================================= // SECTION: Configuration Management (GM Storage) // ================================================================================= class ConfigManager extends BaseManager { constructor() { super(); this.CONFIG_KEY = CONSTANTS.CONFIG_KEY; this.DEFAULT_CONFIG = DEFAULT_CONFIG; /** @type {object|null} */ this.config = null; } /** * @returns {object|null} The current configuration object. */ get() { return this.config; } /** * Loads the configuration from storage, delegates processing to ConfigProcessor. */ async load() { const raw = await GM_getValue(this.CONFIG_KEY); let userConfig = null; if (raw) { try { userConfig = JSON.parse(raw); } catch (e) { Logger.error('LOAD FAILED', LOG_STYLES.RED, 'Failed to parse configuration. Using default settings.', e); userConfig = null; } } this.config = ConfigProcessor.process(userConfig); } /** * Processes via ConfigProcessor, checks size, and saves to storage. * @param {object} obj The configuration object to save. */ async save(obj) { const completeConfig = ConfigProcessor.process(obj); // Size Check const validation = this.validateConfigSize(completeConfig); if (!validation.isValid) { EventBus.publish(EVENTS.CONFIG_SIZE_EXCEEDED, { message: validation.message }); throw new Error(validation.message); } const jsonString = JSON.stringify(completeConfig); this.config = completeConfig; await GM_setValue(this.CONFIG_KEY, jsonString); EventBus.publish(EVENTS.CONFIG_SAVE_SUCCESS); } /** * Decodes a raw string from storage into a user configuration object. * @param {string | null} rawValue The raw string from GM_getValue. * @returns {Promise} The parsed user configuration object, or null if parsing fails. */ async decode(rawValue) { if (!rawValue) return null; try { const parsed = JSON.parse(rawValue); if (isObject(parsed)) { return parsed; } return null; } catch (e) { Logger.error('LOAD FAILED', LOG_STYLES.RED, `Failed to parse raw value. Error: ${e.message}`); return null; } } /** * Validates if the given configuration object is within the storage size limit. * @param {object} configObj - The configuration object to check. * @returns {{ isValid: boolean, message: string | null }} */ validateConfigSize(configObj) { const jsonString = JSON.stringify(configObj); const configSize = new Blob([jsonString]).size; if (configSize > CONSTANTS.CONFIG_SIZE_LIMIT_BYTES) { const sizeInMB = (configSize / 1024 / 1024).toFixed(2); const limitInMB = (CONSTANTS.CONFIG_SIZE_LIMIT_BYTES / 1024 / 1024).toFixed(1); const message = `Configuration size (${sizeInMB} MB) exceeds the ${limitInMB} MB limit.\nChanges are not saved.`; return { isValid: false, message }; } return { isValid: true, message: null }; } } // ================================================================================= // SECTION: UI Elements - Components and Manager // ================================================================================= /** * @class CustomModal * @description A reusable, promise-based modal component. It provides a flexible * structure with header, content, and footer sections, and manages its own * lifecycle and styles using StyleManager. */ class CustomModal { /** * @param {object} [options] * @param {string} [options.title=''] - The title displayed in the modal header. * @param {string} [options.width='500px'] - The width of the modal. * @param {boolean} [options.closeOnBackdropClick=true] - Whether to close the modal when clicking the backdrop. * @param {Array} [options.buttons=[]] - An array of button definitions for the footer. * @param {function(): void} [options.onDestroy] - A callback function executed when the modal is destroyed. * @param {{text: string, id: string, className: string, onClick: (modalInstance: CustomModal, event: Event) => void}} options.buttons[] */ constructor(options = {}) { this.options = { title: '', width: 'min(500px, 95vw)', // Responsive default closeOnBackdropClick: true, buttons: [], onDestroy: null, ...options, }; this.style = StyleManager.request(StyleDefinitions.getModal); this.commonStyle = StyleManager.request(StyleDefinitions.getCommon); this.element = null; this.dom = {}; // To hold references to internal elements like header, content, footer this._createModalElement(); } _createModalElement() { const cls = this.style.classes; const commonCls = this.commonStyle.classes; // Define variables to hold references to key elements. let header, headerTitle, headerContent, content, footer, modalBox, footerMessage; // Create footer buttons declaratively using map and h(). const buttons = this.options.buttons.map((btnDef) => { // Merge passed className with the common modal button class const fullClassName = [commonCls.modalButton, btnDef.className].filter(Boolean).join(' '); return h( 'button', { id: btnDef.id, className: fullClassName, onclick: (e) => btnDef.onClick(this, e), }, btnDef.text ); }); const buttonGroup = h(`div.${cls.buttonGroup}`, buttons); // Create the entire modal structure using h(). this.element = h( `dialog.${cls.dialog}`, (modalBox = h(`div.${cls.box}`, { style: { width: this.options.width } }, [ (header = h(`div.${cls.header}`, [(headerTitle = h('div', this.options.title)), (headerContent = h('div'))])), (content = h(`div.${cls.content}`)), (footer = h(`div.${cls.footer}`, [(footerMessage = h(`div.${cls.footerMessage}`)), buttonGroup])), ])) ); // The 'close' event is the single source of truth for when the dialog has been dismissed. this.element.addEventListener('close', () => this.destroy()); if (this.options.closeOnBackdropClick) { this.element.addEventListener('click', (e) => { if (e.target === this.element) { this.close(); } }); } // Store references and append the final element to the body. this.dom = { header, headerTitle, headerContent, content, footer, modalBox, footerMessage }; document.body.appendChild(this.element); } show(anchorElement = null) { if (this.element instanceof HTMLDialogElement && typeof this.element.showModal === 'function') { if (this.element.open) return; // Prevent InvalidStateError this.element.showModal(); // Positioning logic if (anchorElement && typeof anchorElement.getBoundingClientRect === 'function') { // ANCHORED POSITIONING const modalBox = this.dom.modalBox; const btnRect = anchorElement.getBoundingClientRect(); const margin = 8; // Use actual offsetWidth if available, otherwise fallback to a safe fixed value (500) const modalWidth = modalBox.offsetWidth || 500; const modalHeight = modalBox.offsetHeight; let left = btnRect.left; let top = btnRect.bottom + 4; if (left + modalWidth > window.innerWidth - margin) { left = window.innerWidth - modalWidth - margin; } // Vertical collision detection & adjustment if (top + modalHeight > window.innerHeight - margin) { // Try positioning above the anchor const topAbove = btnRect.top - modalHeight - 4; if (topAbove > margin) { top = topAbove; } else { // If it doesn't fit above, pin to the bottom edge of the window top = window.innerHeight - modalHeight - margin; } } Object.assign(this.element.style, { position: 'absolute', left: `${Math.max(left, margin)}px`, top: `${Math.max(top, margin)}px`, margin: '0', transform: 'none', }); } else { // DEFAULT CENTERING Object.assign(this.element.style, { position: 'fixed', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', margin: '0', }); } } } close() { if (this.element instanceof HTMLDialogElement && this.element.open) { this.element.close(); } } destroy() { if (!this.element) return; this.element.remove(); this.element = null; if (this.options.onDestroy) { this.options.onDestroy(); } } setContent(element) { this.dom.content.textContent = ''; this.dom.content.appendChild(element); } setHeader(element) { this.dom.headerContent.textContent = ''; this.dom.headerContent.appendChild(element); } getContentContainer() { return this.dom.content; } } /** * @abstract * @description Base class for a settings panel/submenu UI component. */ class SettingsPanelBase extends UIComponentBase { constructor(callbacks) { super(callbacks); this.debouncedSave = debounce(this._handleDebouncedSave.bind(this), 300, true); this._handleDocumentClick = this._handleDocumentClick.bind(this); this._handleDocumentKeydown = this._handleDocumentKeydown.bind(this); this._handleConfigUpdate = this._handleConfigUpdate.bind(this); } _onInit() { this._subscribe(EVENTS.CONFIG_UPDATED, this._handleConfigUpdate); } /** * @override * @protected */ _onDestroy() { this.hide(); this.debouncedSave.cancel(); super._onDestroy(); } /** * @private * Collects data and calls the onSave callback. Designed to be debounced. */ async _handleDebouncedSave() { const newConfig = await this._collectDataFromForm(); try { await this.callbacks.onSave?.(newConfig); } catch (e) { // Log the error to console. User notification is handled via EventBus (CONFIG_SIZE_EXCEEDED). Logger.error('SAVE FAILED', LOG_STYLES.RED, 'SettingsPanel save failed:', e); } } /** * @private * Handles the CONFIG_UPDATED event to refresh the form if the panel is open. * @param {object} newConfig The updated configuration object from the event payload. */ async _handleConfigUpdate(newConfig) { if (this.isOpen()) { Logger.debug('UI REFRESH', LOG_STYLES.CYAN, 'Settings panel is open, refreshing form due to config update.'); // Pass the received config directly to populateForm await this.populateForm(newConfig); } } render() { this._injectStyles(); this.element = this._createPanelContainer(); const content = this._createPanelContent(); this.element.appendChild(content); document.body.appendChild(this.element); this._setupEventListeners(); return this.element; } toggle() { const shouldShow = this.element.style.display === 'none'; if (shouldShow) { this.show(); } else { this.hide(); } } isOpen() { return this.element && this.element.style.display !== 'none'; } async show() { if (this.isOpen()) return; // Prevent re-entry await this.populateForm(); const anchorRect = this.callbacks.getAnchorElement().getBoundingClientRect(); let top = anchorRect.bottom + 4; let left = anchorRect.left; this.element.style.display = 'block'; const panelWidth = this.element.offsetWidth; const panelHeight = this.element.offsetHeight; if (left + panelWidth > window.innerWidth - 8) { left = window.innerWidth - panelWidth - 8; } if (top + panelHeight > window.innerHeight - 8) { top = window.innerHeight - panelHeight - 8; } this.element.style.left = `${Math.max(8, left)}px`; this.element.style.top = `${Math.max(8, top)}px`; document.addEventListener('click', this._handleDocumentClick, true); document.addEventListener('keydown', this._handleDocumentKeydown, true); } hide() { this.element.style.display = 'none'; document.removeEventListener('click', this._handleDocumentClick, true); document.removeEventListener('keydown', this._handleDocumentKeydown, true); } _createPanelContainer() { return h(`div#${APPID}-settings-panel`, { style: { display: 'none' }, role: 'menu' }); } _handleDocumentClick(e) { const anchor = this.callbacks.getAnchorElement(); if (this.element && !this.element.contains(e.target) && anchor && !anchor.contains(e.target)) { this.hide(); } } _handleDocumentKeydown(e) { if (e.key === 'Escape') { this.hide(); } } // --- Abstract methods to be implemented by subclasses --- _createPanelContent() { throw new Error('Subclass must implement _createPanelContent()'); } _injectStyles() { throw new Error('Subclass must implement _injectStyles()'); } populateForm(config) { throw new Error('Subclass must implement populateForm()'); } _collectDataFromForm() { throw new Error('Subclass must implement _collectDataFromForm()'); } _setupEventListeners() { throw new Error('Subclass must implement _setupEventListeners()'); } } class SettingsPanelComponent extends SettingsPanelBase { constructor(callbacks) { super(callbacks); this.engine = null; this.formContainer = null; this.store = new ReactiveStore(DEFAULT_CONFIG); this.isPopulating = false; this.styleHandle = null; this.commonStyleHandle = null; this.warningState = null; // Delegate subscription management to the base class this.addStoreSubscription( this.store.subscribe((state, changedPath) => { // Prevent infinite loop: do not trigger save if the change is internal (system state) if (changedPath && changedPath.startsWith('system')) return; if (!this.isPopulating) { this.debouncedSave(); } }) ); } _onInit() { super._onInit(); this._subscribe(EVENTS.CONFIG_SIZE_EXCEEDED, ({ message }) => { this.warningState = { text: message }; if (this.store) { this.store.set('system.warning', this.warningState); } }); this._subscribe(EVENTS.CONFIG_SAVE_SUCCESS, () => { this.warningState = null; if (this.store) { this.store.set('system.warning', null); } }); } /** * @override * @protected */ _onDestroy() { this.engine?.destroy(); this.engine = null; super._onDestroy(); } _injectStyles() { this.styleHandle = StyleManager.request(StyleDefinitions.getSettingsPanel); this.commonStyleHandle = StyleManager.request(StyleDefinitions.getCommon); } _createPanelContainer() { const cls = this.styleHandle.classes; return h(`div#${cls.panel}`, { style: { display: 'none' }, role: 'menu' }); } _getPanelSchema() { // Note: Schema uses logical class names mapped in StyleDefinitions return [ SchemaBuilder.TextDisplay('system.warning', { className: 'warningBanner', visibleIf: (data) => !!data?.system?.warning, }), SchemaBuilder.Group( 'Default Profile', [ SchemaBuilder.Select('options.activeProfileName', null, (data) => (data?.texts || []).map((p) => p.name), { id: `${APPID}-profile-select`, title: 'Select the default profile loaded on startup.', }), ], { title: 'Select the default profile loaded on startup.' } ), SchemaBuilder.Container( [ SchemaBuilder.Group( 'Texts', [ SchemaBuilder.Button( `${APPID}-submenu-edit-texts-btn`, 'Edit Texts...', () => { this.callbacks.onShowTextEditorModal?.(); this.hide(); }, { fullWidth: true, title: 'Open the text editor.' } ), ], { title: 'Open the text editor.' } ), SchemaBuilder.Group( 'JSON', [ SchemaBuilder.Button( `${APPID}-submenu-json-btn`, 'JSON...', () => { this.callbacks.onShowJsonModal?.(); this.hide(); }, { fullWidth: true, title: 'Import, export, or edit settings via JSON.', } ), ], { title: 'Import, export, or edit settings via JSON.' } ), ], { className: 'topRow' } // Mapped to cls.topRow ), SchemaBuilder.Group( 'Options', [ SchemaBuilder.Row([ SchemaBuilder.Label('Trigger Mode', { for: `${APPID}-opt-trigger-mode`, title: 'Choose how to open the text list.' }), SchemaBuilder.Select( 'options.trigger_mode', null, [ { value: 'click', label: 'Click' }, { value: 'hover', label: 'Hover' }, ], { id: `${APPID}-opt-trigger-mode`, title: '"Click" opens on click, "Hover" opens on mouse over.', } ), ]), SchemaBuilder.Row([ SchemaBuilder.Label('Enable Shortcut to Trigger (Alt+Q)', { for: `${APPID}-opt-enable-shortcut`, title: 'Enables the keyboard shortcut (Alt+Q) to toggle the Text List.', }), SchemaBuilder.Toggle('options.enable_shortcut', null, { id: `${APPID}-opt-enable-shortcut`, title: 'Enables the keyboard shortcut (Alt+Q) to toggle the Text List.', }), ]), SchemaBuilder.Container( [ SchemaBuilder.Button( `${APPID}-submenu-shortcut-btn`, 'Shortcuts & Controls', () => { this.callbacks.onShowShortcutModal?.(); this.hide(); }, { title: 'Show the keyboard shortcuts cheat sheet.' } ), ], { className: 'submenuRow' } ), SchemaBuilder.Separator(), SchemaBuilder.Row([ SchemaBuilder.Label('Insertion position', { for: `${APPID}-opt-insertion-position`, title: 'Determines where the text is inserted in the input field.', }), SchemaBuilder.Select( 'options.insertion_position', null, [ { value: 'start', label: 'Start' }, { value: 'cursor', label: 'Cursor' }, { value: 'end', label: 'End' }, ], { id: `${APPID}-opt-insertion-position`, title: 'Determines where the text is inserted in the input field.', } ), ]), SchemaBuilder.Row([ SchemaBuilder.Label('Insert newline before text', { for: `${APPID}-opt-insert-before-newline`, title: 'Automatically add a newline before the pasted text.', }), SchemaBuilder.Toggle('options.insert_before_newline', null, { id: `${APPID}-opt-insert-before-newline`, title: 'Adds a newline character before the inserted text.', }), ]), SchemaBuilder.Row([ SchemaBuilder.Label('Insert newline after text', { for: `${APPID}-opt-insert-after-newline`, title: 'Automatically add a newline after the pasted text.', }), SchemaBuilder.Toggle('options.insert_after_newline', null, { id: `${APPID}-opt-insert-after-newline`, title: 'Adds a newline character after the inserted text.', }), ]), SchemaBuilder.Container( [SchemaBuilder.Label("Note: Option behavior may depend on the input field's state (focus and existing content). For consistent results, click an insert button while the input field is focused.")], { className: 'settingsNote', // Mapped to cls.settingsNote } ), ], { title: 'Configure general options and behavior.' } ), ]; } _createPanelContent() { this.formContainer = h('div'); return this.formContainer; } /** * Populates the settings form with configuration data. * Uses the provided config object if available, otherwise fetches the current config. * @param {object} [config] - Optional. The configuration object to use for population. */ async populateForm(config) { const currentConfig = config || (await this.callbacks.getCurrentConfig()); if (!currentConfig || !currentConfig.options) return; this.isPopulating = true; // Merge current config with warning state if present const storeData = deepClone(currentConfig); if (this.warningState) { storeData.system = { warning: this.warningState }; } else { storeData.system = { warning: null }; } // If the engine already exists, update the state instead of rebuilding the DOM. if (this.engine) { this.store.replaceState(storeData); this.isPopulating = false; return; } if (this.formContainer) { this.formContainer.textContent = ''; } this.store.replaceState(storeData); const schema = this._getPanelSchema(); // Merge styles: Common classes take precedence or serve as base, SettingsPanel classes add specifics const cls = { ...this.commonStyleHandle.classes, ...this.styleHandle.classes }; const context = { styles: cls, store: this.store }; this.engine = new FormEngine(this.store, schema, context); if (this.formContainer) { this.formContainer.appendChild(this.engine.render()); } this.isPopulating = false; } async _collectDataFromForm() { return this.store.getData(); } _setupEventListeners() { // Handled by FormEngine and schema onClick handlers. } } /** * Manages the Text Settings modal by leveraging the CustomModal component. */ class TextEditorModalComponent extends UIComponentBase { constructor(callbacks) { super(callbacks); this.modal = null; this.store = null; this.headerEngine = null; this.contentEngine = null; this.draggedIndex = null; this.cachedConfig = null; // Config cache for Store synchronization this.styleHandle = null; this.commonStyleHandle = null; } _onInit() { // Subscribe to size exceeded event to show error in footer this._subscribe(EVENTS.CONFIG_SIZE_EXCEEDED, ({ message }) => { const footerMessage = this.modal?.dom?.footerMessage; if (footerMessage) { footerMessage.textContent = message; footerMessage.style.color = `var(${StyleDefinitions.VARS.TEXT_DANGER})`; } }); // Subscribe to save success event to clear footer this._subscribe(EVENTS.CONFIG_SAVE_SUCCESS, () => { const footerMessage = this.modal?.dom?.footerMessage; if (footerMessage) { footerMessage.textContent = ''; footerMessage.style.color = ''; } }); } _checkConfigSize() { if (!this.cachedConfig || !this.modal?.dom?.footerMessage) return; const validation = this.callbacks.checkConfigSize(this.cachedConfig); if (!validation.isValid) { const footerMessage = this.modal.dom.footerMessage; footerMessage.textContent = validation.message; footerMessage.style.color = `var(${StyleDefinitions.VARS.TEXT_DANGER})`; } } /** * @override * @protected */ _onDestroy() { this.modal?.destroy(); super._onDestroy(); } render() { this.styleHandle = StyleManager.request(StyleDefinitions.getTextEditorModal); this.commonStyleHandle = StyleManager.request(StyleDefinitions.getCommon); } async open(targetProfile, targetCategoryKey) { if (this.modal || this.isOpening) return; // Prevent re-entry // Ensure styles are ready if (!this.styleHandle) this.render(); this.isOpening = true; try { const cls = this.styleHandle.classes; const commonCls = this.commonStyleHandle.classes; // Load and cache initial config const globalConfig = await this.callbacks.getCurrentConfig(); this.cachedConfig = deepClone(globalConfig); if (!this.cachedConfig || !this.cachedConfig.texts) return; // Determine initial display position const profiles = this.cachedConfig.texts; const initialProfileName = targetProfile || this.cachedConfig.options.activeProfileName || profiles[0]?.name; const validProfileObj = profiles.find((p) => p.name === initialProfileName) || profiles[0]; const validProfile = validProfileObj?.name; const categories = validProfileObj?.categories || []; let initialCategory = categories[0]?.name || null; if (targetCategoryKey && categories.some((c) => c.name === targetCategoryKey)) { initialCategory = targetCategoryKey; } this.callbacks.onModalOpenStateChange?.(true); // Initialize Store this.store = new ReactiveStore({ editor: { activeProfile: validProfile, activeCategory: initialCategory, mode: 'normal', // 'normal' | 'rename' | 'delete_confirm' targetType: null, // 'profile' | 'category' targetKey: null, currentTexts: [], // Initial value will be loaded later listFingerprint: 0, focusedIndex: -1, }, }); this.modal = new CustomModal({ title: `${APPNAME} - Settings`, width: 'min(880px, 95vw)', closeOnBackdropClick: false, buttons: [ { text: 'Cancel', id: `${APPID}-editor-modal-cancel-btn`, className: '', title: 'Discard changes and close the modal.', onClick: () => this.close() }, { text: 'Apply', id: `${APPID}-editor-modal-apply-btn`, className: '', title: 'Save changes and keep the modal open.', onClick: () => this._handleSaveAction(false) }, { text: 'Save', id: `${APPID}-editor-modal-save-btn`, className: commonCls.primaryBtn, title: 'Save changes and close the modal.', onClick: () => this._handleSaveAction(true) }, ], onDestroy: () => { this.callbacks.onModalOpenStateChange?.(false); this.headerEngine?.destroy(); this.contentEngine?.destroy(); this.headerEngine = null; this.contentEngine = null; this.store = null; this.modal = null; this.cachedConfig = null; }, }); // Context for FormEngine, providing styles and store reference const context = { textEditor: this, store: this.store, styles: { ...commonCls, ...cls }, // Merge styles for usage in child components }; // Header engine setup this.headerEngine = new FormEngine(this.store, [SchemaBuilder.create('text-editor-header', `${APPID}-text-editor-header`, { configKey: 'editor' })], context); // Content engine setup (Use text-list) this.contentEngine = new FormEngine( this.store, [ SchemaBuilder.Container([SchemaBuilder.create('text-list', `${APPID}-text-list`, { configKey: 'editor' }), SchemaBuilder.Button(`${APPID}-text-new-btn`, 'Add New Text', null, { className: commonCls.modalButton })], { className: cls.modalContent, }), ], context ); const headerControls = this.headerEngine.render(); const mainContent = this.contentEngine.render(); Object.assign(this.modal.dom.header.style, { paddingBottom: '12px', display: 'flex', flexDirection: 'column', alignItems: 'stretch', gap: '12px', }); Object.assign(this.modal.dom.footer.style, { paddingTop: '16px', }); this.modal.setHeader(headerControls); this.modal.setContent(mainContent); this._setupEventListeners(); // Load initial state this._loadStateFromConfig(validProfile, initialCategory); // Actively check config size and display warning if already exceeded this._checkConfigSize(); this.modal.show(); } finally { this.isOpening = false; } } close() { this.modal?.close(); } _createHeaderControls() { const cls = this.styleHandle.classes; const commonCls = this.commonStyleHandle.classes; const icons = StyleDefinitions.ICONS; const createControlRow = (type, label) => { return h(`div.${cls.headerRow}`, { 'data-type': type }, [ // Container 1: Label h('label', { htmlFor: `${APPID}-${type}-select` }, label), // Container 2: Select / Input h(`div.${cls.renameArea}`, [h(`select#${APPID}-${type}-select`), h('input', { type: 'text', id: `${APPID}-${type}-rename-input`, style: { display: 'none' } })]), // Container 3: Action Buttons h(`div.${cls.actionArea}`, [ h(`div.${cls.mainActions}`, [ h(`button#${APPID}-${type}-rename-btn.${commonCls.modalButton}`, 'Rename'), h(`button#${APPID}-${type}-up-btn.${commonCls.modalButton}.${cls.moveBtn}`, [createIconFromDef(icons.up)]), h(`button#${APPID}-${type}-down-btn.${commonCls.modalButton}.${cls.moveBtn}`, [createIconFromDef(icons.down)]), h(`button#${APPID}-${type}-new-btn.${commonCls.modalButton}`, 'New'), h(`button#${APPID}-${type}-copy-btn.${commonCls.modalButton}`, 'Copy'), h(`button#${APPID}-${type}-delete-btn.${commonCls.modalButton}`, 'Delete'), ]), h(`div.${cls.renameActions}`, { style: { display: 'none' } }, [ // visibility: hidden replaced by display:none/flex in CSS, setting default here h(`button#${APPID}-${type}-rename-ok-btn.${commonCls.modalButton}`, 'OK'), h(`button#${APPID}-${type}-rename-cancel-btn.${commonCls.modalButton}`, 'Cancel'), ]), h(`div.${cls.deleteConfirmGroup}`, { style: { display: 'none' } }, [ h(`span.${cls.deleteConfirmLabel}`, 'Are you sure?'), h(`div`, { style: { display: 'flex', gap: '8px' } }, [ h(`button#${APPID}-${type}-delete-confirm-btn.${commonCls.modalButton}.${cls.deleteConfirmBtnYes}`, 'Confirm Delete'), h(`button#${APPID}-${type}-delete-cancel-btn.${commonCls.modalButton}`, 'Cancel'), ]), ]), ]), ]); }; return h(`div.${cls.headerControls}`, [createControlRow('profile', 'Profile:'), createControlRow('category', 'Category:')]); } /** * @private * @param {'profile' | 'category'} itemType The type of item to enter delete confirmation mode for. */ _enterDeleteConfirmationMode(itemType) { if (this.store.get('editor.mode') !== 'normal') return; const keyToDelete = itemType === 'profile' ? this.store.get('editor.activeProfile') : this.store.get('editor.activeCategory'); if (!keyToDelete) return; this.store.set('editor.mode', 'delete_confirm'); this.store.set('editor.targetType', itemType); this.store.set('editor.targetKey', keyToDelete); } _exitDeleteConfirmationMode() { this.store.set('editor.mode', 'normal'); this.store.set('editor.targetType', null); this.store.set('editor.targetKey', null); } _setupEventListeners() { if (!this.modal) return; const modalElement = this.modal.element; const cls = this.styleHandle.classes; modalElement.addEventListener('click', (e) => { const target = e.target.closest('button'); if (!target) return; const textItemControls = target.closest(`.${cls.itemControls}`); if (textItemControls) { const textItem = textItemControls.closest(`.${cls.textItem}`); const textarea = textItem.querySelector('textarea'); const index = parseInt(textarea.dataset.index, 10); if (target.classList.contains('move-up-btn')) this._handleTextMove(index, -1); if (target.classList.contains('move-down-btn')) this._handleTextMove(index, 1); if (target.classList.contains('delete-btn')) this._handleTextDelete(index); return; } const actionMap = { // Profile Actions [`${APPID}-profile-new-btn`]: () => this._handleItemNew('profile'), [`${APPID}-profile-copy-btn`]: () => this._handleItemCopy('profile'), [`${APPID}-profile-delete-btn`]: () => this._enterDeleteConfirmationMode('profile'), [`${APPID}-profile-delete-confirm-btn`]: () => this._handleItemDelete(), [`${APPID}-profile-delete-cancel-btn`]: () => this._exitDeleteConfirmationMode(), [`${APPID}-profile-up-btn`]: () => this._handleItemMove('profile', -1), [`${APPID}-profile-down-btn`]: () => this._handleItemMove('profile', 1), [`${APPID}-profile-rename-btn`]: () => this._enterRenameMode('profile'), [`${APPID}-profile-rename-ok-btn`]: () => this._handleRenameConfirm('profile'), [`${APPID}-profile-rename-cancel-btn`]: () => this._exitRenameMode(true), // Category Actions [`${APPID}-category-new-btn`]: () => this._handleItemNew('category'), [`${APPID}-category-copy-btn`]: () => this._handleItemCopy('category'), [`${APPID}-category-delete-btn`]: () => this._enterDeleteConfirmationMode('category'), [`${APPID}-category-delete-confirm-btn`]: () => this._handleItemDelete(), [`${APPID}-category-delete-cancel-btn`]: () => this._exitDeleteConfirmationMode(), [`${APPID}-category-up-btn`]: () => this._handleItemMove('category', -1), [`${APPID}-category-down-btn`]: () => this._handleItemMove('category', 1), [`${APPID}-category-rename-btn`]: () => this._enterRenameMode('category'), [`${APPID}-category-rename-ok-btn`]: () => this._handleRenameConfirm('category'), [`${APPID}-category-rename-cancel-btn`]: () => this._exitRenameMode(true), // Text Actions [`${APPID}-text-new-btn`]: () => this._handleTextNew(), }; const action = actionMap[target.id]; if (action) action(); }); // Reflect values directly to Store (With Two-Step Commit) modalElement.addEventListener('change', async (e) => { if (e.target.matches(`#${APPID}-profile-select`)) { // 1. Prepare new state const newProfileName = e.target.value; const profiles = this.cachedConfig.texts || []; const activeProfileObj = profiles.find((p) => p.name === newProfileName); const newCategory = activeProfileObj?.categories[0]?.name || null; // 2. Update Store & Load new data this.store.set('editor.activeProfile', newProfileName); this.store.set('editor.activeCategory', newCategory); this._loadStateFromConfig(newProfileName, newCategory); } else if (e.target.matches(`#${APPID}-category-select`)) { // 1. Update Store & Load new data const newCategory = e.target.value; this.store.set('editor.activeCategory', newCategory); this._loadStateFromConfig(this.store.get('editor.activeProfile'), newCategory); } }); // Input handling for validation visual cues modalElement.addEventListener('input', (e) => { const target = e.target; if (target.matches('textarea')) { target.classList.toggle('is-invalid', target.value.trim() === ''); const footerMessage = this.modal?.dom?.footerMessage; if (footerMessage && footerMessage.textContent) { footerMessage.textContent = ''; } } if (target.matches('input[type="text"]')) { target.classList.remove('is-invalid'); const footerMessage = this.modal?.dom?.footerMessage; if (footerMessage && footerMessage.textContent) { footerMessage.textContent = ''; } } }); modalElement.addEventListener('keydown', (e) => { if (e.target.matches('input[type="text"]')) { if (e.key === 'Enter') { e.preventDefault(); const type = e.target.id.includes('profile') ? 'profile' : 'category'; this._handleRenameConfirm(type); } if (e.key === 'Escape') { e.preventDefault(); this._exitRenameMode(true); } } }); // Drag and Drop // Use the dynamic class for scrollable area const getScrollArea = () => modalElement.querySelector(`.${cls.scrollableArea}`); modalElement.addEventListener('dragstart', (e) => { const scrollArea = getScrollArea(); if (!scrollArea) return; const handle = e.target.closest(`.${cls.dragHandle}`); if (handle) { const target = handle.closest(`.${cls.textItem}`); if (target && scrollArea.contains(target)) { this.draggedIndex = parseInt(target.dataset.index, 10); setTimeout(() => target.classList.add('dragging'), 0); } } }); modalElement.addEventListener('dragend', () => { const scrollArea = getScrollArea(); if (!scrollArea || this.draggedIndex === null) return; const draggingElement = scrollArea.querySelector('.dragging'); if (draggingElement) draggingElement.classList.remove('dragging'); this.draggedIndex = null; }); modalElement.addEventListener('dragover', (e) => { const scrollArea = getScrollArea(); if (!scrollArea || !scrollArea.contains(e.target)) return; e.preventDefault(); const draggingElement = scrollArea.querySelector('.dragging'); if (!draggingElement) return; scrollArea.querySelectorAll(`.${cls.textItem}`).forEach((el) => { el.classList.remove('drag-over-top', 'drag-over-bottom'); }); const target = e.target.closest(`.${cls.textItem}`); if (target && target !== draggingElement) { const box = target.getBoundingClientRect(); const isAfter = e.clientY > box.top + box.height / 2; target.classList.toggle('drag-over-bottom', isAfter); target.classList.toggle('drag-over-top', !isAfter); } else if (!target) { const lastElement = scrollArea.querySelector(`.${cls.textItem}:last-child:not(.dragging)`); if (lastElement) { lastElement.classList.add('drag-over-bottom'); } } }); modalElement.addEventListener('drop', (e) => { const scrollArea = getScrollArea(); if (!scrollArea || !scrollArea.contains(e.target)) return; e.preventDefault(); if (this.draggedIndex === null) return; const dropTarget = scrollArea.querySelector('.drag-over-top, .drag-over-bottom'); // Calculate drop index let dropIndex = -1; if (dropTarget) { const targetIndex = parseInt(dropTarget.dataset.index, 10); if (dropTarget.classList.contains('drag-over-bottom')) { dropIndex = targetIndex + 1; } else { dropIndex = targetIndex; } // Adjust dropIndex if moving downwards if (this.draggedIndex < dropIndex) { dropIndex--; } } else { // Check if dropped at the end const lastElement = scrollArea.querySelector(`.${cls.textItem}:last-child:not(.dragging)`); if (lastElement && lastElement.classList.contains('drag-over-bottom')) { dropIndex = this.store.get('editor.currentTexts').length - 1; } } // Cleanup visual cues scrollArea.querySelectorAll(`.${cls.textItem}`).forEach((el) => { el.classList.remove('drag-over-top', 'drag-over-bottom'); }); // Perform move if valid if (dropIndex !== -1 && dropIndex !== this.draggedIndex) { this._handleTextMove(this.draggedIndex, dropIndex - this.draggedIndex); } }); } /** * Commit Store's currentTexts to cachedConfig (Array-based). * Must be called before Profile/Category switch or Save. */ _saveCurrentStateToConfig() { const activeProfile = this.store.get('editor.activeProfile'); const activeCategory = this.store.get('editor.activeCategory'); const currentTexts = this.store.get('editor.currentTexts'); const profileObj = this.cachedConfig.texts.find((p) => p.name === activeProfile); if (profileObj) { const categoryObj = profileObj.categories.find((c) => c.name === activeCategory); if (categoryObj) { categoryObj.items = [...currentTexts]; } } } /** * Load data from Config cache to Store (Array-based). * Call on initialization or Profile/Category switch. */ _loadStateFromConfig(profileName, categoryName) { const profileObj = this.cachedConfig.texts.find((p) => p.name === profileName); const categoryObj = profileObj ? profileObj.categories.find((c) => c.name === categoryName) : null; const texts = categoryObj ? categoryObj.items : []; this.store.set('editor.currentTexts', [...texts]); // Deep copy this.store.set('editor.listFingerprint', Date.now()); // Force render this.store.set('editor.focusedIndex', -1); } /** * Handler for text pixel input (No re-render) */ _handleTextPixelInput(index, value) { const currentTexts = this.store.get('editor.currentTexts'); if (currentTexts && currentTexts[index] !== undefined) { currentTexts[index] = value; // Update Store, but Fingerprint check in onUpdate prevents DOM rebuild this.store.set('editor.currentTexts', currentTexts); } } async _exitRenameMode(refresh = false) { const currentMode = this.store.get('editor.mode'); if (currentMode !== 'rename') return; const type = this.store.get('editor.targetType'); this.store.set('editor.mode', 'normal'); this.store.set('editor.targetType', null); if (this.modal) { const input = this.modal.element.querySelector(`#${APPID}-${type}-rename-input`); if (input) input.classList.remove('is-invalid'); const footerMessage = this.modal.dom.footerMessage; if (footerMessage) footerMessage.textContent = ''; } // When forced update is required (e.g., on cancel, to restore original value) if (refresh) { this.store.set('editor.lastUpdated', Date.now()); } } _getDragAfterElement(container, y) { const cls = this.styleHandle.classes; const draggableElements = [...container.querySelectorAll(`.${cls.textItem}:not(.dragging)`)]; return draggableElements.reduce( (closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY } ).element; } _autoResizeTextarea(textarea) { if (!textarea) return; // Temporarily set height to auto to allow the textarea to shrink if text is deleted. // Use requestAnimationFrame to prevent layout thrashing during rapid typing. requestAnimationFrame(() => { textarea.style.height = 'auto'; // Set the height to its scrollHeight to fit the content. textarea.style.height = `${textarea.scrollHeight}px`; }); } _handleTextNew() { // 1. Modify data const currentTexts = [...this.store.get('editor.currentTexts')]; const focusedIndex = this.store.get('editor.focusedIndex'); let insertIndex; // Determine insertion position: after the focused item, or at the end if (focusedIndex >= 0 && focusedIndex < currentTexts.length) { insertIndex = focusedIndex + 1; } else { insertIndex = currentTexts.length; } currentTexts.splice(insertIndex, 0, ''); // Insert empty text // 2. Update Store to trigger render this.store.set('editor.currentTexts', currentTexts); this.store.set('editor.listFingerprint', Date.now()); this.store.set('editor.focusedIndex', insertIndex); // Focus new item } _handleTextDelete(index) { // 1. Modify data const currentTexts = this.store.get('editor.currentTexts'); const newTexts = currentTexts.filter((_, i) => i !== index); // 2. Update Store to trigger render this.store.set('editor.currentTexts', newTexts); this.store.set('editor.listFingerprint', Date.now()); this.store.set('editor.focusedIndex', -1); } _handleTextMove(index, direction) { // 1. Modify data const currentTexts = [...this.store.get('editor.currentTexts')]; const newIndex = index + direction; if (newIndex < 0 || newIndex >= currentTexts.length) return; // Remove from old position const [movedItem] = currentTexts.splice(index, 1); // Insert at new position currentTexts.splice(newIndex, 0, movedItem); // 2. Update Store to trigger render this.store.set('editor.currentTexts', currentTexts); this.store.set('editor.listFingerprint', Date.now()); this.store.set('editor.focusedIndex', newIndex); // Follow focus } /** * @private * @param {'profile' | 'category'} itemType The type of item to create. */ async _handleItemNew(itemType) { const newConfig = deepClone(this.cachedConfig); const activeProfileName = this.store.get('editor.activeProfile'); const activeCategoryName = this.store.get('editor.activeCategory'); if (itemType === 'profile') { const profiles = newConfig.texts; const activeIndex = profiles.findIndex((p) => p.name === activeProfileName); // Insert after current or at end const insertIndex = activeIndex >= 0 ? activeIndex + 1 : profiles.length; const newName = proposeUniqueName('New Profile', profiles); const newProfile = { name: newName, categories: [{ name: 'New Category', items: [] }], }; profiles.splice(insertIndex, 0, newProfile); await this.callbacks.onSave(newConfig); this.cachedConfig = newConfig; this.store.set('editor.activeProfile', newName); this.store.set('editor.activeCategory', 'New Category'); } else { const profile = newConfig.texts.find((p) => p.name === activeProfileName); if (!profile) return; const categories = profile.categories; const activeIndex = categories.findIndex((c) => c.name === activeCategoryName); const insertIndex = activeIndex >= 0 ? activeIndex + 1 : categories.length; const newName = proposeUniqueName('New Category', categories); const newCategory = { name: newName, items: [], }; categories.splice(insertIndex, 0, newCategory); // Update config (since profile is a reference to object inside newConfig, it's updated) await this.callbacks.onSave(newConfig); this.cachedConfig = newConfig; this.store.set('editor.activeCategory', newName); } // Reload texts this._loadStateFromConfig(this.store.get('editor.activeProfile'), this.store.get('editor.activeCategory')); // Enter rename mode this._enterRenameMode(itemType); } /** * @private * @param {'profile' | 'category'} itemType The type of item to copy. */ async _handleItemCopy(itemType) { const newConfig = deepClone(this.cachedConfig); const activeProfileName = this.store.get('editor.activeProfile'); const activeCategoryName = this.store.get('editor.activeCategory'); if (itemType === 'profile') { const profiles = newConfig.texts; const activeIndex = profiles.findIndex((p) => p.name === activeProfileName); if (activeIndex === -1) return; const sourceProfile = profiles[activeIndex]; const newName = proposeUniqueName(`${sourceProfile.name} Copy`, profiles); const newProfile = deepClone(sourceProfile); newProfile.name = newName; profiles.splice(activeIndex + 1, 0, newProfile); await this.callbacks.onSave(newConfig); this.cachedConfig = newConfig; this.store.set('editor.activeProfile', newName); this.store.set('editor.activeCategory', newProfile.categories[0]?.name || null); } else { const profile = newConfig.texts.find((p) => p.name === activeProfileName); if (!profile) return; const categories = profile.categories; const activeIndex = categories.findIndex((c) => c.name === activeCategoryName); if (activeIndex === -1) return; const sourceCategory = categories[activeIndex]; const newName = proposeUniqueName(`${sourceCategory.name} Copy`, categories); const newCategory = deepClone(sourceCategory); newCategory.name = newName; categories.splice(activeIndex + 1, 0, newCategory); await this.callbacks.onSave(newConfig); this.cachedConfig = newConfig; this.store.set('editor.activeCategory', newName); } // Reload texts this._loadStateFromConfig(this.store.get('editor.activeProfile'), this.store.get('editor.activeCategory')); } /** * @private */ async _handleItemDelete() { const itemType = this.store.get('editor.targetType'); const keyToDelete = this.store.get('editor.targetKey'); // name if (!keyToDelete) { this._exitDeleteConfirmationMode(); return; } const newConfig = deepClone(this.cachedConfig); if (itemType === 'profile') { const profiles = newConfig.texts; const indexToDelete = profiles.findIndex((p) => p.name === keyToDelete); if (indexToDelete !== -1) { profiles.splice(indexToDelete, 1); // Determine next profile to show const nextIndex = Math.min(indexToDelete, profiles.length - 1); const nextProfile = profiles[nextIndex] || profiles[0]; const nextViewKey = nextProfile?.name || null; if (newConfig.options.activeProfileName === keyToDelete) { newConfig.options.activeProfileName = nextViewKey; } await this.callbacks.onSave(newConfig); this.cachedConfig = newConfig; this.store.set('editor.activeProfile', nextViewKey); this.store.set('editor.activeCategory', nextProfile?.categories[0]?.name || null); } } else { const activeProfileName = this.store.get('editor.activeProfile'); const profile = newConfig.texts.find((p) => p.name === activeProfileName); if (profile) { const categories = profile.categories; const indexToDelete = categories.findIndex((c) => c.name === keyToDelete); if (indexToDelete !== -1) { categories.splice(indexToDelete, 1); // Determine next category const nextIndex = Math.min(indexToDelete, categories.length - 1); const nextCategory = categories[nextIndex] || categories[0]; const nextViewKey = nextCategory?.name || null; await this.callbacks.onSave(newConfig); this.cachedConfig = newConfig; this.store.set('editor.activeCategory', nextViewKey); } } } // Reload texts this._loadStateFromConfig(this.store.get('editor.activeProfile'), this.store.get('editor.activeCategory')); this._exitDeleteConfirmationMode(); } /** * @private * @param {'profile' | 'category'} itemType The type of item to move. * @param {number} direction The direction to move (-1 for up, 1 for down). */ async _handleItemMove(itemType, direction) { const newConfig = deepClone(this.cachedConfig); const activeProfileName = this.store.get('editor.activeProfile'); const activeCategoryName = this.store.get('editor.activeCategory'); let targetArray; let activeKey; if (itemType === 'profile') { targetArray = newConfig.texts; activeKey = activeProfileName; } else { const profile = newConfig.texts.find((p) => p.name === activeProfileName); if (!profile) return; targetArray = profile.categories; activeKey = activeCategoryName; } const currentIndex = targetArray.findIndex((item) => item.name === activeKey); if (currentIndex === -1) return; const newIndex = currentIndex + direction; if (newIndex < 0 || newIndex >= targetArray.length) return; // Move using splice (Arrays maintain order correctly) const [movedItem] = targetArray.splice(currentIndex, 1); targetArray.splice(newIndex, 0, movedItem); await this.callbacks.onSave(newConfig); this.cachedConfig = newConfig; // Value hasn't changed, but trigger forced update for reordering UI this.store.set('editor.lastUpdated', Date.now()); } async _handleSaveAction(shouldClose) { const footerMessage = this.modal?.dom?.footerMessage; if (footerMessage) { // Clear previous messages (unless it's a conflict warning) if (!footerMessage.classList.contains(this.commonStyleHandle.classes.conflictText)) { footerMessage.textContent = ''; footerMessage.style.color = ''; } } this.modal.element.querySelectorAll('.is-invalid').forEach((el) => el.classList.remove('is-invalid')); // 1. Validate const currentTexts = this.store.get('editor.currentTexts'); const cls = this.styleHandle.classes; if (currentTexts.some((t) => t.trim() === '')) { // Mark invalid elements manually since we synced to store const scrollArea = this.modal.element.querySelector(`.${cls.scrollableArea}`); if (scrollArea) { scrollArea.querySelectorAll('textarea').forEach((ta, i) => { if (currentTexts[i].trim() === '') ta.classList.add('is-invalid'); }); } if (footerMessage) { footerMessage.textContent = 'Text fields cannot be empty.'; footerMessage.style.color = `var(${StyleDefinitions.VARS.TEXT_DANGER})`; } return; } // 2. Commit to Config this._saveCurrentStateToConfig(); // 3. Save try { await this.callbacks.onSave(this.cachedConfig); this.cachedConfig = deepClone(this.cachedConfig); // Re-clone to be safe if (shouldClose) { this.close(); } } catch (e) { // Primary error handling is done via events (e.g. CONFIG_SIZE_EXCEEDED). // This catch block serves as a fallback for other unexpected errors. if (footerMessage) { footerMessage.textContent = e.message; footerMessage.style.color = `var(${StyleDefinitions.VARS.TEXT_DANGER})`; } } } _enterRenameMode(type) { const currentMode = this.store.get('editor.mode'); if (currentMode === 'rename') return; this.store.set('editor.mode', 'rename'); this.store.set('editor.targetType', type); // Set focus after UI update requestAnimationFrame(() => { if (this.modal) { const input = this.modal.element.querySelector(`#${APPID}-${type}-rename-input`); if (input) { input.focus(); input.select(); } } }); } async _handleRenameConfirm(type) { const footerMessage = this.modal?.dom?.footerMessage; const input = this.modal.element.querySelector(`#${APPID}-${type}-rename-input`); const newName = input.value.trim(); const oldName = type === 'profile' ? this.store.get('editor.activeProfile') : this.store.get('editor.activeCategory'); if (!newName) { if (footerMessage) { footerMessage.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)} name cannot be empty.`; footerMessage.style.color = `var(${StyleDefinitions.VARS.TEXT_DANGER})`; } input.classList.add('is-invalid'); return; } // Check for duplicates const config = this.cachedConfig; let itemsToCheck; if (type === 'profile') { itemsToCheck = config.texts; } else { const profile = config.texts.find((p) => p.name === this.store.get('editor.activeProfile')); itemsToCheck = profile ? profile.categories : []; } if (newName.toLowerCase() !== oldName.toLowerCase() && itemsToCheck.some((item) => item.name.toLowerCase() === newName.toLowerCase())) { if (footerMessage) { footerMessage.textContent = `Name "${newName}" is already in use.`; footerMessage.style.color = `var(${StyleDefinitions.VARS.TEXT_DANGER})`; } input.classList.add('is-invalid'); return; } // Notify UIManager about the rename BEFORE saving to maintain session consistency if (this.callbacks.onRename) { const activeProfile = this.store.get('editor.activeProfile'); this.callbacks.onRename(type, oldName, newName, activeProfile); } const newConfig = deepClone(config); if (type === 'profile') { const profile = newConfig.texts.find((p) => p.name === oldName); if (profile) { profile.name = newName; // Simply update the property } // Update activeProfileName option if needed if (config.options.activeProfileName === oldName) { newConfig.options.activeProfileName = newName; } await this.callbacks.onSave(newConfig); this.cachedConfig = newConfig; this.store.set('editor.activeProfile', newName); } else { const activeProfileName = this.store.get('editor.activeProfile'); const profile = newConfig.texts.find((p) => p.name === activeProfileName); if (profile) { const category = profile.categories.find((c) => c.name === oldName); if (category) { category.name = newName; // Simply update the property } } await this.callbacks.onSave(newConfig); this.cachedConfig = newConfig; this.store.set('editor.activeCategory', newName); } this._exitRenameMode(true); } getContextForReopen() { const activeCategory = this.store ? this.store.get('editor.activeCategory') : null; return { type: CONSTANTS.MODAL_TYPES.TEXT_EDITOR, key: activeCategory }; } } /** * Manages the JSON editing modal by using the CustomModal component. */ class JsonModalComponent extends UIComponentBase { constructor(callbacks) { super(callbacks); this.modal = null; // To hold the CustomModal instance this.store = null; this.engine = null; this.styleHandle = null; this.commonStyleHandle = null; this.debouncedUpdateSize = debounce((text) => this._updateSizeDisplay(text), 300, true); } render() { this.styleHandle = StyleManager.request(StyleDefinitions.getJsonModal); this.commonStyleHandle = StyleManager.request(StyleDefinitions.getCommon); } async open(anchorElement) { if (this.modal || this.isOpening) return; // Prevent re-entry this.callbacks.onModalOpenStateChange?.(true); // Ensure styles are ready if (!this.styleHandle) this.render(); this.isOpening = true; try { const cls = this.styleHandle.classes; const commonCls = this.commonStyleHandle.classes; this.modal = new CustomModal({ title: `${APPNAME} Settings`, width: 'min(440px, 95vw)', closeOnBackdropClick: true, buttons: [ { text: 'Export', id: `${APPID}-json-modal-export-btn`, className: '', onClick: () => this._handleExport() }, { text: 'Import', id: `${APPID}-json-modal-import-btn`, className: '', onClick: () => this._handleImport() }, { text: 'Cancel', id: `${APPID}-json-modal-cancel-btn`, className: commonCls.pushRightBtn, onClick: () => this.close() }, { text: 'Save', id: `${APPID}-json-modal-save-btn`, className: commonCls.primaryBtn, onClick: () => this._handleSave() }, ], onDestroy: () => { this.debouncedUpdateSize.cancel(); this.callbacks.onModalOpenStateChange?.(false); this.engine?.destroy(); this.engine = null; this.store = null; this.modal = null; }, }); // Apply scoped root class to prevent style leak this.modal.element.classList.add(cls.modalRoot); // Set content specific styles using mapped class names const contentContainer = this.modal.getContentContainer(); contentContainer.classList.add(cls.statusContainer); // Use container class for padding/layout if needed this.store = new ReactiveStore({ json: { editor: '', status: { text: '' }, sizeInfo: { text: 'Checking size...' }, }, }); const context = { styles: { ...commonCls, ...cls }, // Merge styles for FormEngine store: this.store, }; this.engine = new FormEngine(this.store, this._getSchema(), context); this.modal.setContent(this.engine.render()); this.callbacks.onModalOpen?.(); // Notify UIManager const config = await this.callbacks.getCurrentConfig(); const initialText = JSON.stringify(config, null, 2); this.store.set('json.editor', initialText); this._updateSizeDisplay(initialText); this.modal.show(anchorElement); // Defer focus and scroll adjustment until the modal is visible requestAnimationFrame(() => { const textarea = this.modal.getContentContainer().querySelector(`textarea`); if (!textarea) return; textarea.focus(); textarea.scrollTop = 0; textarea.selectionStart = 0; textarea.selectionEnd = 0; }); } finally { this.isOpening = false; } } close() { if (this.modal) { this.modal.close(); } } /** * @override * @protected */ _onDestroy() { this.debouncedUpdateSize.cancel(); this.engine?.destroy(); this.engine = null; this.store = null; this.modal?.destroy(); super._onDestroy(); } _getSchema() { // Note: Schema uses logical class names mapped in StyleDefinitions return [ SchemaBuilder.Container( [ SchemaBuilder.CodeEditor('json.editor', { onChange: (value) => this.debouncedUpdateSize(value || ''), }), SchemaBuilder.Row( [ SchemaBuilder.TextDisplay('json.status', { className: 'msg' }), // Mapped to cls.msg SchemaBuilder.TextDisplay('json.sizeInfo', { className: 'sizeInfo' }), // Mapped to cls.sizeInfo ], { className: 'statusRow', // Mapped to cls.statusRow } ), ], { className: 'content' } // Mapped to cls.content ), ]; } async _handleSave() { try { this._setStatus(''); const jsonText = this.store?.get('json.editor') || ''; const obj = JSON.parse(jsonText); await this.callbacks.onSave(obj); this.close(); } catch (e) { this._setStatus(e.message, `var(${StyleDefinitions.VARS.TEXT_DANGER})`); } } async _handleExport() { try { this._setStatus(''); const config = await this.callbacks.getCurrentConfig(); const jsonString = JSON.stringify(config, null, 2); const blob = new Blob([jsonString], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = h('a', { href: url, download: `${APPID}_config.json`, }); if (a instanceof HTMLElement) { a.click(); } // Revoke the URL after a delay to ensure the download has time to start. setTimeout(() => URL.revokeObjectURL(url), 10000); this._setStatus('Export successful.', `var(${StyleDefinitions.VARS.TEXT_ACCENT})`); } catch (e) { this._setStatus(`Export failed: ${e.message}`, `var(${StyleDefinitions.VARS.TEXT_DANGER})`); } } _handleImport() { const textarea = this.modal.getContentContainer().querySelector(`textarea`); if (!(textarea instanceof HTMLTextAreaElement)) return; const fileInput = h('input', { type: 'file', accept: 'application/json', onchange: (event) => { const target = event.target; if (!(target instanceof HTMLInputElement)) return; const file = target.files?.[0]; // Reset input value to allow re-importing the same file if needed target.value = ''; if (!file) return; const reader = new FileReader(); // Step 1: Update UI immediately upon load completion reader.onload = (e) => { this._setStatus('Processing...'); document.body.style.cursor = 'wait'; // Step 2: Defer heavy processing to allow UI to render requestAnimationFrame(() => { // Guard: Check if modal is still open before proceeding if (!this.modal || !textarea.isConnected) { document.body.style.cursor = ''; return; } try { const readerTarget = e.target; if (readerTarget && typeof readerTarget.result === 'string') { // Heavy operations const importedConfig = JSON.parse(readerTarget.result); const jsonString = JSON.stringify(importedConfig, null, 2); this.store?.set('json.editor', jsonString); this._updateSizeDisplay(jsonString); this._setStatus('Import successful. Click "Save" to apply.', `var(${StyleDefinitions.VARS.TEXT_ACCENT})`); } } catch (err) { this._setStatus(`Import failed: ${err.message}`, `var(${StyleDefinitions.VARS.TEXT_DANGER})`); } finally { document.body.style.cursor = ''; } }); }; reader.onerror = () => { this._setStatus('Failed to read file.', `var(${StyleDefinitions.VARS.TEXT_DANGER})`); }; // Initial status this._setStatus('Reading file...'); reader.readAsText(file); }, }); if (fileInput instanceof HTMLElement) { fileInput.click(); } } getContextForReopen() { return { type: 'json' }; } _updateSizeDisplay(text) { const container = this.modal?.getContentContainer(); if (!container) return; let sizeInBytes = 0; let isRaw = false; try { // Try to parse and minify to get the actual storage size const obj = JSON.parse(text); const minified = JSON.stringify(obj); sizeInBytes = new Blob([minified]).size; } catch { // Fallback to raw text size if parsing fails sizeInBytes = new Blob([text]).size; isRaw = true; } const sizeStr = this._formatBytes(sizeInBytes); const recommendedStr = this._formatBytes(CONSTANTS.CONFIG_SIZE_RECOMMENDED_LIMIT_BYTES); const limitStr = this._formatBytes(CONSTANTS.CONFIG_SIZE_LIMIT_BYTES); // Display format: 1.23 MB / 5.00 MB (Max: 10.00 MB) let color = ''; let bold = false; const v = StyleDefinitions.VARS; // Use colors directly or vars if (sizeInBytes >= CONSTANTS.CONFIG_SIZE_LIMIT_BYTES) { color = `var(${v.TEXT_DANGER})`; bold = true; } else if (sizeInBytes >= CONSTANTS.CONFIG_SIZE_RECOMMENDED_LIMIT_BYTES) { color = `var(${v.TEXT_WARNING})`; bold = true; } const sizeText = `${isRaw ? '(Raw) ' : ''}${sizeStr} / ${recommendedStr} (Max: ${limitStr})`; this.store?.set('json.sizeInfo', { text: sizeText, color, bold, title: `Recommended Limit: ${recommendedStr} (Warns if exceeded)\nHard Limit: ${limitStr} (Cannot save if exceeded)`, }); } _setStatus(text, color = '') { this.store?.set('json.status', { text, color, bold: false }); } _formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } } /** * Manages the Shortcut Cheat Sheet modal. */ class ShortcutModalComponent extends UIComponentBase { constructor(callbacks) { super(callbacks); this.modal = null; this.styleHandle = null; } render() { this.styleHandle = StyleManager.request(StyleDefinitions.getShortcutModal); } open(anchorElement) { if (this.modal) return; // Prevent re-entry // Ensure styles are ready if (!this.styleHandle) this.render(); const cls = this.styleHandle.classes; const commonCls = StyleManager.request(StyleDefinitions.getCommon).classes; this.modal = new CustomModal({ title: `${APPNAME} Keyboard Shortcuts`, width: 'min(400px, 95vw)', closeOnBackdropClick: true, buttons: [{ text: 'Close', id: `${APPID}-shortcut-close-btn`, className: commonCls.primaryBtn, onClick: () => this.close() }], onDestroy: () => { this.modal = null; }, }); // Helper to create grid rows const createRow = (keys, desc, separator = ' + ') => { const keyElements = keys .map((k, i) => { const els = [h(`span.${cls.key}`, k)]; if (i < keys.length - 1) els.push(h('span', separator)); return els; }) .flat(); return [h(`div.${cls.keyGroup}`, keyElements), h(`div.${cls.desc}`, desc)]; }; const createHeader = (text) => h(`div.${cls.sectionHeader}`, text); const content = h(`div.${cls.grid}`, [ createHeader('Global'), ...createRow(['Alt', 'Q'], 'Toggle Text List (If enabled)'), createHeader('Navigation'), ...createRow(['↑ / ↓'], 'Select Item'), ...createRow(['← / →'], 'Switch Category'), ...createRow(['Ctrl', '← / →'], 'Switch Profile'), ...createRow(['Ctrl', '↑ / ↓'], 'Toggle Profile List'), createHeader('Action'), ...createRow(['Enter', 'Space', 'Tab'], 'Select item', ' / '), ...createRow(['Esc'], 'Close Text List'), ]); this.modal.setContent(content); this.modal.show(); } close() { this.modal?.close(); } /** * @override * @protected */ _onDestroy() { this.close(); super._onDestroy(); } } class InsertButtonComponent extends UIComponentBase { constructor(callbacks, options) { super(callbacks); this.options = options; this.styleHandle = null; } /** * Updates the button's title (tooltip) dynamically. * @param {string} title The new title text. */ setTitle(title) { this.options.title = title; if (this.element) { this.element.title = title; } } /** * Renders the settings button element and appends it to the document body. * @returns {HTMLElement} The created button element. */ render() { // Use StyleManager this.styleHandle = StyleManager.request(StyleDefinitions.getInsertButton); const cls = this.styleHandle.classes; // Remove existing if any (safety check) const oldElement = document.getElementById(cls.buttonId); if (oldElement) oldElement.remove(); this.element = h('button', { type: 'button', id: cls.buttonId, title: this.options.title, // Prevent focus loss from the input area onmousedown: (e) => e.preventDefault(), // Restore event handlers directly on the element onclick: (e) => { e.stopPropagation(); this.callbacks.onClick?.(e); }, oncontextmenu: (e) => { e.preventDefault(); e.stopPropagation(); this.callbacks.onContextMenu?.(e); }, onmouseenter: (e) => this.callbacks.onMouseEnter?.(e), onmouseleave: (e) => this.callbacks.onMouseLeave?.(e), }); // Retrieve icon definition from unified definition const iconDef = StyleDefinitions.ICONS.insert; if (iconDef) { const svgElement = createIconFromDef(iconDef); if (svgElement) { this.element.appendChild(svgElement); } } return this.element; } } class TextListComponent extends UIComponentBase { constructor(callbacks, options) { super(callbacks); this.options = options; this.styleHandle = null; this.elements = { tabsContainer: null, optionsContainer: null, }; } /** * @override * @protected */ _onDestroy() { super._onDestroy(); } /** * @private * Retrieves the current list of option buttons. * @returns {HTMLElement[]} */ _getOptions() { if (!this.elements.optionsContainer) return []; // Use the class name from the style handle const cls = this.styleHandle.classes; // Convert to Array to satisfy return type and allow array methods return Array.from(this.elements.optionsContainer.querySelectorAll(`.${cls.option}`)); } /** * Focuses the option at the specified index. * @param {number} index */ focusOption(index) { const options = this._getOptions(); if (options.length === 0) return; // Clamp index let targetIndex = index; if (targetIndex < 0) targetIndex = 0; if (targetIndex >= options.length) targetIndex = options.length - 1; options[targetIndex].focus(); } /** * Focuses the next option in the list. Loops to start if at end. */ focusNext() { const options = this._getOptions(); if (options.length === 0) return; const current = document.activeElement; const currentIndex = current instanceof HTMLElement ? options.indexOf(current) : -1; // If focus is not on an option, start from -1 so next is 0 let nextIndex = currentIndex + 1; if (nextIndex >= options.length) nextIndex = 0; options[nextIndex].focus(); } /** * Focuses the previous option in the list. Loops to end if at start. */ focusPrev() { const options = this._getOptions(); if (options.length === 0) return; const current = document.activeElement; // If focus is not on an option, start from length (so prev is length-1) let currentIndex = current instanceof HTMLElement ? options.indexOf(current) : -1; if (currentIndex === -1) currentIndex = 0; let prevIndex = currentIndex - 1; if (prevIndex < 0) prevIndex = options.length - 1; options[prevIndex].focus(); } render() { this.styleHandle = StyleManager.request(StyleDefinitions.getTextList); const cls = this.styleHandle.classes; // Retrieve icon definitions from StyleDefinitions // Reuse "up" icon for navigation arrows by rotating via CSS classes const upIcon = StyleDefinitions.ICONS.up; // Create profile bar const profileBar = h(`div.${cls.profileBar}`, [ // Previous button (Left arrow: upIcon rotated -90 degrees) (this.elements.prevBtn = h( `button.${cls.navBtn}.${cls.rotateLeft}`, { title: 'Previous Profile', onclick: (e) => { e.stopPropagation(); // Stop propagation to prevent list from closing this.callbacks.onPrevProfile?.(); }, }, [createIconFromDef(upIcon)] )), // Next button (Right arrow: upIcon rotated 90 degrees) (this.elements.nextBtn = h( `button.${cls.navBtn}.${cls.rotateRight}`, { title: 'Next Profile', onclick: (e) => { e.stopPropagation(); // Stop propagation to prevent list from closing this.callbacks.onNextProfile?.(); }, }, [createIconFromDef(upIcon)] )), // Profile name display area (this.elements.profileName = h(`span.${cls.profileName}`, { title: 'Current Profile (Click to switch / Right-Click to edit)', onclick: (e) => { e.stopPropagation(); this.callbacks.onProfileNameClick?.(); }, oncontextmenu: (e) => { e.preventDefault(); e.stopPropagation(); this.callbacks.onProfileNameContextMenu?.(); }, })), ]); this.element = h( `div#${cls.list}`, { style: { display: 'none' }, onmouseenter: (e) => this.callbacks.onMouseEnter?.(e), onmouseleave: (e) => this.callbacks.onMouseLeave?.(e), onmousemove: (e) => this.callbacks.onMouseMove?.(e), }, [ profileBar, (this.elements.tabsContainer = h(`div.${cls.tabs}`)), (this.elements.separator = h(`div.${cls.separator}`)), // Store reference (this.elements.optionsContainer = h(`div.${cls.options}`)), ] ); document.body.appendChild(this.element); return this.element; } /** * Updates the profile name displayed in the header. * @param {string} name */ setProfileName(name) { if (this.elements.profileName) { this.elements.profileName.textContent = name; } } /** * Toggles the visibility of category tabs and separator. * @param {boolean} visible */ toggleTabs(visible) { const display = visible ? '' : 'none'; if (this.elements.tabsContainer) this.elements.tabsContainer.style.display = display; if (this.elements.separator) this.elements.separator.style.display = display; } } class UIManager extends BaseManager { /** * @param {object} config * @param {(config: object) => Promise} onSave * @param {object} platformDetails * @param {() => void} onModalClose * @param {(config: object) => {isValid: boolean, message: string|null}} checkConfigSizeCallback */ constructor(config, onSave, platformDetails, onModalClose, checkConfigSizeCallback) { super(); this.config = config; this.onSave = onSave; this.platformDetails = platformDetails; this.onModalClose = onModalClose; // Flag for requestAnimationFrame throttling this.isRepositioningScheduled = false; // State flag for input mode this.isKeyboardNavigating = false; // Initialize Session State (Array-based access) const profiles = this.config.texts || []; this.sessionActiveProfile = this.config.options.activeProfileName || profiles[0]?.name; const activeProfile = profiles.find((p) => p.name === this.sessionActiveProfile) || profiles[0]; // Update sessionActiveProfile in case the configured one was missing if (activeProfile) { this.sessionActiveProfile = activeProfile.name; } const categories = activeProfile ? activeProfile.categories : []; this.activeCategory = categories[0]?.name || null; this.hideTimeoutId = null; this.isModalOpen = false; this.isProfileListMode = false; this.lastFocusedIndex = 0; // Remember last position for UX this._handlers = { globalClick: this._handleGlobalClick.bind(this), globalKeydown: this._handleGlobalKeydown.bind(this), }; this.components = { settingsPanel: null, insertBtn: null, textList: null, textEditorModal: null, jsonModal: null, shortcutModal: null, }; const modalCallbacks = { onSave: (newConfig) => this.onSave(newConfig), getCurrentConfig: () => this.config, onModalOpenStateChange: (isOpen) => this.setModalState(isOpen), checkConfigSize: checkConfigSizeCallback, // Pass validator to modals // Handle renaming to maintain session consistency onRename: (type, oldName, newName, parentContext) => { if (type === 'profile') { if (this.sessionActiveProfile === oldName) { this.sessionActiveProfile = newName; } } else if (type === 'category') { if (this.sessionActiveProfile === parentContext && this.activeCategory === oldName) { this.activeCategory = newName; } } }, }; // Initialize Settings Panel first to reference it in InsertButton callbacks this.components.settingsPanel = new SettingsPanelComponent({ onSave: (newConfig) => this.onSave(newConfig), getCurrentConfig: () => this.config, // Anchor to insertBtn (will be assigned later, but reference is dynamic) getAnchorElement: () => this.components.insertBtn.element, // Pass only session profile onShowTextEditorModal: () => this.components.textEditorModal.open(this.sessionActiveProfile), onShowJsonModal: () => { this.components.jsonModal.open(this.components.insertBtn.element); }, onShowShortcutModal: () => { this.components.shortcutModal.open(this.components.insertBtn.element); }, }); this.components.insertBtn = new InsertButtonComponent( { onClick: (e) => { const mode = this.config.options.trigger_mode || 'hover'; if (this.components.settingsPanel.isOpen()) { this.components.settingsPanel.hide(); this._showTextList(false); return; } if (mode === 'click') { if (this.components.textList.element.style.display === 'none') { this._showTextList(false); } else { this._hideTextList(); } } else { this._hideTextList(); this.components.settingsPanel.show(); } }, onContextMenu: (e) => { this._hideTextList(); this.components.settingsPanel.toggle(); }, onMouseEnter: () => { const mode = this.config.options.trigger_mode || 'hover'; if (mode === 'hover' && !this.components.settingsPanel.isOpen()) { this._showTextList(false); } }, onMouseLeave: () => { const mode = this.config.options.trigger_mode || 'hover'; if (mode !== 'click') { this._startHideTimer(); } }, }, { id: `${CONSTANTS.ID_PREFIX}insert-btn`, title: 'Add quick text', } ); this.components.textList = new TextListComponent( { onMouseEnter: () => clearTimeout(this.hideTimeoutId), onMouseLeave: () => { const mode = this.config.options.trigger_mode || 'hover'; if (mode !== 'click') { this._startHideTimer(); } }, onMouseMove: (e) => { if (this.isKeyboardNavigating) { // Check if mouse actually moved to prevent unexpected blur on layout changes if (e.movementX === 0 && e.movementY === 0) return; this.isKeyboardNavigating = false; // Remove focus from the list item to clear selection visual if (document.activeElement instanceof HTMLElement && this.components.textList.element.contains(document.activeElement)) { document.activeElement.blur(); } } }, onPrevProfile: () => this._switchProfile(-1), onNextProfile: () => this._switchProfile(1), onProfileNameClick: () => this.toggleProfileList(), onProfileNameContextMenu: () => { this._hideTextList(); this.components.textEditorModal.open(this.sessionActiveProfile); }, }, { id: `${CONSTANTS.ID_PREFIX}text-list`, } ); this.components.jsonModal = new JsonModalComponent(modalCallbacks); this.components.textEditorModal = new TextEditorModalComponent({ ...modalCallbacks, onShowJsonModal: () => { this.components.textEditorModal.close(); this.components.jsonModal.open(this.components.insertBtn.element); }, }); this.components.shortcutModal = new ShortcutModalComponent(modalCallbacks); } async _onInit() { // Explicitly render components that require it this.components.insertBtn?.render(); this.components.textList?.render(); this.components.settingsPanel?.render(); this.components.textEditorModal?.render(); this.components.jsonModal?.render(); this.components.shortcutModal?.render(); this.renderContent(); this._updateButtonTitle(); this._subscribe(EVENTS.REOPEN_MODAL, ({ type, key }) => { if (type === CONSTANTS.MODAL_TYPES.JSON) { this.components.jsonModal.open(this.components.insertBtn.element); } else if (type === CONSTANTS.MODAL_TYPES.TEXT_EDITOR) { this.components.textEditorModal.open(this.sessionActiveProfile, key); } }); this._subscribe(EVENTS.UI_REPOSITION, () => this.repositionInsertButton()); this._subscribe(EVENTS.NAVIGATION_START, () => { this.components.settingsPanel.hide(); this._hideTextList(); }); this.repositionInsertButton(); // Register global key listener for shortcuts (Alt+Q) document.addEventListener('keydown', this._handlers.globalKeydown); if (this.components.settingsPanel) { await this.components.settingsPanel.init(); } if (this.components.textEditorModal) { await this.components.textEditorModal.init(); } } /** * @override * @protected */ _onDestroy() { this._hideTextList(); clearTimeout(this.hideTimeoutId); // Remove global listeners document.removeEventListener('keydown', this._handlers.globalKeydown); document.removeEventListener('click', this._handlers.globalClick); // Ensure click listener is also removed for (const key in this.components) { this.components[key]?.destroy(); } super._onDestroy(); } repositionInsertButton() { if (this.isRepositioningScheduled) return; this.isRepositioningScheduled = true; requestAnimationFrame(() => { PlatformAdapters.UI.repositionInsertButton(this.components.insertBtn); this.isRepositioningScheduled = false; }); } getActiveModal() { if (this.components.jsonModal?.modal?.element?.open) { return this.components.jsonModal; } if (this.components.textEditorModal?.modal?.element?.open) { return this.components.textEditorModal; } return null; } setModalState(isOpen) { this.isModalOpen = isOpen; if (!isOpen) { this.onModalClose?.(); } } toggleProfileList() { this.isProfileListMode = !this.isProfileListMode; if (this.isProfileListMode) { this.renderProfileList(); } else { this.renderContent(); } } renderProfileList() { if (!this.components.textList) return; this.components.textList.toggleTabs(false); const { optionsContainer } = this.components.textList.elements; optionsContainer.textContent = ''; // Array-based access const profiles = this.config.texts || []; const activeProfileName = this.sessionActiveProfile; // Retrieve class names from StyleDefinitions const cls = StyleDefinitions.getTextList().classes; profiles.forEach((profile) => { const profileName = profile.name; const btn = h('button', { className: `${cls.option}` + (profileName === activeProfileName ? ' active' : ''), textContent: profileName, title: 'Switch to this profile', onclick: (e) => { e.preventDefault(); e.stopPropagation(); this._setProfile(profileName); // Only focus if navigating via keyboard if (this.isKeyboardNavigating) { requestAnimationFrame(() => this.components.textList.focusOption(0)); } }, }); optionsContainer.appendChild(btn); }); optionsContainer.scrollTop = 0; } updateConfig(newConfig) { this.config = newConfig; // Array-based validation const profiles = this.config.texts || []; const activeProfileObj = profiles.find((p) => p.name === this.sessionActiveProfile); if (!activeProfileObj) { Logger.warn('STATE RECOVERY', LOG_STYLES.ORANGE, `Session profile "${this.sessionActiveProfile}" no longer exists. Falling back to default.`); const fallbackProfile = profiles[0]; this.sessionActiveProfile = this.config.options.activeProfileName || fallbackProfile?.name; const activeProfile = profiles.find((p) => p.name === this.sessionActiveProfile) || fallbackProfile; this.activeCategory = activeProfile?.categories[0]?.name || null; } else { const categories = activeProfileObj.categories; const categoryExists = categories.some((c) => c.name === this.activeCategory); if (this.activeCategory && !categoryExists) { this.activeCategory = categories[0]?.name || null; } } this.renderContent(); this._updateButtonTitle(); } renderContent() { if (!this.components.textList) return; this.isProfileListMode = false; this.components.textList.toggleTabs(true); const { tabsContainer, optionsContainer } = this.components.textList.elements; tabsContainer.textContent = ''; optionsContainer.textContent = ''; const activeProfileName = this.sessionActiveProfile; const profiles = this.config.texts || []; const activeProfile = profiles.find((p) => p.name === activeProfileName); if (!activeProfile) { Logger.warn('', '', `Active profile "${activeProfileName}" not found.`); return; } this.components.textList.setProfileName(activeProfileName); // Retrieve class names from StyleDefinitions const cls = StyleDefinitions.getTextList().classes; const categories = activeProfile.categories || []; categories.forEach((cat) => { const catName = cat.name; const tab = h('button', { className: `${cls.tab}` + (catName === this.activeCategory ? ' active' : ''), textContent: catName, onclick: (e) => { e.preventDefault(); e.stopPropagation(); this.activeCategory = catName; this.renderContent(); // Only focus if navigating via keyboard if (this.isKeyboardNavigating) { requestAnimationFrame(() => this.components.textList.focusOption(0)); } }, }); tabsContainer.appendChild(tab); }); // Find active category object const activeCategoryObj = categories.find((c) => c.name === this.activeCategory); const items = activeCategoryObj ? activeCategoryObj.items : []; items.forEach((txt) => { const btn = h('button', { className: `${cls.option}`, textContent: txt, title: txt, onclick: (e) => { e.preventDefault(); e.stopPropagation(); this._insertText(txt); this._hideTextList(); }, }); optionsContainer.appendChild(btn); }); } async _switchCategory(direction) { const profiles = this.config.texts || []; const activeProfile = profiles.find((p) => p.name === this.sessionActiveProfile); if (!activeProfile) return; const categories = activeProfile.categories || []; if (categories.length <= 1) return; const currentIndex = categories.findIndex((c) => c.name === this.activeCategory); // Fallback if not found const baseIndex = currentIndex === -1 ? 0 : currentIndex; let newIndex = (baseIndex + direction) % categories.length; if (newIndex < 0) newIndex += categories.length; const newCategory = categories[newIndex]; this.activeCategory = newCategory.name; this.renderContent(); // Reset focus to the first item in the new category requestAnimationFrame(() => this.components.textList.focusOption(0)); } async _switchProfile(direction) { const profiles = this.config.texts || []; if (profiles.length <= 1) return; const currentProfileIndex = profiles.findIndex((p) => p.name === this.sessionActiveProfile); // Fallback if not found const baseIndex = currentProfileIndex === -1 ? 0 : currentProfileIndex; let newIndex = (baseIndex + direction) % profiles.length; if (newIndex < 0) newIndex += profiles.length; const newProfile = profiles[newIndex]; this.sessionActiveProfile = newProfile.name; this.activeCategory = newProfile.categories[0]?.name || null; this.renderContent(); if (this.components.textList.elements.optionsContainer) { this.components.textList.elements.optionsContainer.scrollTop = 0; } } async _setProfile(profileName) { if (profileName === this.sessionActiveProfile) { this.renderContent(); return; } this.sessionActiveProfile = profileName; const profiles = this.config.texts || []; const activeProfile = profiles.find((p) => p.name === profileName); this.activeCategory = activeProfile?.categories[0]?.name || null; this.renderContent(); if (this.components.textList.elements.optionsContainer) { this.components.textList.elements.optionsContainer.scrollTop = 0; } } _updateButtonTitle() { if (!this.components.insertBtn) return; const mode = this.config.options.trigger_mode || 'hover'; const shortcutSuffix = this.config.options.enable_shortcut ? ' (Alt+Q)' : ''; let title; if (mode === 'click') { title = `Click to open Text List${shortcutSuffix} / Right-click to open Settings`; } else { title = `Hover to open Text List${shortcutSuffix} / Click to open Settings`; } this.components.insertBtn.setTitle(title); } _insertText(text) { PlatformAdapters.General.insertText(text, this.config.options); } _positionList() { requestAnimationFrame(() => { const btnRect = this.components.insertBtn.element.getBoundingClientRect(); const listElem = this.components.textList.element; const margin = 8; const gap = 4; const spaceAbove = btnRect.top - margin - gap; const spaceBelow = window.innerHeight - btnRect.bottom - margin - gap; const placeOnTop = spaceAbove >= 200 || spaceAbove >= spaceBelow; if (placeOnTop) { listElem.style.flexDirection = 'column-reverse'; listElem.style.top = 'auto'; listElem.style.bottom = `${window.innerHeight - btnRect.top + gap}px`; listElem.style.maxHeight = `${spaceAbove}px`; } else { listElem.style.flexDirection = 'column'; listElem.style.bottom = 'auto'; listElem.style.top = `${btnRect.bottom + gap}px`; listElem.style.maxHeight = `${spaceBelow}px`; } const listWidth = listElem.offsetWidth || 500; let left = btnRect.left; if (left + listWidth > window.innerWidth - margin) { left = window.innerWidth - listWidth - margin; } left = Math.max(left, margin); listElem.style.left = `${left}px`; listElem.style.opacity = '1'; }); } _showTextList(isKeyboard) { clearTimeout(this.hideTimeoutId); const listElem = this.components.textList.element; if (listElem.style.display !== 'none') return; // Set navigation mode flag based on trigger source this.isKeyboardNavigating = isKeyboard; listElem.style.display = 'flex'; listElem.style.opacity = '0'; listElem.style.left = '-9999px'; listElem.style.top = ''; listElem.style.bottom = ''; this._positionList(); // Setup global click listener for closing (only when open) document.addEventListener('click', this._handlers.globalClick); // Restore focus (Native Focus Management) if (isKeyboard) { requestAnimationFrame(() => { if (this.isProfileListMode) { this.components.textList.focusOption(0); } else { this.components.textList.focusOption(this.lastFocusedIndex); } }); } } _startHideTimer() { this.hideTimeoutId = setTimeout(() => { this._hideTextList(); }, CONSTANTS.TIMING.TIMEOUTS.HIDE_DELAY_MS); } _hideTextList() { clearTimeout(this.hideTimeoutId); const listElem = this.components.textList.element; if (listElem.style.display === 'none') return; // Remember focus index if not in profile list mode if (!this.isProfileListMode) { const options = this.components.textList._getOptions(); const current = document.activeElement; const index = current instanceof HTMLElement ? options.indexOf(current) : -1; if (index >= 0) { this.lastFocusedIndex = index; } } listElem.style.display = 'none'; // Remove global click listener (Keydown listener remains active) document.removeEventListener('click', this._handlers.globalClick); if (this.isProfileListMode) { this.renderContent(); // Reset to text content view } // UX: Restore focus to the input editor const editorSelector = this.platformDetails.selectors.INPUT_TARGET; const editor = document.querySelector(editorSelector); if (editor instanceof HTMLElement) { editor.focus(); } } _handleGlobalClick(e) { const listEl = this.components.textList.element; const btnEl = this.components.insertBtn.element; // If closed, do nothing (though listener should be removed) if (listEl.style.display === 'none') return; // Ignore clicks inside the list or on the toggle button if (listEl.contains(e.target) || btnEl.contains(e.target)) { return; } this._hideTextList(); } /** * Checks if any modal or settings panel is currently open. * @returns {boolean} * @private */ _isAnyModalOpen() { // Check Settings Panel if (this.components.settingsPanel && this.components.settingsPanel.isOpen()) { return true; } // Check Dialog Modals const modals = [this.components.textEditorModal, this.components.jsonModal, this.components.shortcutModal]; return modals.some((comp) => comp?.modal?.element?.open); } _handleGlobalKeydown(e) { // 1. Global Toggle (Alt+Q) // Use code 'KeyQ' for physical key position independence, or 'q' key property. // Exclude AltGr (Ctrl+Alt) to avoid accidental trigger on some keyboard layouts const isAltGraph = e.getModifierState ? e.getModifierState('AltGraph') : false; if (e.altKey && !e.ctrlKey && !e.metaKey && !isAltGraph && (e.code === 'KeyQ' || e.key === 'q')) { // Check if shortcut is enabled if (this.config.options.enable_shortcut === false) { return; } // If any modal/panel is open, ignore the shortcut completely if (this._isAnyModalOpen()) { return; } e.preventDefault(); e.stopPropagation(); if (this.components.textList.element.style.display === 'none') { this._showTextList(true); } else { this._hideTextList(); } return; } // If list is closed, stop processing navigation keys if (this.components.textList.element.style.display === 'none') return; // Prevent interference with IME composition (e.g., when focus logic fails and focus remains on input) if (e.isComposing) return; // Close list on character input // Consume the event so no character is entered, then close the list. // Note: Space key is excluded here because it is handled explicitly in the switch statement below. const isCharKey = !e.ctrlKey && !e.altKey && !e.metaKey && e.key.length === 1 && e.key !== ' '; if (isCharKey) { e.preventDefault(); e.stopPropagation(); this._hideTextList(); return; } // 2. Navigation when List is Open const isProfileList = this.isProfileListMode; // Update navigation mode to keyboard if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { this.isKeyboardNavigating = true; } switch (e.key) { case 'Escape': e.preventDefault(); e.stopPropagation(); this._hideTextList(); break; case 'ArrowUp': e.preventDefault(); e.stopPropagation(); if (e.ctrlKey) { // Ctrl+Up: Toggle Profile List this.toggleProfileList(); if (this.isProfileListMode) { requestAnimationFrame(() => this.components.textList.focusOption(0)); } else { requestAnimationFrame(() => this.components.textList.focusOption(this.lastFocusedIndex)); } } else { // Normal Up: Previous Item this.components.textList.focusPrev(); } break; case 'ArrowDown': e.preventDefault(); e.stopPropagation(); if (e.ctrlKey) { // Ctrl+Down: Toggle Profile List this.toggleProfileList(); if (this.isProfileListMode) { requestAnimationFrame(() => this.components.textList.focusOption(0)); } else { requestAnimationFrame(() => this.components.textList.focusOption(this.lastFocusedIndex)); } } else { // Normal Down: Next Item this.components.textList.focusNext(); } break; case 'ArrowLeft': e.preventDefault(); e.stopPropagation(); if (e.ctrlKey) { // Ctrl+Left: Switch Profile if (!isProfileList) { this._switchProfile(-1).then(() => { // Reset focus to first item after switch requestAnimationFrame(() => this.components.textList.focusOption(0)); }); } } else { // Normal Left: Switch Category (New Feature) if (!isProfileList) { this._switchCategory(-1); } } break; case 'ArrowRight': e.preventDefault(); e.stopPropagation(); if (e.ctrlKey) { // Ctrl+Right: Switch Profile if (!isProfileList) { this._switchProfile(1).then(() => { // Reset focus to first item after switch requestAnimationFrame(() => this.components.textList.focusOption(0)); }); } } else { // Normal Right: Switch Category if (!isProfileList) { this._switchCategory(1); } } break; case 'Tab': case ' ': case 'Enter': e.preventDefault(); e.stopPropagation(); if (document.activeElement instanceof HTMLElement && this.components.textList.element.contains(document.activeElement)) { document.activeElement.click(); } else { // Handle selection via mouse hover if no element is focused const cls = StyleManager.request(StyleDefinitions.getTextList).classes; const hoverOption = this.components.textList.element.querySelector(`.${cls.option}:hover`); if (hoverOption instanceof HTMLElement) { hoverOption.click(); } else { this._hideTextList(); } } break; } } } // ================================================================================= // SECTION: Sync Manager // ================================================================================= class SyncManager extends BaseManager { constructor(appInstance) { super(); this.app = appInstance; this.pendingRemoteConfig = null; this.remoteChangeListenerId = null; } _onInit() { this.remoteChangeListenerId = GM_addValueChangeListener(this.app.configKey, async (name, oldValue, newValue, remote) => { if (remote) { await this._handleRemoteChange(newValue); } }); } /** * @override * @protected */ _onDestroy() { if (this.remoteChangeListenerId !== null) { GM_removeValueChangeListener(this.remoteChangeListenerId); this.remoteChangeListenerId = null; } super._onDestroy(); } onModalClose() { if (this.pendingRemoteConfig) { Logger.log('', '', 'SyncManager: Modal closed with a pending update. Applying it now.'); this.app.applyUpdate(this.pendingRemoteConfig); this.pendingRemoteConfig = null; } } onSave() { // A local save overwrites any pending remote changes. this.pendingRemoteConfig = null; // Also, clear any visible conflict notifications. const activeModal = this.app.uiManager.getActiveModal?.(); if (activeModal) { this._clearConflictNotification(activeModal); } } async _handleRemoteChange(rawValue) { Logger.info('SYNC', LOG_STYLES.TEAL, 'SyncManager: Remote config change detected.'); try { const newConfig = await this.app.configManager.decode(rawValue); const activeModal = this.app.uiManager.getActiveModal?.(); if (activeModal) { Logger.info('SYNC', LOG_STYLES.TEAL, 'SyncManager: A modal is open. Storing update and displaying conflict notification.'); this.pendingRemoteConfig = newConfig; this._showConflictNotification(activeModal); } else { Logger.info('SYNC', LOG_STYLES.TEAL, 'SyncManager: No modal open. Applying silent update.'); this.app.applyUpdate(newConfig); } } catch (e) { Logger.error('SYNC ERROR', LOG_STYLES.RED, 'SyncManager: Failed to handle remote config change:', e); } } _showConflictNotification(modalComponent) { if (!modalComponent?.modal) return; this._clearConflictNotification(modalComponent); // Clear previous state first const messageArea = modalComponent.modal.dom.footerMessage; const cls = StyleDefinitions.COMMON_CLASSES; const v = StyleDefinitions.VARS; if (messageArea) { const messageText = h('span', { textContent: 'Settings updated in another tab.', style: { display: 'flex', alignItems: 'center' }, }); const reloadBtn = h('button', { id: cls.conflictReloadBtnId, className: cls.modalButton, textContent: 'Reload UI', title: 'Discard local changes and load the settings from the other tab.', style: { borderColor: `var(${v.TEXT_DANGER})`, marginLeft: '12px', }, onclick: () => { const reopenContext = modalComponent.getContextForReopen?.(); modalComponent.close(); // onModalClose will handle applying the pending update. // Request to reopen the modal after a short delay to ensure sync completion. setTimeout(() => { EventBus.publish(EVENTS.REOPEN_MODAL, reopenContext); }, 100); }, }); messageArea.textContent = ''; messageArea.classList.add(cls.conflictText); messageArea.append(messageText, reloadBtn); } } _clearConflictNotification(modalComponent) { if (!modalComponent?.modal) return; const messageArea = modalComponent.modal.dom.footerMessage; if (messageArea) { messageArea.textContent = ''; messageArea.classList.remove(StyleDefinitions.COMMON_CLASSES.conflictText); } } } // ================================================================================= // SECTION: Navigation Monitor // Description: Centralizes URL change detection via history API hooks and popstate events. // ================================================================================= class NavigationMonitor { constructor() { this.originalHistoryMethods = { pushState: null, replaceState: null }; this._historyWrappers = {}; this.isInitialized = false; this.lastPath = null; this._handlePopState = this._handlePopState.bind(this); // Debounce the navigation event to allow the DOM to settle and prevent duplicate events this.debouncedNavigation = debounce( () => { EventBus.publish(EVENTS.NAVIGATION); }, CONSTANTS.TIMING.TIMEOUTS.POST_NAVIGATION_DOM_SETTLE, true ); } init() { if (this.isInitialized) return; this.isInitialized = true; // Capture initial path this.lastPath = location.pathname + location.search; this._hookHistory(); window.addEventListener('popstate', this._handlePopState); } destroy() { if (!this.isInitialized) return; this._restoreHistory(); window.removeEventListener('popstate', this._handlePopState); this.debouncedNavigation.cancel(); this.isInitialized = false; } _hookHistory() { // Capture the instance for use in the wrapper const instance = this; for (const m of ['pushState', 'replaceState']) { const orig = history[m]; this.originalHistoryMethods[m] = orig; const wrapper = function (...args) { const result = orig.apply(this, args); instance._onUrlChange(); return result; }; this._historyWrappers[m] = wrapper; history[m] = wrapper; } } _restoreHistory() { for (const m of ['pushState', 'replaceState']) { if (this.originalHistoryMethods[m]) { if (history[m] === this._historyWrappers[m]) { history[m] = this.originalHistoryMethods[m]; } else { Logger.warn('HISTORY HOOK', LOG_STYLES.YELLOW, `history.${m} has been wrapped by another script. Skipping restoration to prevent breaking the chain.`); } this.originalHistoryMethods[m] = null; } } this._historyWrappers = {}; } _handlePopState() { this._onUrlChange(); } _onUrlChange() { const currentPath = location.pathname + location.search; // Prevent re-triggers if the path hasn't actually changed if (currentPath === this.lastPath) { return; } this.lastPath = currentPath; EventBus.publish(EVENTS.NAVIGATION_START); this.debouncedNavigation(); } } // ================================================================================= // SECTION: DOM Observers and Event Listeners // ================================================================================= class ObserverManager extends BaseManager { constructor() { super(); this.activePageObservers = []; // To store cleanup functions } /** * Starts all platform-specific observers by retrieving and executing them * from the PlatformAdapter. */ start() { // Subscribe to navigation events to handle page changes this._subscribe(EVENTS.NAVIGATION, () => this._onNavigation()); // Perform initial setup by publishing the navigation event. EventBus.publish(EVENTS.NAVIGATION); } /** * @override * @protected */ _onDestroy() { this.activePageObservers.forEach((cleanup) => cleanup()); this.activePageObservers = []; super._onDestroy(); } /** * @private * @description Handles the logic required when a navigation occurs or the app initializes. * Resets observers and sets up page-specific listeners. */ _onNavigation() { try { Logger.debug('NAVIGATOR', LOG_STYLES.CYAN, 'Navigation detected. Starting lifecycle...'); // 1. Cleanup previous page resources this.activePageObservers.forEach((cleanup) => cleanup()); this.activePageObservers = []; // 2. Initialize Page-Specific Observers const observerStarters = PlatformAdapters.Observer.getInitializers(); for (const startObserver of observerStarters) { // Ensure the context of 'this' is the ObserverManager instance const cleanup = startObserver.call(this, {}); if (typeof cleanup === 'function') { this.activePageObservers.push(cleanup); } } } catch (e) { Logger.error('NAV_HANDLER_ERROR', LOG_STYLES.RED, 'Error during navigation handling:', e); } } } // ================================================================================= // SECTION: Main Application Controller // ================================================================================= /** * @class Sentinel * @description Detects DOM node insertion using a shared, prefixed CSS animation trick. * @property {Map void>>} listeners * @property {Set} rules * @property {HTMLElement | null} styleElement * @property {CSSStyleSheet | null} sheet * @property {string[]} pendingRules * @property {WeakMap} ruleSelectors */ class Sentinel { /** * @param {string} prefix - A unique identifier for this Sentinel instance to avoid CSS conflicts. Required. */ constructor(prefix) { if (!prefix) { throw new Error('[Sentinel] "prefix" argument is required to avoid CSS conflicts.'); } /** @type {any} */ const globalScope = window; globalScope.__global_sentinel_instances__ = globalScope.__global_sentinel_instances__ || {}; if (globalScope.__global_sentinel_instances__[prefix]) { return globalScope.__global_sentinel_instances__[prefix]; } // Use a unique, prefixed animation name shared by all scripts in a project. this.animationName = `${prefix}-global-sentinel-animation`; this.styleId = `${prefix}-sentinel-global-rules`; // A single, unified style element this.listeners = new Map(); this.rules = new Set(); // Tracks all active selectors this.styleElement = null; // Holds the reference to the single style element this.sheet = null; // Cache the CSSStyleSheet reference this.pendingRules = []; // Queue for rules requested before sheet is ready /** @type {WeakMap} */ this.ruleSelectors = new WeakMap(); // Tracks selector strings associated with CSSRule objects this._injectStyleElement(); document.addEventListener('animationstart', this._handleAnimationStart.bind(this), true); globalScope.__global_sentinel_instances__[prefix] = this; } _injectStyleElement() { // Ensure the style element is injected only once per project prefix. this.styleElement = document.getElementById(this.styleId); if (this.styleElement instanceof HTMLStyleElement) { this.sheet = this.styleElement.sheet; return; } // Create empty style element this.styleElement = h('style', { id: this.styleId, }); // CSP Fix: Try to fetch a valid nonce from existing scripts/styles // "nonce" property exists on HTMLScriptElement/HTMLStyleElement, not basic Element. let nonce; const script = document.querySelector('script[nonce]'); const style = document.querySelector('style[nonce]'); if (script instanceof HTMLScriptElement) { nonce = script.nonce; } else if (style instanceof HTMLStyleElement) { nonce = style.nonce; } if (nonce) { this.styleElement.setAttribute('nonce', nonce); } // Try to inject immediately. If the document is not yet ready (e.g. extremely early document-start), wait for the root element. const target = document.head || document.documentElement; const initSheet = () => { if (this.styleElement instanceof HTMLStyleElement) { this.sheet = this.styleElement.sheet; // Insert the shared keyframes rule at index 0. try { const keyframes = `@keyframes ${this.animationName} { from { transform: none; } to { transform: none; } }`; this.sheet.insertRule(keyframes, 0); } catch (e) { Logger.error('SENTINEL', LOG_STYLES.RED, 'Failed to insert keyframes rule:', e); } this._flushPendingRules(); } }; if (target) { target.appendChild(this.styleElement); initSheet(); } else { const observer = new MutationObserver(() => { const retryTarget = document.head || document.documentElement; if (retryTarget) { observer.disconnect(); retryTarget.appendChild(this.styleElement); initSheet(); } }); observer.observe(document, { childList: true }); } } _flushPendingRules() { if (!this.sheet || this.pendingRules.length === 0) return; const rulesToInsert = [...this.pendingRules]; this.pendingRules = []; rulesToInsert.forEach((selector) => { this._insertRule(selector); }); } /** * Helper to insert a single rule into the stylesheet * @param {string} selector */ _insertRule(selector) { try { const index = this.sheet.cssRules.length; const ruleText = `${selector} { animation-duration: 0.001s; animation-name: ${this.animationName}; }`; this.sheet.insertRule(ruleText, index); // Associate the inserted rule with the selector via WeakMap for safer removal later. // This mimics sentinel.js behavior to handle index shifts and selector normalization. const insertedRule = this.sheet.cssRules[index]; if (insertedRule) { this.ruleSelectors.set(insertedRule, selector); } } catch (e) { Logger.error('SENTINEL', LOG_STYLES.RED, `Failed to insert rule for selector "${selector}":`, e); } } _handleAnimationStart(event) { // Check if the animation is the one we're listening for. if (event.animationName !== this.animationName) return; const target = event.target; if (!(target instanceof Element)) { return; } // Check if the target element matches any of this instance's selectors. for (const [selector, callbacks] of this.listeners.entries()) { if (target.matches(selector)) { // Use a copy of the callbacks array in case a callback removes itself. [...callbacks].forEach((cb) => cb(target)); } } } /** * @param {string} selector * @param {(element: Element) => void} callback */ on(selector, callback) { // Add callback to listeners if (!this.listeners.has(selector)) { this.listeners.set(selector, []); } this.listeners.get(selector).push(callback); // If selector is already registered in rules, do nothing if (this.rules.has(selector)) return; this.rules.add(selector); // Apply rule if (this.sheet) { this._insertRule(selector); } else { this.pendingRules.push(selector); } } /** * @param {string} selector * @param {(element: Element) => void} callback */ off(selector, callback) { const callbacks = this.listeners.get(selector); if (!callbacks) return; const newCallbacks = callbacks.filter((cb) => cb !== callback); if (newCallbacks.length === callbacks.length) { return; // Callback not found, do nothing. } if (newCallbacks.length === 0) { // Remove listener and rule this.listeners.delete(selector); this.rules.delete(selector); if (this.sheet) { // Iterate backwards to avoid index shifting issues during deletion for (let i = this.sheet.cssRules.length - 1; i >= 0; i--) { const rule = this.sheet.cssRules[i]; // Check for recorded selector via WeakMap or fallback to selectorText match const recordedSelector = this.ruleSelectors.get(rule); if (recordedSelector === selector || (rule instanceof CSSStyleRule && rule.selectorText === selector)) { this.sheet.deleteRule(i); // We assume one rule per selector, so we can break after deletion break; } } } } else { this.listeners.set(selector, newCallbacks); } } suspend() { if (this.styleElement instanceof HTMLStyleElement) { this.styleElement.disabled = true; } Logger.debug('SENTINEL', LOG_STYLES.CYAN, 'Suspended.'); } resume() { if (this.styleElement instanceof HTMLStyleElement) { this.styleElement.disabled = false; } Logger.debug('SENTINEL', LOG_STYLES.CYAN, 'Resumed.'); } } // ================================================================================= // SECTION: Core Functions // ================================================================================= class AppController extends BaseManager { constructor(platformDetails) { super(); this.configManager = null; this.uiManager = null; this.platformDetails = platformDetails; this.syncManager = null; this.observerManager = new ObserverManager(); } async _onInit() { // Inject platform-specific CSS variables immediately StyleManager.injectPlatformVariables(this.platformDetails.platformId); this.configManager = new ConfigManager(); await this.configManager.load(); // Set logger level from config immediately after loading Logger.setLevel(this.configManager.get().developer.logger_level); this.syncManager = new SyncManager(this); this.uiManager = new UIManager( this.configManager.get(), (newConfig) => this.handleSave(newConfig), this.platformDetails, () => this.syncManager.onModalClose(), (conf) => this.configManager.validateConfigSize(conf) // Pass size validator ); await this.uiManager.init(); await this.syncManager.init(); this.observerManager.init(); this.observerManager.start(); } /** * @override * @protected */ _onDestroy() { // Do not destroy navigationMonitor to keep history hooks active for recovery this.uiManager?.destroy(); this.observerManager?.destroy(); this.configManager?.destroy(); this.syncManager?.destroy(); super._onDestroy(); Logger.debug('SHUTDOWN', LOG_STYLES.GRAY, 'AppController destroyed (suspended).'); } // Method required by the SyncManager's interface for silent updates applyUpdate(newConfig) { const completeConfig = ConfigProcessor.process(newConfig); this.configManager.config = completeConfig; this.uiManager.updateConfig(completeConfig); // Apply logger level immediately if (completeConfig.developer && completeConfig.developer.logger_level) { Logger.setLevel(completeConfig.developer.logger_level); } // Notify UI components (e.g., SettingsPanel) to update their state EventBus.publish(EVENTS.CONFIG_UPDATED, completeConfig); } // Method required by the SyncManager's interface getAppId() { return APPID; } // Getter required by the SyncManager's interface get configKey() { return CONSTANTS.CONFIG_KEY; } async handleSave(newConfig) { try { // 1. Save to storage (Validation and sanitization occur here) await this.configManager.save(newConfig); // 2. Retrieve the sanitized config from the manager // This ensures we are using the valid data that was actually saved. const savedConfig = this.configManager.get(); // 3. Update the UI Manager with the new config this.uiManager.updateConfig(savedConfig); // 4. Apply the new logger level immediately if (savedConfig.developer && savedConfig.developer.logger_level) { Logger.setLevel(savedConfig.developer.logger_level); } // 5. Notify UI components (e.g., SettingsPanel) to refresh their form values // This is critical if the save was triggered by one component but others need to stay in sync. EventBus.publish(EVENTS.CONFIG_UPDATED, savedConfig); // 6. Notify SyncManager of the successful save this.syncManager.onSave(); } catch (err) { Logger.error('CONFIG', '', 'Failed to save config:', err); throw err; // Re-throw the error for the UI layer to catch } } } // ================================================================================= // SECTION: Lifecycle Manager // Description: Centralizes application startup/shutdown logic. // ================================================================================= class LifecycleManager extends BaseManager { constructor(platformDetails) { super(); this.platformDetails = platformDetails; this.app = new AppController(platformDetails); this.navMonitor = new NavigationMonitor(); this._boundUpdateState = this._updateState.bind(this); } /** * @protected * @override */ _onInit() { // Start navigation monitoring this.navMonitor.init(); // Trigger 1: DOM Detection sentinel.on(this.platformDetails.selectors.INPUT_TARGET, this._boundUpdateState); // Trigger 2: Navigation this._subscribe(EVENTS.NAVIGATION, this._boundUpdateState); // Initial check this._updateState(); } /** * @protected * @override */ _onDestroy() { // Stop navigation monitoring this.navMonitor.destroy(); // Cleanup Sentinel listener sentinel.off(this.platformDetails.selectors.INPUT_TARGET, this._boundUpdateState); // Ensure AppController is destroyed if (this.app.isInitialized) { this.app.destroy(); } super._onDestroy(); } _updateState() { // 1. Check exclusion rules if (PlatformAdapters.General.isExcludedPage()) { Logger.info('SUSPENDED', LOG_STYLES.YELLOW, 'Excluded page detected. App suspended.'); if (this.app.isInitialized) { this.app.destroy(); } return; } // 2. Try to launch if valid target exists if (!this.app.isInitialized) { const target = document.querySelector(this.platformDetails.selectors.INPUT_TARGET); if (target) { this.app.init(); } } } } // ================================================================================= // SECTION: Entry Point // ================================================================================= // Exit if already executed if (ExecutionGuard.hasExecuted()) return; ExecutionGuard.setExecuted(); // Singleton instance for observing internal DOM node insertions. const sentinel = new Sentinel(OWNERID); // Main Execution const platformDetails = PlatformAdapters.General.getPlatformDetails(); if (platformDetails) { const lifecycleManager = new LifecycleManager(platformDetails); lifecycleManager.init(); } })();