// Highlighting, painted with the CSS Custom Highlight API rather than // spans, so the text stays one node per line and the page stays small. // Its ranges are static: a live Range is moved by every change to the // page, and a pull's files hold tens of thousands, so each file drawn // as the reader scrolls cost more than the last. /** The text in `el`, as one node, or nothing for an empty line. */ function textOf(el: Element): Text | null { el.normalize(); const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); return walker.nextNode() as Text | null; } /** * The highlight named `name`, made if there is none yet. A changed word's * mark, `xins` or `xdel`, is painted over the syntax's, its text in the * body's colour: what reads on it in every theme (harness/contrast.mjs). */ function named(name: string): Highlight { let h = CSS.highlights.get(name); if (!h) CSS.highlights.set(name, (h = new Highlight())); if (name === 'xins' || name === 'xdel') h.priority = 1; return h; } /** * Paints the ranges `ranges` encodes over the text of the `code` * elements in `el`, taken as if joined by newlines. `ranges` is * `name:start,length,start,length…;…`, each start counted from the * end of the range before it. */ function paint(el: Element, ranges: string): [Highlight, StaticRange][] { const nodes: [number, Text][] = []; let at = 0; // Not what is said under a diff's lines, which is no part of its text. for (const code of el.querySelectorAll('code:not(.t code)')) { const text = textOf(code); if (text) nodes.push([at, text]); at += (text ? text.length : 0) + 1; } return over(nodes, ranges); } /** Paints `ranges` over `nodes`, each with where it starts in the text. */ function over(nodes: [number, Text][], ranges: string): [Highlight, StaticRange][] { const painted: [Highlight, StaticRange][] = []; for (const group of ranges.split(';')) { if (!group) continue; const [name, list] = group.split(':'); const h = named(name); const x = list.split(','); let o = 0; let j = 0; for (let i = 0; i < x.length; i += 2) { o += +x[i]; const end = o + +x[i + 1]; while (j < nodes.length && nodes[j][0] + nodes[j][1].length <= o) j++; for (let k = j; k < nodes.length && nodes[k][0] < end; k++) { const [a, t] = nodes[k]; const r = new StaticRange({ startContainer: t, startOffset: Math.max(o, a) - a, endContainer: t, endOffset: Math.min(end, a + t.length) - a }); h.add(r); painted.push([h, r]); } o = end; } } return painted; } /** * An action painting `ranges` over all the text in its element, however * many nodes it is in: a search's lines, their matches marked in * elements of their own. */ export function painted(el: Element, ranges: string | undefined) { if (!globalThis.CSS?.highlights) return {}; const all = (r: string | undefined) => { const nodes: [number, Text][] = []; const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); let at = 0; for (let t = walker.nextNode() as Text | null; t; t = walker.nextNode() as Text | null) { nodes.push([at, t]); at += t.length; } return over(nodes, r ?? ''); }; let done = all(ranges); const clear = () => done.forEach(([h, r]) => h.delete(r)); return { update(next: string | undefined) { clear(); done = all(next); }, destroy: clear }; } /** * An action painting `ranges` over the code in its element, again as what * it shows changes. With `near`, not until the element nears the screen: * WebKit takes seconds over ranges in what `content-visibility` skips, as * a pull's files below the first are. */ export function highlight( el: Element, shown: { ranges?: string | null; text: unknown; near?: boolean } ) { if (!globalThis.CSS?.highlights) return {}; let ranges = shown.ranges ?? ''; let painted: [Highlight, StaticRange][] = []; let seen = !shown.near; const clear = () => painted.forEach(([h, r]) => h.delete(r)); const watch = shown.near ? new IntersectionObserver( (at) => { if (!at.some((a) => a.isIntersecting)) return; watch?.disconnect(); seen = true; painted = paint(el, ranges); }, { rootMargin: '200px 0px' } ) : undefined; if (watch) watch.observe(el); else painted = paint(el, ranges); return { update(next: typeof shown) { ranges = next.ranges ?? ''; if (!seen) return; clear(); painted = paint(el, ranges); }, destroy() { watch?.disconnect(); clear(); } }; } /** * An action painting each block of code in the Markdown its element * shows, as its `data-ranges` say (the server's, as for a file), again as * the HTML it shows changes. */ export function fences(el: Element, _html: string) { if (!globalThis.CSS?.highlights) return {}; const all = () => [...el.querySelectorAll('pre > code[data-ranges]')].flatMap((code) => paint(code.parentElement!, code.getAttribute('data-ranges') ?? '') ); let painted = all(); const clear = () => painted.forEach(([h, r]) => h.delete(r)); return { update() { clear(); painted = all(); }, destroy: clear }; }