Skip to content

fix(ui): restore cross-block annotations over list markers and alert titles from drafts - #1542

Merged
backnotprop merged 4 commits into
mainfrom
fix/restore-list-items-and-alert-fromstore
Sep 15, 2026
Merged

backnotprop merged 4 commits into
mainfrom
fix/restore-list-items-and-alert-fromstore

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Closes the two restore shapes that still failed closed after #1535/#1541: a selection spanning two list items, and a drag from a GitHub alert's icon through its title into its body restored from a draft. Both came back from a reload with the comment in the panel and no highlight anywhere in the document.

The two causes

List markers were painted but never selected. ListMarker is select-none, so no browser puts the bullet or numeral in a selection string — it is absent from the quote an annotation stores. It was not absent from what the highlighter painted, so a restore spanning two items resolved onto a•b while its quote read a\n\nb; compactText only removes whitespace, the bullet survived it, the content verification rejected a perfectly correct restore, and the text-search rescue could not bridge the blocks either.

Stored positions can resolve into excluded chrome. web-highlighter's textOffset counts every text node under the parent it records — the alert's visually hidden "Tip: " included — and its resolver puts a boundary landing exactly at one text node's end back onto that node. The start therefore resumed inside the hidden span, which painting never enters, so every run before the last one was dropped. Creation was already fine (#1541 snaps the live range); only the draft path was.

And the rescue could not bridge a block boundary at all. The document's text nodes are joined with nothing between them while the browser's selection string carries a blank line, so no cross-block quote could ever match the whitespace-collapsing fallback.

What changed

  1. ListMarker carries annotation-exclude, so the painted text and the stored quote agree by construction. The verification reads painted text through the same exclusion instead of raw textContent (chrome inside a wrapper can never reintroduce the asymmetry), and the manual text-search wrapper skips excluded runs the way the search that produced its range does. Block starts are normalized as whitespace on the haystack side of the search — the same place the needle already has it.
  2. A tap on web-highlighter's Serialize.Restore hook moves either restored boundary onto the nearest annotatable text node, refusing the snap if it would invert the range. Normalizing the metas at creation time is not available — they are only meaningful in the resolver's own coordinates, which count the excluded text — and it would do nothing for drafts already on disk.
  3. The Unanchored chip now covers markdown surfaces. useAnnotationHighlighter reports each restore pass (onRestoreReport: what it attempted, which of those ended unanchored), the Viewer forwards it, and the editor keeps a markdown set beside the HTML one; an id the pass re-anchors clears its own chip, and a document or message change clears the set.

Edit Mode diagnosis (item 3)

Not a separate bug. applyEditedDocument re-anchors by looking for a block whose content contains the quote, which no single block does for a quote spanning two — so every cross-block annotation loses its stored positions on every edit-mode commit, whether or not the edit came near it. Text search was then the only path left, and it could not bridge a boundary, so one word changed in an unannotated list item unpainted every cross-block comment at once ("5 annotations no longer match the text"). Fixed by the search change in commit 1; packages/editor/App.editModeCrossBlockHighlights.test.tsx pins the whole loop through the real App.

Browser verification

Re-ran the QA harness against this build (plan review + annotate, ports 19850-19855), same seven shapes, same fixtures:

# shape plan review annotate marks
A paragraph → paragraph painted painted 2
B heading → paragraph painted painted 2
C two list items painted painted 2 (no bullet)
D paragraph → code fence painted painted 4
E alert icon → title → body painted painted 2
F single paragraph painted painted 1
G prose containing "Tip:" painted painted 1

Zero restore warnings on the console on either surface (previously four, for C and E). Export quotes unchanged and correct on both: "First list item about caching behaviour\nSecond list item about retry" with no bullet, "Browser quirks\nSafari clamps the selection" with no hidden "Tip: ", and the reviewer's own "Tip: real prose" intact. Edit Mode: 7 highlights before an edit to an unannotated list item, 7 after (previously 5 → 2). Drift is still fail-closed — positions drifted with the quote still present are rescued onto the right text, positions drifted with the quote gone paint nothing and now carry the Unanchored chip.

