pretext-editor
v0.6.14
Published
High-performance Canvas-virtualized text editor for large documents based @chenglou/pretext
Maintainers
Readme
pretext-editor
A lightweight, high-performance Canvas-virtualized code editor with VS Code-style keyboard shortcuts, syntax highlighting, and multi-cursor editing.
Built on @chenglou/pretext + vscode-textmate. Integrates with React / Vue 3 / Svelte / Angular.
Features
- Canvas virtual scrolling — fluid editing of 10,000+ line files; only visible lines are rendered
- Syntax highlighting — vscode-textmate + Oniguruma, 30+ languages
- VS Code shortcuts — navigation, editing, selection, clipboard, history
- Multi-cursor editing — Alt+Click, Ctrl+D (next occurrence), Ctrl+Shift+L (all occurrences)
- Column selection — Alt+Shift+drag
- Indent guides — auto-detected indent unit with active-scope bracket highlighting
- Undo / redo — 200-entry snapshot stack
Install
npm install pretext-editorFramework Support
| Framework | Extra setup needed |
|-----------|-------------------|
| React | None — import and use |
| Vue 3 | None — import and use |
| Svelte | None — import and use |
| Angular | Copy editor.component.ts + create Worker |
| Vanilla / no framework | Use EditorController directly |
React, Vue, and Svelte all require one line in your Vite config (see below). Angular does not use Vite.
Vite Setup (React / Vue / Svelte)
Add one line to your vite.config.ts:
export default defineConfig({
optimizeDeps: { exclude: ['pretext-editor'] },
})This prevents Vite from pre-bundling the package with esbuild, which would break the syntax-highlighting worker.
React
import { PretextEditor } from 'pretext-editor/react'
import 'pretext-editor/react/index.css'
function App() {
return (
<div style={{ height: '100vh' }}>
<PretextEditor value="console.log('hello')" language="typescript" />
</div>
)
}The component is uncontrolled for typing — you pass value to set initial content or to replace it externally (e.g. loading a file), but you do not need to sync state on every keystroke.
To react to edits, use onTextChanged:
<PretextEditor
value={code}
language="typescript"
onTextChanged={(r1, c1, r2, c2, oldValue, newValue) => {
setCode(newValue)
}}
/>To get a handle for scrolling:
const ref = useRef<PretextEditorHandle>(null)
<PretextEditor ref={ref} value={code} language="typescript" />
ref.current?.scrollTextTo(0, 42)Vue 3
<template>
<div style="height: 100vh">
<PretextEditor
:value="code"
@text-changed="code = $event.newValue"
language="typescript"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { PretextEditor } from 'pretext-editor/vue'
import 'pretext-editor/vue/index.css'
const code = ref("console.log('hello')")
</script>The component emits @text-changed with { r1, c1, r2, c2, oldValue, newValue } on every edit, and @cursor-updated with { line, col } on cursor moves.
Svelte
<script lang="ts">
import PretextEditor from 'pretext-editor/svelte'
let code = "console.log('hello')"
function handleTextChanged(e: CustomEvent<{ r1: number; c1: number; r2: number; c2: number; oldValue: string; newValue: string }>) {
code = e.detail.newValue
}
</script>
<div style="height: 100vh">
<PretextEditor value={code} language="typescript" on:text-changed={handleTextChanged} />
</div>The component dispatches a text-changed CustomEvent with { r1, c1, r2, c2, oldValue, newValue } on every edit, and cursor-updated with { line, col } on cursor moves. Use bind:this to get a handle reference.
Angular
Angular requires two extra steps: copy the component into your project, and create the highlight worker yourself.
Step 1 — Copy the component file:
cp node_modules/pretext-editor/dist/angular/editor.component.ts src/app/pretext-editor/editor.component.tsStep 2 — Use it in your component, passing a Worker via [worker]:
import { Component } from '@angular/core'
import { PretextEditorComponent } from './pretext-editor/editor.component'
@Component({
standalone: true,
imports: [PretextEditorComponent],
template: `
<pretext-editor
[value]="code"
[worker]="worker"
language="typescript"
(textChanged)="code = $event.newValue"
style="height: 100vh; display: block"
/>
`,
})
export class AppComponent {
code = "console.log('hello')"
readonly worker = typeof Worker !== 'undefined'
? new Worker(
new URL(
// Adjust the relative path to match your component file's location
'../../node_modules/pretext-editor/dist/highlight.worker.bundle.js',
import.meta.url,
),
{ type: 'module' },
)
: undefined
}Create the Worker at class level (not inside a lifecycle hook) so WASM loading starts before the editor mounts.
Vanilla / No Framework
Use EditorController directly:
import { EditorController } from 'pretext-editor'
import { createWorker } from 'pretext-editor/worker-create'
const container = document.querySelector('.editor-scroll')
const canvas = document.querySelector('.editor-canvas')
const textarea = document.querySelector('.editor-textarea')
const content = document.querySelector('.editor-content')
const ctrl = new EditorController({
value: "console.log('hello')",
language: 'typescript',
worker: createWorker(),
})
ctrl.mount(container, canvas, textarea, () => {
// called on every state change — update your own UI here
const state = ctrl.getState()
}, content)You are responsible for the DOM structure (.editor-scroll, .editor-canvas, .editor-textarea, .editor-content) and CSS. See demo/vanilla/ for a full example.
Electron / electron-vite
For Electron projects, the standard optimizeDeps.exclude approach fails because Electron's renderer cannot resolve new Worker(new URL(...)) paths inside node_modules. Use pretextEditorBundlePlugin() instead — it inlines the entire highlight worker as a blob URL at build time, bypassing all path resolution.
// vite.config.ts (renderer)
import { pretextEditorBundlePlugin } from 'pretext-editor/vite'
export default defineConfig({
plugins: [react(), pretextEditorBundlePlugin()],
// optimizeDeps.exclude is injected automatically
})For non-Electron Vite projects (browser only), optimizeDeps.exclude remains sufficient and keeps the bundle lighter.
Props
| Prop | React | Vue | Svelte | Angular | Type | Default |
|------|-------|-----|--------|---------|------|---------|
| value | ✓ | ✓ | ✓ | ✓ | string | '' |
| language | ✓ | ✓ | ✓ | ✓ | string | — |
| theme | ✓ | ✓ | ✓ | ✓ | string | 'dark-plus' |
| fontSize | ✓ | ✓ | ✓ | ✓ | number | 14 |
| fontFamily | ✓ | ✓ | ✓ | ✓ | string | Menlo, Monaco, … |
| tabSize | ✓ | ✓ | ✓ | ✓ | number | 4 |
| wordWrap | ✓ | ✓ | ✓ | ✓ | boolean | false |
| keymap | ✓ | ✓ | ✓ | ✓ | Partial<Record<CommandId, KeyBinding>> | — |
| worker | — | — | — | ✓ | Worker | — |
| components | ✓ | — | — | — | { ContextMenu?, SearchBar? } | — |
Text change callbacks — fired with full diff (r1, c1, r2, c2, oldValue, newValue):
| Framework | Callback |
|-----------|----------|
| React | onTextChanged?: (r1, c1, r2, c2, oldValue, newValue) => void |
| Vue | @text-changed="({ r1, c1, r2, c2, oldValue, newValue }) => ..." |
| Svelte | on:text-changed — CustomEvent<{ r1, c1, r2, c2, oldValue, newValue }> |
| Angular | (textChanged)="handler($event)" |
Cursor callbacks — fired with { line, col } on cursor move:
| Framework | Callback |
|-----------|----------|
| React | onCursorUpdated?: ({ line, col }) => void |
| Vue | @cursor-updated="({ line, col }) => ..." |
| Svelte | on:cursor-updated — CustomEvent<{ line, col }> |
| Angular | (cursorUpdated)="handler($event)" |
Scroll callbacks — deduplicated, fire independently:
| Framework | onScroll | onTextScroll |
|-----------|----------|--------------|
| React | onScroll?: (x, y, fromUser) => void | onTextScroll?: (col, line, fromUser) => void |
| Vue | @scroll="({ x, y, fromUser }) => ..." | @text-scroll="({ col, line, fromUser }) => ..." |
| Svelte | on:scroll — CustomEvent<{ x, y, fromUser }> | on:text-scroll — CustomEvent<{ col, line, fromUser }> |
| Angular | (scroll)="handler($event)" — $event: { x, y, fromUser } | (textScroll)="handler($event)" — $event: { col, line, fromUser } |
fromUser is true when scrolled by the user, false when triggered by scrollTo / scrollTextTo.
Custom Context Menu & Search Bar
React — pass component references via components:
<PretextEditor
components={{
ContextMenu: ({ builtins, onClose }) => (
<MyMenu items={[builtins.copy, builtins.paste]} onClose={onClose} />
),
SearchBar: ({ state, actions }) => (
<MySearchBar state={state} actions={actions} />
),
}}
/>Vue — use scoped slots:
<PretextEditor>
<template #context-menu="{ builtins, onClose }">
<MyMenu :builtins="builtins" :on-close="onClose" />
</template>
<template #search-bar="{ state, actions }">
<MySearchBar :state="state" :actions="actions" />
</template>
</PretextEditor>Svelte — use named slots:
<PretextEditor>
<svelte:fragment slot="context-menu" let:builtins let:onClose>
<MyMenu {builtins} {onClose} />
</svelte:fragment>
<svelte:fragment slot="search-bar" let:state let:actions>
<MySearchBar {state} {actions} />
</svelte:fragment>
</PretextEditor>Angular — use ng-template with #contextMenu / #searchBar:
<pretext-editor>
<ng-template #contextMenu let-builtins="builtins" let-onClose="onClose">
<my-menu [builtins]="builtins" (close)="onClose()" />
</ng-template>
<ng-template #searchBar let-state="state" let-actions="actions">
<my-search-bar [state]="state" [actions]="actions" />
</ng-template>
</pretext-editor>ContextMenuBuiltins has four entries: copy, cut, paste, selectAll — each a ContextMenuItem.
SearchActions methods: setQuery · next · prev · close · setCaseSensitive · setWholeWord · setUseRegex · toggleReplace · setReplaceQuery · setPreserveCase · replace · replaceAll
Themes
Built-in values for theme: 'dark-plus' · 'dracula' · 'github-light'
Handle Methods
// React
const ref = useRef<PretextEditorHandle>(null)
ref.current?.scrollTextTo(0, 42)
// Vue
const editorRef = ref<PretextEditorHandle>()
// Svelte
let editorRef: PretextEditorHandle
<PretextEditor bind:this={editorRef} ... />
// Angular — via @ViewChild
@ViewChild(PretextEditorComponent) editor!: PretextEditorComponent
this.editor.scrollTextTo(0, 42)| Method | Description |
|--------|-------------|
| getTextOffset() | Current text scroll position as { col, line } |
| getVisibleLines() | { from, to } visible line range |
| scrollTo(x, y) | Scroll to a pixel position |
| scrollTextTo(col, line) | Scroll to a text position (col column, line number) |
Keyboard Shortcuts
| Shortcut | Action | |----------|--------| | ↑ ↓ ← → | Move cursor | | Ctrl+← → | Move by word | | Home / End | Line start / end | | Ctrl+Home / End | File start / end | | Shift+arrows | Extend selection | | Ctrl+A | Select all | | Ctrl+L | Select current line | | Ctrl+D | Select next occurrence | | Ctrl+Shift+L | Select all occurrences | | Alt+Click | Add / remove cursor | | Alt+Shift+drag | Column selection | | Ctrl+Backspace / Delete | Delete by word | | Tab / Shift+Tab | Indent / dedent | | Alt+↑ ↓ | Move line up / down | | Alt+Shift+↑ ↓ | Copy line up / down | | Ctrl+Enter | Insert line below | | Ctrl+Shift+Enter | Insert line above | | Ctrl+/ | Toggle line comment | | Ctrl+Shift+K | Delete line | | Ctrl+Z / Ctrl+Y | Undo / redo | | Ctrl+F | Open search |
Supported Languages
typescript · tsx · javascript · jsx · python · rust · go · c · cpp · csharp · java · kotlin · swift · ruby · php · css · scss · less · html · xml · vue · svelte · json · jsonc · yaml · toml · markdown · bash · fish · sql · graphql · lua · dart · scala · r · haml · glsl · postcss
import { extToLang } from 'pretext-editor'
extToLang('ts') // → 'typescript'
extToLang('py') // → 'python'Demos
cd demo/react && npm install && npm run dev # React + Vite
cd demo/vue && npm install && npm run dev # Vue 3 + Vite
cd demo/svelte && npm install && npm run dev # Svelte + Vite
cd demo/angular && npm install && npm run dev # Angular
cd demo/vanilla && npm install && npm run build # Vanilla — open index.html after buildLicense
MIT
