type Chunk = {
element: HTMLElement;
near: boolean;
rich: boolean;
height: number;
dirty: boolean;
render(rich: boolean): void;
visible(visible: boolean): void;
};
/** Rich markup near the viewport, searchable text elsewhere, without disturbing a selection or find result. */
export function viewport(root: HTMLElement) {
const chunks = new Map();
const pending = new Set();
const pinned = new Set();
let frame = 0;
let width = 0;
let disposed = false;
function schedule() {
if (frame || disposed) return;
frame = requestAnimationFrame(flush);
}
function flush() {
frame = 0;
const until = performance.now() + 4;
const selection = document.getSelection();
for (const chunk of pending) {
pending.delete(chunk);
if (chunk.near === chunk.rich && !chunk.dirty) continue;
// firefox exposes the active native find match as a selection, too
if (
chunk.element.contains(document.activeElement) ||
(selection &&
Array.from({ length: selection.rangeCount }, (_, i) => selection.getRangeAt(i))
.some((range) => range.intersectsNode(chunk.element)))
) {
pinned.add(chunk);
continue;
}
if (!chunk.near && chunk.height > 0) {
chunk.element.style.height = `${chunk.height}px`;
} else chunk.element.style.removeProperty("height");
chunk.rich = chunk.near;
chunk.dirty = false;
chunk.render(chunk.rich);
if (chunk.rich) sizes?.observe(chunk.element);
else sizes?.unobserve(chunk.element);
if (performance.now() >= until) break;
}
if (pending.size) schedule();
}
const nearby = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const chunk = chunks.get(entry.target);
if (!chunk) continue;
chunk.near = entry.isIntersecting;
pending.add(chunk);
}
schedule();
},
{ root, rootMargin: "800px 0px" },
);
const visible = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver(
(entries) => {
for (const entry of entries) chunks.get(entry.target)?.visible(entry.isIntersecting);
},
{ root },
);
const sizes = typeof ResizeObserver === "undefined" ? null : new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === root) {
if (width === entry.contentRect.width) continue;
width = entry.contentRect.width;
for (const chunk of chunks.values()) {
chunk.height = 0;
chunk.element.style.removeProperty("height");
}
} else {
const chunk = chunks.get(entry.target);
if (chunk?.rich) chunk.height = entry.contentRect.height;
}
}
});
sizes?.observe(root);
const release = () => {
for (const chunk of pinned) pending.add(chunk);
pinned.clear();
if (pending.size) schedule();
};
document.addEventListener("selectionchange", release);
root.addEventListener("focusout", release);
return {
add(element: HTMLElement, render: Chunk["render"], onVisible: Chunk["visible"]) {
const chunk: Chunk = {
element,
near: nearby === null,
rich: nearby === null,
height: 0,
dirty: false,
render,
visible: onVisible,
};
chunks.set(element, chunk);
render(chunk.rich);
nearby?.observe(element);
visible?.observe(element);
return {
redraw() {
if (!chunk.rich) return;
chunk.dirty = true;
pending.add(chunk);
schedule();
},
dispose() {
nearby?.unobserve(element);
visible?.unobserve(element);
sizes?.unobserve(element);
chunks.delete(element);
pending.delete(chunk);
pinned.delete(chunk);
},
};
},
dispose() {
disposed = true;
cancelAnimationFrame(frame);
nearby?.disconnect();
visible?.disconnect();
sizes?.disconnect();
chunks.clear();
pending.clear();
pinned.clear();
document.removeEventListener("selectionchange", release);
root.removeEventListener("focusout", release);
},
};
}