A Markdown editor for writers who keep changing their minds.
Keep several versions of every word, sentence and paragraph, cycle between them in place, fade what you are unsure of, stash what does not fit, and let the Lab show you what to cut. Rust compiled to WebAssembly, plain JavaScript, no framework.
Features · Run it locally · Try it in five minutes · Keyboard shortcuts · Embed it · Develop
Drafting is mostly choosing. Ordinary editors make you delete the version you are not sure about; Terraphim Editor keeps every alternative attached to the text it belongs to, inside the Markdown file itself, so you can decide later. Nothing is rewritten without you asking, and every change is one undo step.
Your alternatives, ghosts and overflow live in a small trailing block at the end of the Markdown file:
Every team runs on a handful of trade-offs.
```terraphim-alternatives
{"version":1,"spans":[{"id":"s1","kind":"word","anchor":{"start":32,"end":42,"text":"trade-offs"},
"active":0,"alts":[{"text":"trade-offs","source":"original"},{"text":"choices","source":"human"}]}]}
```Any Markdown viewer still shows the text; Terraphim Editor shows the alternatives.
You need Rust, the WebAssembly target, Trunk, and two small Node tools that build the embeddable bundle.
1. Toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # Rust (skip if installed)
rustup target add wasm32-unknown-unknown
cargo install trunk wasm-pack
npm install -g terser clean-css-cli # used by Trunk's build hook2. Clone and serve
The Rust dependencies are available on crates.io. No private-registry account or token is required.
git clone https://github.com/terraphim/terraphim-editor.git
cd terraphim-editor
trunk serveOpen http://127.0.0.1:8080. Trunk compiles the Rust to WebAssembly, serves the page and rebuilds on every change. The first build takes a few minutes; later ones take seconds.
3. Check everything works (optional)
cargo test --workspace # Rust tests
scripts/browser-tests.sh # browser tests in headless Chrome (needs Chrome)- Switch to Write_On. Click the word counter at the top left.
- Add alternatives. Select a word, press Ctrl+Shift+A, type another version in the panel and press Enter. Dots appear under the word.
- Cycle. Close the panel (Escape), hover the word and press Up or Down.
- Ghost a sentence. Select it and press Ctrl+/. Press it again with the caret inside to revive it.
- Stash a tangent. Select a paragraph and press Ctrl+Shift+X; open XYZ at the bottom right to see it.
- Ask the Lab. Click LAB, choose "Mark hedges and filler", then try Tighten more and Make the cuts. Ctrl+Z undoes the whole trim.
- Rearrange. In plain mode press Ctrl+Shift+L for the toolbar, switch to Blocks and move a paragraph with Alt+Down.
- Save. Press Ctrl+S (or click the floppy). In Chrome and Edge the first save asks where to put the
.mdfile and later saves write to it; in Firefox and Safari it downloads. Opening it again (Ctrl+O, the folder icon, or dropping the file on the editor) restores every alternative, ghost and stash, and unsaved work survives a reload as a draft the editor offers to restore.
Formatting and selection shortcuts use Ctrl on every platform (not Cmd); undo, redo, save, open and Ctrl+Enter also accept Cmd. The keyboard glyph in Write_On mode shows the same reference.
Writing
| Keys | Action |
|---|---|
| Ctrl+B / Ctrl+I | Bold / italic |
| Ctrl+K / Ctrl+L | Inline code / link |
| Ctrl+H | Heading |
/ |
Command palette |
| Ctrl+Shift+L | Show or hide the toolbar and preview (plain mode) |
| Ctrl+Z / Ctrl+Y (or Cmd) | Undo / redo |
| Ctrl+S / Ctrl+O (or Cmd) | Save / open a file (replaces the browser's own) |
Selection (Write_On)
| Keys | Action |
|---|---|
| Right-click, Menu key or Shift+F10 | Selection menu |
| Ctrl+Shift+A | Alternatives for selection |
| Ctrl+Shift+G | AI alternatives for selection (knowledge-graph synonyms) |
| Ctrl+/ | Ghost it / Revive |
| Ctrl+Shift+X | Stash this in Overflow |
Alternatives
| Keys | Action |
|---|---|
| Hover a span, Up / Down | Cycle in place (wraps round) |
| Alt+Up / Alt+Down, caret in a span | Cycle without the mouse |
| Click a dot | Jump to that alternative |
| Panel: Up / Down | Make a line active |
| Panel: Alt+Up / Alt+Down | Reorder |
| Panel: Enter on the last line | Add an alternative |
| Escape | Close the panel |
Overflow, Lab and trim
| Keys | Action |
|---|---|
| XYZ (bottom right) | Open or close Overflow |
| Ctrl+Enter in Overflow | Insert the selection (or current line) at the caret |
| LAB (bottom centre) | Open the Lab; Up / Down, Home / End move between actions |
| Walk through: Left / Right, K, Escape | Previous / next cut, keep it, stop |
Blocks view
| Keys | Action |
|---|---|
| Up / Down, Home / End | Move between cards |
| Enter or F2 | Edit the block |
| Shift+Enter | Add a paragraph below |
| Delete | Delete the block |
| Alt+Up / Alt+Down | Move the block |
| Ctrl+Enter / Escape (editing) | Commit / close (typed text is kept in the drafts notice) |
trunk build --release writes an embeddable bundle next to the standalone page in target/trunk-dist: terraphim-editor.min.js, terraphim-editor.min.css, the WebAssembly module (terraphim_editor.js and terraphim_editor_bg.wasm) and a working example.html. Serve those files from one directory and a page with nothing but a container gets the whole editor, Write_On chrome, alternatives, Lab, Blocks, Overflow and save/open included:
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<link rel="stylesheet" href="terraphim-editor.min.css">
<div id="editor" style="height: 80vh"></div>
<script src="terraphim-editor.min.js"></script>
<script type="module">
const editor = await TeraphimEditor.create(document.getElementById('editor'), {
value: '# Hello',
// wasmUrl / glueUrl: only if the module is not next to the script
// standalone: true: Ctrl+S / Ctrl+O anywhere on the page
});
editor.setValue('# Hello again');
editor.on('te:saved', (event) => console.log(event.detail.text));
</script>The editor object has getValue(), setValue(), openDocument(text, name), saveDocument() (Markdown plus the annotation block), exportDocument() (clean Markdown), isWriteOn() / setWriteOn(), on() / off() for its te:* events, persistence and destroy(). Shoelace is loaded from its CDN; link FontAwesome yourself, as above. One editor per page: the document model lives in the WebAssembly module as a single instance, so a second create() is refused until the first is destroyed. Write_On themes the editor's own container and never restyles the host page; its corner controls sit in the viewport corners, so give the editor most of the page. Design notes: docs/design/embedding.md.
The standalone page (index.html) creates its editor on load and exposes it as window.terraphimEditor; with the embed, use the object create() returns. Either way a host script can take over storage:
<script>
document.addEventListener('te:save', (event) => {
event.preventDefault(); // store documents yourself instead of a file
const text = window.terraphimEditor.saveDocument(); // Markdown plus annotation block
fetch('/documents/draft.md', { method: 'PUT', body: text })
.then(() => window.terraphimEditor.persistence.markClean());
});
document.addEventListener('te:open', (event) => {
event.preventDefault();
fetch('/documents/draft.md')
.then((response) => response.text())
.then((text) => window.terraphimEditor.openDocument(text, 'draft.md'));
});
</script>te:save, te:open and te:markdown (the M↓ control) bubble to document and are cancelable; without preventDefault() the editor saves to a file or download, opens a file, and shows the export dialog. te:saved carries the serialised document (detail.text) whenever the editor saves; te:written, te:opened, te:dirty-change, te:mode-change and te:blocks-drafts report what happened. exportDocument() returns the clean Markdown. Set fileSystemAccess: false in the editor config to always download instead of using the browser's file picker. In an embedding page Ctrl+S and Ctrl+O act only while focus is in the editor, so the host's own shortcuts keep working; standalone: true (what the standalone page sets) makes them act anywhere. Design notes: docs/design/persistence.md.
flowchart LR
subgraph Browser
UI["public/js<br/>surface, chrome, panels,<br/>Lab, trim, Blocks"]
end
subgraph WebAssembly
Bridge["src/<br/>document, lab, kg bridges"]
Alt["terraphim_alternatives<br/>spans, ghosts, overflow,<br/>annotation block"]
Lab["terraphim_lab<br/>marks and trim"]
Core["terraphim_lsp_core<br/>KG matching, synonyms"]
end
UI <--> Bridge
Bridge --> Alt
Bridge --> Lab
Bridge --> Core
Core --> Alt
Lab --> Alt
The Rust model is the source of truth: every edit goes to it first, and the surface follows, so undo and redo restore text and annotations together.
| Path | Contents |
|---|---|
src/ |
WebAssembly entry point, Markdown rendering, the document, Lab and knowledge-graph bridges |
crates/terraphim_alternatives |
The span model, annotation block, re-anchoring, moves and the shared word count |
crates/terraphim_lab |
The Lab engine: mark actions and trim |
public/js/, public/css/ |
The editor surface and features (including save, open and export); design tokens |
docs/requirements/, docs/design/ |
Specification and per-feature design notes |
tests/ |
Native tests and the browser test binaries (web_*.rs) |
| Command | What it does |
|---|---|
trunk serve |
Development server with live rebuild at http://127.0.0.1:8080 |
cargo test --workspace |
Rust unit and integration tests |
scripts/browser-tests.sh |
Every browser test binary in headless Chrome, one at a time, with a time and load table. Before each binary it waits (saying what for) for the load to drop below a limit (20 on Linux, three quarters of the CPUs on macOS); it skips the benchmarks under heavy load (exit code 3) and, locally, stops without running the rest if the load stays high for 5 minutes (exit code 4; on CI it only warns). Use it for every local browser run (see CLAUDE.md, "Browser tests") |
scripts/browser-tests.sh web_kg |
A single browser test binary, load-guarded |
cargo clippy --workspace --all-targets -- -D warnings |
Lints (also run with --target wasm32-unknown-unknown --lib --tests) |
cargo bench |
Criterion benchmarks |
scripts/release-dist.sh |
Regenerate the committed release copy in dist/ |
Browser tests drive the real DOM and the real WebAssembly bindings; there are no mocks. Development builds write to target/trunk-dist; dist/ changes only through scripts/release-dist.sh.
Work is tracked in Gitea issues at git.terraphim.cloud. Create a branch per issue (task/<number>-<short-title>), keep each change one undo step for the writer, and open a pull request against main.
MIT. See LICENSE.










