// ==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