Repository navigation
feat(notes): save a reader excerpt into the note, anchored to its source - #118
Merged
Merged
Conversation
Selecting text in the reader had nowhere to go: notes are independent Tiptap documents with no link back to their source, so the reading loop stopped exactly where the provenance chain would start paying off. - An excerpt is a blockquote plus a `SourceAnchor` written as a plain markdown link (`#know-note-source?doc=…&page=…&start=…&end=…`). The anchor has to live in the note body itself: the body is not Tiptap JSON — `NoteEditor` serialises the document to markdown on every change and parses markdown back — so an anchor kept only in a node attribute would disappear on the next round trip. A quote plus an ordinary link also degrades for free: an unknown or malformed anchor loads as text, never as a note that cannot be opened. - `escapeExcerptMarkdown` keeps the selected text literal. An excerpt is what the user *selected*, not markdown they wrote, so `# heading`, `- list`, `[x](y)` and `<b>` must not become a heading, a list, a link or bold. The table follows prosemirror-markdown's `esc()`, plus `<`, `&` and `|` because tiptap-markdown runs with `html: true` and raw HTML, entities and tables are all live. Leading indentation is clamped to three spaces — the one case escaping cannot express, since what triggers an indented code block is whitespace rather than punctuation. - `selectionToSourceAnchor` joins `citationToSourceAnchor` as the second entry at the single place that constructs a `SourceAnchor`, so the duplicated `documentId` has exactly one writer. - Saving appends to the open note and only creates one when none is open, so excerpts accumulate into the note being worked on instead of becoming a note each. The append travels as a targeted command (`noteId` + markdown) consumed by the editor that owns the document: writing through `updateNote` from the reader would be a second writer, and the editor's next `onUpdate` would overwrite the excerpt with its older body. - Note links now have one dispatch point. `openOnClick` is off because Tiptap's Link calls `window.open(href)` inside an editable editor, which is wrong both for these anchors and for ordinary links; `#know-note-source?…` goes to `openSourceAnchor` and everything else to `openExternalUrl`. A deleted source keeps the quote and the reference and only disables navigation (`data-source-missing` + `aria-disabled`) — the markdown is never rewritten, because "the evidence is unreachable now" and "this note came from there" are different facts. - `citationDocumentExists` becomes `sourceDocumentExists`, now that the excerpt anchor is a second consumer of the same rule. Verification: `npm run typecheck`, `npm test` (237 pass; 18 new — escaping and its reversibility, the append seam, anchor round-trip, and the targeted command's cross-note delivery), `npm run build`, `npm run check:design`, `npx prettier --check src test`. The Markdown → Tiptap round trip was checked against the real Tiptap in a throwaway jsdom harness (deliberately not committed — jsdom is only a transitive dependency, so it must not become a test dependency): 42 markdown-sensitive selections — headings, all three bullet markers, ordered lists, nested quotes, thematic breaks, inline links, images, inline/block HTML, entities, setext pairs, tables, 4/8-space and tab indentation, and multi-line combinations — all keep both their visible text and their structure. Not verified: nothing was exercised in a real Electron window. Click dispatch, the disabled styling, the append into a dirty editor and the save path are covered by unit tests over the pure parts only, and are listed as manual QA in the PR. Closes #73
This was referenced Sep 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #73.
Selecting text in the reader had nowhere to go: notes are independent Tiptap
documents with no link back to their source, so the reading loop stopped exactly
where the provenance chain would start paying off. This connects #71's reader to
#72's
SourceAnchor, and the value is the contract rather than the quote: every"go back to the original" affordance now goes through
openSourceAnchor().The decision the issue asked for: where the anchor lives
In the note body, as an ordinary markdown link. Not in a Tiptap node
attribute, not in new
notesmetadata.The repository makes this mostly a non-choice. The note body is not Tiptap
JSON:
NoteEditorserialises the document to markdown viatiptap-markdownonevery change (
onUpdate→getMarkdown()) and parses markdown back, andnoteshas a single
contentcolumn. So anything that lives only in a node attributeis gone on the next round trip. A
metadatacolumn would not escape that either— the body has no stable ids to bind a quote to an anchor, so you would end up
sending an identifier through the markdown anyway, plus a migration.
Three consequences, all wanted: it round-trips as core markdown (no custom node,
no half-written serializer); an unknown, truncated or malformed anchor degrades
to text plus a link rather than a note that cannot be opened; and it is pure
string in / string out, so it is testable without a DOM. The fragment reuses
SourceAnchor's existing query encoding, so a note link and a transcriptcitation resolve through the same code and the same location priority.
Flow
Excerpts append to the note being worked on; a note is created only when none is
open. The append is a targeted command (
noteId+ markdown) consumed by theeditor that owns the document — writing through
updateNotefrom the readerwould be a second writer, and the editor's next
onUpdatewould overwrite theexcerpt with its older body. The
noteIdmatters: without it, a command issuedwhile note A unmounts can be consumed by note B.
Escaping (the part that is not obvious)
An excerpt is what the user selected, not markdown they wrote. A PDF or web
selection can legitimately look like markdown, and without escaping the excerpt
is silently rewritten —
# Attentionbecomes a heading,- not a listbecomes alist,
[foo](bar)becomes a link, and/---lose their textentirely.
escapeExcerptMarkdownfollows prosemirror-markdown'sesc()and adds<,&and|, because tiptap-markdown runs withhtml: true, so raw HTML,entities and tables are all live.
Leading indentation is the one case escaping cannot express (what triggers an
indented code block is whitespace, not punctuation), so it is clamped to three
spaces. It is deliberately not worked around with
 : that reverts to aplain space on re-serialisation, so the next load would become a code block again
