| name | prosemirror-notes |
| description | Guide for working with the ProseMirror-based notes editor. Use when editing notes feature code, fixing editor bugs, working with todos/checkboxes, wiki_links, decorations, or serialization. |
ProseMirror Notes Editor
This skill documents how the notes editor works, common pitfalls, and how to debug issues.
Key Files
| File | Purpose |
|---|
src/features/notes/simple-todo.ts | Todo checkbox plugin (decorations, toggle, keyboard handling) |
src/components/prosemirror/tables/schema.ts | Schema including wiki_link node definition |
src/components/prosemirror/tables/serializer.ts | Markdown serialization |
src/components/prosemirror/tables/parser.ts | Markdown parsing |
src/components/prosemirror/wiki-links/plugin.ts | Wikilink autocomplete ([[) |
Critical Concepts
1. Atom Nodes
The wiki_link node is defined as atom: true:
const wikiLinkNodeSpec: NodeSpec = {
group: "inline",
inline: true,
atom: true,
attrs: { href: { default: "" }, title: { default: "" } },
};
What atom: true means:
- The node is treated as a single indivisible unit
- Cursor cannot be placed inside it
textBetween() represents it based on the leafText parameter, NOT its content
2. The textBetween Trap
node.textBetween(from, to, blockSeparator?, leafText?) extracts text, but:
- Text nodes: Returns their text content
- Atom nodes: Returns the
leafText parameter value (default: empty string)
THE BUG THAT BREAKS EVERYTHING:
const text = node.textBetween(0, node.content.size, undefined, "\n");
const text = node.textBetween(0, node.content.size, undefined, "");
If you use "\n" as leafText, a todo like:
- [ ] text [[link]] more
Becomes:
"- [ ] text \n more"
And TODO_REGEX = /^(\s*)- \[([ xX])\] ?(.*)$/ fails because:
(.*) stops at the newline
$ doesn't match (there's still \n more remaining)
3. Preserving Inline Nodes During Edits
When modifying a line that contains inline nodes (wiki_link, marks, etc.):
BAD - Destroys inline nodes:
transaction.replaceWith(
range.lineStart,
range.lineEnd,
state.schema.text(newText)
);
GOOD - Preserves inline nodes:
const checkboxPos = range.lineStart + indent.length + 3;
transaction.replaceWith(
checkboxPos,
checkboxPos + 1,
state.schema.text(newChecked)
);
4. How Todo Decorations Work
The todo system uses ProseMirror decorations to:
- Hide the raw
- [ ] markdown
- Show a checkbox widget in its place
- Apply styling classes to the paragraph
function buildTodoDecorations(doc: PMNode): DecorationSet {
doc.descendants((node, pos) => {
const text = node.textBetween(0, node.content.size, undefined, "");
const match = text.match(TODO_REGEX);
if (match) {
decorations.push(Decoration.node(pos, pos + node.nodeSize, {
class: "todo-paragraph"
}));
decorations.push(Decoration.inline(markerStart, markerEnd, {
class: "todo-marker-hidden"
}));
decorations.push(Decoration.widget(markerStart,
() => createCheckboxWidget(isChecked)
));
}
});
}
Debugging Checklist
When todos/wikilinks aren't working:
- Check
textBetween calls - Are they using problematic leafText values?
- Check document structure - Use
console.log(JSON.stringify(node.toJSON(), null, 2))
- Check if decorations apply - Inspect element to see if
todo-paragraph class is present
- Check serialization - Does saving and reloading preserve content?
Common Patterns
Getting Text for Regex Matching
const text = node.textBetween(0, node.content.size, undefined, "");
const lineText = state.doc.textBetween(start, end, undefined, "");
ALL functions that use textBetween for regex matching must include leafText: "":
toggleTodoWithinRange
handleTodoBackspace
handleTodoEnter
handleTodoClick
handleTodoIndent
handleTodoOutdent
buildTodoDecorations
Safe Todo Toggle
if (isTodoLine) {
const checkboxPos = lineStart + indent.length + 3;
tr.replaceWith(checkboxPos, checkboxPos + 1, schema.text(newState));
}
Inserting Inline Nodes
const wikiLinkNode = schema.nodes.wiki_link.create({
href: noteName,
title: displayTitle
});
tr.replaceWith(from, to, [wikiLinkNode, schema.text(" ")]);
The Todo Regex Patterns
const TODO_REGEX = /^(\s*)- \[([ xX])\] ?(.*)$/;
const LIST_TODO_REGEX = /^\[([ xX])\] ?(.*)$/;
const TRIGGER_REGEX = /^(\s*)(-\s*)?\[\]$/;
const EMPTY_TODO_REGEX = /^(\s*)- \[([ xX])\]\s*$/;
CRITICAL: Handle BOTH Todo Formats
There are TWO todo formats that must be handled in every keyboard handler:
| Format | Regex | Example | Used In |
|---|
| Standalone | TODO_REGEX | - [ ] text | Paragraphs |
| List item | LIST_TODO_REGEX | [ ] text | Inside list items |
Every handler must check BOTH patterns:
handleTodoBackspace - must handle both formats
handleTodoEnter - must handle both formats
toggleTodoWithinRange - must handle both formats
handleTodoClick - checks both with text.match(TODO_REGEX) \|\| text.match(LIST_TODO_REGEX)
Keyboard Handler Behaviors
Enter Key (handleTodoEnter)
When pressing Enter on a todo line:
- Empty todo → Remove the marker, leave empty line
- Todo with content → Create new empty todo below
const newTodoContent = `${indent}- [ ] `;
Backspace Key (handleTodoBackspace)
When pressing Backspace:
- Cursor at content start → Remove the todo marker
- Empty todo, cursor anywhere after marker → Remove the marker
const isEmptyTodo = !contentText.trim();
const atContentStart = cursorOffsetInLine === contentStartOffset;
const inEmptyTodo = isEmptyTodo && cursorOffsetInLine >= contentStartOffset;
Marker length calculations:
- Standalone todo
- [ ] : marker is 6 chars (with trailing space) or 5 chars (without)
- List item todo
[ ] : marker is 4 chars (with trailing space) or 3 chars (without)
Position Calculations
Document Positions vs Text Positions
- Document positions: Used by ProseMirror for cursor, selections, ranges
- Text positions: Character indices in strings from
textBetween()
For plain text, these are equivalent. But with atom nodes (wiki_link):
- Atom node takes 1 document position
- Atom node takes 0 text characters (with
leafText: "")
This can cause mismatches when:
const cursorOffsetInLine = selection.from - paragraphRange.lineStart;
const contentStartOffset = indent.length + markerLength;
Solution: For complex operations, work with document positions directly or avoid slicing text based on cursor position.
Testing Changes
After modifying todo/wikilink code:
- Basic todo: Create
- [ ] test, verify checkbox renders
- With wikilink: Add
[[link]] via autocomplete, verify checkbox still works
- Toggle: Click checkbox, verify wikilink preserved
- Enter on todo: Press Enter, verify new checkbox created (not bullet)
- Enter on empty todo: Press Enter, verify marker removed
- Backspace on empty todo: Press Backspace, verify marker removed
- Indented todos: Test all above with indented todos (2+ spaces)
- List item todos: Test with
[ ] text format inside lists
- Save/reload: Verify content persists correctly