The editor is the product. Everything else in Loomdraft, the sidebar, the search, the export pipeline, exists to serve the moments when a writer is staring at the cursor and trying to put the right words in the right order. Getting the editor wrong means the application doesn't work, no matter how good the rest of it is.
CodeMirror 6 is the right foundation for this, but not for the obvious reason. It isn't chosen because it ships as a React component you drop into a form. It's chosen because it's an extension system first and an editor second. That distinction determines everything about how Loomdraft's writing modes work.
01. WHY CODEMIRROR 6
Version 5 was a monolith. You got a feature set and customized around the edges. Version 6 was a full rewrite organized around a composable extension model. Every capability, syntax highlighting, keybindings, autocomplete, decorations, line numbers, is an extension. You assemble the editor you need from small pieces.
For a writing application, this matters because the feature set required for a novelist is genuinely different from the feature set required for a programmer. Loomdraft needs real-time word counts, not line numbers. It needs paragraph-level soft wrapping with a maximum prose width, not horizontal scrolling. It needs wiki-link hover previews, not LSP completions.
// src/editor/extensions.ts
import {
EditorView,
keymap,
lineWrapping,
drawSelection,
} from "@codemirror/view";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { syntaxHighlighting } from "@codemirror/language";
export function buildExtensions(config: EditorConfig): Extension[] {
return [
lineWrapping,
markdown({ base: markdownLanguage }),
syntaxHighlighting(proseTheme),
drawSelection(),
keymap.of(proseKeymap),
wordCountField,
wikiLinkPlugin,
config.typewriterMode ? typewriterScroll : [],
config.focusMode ? dimSurroundingParagraphs : [],
config.readingWidth ? maxProseWidth(config.readingWidth) : [],
].flat();
}The buildExtensions function returns a fresh array whenever the writing mode changes. CodeMirror's EditorState.reconfigure accepts a new extension array without destroying the document or losing cursor position. Switching from standard mode to typewriter mode is a state transaction, not a remount.
02. WRITING MODES
Loomdraft ships four modes. Each is a composition of extensions added or removed from the base set.
Standard is the default. Syntax-highlighted Markdown, document outline in the right gutter, word count in the status bar.
Focus activates dimSurroundingParagraphs, a decoration extension that applies reduced opacity to every paragraph except the one containing the cursor. The active paragraph renders at full opacity and everything else at 25%. The effect is subtle but immediate. It collapses the peripheral visual field and anchors attention.
// src/editor/plugins/focus-mode.ts
const dimSurroundingParagraphs = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.compute(view);
}
update(update: ViewUpdate) {
if (update.selectionSet || update.docChanged) {
this.decorations = this.compute(update.view);
}
}
compute(view: EditorView): DecorationSet {
const cursor = view.state.selection.main.head;
const activeLine = view.state.doc.lineAt(cursor).number;
const builder = new RangeSetBuilder<Decoration>();
for (let i = 1; i <= view.state.doc.lines; i++) {
if (i !== activeLine) {
const line = view.state.doc.line(i);
builder.add(
line.from,
line.to,
Decoration.mark({ class: "cm-dim-paragraph" })
);
}
}
return builder.finish();
}
},
{ decorations: (v) => v.decorations }
);Typewriter keeps the active line vertically centered in the viewport regardless of scroll position. It's implemented as a scrollIntoView that fires on every selection change, offset to the viewport midpoint.
Manuscript removes all chrome. No sidebar, no status bar, no toolbar. The editor fills the window with a constrained prose width (65ch by default, configurable), large line height, and no syntax decoration. Just text on the page. It's the mode you switch into when you're not optimizing the tool, you're using it.
03. WIKI-LINKS
The wiki-link system is a hover preview plugin. Type [[character-name]] anywhere in a document and the bracketed text becomes a clickable link. Hovering shows a popover with the first 200 characters of the linked document and clicking navigates to it, adding a backlink entry to the target's frontmatter.
The plugin works in two layers. The first is a syntax extension that teaches CodeMirror to parse [[...]] patterns as a distinct token type within the Markdown grammar:
// src/editor/plugins/wiki-links.ts
const wikiLinkSyntax = new MarkdownExtension({
parseInline: [
{
name: "WikiLink",
parse(cx, next, pos) {
if (next !== 91 || cx.char(pos + 1) !== 91) return -1; // [[
const end = cx.slice(pos, cx.end).indexOf("]]");
if (end < 0) return -1;
return cx.addElement(
cx.elt("WikiLink", pos, pos + end + 4)
);
},
},
],
});The second layer is a decoration plugin that applies a cm-wiki-link class to all WikiLink nodes, enabling CSS hover effects, and registers a hoverTooltip that fetches document previews from the Rust backend on demand.
NOTE
Backlinks are stored in the YAML frontmatter of the target document. When you link to a character from three scenes, backlinks: [scene-01, scene-03, inciting-incident] appears in that character file. This means backlinks are queryable with grep and visible in any editor, not locked inside a database.
04. WORD COUNT AND AUTO-SAVE
The word count is a CodeMirror StateField, a piece of state stored in the editor state object and recomputed whenever the document changes.
const wordCountField = StateField.define<number>({
create(state) {
return countWords(state.doc.toString());
},
update(count, tr) {
return tr.docChanged
? countWords(tr.newDoc.toString())
: count;
},
});
function countWords(text: string): number {
return text.trim().split(/\s+/).filter(Boolean).length;
}React reads the word count from the editor via EditorView.state.field(wordCountField) in a useEffect that subscribes to view updates. This keeps the count in sync without requiring React re-renders on every keystroke. The state field computes inside CodeMirror's own transaction system, and React is only notified when the component needs to display a new number.
Auto-save runs in a useEffect with a 10-second interval. It reads the current document string, diffs it against the last saved snapshot using a simple hash comparison, and invokes write_document only if something changed. On every save, a backup copy is written to .app/backups/[filename]-[timestamp].md and the oldest backup is pruned if the count exceeds 20.
05. THE LESSON
CodeMirror 6 rewards the investment in understanding its extension model. The early hours of learning StateField, ViewPlugin, Decoration, and Transaction pay back directly in capability. Features that would require hacking the DOM in most editors are first-class extension points here.
For a writing application, that means the editor grows alongside the requirements. New writing modes, new document annotations, new keyboard behaviors, they're all extensions, not patches.