Skip to content

feat(super-editor): render imported heading/bookmark cross-references (SD-2495)#2882

Open
tupizz wants to merge 4 commits intomainfrom
tadeu/sd-2495-cross-references
Open

feat(super-editor): render imported heading/bookmark cross-references (SD-2495)#2882
tupizz wants to merge 4 commits intomainfrom
tadeu/sd-2495-cross-references

Conversation

@tupizz
Copy link
Copy Markdown
Contributor

@tupizz tupizz commented Apr 21, 2026

Three features + one pre-existing bug fix, packaged under the Internal Anchors epic (SD-2495).

What ships

Ticket What changes for the user
SD-2536 — Import model Word cross-references (REF / NOTEREF / STYLEREF fields) now survive DOCX import. Before this PR, every REF field was silently dropped.
SD-2537 — Render Cross-references render in the document with their Word-cached text. \h variants are clickable and navigate to the target bookmark.
SD-2454 — Bookmark brackets Optional Word-style gray [ / ] indicators around bookmarked content. Off by default, toggleable.
IT-949 — Customer bug Closed by SD-2536/2537. "Section 15" now renders in the Brillio lease.

Why it was broken (one root cause, two symptoms)

The sd:crossReference v3 translator existed but was never wired into the v2 importer's entity list. The passthrough fallback refused to wrap it (because it was "registered"), and no entity claimed it, so the dispatch loop dropped every REF field on the floor. On top of that, even if the node had reached the renderer, #scrollPageIntoView wrote scrollTop to the wrong DOM element, so click-to-navigate silently no-op'd on any real consumer layout.

How the fix is structured

DOCX import                         Render                            Click
─────────────                       ──────                            ─────
v2 importer                         pm-adapter                        EditorInputManager
   │                                   │                                 │
   ├─ NEW crossReferenceImporter.js   ├─ cross-reference.ts             ├─ handleLinkClick
   │   (the missing wire-up)         │   never returns null;            │   generalized anchor
   │                                 │   synthesizes link mark           │   routing to goToAnchor
   │                                 │   when instruction has \h         │
   ├─ extractResolvedText            │                                  │
   │   recursive walk                └─ bookmark-start.ts / -end.ts     │
   │                                     emit [ ] markers when           │
   └─ ref-preprocessor                   showBookmarks is on            │
       (unchanged)                                                       ▼
                                                                   PresentationEditor
                                                                     scrollPageIntoView
                                                                     writes to real
                                                                     scroll container

Files at a glance

SD-2536 (import)

  • v2/importer/crossReferenceImporter.js — new, 3-line entity bridge
  • v2/importer/docxImporter.js — registers entity in dispatcher list
  • v3/handlers/sd/crossReference/crossReference-translator.js — recursive text walk

SD-2537 (render + navigate)

  • pm-adapter/.../cross-reference.ts — always emits TextRun; synthesizes link from \h
  • presentation-editor/pointer-events/EditorInputManager.ts — any #<bookmark> click routes to goToAnchor (was TOC-only)
  • presentation-editor/PresentationEditor.ts#scrollPageIntoView writes to #scrollContainer

SD-2454 (bracket indicators)

  • pm-adapter/.../bookmark-start.ts, bookmark-end.ts — emit [ / ] TextRuns when enabled
  • pm-adapter/converter-context.tsshowBookmarks flag + renderedBookmarkIds to pair suppression
  • painters/dom/src/renderer.ts + styles.ts — gray CSS, hover tooltip from bookmark name
  • super-editor/PresentationEditor.tssetShowBookmarks(boolean) runtime toggle
  • superdoc/src/core/SuperDoc.js — public API passthrough
  • superdoc/src/dev/components/SuperdocDev.vue — toolbar toggle button

How to verify

Open the dev app, upload a DOCX with cross-references:

  • Text in the viewer matches Word. "Section 15" (and every other REF) renders.
  • Italic numbers with underline = hyperlinked cross-refs. Click one → viewport scrolls to the target section.

