@pre-markdown/layout
v0.2.1
Published
Pretext-based zero-DOM-reflow text layout engine
Maintainers
Readme
@pre-markdown/layout
Zero-DOM-reflow text layout engine — Pretext-powered measurement, virtual scrolling, cursor positioning, and line rendering.
Overview
@pre-markdown/layout is the layout engine of PreMarkdown, built on @chenglou/pretext. It provides pixel-accurate text measurement without triggering browser DOM reflows, enabling:
- Zero-Reflow Layout — Uses pretext's
prepare()+layout()two-phase pipeline: no DOM queries, no forced layouts - LRU-Cached Measurement —
PreparedTextresults are cached; subsequent layouts are pure arithmetic (~0.0002ms) - Virtual Scrolling — Only render visible lines in the viewport with configurable buffer zones
- Incremental Document Layout — Reuse cached heights for unchanged paragraphs on edits
- Cursor Positioning — Pretext-based cursor/caret positioning engine
- Line Number Rendering — Configurable line number renderer
- Pluggable Backend — Swap the measurement backend for testing or Web Worker offloading
Installation
npm install @pre-markdown/layoutpnpm add @pre-markdown/layoutNote:
@pre-markdown/coreand@chenglou/pretextare dependencies and will be installed automatically.
Quick Start
Basic Layout
import { LayoutEngine } from '@pre-markdown/layout'
const engine = new LayoutEngine({
font: '16px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
lineHeight: 24,
maxWidth: 800,
})
const { height, lineCount } = engine.computeLayout('Hello world, this is a long paragraph...')
console.log(`Height: ${height}px, Lines: ${lineCount}`)Layout with Line Details
const result = engine.computeLayoutWithLines('Hello world...')
for (const line of result.lines!) {
console.log(`Line: "${line.text}" at y=${line.y}, width=${line.width}`)
}Viewport-only Layout (Virtual Scrolling)
Only compute lines visible in the current viewport:
const viewport = engine.computeViewportLayout(text, scrollTop, viewportHeight)
console.log(`Rendering lines ${viewport.startIndex}–${viewport.endIndex}`)
console.log(`Total document height: ${viewport.totalHeight}px`)
for (const line of viewport.visibleLines) {
renderLine(line.text, line.y)
}Multi-paragraph Document Layout
const paragraphs = ['First paragraph...', 'Second paragraph...', 'Third...']
const { totalHeight, paragraphOffsets, paragraphHeights } = engine.computeDocumentLayout(paragraphs)
console.log(`Total height: ${totalHeight}px`)
paragraphOffsets.forEach((y, i) => {
console.log(`Paragraph ${i} at y=${y}, height=${paragraphHeights[i]}`)
})Incremental Document Layout (Real-time Editing)
Only recompute layout for paragraphs that changed:
// Initial layout
let result = engine.updateDocumentLayout(paragraphs)
// After editing paragraph 2
paragraphs[2] = 'Updated content...'
result = engine.updateDocumentLayout(paragraphs)
console.log(`Changed paragraphs:`, result.changedIndices) // [2]Hit Testing
Find which paragraph and line is at a scroll position:
const hit = engine.hitTest(paragraphs, scrollTop)
if (hit) {
console.log(`At paragraph ${hit.paragraphIndex}, line ${hit.lineIndex}`)
}API Reference
LayoutEngine
The main class for text measurement and layout.
class LayoutEngine {
constructor(config: LayoutConfig, backend?: MeasurementBackend)
// Configuration
updateConfig(config: Partial<LayoutConfig>): void
getConfig(): Readonly<LayoutConfig>
setBackend(backend: MeasurementBackend): void
setLocale(locale?: string): void
// Core Layout
computeLayout(text: string): LayoutResult
computeCodeLayout(text: string): LayoutResult
computeLayoutWithLines(text: string): LayoutResult
computeViewportLayout(text: string, scrollTop: number, viewportHeight: number): ViewportLayoutResult
// Multi-paragraph Layout
computeDocumentLayout(paragraphs: string[]): { totalHeight, paragraphOffsets, paragraphHeights }
hitTest(paragraphs: string[], scrollTop: number): { paragraphIndex, lineIndex } | null
// Incremental Layout
updateDocumentLayout(paragraphs: string[]): { totalHeight, paragraphOffsets, paragraphHeights, changedIndices }
getCachedTotalHeight(): number
// Cache Management
invalidateCache(text?: string): void
clearAllCaches(): void
getCacheStats(): { preparedSize: number; segmentSize: number }
}LayoutConfig
interface LayoutConfig {
/** CSS font string (e.g., '16px Inter'). Must be loaded before use. */
font: string
/** Line height in pixels (must match CSS line-height) */
lineHeight: number
/** Maximum width for text wrapping (pixels) */
maxWidth: number
/** White-space mode: 'normal' (default) or 'pre-wrap' */
whiteSpace?: 'normal' | 'pre-wrap'
/** Viewport buffer multiplier (default 2 = 2x viewport above & below) */
viewportBuffer?: number
/** Font for code blocks (defaults to main font) */
codeFont?: string
/** Line height for code blocks (defaults to main lineHeight) */
codeLineHeight?: number
}LayoutResult
interface LayoutResult {
height: number // Total height of all lines (px)
lineCount: number // Number of visual lines
lines?: LayoutLine[] // Per-line info (when requested)
}
interface LayoutLine {
text: string // Line text content
width: number // Measured width (px)
y: number // Y position from top (px)
sourceIndex: number // Source line index
}ViewportLayoutResult
interface ViewportLayoutResult {
visibleLines: LayoutLine[] // Lines in the viewport
totalHeight: number // Full document height
startY: number // Y offset of first visible line
startIndex: number // Index of first visible line
endIndex: number // Index of last visible line (exclusive)
}VirtualList
Dynamic-height virtual scrolling list for rendering large documents.
import { VirtualList } from '@pre-markdown/layout'
import type { VirtualListConfig, VirtualListItem, ViewportRange } from '@pre-markdown/layout'CursorEngine
Pretext-based cursor/caret positioning engine for accurate cursor placement without DOM measurement.
import { CursorEngine } from '@pre-markdown/layout'
import type { Point, Rect, CursorPosition, VisualLineInfo, LineNumberInfo } from '@pre-markdown/layout'LineRenderer
Line number rendering engine.
import { LineRenderer } from '@pre-markdown/layout'
import type { LineRendererConfig, RenderedLineNumber } from '@pre-markdown/layout'MeasurementBackend
Pluggable interface for text measurement — swap implementations for different environments.
interface MeasurementBackend {
prepare(text: string, font: string, options?): PreparedText
prepareWithSegments(text: string, font: string, options?): PreparedTextWithSegments
layout(prepared: PreparedText, maxWidth: number, lineHeight: number): PretextLayoutResult
layoutWithLines(prepared: PreparedTextWithSegments, maxWidth: number, lineHeight: number): PretextLinesResult
clearCache(): void
setLocale(locale?: string): void
}createFallbackBackend(avgCharWidth?)
Creates a measurement backend that uses character-count heuristics instead of Canvas. Useful for Node.js / testing environments:
import { LayoutEngine, createFallbackBackend } from '@pre-markdown/layout'
const engine = new LayoutEngine(config, createFallbackBackend(8))createWorkerBackend
Offload text measurement to a Web Worker for non-blocking layout computation:
import { LayoutEngine, createWorkerBackend } from '@pre-markdown/layout'
const worker = createWorkerBackend()
const engine = new LayoutEngine(config, worker)Architecture: Two-Phase Pipeline
The layout engine uses pretext's two-phase pipeline for maximum performance:
Text → prepare() → PreparedText → layout() → LayoutResult
~1-5ms (cached) ~0.0002ms
(LRU) (pure math)prepare()— One-time text analysis: font metrics, grapheme segmentation, word boundaries. Results are cached in an LRU cache (512 entries).layout()— Pure arithmetic: line breaking, height calculation. No DOM access, safe to call in animation frames.
This separation means:
- First layout of a paragraph: ~1-5ms (font measurement)
- Subsequent layouts (e.g., window resize): ~0.0002ms (cache hit)
- Animation-frame safe layout: ✅
Performance
| Operation | Time | |-----------|------| | First layout (cache miss) | ~1-5ms | | Cached layout (cache hit) | ~0.0002ms | | Document layout (100 paragraphs) | < 10ms | | Incremental update (1 paragraph) | < 1ms | | Viewport layout (visible only) | < 0.5ms |
Module Format
| Format | Entry |
|--------|-------|
| ESM | dist/index.js |
| CJS | dist/index.cjs |
| Types | dist/index.d.ts |
Related Packages
| Package | Description | |---------|-------------| | @pre-markdown/core | AST types, visitors, events, plugins | | @pre-markdown/parser | Markdown → AST parser | | @pre-markdown/renderer | AST → HTML renderer | | @chenglou/pretext | Underlying text measurement engine |
License
MIT © 2024-2026 PreMarkdown Contributors
