Skip to content

Fix comments on Markdown tables - #82

Merged
kylemcd merged 1 commit into
mainfrom
79-comments-doesnt-work-well-with-markdown-tables
Sep 22, 2026
Merged

kylemcd merged 1 commit into
mainfrom
79-comments-doesnt-work-well-with-markdown-tables

Conversation

@kylemcd

@kylemcd kylemcd commented Sep 10, 2026 •

Copy link
Copy Markdown
Owner

Closes #79.

Comment cards on a Markdown table, before and after the fix

Recorded in Obsidian 1.13.7, Live Preview, driving the real Add comment command. Same note and same actions against main and against this branch.

The report was two things: comment cards stacking at the top of a table instead of beside their row, and tables "getting very buggy". The second turned out to be three separate defects.

Cards align to their row

A Live Preview table is a single block widget, and Obsidian's widget implements no coordsAt — so coordsAtPos returns the widget's own rect for every position inside it, and every card in a table measured to the same point. The margin now measures the rendered cell where the anchor resolves to one. The draft composer takes the same path, so a new comment opens beside its row too.

An anchor spanning two rows has no single cell to paint, so it reports the cell it starts in and says separately whether it ends there — the painter still needs the whole anchor in one cell, the margin only needs somewhere to sit.

Commenting a table no longer breaks it

Commenting a whole row dropped that row and everything below it out of the rendered table. Commenting the whole table stopped it rendering as a table at all. Both come from the opening marker landing before a row's leading pipe, and Obsidian keeps an outer-pipe table going only while each line starts with one. A marker on the delimiter row broke it the same way, by leaving a cell that is no longer just dashes and colons.

Anchors are now pulled inside the outer pipes of any row they touch, stepping over the delimiter row and trimming the padding that leaves behind. Markers end up in cell text, where the table doesn't care about them. A selection of nothing but borders or cell separators is refused with an explanation rather than silently wrecking the block.

The same care applies at a table's edges:

  • Selections that run in from outside the table. Whitespace is trimmed before deciding which ends sit in a table. A selection dragged in from the paragraph above starts in the table instead of having its marker dragged onto the header's border, and one that trims to prose stays on the prose.
  • The blank line above a table. Obsidian starts a table only below a blank line, a heading or the top of the note. A selection that ends on that blank line (Shift+Down from the start of a paragraph takes its newline) keeps its markers on the paragraph. Live Preview puts a filled blank line back by itself, so there it only left a stray marker line, but Reading view writes to the file directly, and there the table disappeared.
  • Escaped pipes. A boundary between a backslash and the pipe it escapes widens to keep \| whole. A marker between them un-escapes the pipe, which splits the cell in two and shows the marker as text.

The write path and the existing-highlight lookup share one normalization, so running Add comment twice on the same text still finds the comment it just made.

Hover linkage works inside tables

Inside a table the highlight is painted with the CSS Custom Highlight API, which leaves no element behind — and every part of the card/text linkage was looking for a .doc-comment-span. So hovering a card left the text flat, and hovering the text never raised its card.

The hovered comment is now painted under its own registry name, using the same 18% / 38% pair prose uses, and the painted ranges' client rects are hit-tested to find the comment under the pointer. A cell is usually a single text node, so mouseover never fires as the pointer crosses into the commented words — mousemove is tracked while over a table instead.

Repairing tables an older comment already broke

The clamp stops new damage but leaves notes that already have it, where the table stays broken until someone hand-edits an HTML comment — exactly what this plugin exists to avoid.

Repairing a table an older comment broke

The comment's card already sits beside the damage, so that is where the explanation goes: a notice saying the comment is breaking its table, and a one-click Repair. The Repair table comments in this note command does the whole note at once, which is the way out when several anchors broke the same table. Nothing is rewritten unless you ask, and both paths are a single undo step.

Detection has to be cheap, because the card needs it live, so it runs in two stages. First a per-comment string check on the marker's own line: does stripping the markers change whether the line starts with a pipe, ends with one, reads as a delimiter row, or is blank? It reads only that line, never splitting the document, and nearly every note stops there. Only for a line that answers yes does the second stage strip and re-scan — and only across the blank-line block containing it, since tables never cross one. A line can look damaging in prose that was never a table, which is what that confirmation is for. The margin keeps the result for the document version it came from, so cursor moves and scrolls don't run it again. Code-block comments are skipped: their markers sit on lines of their own by design.

Repair reuses the clamp and rebuilds the whole block with every anchor reinserted, not just the repaired ones: a table broken by two anchors only reappears once both are out of the way, and a healthy neighbour's markers have to come back exactly where they were. Only markers the parser recognized are taken out and put back: marker-shaped text inside code, such as a row documenting the syntax, or markers a stray backtick hides from the parser, is left exactly as it was. Markers go back in one forward pass, so an anchor nested inside the one being repaired stays intact. A comment with no markers in the block, such as an orphan whose text was deleted, is skipped rather than blocking the repair. A comment that cannot be placed is not reported as broken at all, so a Repair action is never offered that would do nothing.

