Skip to content

Commit 0bbe546

Browse files
bjohasclaude
andcommitted
fix(superdoc): scrollToElement matches any of nodeId/sdBlockId/id/paraId 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>
1 parent 1fcf864 commit 0bbe546

2 files changed

Lines changed: 53 additions & 3 deletions

File tree

packages/superdoc/src/core/SuperDoc.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1438,13 +1438,18 @@ export class SuperDoc extends EventEmitter {
14381438
}
14391439

14401440
// 2. Fall back to a single PM walk looking for matching block-level
1441-
// id attributes (nodeId / sdBlockId / id / paraId).
1441+
// id attributes. Block nodes can carry multiple ID-shaped attrs
1442+
// at once — e.g. paragraphs from a `.docx` carry both `paraId`
1443+
// (the OOXML `w14:paraId`) and `sdBlockId` (minted by SuperDoc
1444+
// on import). We must compare against each independently rather
1445+
// than picking the first non-null and comparing, because the
1446+
// caller may have a handle on any one of them and consumers
1447+
// shouldn't have to know which ID type a given block carries.
14421448
if (pos == null || !Number.isFinite(pos)) {
14431449
editor.state.doc.descendants((node, p) => {
14441450
if (pos != null) return false;
14451451
const a = node.attrs || {};
1446-
const candidate = a.nodeId ?? a.sdBlockId ?? a.id ?? a.paraId;
1447-
if (candidate && candidate === elementId) {
1452+
if (a.nodeId === elementId || a.sdBlockId === elementId || a.id === elementId || a.paraId === elementId) {
14481453
pos = p;
14491454
return false;
14501455
}

packages/superdoc/src/core/SuperDoc.test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,51 @@ describe('SuperDoc core', () => {
475475
);
476476
});
477477

478+
it('scrollToElement matches paraId even when sdBlockId is also present', async () => {
479+
const { superdocStore } = createAppHarness();
480+
superdocStore.documents = [{ getPresentationEditor: vi.fn(() => ({ scrollToElement: vi.fn(async () => false) })) }];
481+
482+
const scrollIntoView = vi.fn();
483+
const targetEl = { scrollIntoView };
484+
const setCursorById = vi.fn(() => false);
485+
486+
// A paragraph carrying BOTH a long sdBlockId and a short paraId.
487+
// The walk must match against each attr independently — picking
488+
// the first non-null and comparing would let sdBlockId mask paraId.
489+
const node = {
490+
attrs: {
491+
sdBlockId: '3496bf7f-b408-489d-9d1d-7a6854c09e70',
492+
paraId: '00000001',
493+
},
494+
};
495+
const descendants = (cb) => {
496+
cb(node, 7);
497+
};
498+
499+
const instance = new SuperDoc({
500+
selector: '#host',
501+
document: 'https://example.com/doc.docx',
502+
documents: [],
503+
modules: { comments: {}, toolbar: {} },
504+
onException: vi.fn(),
505+
});
506+
await flushMicrotasks();
507+
508+
Object.defineProperty(instance, 'activeEditor', {
509+
configurable: true,
510+
get: () => ({
511+
state: { doc: { descendants, content: { size: 100 } }, selection: { from: null } },
512+
commands: { setCursorById },
513+
getElementAtPos: vi.fn(() => targetEl),
514+
}),
515+
});
516+
517+
await expect(instance.scrollToElement('00000001')).resolves.toBe(true);
518+
expect(scrollIntoView).toHaveBeenCalledWith(
519+
expect.objectContaining({ block: expect.any(String), inline: 'nearest' }),
520+
);
521+
});
522+
478523
it('scrollToHeading walks for the Nth heading at the given level and scrolls', async () => {
479524
const { superdocStore } = createAppHarness();
480525
// Mock doc with three Heading1 paragraphs at known positions.

0 commit comments

Comments
 (0)