Fix comments on Markdown tables - #82
Merged
Merged
Conversation
kylemcd
force-pushed
the
79-comments-doesnt-work-well-with-markdown-tables
branch
10 times, most recently
from
September 16, 2026 20:59
5031ac8 to
178cb7a
Compare
kylemcd
force-pushed
the
79-comments-doesnt-work-well-with-markdown-tables
branch
from
September 22, 2026 16:02
178cb7a to
80026e6
Compare
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
force-pushed
the
79-comments-doesnt-work-well-with-markdown-tables
branch
from
September 22, 2026 16:51
80026e6 to
a484c36
Compare
kylemcd
marked this pull request as ready for review
September 22, 2026 16:52
kylemcd
deleted the
79-comments-doesnt-work-well-with-markdown-tables
branch
September 22, 2026 17:05
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 #79.
Recorded in Obsidian 1.13.7, Live Preview, driving the real Add comment command. Same note and same actions against
mainand 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— socoordsAtPosreturns 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:
\|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
mouseovernever fires as the pointer crosses into the commented words —mousemoveis 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.
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 plainline | with a pipeis. 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.tsandtypes.tsare byte-identical tomain, and nothing rewrites existing comments unless you run a repair. Behaviour outside tables is unchanged —clampToTableCellsreturns 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 tocoordsAtPoseverywhere 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:
\|\|The escaped-pipe "before" is the rendering of what
mainwrites 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 asmaindoes.Card position, measured in the app: on
mainthe 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.