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); }, }; }