— a fix that rots is worse than the bug.
Acceptance
npm test: excerpt markdown round-trips its anchor; Tiptap round trip verified in a harness (below)npm test: anchor parsing and priority are #72's, already tested. The click itself is manual QAnpm test:sourceDocumentExists; markdown is never rewritten. The disabled styling is manual QAnpm test: a plain note keeps its content; a non-excerpt link is never treated as oneVerification
npm run typecheck— main/preload, renderer and test projectsnpm test— 237 pass / 0 fail (18 new: escaping and its reversibility, the append seam, anchor round-trip, the targeted command's cross-note delivery)npm run buildnpm run check:design— no violationsnpx prettier --check src test— clean (only touched files were formatted)npm run lint— 0 errors, 107 warnings (the pre-existingno-explicit-anybaseline; this PR removes two of them rather than adding any)The Markdown → Tiptap round trip was checked against the real Tiptap in a
throwaway jsdom harness, deliberately not committed: jsdom is only a transitive
dependency (via
cheerio), so relying on it as a test dependency would depend onnpm's hoisting. 42 markdown-sensitive selections — headings, all three bullet
markers, ordered lists (
1.and1)), nested quotes, thematic breaks, inlinelinks, images, inline/block HTML, entities, backslashes, setext pairs, tables,
4/8-space and tab indentation, and multi-line combinations — all keep both their
visible text and their structure. The durable artefact is the committed unit
tests over
escapeExcerptMarkdown; the harness was the one-off check that therules are the right ones.
Not verified — needs a real window
Nothing was exercised in a running Electron window. Explicitly unverified:
the append goes through
onUpdate→onChange→ the existing save path, sothe note becomes dirty and still needs the normal save. Behaviour differs
between the two branches (a new note is persisted immediately, an appended one
is not), which is consistent with the existing editor semantics but worth
confirming by hand.
Out of scope
SourceAnchorschema change and no DB migration.command's
noteIdmatching to grow, which it is shaped for but does not do.documentIdinSourceAnchoris still there. It now has exactlyone construction path, so it cannot be built inconsistently, but collapsing the
type itself is a separate change.