import { Node as PMNode } from 'prosemirror-model'; import { NodeView } from 'prosemirror-view'; /** Content root the editor mounts the document under. Author CSS is scoped to * it so a rule such as `div { background: green }` styles the content only and * can never paint the editor chrome (toolbar, panels, surface) that shares the * same shadow root. */ const CONTENT_SCOPE = '.ProseMirror'; /** Advances past a quoted string starting at `i` (the opening quote), honoring * backslash escapes; returns the index just after the closing quote. */ function skipString(css: string, i: number): number { const quote = css[i]; i += 1; while (i < css.length) { if (css[i] === '\\') { i += 2; continue; } if (css[i] === quote) return i + 1; i += 1; } return i; } /** Splits on a top-level separator, ignoring ones inside (), [] or strings. */ function splitTopLevel(str: string, sep: string): string[] { const parts: string[] = []; let depth = 0; let buf = ''; for (let i = 0; i < str.length; i += 1) { const c = str[i]; if (c === '"' || c === "'") { const end = skipString(str, i); buf += str.slice(i, end); i = end - 1; continue; } if (c === '(' || c === '[') depth += 1; else if (c === ')' || c === ']') depth -= 1; if (c === sep && depth === 0) { parts.push(buf); buf = ''; continue; } buf += c; } parts.push(buf); return parts; } /** Confines one complex selector to the content root. `html`/`body`/`:root` * map onto the root itself. */ function scopeSelector(selector: string, scope: string): string { const sel = selector.trim(); if (!sel) return sel; if (/^(?:html|body|:root)$/i.test(sel)) return scope; const root = sel.match(/^(?:html|body|:root)\b\s*/i); return root ? `${scope} ${sel.slice(root[0].length)}`.trim() : `${scope} ${sel}`; } /** Confines a comma-separated selector list to the content root. */ function scopeSelectorList(list: string, scope: string): string { return splitTopLevel(list, ',') .map(part => scopeSelector(part, scope)) .filter(Boolean) .join(', '); } const NESTED_AT_RULES = new Set(['media', 'supports', 'container', 'layer', 'scope']); /** * Scopes every rule in a CSS block to the content root by prefixing selectors, * keeping each declaration block **verbatim**. Declarations are never re-parsed * (a CSSOM round-trip drops `var()` inside shorthands, e.g. gradient text), and * strings/nested braces are tracked so `content: "}"` can't derail it. Nested * conditionals (`@media`, `@supports`, …) recurse; other at-rules * (`@keyframes`, `@font-face`, …) pass through untouched. */ function scopeBlock(css: string, scope: string): string { let out = ''; let i = 0; const n = css.length; while (i < n) { let j = i; while (j < n) { const c = css[j]; if (c === '"' || c === "'") { j = skipString(css, j); continue; } if (c === '{' || c === ';') break; j += 1; } if (j >= n) { out += css.slice(i); break; } if (css[j] === ';') { // A statement at-rule (e.g. a stray @charset) — keep verbatim. out += css.slice(i, j + 1); i = j + 1; continue; } const prelude = css.slice(i, j).trim(); let depth = 1; let k = j + 1; while (k < n && depth > 0) { const c = css[k]; if (c === '"' || c === "'") { k = skipString(css, k); continue; } if (c === '{') depth += 1; else if (c === '}') depth -= 1; k += 1; } const body = css.slice(j + 1, k - 1); if (prelude.startsWith('@')) { const name = (prelude.match(/^@([a-z-]+)/i)?.[1] ?? '').toLowerCase(); out += NESTED_AT_RULES.has(name) ? `${prelude} {${scopeBlock(body, scope)}}` : `${prelude} {${body}}`; } else if (prelude) { out += `${scopeSelectorList(prelude, scope)} {${body}}`; } i = k; } return out; } /** Returns author CSS confined to the content root. */ function scopeCss(css: string, scope: string): string { if (!css.trim()) return ''; try { return scopeBlock(css, scope); } catch { return css; } } /** * Node view for an author `