// ==UserScript==
// @name Quick-Text-Buttons
// @namespace https://github.com/p65536
// @version 2.2.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
// @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: Style Definitions
// =================================================================================
// Style definitions for styled Logger.badge()
const LOG_STYLES = {
BASE: 'color: white; padding: 2px 6px; border-radius: 4px; font-weight: bold;',
BLUE: 'background: #007bff;',
GREEN: 'background: #28a745;',
YELLOW: 'background: #ffc107; color: black;',
RED: 'background: #dc3545;',
GRAY: 'background: #6c757d;',
};
// =================================================================================
// 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
/**
* 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;
Logger.badge('LOG LEVEL', LOG_STYLES.BLUE, 'log', `Logger level is set to '${this.level}'.`);
} else {
Logger.badge('INVALID LEVEL', LOG_STYLES.YELLOW, 'warn', `Invalid log level "${level}". Valid levels are: ${Object.keys(this.levels).join(', ')}. Level not changed.`);
}
}
/** @param {...any} args The messages or objects to log. */
static error(...args) {
if (this.levels[this.level] >= this.levels.error) {
console.error(LOG_PREFIX, ...args);
}
}
/** @param {...any} args The messages or objects to log. */
static warn(...args) {
if (this.levels[this.level] >= this.levels.warn) {
console.warn(LOG_PREFIX, ...args);
}
}
/** @param {...any} args The messages or objects to log. */
static info(...args) {
if (this.levels[this.level] >= this.levels.info) {
console.info(LOG_PREFIX, ...args);
}
}
/** @param {...any} args The messages or objects to log. */
static log(...args) {
if (this.levels[this.level] >= this.levels.log) {
console.log(LOG_PREFIX, ...args);
}
}
/**
* Logs messages for debugging. Only active in 'debug' level.
* @param {...any} args The messages or objects to log.
*/
static debug(...args) {
if (this.levels[this.level] >= this.levels.debug) {
// Use console.debug for better filtering in browser dev tools.
console.debug(LOG_PREFIX, ...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}`);
}
}
/**
* @param {...any} args The title for the log group.
* @returns {void}
*/
static group = (...args) => console.group(LOG_PREFIX, ...args);
/**
* @param {...any} args The title for the collapsed log group.
* @returns {void}
*/
static groupCollapsed = (...args) => console.groupCollapsed(LOG_PREFIX, ...args);
/**
* Closes the current log group.
* @returns {void}
*/
static groupEnd = () => console.groupEnd();
/**
* Logs a message with a styled badge for better visibility.
* @param {string} badgeText - The text inside the badge.
* @param {string} badgeStyle - The background-color style (from LOG_STYLES).
* @param {'log'|'warn'|'error'|'info'|'debug'} level - The console log level.
* @param {...any} args - Additional messages to log after the badge.
*/
static badge(badgeText, badgeStyle, level, ...args) {
if (this.levels[this.level] < this.levels[level]) {
return; // Respect the current log level
}
const style = `${LOG_STYLES.BASE} ${badgeStyle}`;
const consoleMethod = console[level] || console.log;
consoleMethod(
`%c${LOG_PREFIX}%c %c${badgeText}%c`,
'font-weight: bold;', // Style for the prefix
'color: inherit;', // Reset for space
style, // Style for the badge
'color: inherit;', // Reset for the rest of the message
...args
);
}
}
// =================================================================================
// 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_LIMIT_BYTES: 10485760, // 10MB
ID_PREFIX: `${APPID}-id-`,
TEXT_LIST_WIDTH: 500,
HIDE_DELAY_MS: 250,
Z_INDICES: {
SETTINGS_PANEL: 11000,
TEXT_LIST: 20001,
},
MODAL: {
WIDTH: 440,
PADDING: 16,
RADIUS: 8,
BTN_RADIUS: 5,
BTN_FONT_SIZE: 13,
BTN_PADDING: '5px 16px',
TITLE_MARGIN_BOTTOM: 8,
BTN_GROUP_GAP: 8,
TEXTAREA_HEIGHT: 200,
},
TIMING: {
DEBOUNCE_DELAYS: {
UI_UPDATE: 50,
},
TIMEOUTS: {
// Fallback delay for requestIdleCallback
IDLE_EXECUTION_FALLBACK: 50,
POST_NAVIGATION_DOM_SETTLE: 200,
// Delay to wait for panel transition animations to complete
PANEL_TRANSITION_DURATION: 350,
},
},
UI_DEFAULTS: {
// Offset from the left edge of the input area
BUTTON_OFFSET_X: 12,
// Gap between buttons
BUTTON_GAP_Y: 8,
},
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',
},
},
PLATFORM: {
CHATGPT: {
ID: 'chatgpt',
HOST: 'chatgpt.com',
},
GEMINI: {
ID: 'gemini',
HOST: 'gemini.google.com',
},
},
MODAL_TYPES: {
JSON: 'json',
TEXT_EDITOR: 'textEditor',
},
SLIDER_MAPPINGS: {
VALUE_TO_CONFIG: { 0: 'start', 1: 'cursor', 2: 'end' },
CONFIG_TO_VALUE: { start: '0', cursor: '1', end: '2' },
VALUE_TO_DISPLAY: { 0: 'Start', 1: 'Cursor', 2: 'End' },
},
};
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`,
};
// ---- Site-specific Style Variables ----
const SITE_STYLES = {
chatgpt: {
ANCHOR: {
display: 'flex',
'align-items': 'center',
gap: '2px',
},
INSERT_BUTTON: {
styles: {
position: 'static !important',
margin: '0 0 0 0 !important',
display: 'flex',
// Updated dimensions to match native buttons
width: 'calc(var(--spacing)*9)',
height: 'calc(var(--spacing)*9)',
background: 'transparent',
border: 'none',
// Capsule/Circle shape
'border-radius': '50%',
// Apply site icon color
color: 'var(--text-primary)',
},
hoverStyles: {
background: 'var(--interactive-bg-secondary-hover)',
},
iconDef: {
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',
},
},
],
},
},
SETTINGS_PANEL: {
bg: 'var(--sidebar-surface-primary)',
text_primary: 'var(--text-primary)',
text_secondary: 'var(--text-secondary)',
border_medium: 'var(--border-medium)',
border_default: 'var(--border-default)',
border_light: 'var(--border-light)',
accent_color: 'var(--text-accent)',
input_bg: 'var(--bg-primary)',
input_text: 'var(--text-primary)',
input_border: 'var(--border-default)',
toggle_bg_off: 'var(--bg-primary)',
toggle_bg_on: 'var(--text-accent)',
toggle_knob: 'var(--text-primary)',
},
JSON_MODAL: {
bg: 'var(--main-surface-primary)',
text: 'var(--text-primary)',
border: 'var(--border-default)',
btn_bg: 'var(--interactive-bg-tertiary-default)',
btn_hover_bg: 'var(--interactive-bg-secondary-hover)',
btn_text: 'var(--text-primary)',
btn_border: 'var(--border-default)',
textarea_bg: 'var(--bg-primary)',
textarea_text: 'var(--text-primary)',
textarea_border: 'var(--border-default)',
msg_error_text: 'var(--text-danger)',
msg_success_text: 'var(--text-accent)',
},
THEME_MODAL: {
bg: 'var(--main-surface-primary)',
text: 'var(--text-primary)',
border: 'var(--border-default)',
btn_bg: 'var(--interactive-bg-tertiary-default)',
btn_hover_bg: 'var(--interactive-bg-secondary-hover)',
btn_text: 'var(--text-primary)',
btn_border: 'var(--border-default)',
error_text: 'var(--text-danger)',
delete_confirm_label_text: 'var(--text-danger)',
delete_confirm_btn_text: 'var(--interactive-label-danger-secondary-default)',
delete_confirm_btn_bg: 'var(--interactive-bg-danger-secondary-default)',
delete_confirm_btn_hover_text: 'var(--interactive-label-danger-secondary-hover)',
delete_confirm_btn_hover_bg: 'var(--interactive-bg-danger-secondary-hover)',
fieldset_border: 'var(--border-medium)',
legend_text: 'var(--text-secondary)',
label_text: 'var(--text-secondary)',
input_bg: 'var(--bg-primary)',
input_text: 'var(--text-primary)',
input_border: 'var(--border-default)',
slider_display_text: 'var(--text-secondary)',
popup_bg: 'var(--main-surface-primary)',
popup_border: 'var(--border-default)',
dnd_indicator_color: 'var(--text-accent)',
upIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z' } }],
},
downIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z' } }],
},
deleteIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
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' } }],
},
},
TEXT_LIST: {
bg: 'var(--main-surface-primary)',
text: 'var(--text-primary)',
border: 'var(--border-light)',
shadow: 'var(--drop-shadow-md, 0 3px 3px #0000001f)',
separator_bg: 'var(--border-default)',
tab_bg: 'var(--interactive-bg-tertiary-default)',
tab_text: 'var(--text-primary)',
tab_border: 'var(--border-light)',
tab_hover_bg: 'var(--interactive-bg-secondary-hover)',
tab_active_bg: 'var(--interactive-bg-secondary-hover)',
tab_active_border: 'var(--border-default)',
tab_active_outline: 'var(--border-default)',
option_bg: 'var(--interactive-bg-tertiary-default)',
option_text: 'var(--text-primary)',
option_border: 'var(--border-default)',
option_hover_bg: 'var(--interactive-bg-secondary-hover)',
option_hover_border: 'var(--border-default)',
option_hover_outline: 'var(--border-default)',
},
},
gemini: {
ANCHOR: {
display: 'flex',
'align-items': 'center',
position: 'relative', // Ensure anchor is a positioning context
},
INSERT_BUTTON: {
styles: {
position: 'absolute !important',
left: '8px',
bottom: '12px',
margin: '0 !important',
display: 'flex',
width: '40px',
height: '40px',
background: 'transparent',
border: 'none',
'border-radius': '50%',
// Match native tool button color
color: 'var(--mat-icon-button-icon-color, var(--mat-sys-on-surface-variant))',
},
hoverStyles: {
// Replicate Material Design 3 state layer: State Layer Color at 8% opacity
background: 'color-mix(in srgb, var(--mat-icon-button-state-layer-color) 8%, transparent)',
},
iconDef: {
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',
},
},
],
},
},
SETTINGS_PANEL: {
bg: 'var(--gem-sys-color--surface-container-highest)',
text_primary: 'var(--gem-sys-color--on-surface)',
text_secondary: 'var(--gem-sys-color--on-surface-variant)',
border_medium: 'var(--gem-sys-color--outline)',
border_default: 'var(--gem-sys-color--outline)',
border_light: 'var(--gem-sys-color--outline)',
accent_color: 'var(--gem-sys-color--primary)',
input_bg: 'var(--gem-sys-color--surface-container-low)',
input_text: 'var(--gem-sys-color--on-surface)',
input_border: 'var(--gem-sys-color--outline)',
toggle_bg_off: 'var(--gem-sys-color--surface-container)',
toggle_bg_on: 'var(--gem-sys-color--primary)',
toggle_knob: 'var(--gem-sys-color--on-primary-container)',
},
JSON_MODAL: {
bg: 'var(--gem-sys-color--surface-container-highest)',
text: 'var(--gem-sys-color--on-surface)',
border: 'var(--gem-sys-color--outline)',
btn_bg: 'var(--gem-sys-color--surface-container-high)',
btn_hover_bg: 'var(--gem-sys-color--surface-container-higher)',
btn_text: 'var(--gem-sys-color--on-surface-variant)',
btn_border: 'var(--gem-sys-color--outline)',
textarea_bg: 'var(--gem-sys-color--surface-container-low)',
textarea_text: 'var(--gem-sys-color--on-surface)',
textarea_border: 'var(--gem-sys-color--outline)',
msg_error_text: 'var(--gem-sys-color--error)',
msg_success_text: 'var(--gem-sys-color--primary)',
},
THEME_MODAL: {
bg: 'var(--gem-sys-color--surface-container-highest)',
text: 'var(--gem-sys-color--on-surface)',
border: 'var(--gem-sys-color--outline)',
btn_bg: 'var(--gem-sys-color--surface-container-high)',
btn_hover_bg: 'var(--gem-sys-color--surface-container-higher)',
btn_text: 'var(--gem-sys-color--on-surface-variant)',
btn_border: 'var(--gem-sys-color--outline)',
error_text: 'var(--gem-sys-color--error)',
delete_confirm_label_text: 'var(--gem-sys-color--error)',
delete_confirm_btn_text: 'var(--gem-sys-color--on-error-container)',
delete_confirm_btn_bg: 'var(--gem-sys-color--error-container)',
delete_confirm_btn_hover_text: 'var(--gem-sys-color--on-error-container)',
delete_confirm_btn_hover_bg: 'var(--gem-sys-color--error-container)',
fieldset_border: 'var(--gem-sys-color--outline)',
legend_text: 'var(--gem-sys-color--on-surface-variant)',
label_text: 'var(--gem-sys-color--on-surface-variant)',
input_bg: 'var(--gem-sys-color--surface-container-low)',
input_text: 'var(--gem-sys-color--on-surface)',
input_border: 'var(--gem-sys-color--outline)',
slider_display_text: 'var(--gem-sys-color--on-surface-variant)',
popup_bg: 'var(--gem-sys-color--surface-container-highest)',
popup_border: 'var(--gem-sys-color--outline)',
dnd_indicator_color: 'var(--gem-sys-color--primary)',
upIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-528 296-344l-56-56 240-240 240 240-56 56-184-184Z' } }],
},
downIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
children: [{ tag: 'path', props: { d: 'M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z' } }],
},
deleteIconDef: {
tag: 'svg',
props: { xmlns: 'http://www.w3.org/2000/svg', height: '24px', viewBox: '0 -960 960 960', width: '24px', fill: 'currentColor' },
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' } }],
},
},
TEXT_LIST: {
bg: 'var(--gem-sys-color--surface-container-high)',
text: 'var(--gem-sys-color--on-surface)',
border: 'var(--gem-sys-color--outline)',
shadow: '0 4px 12px rgba(0,0,0,0.25)',
separator_bg: 'var(--gem-sys-color--outline-variant)',
tab_bg: 'var(--gem-sys-color--surface-container)',
tab_text: 'var(--gem-sys-color--on-surface-variant)',
tab_border: 'var(--gem-sys-color--outline)',
tab_hover_bg: 'var(--gem-sys-color--surface-container-higher)',
tab_active_bg: 'var(--gem-sys-color--surface-container-higher)',
tab_active_border: 'var(--gem-sys-color--primary)',
tab_active_outline: 'var(--gem-sys-color--primary)',
option_bg: 'var(--gem-sys-color--surface-container)',
option_text: 'var(--gem-sys-color--on-surface-variant)',
option_border: 'var(--gem-sys-color--outline)',
option_hover_bg: 'var(--gem-sys-color--surface-container-higher)',
option_hover_border: 'var(--gem-sys-color--outline)',
option_hover_outline: 'var(--gem-sys-color--primary)',
},
},
};
const DEFAULT_CONFIG = {
options: {
insert_before_newline: false,
insert_after_newline: false,
insertion_position: 'cursor', // 'cursor', 'start', 'end'
trigger_mode: 'hover', // 'hover', 'click'
activeProfileName: 'Default',
},
developer: {
logger_level: 'log', // 'error', 'warn', 'info', 'log', 'debug'
},
texts: {
Default: {
Test: [
'[TEST MESSAGE] You can ignore this message.',
'Tell me something interesting.',
'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.',
],
Images: [
'For each generated image, include an "image number" (e.g., Image 1, Image 2, ...), a title, and an image description.\n\n',
'Refer to the body shape and illustration style in the attached images, and draw the same person. Pay special attention to maintaining character consistency.\n\n',
'Feel free to illustrate a scene from everyday life. You can choose the composition or situation. If you are depicting consecutive scenes (a story), make sure to keep everything consistent (e.g., do not change clothing for no reason).\n\n',
],
Coding: [
'### Code Editing Rules (Apply to the entire chat)\nStrictly follow these rules for all code suggestions, changes, optimizations, and Canvas reflection:\n1. **Do not modify any part of the code that is not being edited.**\n * This includes blank lines, comments, variable names, order, etc. **Strictly keep all unmodified parts as is.**\n2. **Always leave concise, meaningful comments.**\n * Limit comments to content that aids understanding or future maintenance. Do not include formal or duplicate notes.\n3. **When proposing or changing code, clearly state the intent and scope.**\n * Example: "Improve performance of this function," "Simplify this conditional branch," etc.\n4. **Apply the above rules even for Canvas reflection.**\n * Do not reformat, remove, or reorder content on the GPT side.\n5. **Preserve the overall style of the code (indentation, newlines, etc.).**\n * Only edited parts should stand out clearly as differences.\n\n',
'Optimize the following script according to modern design guidelines.\nWhile maintaining its purpose and function, improve the structure, readability, and extensibility.\nIf there are improvements, clearly indicate them in the code comments and compare Before→After.\n\n```\n```\n\n',
],
Summary: [
'STEP 1: For this chat log, do not summarize, but clearly show the structure of the content. Please output in the following format:\n\n- 🔹 List of topics (each topic heading and its starting point)\n- 🧷 List of technical terms / keywords / commands / proper nouns\n- 📌 Key statements marking turning points in the discussion (quotes allowed)\n\n[NOTE]\nThe goal is not to summarize, but to "enumerate and organize the topics."\nGive priority to extracting important elements while maintaining context.\n',
'STEP 2: For this chat log, enumerate the content as it is, without summarizing or restructuring.\n\nSample output format:\n1. [Start] Consulted about PowerShell script character encoding error\n2. [Proposal] Suggested UTF-8 with BOM save\n3. [Clarification] Clarified misunderstanding about Shift-JIS (e.g., cp932)\n4. [Conclusion] Decided on UTF-8-only approach with PowerShell\n\n[NOTE]\nMaintain the original order of topics. The goal is not to summarize, but to list "what was discussed" and "what conclusions were drawn."',
"STEP 3: Provide a mid-level summary for each topic in this chat log.\nCompression ratio can be low. Do not omit topics, and keep granularity somewhat fine.\n\nSample output format:\n## Chat title (or date)\n\n### Topic 1: About XXXXX\n- Overview:\n- Main discussion points:\n- Tentative conclusion or direction:\n\n### Topic 2: About YYYYY\n- ...\n\n[NOTE]\nIt's okay to be verbose. Ensure important details are not omitted so that a human can organize them later.",
'STEP 4: For each topic in this chat log, add the following indicators:\n\n- [Importance]: High / Medium / Low\n- [Reference recommended]: Yes / No (Is it worth reusing/repurposing?)\n- [Reference keywords]: About 3 search keywords\n\nThe purpose is to provide criteria for organizing or deleting this record in the future.',
],
Memory: [
'[Memory list output] Please display all currently stored model set context (memory list) for me.\nSeparate by category, output concisely and accurately.',
'[Add to memory] Please add the following content to the model set context:\n\n[Category] (e.g., PowerShell)\n[Content]\n- Always unify the log output folder for PowerShell scripts to a "logs" subfolder.\n- Internal comments in scripts should be written in Japanese.\n\nPlease consistently refer to this information as context and policy in future conversations.',
'[Edit memory] Please edit the following memory content:\n\n[Target category] PowerShell\n[Current text to be edited] The default encoding for PowerShell scripts is "UTF-8 with BOM."\n[New text] The default encoding for PowerShell scripts is "UTF-8 without BOM."\n\nBe sure to discard the old information and replace it with the new information.',
'[Delete memory] Please completely delete the following memory content:\n\n[Target category] Image generation (Haruna)\n[Text to be deleted]\n- Always include an image number and situation description (caption) when generating images.\n\nEnsure that this information is completely removed and will not affect future conversations.',
'Summarize everything you have learned about our conversation and commit it to the memory update.',
],
},
},
};
// =================================================================================
// 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;
},
/**
* 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 details not found.');
return;
}
// Use INPUT_TARGET for text insertion logic
const editor = document.querySelector(platform.selectors.INPUT_TARGET);
if (!editor) {
Logger.error('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
const paddingLeft = platform.selectors.ANCHOR_PADDING_LEFT;
const insertMethod = platform.selectors.INSERT_METHOD;
if (!insertMethod) {
Logger.warn('INSERT_METHOD is not defined for this platform.');
}
// Check if padding update is needed
let shouldUpdatePadding = false;
if (paddingLeft) {
const currentPadding = anchor.style.paddingLeft;
shouldUpdatePadding = currentPadding !== paddingLeft;
}
// 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,
shouldUpdatePadding,
paddingLeft,
insertMethod,
};
},
mutate: (measured) => {
// Write phase
if (!measured || !measured.anchor) {
insertButton.element.style.display = 'none';
return;
}
const { anchor, isGhost, existingBtn, shouldInject, shouldUpdatePadding, paddingLeft, insertMethod } = measured;
if (!anchor.isConnected) {
Logger.badge('UI RETRY', LOG_STYLES.YELLOW, 'debug', 'Anchor detached. Retrying reposition.');
EventBus.publish(EVENTS.UI_REPOSITION);
return;
}
// 1. Ghost Buster
if (isGhost && existingBtn) {
Logger.badge('GHOST BUSTER', LOG_STYLES.YELLOW, 'warn', 'Detected non-functional ghost button. Removing...');
existingBtn.remove();
}
// 2. Injection
if (shouldInject || isGhost) {
// Add marker class to apply flex/relative styles from SITE_STYLES.ANCHOR
anchor.classList.add(`${APPID}-anchor-styled`);
// Insert based on explicit method
if (insertMethod === 'append') {
anchor.appendChild(insertButton.element);
} else if (insertMethod === 'prepend') {
anchor.prepend(insertButton.element);
}
Logger.badge('UI INJECTION', LOG_STYLES.GREEN, 'debug', `Button injected into Anchor (${insertMethod}).`);
}
// 3. Update padding if configured
if (shouldUpdatePadding && paddingLeft) {
anchor.style.paddingLeft = paddingLeft;
}
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 {Promise<() => void>}
*/
async 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);
};
},
},
StyleManager: {
getInsertButtonConfig(platformId) {
const platformStyles = SITE_STYLES[platformId];
return {
...platformStyles.INSERT_BUTTON,
anchorStyles: platformStyles.ANCHOR, // Include anchor styles for injection
};
},
},
};
// =================================================================================
// 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.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.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.
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;
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, false);
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,
]),
_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) {
console.log(LOG_PREFIX, `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()];
// Use groupCollapsed for a cleaner default view
console.groupCollapsed(LOG_PREFIX, `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(`-> Executing: ${key}`);
listener(...args);
} catch (e) {
// Enhance error logging with the specific subscriber key
Logger.badge('LISTENER ERROR', LOG_STYLES.RED, 'error', `Listener "${key}" failed for event "${event}":`, e);
}
});
console.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.badge('LISTENER ERROR', LOG_STYLES.RED, 'error', `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.badge('UI QUEUE ERROR', LOG_STYLES.RED, 'error', 'Error in queued UI work:', e);
}
}
this.isUiWorkScheduled = false;
},
};
// =================================================================================
// SECTION: Utility Functions
// =================================================================================
/**
* Schedules a function to run when the browser is idle.
* @param {(deadline: IdleDeadline) => void} callback The function to execute.
* @param {number} [timeout] The maximum delay in milliseconds.
*/
function runWhenIdle(callback, timeout = 2000) {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(callback, { timeout });
} else {
setTimeout(callback, CONSTANTS.TIMING.TIMEOUTS.IDLE_EXECUTION_FALLBACK);
}
}
/**
* @param {Function} func
* @param {number} delay
* @returns {((...args: any[]) => void) & { cancel: () => void }}
*/
function debounce(func, delay) {
let timeout;
const debounced = function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => {
// After the debounce delay, schedule the actual execution for when the browser is idle.
runWhenIdle(() => func.apply(this, args));
}, delay);
};
debounced.cancel = () => {
clearTimeout(timeout);
};
return debounced;
}
/**
* Helper function to check if an item is a non-array object.
* @param {*} item The item to check.
* @returns {boolean}
*/
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) {
return structuredClone(obj);
}
/**
* 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.
* It checks for "Copy", "Copy 2", "Copy 3", etc., in a case-insensitive manner.
* @param {string} baseName The initial name to check.
* @param {Set | Array} existingNames A Set or Array containing existing names.
* @returns {string} A unique name.
*/
function proposeUniqueName(baseName, existingNames) {
const existingNamesLower = new Set(Array.from(existingNames).map((name) => name.toLowerCase()));
if (!existingNamesLower.has(baseName.trim().toLowerCase())) {
return baseName;
}
let proposedName = `${baseName} Copy`;
if (!existingNamesLower.has(proposedName.trim().toLowerCase())) {
return proposedName;
}
let counter = 2;
while (true) {
proposedName = `${baseName} Copy ${counter}`;
if (!existingNamesLower.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.badge('LAYOUT ERROR', LOG_STYLES.RED, 'error', '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.badge('LAYOUT ERROR', LOG_STYLES.RED, 'error', '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.badge('UNSAFE URL', LOG_STYLES.YELLOW, 'warn', `Blocked potentially unsafe protocol "${parsedUrl.protocol}" in attribute "${key}":`, url);
}
} catch {
el.setAttribute(key, '#');
Logger.badge('INVALID URL', LOG_STYLES.YELLOW, 'warn', `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) {
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);
return el;
}
/**
* 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);
}
/**
* Waits for a specific HTMLElement to appear in the DOM using a high-performance, Sentinel-based approach.
* It specifically checks for `instanceof HTMLElement` and will not resolve for other element types (e.g., SVGElement), even if they match the selector.
* @param {string} selector The CSS selector for the element.
* @param {object} [options]
* @param {number} [options.timeout] The maximum time to wait in milliseconds.
* @param {Document | HTMLElement} [options.context] The element to search within.
* @param {Sentinel} [sentinelInstance] The Sentinel instance to use (defaults to global `sentinel`).
* @returns {Promise} A promise that resolves with the HTMLElement or null if timed out.
*/
function waitForElement(selector, { timeout = 10000, context = document } = {}, sentinelInstance = sentinel) {
// First, check if the element already exists.
const existingEl = context.querySelector(selector);
if (existingEl instanceof HTMLElement) {
return Promise.resolve(existingEl);
}
// If not, use Sentinel wrapped in a Promise.
return new Promise((resolve) => {
let timer = null;
let sentinelCallback = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
if (sentinelCallback) sentinelInstance.off(selector, sentinelCallback);
};
timer = setTimeout(() => {
cleanup();
Logger.badge('WAIT TIMEOUT', LOG_STYLES.YELLOW, 'warn', `Timed out after ${timeout}ms waiting for element "${selector}"`);
resolve(null);
}, timeout);
sentinelCallback = (element) => {
// Ensure the found element is an HTMLElement and is within the specified context.
if (element instanceof HTMLElement && context.contains(element)) {
cleanup();
resolve(element);
}
};
sentinelInstance.on(selector, sentinelCallback);
});
}
/**
* @description A dispatch table object that maps UI schema types to their respective rendering functions.
*/
const UI_SCHEMA_RENDERERS = {
_renderContainer(def) {
let className = def.className;
if (!className) {
const classMap = {
'container-row': `${APPID}-submenu-row`,
'container-stacked-row': `${APPID}-submenu-row-stacked`,
};
className = classMap[def.type] || '';
}
const element = h(`div`, { className });
if (def.children) {
element.appendChild(buildUIFromSchema(def.children));
}
return element;
},
fieldset(def) {
const element = h(`fieldset.${APPID}-submenu-fieldset`, [h('legend', def.legend)]);
if (def.children) {
element.appendChild(buildUIFromSchema(def.children));
}
return element;
},
'submenu-separator': (def) => h(`div.${APPID}-submenu-separator`),
select(def) {
return h('select', { id: def.id, title: def.title });
},
slider(def) {
return h(`div.${APPID}-slider-wrapper`, [
h('input', {
type: 'range',
id: def.id,
min: def.min,
max: def.max,
step: def.step,
dataset: def.dataset,
}),
h(`span`, { id: def.displayId }),
]);
},
button(def) {
return h(
`button#${def.id}.${APPID}-modal-button`,
{
style: { width: '100%' },
title: def.title,
},
def.text
);
},
label: (def) => h('label', { htmlFor: def.for, title: def.title }, def.text),
toggle(def) {
return h(`label.${APPID}-toggle-switch`, { title: def.title }, [h('input', { type: 'checkbox', id: def.id }), h(`span.${APPID}-toggle-slider`)]);
},
};
// Assign aliases for container types
['container', 'container-row', 'container-stacked-row'].forEach((type) => {
UI_SCHEMA_RENDERERS[type] = UI_SCHEMA_RENDERERS._renderContainer;
});
/**
* @description Recursively builds a DOM fragment from a declarative schema object.
* @param {Array