Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pages/demos/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ <h2>Rich Text</h2>
<h2>Masonry</h2>
<p>A text-card occlusion demo where height prediction comes from Pretext instead of DOM reads.</p>
</a>

<a class="card" href="/demos/virtual-scroll">
<h2>Virtual Scroll</h2>
<p>Thousands of variable-height rows scrolled with zero DOM measurement — heights predicted entirely by Pretext.</p>
</a>
</section>
</main>
</body>
Expand Down
64 changes: 64 additions & 0 deletions pages/demos/virtual-scroll.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Virtual Scroll — Pretext</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
background: #f4f1ea;
color: #201b18;
}
.stats {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
gap: 24px;
justify-content: center;
padding: 10px 16px;
background: rgba(32, 27, 24, 0.92);
color: #fffdf8;
font: 13px/1.4 'SF Mono', ui-monospace, monospace;
backdrop-filter: blur(8px);
}
.stats span { opacity: 0.55; }
.stats b { opacity: 1; font-weight: 600; }
.viewport {
position: relative;
width: min(640px, calc(100vw - 32px));
margin: 56px auto 0;
}
.row {
position: absolute;
left: 0;
right: 0;
padding: 12px 16px;
background: #fffdf8;
border-bottom: 1px solid #e8e0d6;
font-size: 15px;
line-height: 22px;
color: #333;
}
.row-index {
font: 11px/1 'SF Mono', ui-monospace, monospace;
color: #955f3b;
margin-bottom: 4px;
}
</style>
</head>
<body>
<div class="stats">
<div><span>items </span><b id="stat-total">0</b></div>
<div><span>rendered </span><b id="stat-rendered">0</b></div>
<div><span>DOM reads </span><b id="stat-dom-reads">0</b></div>
<div><span>total height </span><b id="stat-height">0</b></div>
</div>
<div class="viewport" id="viewport"></div>
<script type="module" src="./virtual-scroll.ts"></script>
</body>
</html>
165 changes: 165 additions & 0 deletions pages/demos/virtual-scroll.ts
Original file line number Diff line number Diff line change
@@ -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<number, HTMLDivElement>()

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<number, HTMLDivElement>()
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()