Open a doc with user-named bookmarks:

  • Click the new Show bookmarks button in the toolbar → gray [name]…[ brackets appear. Hover for the tooltip with the bookmark name. Click again to hide.

Programmatic consumers:

new SuperDoc({,
  layoutEngineOptions: { showBookmarks: true },
});
// or at runtime
superdocInstance.setShowBookmarks(true);

Tests

Suite Count Status
@superdoc/pm-adapter (includes new cross-reference.test.ts, bookmark-markers.test.ts) 1752
super-editor (includes new integration test, EditorInputManager.anchorClick.test.ts, cross-ref translator extensions) 11407

Regression guards worth noting:

  • crossReferenceImporter.integration.test.js asserts the entity is a member of the v2 entities list — if a future refactor drops the wire-up, this breaks immediately.
  • EditorInputManager.anchorClick.test.ts pins the generalized anchor routing so nobody reverts the branch to TOC-only.
  • bookmark-markers.test.ts covers the orphan-bracket suppression (bookmarkEnd won't emit ] if the matching start was suppressed).

Test plan

  • Brillio lease (IT-949 repro): "Section 15" renders; 55/55 REFs import; clicking any xref scrolls to its target
  • mbhpc-wood: all switch combos (\w \h, \r \h, \w \n \h) import; click navigation works
  • bookmark_use_cases.docx: all 8 user-authored bookmarks show brackets when toggle is on; auto-generated _Toc…/_Ref… stay hidden even when on; brackets disappear when toggled off
  • pm-adapter + super-editor unit + integration suites green
  • Layout corpus regression via pnpm test:layout — in CI / follow-up
  • Playwright behavior test for click-to-navigate — follow-up

Explicitly out of scope (noted for the record)

  • \w / \r / \n outline-number recomputation (F9 in Word). We use Word's cached text as-is.
  • UI for inserting cross-references — that's SD-2345 under the authoring epic SD-2490.
  • Same missing-v2-wiring bug for tableOfContentsEntry, sequenceField, citation, authorityEntry, tableOfAuthorities — separate follow-up ticket.
  • Search/find normalization for NBSP inside cross-references — minor, separate.

… (SD-2495)

Wire the sd:crossReference v3 translator into the v2 importer entity list so
REF / NOTEREF / STYLEREF fields imported from DOCX actually produce PM
crossReference nodes instead of being silently dropped by the dispatch loop.
Walk nested run wrappers when extracting the field's cached display text, and
render the cross-reference as an internal hyperlink when the instruction
carries the \\h switch. Route clicks on internal #bookmark anchors through
goToAnchor so rendered cross-references navigate to their target in the
document.

Fixes IT-949 — Word cross-references (e.g. "Section 15") now appear in the
viewer and are searchable, matching Word's output.
@github-actions
Copy link
Copy Markdown
Contributor

Status: PASS

The PR is spec-compliant. Here's what I checked:

w:fldChar / w:fldCharType (§17.18.29) — The decode function emits three runs with fldCharType values begin, separate, and end. These are the exact three enumeration values defined by ST_FldCharType. ✓

REF field switches (§17.16.5.51) — The parseDisplay function maps:

  • \nnumberOnly — spec: "entire paragraph number without trailing periods" ✓
  • \wnumberFullContext — spec: "paragraph number in full context" ✓
  • \paboveBelow — spec: "position relative to the source bookmark using 'above' or 'below'" ✓
  • \rparagraphNumber — spec: "entire paragraph number in relative context" ✓

\h switch and hyperlink creation (§17.16.5.51) — The spec states \h "Creates a hyperlink to the bookmarked paragraph." The cross-reference renderer correctly synthesizes an internal link pointing at #<target> when \h is detected via /\\h\b/. The word-boundary anchor in the regex correctly avoids matching h inside bookmark names like bh-target. ✓

Field structure — The decoded output follows the spec's required complex field pattern: a begin run, an instruction run (via buildInstructionElements), an optional separate run, content runs, and an end run. The spec example at §17.16.18 uses exactly this layout. ✓

One non-blocking observation: parseDisplay uses a priority-ordered includes chain, so a combined instruction like REF target \n \p would be classified as numberOnly and the \p modifier ignored. The spec notes that \p can be used in conjunction with \n, \r, and \w — so combined-switch handling is underspecified here, though it's a missing feature rather than a violation. The \t suppression switch (valid in REF per spec) is similarly unhandled, which is consistent with not supporting combined switches yet.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 21, 2026

Codecov Report

❌ Patch coverage is 47.05882% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/superdoc/src/core/SuperDoc.js 47.05% 9 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 62179b6a5f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +31 to +35
if (target && /\\h\b/.test(instruction)) {
const synthesized = buildFlowRunLink({ anchor: target });
if (synthesized) {
run.link = run.link ? { ...run.link, ...synthesized, anchor: target } : synthesized;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve link boundaries for synthesized cross-reference links

This code now attaches run.link for \h cross-references, but paragraph coalescing still merges adjacent text runs without considering link metadata (mergeAdjacentRuns in converters/paragraph.ts). If a cross-reference run is adjacent to same-styled plain text (a common inline REF layout), the merge step can drop the synthesized link entirely or extend it onto neighboring text, so click-to-anchor behavior becomes inconsistent and surrounding words may become incorrectly linked. These synthesized link runs need to be excluded from merge compatibility (or merge logic must compare run.link).

Useful? React with 👍 / 👎.

tupizz added 2 commits April 21, 2026 12:12
…oll container

#scrollPageIntoView wrote scrollTop to #visibleHost, which is typically
overflow: visible and therefore not the actual scroll target. Anchor
navigation (TOC clicks and SD-2495 cross-reference click-to-navigate)
silently did nothing whenever the bookmark target was outside the
current viewport — the PM selection moved but the viewport never
scrolled.

Write to #scrollContainer (the resolved scrollable ancestor) as the
primary target, plus #visibleHost for backward compatibility with
legacy layouts and the existing test harness that mocks scrollTop on
the host element.

This unblocks SD-2495's cross-reference click-to-navigate on docs
where cross-references and their targets live on different pages.
Opt-in visual indicators for bookmark positions — mirrors Word's "Show
bookmarks" (File > Options > Advanced). Off by default.

- Pm-adapter bookmark-start and new bookmark-end converters emit gray `[`
  and `]` marker TextRuns when `layoutEngineOptions.showBookmarks` is
  true. Markers flow through pagination and line breaking as real
  characters, matching Word's own visual model.
- Auto-generated bookmarks (`_Toc…`, `_Ref…`, `_GoBack`) are hidden even
  when the feature is on — matching Word. A `renderedBookmarkIds` set on
  the converter context pairs suppression so closing brackets don't
  orphan open ones.
- PresentationEditor.setShowBookmarks toggles at runtime: clears the
  flow-block cache and schedules a re-render.
- SuperDoc.setShowBookmarks is the public API passthrough.
- Dev app gets a Show/Hide bookmarks toggle button in the header.
- CSS: subtle gray, non-selectable so users don't include brackets in
  copied text. Bookmark name surfaces via the native title tooltip on
  the opening bracket.
@tupizz tupizz self-assigned this Apr 21, 2026
Fills the test gaps surfaced by the testing-excellence review of this PR:

- crossReferenceImporter.integration.test.js (new, 4 tests): exercises the
  full v2 body pipeline (preprocessor -> dispatcher -> entity handler ->
  v3 translator). Asserts crossReferenceEntity is a member of the
  defaultNodeListHandler entities list, so the exact root cause that
  produced IT-949 ("Section 15" vanishing) fails loudly if a future
  refactor drops the wire-up. Unit tests of the translator alone cannot
  catch this — they bypass the dispatcher.

- EditorInputManager.anchorClick.test.ts (new, 4 tests): pins the
  SD-2537 click-to-navigate routing. Clicking #bookmark hrefs routes
  through goToAnchor (was TOC-only before). External and empty-fragment
  hrefs are explicitly NOT routed.

- cross-reference.test.ts: added marks-propagation test (node.marks flow
  into the emitted TextRun so italic/textStyle on xref text survives —
  SD-2537 "preserve surrounding run styling" AC).

- bookmark-markers.test.ts: converted the `for` loop over auto-generated
  bookmark names into `it.each`. Each input now reports per-case on
  failure, complies with testing-excellence's "no control flow inside
  test bodies" guideline.

- PresentationEditor.test.ts: documents why the scrollContainer-vs-
  visibleHost branch of the SD-2495 scrollPageIntoView fix isn't unit-
  testable here (happy-dom doesn't propagate inline overflow through
  getComputedStyle, which is what findScrollableAncestor uses).
caio-pizzol pushed a commit to kendaller/superdoc that referenced this pull request Apr 22, 2026
Behavior test (tests/behavior/tests/navigation/pageref-standalone-click.spec.ts):
Covers the PR's load-bearing case - a PAGEREF \h field NOT wrapped in a
<w:hyperlink>. The existing toc-anchor-scroll.spec.ts only exercises the
wrapped-in-hyperlink shape, where the outer link mark already propagates
via marksAsAttrs and the PR is a no-op. Fixtures exercise both \h and \H
(case-insensitivity per ECMA-376 17.16.1).

Preprocessor unit tests (ref/noteref/styleref):
These three importer modules were added in superdoc-dev#2882 without tests. Each
verifies the preprocessor produces a sd:crossReference node with the
right fieldType and preserves the instruction text verbatim.
caio-pizzol added a commit that referenced this pull request Apr 22, 2026
…#2899)

* feat(super-editor): render imported heading/bookmark cross-references (SD-2495)

Wire the sd:crossReference v3 translator into the v2 importer entity list so
REF / NOTEREF / STYLEREF fields imported from DOCX actually produce PM
crossReference nodes instead of being silently dropped by the dispatch loop.
Walk nested run wrappers when extracting the field's cached display text, and
render the cross-reference as an internal hyperlink when the instruction
carries the \\h switch. Route clicks on internal #bookmark anchors through
goToAnchor so rendered cross-references navigate to their target in the
document.

Fixes IT-949 — Word cross-references (e.g. "Section 15") now appear in the
viewer and are searchable, matching Word's output.

* fix(presentation-editor): anchor nav writes scrollTop to the real scroll container

#scrollPageIntoView wrote scrollTop to #visibleHost, which is typically
overflow: visible and therefore not the actual scroll target. Anchor
navigation (TOC clicks and SD-2495 cross-reference click-to-navigate)
silently did nothing whenever the bookmark target was outside the
current viewport — the PM selection moved but the viewport never
scrolled.

Write to #scrollContainer (the resolved scrollable ancestor) as the
primary target, plus #visibleHost for backward compatibility with
legacy layouts and the existing test harness that mocks scrollTop on
the host element.

This unblocks SD-2495's cross-reference click-to-navigate on docs
where cross-references and their targets live on different pages.

* feat(layout): show-bookmarks bracket indicators (SD-2454)

Opt-in visual indicators for bookmark positions — mirrors Word's "Show
bookmarks" (File > Options > Advanced). Off by default.

- Pm-adapter bookmark-start and new bookmark-end converters emit gray `[`
  and `]` marker TextRuns when `layoutEngineOptions.showBookmarks` is
  true. Markers flow through pagination and line breaking as real
  characters, matching Word's own visual model.
- Auto-generated bookmarks (`_Toc…`, `_Ref…`, `_GoBack`) are hidden even
  when the feature is on — matching Word. A `renderedBookmarkIds` set on
  the converter context pairs suppression so closing brackets don't
  orphan open ones.
- PresentationEditor.setShowBookmarks toggles at runtime: clears the
  flow-block cache and schedules a re-render.
- SuperDoc.setShowBookmarks is the public API passthrough.
- Dev app gets a Show/Hide bookmarks toggle button in the header.
- CSS: subtle gray, non-selectable so users don't include brackets in
  copied text. Bookmark name surfaces via the native title tooltip on
  the opening bracket.

* test: close regression gaps for SD-2495 / SD-2454 / anchor-nav fix

Fills the test gaps surfaced by the testing-excellence review of this PR:

- crossReferenceImporter.integration.test.js (new, 4 tests): exercises the
  full v2 body pipeline (preprocessor -> dispatcher -> entity handler ->
  v3 translator). Asserts crossReferenceEntity is a member of the
  defaultNodeListHandler entities list, so the exact root cause that
  produced IT-949 ("Section 15" vanishing) fails loudly if a future
  refactor drops the wire-up. Unit tests of the translator alone cannot
  catch this — they bypass the dispatcher.

- EditorInputManager.anchorClick.test.ts (new, 4 tests): pins the
  SD-2537 click-to-navigate routing. Clicking #bookmark hrefs routes
  through goToAnchor (was TOC-only before). External and empty-fragment
  hrefs are explicitly NOT routed.

- cross-reference.test.ts: added marks-propagation test (node.marks flow
  into the emitted TextRun so italic/textStyle on xref text survives —
  SD-2537 "preserve surrounding run styling" AC).

- bookmark-markers.test.ts: converted the `for` loop over auto-generated
  bookmark names into `it.each`. Each input now reports per-case on
  failure, complies with testing-excellence's "no control flow inside
  test bodies" guideline.

- PresentationEditor.test.ts: documents why the scrollContainer-vs-
  visibleHost branch of the SD-2495 scrollPageIntoView fix isn't unit-
  testable here (happy-dom doesn't propagate inline overflow through
  getComputedStyle, which is what findScrollableAncestor uses).

* feat(pm-adapter): synthesize internal link for PAGEREF with \h switch

Mirrors the pattern added for crossReference in #2882. When a PAGEREF
field instruction carries the `\h` switch, attach a FlowRunLink via
`buildFlowRunLink({ anchor: bookmarkId })` so clicks on the rendered
page number navigate to the referenced bookmark through the existing
anchor-link routing (`EditorInputManager.#handleLinkClick` → `goToAnchor`).

Previously the PAGEREF inline converter emitted the `pageReference`
token run with `pageRefMetadata.bookmarkId` but no `link`, so the DOM
layer never produced a clickable element for PAGEREFs. TOC entries and
other hyperlinked page references imported from Word therefore failed
to navigate on click, even though Word honored the `\h` switch.

Depends on #2882 for `buildFlowRunLink` export and the generalized
anchor-click routing.

* fix(pm-adapter): match PAGEREF \h switch case-insensitively

Word field switches are case-insensitive per the field-code grammar, so
`\H` should produce the hyperlink the same as `\h`. Reviewer
(codex-connector) flagged that the original `/\\h\b/` regex skipped the
link synthesis for instructions like `PAGEREF _Toc123 \H`, leaving them
non-navigable even though the author had requested hyperlink behavior.

Adds the `i` flag and a regression test with an uppercase switch.

* test: add coverage for standalone PAGEREF \h + REF-family preprocessors

Behavior test (tests/behavior/tests/navigation/pageref-standalone-click.spec.ts):
Covers the PR's load-bearing case - a PAGEREF \h field NOT wrapped in a
<w:hyperlink>. The existing toc-anchor-scroll.spec.ts only exercises the
wrapped-in-hyperlink shape, where the outer link mark already propagates
via marksAsAttrs and the PR is a no-op. Fixtures exercise both \h and \H
(case-insensitivity per ECMA-376 17.16.1).

Preprocessor unit tests (ref/noteref/styleref):
These three importer modules were added in #2882 without tests. Each
verifies the preprocessor produces a sd:crossReference node with the
right fieldType and preserves the instruction text verbatim.

* test(superdoc): cover setShowBookmarks propagation and no-op guard

Closes the Codecov patch-coverage gap on SuperDoc.js flagged on PR #2899.
The uncovered setShowBookmarks method came in via the SD-2454 merge and
had no test for its config mutation, no-op short-circuit, or Boolean()
coercion. Models the existing setDisableContextMenu test pattern.

* refactor(pm-adapter): drop TextRun casts + case-insensitive \h in cross-reference

Addresses review feedback on #2899:

- page-reference.ts: remove redundant `as TextRun` casts — textNodeToRun
  already returns TextRun, so the casts are noise (cross-reference.ts:34
  already does this cleanly). Shorten the \h comment.
- cross-reference.ts: add the `i` flag to the \h switch regex to match
  ECMA-376 §17.16.1, same fix as 7f19358 for page-reference. Add a
  regression test covering `\H`.

* test: add unit coverage for scroll fan-out when ancestor != host

Stubs window.getComputedStyle to mark a wrapper element as scrollable so
#findScrollableAncestor returns the wrapper, then asserts both the wrapper
and the visibleHost receive scrollTop. This pins the SD-2495 fix: a revert
to the pre-fix one-liner (writing only to the visibleHost) now fails.

---------

Co-authored-by: Tadeu Tupinamba <tadeu.tupiz@gmail.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
Co-authored-by: Caio Pizzol <caio@superdoc.dev>
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