Skip to content

fix(super-editor): keep comment range markers around unpaired tracked changes (SD-2528)#3239

Open
tupizz wants to merge 8 commits into
mainfrom
tadeu/sd-2528-redline-comment-roundtrip
Open

fix(super-editor): keep comment range markers around unpaired tracked changes (SD-2528)#3239
tupizz wants to merge 8 commits into
mainfrom
tadeu/sd-2528-redline-comment-roundtrip

Conversation

@tupizz
Copy link
Copy Markdown
Contributor

@tupizz tupizz commented May 11, 2026

Demo

After export-import

CleanShot 2026-05-14 at 08 13 24@2x

Summary

When a user comments on redlined text, the exported DOCX put w:commentRangeStart outside the w:del wrapper but w:commentRangeEnd and the w:commentReference run inside it. Re-importing that file separates the comment from the redline (and from itself) instead of restoring a single unified bubble. Customer-reported (Bonterms).

Root cause is in mergeConsecutiveTrackedChanges (translate-paragraph-node.js). The forward scan greedily absorbed every comment marker / comment-reference run it found after a w:ins/w:del, even when no same-id wrapper actually followed to merge with. So a lone tracked change always swallowed its trailing comment markers.

Fix: buffer comment markers during the scan and only commit them inside the wrapper when a same-id merge actually happens. Otherwise emit them as siblings.

  • w:commentRangeStart ... w:del ... w:commentRangeEnd ... w:r/w:commentReference now stays as siblings (SD-2528).
  • SD-1519 same-id merge with comments between is still preserved and now covered by a regression test.

Test plan

  • Unit tests in translate-paragraph-node.test.js cover three cases: SD-2528 (single wrapper + trailing comments), SD-1519 (same-id merge absorbs comments), different-id (no merge, comments stay siblings).
  • Full pnpm --filter super-editor test passes (1008 files, 12711 tests).
  • Round-trip a fixture with a comment on a redline in Word and confirm the unified bubble survives.

… tracked changes (SD-2528)

mergeConsecutiveTrackedChanges greedily absorbed trailing w:commentRangeEnd
and w:r->w:commentReference elements into a w:del/w:ins wrapper even when no
same-id wrapper followed to actually merge into. This produced a lopsided
structure where w:commentRangeStart sat outside the wrapper but
w:commentRangeEnd ended up inside it, breaking comment round-trip on redlined
text.

Buffer comment markers during the forward scan and only commit them inside
the wrapper when a same-id merge actually happens. Otherwise emit them as
siblings, matching Word's expected OOXML.

SD-1519 merge behavior is unchanged and covered by the new tests.
@tupizz tupizz requested a review from a team as a code owner May 11, 2026 22:48
@linear
Copy link
Copy Markdown

linear Bot commented May 11, 2026

SD-2528

@github-actions
Copy link
Copy Markdown
Contributor

Note: The MCP ecma-spec tools were not granted permission, so I'm reviewing against my working knowledge of ECMA-376 Part 1 (WordprocessingML).

Status: PASS

The PR doesn't change the OOXML vocabulary being emitted β€” it only adjusts where comment range markers land relative to a tracked-change wrapper. Quick verification against the spec:

  • w:ins / w:del use CT_RunTrackChange, whose content model permits both EG_RangeMarkupElements (the w:commentRangeStart/End family) and w:r (which is the legal carrier for w:commentReference). So comment markers being inside or outside the wrapper are both spec-valid β€” the fix is about round-trip pairing, not spec legality. (w:ins, w:del)
  • Attributes on the fixtures are clean: w:del carries w:id/w:author/w:date (CT_TrackChange β€” w:id and w:author required, w:date optional βœ“), w:commentRangeStart/End carry the required w:id (CT_MarkupRange βœ“), and w:commentReference carries the required w:id (CT_Markup βœ“). (w:commentRangeStart, w:commentReference)
  • No new attributes or elements are introduced, and no defaults are being assumed.

The AIDEV-NOTE correctly captures the real motivation: the previous behavior could end up with w:commentRangeStart outside the wrapper and w:commentRangeEnd absorbed inside it, which β€” while structurally legal β€” breaks the importer's pairing assumptions. The new behavior preserves the marker siblings when no merge happens, which is the right call.