The scanner now agrees with Obsidian

Underneath all of it, our idea of where a table ends was looser than Obsidian's: any line holding a pipe counted as a row. Obsidian continues an outer-pipe table only while a line starts with a pipe, and a pipe-less one only where the tokenizer still sees Markdown — so an unterminated <!--co: block beneath a pipe-less table is not a row, while a plain line | with a pipe is. It starts a table only below a blank line, a heading or the top of the note. It also accepts a single dash per column, unlike GFM's three.

The scanner mirrors those rules, taken from Obsidian's bundled tokenizer, and moved to src/format/table.ts, which keeps the edit layer free of CodeMirror. It is deliberately stricter than GFM: indented tables, tables in list items or blockquotes, and headers with only one outer pipe all render as plain text in Obsidian (checked in the app), so the scanner doesn't count them.

Compatibility

The storage format is untouched. serialize.ts, escape.ts, parse.ts and types.ts are byte-identical to main, and nothing rewrites existing comments unless you run a repair. Behaviour outside tables is unchanged — clampToTableCells returns a selection that touches no table row exactly as given, except that an end on the blank line directly above a table moves back onto the selected text — and margin positioning falls back to coordsAtPos everywhere outside a table widget.

One deliberate behaviour change: selecting nothing but table borders or cell separators is refused, where it previously wrote a comment around the pipes.

Verification

284 tests pass. Beyond those, verified in Obsidian 1.13.7, Live Preview, driven through the real Add comment command and composer rather than synthetic edits:

selection before after
one cell intact intact
whole row table 3 rows → 1, rows spilled as raw pipe text intact
header row table stopped rendering intact
whole table table stopped rendering intact
cross-cell intact intact
cross-row intact intact
borders only wrote a comment, broke the table refused with a message
just the pipe of an escaped \| cell split in two, marker shown as text intact, markers around \|
paragraph and its newline, commented from Reading view table disappeared intact

The escaped-pipe "before" is the rendering of what main writes for that selection. The Reading view row called the plugin's own Reading view write method directly with that selection, rather than selecting text on the rendered page; its "before" was measured on this branch's previous build, which handled that selection exactly as main does.

Card position, measured in the app: on main the first card's top was 270, which is exactly the table widget's top edge; here it is 317, exactly its row's top. The two lower cards sit below their rows only because stacking pushes them — those rows are 30px apart and the cards are ~70px tall.

Hovering a card promotes its range to the active registry name and marks the card active; hovering the cell raises the right card. Both revert on leave. A table broken by an older comment — including one whose marker filled the blank line above it — shows the notice on that comment's card, and Repair restores the table; undo and redo switch it back and forth, with the notice following. Reading view still renders its highlights inside tables, and Source mode is unaffected — both measure real elements. No errors captured in the app throughout.

On a 10,500-line note with 100 comments, the broken-anchor check takes 0.13 ms per edit, down from 2.53 ms on every editor update.

@kylemcd
kylemcd force-pushed the 79-comments-doesnt-work-well-with-markdown-tables branch 10 times, most recently from 5031ac8 to 178cb7a Compare September 16, 2026 20:59
@kylemcd
kylemcd force-pushed the 79-comments-doesnt-work-well-with-markdown-tables branch from 178cb7a to 80026e6 Compare September 22, 2026 16:02
Closes #79.

Cards piled onto the top of the table. A Live Preview table is a single block
widget, and Obsidian's widget implements no `coordsAt`, so `coordsAtPos` hands
back the widget's own rect for every position inside it — every card in a table
measured to the same point. Measure the rendered cell instead where the anchor
resolves to one; the draft composer takes the same path. An anchor spanning two
rows has no single cell to paint, so it reports the cell it starts in and says
separately whether it ends there.

Commenting a whole row dropped that row and everything below it out of the
table; commenting the whole table stopped it rendering as a table at all. Both
came from the opening marker landing before a row's leading pipe, and Obsidian
keeps an outer-pipe table going only while each line starts with one. A marker
on the delimiter row broke it the same way. Anchors are now pulled inside the
outer pipes of any row they touch, stepping over the delimiter row and trimming
the padding that leaves behind. A selection of nothing but borders is refused
with an explanation. The write and the existing-highlight lookup share one
normalization, so running Add comment twice on the same text still finds the
comment it just made.

