5.7 KiB
DESIGN: Notes Rich Text Editor, Obsidian-Style Linking & Knowledge Graph
Version: v0.17.3
Status: Draft
Scope: Upgrade the notes editor from a plain <textarea> to a CM6-powered
markdown editor with live preview, [[wikilink]] resolution, a backlinks
system, Canvas-rendered force-directed graph visualization, transclusion,
note-from-selection (chat → notes bridge), and daily notes.
Depends on: v0.17.2 (CodeMirror 6 bundle provides CM.codeEditor() and
the markdown language mode).
Motivation
The notes system (v0.9.3 side panel, CRUD, folders, search) stores markdown
but edits it as raw text in a <textarea>. Now that CM6 is bundled, we can
give notes the same live-preview markdown experience that the chat input gets
— plus something the chat input doesn't need: inter-note linking.
The Obsidian model is the right reference: markdown files with [[wikilinks]]
resolved at render time, bidirectional backlinks, and create-on-reference.
Our advantage over Obsidian: we already have semantic search via embeddings
(v0.14.0 KB infrastructure), so link autocomplete can be smarter than
filename matching.
Beyond linking, the notes system sits adjacent to the chat system but has no bridge between them. Users accumulate valuable AI responses in conversations that they then manually copy into notes. Note-from-selection closes that gap. A visual graph makes the link topology navigable and discoverable, turning notes from a flat list into a connected knowledge base.
Architecture
New CM6 Factory: CM.noteEditor()
A third factory alongside chatInput() and codeEditor(), tailored for
long-form markdown editing in the notes panel.
CM.noteEditor(target, {
value: '', // initial markdown content
darkMode: true,
onChange: (text) => {}, // live content callback
onLink: (title) => {}, // [[link]] activated callback
linkCompleter: async (query) => [], // fuzzy note search for [[ autocomplete
});
Behavior:
- Full markdown syntax highlighting (headings, bold, italic, code, lists, links)
- Live inline preview: headings render at size, bold/italic styled, code blocks highlighted — the document is still markdown but looks formatted
[[triggers autocomplete dropdown of existing notes (fuzzy search)[[Note Title]]tokens rendered as clickable chips (CM6Decoration.widget)![[Note Title]]recognized as transclusion markers (decoration differs from regular links — see Transclusion section)- Line numbers off (it's a note, not code)
- Spell check enabled (
EditorView.contentAttributes: { spellcheck: 'true' }) - Vim/Emacs keybindings respected (user preference from v0.17.2)
Wikilink Syntax
Standard double-bracket syntax with optional display text:
[[Note Title]] → links to note titled "Note Title"
[[Note Title|display text]] → shows "display text", links to "Note Title"
![[Note Title]] → embeds target note content inline (transclusion)
Resolution order:
- Exact title match (case-insensitive)
- Fuzzy title match (for autocomplete, not for rendering)
- Unresolved → rendered as red chip with "create" affordance
Bundle Addition
The note editor factory lives in a new file in src/editor/:
src/editor/
index.mjs # existing — adds noteEditor to window.CM
chat-input.mjs # existing
code-editor.mjs # existing
note-editor.mjs # NEW — noteEditor() factory
wikilink.mjs # NEW — CM6 extension: parse, decorate, autocomplete [[links]]
theme.mjs # existing
The wikilink extension is CM6-native: a ViewPlugin that scans for [[...]]
patterns and replaces them with Decoration.widget nodes (clickable chips).
The autocomplete uses CM6's autocompletion with a custom completeFromList
source triggered by [[.
No bundle size concern — the wikilink plugin is tiny custom code, not an external dependency.
Data Model
note_links Table
Tracks directed links between notes, extracted on save.
-- Postgres
CREATE TABLE IF NOT EXISTS note_links (
source_note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id UUID REFERENCES notes(id) ON DELETE CASCADE,
target_title TEXT NOT NULL, -- raw [[title]] text, for unresolved links
display_text TEXT, -- optional |alias
is_transclusion BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (source_note_id, target_title)
);
CREATE INDEX idx_note_links_target ON note_links(target_note_id)
WHERE target_note_id IS NOT NULL;
-- SQLite
CREATE TABLE IF NOT EXISTS note_links (
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id TEXT REFERENCES notes(id) ON DELETE CASCADE,
target_title TEXT NOT NULL,
display_text TEXT,
is_transclusion INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (source_note_id, target_title)
);
CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(target_note_id)
WHERE target_note_id IS NOT NULL;
Key design decisions:
target_note_idis nullable — an unresolved link (target note doesn't exist yet) stores the title but hasNULLfor the FK. When a note with that title is created, a background pass resolves dangling links.- Primary key is
(source_note_id, target_title)— a note can only link to the same title once (last one wins if duplicated). target_titleis always stored for human readability and re-resolution after renames.is_transclusiondistinguishes![[embed]]from[[link]]— useful for
[... truncated for brevity, full content used in tool]