johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
// 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
};
}