Skip to content

feat(superdoc): scrollToHeading API + fix scrollToComment / scrollToElement in flow layout#3276

Draft
bjohas wants to merge 6 commits into
superdoc-dev:mainfrom
OpenDevEd:feat/scroll-to-x
Draft

feat(superdoc): scrollToHeading API + fix scrollToComment / scrollToElement in flow layout#3276
bjohas wants to merge 6 commits into
superdoc-dev:mainfrom
OpenDevEd:feat/scroll-to-x

Conversation

@bjohas
Copy link
Copy Markdown
Contributor

@bjohas bjohas commented May 14, 2026

Summary

Three related changes to the public scroll-to-X surface, motivated by an external integration (Electron-based DOCX viewer) where comment-panel click → editor scroll was silently broken in flow layout and unreliable in paginated layout.

  1. fix(super-editor)Editor.getElementAtPos in flow / web layout now resolves view.domAtPos's parent-element + offset to the actual child element, instead of returning the parent. Previously, every position in the doc collapsed to "the entire editor root," so scrollIntoView always landed at the top.

  2. fix(superdoc)SuperDoc.scrollToComment(id) now delegates to scrollToElement(id). The old root.querySelector('[data-comment-ids*="..."]') lookup missed virtualised pages in paginated mode and missed marks for which SuperDoc hadn't emitted a visible highlight span.

  3. fix(superdoc) + feat(superdoc)SuperDoc.scrollToElement(id) now falls back to a body-editor path when no presentation editor exists (flow layout) or when presentation returns false. Walks the PM doc via commands.setCursorById for comment / tracked-change marks, with an attr-match fallback for block IDs.

  4. feat(superdoc) — new SuperDoc.scrollToHeading(level, ordinal, options) public API. Walks the doc counting paragraphs whose paragraphProperties.styleId = "HeadingN" (or heading nodes with a level attr), targets a position inside the heading's text content (not the doc-level boundary before the paragraph — the layout-fragment index doesn't cover boundaries), and dispatches through the same flow/paginated split as scrollToElement.

Motivation

In an external app embedding SuperDoc 1.31.2:

  • superdoc.scrollToComment(id) returned true but didn't actually scroll in many cases (flow + paginated mismatches between marks and highlight spans).
  • superdoc.scrollToElement(id) returned false for every comment in flow layout (no presentation editor → unconditional bail-out).
  • No clean API for "scroll to the Nth heading at level X" — every consumer ended up hand-rolling a PM walk + dual-mode scroll logic.

With this PR, against a small commented test docx:

Before After
scrollToComment flow 0/4 scrolled 4/4
scrollToComment paginated 3/4 3/4 (1 residual upstream scrollToPositionAsync edge case)
scrollToHeading(level, ordinal) flow n/a 9/9
scrollToHeading(level, ordinal) paginated n/a 9/9

The one residual paginated case (a comment whose marks land at pos=13240/42/47) is scrollToPositionAsync returning true without moving the scroll — out of scope for this PR; happy to file separately.

Test plan

  • pnpm --filter superdoc test src/core/SuperDoc.test.js — 84/84 pass, including 4 new cases covering the new delegation, body-editor fallback, and heading walk.
  • pnpm --filter @superdoc/super-editor test src/editors/v1/core/Editor — 160/160 pass (no regression around getElementAtPos).
  • pnpm --filter @superdoc/super-editor test src/editors/v1/core/presentation-editor — 1369/1369 pass.
  • End-to-end Playwright probe in an external Electron consumer of the built dist/ against the 4-comment / 41-heading test docx. Numbers above.

Notes

  • No type-test additions yet — happy to add to tests/consumer-typecheck/ per CONTRIBUTING if you'd prefer the new scrollToHeading signature pinned there before merge.
  • The Editor.getElementAtPos change is the most cross-cutting — only touches the body-editor branch (presentation-editor implementation is unchanged) and internal callers all use .closest(...) to climb to the class they want, which still works with the more specific child element this now returns.

