pretext-markdown
v0.4.0
Published
High-performance Canvas-virtualized markdown preview for large documents based @chenglou/pretext
Maintainers
Readme
pretext-markdown
High-performance Canvas-based Markdown preview — React, Vue, Angular, Svelte, and vanilla HTML5. Parses Markdown into discrete sections, caches each section's layout independently, and renders only what is visible — large documents scroll smoothly.
| Layer | Technology |
|---|---|
| Text layout engine | @chenglou/pretext Pure JavaScript/TypeScript library for multiline text measurement |
| Parsing | marked Lexer → MarkdownBlock[] |
| Rendering | Canvas 2D — only visible sections are drawn |
| Code highlighting | Shiki with JavaScript regex engine (no WASM) |
Installation
npm install pretext-markdownCore API (pretext-markdown)
The root entry exports framework-agnostic utilities usable from any environment:
import { parseMarkdown, PretextBinding, searchLayouts, VanillaSearchBar } from 'pretext-markdown'
import type { MarkdownBlock, MdSearchMatch, SearchState, SearchActions } from 'pretext-markdown'parseMarkdown(src: string): MarkdownBlock[]
Parses Markdown source into blocks. Each block carries a startLine (source line number).
MarkdownBlock
Variants: heading · paragraph · code · blockquote · list · table · image · hr
PretextBinding
Simple object for editor↔preview scroll sync. Two components share one binding:
const binding = new PretextBinding()
// Editor side — report top visible line on scroll
binding.reportSourceLine(editorTopLine)
// Preview side — read where to scroll when activated
const line = binding.getSourceLine()searchLayouts(layouts, query, caseSensitive, wholeWord, useRegex)
Searches through section layouts and returns { matches: MdSearchMatch[], regexError: string | null }. Used to build custom search UIs on top of the engine.
VanillaSearchBar
A zero-dependency DOM-based search bar. Mount it inside a container, then call .update(state, actions) from the onSearchStateChange callback. Imports require CSS:
import 'pretext-markdown/icons/icons.css'
import 'pretext-markdown/styles/search-bar.css'React
Import from pretext-markdown/react:
import { PretextMarkdown, usePretextBinding } from 'pretext-markdown/react'
import type { PretextMarkdownHandle } from 'pretext-markdown/react'
export function Preview({ content }: { content: string }) {
const mdRef = useRef<PretextMarkdownHandle>(null)
return (
<div style={{ height: 600 }}>
<PretextMarkdown ref={mdRef} value={content} />
</div>
)
}React 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 |
| className | string | — | Class on the scroll container |
| style | CSSProperties | — | Inline style on the scroll container |
| binding | PretextBinding | — | Shared binding for scroll sync |
| active | boolean | false | When true, scrolls to binding position on mount/change |
| contextMenuItems | (builtins) => ContextMenuItem[] | — | Custom right-click menu items |
| renderSearchBar | (state: SearchState, actions: SearchActions) => ReactNode | — | Override the default search bar UI |
| ref | Ref<PretextMarkdownHandle> | — | Imperative handle |
Imperative Handle
const mdRef = useRef<PretextMarkdownHandle>(null)
// Get visible section range
const { from, to } = mdRef.current?.getVisibleBlocks() ?? { from: 0, to: 0 }
// Scroll to section by index
mdRef.current?.showBlocks(3)
// Re-layout a single section after inline edit
mdRef.current?.updateBlock(3, updatedBlock)
// Search
mdRef.current?.openSearch('keyword')
mdRef.current?.searchNext()
mdRef.current?.closeSearch()Search (React)
Press Ctrl/Cmd+F to open search, Escape to close. The default UI is a floating bar in the top-right corner.
Custom search UI via renderSearchBar:
import type { SearchState, SearchActions } from 'pretext-markdown/react'
<PretextMarkdown
value={content}
renderSearchBar={(state: SearchState, actions: SearchActions) => (
<MySearchBar state={state} actions={actions} />
)}
/>SearchState fields: isOpen · query · caseSensitive · matchCount · currentIndex
SearchActions methods: setQuery(q) · next() · prev() · setCaseSensitive(v) · close()
Editor↔Preview Scroll Sync (React)
import { PretextBinding } from 'pretext-markdown'
function EditorPreview({ content }: { content: string }) {
const binding = useRef(new PretextBinding()).current
const [mode, setMode] = useState<'edit' | 'preview'>('edit')
const mdRef = useRef<PretextMarkdownHandle>(null)
return (
<div>
<button onClick={() => setMode(m => m === 'edit' ? 'preview' : 'edit')}>Toggle</button>
{mode === 'edit' ? (
<Editor binding={binding} />
) : (
<PretextMarkdown ref={mdRef} value={content} binding={binding} active />
)}
</div>
)
}Vue
Import from pretext-markdown/vue:
<script setup lang="ts">
import { ref } from 'vue'
import { PretextMarkdown, usePretextBinding } from 'pretext-markdown/vue'
import type { PretextMarkdownHandle } from 'pretext-markdown/vue'
const content = ref('# Hello World')
const mdRef = ref<PretextMarkdownHandle>()
</script>
<template>
<div style="height: 600px">
<PretextMarkdown ref="mdRef" :value="content" />
</div>
</template>Vue Props
| Prop | Type | Default |
|---|---|---|
| value | string | required |
| fontSize | number | 14 |
| fontFamily | string | 'Menlo, Monaco, "Courier New", monospace' |
| className | string | '' |
| style | string | '' |
| binding | PretextBinding \| null | null |
| active | boolean | false |
The imperative handle is exposed via defineExpose — access via template ref.
Search (Vue)
Press Ctrl/Cmd+F to open search, Escape to close. The default SearchBar is rendered inside the component.
<script setup lang="ts">
import { ref } from 'vue'
import type { PretextMarkdownHandle, SearchState, SearchActions } from 'pretext-markdown/vue'
const mdRef = ref<PretextMarkdownHandle>()
function openSearch() {
mdRef.value?.openSearch('keyword')
}
</script>SearchState / SearchActions types are re-exported from pretext-markdown/vue.
Angular
Import from pretext-markdown/angular.
Ensure experimentalDecorators is enabled in your tsconfig.json:
{ "compilerOptions": { "experimentalDecorators": true } }import { Component, ViewChild } from '@angular/core'
import { PretextMarkdownComponent } from 'pretext-markdown/angular'
@Component({
selector: 'app-root',
standalone: true,
imports: [PretextMarkdownComponent],
template: `
<div style="height:600px">
<pretext-markdown [value]="content" #md></pretext-markdown>
</div>
`,
})
export class AppComponent {
content = '# Hello World'
@ViewChild('md') mdRef!: PretextMarkdownComponent
scrollToTop() {
this.mdRef.showBlocks(0)
}
}Angular Inputs
| Input | Type | Default |
|---|---|---|
| value | string | '' |
| fontSize | number | 14 |
| fontFamily | string | 'Menlo, Monaco, "Courier New", monospace' |
| className | string | '' |
| style | string | '' |
| binding | PretextBinding \| undefined | — |
| active | boolean | false |
Search (Angular)
Press Ctrl/Cmd+F to open search. A <pretext-search-bar> is rendered inside the component.
export class AppComponent {
@ViewChild('md') mdRef!: PretextMarkdownComponent
openSearch() {
this.mdRef.openSearch('keyword')
}
}The component exposes search methods: openSearch(q?), closeSearch(), searchNext(), searchPrev(), replace(), replaceAll(), and setters for all toggles.
Svelte
Import from pretext-markdown/svelte:
<script lang="ts">
import PretextMarkdown from 'pretext-markdown/svelte'
import type { MarkdownBlock } from 'pretext-markdown'
let content = $state('# Hello World')
let mdRef = $state<{ getVisibleBlocks(): { from: number; to: number }; showBlocks(n: number): void; updateBlock(i: number, b: MarkdownBlock): void } | null>(null)
</script>
<div style="height:600px">
<PretextMarkdown bind:this={mdRef} value={content} fontSize={15} />
</div>Svelte Props
| Prop | Type | Default |
|---|---|---|
| value | string | '' |
| fontSize | number | 14 |
| fontFamily | string | 'Menlo, Monaco, "Courier New", monospace' |
| className | string | '' |
| style | string | '' |
| binding | PretextBinding \| null | null |
| active | boolean | false |
Imperative methods are exposed as component exports — access by binding the component to a variable with bind:this.
Search (Svelte)
Press Ctrl/Cmd+F to open search. A <SearchBar> is rendered inside the component automatically.
<script lang="ts">
import PretextMarkdown from 'pretext-markdown/svelte'
let mdRef: { openSearch(q?: string): void; closeSearch(): void; searchNext(): void; searchPrev(): void; replace(): void; replaceAll(): void } | null = null
function openSearch() {
mdRef?.openSearch('keyword')
}
</script>
<PretextMarkdown bind:this={mdRef} value={content} />Vanilla HTML5
Use PretextCore directly:
import { PretextCore, VanillaSearchBar } from 'pretext-markdown'
import 'pretext-markdown/styles/pretext-markdown.css'
import 'pretext-markdown/styles/search-bar.css'
import 'pretext-markdown/icons/icons.css'
const root = document.getElementById('md-root')!
root.classList.add('ptmic-root')
const host = document.createElement('div')
host.className = 'ptmic-host'
root.appendChild(host)
// Search bar (renders as overlay inside root)
const searchBar = new VanillaSearchBar(root)
const core = new PretextCore(host, {
value: '# Hello World',
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
onSearchStateChange: (state, actions) => searchBar.update(state, actions),
})
// Update content
core.updateOptions({ value: '# New Content' })
// Imperative methods
const { from, to } = core.handle.getVisibleBlocks()
core.handle.showBlocks(5)
core.handle.updateBlock(3, updatedBlock)
core.handle.openSearch('keyword')
// Clean up
core.destroy()
searchBar.remove()Performance Architecture
Every top-level Markdown construct is one section (heading, paragraph, code block, etc.). Each section is laid out independently — Y coordinates inside a section are relative to section top (start from 0), so cached layouts can be repositioned without recomputation.
Progressive Layout
On mount or content change, sections are laid out in batches using requestIdleCallback.
Batch size doubles each tick from 200 → 400 → 800 → 1600 source lines. Files under
200 total lines are laid out synchronously.
Virtual Rendering
On scroll, only sections intersecting the viewport are drawn to the canvas. Non-visible sections cost nothing — their layout is already cached.
Generation Counter
If content changes mid-layout, the generation counter gen is incremented.
Each async callback captures gen at dispatch time and returns early if stale,
preventing old layout passes from corrupting the current cache.
Incremental Update (updateBlock)
When editing a single section, call updateBlock(idx, newBlock) instead of
changing the whole value. Only that section is re-laid out, and if its height
changed, subsequent section positions are updated incrementally.
