pretext-markdown
v0.2.2
Published
High-performance Canvas-virtualized markdown preview for large documents
Maintainers
Readme
pretext-markdown
High-performance Canvas-based Markdown preview for React, based on pretext. Parses Markdown into discrete sections, caches each section's layout independently, and renders only what is visible — so even large documents open instantly and scroll smoothly.
How it works
| Layer | Technology |
|---|---|
| Text layout engine | @chenglou/pretext Pure JavaScript/TypeScript library for multiline text measurement |
| Parsing | marked Lexer — tokenizes to MarkdownBlock[] |
| Rendering | Canvas 2D API — only visible sections are drawn each frame |
| Code highlighting | Shiki with JavaScript regex engine (no WASM) |
Installation
npm install pretext-markdownUsage
import { useRef } from 'react'
import { PretextMarkdown } from 'pretext-markdown'
import type { PretextMarkdownHandle } from 'pretext-markdown'
export function Preview({ content }: { content: string }) {
const mdRef = useRef<PretextMarkdownHandle>(null)
return (
<div style={{ height: 600 }}>
<PretextMarkdown
ref={mdRef}
value={content}
/>
</div>
)
}The component fills its parent — give the parent an explicit height.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | Markdown source (controlled) |
| fontSize | number | 14 | Body font size in px |
| fontFamily | string | 'Menlo, Monaco, "Courier New", monospace' | CSS font-family string |
| className | string | — | Class on the scroll container |
| style | CSSProperties | — | Inline style on the scroll container |
| ref | Ref<PretextMarkdownHandle> | — | Imperative handle for scroll control |
Imperative handle
PretextMarkdown forwards a ref exposing section-level scroll control, designed
for syncing scroll position when toggling between preview and edit modes:
const mdRef = useRef<PretextMarkdownHandle>(null)
// Index of the section at the top of the viewport
const topBlock = mdRef.current?.getTopBlock()
// Scroll to a specific section by index
mdRef.current?.scrollToBlock(3)
// Replace one section's layout (e.g. after an in-place edit)
mdRef.current?.updateBlock(3, newMarkdownBlock)| Method | Returns | Description |
|---|---|---|
| getTopBlock() | number | Index of the MarkdownBlock at the top of the visible area |
| scrollToBlock(idx) | void | Scroll the viewport so section idx appears at the top |
| updateBlock(idx, block) | void | Re-layout a single section and update total height incrementally |
Exported utilities
import { parseMarkdown } from 'pretext-markdown'
import type { MarkdownBlock } from 'pretext-markdown'
// Parse Markdown source into blocks
const blocks = parseMarkdown(source)
// Each block carries its starting source line
// Useful for mapping editor cursor line → preview section index:
const blockIdx = blocks.findLastIndex(b => b.startLine <= editorLine)MarkdownBlock variants: heading · paragraph · code · blockquote ·
list-item · hr — each tagged with startLine: number.
Performance architecture
Section model
Every top-level Markdown construct is one section:
| Markdown | Section kind |
|---|---|
| # Heading | heading (its own section, never merged with body) |
| Normal paragraph | paragraph |
| ```lang … ``` | code |
| > quote | blockquote |
| - item / 1. item | list-item (one section per item) |
| --- | hr |
Each section is laid out independently. Y coordinates inside a section are relative to the section top (start from 0), so cached layouts can be repositioned without recomputation when other sections change height.
Section cache
SectionCache {
layouts: (SectionLayout | null)[] // null = not yet computed
heights: number[] // actual height or fast estimate
startYs: number[] // PV + cumulative prefix sums
totalHeight: number
gen: number // generation counter
}When a section is first laid out, heights[i] is updated from the estimate to
the real value and startYs is recomputed from i onward in O(n − i). The
scrollable div height (totalHeight) is set as React state, so the native
scrollbar is always accurate even before all sections are laid out.
Progressive rendering
On mount (or when value changes), sections are laid out in batches using
requestIdleCallback. Batch size is measured in source lines and follows a
doubling schedule:
Batch 1 ≥ 200 source lines → paint
Batch 2 ≥ 400 source lines → paint
Batch 3 ≥ 800 source lines → paint
Batch 4 ≥ 1600 source lines → paint
Batch 5+ ≥ 1600 source lines → paint (cap at 1600)Files with fewer than 200 total source lines are laid out in a single synchronous pass — no idle scheduling needed.
Virtual rendering
On every scroll event, repaint() iterates startYs to find sections that
intersect [scrollTop, scrollTop + viewHeight]. Only those sections are passed
to renderMarkdown. Sections outside the viewport are skipped entirely —
layout cost is already paid and cached; drawing cost is O(visible sections).
totalHeight ──────────────── scrollable div height (native scrollbar)
┌──────── scrollTop
section 0 │
section 1 │ ← these sections are passed to renderMarkdown
section 2 │
└──────── scrollTop + viewHeight
section 3 (skipped)
…Async safety: generation counter
The progressive layout loop runs across multiple requestIdleCallback ticks.
If the document changes mid-flight (file switch, value prop update), the
generation counter gen is incremented. Each idle callback captures gen at
dispatch time:
function layoutBatch() {
if (gen !== cache.gen) return // stale — discard
…
requestIdleCallback(() => layoutBatch())
}This prevents a slow layout pass for a previous document from writing into the cache of the current one.
Incremental update via updateBlock
When the host knows exactly which section changed (e.g. the user edited one
paragraph in the editor before switching to preview), it can call
updateBlock(idx, newBlock) instead of changing value:
layoutSingleBlockre-computes only sectionidx.- If its height changed,
startYsis updated fromidxonward. totalHeightstate is updated so the scrollbar adjusts.repaint()is called synchronously.
All other sections' cached layouts remain untouched.
Mode-switch scroll sync
parseMarkdown and MarkdownBlock are exported so the host layer can maintain
a source-line → section-index mapping without parsing twice:
// editor line → preview section
const blocks = parseMarkdown(currentContent)
const blockIdx = blocks.findLastIndex(b => b.startLine <= editorTopLine)
mdRef.current?.scrollToBlock(blockIdx)
// preview section → editor line
const topBlockIdx = mdRef.current?.getTopBlock() ?? 0
const sourceLine = blocks[topBlockIdx]?.startLine ?? 0
editorRef.current?.scrollToLine(sourceLine)