diff --git a/public/script.js b/public/script.js index 2f6814b13..7b15dc8d8 100644 --- a/public/script.js +++ b/public/script.js @@ -237,6 +237,7 @@ import { SlashCommandBrowser } from './scripts/slash-commands/SlashCommandBrowse import { initCustomSelectedSamplers, validateDisabledSamplers } from './scripts/samplerSelect.js'; import { DragAndDropHandler } from './scripts/dragdrop.js'; import { INTERACTABLE_CONTROL_CLASS, initKeyboard } from './scripts/keyboard.js'; +import { initDynamicStyles } from './scripts/dynamic-styles.js'; //exporting functions and vars for mods export { @@ -892,6 +893,7 @@ async function firstLoadInit() { sendSystemMessage(system_message_types.WELCOME); await getSettings(); initKeyboard(); + initDynamicStyles(); initTags(); await getUserAvatars(true, user_avatar); await getCharacters(); diff --git a/public/scripts/dynamic-styles.js b/public/scripts/dynamic-styles.js new file mode 100644 index 000000000..36e0faa01 --- /dev/null +++ b/public/scripts/dynamic-styles.js @@ -0,0 +1,152 @@ +/** @type {CSSStyleSheet} */ +let dynamicStyleSheet = null; +/** @type {CSSStyleSheet} */ +let dynamicExtensionStyleSheet = null; + +/** + * An observer that will check if any new stylesheets are added to the head + * @type {MutationObserver} + */ +const observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + if (mutation.type !== 'childList') return; + + mutation.addedNodes.forEach(node => { + if (node instanceof HTMLLinkElement && node.tagName === 'LINK' && node.rel === 'stylesheet') { + node.addEventListener('load', () => { + try { + applyDynamicFocusStyles(node.sheet); + } catch (e) { + console.warn('Failed to process new stylesheet:', e); + } + }); + } + }); + }); +}); + +/** + * Generates dynamic focus styles based on the given stylesheet, taking its hover styles as reference + * + * @param {CSSStyleSheet} styleSheet - The stylesheet to process + * @param {object} [options] - Optional configuration options + * @param {boolean} [options.fromExtension=false] - Indicates if the styles are from an extension + */ +function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) { + /** @type {{baseSelector: string, rule: CSSStyleRule}[]} */ + const hoverRules = []; + /** @type {Set} */ + const focusRules = new Set(); + + const PLACEHOLDER = ':__PLACEHOLDER__'; + + /** + * Processes the CSS rules and separates selectors for hover and focus + * @param {CSSRuleList} rules - The CSS rules to process + */ + function processRules(rules) { + Array.from(rules).forEach(rule => { + if (rule instanceof CSSImportRule) { + // Make sure that @import rules are processed recursively + processImportedStylesheet(rule.styleSheet); + } else if (rule instanceof CSSStyleRule) { + // Separate multiple selectors on a rule + const selectors = rule.selectorText.split(',').map(s => s.trim()); + + // We collect all hover and focus rules to be able to later decide which hover rules don't have a matching focus rule + selectors.forEach(selector => { + if (selector.includes(':hover')) { + const baseSelector = selector.replace(':hover', PLACEHOLDER).trim(); + hoverRules.push({ baseSelector, rule }); + } else if (selector.includes(':focus')) { + // We need to make sure that we both remember existing :focus and :focus-within rules + const baseSelector = selector.replace(':focus-within', PLACEHOLDER).replace(':focus', PLACEHOLDER).trim(); + focusRules.add(baseSelector); + } + }); + } else if (rule instanceof CSSMediaRule || rule instanceof CSSSupportsRule) { + // Recursively process nested rules + processRules(rule.cssRules); + } + }); + } + + /** + * Processes the CSS rules of an imported stylesheet recursively + * @param {CSSStyleSheet} sheet - The imported stylesheet to process + */ + function processImportedStylesheet(sheet) { + if (sheet && sheet.cssRules) { + processRules(sheet.cssRules); + } + } + + processRules(styleSheet.cssRules); + + /** @type {CSSStyleSheet} */ + let targetStyleSheet = null; + + // Now finally create the dynamic focus rules + hoverRules.forEach(({ baseSelector, rule }) => { + if (!focusRules.has(baseSelector)) { + // Only initialize the dynamic stylesheet if needed + targetStyleSheet ??= getDynamicStyleSheet({ fromExtension }); + + const focusSelector = rule.selectorText.replace(/:hover/g, ':focus'); + const focusRule = `${focusSelector} { ${rule.style.cssText} }`; + + try { + targetStyleSheet.insertRule(focusRule, targetStyleSheet.cssRules.length); + } catch (e) { + console.warn('Failed to insert focus rule:', e); + } + } + }); +} + +/** + * Retrieves the stylesheet that should be used for dynamic rules + * + * @param {object} options - The options object + * @param {boolean} [options.fromExtension=false] - Indicates whether the rules are coming from extensions + * @return {CSSStyleSheet} The dynamic stylesheet + */ +function getDynamicStyleSheet({ fromExtension = false } = {}) { + if (fromExtension) { + if (!dynamicExtensionStyleSheet) { + const styleSheetElement = document.createElement('style'); + styleSheetElement.setAttribute('id', 'dynamic-extension-styles'); + document.head.appendChild(styleSheetElement); + dynamicExtensionStyleSheet = styleSheetElement.sheet; + } + return dynamicExtensionStyleSheet; + } else { + if (!dynamicStyleSheet) { + const styleSheetElement = document.createElement('style'); + styleSheetElement.setAttribute('id', 'dynamic-styles'); + document.head.appendChild(styleSheetElement); + dynamicStyleSheet = styleSheetElement.sheet; + } + return dynamicStyleSheet; + } +} + +/** + * Initializes dynamic styles for ST + */ +export function initDynamicStyles() { + // Start observing the head for any new added stylesheets + observer.observe(document.head, { + childList: true, + subtree: true + }); + + // Process all stylesheets on initial load + Array.from(document.styleSheets).forEach(sheet => { + try { + applyDynamicFocusStyles(sheet, { fromExtension: sheet.href.toLowerCase().includes('scripts/extensions') }); + } catch (e) { + console.warn('Failed to process stylesheet on initial load:', e); + } + }); +}