🤖 Generated with Claude Code

bjohas and others added 3 commits May 14, 2026 09:02
`view.domAtPos(pos)` returns `{node, offset}` where `node` is an
element parent and `offset` is the child index when the position
sits between block children. The previous implementation returned
the parent directly, which for the editor root collapses every
position into "the entire document" — `scrollIntoView()` on that
element always lands at the top of the editor regardless of which
heading or paragraph was requested.

Resolve to `parent.childNodes[offset]` (clamped) when the returned
node is an element parent. The text-node branch is unchanged. This
fixes web/flow-layout `getElementAtPos` for positions on block
boundaries; the presentation-editor branch isn't touched.

Verified with a probe that calls the body-editor `getElementAtPos`
for known heading positions (2725, 3513, 4477, 13264) — before
this fix all four returned the same ProseMirror root DIV; after,
each returns the actual heading's wrapper element.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ading

Three related fixes to the public scroll-to-X API surface, motivated
by an external integration where comment-panel click → editor scroll
was silently broken in flow layout and unreliable in paginated
layout.

  fix(superdoc): scrollToComment delegates to scrollToElement

  The previous implementation hand-rolled a DOM-only lookup
  (`root.querySelector('[data-comment-ids*="..."]')` →
  `scrollIntoView`). This had two failure modes:

    1. Paginated/presentation layout virtualises pages — comment
       highlights on offscreen pages aren't in the DOM, so the
       selector misses and the call returns false even though the
       comment exists in the document.
    2. When two `commentMark`s share a position, SuperDoc emits a
       single `.sd-editor-comment-highlight` element. Lookups for the
       suppressed thread silently fail in either layout.

  Route through the existing `scrollToElement` instead. It already
  does the right thing for both layouts (with the flow-layout
  fallback added below). The side-panel activation is preserved.

  fix(superdoc): scrollToElement falls back to body-editor in flow

  In flow / web layouts there is no `presentationEditor`, so
  `presentationEditor.scrollToElement` returned false unconditionally
  and the whole API was a no-op outside paginated mode.

  When the presentation path isn't available (or returns false), walk
  the ProseMirror doc directly: use `editor.commands.setCursorById`
  to resolve comment and tracked-change marks (it already does the
  right `findRangeById`-based lookup), then fall back to a one-pass
  attr-match for block IDs (`nodeId` / `sdBlockId` / `id` /
  `paraId`). Use `editor.getElementAtPos(pos)` for the DOM target so
  callers benefit from the companion `Editor.getElementAtPos` fix
  in the same branch.

  feat(superdoc): scrollToHeading(level, ordinal, options)

  New public API. Walks the ProseMirror doc counting headings of the
  requested level — recognises both the `heading` node type (level
  attr) and the OOXML import shape where headings are paragraphs
  with `paragraphProperties.styleId = 'HeadingN'` — and scrolls to
  the 1-based `ordinal`-th match.

  The walk targets a position INSIDE the heading paragraph's text
  content (the first text descendant), not the doc-level boundary
  before the paragraph. The presentation editor's layout fragment
  index only spans positions inside text content, so a boundary
  position misses every fragment and `scrollToPositionAsync` returns
  false. Walking one level in unblocks the print-mode path.

