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.
This skill documents how the notes editor works, common pitfalls, and how to debug issues.
| 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 ([[) |
The wiki_link node is defined as atom: true:
const wikiLinkNodeSpec: NodeSpec = {
group: "inline",
inline: true,
atom: true, // <-- This is critical!
attrs: { href: { default: "" }, title: { default: "" } },
// ...
};
What atom: true means:
textBetween() represents it based on the leafText parameter, NOT its contenttextBetween Trapnode.textBetween(from, to, blockSeparator?, leafText?) extracts text, but:
leafText parameter value (default: empty string)THE BUG THAT BREAKS EVERYTHING:
// BAD - wiki_link becomes "\n", breaking regex matching
const text = node.textBetween(0, node.content.size, undefined, "\n");
// GOOD - wiki_link becomes "", regex works correctly
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)When modifying a line that contains inline nodes (wiki_link, marks, etc.):
BAD - Destroys inline nodes:
// This replaces entire line with plain text
transaction.replaceWith(
range.lineStart,
range.lineEnd,
state.schema.text(newText) // <-- Creates plain text, destroys wiki_links!
);
GOOD - Preserves inline nodes:
// Only replace the specific character that needs to change
// For toggling checkbox: only replace the " " or "x" character
const checkboxPos = range.lineStart + indent.length + 3; // Position of checkbox char
transaction.replaceWith(
checkboxPos,
checkboxPos + 1,
state.schema.text(newChecked) // Just " " or "x"
);
The todo system uses ProseMirror decorations to:
- [ ] markdownfunction buildTodoDecorations(doc: PMNode): DecorationSet {
doc.descendants((node, pos) => {
// Get text content (atom nodes become empty string)
const text = node.textBetween(0, node.content.size, undefined, "");
// Match todo pattern
const match = text.match(TODO_REGEX);
if (match) {
// 1. Node decoration for styling
decorations.push(Decoration.node(pos, pos + node.nodeSize, {
class: "todo-paragraph"
}));
// 2. Inline decoration to hide "- [ ] "
decorations.push(Decoration.inline(markerStart, markerEnd, {
class: "todo-marker-hidden"
}));
// 3. Widget decoration for checkbox
decorations.push(Decoration.widget(markerStart,
() => createCheckboxWidget(isChecked)
));
}
});
}
When todos/wikilinks aren't working:
textBetween calls - Are they using problematic leafText values?console.log(JSON.stringify(node.toJSON(), null, 2))todo-paragraph class is present// Always use empty string for leafText when matching patterns
// This applies to BOTH node.textBetween() and state.doc.textBetween()
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: "":
toggleTodoWithinRangehandleTodoBackspacehandleTodoEnterhandleTodoClickhandleTodoIndenthandleTodoOutdentbuildTodoDecorations// For existing todos, only replace the checkbox character
if (isTodoLine) {
const checkboxPos = lineStart + indent.length + 3;
tr.replaceWith(checkboxPos, checkboxPos + 1, schema.text(newState));
}
// When inserting wiki_link via autocomplete
const wikiLinkNode = schema.nodes.wiki_link.create({
href: noteName,
title: displayTitle
});
tr.replaceWith(from, to, [wikiLinkNode, schema.text(" ")]);
// Standalone paragraph todo: "- [ ] text" or "- [x] text"
const TODO_REGEX = /^(\s*)- \[([ xX])\] ?(.*)$/;
// List item todo (inside list_item): "[ ] text" or "[x] text"
const LIST_TODO_REGEX = /^\[([ xX])\] ?(.*)$/;
// Trigger pattern for creating new todo
const TRIGGER_REGEX = /^(\s*)(-\s*)?\[\]$/;
// Empty todo detection
const EMPTY_TODO_REGEX = /^(\s*)- \[([ xX])\]\s*$/;
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 formatshandleTodoEnter - must handle both formatstoggleTodoWithinRange - must handle both formatshandleTodoClick - checks both with text.match(TODO_REGEX) \|\| text.match(LIST_TODO_REGEX)handleTodoEnter)When pressing Enter on a todo line:
// Always create a NEW empty todo (don't try to split content)
// This avoids position mapping issues with inline nodes
const newTodoContent = `${indent}- [ ] `;
handleTodoBackspace)When pressing Backspace:
// Check if todo is empty/whitespace-only
const isEmptyTodo = !contentText.trim();
// Handle backspace if:
// 1. Cursor is at the very start of content, OR
// 2. Todo is empty and cursor is at or after content start
const atContentStart = cursorOffsetInLine === contentStartOffset;
const inEmptyTodo = isEmptyTodo && cursorOffsetInLine >= contentStartOffset;
Marker length calculations:
- [ ] : marker is 6 chars (with trailing space) or 5 chars (without)[ ] : marker is 4 chars (with trailing space) or 3 chars (without)textBetween()For plain text, these are equivalent. But with atom nodes (wiki_link):
leafText: "")This can cause mismatches when:
// Document position-based
const cursorOffsetInLine = selection.from - paragraphRange.lineStart;
// Text position-based
const contentStartOffset = indent.length + markerLength;
Solution: For complex operations, work with document positions directly or avoid slicing text based on cursor position.
After modifying todo/wikilink code:
- [ ] test, verify checkbox renders[[link]] via autocomplete, verify checkbox still works[ ] text format inside lists