您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Automatically applies a theme based on the project name (changes user/assistant names, text color, icon, bubble style, window background, input area style, standing images, etc.)
当前为
// ==UserScript== // @name ChatGPT Project Theme Automator // @namespace https://github.com/p65536 // @version 1.1.1 // @license MIT // @description Automatically applies a theme based on the project name (changes user/assistant names, text color, icon, bubble style, window background, input area style, standing images, etc.) // @icon https://chatgpt.com/favicon.ico // @author p65536 // @match https://chatgpt.com/* // @grant GM_setValue // @grant GM_getValue // ==/UserScript== (() => { 'use strict'; // ================================================================================= // SECTION: Configuration and Constants // Description: Defines default settings, global constants, CSS selectors, and configuration keys. // ================================================================================= // ---- Default Settings & Theme Configuration ---- const DEFAULT_ICON_SIZE = 64; const DEFAULT_THEME_CONFIG = { options: { icon_size: DEFAULT_ICON_SIZE }, themeSets: [ { projects: ["/project1/"], user: { name: null, icon: null, textcolor: null, font: null, bubbleBgColor: null, bubblePadding: null, bubbleBorderRadius: null, bubbleMaxWidth: null, standingImage: null }, assistant: { name: null, icon: null, textcolor: null, font: null, bubbleBgColor: null, bubblePadding: null, bubbleBorderRadius: null, bubbleMaxWidth: null, standingImage: null }, windowBgColor: null, windowBgImage: null, windowBgSize: null, windowBgPosition: null, windowBgRepeat: null, windowBgAttachment: null, inputAreaBgColor: null, inputAreaTextColor: null, inputAreaPlaceholderColor: null } ], defaultSet: { user: { name: 'You', icon: '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#e3e3e3"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M12 6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2m0 10c2.7 0 5.8 1.29 6 2H6c.23-.72 3.31-2 6-2m0-12C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 10c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>', textcolor: null, font: null, bubbleBgColor: null, bubblePadding: "6px 10px", bubbleBorderRadius: "10px", bubbleMaxWidth: null, standingImage: null }, assistant: { name: 'ChatGPT', icon: '<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#e3e3e3"><g><rect fill="none" height="24" width="24"/></g><g><g><path d="M19.94,9.06C19.5,5.73,16.57,3,13,3C9.47,3,6.57,5.61,6.08,9l-1.93,3.48C3.74,13.14,4.22,14,5,14h1l0,2c0,1.1,0.9,2,2,2h1 v3h7l0-4.68C18.62,15.07,20.35,12.24,19.94,9.06z M14.89,14.63L14,15.05V19h-3v-3H8v-4H6.7l1.33-2.33C8.21,7.06,10.35,5,13,5 c2.76,0,5,2.24,5,5C18,12.09,16.71,13.88,14.89,14.63z"/><path d="M12.5,12.54c-0.41,0-0.74,0.31-0.74,0.73c0,0.41,0.33,0.74,0.74,0.74c0.42,0,0.73-0.33,0.73-0.74 C13.23,12.85,12.92,12.54,12.5,12.54z"/><path d="M12.5,7c-1.03,0-1.74,0.67-2,1.45l0.96,0.4c0.13-0.39,0.43-0.86,1.05-0.86c0.95,0,1.13,0.89,0.8,1.36 c-0.32,0.45-0.86,0.75-1.14,1.26c-0.23,0.4-0.18,0.87-0.18,1.16h1.06c0-0.55,0.04-0.65,0.13-0.82c0.23-0.42,0.65-0.62,1.09-1.27 c0.4-0.59,0.25-1.38-0.01-1.8C13.95,7.39,13.36,7,12.5,7z"/></g></g></svg>', textcolor: null, font: null, bubbleBgColor: null, bubblePadding: "6px 10px", bubbleBorderRadius: "10px", bubbleMaxWidth: null, standingImage: null }, windowBgColor: null, windowBgImage: null, windowBgSize: "cover", windowBgPosition: "center center", windowBgRepeat: "no-repeat", windowBgAttachment: "scroll", inputAreaBgColor: null, inputAreaTextColor: null, inputAreaPlaceholderColor: null } }; // ---- Global Constants ---- const CONFIG_KEY = 'cpta_config'; const ICON_MARGIN = 16; const STANDING_IMAGE_Z_INDEX = 'auto'; const MAX_STANDING_IMAGES_RETRIES = 10; const STANDING_IMAGES_RETRY_INTERVAL = 250; // ---- Common Settings for Modal Functions ---- const MODAL_WIDTH = 440; const MODAL_PADDING = 4; const MODAL_RADIUS = 8; const MODAL_BTN_RADIUS = 5; const MODAL_BTN_FONT_SIZE = 13; const MODAL_BTN_PADDING = '5px 16px'; const MODAL_TITLE_MARGIN_BOTTOM = 8; const MODAL_BTN_GROUP_GAP = 8; const MODAL_TEXTAREA_HEIGHT = 200; // ---- CSS Selectors ---- const SELECTORS = { SIDEBAR_WIDTH_TARGET: 'div[id="stage-slideover-sidebar"]', CHAT_CONTENT_MAX_WIDTH: 'div[class*="--thread-content-max-width"]', CHAT_MAIN_AREA_BG_TARGET: 'main#main', BUTTON_SHARE_CHAT: '[data-testid="share-chat-button"]', USER_BUBBLE_CSS_TARGET: 'div[data-message-author-role="user"] div:has(> .whitespace-pre-wrap)', USER_TEXT_CONTENT_CSS_TARGET: 'div[data-message-author-role="user"] .whitespace-pre-wrap', ASSISTANT_BUBBLE_MD_CSS_TARGET: 'div[data-message-author-role="assistant"] div:has(> .markdown)', ASSISTANT_MARKDOWN_CSS_TARGET: 'div[data-message-author-role="assistant"] .markdown', ASSISTANT_WHITESPACE_CSS_TARGET: 'div[data-message-author-role="assistant"] .whitespace-pre-wrap', INPUT_AREA_BG_TARGET: 'form[data-type="unified-composer"] > div:first-child', INPUT_TEXT_FIELD_TARGET: 'div.ProseMirror#prompt-textarea', INPUT_PLACEHOLDER_TARGET: 'div.ProseMirror#prompt-textarea p.placeholder[data-placeholder]', MESSAGE_CONTAINER_OBSERVER_TARGET: 'div[class*="--composer-overlap-px"]', MESSAGE_AUTHOR_ROLE_ATTR: '[data-message-author-role]', PROJECT_NAME_TITLE_OBSERVER_TARGET: 'title', }; // ================================================================================= // SECTION: Global State Management // Description: Defines and manages the global state of the script. // ================================================================================= const state = { CPTA_CONFIG: null, themeStyleElem: null, lastURL: null, lastProject: null, lastAppliedThemeSet: null, globalProjectObserver: null, currentProjectNameSourceObserver: null, currentObservedProjectNameSource: null, lastObservedProjectName: null, containerObserver: null, currentMsgContainer: null, currentMessageMutator: null, cachedProjectName: null, cachedThemeSet: null }; let standingImagesRetryCount = 0; // ================================================================================= // SECTION: Utility Functions // Description: General helper functions used across the script. // ================================================================================= /** * Debounces a function, delaying its execution until after a certain time has passed * since the last time it was invoked. * @param {Function} func - The function to debounce. * @param {number} delay - The delay in milliseconds. * @returns {Function} The debounced function. */ function debounce(func, delay) { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), delay); }; } /** * Creates a CSS-compatible url() value from an icon string. * Converts SVG strings to a data URL, otherwise returns a standard url(). * @param {string} icon - The icon string (SVG or URL). * @returns {string} A CSS url() value. */ function createIconCssUrl(icon) { if (!icon) return 'none'; if (/^<svg\b/i.test(icon.trim())) { // Encode SVG for data URL const encodedSvg = encodeURIComponent(icon .replace(/"/g, "'") // Use single quotes .replace(/\s+/g, ' ') // Minify whitespace ).replace(/[()]/g, (c) => `%${c.charCodeAt(0).toString(16)}`); // Escape parentheses return `url("data:image/svg+xml,${encodedSvg}")`; } // Assume it's a regular URL return `url(${icon})`; } /** * Formats a string so that it is valid as a CSS background-image value. * @param {string | null} value * @returns {string | null} formatted value */ function formatCssBgImageValue(value) { if (!value) return null; const trimmedVal = String(value).trim(); // If it is already in the form of a CSS function, return it as is. if (/^[a-z-]+\(.*\)$/i.test(trimmedVal)) { return trimmedVal; } const escapedVal = trimmedVal.replace(/"/g, '\\"'); return `url("${escapedVal}")`; } // ================================================================================= // SECTION: Configuration Management (GM Storage) // Description: Functions for loading and saving script configuration using Greasemonkey storage. // ================================================================================= /** * Loads the configuration object from GM_getValue. * Uses defaultObj if no saved config is found or if parsing fails. * @param {string} key - The storage key for the configuration. * @param {object} defaultObj - The default configuration object. * @returns {Promise<object>} A promise that resolves to the loaded or default configuration object. */ async function loadConfig(key, defaultObj) { try { const raw = await GM_getValue(key); if (raw) { return JSON.parse(raw); } else { console.log('CPTA: No saved config found. Using default config.'); return JSON.parse(JSON.stringify(defaultObj)); } } catch (e) { console.error('CPTA: Failed to load or parse config. Using default config. Error:', e); return JSON.parse(JSON.stringify(defaultObj)); } } /** * Saves the configuration object to GM_setValue. * @param {string} key - The storage key for the configuration. * @param {object} obj - The configuration object to save. * @returns {Promise<void>} A promise that resolves when the configuration is saved. */ async function saveConfig(key, obj) { await GM_setValue(key, JSON.stringify(obj)); } /** * Gets the icon size from the configuration object. * @param {object} cfg - The configuration object. * @returns {number} The icon size. */ function getIconSizeFromConfig(cfg) { if (cfg && cfg.options && typeof cfg.options.icon_size === "number") { return cfg.options.icon_size; } return DEFAULT_ICON_SIZE; } // ================================================================================= // SECTION: Theme and Actor Configuration Logic // Description: Functions to determine the current project, active theme set, and actor-specific configurations. // ================================================================================= /** * Gets the current project name from the document title and updates the cache. * If the project name changes, it clears the cached theme set. * @returns {string} The current project name. */ function getProjectNameAndCache() { const currentName = document.title.trim(); if (currentName !== state.cachedProjectName) { state.cachedProjectName = currentName; state.cachedThemeSet = null; } return state.cachedProjectName; } /** * Validates the 'projects' array in each theme set and the default set during config import. * Only RegExp objects or strings in the /pattern/flags format are allowed. * Throws an error if any entry is not a valid regular expression or does not use the correct string format. * This function should be called immediately after importing (parsing) external config JSON. * * @param {Array<object>} themeSets - Array of theme set objects to validate. * @param {object} defaultSet - The default theme set object to validate. * @throws {Error} If any projects entry is not a RegExp or a valid /pattern/flags string. */ function validateProjectsConfigOnImport(themeSets, defaultSet) { // Validate each theme set for (const set of themeSets ?? []) { if (!Array.isArray(set.projects)) continue; for (const p of set.projects) { if (typeof p === 'string') { // Only allow strings matching /pattern/flags format if (!/^\/.*\/[gimsuy]*$/.test(p)) { throw new Error( `All projects entries must be a /pattern/flags-style string or RegExp object. Invalid value: ${p}` ); } // Additional check: confirm the RegExp constructor does not throw const lastSlash = p.lastIndexOf('/'); const pattern = p.slice(1, lastSlash); const flags = p.slice(lastSlash + 1); try { new RegExp(pattern, flags); } catch (e) { throw new Error( `Invalid regular expression in projects: ${p}\n${e}` ); } } else if (!(p instanceof RegExp)) { throw new Error( 'All projects entries must be RegExp objects or /pattern/flags-style strings.' ); } } } // Validate the default set if (defaultSet && Array.isArray(defaultSet.projects)) { for (const p of defaultSet.projects) { if (typeof p === 'string') { if (!/^\/.*\/[gimsuy]*$/.test(p)) { throw new Error( `All defaultSet.projects entries must be a /pattern/flags-style string or RegExp object. Invalid value: ${p}` ); } const lastSlash = p.lastIndexOf('/'); const pattern = p.slice(1, lastSlash); const flags = p.slice(lastSlash + 1); try { new RegExp(pattern, flags); } catch (e) { throw new Error( `Invalid regular expression in defaultSet.projects: ${p}\n${e}` ); } } else if (!(p instanceof RegExp)) { throw new Error( 'All defaultSet.projects entries must be RegExp objects or /pattern/flags-style strings.' ); } } } } /** * Retrieves the theme set applicable to the current project. * Uses cached theme set if available and project name hasn't changed. * @returns {object} The applicable theme set object. */ function getThemeSet() { //console.log('[CPTA Debug] getThemeSet: CPTA_CONFIG being used', JSON.stringify(state.CPTA_CONFIG)); getProjectNameAndCache(); if (state.cachedThemeSet) { return state.cachedThemeSet; } const regexArr = []; for (const set of state.CPTA_CONFIG.themeSets ?? []) { for (const proj of set.projects ?? []) { if (typeof proj === 'string') { if (/^\/.*\/[gimsuy]*$/.test(proj)) { const lastSlash = proj.lastIndexOf('/'); const pattern = proj.slice(1, lastSlash); const flags = proj.slice(lastSlash + 1); try { regexArr.push({ pattern: new RegExp(pattern, flags), set }); } catch (e) { /* ignore invalid regex strings in config */ } } else { throw new Error(`[CPTA] projects entry must be a /pattern/flags string: ${proj}`); } } else if (proj instanceof RegExp) { regexArr.push({ pattern: new RegExp(proj.source, proj.flags), set }); } } } const name = state.cachedProjectName; const regexHit = regexArr.find(r => r.pattern.test(name)); const resultSet = regexHit ? regexHit.set : state.CPTA_CONFIG.defaultSet; state.cachedThemeSet = resultSet; //console.log('[CPTA Debug] getThemeSet: Final resultSet (baseSet):', JSON.stringify(resultSet)); return resultSet; } /** * Gets the configuration for a specific actor (user/assistant) based on the * current theme set and default settings. * @param {string} actor - The actor type ('user' or 'assistant'). * @param {object} set - The current theme set. * @param {object} defaultSet - The default theme set from the global configuration. * @returns {object} The resolved actor configuration. */ function getActorConfig(actor, set, defaultSet) { const currentActorSet = set[actor] ?? {}; const defaultActorSet = defaultSet[actor] ?? {}; return { name: currentActorSet.name ?? defaultActorSet.name, icon: currentActorSet.icon ?? defaultActorSet.icon, textcolor: currentActorSet.textcolor, font: currentActorSet.font ?? defaultActorSet.font, bubbleBgColor: currentActorSet.bubbleBgColor ?? defaultActorSet.bubbleBgColor, bubblePadding: currentActorSet.bubblePadding ?? defaultActorSet.bubblePadding, bubbleBorderRadius: currentActorSet.bubbleBorderRadius ?? defaultActorSet.bubbleBorderRadius, bubbleMaxWidth: currentActorSet.bubbleMaxWidth ?? defaultActorSet.bubbleMaxWidth, standingImage: currentActorSet.standingImage ?? defaultActorSet.standingImage, }; } // ================================================================================= // SECTION: DOM Manipulation and Styling - Core Theme Application // Description: Functions responsible for generating and applying theme CSS, // managing avatar injection, and handling standing images. // ================================================================================= /** * Updates CSS custom properties on the :root element with the current theme's values. * @param {object} baseSet - The base theme set. * @param {object} userConf - The resolved user configuration. * @param {object} assistantConf - The resolved assistant configuration. * @param {object} defaultFullConf - The full default configuration for fallback. */ function updateThemeVars(baseSet, userConf, assistantConf, defaultFullConf) { const rootStyle = document.documentElement.style; const themeVars = { // User '--cpta-user-name': userConf.name ? `'${userConf.name.replace(/'/g, "\\'")}'` : null, '--cpta-user-icon': createIconCssUrl(userConf.icon), '--cpta-user-textcolor': userConf.textcolor ?? null, '--cpta-user-font': userConf.font ?? null, '--cpta-user-bubble-bg': userConf.bubbleBgColor ?? null, '--cpta-user-bubble-padding': userConf.bubblePadding ?? null, '--cpta-user-bubble-radius': userConf.bubbleBorderRadius ?? null, '--cpta-user-bubble-maxwidth': userConf.bubbleMaxWidth ?? null, '--cpta-user-bubble-margin-left': userConf.bubbleMaxWidth ? 'auto' : null, '--cpta-user-bubble-margin-right': userConf.bubbleMaxWidth ? '0' : null, // Assistant '--cpta-assistant-name': assistantConf.name ? `'${assistantConf.name.replace(/'/g, "\\'")}'` : null, '--cpta-assistant-icon': createIconCssUrl(assistantConf.icon), '--cpta-assistant-textcolor': assistantConf.textcolor ?? null, '--cpta-assistant-font': assistantConf.font ?? null, '--cpta-assistant-bubble-bg': assistantConf.bubbleBgColor ?? null, '--cpta-assistant-bubble-padding': assistantConf.bubblePadding ?? null, '--cpta-assistant-bubble-radius': assistantConf.bubbleBorderRadius ?? null, '--cpta-assistant-bubble-maxwidth': assistantConf.bubbleMaxWidth ?? null, '--cpta-assistant-margin-right': assistantConf.bubbleMaxWidth ? 'auto' : null, '--cpta-assistant-margin-left': assistantConf.bubbleMaxWidth ? '0' : null, // Window/input '--cpta-window-bg-color': baseSet.windowBgColor ?? defaultFullConf.windowBgColor, '--cpta-window-bg-image': formatCssBgImageValue(baseSet.windowBgImage ?? defaultFullConf.windowBgImage), '--cpta-window-bg-size': baseSet.windowBgSize ?? defaultFullConf.windowBgSize, '--cpta-window-bg-pos': baseSet.windowBgPosition ?? defaultFullConf.windowBgPosition, '--cpta-window-bg-repeat': baseSet.windowBgRepeat ?? defaultFullConf.windowBgRepeat, '--cpta-window-bg-attach': baseSet.windowBgAttachment ?? defaultFullConf.windowBgAttachment, '--cpta-input-bg': baseSet.inputAreaBgColor ?? defaultFullConf.inputAreaBgColor, '--cpta-input-color': baseSet.inputAreaTextColor ?? defaultFullConf.inputAreaTextColor, '--cpta-input-ph-color': baseSet.inputAreaPlaceholderColor ?? defaultFullConf.inputAreaPlaceholderColor, }; for (const [key, value] of Object.entries(themeVars)) { if (value !== null && value !== undefined) { rootStyle.setProperty(key, value); } else { rootStyle.removeProperty(key); } } } /** * Creates a static CSS template string that uses CSS variables for theming. * This is injected into the page only once. * @returns {string} The static CSS ruleset. */ function createThemeCSSTemplate() { return ` /* --- Static Base Styles --- */ /* These rules are always applied regardless of the theme. */ ${SELECTORS.USER_BUBBLE_CSS_TARGET}, ${SELECTORS.ASSISTANT_BUBBLE_MD_CSS_TARGET}, div[data-message-author-role="assistant"] div:has(> .whitespace-pre-wrap):not(${SELECTORS.ASSISTANT_BUBBLE_MD_CSS_TARGET}) { box-sizing: border-box; } #page-header, ${SELECTORS.BUTTON_SHARE_CHAT} { background: transparent; } ${SELECTORS.BUTTON_SHARE_CHAT}:hover { background-color: var(--interactive-bg-secondary-hover); } #fixedTextUIRoot, #fixedTextUIRoot * { color: inherit; } `; } /** * Applies the generated theme CSS to a <style> element in the document head. * It only updates the CSS if the themeId (derived from theme content) has changed. */ function applyTheme() { // --- 1. Get or create style elements --- // Static styles (always present) if (!state.themeStyleElem) { state.themeStyleElem = document.createElement('style'); state.themeStyleElem.id = 'cpta-theme-style'; state.themeStyleElem.textContent = createThemeCSSTemplate(); document.head.appendChild(state.themeStyleElem); } // Dynamic rules (content changes based on theme) const dynamicRulesStyleId = 'cpta-dynamic-rules-style'; let dynamicRulesStyleElem = document.getElementById(dynamicRulesStyleId); if (!dynamicRulesStyleElem) { dynamicRulesStyleElem = document.createElement('style'); dynamicRulesStyleElem.id = dynamicRulesStyleId; document.head.appendChild(dynamicRulesStyleElem); } // --- 2. Get current theme config --- const baseSet = getThemeSet(); const userConf = getActorConfig('user', baseSet, state.CPTA_CONFIG.defaultSet); const assistantConf = getActorConfig('assistant', baseSet, state.CPTA_CONFIG.defaultSet); const defaultFullConf = state.CPTA_CONFIG.defaultSet; // --- 3. Build dynamic CSS rules based on config --- const dynamicRules = []; const assistantBubbleSelector = `${SELECTORS.ASSISTANT_BUBBLE_MD_CSS_TARGET}, div[data-message-author-role="assistant"] div:has(> .whitespace-pre-wrap):not(${SELECTORS.ASSISTANT_BUBBLE_MD_CSS_TARGET})`; // User Bubble if (userConf.textcolor) { dynamicRules.push(`${SELECTORS.USER_TEXT_CONTENT_CSS_TARGET} { color: var(--cpta-user-textcolor); }`); } if (userConf.font) { dynamicRules.push(`${SELECTORS.USER_TEXT_CONTENT_CSS_TARGET} { font-family: var(--cpta-user-font); }`); } if (userConf.bubbleBgColor) { dynamicRules.push(`${SELECTORS.USER_BUBBLE_CSS_TARGET} { background-color: var(--cpta-user-bubble-bg); }`); } if (userConf.bubblePadding) { dynamicRules.push(`${SELECTORS.USER_BUBBLE_CSS_TARGET} { padding: var(--cpta-user-bubble-padding); }`); } if (userConf.bubbleBorderRadius) { dynamicRules.push(`${SELECTORS.USER_BUBBLE_CSS_TARGET} { border-radius: var(--cpta-user-bubble-radius); }`); } if (userConf.bubbleMaxWidth) { dynamicRules.push(`${SELECTORS.USER_BUBBLE_CSS_TARGET} { max-width: var(--cpta-user-bubble-maxwidth); margin-left: var(--cpta-user-bubble-margin-left); margin-right: var(--cpta-user-bubble-margin-right); }`); } // Assistant Bubble if (assistantConf.font) { dynamicRules.push(`${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET}, ${SELECTORS.ASSISTANT_WHITESPACE_CSS_TARGET} { font-family: var(--cpta-assistant-font); }`); } if (assistantConf.bubbleBgColor) { dynamicRules.push(`${assistantBubbleSelector} { background-color: var(--cpta-assistant-bubble-bg); }`); } if (assistantConf.bubblePadding) { dynamicRules.push(`${assistantBubbleSelector} { padding: var(--cpta-assistant-bubble-padding); }`); } if (assistantConf.bubbleBorderRadius) { dynamicRules.push(`${assistantBubbleSelector} { border-radius: var(--cpta-assistant-bubble-radius); }`); } if (assistantConf.bubbleMaxWidth) { dynamicRules.push(`${assistantBubbleSelector} { max-width: var(--cpta-assistant-bubble-maxwidth); margin-right: var(--cpta-assistant-margin-right); margin-left: var(--cpta-assistant-margin-left); }`); } if (assistantConf.textcolor) { const selectors = [ `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} p`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} h1`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} h2`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} h3`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} h4`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} h5`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} h6`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} ul li`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} ol li`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} ul li::marker`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} ol li::marker`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} strong`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} em`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} blockquote`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} table`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} th`, `${SELECTORS.ASSISTANT_MARKDOWN_CSS_TARGET} td` ]; dynamicRules.push(`${selectors.join(', ')} { color: var(--cpta-assistant-textcolor); }`); } // Window Background if (baseSet.windowBgColor) { dynamicRules.push(`${SELECTORS.CHAT_MAIN_AREA_BG_TARGET} { background-color: var(--cpta-window-bg-color); }`); } if (baseSet.windowBgImage) { dynamicRules.push(`${SELECTORS.CHAT_MAIN_AREA_BG_TARGET} { background-image: var(--cpta-window-bg-image); background-size: var(--cpta-window-bg-size); background-position: var(--cpta-window-bg-pos); background-repeat: var(--cpta-window-bg-repeat); background-attachment: var(--cpta-window-bg-attach); }`); } // Input Area if (baseSet.inputAreaBgColor) { dynamicRules.push(`${SELECTORS.INPUT_AREA_BG_TARGET} { background-color: var(--cpta-input-bg); }`); dynamicRules.push(`${SELECTORS.INPUT_TEXT_FIELD_TARGET} { background-color: transparent; }`); } if (baseSet.inputAreaTextColor) { dynamicRules.push(`${SELECTORS.INPUT_TEXT_FIELD_TARGET} { color: var(--cpta-input-color); }`); } if (baseSet.inputAreaPlaceholderColor) { dynamicRules.push(`${SELECTORS.INPUT_PLACEHOLDER_TARGET} { color: var(--cpta-input-ph-color); }`); } // --- 4. Apply rules and variables --- dynamicRulesStyleElem.textContent = dynamicRules.join('\n'); updateThemeVars(baseSet, userConf, assistantConf, defaultFullConf); } // ---- Avatar Management ---- /** * Injects or updates the avatar (icon and name) for a given message element. * Uses a data attribute on the message element for caching to avoid redundant updates. * @param {HTMLElement} msgElem - The message element with 'data-message-author-role'. */ function injectAvatar(msgElem) { const role = msgElem.getAttribute('data-message-author-role'); if (!role) return; const msgWrapper = msgElem.closest('div'); if (!msgWrapper) return; // Do nothing if the avatar container already exists if (msgWrapper.querySelector('.side-avatar-container')) return; msgWrapper.classList.add('chat-wrapper'); // Create a structural container without specific styles or content const container = document.createElement('div'); container.className = 'side-avatar-container'; const iconWrapper = document.createElement('span'); iconWrapper.className = 'side-avatar-icon'; const nameDiv = document.createElement('div'); nameDiv.className = 'side-avatar-name'; // The actual name and icon are set by CSS via ::after and background-image container.append(iconWrapper, nameDiv); msgWrapper.appendChild(container); // Set min-height to accommodate the avatar requestAnimationFrame(() => { if (nameDiv.offsetHeight && state.CPTA_CONFIG.options.icon_size) { msgWrapper.style.minHeight = (state.CPTA_CONFIG.options.icon_size + nameDiv.offsetHeight) + "px"; } }); } /** * Injects or updates the global CSS styles required for avatars. */ function injectAvatarStyle() { const styleId = 'cpta-avatar-style'; let avatarStyle = document.getElementById(styleId); if (avatarStyle) avatarStyle.remove(); avatarStyle = document.createElement('style'); avatarStyle.id = styleId; avatarStyle.textContent = ` .side-avatar-container { position: absolute; top: 0; display: flex; flex-direction: column; align-items: center; width: ${state.CPTA_CONFIG.options.icon_size}px; pointer-events: none; white-space: normal; word-break: break-word; } .side-avatar-icon { width: ${state.CPTA_CONFIG.options.icon_size}px; height: ${state.CPTA_CONFIG.options.icon_size}px; border-radius: 50%; display: block; box-shadow: 0 0 6px rgba(0,0,0,0.2); background-size: cover; background-position: center; background-repeat: no-repeat; } .side-avatar-name { font-size: 0.75rem; text-align: center; margin-top: 4px; width: 100%; } .chat-wrapper[data-message-author-role="user"] .side-avatar-container { right: calc(-${state.CPTA_CONFIG.options.icon_size}px - ${ICON_MARGIN}px); } .chat-wrapper[data-message-author-role="assistant"] .side-avatar-container { left: calc(-${state.CPTA_CONFIG.options.icon_size}px - ${ICON_MARGIN}px); } /* --- Dynamic Content via CSS Variables --- */ .chat-wrapper[data-message-author-role="user"] .side-avatar-icon { background-image: var(--cpta-user-icon); } .chat-wrapper[data-message-author-role="user"] .side-avatar-name { color: var(--cpta-user-textcolor); } .chat-wrapper[data-message-author-role="user"] .side-avatar-name::after { content: var(--cpta-user-name); } .chat-wrapper[data-message-author-role="assistant"] .side-avatar-icon { background-image: var(--cpta-assistant-icon); } .chat-wrapper[data-message-author-role="assistant"] .side-avatar-name { color: var(--cpta-assistant-textcolor); } .chat-wrapper[data-message-author-role="assistant"] .side-avatar-name::after { content: var(--cpta-assistant-name); } `; document.head.appendChild(avatarStyle); } /** * Injects the CSS rules required for standing images. */ function injectStandingImageStyle() { const styleId = 'cpta-standing-image-style'; if (document.getElementById(styleId)) return; const style = document.createElement('style'); style.id = styleId; style.textContent = ` #cpta-standing-image-user, #cpta-standing-image-assistant { position: fixed; bottom: 0px; height: 100vh; min-height: 100px; max-height: 100vh; z-index: ${STANDING_IMAGE_Z_INDEX}; pointer-events: none; margin: 0; padding: 0; background-repeat: no-repeat; background-position: bottom center; background-size: contain; } #cpta-standing-image-assistant { display: var(--cpta-si-assistant-display, none); background-image: var(--cpta-si-assistant-bg-image, none); left: var(--cpta-si-assistant-left, 0px); width: var(--cpta-si-assistant-width, 0px); max-width: var(--cpta-si-assistant-width, 0px); mask-image: var(--cpta-si-assistant-mask, none); -webkit-mask-image: var(--cpta-si-assistant-mask, none); } #cpta-standing-image-user { display: var(--cpta-si-user-display, none); background-image: var(--cpta-si-user-bg-image, none); right: 0px; width: var(--cpta-si-user-width, 0px); max-width: var(--cpta-si-user-width, 0px); mask-image: var(--cpta-si-user-mask, none); -webkit-mask-image: var(--cpta-si-user-mask, none); } `; document.head.appendChild(style); } // ---- Standing Image Management ---- /** * Gets the current width of the sidebar. * @returns {number} The width of the sidebar in pixels, or 0 if not found/visible. */ function getSidebarWidth() { const sidebar = document.querySelector(SELECTORS.SIDEBAR_WIDTH_TARGET); if (sidebar && sidebar.offsetParent !== null) { const styleWidth = sidebar.style.width; if (styleWidth && styleWidth.endsWith('px')) { return parseInt(styleWidth, 10); } if (sidebar.offsetWidth) { return sidebar.offsetWidth; } } return 0; } /** * Updates the display and positioning of user and assistant standing images. * This function handles retries if essential DOM elements are not initially available. * @param {string|null} userImgVal - URL for the user's standing image. * @param {string|null} assistantImgVal - URL for the assistant's standing image. */ function updateStandingImages(userImgVal, assistantImgVal) { setupStandingImage('cpta-standing-image-user', userImgVal); setupStandingImage('cpta-standing-image-assistant', assistantImgVal); debouncedRecalculateStandingImagesLayout(); } /** * @param {string} id - element id * @param {string|null} imgVal - standingImage */ function setupStandingImage(id, imgVal) { if (!document.getElementById(id)) { const el = document.createElement('div'); el.id = id; document.body.appendChild(el); } const rootStyle = document.documentElement.style; const actorType = id.includes('assistant') ? 'assistant' : 'user'; const displayVar = `--cpta-si-${actorType}-display`; const bgImageVar = `--cpta-si-${actorType}-bg-image`; const bgVal = formatCssBgImageValue(imgVal); if (!bgVal) { rootStyle.setProperty(displayVar, 'none'); rootStyle.removeProperty(bgImageVar); return; } rootStyle.setProperty(displayVar, 'block'); rootStyle.setProperty(bgImageVar, bgVal); } /** * Debounced function to recalculate and update the layout of standing images. * Typically called on window resize or sidebar resize. */ const debouncedRecalculateStandingImagesLayout = debounce(() => { const rootStyle = document.documentElement.style; const chatContent = document.querySelector(SELECTORS.CHAT_CONTENT_MAX_WIDTH); if (!chatContent) { if (standingImagesRetryCount < MAX_STANDING_IMAGES_RETRIES) { standingImagesRetryCount++; setTimeout(debouncedRecalculateStandingImagesLayout, STANDING_IMAGES_RETRY_INTERVAL); } else { console.log('[CPTA Debug] Layout calculation: Max retries reached for chatContent.'); standingImagesRetryCount = 0; } return; } standingImagesRetryCount = 0; const chatRect = chatContent.getBoundingClientRect(); const sidebarWidth = getSidebarWidth(); const windowWidth = window.innerWidth; const windowHeight = window.innerHeight; const iconSize = state.CPTA_CONFIG.options.icon_size; // Assistant (left) layout calculation const assistantWidth = Math.max(0, chatRect.left - (sidebarWidth + iconSize + (ICON_MARGIN * 2))); rootStyle.setProperty('--cpta-si-assistant-left', sidebarWidth + 'px'); rootStyle.setProperty('--cpta-si-assistant-width', assistantWidth + 'px'); // User (right) layout calculation const userWidth = Math.max(0, windowWidth - chatRect.right - (iconSize + (ICON_MARGIN * 2))); rootStyle.setProperty('--cpta-si-user-width', userWidth + 'px'); // Masking logic const maskValue = `linear-gradient(to bottom, transparent 0px, rgba(0,0,0,1) 60px, rgba(0,0,0,1) 100%)`; const assistantImg = document.getElementById('cpta-standing-image-assistant'); if (assistantImg && assistantImg.offsetHeight >= (windowHeight - 32)) { rootStyle.setProperty('--cpta-si-assistant-mask', maskValue); } else { rootStyle.setProperty('--cpta-si-assistant-mask', 'none'); } const userImg = document.getElementById('cpta-standing-image-user'); if (userImg && userImg.offsetHeight >= (windowHeight - 32)) { rootStyle.setProperty('--cpta-si-user-mask', maskValue); } else { rootStyle.setProperty('--cpta-si-user-mask', 'none'); } }, 250); /** * Updates the min-height of all chat message wrappers to accommodate avatars. * Called after settings changes that might affect avatar/name display. */ function updateAllChatWrapperHeight() { document.querySelectorAll('.chat-wrapper').forEach(msgWrapper => { const container = msgWrapper.querySelector('.side-avatar-container'); const nameDiv = container?.querySelector('.side-avatar-name'); if (container && nameDiv && state.CPTA_CONFIG?.options?.icon_size && nameDiv.offsetHeight) { msgWrapper.style.minHeight = (state.CPTA_CONFIG.options.icon_size + nameDiv.offsetHeight) + "px"; } }); } // ================================================================================= // SECTION: UI Elements - Settings Button and Modal // Description: Functions for creating and managing the settings button and configuration modal. // ================================================================================= /** * Ensures the common UI styles for the settings button and modal are injected. */ function ensureCommonUIStyle() { if (document.getElementById('cpta-settings-common-style')) return; const style = document.createElement('style'); style.id = 'cpta-settings-common-style'; style.textContent = ` #cpta-id-settings-btn { transition: background 0.12s, border-color 0.12s, box-shadow 0.12s; } #cpta-id-settings-btn:hover { background: var(--interactive-bg-secondary-hover) !important; border-color: var(--border-default, #888); box-shadow: 0 2px 8px var(--border-default, #3336); } .cpta-modal-button { background: var(--interactive-bg-tertiary-default); color: var(--text-primary); border: 1px solid var(--border-default); border-radius: var(--radius-md, ${MODAL_BTN_RADIUS}px); padding: ${MODAL_BTN_PADDING}; font-size: ${MODAL_BTN_FONT_SIZE}px; cursor: pointer; transition: background 0.12s; } .cpta-modal-button:hover { background: var(--interactive-bg-secondary-hover) !important; border-color: var(--border-default); } `; document.head.appendChild(style); } /** * Creates and manages the settings modal dialog. * @param {object} options - Options for the modal. * @param {string} options.modalId - The ID for the modal overlay element. * @param {string} options.titleText - The title text for the modal. * @param {Function} options.onSave - Async callback function executed when saving. * @param {Function} options.getCurrentConfig - Async function to get the current config for display. * @param {HTMLElement} options.anchorBtn - The button element to anchor the modal to. * @returns {HTMLElement} The modal overlay element. */ function setupSettingsModal({ modalId, titleText, onSave, getCurrentConfig, anchorBtn }) { let modalOverlay = document.getElementById(modalId); if (modalOverlay) return modalOverlay; modalOverlay = document.createElement('div'); modalOverlay.id = modalId; Object.assign(modalOverlay.style, { display: 'none', position: 'fixed', zIndex: '2147483648', left: '0', top: '0', width: '100vw', height: '100vh', background: 'rgba(0, 0, 0, 0.5)', pointerEvents: 'auto' }); const modalBox = document.createElement('div'); Object.assign(modalBox.style, { position: 'absolute', width: MODAL_WIDTH + 'px', padding: MODAL_PADDING + 'px', borderRadius: `var(--radius-lg, ${MODAL_RADIUS}px)`, background: 'var(--main-surface-primary)', color: 'var(--text-primary)', border: '1px solid var(--border-default)', boxShadow: 'var(--drop-shadow-lg, 0 4px 16px #00000026)' }); const modalTitle = document.createElement('h5'); modalTitle.innerText = titleText; Object.assign(modalTitle.style, { marginTop: '0', marginBottom: MODAL_TITLE_MARGIN_BOTTOM + 'px' }); const textarea = document.createElement('textarea'); Object.assign(textarea.style, { width: '100%', height: MODAL_TEXTAREA_HEIGHT + 'px', boxSizing: 'border-box', fontFamily: 'monospace', fontSize: '13px', marginBottom: '0', border: '1px solid var(--border-default)', background: 'var(--bg-primary)', color: 'var(--text-primary)' }); const msgDiv = document.createElement('div'); Object.assign(msgDiv.style, { color: 'var(--text-danger,#f33)', marginTop: '2px', minHeight: '4px' }); const btnGroup = document.createElement('div'); Object.assign(btnGroup.style, { display: 'flex', flexWrap: 'wrap', justifyContent: 'flex-end', gap: MODAL_BTN_GROUP_GAP + 'px', marginTop: '8px' }); const btnExport = document.createElement('button'); btnExport.type = 'button'; btnExport.innerText = 'Export'; btnExport.classList.add('cpta-modal-button'); const btnImport = document.createElement('button'); btnImport.type = 'button'; btnImport.innerText = 'Import'; btnImport.classList.add('cpta-modal-button'); const btnSave = document.createElement('button'); btnSave.type = 'button'; btnSave.innerText = 'Save'; btnSave.classList.add('cpta-modal-button'); const btnCancel = document.createElement('button'); btnCancel.type = 'button'; btnCancel.innerText = 'Cancel'; btnCancel.classList.add('cpta-modal-button'); btnGroup.append(btnExport, btnImport, btnSave, btnCancel); modalBox.append(modalTitle, textarea, btnGroup, msgDiv); modalOverlay.appendChild(modalBox); document.body.appendChild(modalOverlay); function closeModal() { modalOverlay.style.display = 'none'; } btnExport.addEventListener('click', async () => { try { const config = await getCurrentConfig(); const jsonString = JSON.stringify(config, null, 2); const filename = 'cpta_config.json'; const blob = new Blob([jsonString], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); msgDiv.textContent = 'Export successful.'; msgDiv.style.color = 'var(--text-accent, #66b5ff)'; } catch (e) { msgDiv.textContent = 'Export failed: ' + e.message; msgDiv.style.color = 'var(--text-danger,#f33)'; } }); btnImport.addEventListener('click', () => { const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'application/json'; fileInput.style.display = 'none'; document.body.appendChild(fileInput); fileInput.addEventListener('change', async (event) => { const file = event.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = async (e) => { try { const importedConfig = JSON.parse(e.target.result); textarea.value = JSON.stringify(importedConfig, null, 2); msgDiv.textContent = 'Import successful. Click "Save" to apply the themes.'; msgDiv.style.color = 'var(--text-accent, #66b5ff)'; } catch (err) { msgDiv.textContent = 'Import failed: ' + err.message; msgDiv.style.color = 'var(--text-danger,#f33)'; } finally { document.body.removeChild(fileInput); } }; reader.readAsText(file); } else { document.body.removeChild(fileInput); } }); fileInput.click(); }); btnSave.addEventListener('click', async () => { try { const obj = JSON.parse(textarea.value); // === Add validation here === try { validateProjectsConfigOnImport(obj.themeSets, obj.defaultSet); } catch (e) { msgDiv.textContent = 'Invalid projects array: ' + e.message; msgDiv.style.color = 'var(--text-danger,#f33)'; return; } await onSave(obj); closeModal(); } catch (e) { msgDiv.textContent = 'JSON parse error: ' + e.message; msgDiv.style.color = 'var(--text-danger,#f33)'; } }); btnCancel.addEventListener('click', closeModal); modalOverlay.addEventListener('mousedown', e => { if (e.target === modalOverlay) closeModal(); }); async function openModal() { let cfg = await getCurrentConfig(); textarea.value = JSON.stringify(cfg, null, 2); msgDiv.textContent = ''; if (anchorBtn && anchorBtn.getBoundingClientRect) { const btnRect = anchorBtn.getBoundingClientRect(); const margin = 8; let left = btnRect.left; let top = btnRect.bottom + 4; if (left + MODAL_WIDTH > window.innerWidth - margin) { left = window.innerWidth - MODAL_WIDTH - margin; } left = Math.max(left, margin); modalBox.style.left = left + 'px'; modalBox.style.top = top + 'px'; modalBox.style.transform = ''; } else { modalBox.style.left = '50%'; modalBox.style.top = '120px'; modalBox.style.transform = 'translateX(-50%)'; } modalOverlay.style.display = 'block'; } modalOverlay.open = openModal; modalOverlay.close = closeModal; return modalOverlay; } /** * Ensures the settings button is present in the UI. * Sets up the click handler to open the settings modal. */ function ensureSettingsBtn() { if (document.getElementById('cpta-id-settings-btn')) return; const btn = document.createElement('button'); btn.id = 'cpta-id-settings-btn'; btn.textContent = '⚙️'; btn.title = 'Settings (ChatGPT Project Theme Automator)'; Object.assign(btn.style, { position: 'fixed', top: '10px', right: '320px', zIndex: 99999, width: '32px', height: '32px', borderRadius: '50%', background: 'var(--interactive-bg-secondary-default)', border: '1px solid var(--interactive-border-secondary-default)', fontSize: '16px', cursor: 'pointer', boxShadow: 'var(--drop-shadow-xs, 0 1px 1px #0000000d)' }); document.body.appendChild(btn); const settingsModal = setupSettingsModal({ modalId: 'cpta-settings-modal', titleText: 'ChatGPT Project Theme Automator Settings', onSave: async (cfg) => { await saveConfig(CONFIG_KEY, cfg); state.CPTA_CONFIG = cfg; state.cachedThemeSet = null; state.CPTA_CONFIG.options.icon_size = getIconSizeFromConfig(cfg); injectAvatarStyle(); updateTheme(); updateAllChatWrapperHeight(); }, getCurrentConfig: () => Promise.resolve(state.CPTA_CONFIG), anchorBtn: document.getElementById('cpta-id-settings-btn') }); document.getElementById('cpta-id-settings-btn').onclick = () => { settingsModal.open(); }; } // Detect the disappearance of the button with MutationObserver and revive it const cptaBtnObserver = new MutationObserver(ensureSettingsBtn); cptaBtnObserver.observe(document.body, { childList: true, subtree: true }); // ================================================================================= // SECTION: DOM Observers and Event Listeners // Description: Sets up MutationObservers and event listeners to react to DOM changes, // URL changes, and other events. // ================================================================================= // ---- Message Container Observer ---- /** * Sets up a MutationObserver to watch for added messages within a given container * and injects avatars into them. * @param {HTMLElement|null} container - The message container element to observe. */ function setupMessageObserver(container) { if (!container || !container.isConnected) { state.currentMessageMutator?.disconnect(); state.currentMessageMutator = null; state.currentMsgContainer = null; return; } if (container === state.currentMsgContainer) return; state.currentMessageMutator?.disconnect(); state.currentMessageMutator = new MutationObserver(mutations => { for (const m of mutations) { for (const node of m.addedNodes) { if (node instanceof HTMLElement && node.hasAttribute('data-message-author-role')) { injectAvatar(node); } node.querySelectorAll?.('[data-message-author-role]').forEach(injectAvatar); } } }); state.currentMessageMutator.observe(container, { childList: true, subtree: true }); state.currentMsgContainer = container; } /** * Initializes the MutationObserver for detecting the main message container's * appearance or changes, then calls setupMessageObserver. */ function startMessageContainerObserver() { if (state.containerObserver) { return; } state.containerObserver?.disconnect(); let initialContainer = document.querySelector(SELECTORS.MESSAGE_CONTAINER_OBSERVER_TARGET) || Array.from(document.querySelectorAll('div')).find(div => div.querySelector(SELECTORS.MESSAGE_AUTHOR_ROLE_ATTR)); if (initialContainer) { console.log("CPTA: Initial message container found:", initialContainer); setupMessageObserver(initialContainer); initialContainer.querySelectorAll('[data-message-author-role]').forEach(injectAvatar); updateTheme(); } state.containerObserver = new MutationObserver(() => { const newContainer = document.querySelector(SELECTORS.MESSAGE_CONTAINER_OBSERVER_TARGET) || Array.from(document.querySelectorAll('div')).find(div => div.querySelector(SELECTORS.MESSAGE_AUTHOR_ROLE_ATTR)); if (newContainer && newContainer !== state.currentMsgContainer) { setupMessageObserver(newContainer); newContainer.querySelectorAll('[data-message-author-role]').forEach(injectAvatar); updateTheme(); } else if (!newContainer && state.currentMsgContainer) { setupMessageObserver(null); } }); state.containerObserver.observe(document.body, { childList: true, subtree: true }); } // ---- Project Name (Title) Observer ---- /** * Initializes MutationObservers to detect changes in the document title (project name) * and triggers a theme update. */ function startGlobalProjectElementObserver() { if (state.globalProjectObserver) return; state.globalProjectObserver = new MutationObserver(() => { const newTitle = document.querySelector(SELECTORS.PROJECT_NAME_TITLE_OBSERVER_TARGET); let targetElement = null; let targetTextContent = ''; if (newTitle) { targetElement = newTitle; targetTextContent = newTitle.textContent.trim(); } if (targetElement && targetElement !== state.currentObservedProjectNameSource) { state.currentProjectNameSourceObserver?.disconnect(); state.currentObservedProjectNameSource = null; state.lastObservedProjectName = null; state.currentProjectNameSourceObserver = new MutationObserver(() => { const currentText = (state.currentObservedProjectNameSource?.textContent || '').trim(); if (currentText !== state.lastObservedProjectName) { state.lastObservedProjectName = currentText; updateTheme(); } }); state.currentProjectNameSourceObserver.observe(targetElement, { childList: true, characterData: true }); state.currentObservedProjectNameSource = targetElement; state.lastObservedProjectName = targetTextContent; updateTheme(); } else if (!targetElement && state.currentObservedProjectNameSource) { state.currentProjectNameSourceObserver?.disconnect(); state.currentObservedProjectNameSource = null; state.lastObservedProjectName = null; updateTheme(); } }); state.globalProjectObserver.observe(document.body, { childList: true, subtree: true }); const initialTitle = document.querySelector(SELECTORS.PROJECT_NAME_TITLE_OBSERVER_TARGET); let initialTarget = null; if (initialTitle) { initialTarget = initialTitle; } if (initialTarget) { state.currentProjectNameSourceObserver = new MutationObserver(() => { const currentText = (state.currentObservedProjectNameSource?.textContent || '').trim(); if (currentText !== state.lastObservedProjectName) { state.lastObservedProjectName = currentText; updateTheme(); } }); state.currentProjectNameSourceObserver.observe(initialTarget, { childList: true, characterData: true }); state.currentObservedProjectNameSource = initialTarget; state.lastObservedProjectName = (initialTarget?.textContent || '').trim(); updateTheme(); } } // ---- Sidebar Resize Observer ---- /** * Initializes a ResizeObserver to detect changes in the sidebar width * and triggers recalculation of standing image layouts. * Also handles the appearance/disappearance of the sidebar itself via MutationObserver. */ const sidebarContainerObserver = new MutationObserver(() => { startSidebarResizeObserver(); }); sidebarContainerObserver.observe(document.body, { childList: true, subtree: true }); let sidebarResizeObserver = null; let lastSidebarElem = null; function startSidebarResizeObserver() { const sidebar = document.querySelector(SELECTORS.SIDEBAR_WIDTH_TARGET); if (!sidebar) { lastSidebarElem = null; if (sidebarResizeObserver) sidebarResizeObserver.disconnect(); return; } if (sidebar === lastSidebarElem) return; if (sidebarResizeObserver) sidebarResizeObserver.disconnect(); lastSidebarElem = sidebar; sidebarResizeObserver = new ResizeObserver(() => { debouncedRecalculateStandingImagesLayout(); }); sidebarResizeObserver.observe(sidebar); debouncedRecalculateStandingImagesLayout(); } // ---- URL Change Handling ---- /** * Handles URL changes (via history API or popstate) and triggers a theme update. * This is an IIFE that returns the actual handler function. */ const handleURLChange = (() => { let localLastURL = location.href; return () => { if (location.href !== localLastURL) { localLastURL = location.href; updateTheme(); } }; })(); for (const m of ['pushState', 'replaceState']) { const orig = history[m]; history[m] = function (...args) { orig.apply(this, args); handleURLChange(); }; } window.addEventListener('popstate', handleURLChange); // ================================================================================= // SECTION: Core Theme Update Logic // Description: The main function that orchestrates theme updates based on various triggers. // ================================================================================= /** * Main function to update the theme. * It determines if the URL, project, or theme content has changed and applies * necessary updates (CSS, standing images, avatars). * It also updates the `state.lastURL`, `state.lastProject`, and `state.lastAppliedThemeSet`. */ function updateTheme() { const currentLiveURL = location.href; const currentProjectName = state.cachedProjectName; let urlChanged = false; if (currentLiveURL !== state.lastURL) { urlChanged = true; state.lastURL = currentLiveURL; } let projectChanged = false; if (currentProjectName !== state.lastProject) { projectChanged = true; state.lastProject = currentProjectName; } const currentThemeSet = getThemeSet(); let contentChanged = false; if (currentThemeSet !== state.lastAppliedThemeSet) { contentChanged = true; state.lastAppliedThemeSet = currentThemeSet; } const themeShouldUpdate = urlChanged || projectChanged || contentChanged; if (themeShouldUpdate) { applyTheme(); const userConf = getActorConfig('user', currentThemeSet, state.CPTA_CONFIG.defaultSet); const assistantConf = getActorConfig('assistant', currentThemeSet, state.CPTA_CONFIG.defaultSet); updateStandingImages(userConf.standingImage, assistantConf.standingImage); } } // ================================================================================= // SECTION: Initialization // Description: Script entry point // ================================================================================= /** * Initializes the script: loads configuration, sets up UI elements, * observers, and event listeners. */ async function init() { state.CPTA_CONFIG = await loadConfig(CONFIG_KEY, DEFAULT_THEME_CONFIG); state.CPTA_CONFIG.options.icon_size = getIconSizeFromConfig(state.CPTA_CONFIG); injectAvatarStyle(); injectStandingImageStyle(); ensureCommonUIStyle(); ensureSettingsBtn(); startGlobalProjectElementObserver(); startMessageContainerObserver(); // Add resize listener for standing images window.addEventListener('resize', debouncedRecalculateStandingImagesLayout); startSidebarResizeObserver(); } // ---- Script Entry Point ---- init(); // ================================================================================= // SECTION: Debugging // Description: Debugging utilities. // ================================================================================= /** * Checks the validity of essential CSS selectors used by the script. * Callable from the browser console via `unsafeWindow.cptaCheckSelectors()`. * @returns {boolean} True if all checked selectors are found, false otherwise. */ if (typeof unsafeWindow !== 'undefined') { unsafeWindow.cptaCheckSelectors = function() { const selectorsToCheck = [ { selector: SELECTORS.SIDEBAR_WIDTH_TARGET, desc: "サイドバー (幅指定)" }, { selector: SELECTORS.CHAT_CONTENT_MAX_WIDTH, desc: "チャットコンテンツ幅基準要素" }, { selector: SELECTORS.CHAT_MAIN_AREA_BG_TARGET, desc: "チャットメインエリア (背景適用対象)" }, { selector: SELECTORS.USER_BUBBLE_CSS_TARGET, desc: "ユーザーメッセージバブル" }, { selector: SELECTORS.ASSISTANT_BUBBLE_MD_CSS_TARGET, desc: "アシスタントメッセージバブル (Markdown)" }, { selector: SELECTORS.MESSAGE_AUTHOR_ROLE_ATTR, desc: "任意のメッセージ要素 (アバター注入対象)" }, { selector: SELECTORS.INPUT_AREA_BG_TARGET, desc: "入力エリアの背景変更対象" }, { selector: SELECTORS.INPUT_TEXT_FIELD_TARGET, desc: "入力テキストフィールド" }, { selector: SELECTORS.INPUT_PLACEHOLDER_TARGET, desc: "入力エリアプレースホルダー" }, { selector: SELECTORS.MESSAGE_CONTAINER_OBSERVER_TARGET, desc: "メッセージコンテナ (Observer用)" }, { selector: SELECTORS.PROJECT_NAME_TITLE_OBSERVER_TARGET, desc: "ページタイトル (プロジェクト名取得用)" }, ]; let allOK = true; console.groupCollapsed("CPTA CSS Selector Check"); for (const {selector, desc} of selectorsToCheck) { const el = document.querySelector(selector); if (el) { console.log(`✅ [OK] "${selector}"\n description: ${desc}\n element found:`, el); } else { console.warn(`❌ [NG] "${selector}"\n description: ${desc}\n element NOT found.`); allOK = false; } } if (allOK) { console.log("🎉 CPTA: All essential selectors are currently valid!"); } else { console.warn("⚠️ CPTA: One or more essential selectors are NOT found. Theme might not apply correctly."); } console.groupEnd(); return allOK; }; console.log("CPTA: Debug function cptaCheckSelectors() is available via console (unsafeWindow.cptaCheckSelectors)."); } else { console.warn("CPTA: unsafeWindow is not available. Debug function cptaCheckSelectors() cannot be exposed to console."); } // 2025-05 Firefox対策: 不要になればこのIIFEごと削除 // [Firefox限定] チャット表示エリアの謎枠線消去 // Firefoxはoverflow-y-autoな要素に“アクセシビリティ枠線”を描画するため、overflow-x-hiddenで強制消去 // ※ 2025-05現在。今後UI仕様変更時は要再検証 (function() { // Firefox判定 if (!/firefox/i.test(navigator.userAgent)) return; // 対象クラスの選択セレクタ const SELECTOR = '.flex.h-full.flex-col.overflow-y-auto'; // スクロール枠線対策を適用 function fixOverflowXHidden() { for (const el of document.querySelectorAll(SELECTOR)) { // 既に設定済みなら無駄な再設定はしない if (el.style.overflowX !== 'hidden') el.style.overflowX = 'hidden'; } } // MutationObserverでDOM変化時にも自動対応 const observer = new MutationObserver(fixOverflowXHidden); // body直下でchildList, subtree監視 observer.observe(document.body, { childList: true, subtree: true }); // 初期実行(即時効果・ページ遷移直後対応) fixOverflowXHidden(); })(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址