110 lines
3.0 KiB
TypeScript
110 lines
3.0 KiB
TypeScript
/**
|
|
* Theme custom CSS is global in the launcher. The advanced editor renders a
|
|
* single 1000 x 650 canvas at a time, so previews are scoped to that canvas
|
|
* instead of restyling the editor's own chrome.
|
|
*/
|
|
|
|
const ROOT_SELECTORS = /(^|[\s>+~,(])(?::root|html|body)(?=$|[\s>+~.#:[\],)])/g;
|
|
|
|
const splitSelectorList = (selectorText: string): string[] => {
|
|
const selectors: string[] = [];
|
|
let start = 0;
|
|
let depth = 0;
|
|
let quote = '';
|
|
let escaped = false;
|
|
|
|
for (let index = 0; index < selectorText.length; index += 1) {
|
|
const character = selectorText[index];
|
|
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (character === '\\') {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (quote) {
|
|
if (character === quote) quote = '';
|
|
continue;
|
|
}
|
|
if (character === '"' || character === "'") {
|
|
quote = character;
|
|
continue;
|
|
}
|
|
if (character === '(' || character === '[') {
|
|
depth += 1;
|
|
continue;
|
|
}
|
|
if ((character === ')' || character === ']') && depth > 0) {
|
|
depth -= 1;
|
|
continue;
|
|
}
|
|
if (character === ',' && depth === 0) {
|
|
selectors.push(selectorText.slice(start, index));
|
|
start = index + 1;
|
|
}
|
|
}
|
|
|
|
selectors.push(selectorText.slice(start));
|
|
return selectors;
|
|
};
|
|
|
|
const scopeSelector = (selector: string, scope: string): string => {
|
|
const trimmed = selector.trim();
|
|
if (!trimmed || trimmed.startsWith(scope)) return trimmed;
|
|
|
|
const rooted = trimmed.replace(
|
|
ROOT_SELECTORS,
|
|
(_match, boundary: string) => `${boundary}${scope}`
|
|
);
|
|
if (rooted.startsWith(scope)) return rooted;
|
|
return `${scope} ${rooted}`;
|
|
};
|
|
|
|
/** Prefix every selector in a comma-separated list with the editor canvas scope. */
|
|
export const scopeThemeSelectors = (selectorText: string, scope: string): string =>
|
|
splitSelectorList(selectorText)
|
|
.map((selector) => scopeSelector(selector, scope))
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
|
|
const scopeRule = (rule: CSSRule, scope: string) => {
|
|
if (rule.type === 1) {
|
|
const styleRule = rule as CSSStyleRule;
|
|
styleRule.selectorText = scopeThemeSelectors(styleRule.selectorText, scope);
|
|
return;
|
|
}
|
|
|
|
const grouping = rule as CSSGroupingRule;
|
|
if (grouping.cssRules) {
|
|
for (const child of Array.from(grouping.cssRules)) scopeRule(child, scope);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Parse custom CSS with the browser and scope its selectors to the preview
|
|
* canvas. `@font-face`, keyframes and at-rule blocks are preserved.
|
|
*/
|
|
export const scopeThemeCss = (css: string | undefined, scope: string): string => {
|
|
const source = css?.trim();
|
|
if (!source || typeof document === 'undefined') return '';
|
|
|
|
const style = document.createElement('style');
|
|
style.media = 'not all';
|
|
style.textContent = source;
|
|
document.head.appendChild(style);
|
|
|
|
try {
|
|
const sheet = style.sheet;
|
|
if (!sheet) return '';
|
|
const rules = Array.from(sheet.cssRules);
|
|
for (const rule of rules) scopeRule(rule, scope);
|
|
return rules.map((rule) => rule.cssText).join('\n');
|
|
} catch {
|
|
return '';
|
|
} finally {
|
|
style.remove();
|
|
}
|
|
};
|