Blame Raw
Cohee · 51ad27fb · · 203 lines (9.4 KB)
2 contributors
1/** @type {CSSStyleSheet} */
2let dynamicStyleSheet = null;
3/** @type {CSSStyleSheet} */
4let dynamicExtensionStyleSheet = null;
5
6/**
7 * An observer that will check if any new stylesheets are added to the head
8 * @type {MutationObserver}
9 */
10const 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 */
35function 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 */
165function 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 */
188export 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}