| 1 | /** @type {CSSStyleSheet} */ |
| 2 | let dynamicStyleSheet = null; |
| 3 | /** @type {CSSStyleSheet} */ |
| 4 | let dynamicExtensionStyleSheet = null; |
| 5 | |
| 6 | /** |
| 7 | * An observer that will check if any new stylesheets are added to the head |
| 8 | * @type {MutationObserver} |
| 9 | */ |
| 10 | const observer = new MutationObserver(mutations => { |
| 11 | mutations.forEach(mutation => { |
| 12 | if (mutation.type !== 'childList') return; |
| 13 | |
| 14 | mutation.addedNodes.forEach(node => { |
| 15 | if (node instanceof HTMLLinkElement && node.tagName === 'LINK' && node.rel === 'stylesheet') { |
| 16 | node.addEventListener('load', () => { |
| 17 | try { |
| 18 | applyDynamicFocusStyles(node.sheet); |
| 19 | } catch (e) { |
| 20 | console.warn('Failed to process new stylesheet:', e); |
| 21 | } |
| 22 | }); |
| 23 | } |
| 24 | }); |
| 25 | }); |
| 26 | }); |
| 27 | |
| 28 | /** |
| 29 | * Generates dynamic focus styles based on the given stylesheet, taking its hover styles as reference |
| 30 | * |
| 31 | * @param {CSSStyleSheet} styleSheet - The stylesheet to process |
| 32 | * @param {object} [options] - Optional configuration options |
| 33 | * @param {boolean} [options.fromExtension=false] - Indicates if the styles are from an extension |
| 34 | */ |
| 35 | function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) { |
| 36 | /** @typedef {{ type: 'media'|'supports'|'container', conditionText: string }} WrapperCond */ |
| 37 | /** @type {{baseSelector: string, rule: CSSStyleRule, wrappers: WrapperCond[]}[]} */ |
| 38 | const hoverRules = []; |
| 39 | /** @type {Set<string>} */ |
| 40 | const focusRules = new Set(); |
| 41 | |
| 42 | const PLACEHOLDER = ':__PLACEHOLDER__'; |
| 43 | |
| 44 | /** |
| 45 | * Builds a stable signature string for a chain of wrapper conditions so we can distinguish |
| 46 | * identical selectors under different contexts (e.g., different @media queries) |
| 47 | * @param {WrapperCond[]} wrappers |
| 48 | * @returns {string} |
| 49 | */ |
| 50 | function wrapperSignature(wrappers) { |
| 51 | return wrappers.map(w => `${w.type}:${w.conditionText}`).join(';'); |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Processes the CSS rules and separates selectors for hover and focus |
| 56 | * @param {CSSRuleList} rules - The CSS rules to process |
| 57 | * @param {WrapperCond[]} wrappers - Current chain of wrapper conditions (@media/@supports/etc.) |
| 58 | */ |
| 59 | function processRules(rules, wrappers = []) { |
| 60 | Array.from(rules).forEach(rule => { |
| 61 | if (rule instanceof CSSImportRule) { |
| 62 | // Make sure that @import rules are processed recursively |
| 63 | // If the @import has media conditions, treat them as wrappers as well |
| 64 | /** @type {WrapperCond[]} */ |
| 65 | const extra = (rule.media && rule.media.mediaText) ? [{ type: 'media', conditionText: rule.media.mediaText }] : []; |
| 66 | processImportedStylesheet(rule.styleSheet, [...wrappers, ...extra]); |
| 67 | } else if (rule instanceof CSSStyleRule) { |
| 68 | // Separate multiple selectors on a rule |
| 69 | const selectors = rule.selectorText.split(',').map(s => s.trim()); |
| 70 | |
| 71 | // We collect all hover and focus rules to be able to later decide which hover rules don't have a matching focus rule |
| 72 | selectors.forEach(selector => { |
| 73 | const isHover = selector.includes(':hover'), isFocus = selector.includes(':focus'); |
| 74 | if (isHover && isFocus) { |
| 75 | // We currently do nothing here. Rules containing both hover and focus are very specific and should never be automatically touched |
| 76 | } else if (isHover) { |
| 77 | const baseSelector = selector.replace(/:hover/g, PLACEHOLDER).trim(); |
| 78 | hoverRules.push({ baseSelector, rule, wrappers: [...wrappers] }); |
| 79 | } else if (isFocus) { |
| 80 | // We need to make sure that we remember all existing :focus, :focus-within and :focus-visible rules |
| 81 | const baseSelector = selector.replace(/:focus(-within|-visible)?/g, PLACEHOLDER).trim(); |
| 82 | focusRules.add(`${baseSelector}|${wrapperSignature(wrappers)}`); |
| 83 | } |
| 84 | }); |
| 85 | } else if (rule instanceof CSSMediaRule) { |
| 86 | // Recursively process nested @media rules |
| 87 | processRules(rule.cssRules, [...wrappers, { type: 'media', conditionText: rule.conditionText }]); |
| 88 | } else if (rule instanceof CSSSupportsRule) { |
| 89 | // Recursively process nested @supports rules |
| 90 | processRules(rule.cssRules, [...wrappers, { type: 'supports', conditionText: rule.conditionText }]); |
| 91 | } else if (rule instanceof window.CSSContainerRule) { |
| 92 | // Recursively process nested @container rules (if supported by the browser) |
| 93 | // Note: conditionText contains the query like "(min-width: 300px)" or "style(color)" |
| 94 | // Using 'container' as the type ensures uniqueness separate from @media/@supports |
| 95 | processRules(rule.cssRules, [...wrappers, { type: 'container', conditionText: rule.conditionText }]); |
| 96 | } |
| 97 | }); |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * Processes the CSS rules of an imported stylesheet recursively |
| 102 | * @param {CSSStyleSheet} sheet - The imported stylesheet to process |
| 103 | * @param {WrapperCond[]} wrappers - Wrapper conditions inherited from (at)import media |
| 104 | */ |
| 105 | function processImportedStylesheet(sheet, wrappers = []) { |
| 106 | if (sheet && sheet.cssRules) { |
| 107 | processRules(sheet.cssRules, wrappers); |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | processRules(styleSheet.cssRules, []); |
| 112 | |
| 113 | /** @type {CSSStyleSheet} */ |
| 114 | let targetStyleSheet = null; |
| 115 | |
| 116 | // Now finally create the dynamic focus rules |
| 117 | hoverRules.forEach(({ baseSelector, rule, wrappers }) => { |
| 118 | if (!focusRules.has(`${baseSelector}|${wrapperSignature(wrappers)}`)) { |
| 119 | // Only initialize the dynamic stylesheet if needed |
| 120 | targetStyleSheet ??= getDynamicStyleSheet({ fromExtension }); |
| 121 | |
| 122 | // The closest keyboard-equivalent to :hover styling is utilizing the :focus-visible rule from modern browsers. |
| 123 | // It let's the browser decide whether a focus highlighting is expected and makes sense. |
| 124 | // So we take all :hover rules that don't have a manually defined focus rule yet, and create their |
| 125 | // :focus-visible counterpart, which will make the styling work the same for keyboard and mouse. |
| 126 | // If something like :focus-within or a more specific selector like `.blah:has(:focus-visible)` for elements inside, |
| 127 | // it should be manually defined in CSS. |
| 128 | const focusSelector = rule.selectorText.replace(/:hover/g, ':focus-visible'); |
| 129 | |
| 130 | // Skip pseudo-elements (::before, ::after, ::-webkit-scrollbar, etc.) |
| 131 | // as they cannot have :focus-visible appended (invalid CSS syntax) |
| 132 | if (focusSelector.includes('::')) { |
| 133 | return; |
| 134 | } |
| 135 | let focusRule = `${focusSelector} { ${rule.style.cssText} }`; |
| 136 | |
| 137 | // Wrap the generated rule into the same @media/@supports/@container chain (if any) |
| 138 | if (wrappers.length > 0) { |
| 139 | // Build nested blocks from outermost to innermost |
| 140 | // Example: @media (x) { @supports (y) { <rule> } } |
| 141 | focusRule = wrappers.reduceRight((inner, w) => { |
| 142 | if (w.type === 'media') return `@media ${w.conditionText} { ${inner} }`; |
| 143 | if (w.type === 'supports') return `@supports ${w.conditionText} { ${inner} }`; |
| 144 | if (w.type === 'container') return `@container ${w.conditionText} { ${inner} }`; |
| 145 | return inner; |
| 146 | }, focusRule); |
| 147 | } |
| 148 | |
| 149 | try { |
| 150 | targetStyleSheet.insertRule(focusRule, targetStyleSheet.cssRules.length); |
| 151 | } catch (e) { |
| 152 | console.warn('Failed to insert focus rule:', e); |
| 153 | } |
| 154 | } |
| 155 | }); |
| 156 | } |
| 157 | |
| 158 | /** |
| 159 | * Retrieves the stylesheet that should be used for dynamic rules |
| 160 | * |
| 161 | * @param {object} options - The options object |
| 162 | * @param {boolean} [options.fromExtension=false] - Indicates whether the rules are coming from extensions |
| 163 | * @return {CSSStyleSheet} The dynamic stylesheet |
| 164 | */ |
| 165 | function getDynamicStyleSheet({ fromExtension = false } = {}) { |
| 166 | if (fromExtension) { |
| 167 | if (!dynamicExtensionStyleSheet) { |
| 168 | const styleSheetElement = document.createElement('style'); |
| 169 | styleSheetElement.setAttribute('id', 'dynamic-extension-styles'); |
| 170 | document.head.appendChild(styleSheetElement); |
| 171 | dynamicExtensionStyleSheet = styleSheetElement.sheet; |
| 172 | } |
| 173 | return dynamicExtensionStyleSheet; |
| 174 | } else { |
| 175 | if (!dynamicStyleSheet) { |
| 176 | const styleSheetElement = document.createElement('style'); |
| 177 | styleSheetElement.setAttribute('id', 'dynamic-styles'); |
| 178 | document.head.appendChild(styleSheetElement); |
| 179 | dynamicStyleSheet = styleSheetElement.sheet; |
| 180 | } |
| 181 | return dynamicStyleSheet; |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | /** |
| 186 | * Initializes dynamic styles for ST |
| 187 | */ |
| 188 | export function initDynamicStyles() { |
| 189 | // Start observing the head for any new added stylesheets |
| 190 | observer.observe(document.head, { |
| 191 | childList: true, |
| 192 | subtree: true, |
| 193 | }); |
| 194 | |
| 195 | // Process all stylesheets on initial load |
| 196 | Array.from(document.styleSheets).forEach(sheet => { |
| 197 | try { |
| 198 | applyDynamicFocusStyles(sheet, { fromExtension: sheet.href?.toLowerCase().includes('scripts/extensions') == true }); |
| 199 | } catch (e) { |
| 200 | console.warn('Failed to process stylesheet on initial load:', e); |
| 201 | } |
| 202 | }); |
| 203 | } |