~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
165 lines · 5.0 KB Raw History
// 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
	};
}