Verified against a Playwright probe on a 4-comment, 41-heading test
docx: flow comment-scroll went from 0/4 to 4/4 (`scrollToComment`);
heading scroll works 9/9 in both layouts at levels 1–3, ordinals
1–3. Print-mode comment scroll is unchanged at 3/4 (one residual
case where `scrollToPositionAsync` returns true but the painter
doesn't actually scroll; tracked separately as it's not in this
patch's scope).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Updates the two existing `scrollToComment` tests to reflect the new
delegation-via-`scrollToElement` behaviour (no more bespoke
`querySelector('[data-comment-ids*=...]')` lookup), and adds:

- `scrollToElement` falls back to the body editor when the
  presentation path returns false (flow-layout case): uses
  `commands.setCursorById` to resolve the id and
  `getElementAtPos` for the DOM target.
- `scrollToHeading` walks the doc, counts paragraphs whose
  `paragraphProperties.styleId = "HeadingN"`, picks the
  1-based ordinal-th, and passes a text-inside-content
  position (not the doc-level boundary) to
  `scrollToPositionAsync`.
- `scrollToHeading` rejects out-of-range levels (0, 7),
  non-positive ordinals, and non-integer arguments.

All 84 `SuperDoc.test.js` cases pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 14, 2026

Codecov Report

❌ Patch coverage is 87.56219% with 25 lines in your changes missing coverage. Please review.

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

📢 Thoughts on this report? Let us know!

@caio-pizzol caio-pizzol self-assigned this May 14, 2026
bjohas and others added 2 commits May 14, 2026 16:06
…r empty headings

scrollToHeading targets a position INSIDE the heading paragraph's
text content (not the doc-level boundary just before it) so the
presentation editor's layout-fragment index — which only spans
inside-text positions — has something to scroll to. The original
patch did this by walking the heading node's own descendants.

Refinement: when the heading itself carries no text content
(empty paragraph, or content limited to structural markers like
bookmarkStart / commentRangeStart), walk forward in the doc for
the next text-bearing position instead of returning false. That
way the viewport at least lands near where the empty heading
lives — useful in user-facing TOC clicks where the click target
is the empty heading entry the user intentionally selected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`scrollToPositionAsync` had a hard-coded 2 second timeout for
waiting on the painter to mount the target virtualised page.
Callers navigating long documents — where the painter may need
longer than 2 s to settle when jumping far from the current
viewport — had no way to extend it short of editing the static
`ANCHOR_NAV_TIMEOUT_MS` constant.

Adds an opt-in `timeoutMs` to the options bag. Backwards-
compatible: defaults stay at 2000 ms when unset. The warn message
now also includes the actual timeout that elapsed, which is
useful when diagnosing whether a particular long-doc click was
fighting the timeout or some other issue.

`SuperDoc.scrollToHeading` passes the option through to
`scrollToPositionAsync` when given, so external apps can extend
the wait at the call site without reaching into the presentation
editor directly:

  await superdoc.scrollToHeading(2, 18, { timeoutMs: 8000 });

`scrollToElement` / `navigateTo` deliberately don't yet thread
the option — those traverse `#navigateToBlock` / `#navigateToComment` /
`#navigateToTrackedChange` before reaching `scrollToPositionAsync`,
which is a wider surface to touch. Can be added in a follow-up
once the call sites that need it surface.

Existing scrollToPosition test updated to match the new warn
message; a new test exercises the option (short timeout fires
faster than the default and the warn text includes the supplied
value). 86/86 SuperDoc.test.js + 14/14 scrollToPosition.test.ts
pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…aId per node

Block nodes in imported docx files can carry more than one ID-shaped
attr at once — paragraphs from `.docx` carry both `paraId` (from
`w14:paraId` in OOXML) and `sdBlockId` (minted by SuperDoc on import).

The previous walk did `const candidate = a.nodeId ?? a.sdBlockId ?? a.id ?? a.paraId`
and compared the single first-non-null pick to the caller's elementId.
That meant a paragraph carrying both `sdBlockId: "3496bf7f-..."` and
`paraId: "00000001"` would always return `sdBlockId` as the candidate
and never match a caller looking for `"00000001"`.

Compare each attr independently so the walk matches when ANY of the
candidate fields equals elementId. Consumers can now pass whichever
ID handle they have without knowing which ID types a given block
happens to carry.

Adds one regression test asserting that scrollToElement('00000001')
resolves to `true` even when the matching paragraph also has a
non-matching sdBlockId.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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