Checks

bun run typecheck, DOM_TESTS=1 bun test packages/ui packages/editor (1732 pass; the 4 DocBadges failures are pre-existing on origin/main in the same whole-suite run and pass in isolation), bun test scripts/dom-test-allowlist.test.ts, bun run --cwd packages/ui smoke:package, bun run --cwd apps/guides-show check:manifest.

Three DOM-gated test files added and registered in .github/workflows/test.yml; Viewer.crossBlockRestore.test.tsx's "two list items" fixture was corrected to the real browser shape (it previously put the bullet into originalText, which no browser does, and passed for the wrong reason).

…idge block boundaries in the text search

A list marker is `select-none`, so no browser puts its bullet or numeral in
a selection string: it is absent from the quote an annotation stores. It was
not absent from what the highlighter painted, though, so a selection spanning
two list items restored onto "a<bullet>b" while its quote read "a\n\nb", the
content verification rejected a perfectly good restore, and the annotation
came back from a reload with no highlight at all.

Marked the marker `annotation-exclude` so the painted text and the stored
quote agree by construction, and made the restore comparison read painted text
through the same exclusion rather than raw `textContent`, so chrome that ends
up inside a wrapper can never reintroduce the asymmetry. The manual text-search
wrapper now skips excluded runs too, matching the search that produced its
range.

The text-search rescue could not bridge a block boundary either: the document's
text nodes are joined with nothing between them while the browser's selection
string carries a blank line, so a cross-block quote had no match in the
whitespace-collapsing fallback. Block starts are now normalized as whitespace
on the haystack side, which is the same place the needle already has it.
…range is snapped

web-highlighter's stored `textOffset` counts every text node under the parent
it records, including `.annotation-exclude` chrome, and its resolver puts a
boundary that lands exactly at one text node's end back onto THAT node. A drag
from a GitHub alert's icon through its title into its body therefore stored a
start of 5 — the length of the hidden "Tip: " — and restored INTO the hidden
span, which painting never enters: every run before the last one was dropped,
the content verification rejected what was left, and the annotation came back
from its own draft with no highlight.

Tapped web-highlighter's `Serialize.Restore` hook to move either boundary onto
the nearest annotatable text node, refusing the snap if it would invert the
range. Normalizing the metas at creation time instead is not available: they are
only meaningful in the resolver's own coordinates, which count the excluded
text — and it would do nothing for the drafts already on disk.
…ss-block highlights

Diagnosis for the Edit Mode symptom: `applyEditedDocument` re-anchors by
looking for a block whose content contains the annotation's quote, which no
single block does for a quote spanning two — so every cross-block annotation
loses its stored positions on every commit, whether or not the edit came near
it. That left the text search as the only path, and it could not bridge a block
boundary, so one word changed in an unannotated list item unpainted every
cross-block comment at once.

The cause is the search, fixed in the first commit of this branch; this is the
App-level guard over the whole loop: restore a cross-block comment from a
draft, edit a word elsewhere through the real edit session, and it is still
painted over both paragraphs afterwards.
A markdown restore that fails closed left no user-visible signal: the
verification drops a highlight whose stored positions resolved onto the wrong
text, the text-search rescue comes up empty, and the comment sits in the panel
pointing at nothing while only the console says so. The panel already renders
an "Unanchored" chip for exactly this, but it was wired to the HTML surface
alone.

`useAnnotationHighlighter` now reports each restore pass (`onRestoreReport`:
what it attempted, and which of those ended with no highlight), the Viewer
forwards it, and the editor keeps a markdown unanchored set beside the HTML
one — an id the pass re-anchored clears its own chip, and a document or message
change clears the set. Both sets feed the panel prop that already existed.
@backnotprop
backnotprop merged commit 594f52b into main Sep 15, 2026
28 checks passed
@backnotprop
backnotprop deleted the fix/restore-list-items-and-alert-fromstore branch September 15, 2026 15:33
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.

1 participant