@tupizz tupizz self-assigned this May 11, 2026
@codecov-commenter
Copy link
Copy Markdown

Codecov Report

βœ… All modified and coverable lines are covered by tests.

πŸ“’ Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

@luccas-harbour luccas-harbour left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @tupizz!
I performed the same steps that were shown in the loom video that was in the ticket and I can see that the problems persist:

  • After recreating the same document as in the video, exporting it and loading it into SD again, the comments are displayed separately instead of threaded
  • If I accept the added track change, the comment reply does not go away
  • If I accept the replacement track change, the comment reply doesn't go away either
    Can you check?

…rip (SD-2528)

The previous fix left commentRangeStart as a sibling of w:ins/w:del.
documentCommentsImporter.js' extractCommentRangesFromDocument only
associates a comment with a tracked change when commentRangeStart sits
inside the wrapper, so the sibling shape silently dropped the comment
to TC link on re-import.

Fold any leading commentRangeStart sibling into the immediately following
w:ins/w:del as its first child, matching the shape Word produces. The
existing SD-1519 same-id merge for trailing commentRangeEnd and
w:r/w:commentReference stays unchanged.

Adds an end-to-end test that loads the Google-Docs TC+comment fixture,
exports it, re-imports the exported XML, and asserts every comment that
was originally inside a tracked change still carries
trackedChangeParentId after the round-trip.
tupizz added 2 commits May 13, 2026 18:38
…ts (SD-2528)

A user comment anchored to a tracked change carries trackedChangeParentId
pointing at the TC. Two bugs broke the link end-to-end:

1. docxImporter built two tracked-change id maps independently
   (trackedChangeIdMap and trackedChangeIdMapsByPart), each minting fresh
   UUIDs for the same Word w:id. The comments importer used the global
   map; ins/del translators used the per-part map. The two never matched,
   so trackedChangeParentId on a comment never pointed at the actual TC
   mark id in the PM doc. Fix: build the per-part maps first and reuse
   the document.xml entry as the global map.

2. The comments-store resolve handler only resolved the TC's own
   redline-display entry. User comments with trackedChangeParentId === the
   resolved TC stayed open. Fix: after resolving the TC entity, iterate
   commentsList and resolve every comment whose trackedChangeParentId
   matches. Defer via Promise.resolve so the cascading resolveComment
   doesn't dispatch into a still-running accept/rejectTrackedChangeById
   loop and collide with the loop's mutable tr.

E2E browser repro on the real Google-Docs TC+comment fixture: accept TC
by id, both the TC and its anchored user comments resolve in one user
action. Same for reject. No mismatched-tr errors.

The export-side round-trip test also asserts the two id maps are
aligned and every comment trackedChangeParentId matches a real
tracked-change mark id in the PM doc.
@tupizz tupizz requested a review from luccas-harbour May 13, 2026 22:04
… on re-import (SD-2528)

A reply that the user typed under a tracked-change bubble has
parentCommentId pointing at the synthetic TC entity in the comments
store. On export the TC parent is filtered out of comments.xml
(TC entries are not real comments), so the reply lands in the file
without any paraIdParent. On re-import the reply gets trackedChangeParentId
via the document.xml walker (the commentRange wraps the TC text) but
parentCommentId was left undefined β€” the sidebar then renders the reply
as a separate top-level bubble next to the TC instead of nested under it,
matching the user-reported regression in image 1 of SD-2528.

Promote trackedChangeParentId to parentCommentId when no explicit
parent is set. CommentDialog already threads via direct
parentCommentId === trackedChangeId (line 321), so this is the
cheapest path to restore the live pre-export state.

Round-trip stable: re-export still filters TC parents but re-emits the
commentRange inside the wrapper, which gets re-detected on the next
import via extractCommentRangesFromDocument and re-establishes the
linkage.
@tupizz tupizz marked this pull request as draft May 13, 2026 22:26
@tupizz
Copy link
Copy Markdown
Contributor Author