The clamp covers the edges of a table too. Whitespace is trimmed before deciding
which ends sit in a table, so a selection dragged in from the paragraph above
starts in the table instead of having its marker dragged onto the header's
border, and one that trims to prose stays on the prose. Obsidian starts a table
only below a blank line, a heading or the top of the note, so a selection that
ends on the blank line above a table (Shift+Down from the start of a paragraph)
keeps its markers on the paragraph. Live Preview puts a filled blank line back
by itself, but Reading view writes to the file directly, where the table
disappeared. A boundary between a backslash and the pipe it escapes widens to
keep `\|` whole: a marker between them un-escapes the pipe, splits the cell in
two and shows the marker as text.

Hovering a card left the text flat and hovering the text never raised its card:
inside a table the highlight is painted with the CSS Custom Highlight API, which
has no element, and every part of that linkage was looking for a
`.doc-comment-span`. The hovered comment is now painted under its own registry
name — the same 18% / 38% pair prose uses — and the painted ranges' client rects
are hit-tested to find the comment under the pointer. A cell is usually one text
node, so mouseover never fires as the pointer crosses into the commented words;
mousemove is tracked while over a table instead.

That fixes new damage but leaves notes that already have it, where the table
stays broken until someone hand-edits an HTML comment — exactly what this plugin
exists to avoid. Detection has to be cheap, because the card needs it live, so
it runs in two stages: a per-comment string check on the marker's own line (does
stripping the markers change whether the line starts with a pipe, ends with one,
reads as a delimiter row, or is blank?), read without splitting the document,
and only for a line that answers yes, a strip-and-rescan across the blank-line
block containing it, since tables never cross one. The margin keeps the result
for the document version it came from, so cursor moves and scrolls don't run it
again. Code-block comments are skipped; their markers sit on lines of their own
by design. Repair reuses the clamp and rebuilds the whole block with every
anchor reinserted, not just the repaired ones — a table broken by two anchors
only reappears once both are out of the way, and a healthy neighbour's markers
have to come back exactly where they were. Only markers the parser recognized
are taken out and put back; marker-shaped text inside code, or hidden from the
parser by a stray backtick, is left exactly as it was. Markers go back in one
forward pass, so an anchor nested inside the one being repaired stays intact,
and a comment with no markers in the block, such as an orphan whose text was
deleted, is skipped rather than blocking the repair. A comment that cannot be
placed is not reported as broken, so a Repair action is never offered that would
do nothing.

Discovery is the card, which already sits beside the damage: a notice saying the
comment is breaking its table, and a one-click Repair. The command repairs the
whole note at once, the way out when several anchors broke the same table.
Nothing is rewritten unless asked, and both paths are a single undo step. The
notice deliberately does not ask the margin to reposition — reconcile runs
inside CodeMirror's update cycle and repositioning reads layout, which throws
there and takes the whole margin plugin down with it.

Underneath all of it, our idea of where a table ends disagreed with Obsidian's.
Any line holding a pipe counted as a row. Obsidian continues an outer-pipe table
only while a line starts with a pipe, and a pipe-less one only where the
tokenizer still sees Markdown — so an unterminated `<!--co:` block beneath a
pipe-less table is not a row, while a plain `line | with a pipe` is — and it
starts a table only below a blank line, a heading or the top of the note. The
scanner mirrors those rules now, taken from Obsidian's bundled tokenizer, and
moved to format/table.ts so the edit layer stays free of CodeMirror. It is
deliberately stricter than GFM: indented tables, tables in list items or
blockquotes, and headers with only one outer pipe render as plain text in
Obsidian, so they aren't counted.

The storage format is untouched: serialize.ts, escape.ts, parse.ts and types.ts
are byte-identical to main, and nothing rewrites existing comments unasked.

Verified in Obsidian 1.13.7, Live Preview, through the real Add comment command
and composer. All seven selection shapes leave the table rendering (borders-only
refuses); a card measures to its row's exact top where stacking allows it, where
before it measured to the table widget's exact top edge; both hover directions
work; a broken anchor shows the notice on its own card only, repairs on click or
by command, and undoes with one keystroke. Selections dragged in from the text
around a table, a Reading view comment ending on the blank line above one, and a
comment on the pipe of an escaped `\|` all leave the table rendering. On a
10,500-line note with 100 comments the broken-anchor check takes 0.13 ms per
edit, down from 2.53 ms on every update.
@kylemcd
kylemcd force-pushed the 79-comments-doesnt-work-well-with-markdown-tables branch from 80026e6 to a484c36 Compare September 22, 2026 16:51
@kylemcd
kylemcd marked this pull request as ready for review September 22, 2026 16:52
@kylemcd
kylemcd merged commit b5aed8c into main Sep 22, 2026
1 check passed
@kylemcd
kylemcd deleted the 79-comments-doesnt-work-well-with-markdown-tables branch September 22, 2026 17:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Comments doesn't work well with markdown tables

1 participant