From a796cf775a01f0c06f07cb226a06f1d2eeed0694 Mon Sep 17 00:00:00 2001 From: barry <91018388+barry166@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:05:45 +0800 Subject: [PATCH] demo: virtual scroll with zero DOM measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A variable-height virtual scrolling list where every row height is predicted by Pretext's prepare() + layout() — no DOM measurements at all during scroll. Demonstrates the core README use case: "proper virtualization/occlusion without guesstimates & caching". - 38k items, only ~20 DOM nodes rendered at any time - Binary search for first visible row - Row pooling / recycling for smooth scrolling - Stats bar showing items, rendered count, DOM reads, total height - Reuses the existing shower-thoughts.json corpus from the masonry demo --- pages/demos/index.html | 5 + pages/demos/virtual-scroll.html | 64 +++++++++++++ pages/demos/virtual-scroll.ts | 165 ++++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 pages/demos/virtual-scroll.html create mode 100644 pages/demos/virtual-scroll.ts diff --git a/pages/demos/index.html b/pages/demos/index.html index a15f4efd..97ed65d6 100644 --- a/pages/demos/index.html +++ b/pages/demos/index.html @@ -136,6 +136,11 @@

Rich Text

Masonry

A text-card occlusion demo where height prediction comes from Pretext instead of DOM reads.

+ + +

Virtual Scroll

+

Thousands of variable-height rows scrolled with zero DOM measurement — heights predicted entirely by Pretext.

+
diff --git a/pages/demos/virtual-scroll.html b/pages/demos/virtual-scroll.html new file mode 100644 index 00000000..e20f4f9a --- /dev/null +++ b/pages/demos/virtual-scroll.html @@ -0,0 +1,64 @@ + + + + + +Virtual Scroll — Pretext + + + +
+
items 0
+
rendered 0
+
DOM reads 0
+
total height 0
+
+
+ + + diff --git a/pages/demos/virtual-scroll.ts b/pages/demos/virtual-scroll.ts new file mode 100644 index 00000000..c9743f74 --- /dev/null +++ b/pages/demos/virtual-scroll.ts @@ -0,0 +1,165 @@ +import { prepare, layout, type PreparedText } from '../../src/layout.ts' +import rawThoughts from './masonry/shower-thoughts.json' + +// --- config --- +const font = '15px "Helvetica Neue", Helvetica, Arial, sans-serif' +const lineHeight = 22 +const rowPaddingY = 12 +const rowPaddingX = 16 +const borderBottom = 1 +const overscan = 3 // extra rows rendered above/below viewport +const repeatCount = 20 // repeat the corpus to reach thousands of items + +// --- prepare all texts upfront (the whole point of Pretext) --- +type Item = { text: string; prepared: PreparedText } + +const items: Item[] = [] +for (let r = 0; r < repeatCount; r++) { + for (let i = 0; i < rawThoughts.length; i++) { + items.push({ + text: rawThoughts[i]!, + prepared: prepare(rawThoughts[i]!, font), + }) + } +} + +// --- precompute all row heights from Pretext (zero DOM reads) --- +// This is the key insight: layout() is pure arithmetic on cached widths, +// so we can predict every row's pixel height before any DOM exists. +let contentWidth = 0 +let rowHeights: number[] = [] +let rowTops: number[] = [] +let totalHeight = 0 + +function recomputeHeights(viewportWidth: number): void { + contentWidth = viewportWidth + const textWidth = contentWidth - rowPaddingX * 2 + + rowHeights = new Array(items.length) + rowTops = new Array(items.length) + totalHeight = 0 + + for (let i = 0; i < items.length; i++) { + const { height } = layout(items[i]!.prepared, textWidth, lineHeight) + const rowH = height + rowPaddingY * 2 + borderBottom + rowHeights[i] = rowH + rowTops[i] = totalHeight + totalHeight += rowH + } +} + +// --- binary search for the first visible row --- +function findFirstVisible(scrollTop: number): number { + let lo = 0 + let hi = items.length - 1 + while (lo < hi) { + const mid = (lo + hi) >>> 1 + if (rowTops[mid]! + rowHeights[mid]! <= scrollTop) { + lo = mid + 1 + } else { + hi = mid + } + } + return lo +} + +// --- DOM --- +const viewport = document.getElementById('viewport') as HTMLDivElement +const statTotal = document.getElementById('stat-total') as HTMLElement +const statRendered = document.getElementById('stat-rendered') as HTMLElement +const statDomReads = document.getElementById('stat-dom-reads') as HTMLElement +const statHeight = document.getElementById('stat-height') as HTMLElement + +const rowPool: HTMLDivElement[] = [] +let activeRows = new Map() + +function acquireRow(): HTMLDivElement { + const recycled = rowPool.pop() + if (recycled) return recycled + const el = document.createElement('div') + el.className = 'row' + const idx = document.createElement('div') + idx.className = 'row-index' + el.appendChild(idx) + el.appendChild(document.createElement('span')) + viewport.appendChild(el) + return el +} + +function releaseRow(el: HTMLDivElement): void { + el.style.display = 'none' + rowPool.push(el) +} + +statTotal.textContent = String(items.length) +statDomReads.textContent = '0' + +// --- render loop --- +let scheduledRaf: number | null = null +let prevViewportWidth = 0 + +window.addEventListener('resize', () => scheduleRender()) +window.addEventListener('scroll', () => scheduleRender(), true) +document.fonts.ready.then(() => scheduleRender()) + +function scheduleRender(): void { + if (scheduledRaf != null) return + scheduledRaf = requestAnimationFrame(() => { + scheduledRaf = null + render() + }) +} + +function render(): void { + const viewportWidth = viewport.clientWidth + if (viewportWidth !== prevViewportWidth) { + recomputeHeights(viewportWidth) + prevViewportWidth = viewportWidth + viewport.style.height = `${totalHeight}px` + statHeight.textContent = `${Math.round(totalHeight)}px` + } + + const scrollTop = window.scrollY - viewport.offsetTop + const windowHeight = document.documentElement.clientHeight + + const viewTop = Math.max(0, scrollTop) + const viewBottom = scrollTop + windowHeight + + const firstVisible = Math.max(0, findFirstVisible(viewTop) - overscan) + let lastVisible = firstVisible + while (lastVisible < items.length - 1 && rowTops[lastVisible]! < viewBottom) { + lastVisible++ + } + lastVisible = Math.min(items.length - 1, lastVisible + overscan) + + // recycle rows that left the visible range + const nextActive = new Map() + for (const [idx, el] of activeRows) { + if (idx < firstVisible || idx > lastVisible) { + releaseRow(el) + } else { + nextActive.set(idx, el) + } + } + + // create or reuse rows in the visible range + for (let i = firstVisible; i <= lastVisible; i++) { + let el = nextActive.get(i) + if (!el) { + el = acquireRow() + const indexEl = el.children[0] as HTMLDivElement + const textEl = el.children[1] as HTMLSpanElement + indexEl.textContent = `#${i}` + textEl.textContent = items[i]!.text + nextActive.set(i, el) + } + el.style.display = '' + el.style.top = `${rowTops[i]!}px` + el.style.height = `${rowHeights[i]!}px` + } + + activeRows = nextActive + statRendered.textContent = String(nextActive.size) +} + +scheduleRender()