tupizz commented May 13, 2026

thanks @luccas-harbour I missunderstood the requirements

…n (SD-2528)

The UI guarded TC reply threading with isRangeThreadedComment, which is
true only when the source DOCX has no commentsExtended.xml (Google Docs
style). SuperDoc-exported DOCX files always write commentsExtended.xml,
so on re-import the guard short-circuited and the reply rendered as a
top-level bubble next to its TC instead of nested under it.

Drop the file-origin guard from the two sites that threaded TC replies:
collectTrackedChangeThread in CommentDialog.vue and
shouldThreadWithTrackedChange in comments-store.js.
trackedChangeParentId pointing at a tracked-change entity is sufficient
to thread; file origin should not change whether a comment threads under
its TC.

Reverts the earlier importer-side patch that promoted
trackedChangeParentId into parentCommentId. That patch violated the
comment-diffing contract (parentCommentId is diffed; trackedChangeParentId
is intentionally ignored because it is regenerated across imports) and
broke six existing tests. The UI-side change is surgical and breaks no
tests.
@tupizz tupizz marked this pull request as ready for review May 14, 2026 11:13
tupizz added 2 commits May 14, 2026 08:13
…RTED/resolve gates (SD-2528)

Three visual round-trip regressions after the SD-2528 fix made TC replies
thread again:

1. CommentHighlightDecorator painted its pink (external) / green (internal)
   inline background on every element with the superdoc-comment-highlight
   class β€” including text that already carries a track-insert-dec /
   track-delete-dec decoration. The inline style won over the TC's own CSS
   class background, so a green trackInsert came back pink after re-import.
   Skip the BG override when the element is also a tracked-change decoration:
   the TC color (green for insert, red for delete) is the right signal for
   the user, and the comment range is still visually identified by its
   dashed border + sidebar bubble.

2. CommentHeader's IMPORTED tag fired whenever comment.origin or
   importedAuthor was set β€” including comments authored by the current user
   in a previous session. Round-tripping a file you exported then re-opened
   should not relabel your own comments as imported. Suppress the tag when
   the comment's creatorEmail matches the current user's email.

3. CommentHeader's allowResolve guard treated parentCommentId as the only
   marker of a child comment. A TC-anchored reply on re-import keeps the
   linkage through trackedChangeParentId only (parentCommentId is left
   undefined to preserve the comment-diffing contract). The resolve check
   affordance therefore appeared on re-imported replies even though the
   pre-export state had no parentCommentId either. Treat
   trackedChangeParentId as an equivalent child signal.

All three are surgical render-side gates β€” no converter / data-model
changes. 1369 super-editor presentation tests pass.
Copy link
Copy Markdown
Contributor

@luccas-harbour luccas-harbour left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @tupizz!
do you mind checking these?

- [P2] Restrict cascading resolve to the active document β€” packages/superdoc/src/stores/comments-store.js:690-695
  When a tracked-change resolve event includes `documentId`, `findTrackedChangeById()` filters to that document, but this new cascade scans every item in `commentsList`. In multi-document sessions where imported/live tracked-change ids are reused across documents, accepting or rejecting a change in one document will also resolve comments attached to the same `trackedChangeParentId` in another document.

- [P2] Only suppress comment fill for highlighted redlines β€” packages/super-editor/src/editors/v1/core/presentation-editor/dom/CommentHighlightDecorator.ts:184-186
  This treats any base tracked-change class as having its own background, but final/original modes still render visible spans with `track-insert-dec`/`track-delete-dec` and no `.highlighted` background, and format changes only have a border. In those modes a comment anchored on the text has its comment background cleared with no TC background replacing it, making the comment highlight disappear.

- [P2] Preserve regular parent threads for TC-anchored replies β€” packages/superdoc/src/components/CommentsLayer/CommentDialog.vue:327-329
  This now pulls every comment with `trackedChangeParentId` into the tracked-change dialog, even when the comment also has a `parentCommentId` pointing to a regular comment thread. The importer supports that shape for replies whose range is inside a TC but whose parent spans outside it, so those replies will appear both under their real parent and inside the TC dialog without the parent context.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants