Skip to content

Commit fb20ea6

Browse files
committed
fix: make story-aware refs, ranges, and writes route correctly
1 parent 6cec2bb commit fb20ea6

8 files changed

Lines changed: 277 additions & 48 deletions

File tree

packages/document-api/src/ranges/ranges.types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import type { SelectionTarget, SelectionPoint } from '../types/address.js';
1010
import type { BlockNodeType } from '../types/base.js';
11+
import type { StoryLocator } from '../types/story.types.js';
1112

1213
// ---------------------------------------------------------------------------
1314
// Anchor types
@@ -52,6 +53,8 @@ export interface ResolveRangeInput {
5253
end: RangeAnchor;
5354
/** Optional expected revision for consistency checking. */
5455
expectedRevision?: string;
56+
/** Story to resolve the range against. Defaults to body when absent. */
57+
in?: StoryLocator;
5558
}
5659

5760
/** Per-block preview metadata within the resolved range. */

packages/document-api/src/ranges/resolve.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { ResolveRangeInput, ResolveRangeOutput, RangeResolverAdapter, Range
1010
import { DocumentApiValidationError } from '../errors.js';
1111
import { isRecord, assertNoUnknownFields } from '../validation-primitives.js';
1212
import { isSelectionPoint } from '../validation/selection-target-validator.js';
13+
import { validateStoryLocator } from '../validation/story-validator.js';
1314

1415
// ---------------------------------------------------------------------------
1516
// Anchor validation
@@ -76,7 +77,7 @@ function validateAnchor(value: unknown, fieldName: string): asserts value is Ran
7677
// Input validation
7778
// ---------------------------------------------------------------------------
7879

79-
const RESOLVE_RANGE_ALLOWED_KEYS = new Set(['start', 'end', 'expectedRevision']);
80+
const RESOLVE_RANGE_ALLOWED_KEYS = new Set(['start', 'end', 'expectedRevision', 'in']);
8081

8182
function validateResolveRangeInput(input: unknown): asserts input is ResolveRangeInput {
8283
if (!isRecord(input)) {
@@ -107,6 +108,8 @@ function validateResolveRangeInput(input: unknown): asserts input is ResolveRang
107108
{ field: 'expectedRevision', value: input.expectedRevision },
108109
);
109110
}
111+
112+
validateStoryLocator(input.in, 'in');
110113
}
111114

112115
// ---------------------------------------------------------------------------

packages/super-editor/src/document-api-adapters/helpers/range-resolver.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
1515
encodeV3Ref: vi.fn(() => 'text:mock-encoded'),
1616
getRevision: vi.fn(() => '0'),
1717
checkRevision: vi.fn(),
18+
resolveStoryRuntime: vi.fn(),
1819
}));
1920

2021
vi.mock('./index-cache.js', () => ({
@@ -40,6 +41,13 @@ vi.mock('./node-address-resolver.js', () => ({
4041
Boolean(candidate.node?.inlineContent || candidate.node?.isTextblock),
4142
}));
4243

44+
// Story runtime resolution: return a passthrough body runtime wrapping the
45+
// editor that was passed in. Tests that exercise non-body story targeting
46+
// should override this mock as needed.
47+
vi.mock('../story-runtime/resolve-story-runtime.js', () => ({
48+
resolveStoryRuntime: mocks.resolveStoryRuntime,
49+
}));
50+
4351
// ---------------------------------------------------------------------------
4452
// Helpers
4553
// ---------------------------------------------------------------------------
@@ -170,6 +178,15 @@ beforeEach(() => {
170178
vi.clearAllMocks();
171179
mocks.getRevision.mockReturnValue('0');
172180
mocks.encodeV3Ref.mockReturnValue('text:mock-encoded');
181+
182+
// Default: resolveStoryRuntime returns a passthrough body runtime
183+
// wrapping the editor that was passed in.
184+
mocks.resolveStoryRuntime.mockImplementation((hostEditor: Editor) => ({
185+
locator: { kind: 'story', storyType: 'body' },
186+
storyKey: 'body',
187+
editor: hostEditor,
188+
kind: 'body',
189+
}));
173190
});
174191

175192
// ---------------------------------------------------------------------------

packages/super-editor/src/document-api-adapters/helpers/range-resolver.ts

Lines changed: 124 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
*
55
* Composes existing primitives:
66
* - SelectionPoint resolution (selection-target-resolver.ts)
7-
* - V3 ref encoding (query-match-adapter.ts)
7+
* - V3/V4 ref encoding (query-match-adapter.ts, story-ref-codec.ts)
88
* - Revision tracking (revision-tracker.ts)
99
* - Block index (index-cache.ts)
10+
* - Story runtime resolution (resolve-story-runtime.ts)
1011
*/
1112

1213
import type {
@@ -17,8 +18,9 @@ import type {
1718
SelectionTarget,
1819
SelectionPoint,
1920
SelectionEdgeNodeType,
21+
StoryLocator,
2022
} from '@superdoc/document-api';
21-
import { SELECTION_EDGE_NODE_TYPES } from '@superdoc/document-api';
23+
import { SELECTION_EDGE_NODE_TYPES, storyLocatorToKey } from '@superdoc/document-api';
2224
import type { Editor } from '../../core/Editor.js';
2325
import { getBlockIndex } from './index-cache.js';
2426
import { isTextBlockCandidate, type BlockCandidate, type BlockIndex } from './node-address-resolver.js';
@@ -27,7 +29,10 @@ import { encodeV3Ref } from '../plan-engine/query-match-adapter.js';
2729
import { getRevision, checkRevision } from '../plan-engine/revision-tracker.js';
2830
import { PlanError } from '../plan-engine/errors.js';
2931
import { DocumentApiAdapterError } from '../errors.js';
30-
import { decodeRef } from '../story-runtime/story-ref-codec.js';
32+
import { decodeRef, encodeV4Ref } from '../story-runtime/story-ref-codec.js';
33+
import { resolveStoryFromRef, resolveStoryFromInput } from '../story-runtime/resolve-story-context.js';
34+
import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js';
35+
import { BODY_STORY_KEY, buildStoryKey } from '../story-runtime/story-key.js';
3136

3237
// ---------------------------------------------------------------------------
3338
// Constants
@@ -236,11 +241,21 @@ function resolveGapPosition(index: BlockIndex, absPos: number): SelectionPoint {
236241
// SelectionTarget construction
237242
// ---------------------------------------------------------------------------
238243

239-
function buildSelectionTarget(editor: Editor, index: BlockIndex, absFrom: number, absTo: number): SelectionTarget {
244+
function buildSelectionTarget(
245+
editor: Editor,
246+
index: BlockIndex,
247+
absFrom: number,
248+
absTo: number,
249+
story?: StoryLocator,
250+
): SelectionTarget {
240251
return {
241252
kind: 'selection',
242253
start: absPositionToSelectionPoint(editor, index, absFrom),
243254
end: absPositionToSelectionPoint(editor, index, absTo),
255+
// Attach story metadata for non-body stories so that callers can chain
256+
// the target into mutations without repeating `in`. Body stories omit
257+
// the field for backward compatibility (body is the default).
258+
...(story && { story }),
244259
};
245260
}
246261

@@ -333,6 +348,7 @@ function encodeRangeRef(
333348
absFrom: number,
334349
absTo: number,
335350
revision: string,
351+
storyKey?: string,
336352
): string | null {
337353
const segments: Array<{ blockId: string; start: number; end: number }> = [];
338354

@@ -370,6 +386,19 @@ function encodeRangeRef(
370386
return null;
371387
}
372388

389+
// Non-body stories use V4 refs to preserve the storyKey for downstream
390+
// mutations. Body stories keep V3 for backward compatibility.
391+
if (storyKey && storyKey !== BODY_STORY_KEY) {
392+
return encodeV4Ref({
393+
v: 4,
394+
rev: revision,
395+
storyKey,
396+
scope: 'match',
397+
matchId: `range:${absFrom}-${absTo}`,
398+
segments,
399+
});
400+
}
401+
373402
return encodeV3Ref({
374403
v: 3,
375404
rev: revision,
@@ -419,7 +448,7 @@ function rangeContainsOnlyTextBlocks(index: BlockIndex, absFrom: number, absTo:
419448
*/
420449
export function resolveAbsoluteRange(
421450
editor: Editor,
422-
input: { absFrom: number; absTo: number; expectedRevision?: string },
451+
input: { absFrom: number; absTo: number; expectedRevision?: string; storyLocator?: StoryLocator },
423452
): ResolveRangeOutput {
424453
const revision = getRevision(editor);
425454

@@ -433,7 +462,14 @@ export function resolveAbsoluteRange(
433462
const absFrom = Math.min(input.absFrom, input.absTo);
434463
const absTo = Math.max(input.absFrom, input.absTo);
435464

436-
const target = buildSelectionTarget(editor, index, absFrom, absTo);
465+
// Non-body stories attach metadata to the target and encode V4 refs.
466+
// Body stories (undefined or explicit body locator) omit the field for
467+
// backward compatibility.
468+
const isNonBody = input.storyLocator !== undefined && input.storyLocator.storyType !== 'body';
469+
const storyForTarget = isNonBody ? input.storyLocator : undefined;
470+
const storyKey = isNonBody ? buildStoryKey(input.storyLocator!) : undefined;
471+
472+
const target = buildSelectionTarget(editor, index, absFrom, absTo, storyForTarget);
437473

438474
// The V3 text ref can only encode text-block content segments. The ref is
439475
// lossy when the target uses nodeEdge endpoints (structural block boundaries)
@@ -445,7 +481,7 @@ export function resolveAbsoluteRange(
445481
return {
446482
evaluatedRevision: revision,
447483
handle: {
448-
ref: encodeRangeRef(editor, index, absFrom, absTo, revision),
484+
ref: encodeRangeRef(editor, index, absFrom, absTo, revision, storyKey),
449485
refStability: 'ephemeral',
450486
coversFullTarget,
451487
},
@@ -454,23 +490,95 @@ export function resolveAbsoluteRange(
454490
};
455491
}
456492

493+
// ---------------------------------------------------------------------------
494+
// Story resolution for range anchors
495+
// ---------------------------------------------------------------------------
496+
497+
/**
498+
* Extracts the story locator embedded in a range anchor's ref, if any.
499+
*
500+
* Only `ref`-kind anchors can carry story information (via V4 refs).
501+
* `document` and `point` anchors are story-agnostic.
502+
*/
503+
function extractStoryFromAnchor(anchor: RangeAnchor): StoryLocator | undefined {
504+
if (anchor.kind !== 'ref') return undefined;
505+
return resolveStoryFromRef(anchor.ref);
506+
}
507+
508+
/**
509+
* Reconciles stories extracted from the start and end anchors.
510+
*
511+
* Both anchors must target the same story — a range cannot span multiple stories.
512+
* Returns `undefined` when neither anchor carries story information.
513+
*/
514+
function reconcileAnchorStories(
515+
startStory: StoryLocator | undefined,
516+
endStory: StoryLocator | undefined,
517+
): StoryLocator | undefined {
518+
if (!startStory) return endStory;
519+
if (!endStory) return startStory;
520+
521+
if (storyLocatorToKey(startStory) !== storyLocatorToKey(endStory)) {
522+
throw new DocumentApiAdapterError(
523+
'INVALID_INPUT',
524+
`Range anchor story mismatch: start ref targets "${storyLocatorToKey(startStory)}" ` +
525+
`but end ref targets "${storyLocatorToKey(endStory)}". A range cannot span multiple stories.`,
526+
{ startStory: storyLocatorToKey(startStory), endStory: storyLocatorToKey(endStory) },
527+
);
528+
}
529+
530+
return startStory;
531+
}
532+
533+
/**
534+
* Resolves the effective story locator for a range operation.
535+
*
536+
* Merges three potential sources using the standard precedence rules:
537+
* 1. `input.in` — explicit story targeting on the operation input
538+
* 2. Ref anchors — V4 refs in `start` or `end` that embed a storyKey
539+
*
540+
* All sources must agree; mismatches produce a clear error.
541+
*/
542+
function resolveRangeStory(input: ResolveRangeInput): StoryLocator | undefined {
543+
const startStory = extractStoryFromAnchor(input.start);
544+
const endStory = extractStoryFromAnchor(input.end);
545+
const anchorStory = reconcileAnchorStories(startStory, endStory);
546+
547+
return resolveStoryFromInput({ in: input.in }, anchorStory ? { story: anchorStory } : undefined);
548+
}
549+
550+
// ---------------------------------------------------------------------------
551+
// Public entry point
552+
// ---------------------------------------------------------------------------
553+
457554
/**
458555
* Resolves two explicit anchors into a contiguous document range.
459556
*
460-
* Returns a transparent SelectionTarget, a mutation-ready ref, and preview metadata.
557+
* Story-aware: resolves the target story from `input.in` and/or V4 ref
558+
* anchors, then evaluates all anchors against the correct story editor's
559+
* document state and revision counter.
560+
*
561+
* @param hostEditor - The body (host) editor — used to resolve story runtimes.
562+
* @param input - The range resolution input with anchors and optional story locator.
563+
* @returns A transparent SelectionTarget, a mutation-ready ref, and preview metadata.
461564
*/
462-
export function resolveRange(editor: Editor, input: ResolveRangeInput): ResolveRangeOutput {
463-
const revision = getRevision(editor);
565+
export function resolveRange(hostEditor: Editor, input: ResolveRangeInput): ResolveRangeOutput {
566+
// Determine which story to resolve against (defaults to body).
567+
const storyLocator = resolveRangeStory(input);
568+
const runtime = resolveStoryRuntime(hostEditor, storyLocator);
569+
const storyEditor = runtime.editor;
570+
571+
const revision = getRevision(storyEditor);
464572

465573
if (input.expectedRevision !== undefined) {
466-
checkRevision(editor, input.expectedRevision);
574+
checkRevision(storyEditor, input.expectedRevision);
467575
}
468576

469-
const index = getBlockIndex(editor);
577+
const index = getBlockIndex(storyEditor);
470578

471-
// Resolve both anchors to absolute PM positions
472-
const rawFrom = resolveAnchor(editor, input.start, revision, index);
473-
const rawTo = resolveAnchor(editor, input.end, revision, index);
579+
// Resolve both anchors to absolute PM positions in the story's document
580+
const rawFrom = resolveAnchor(storyEditor, input.start, revision, index);
581+
const rawTo = resolveAnchor(storyEditor, input.end, revision, index);
474582

475-
return resolveAbsoluteRange(editor, { absFrom: rawFrom, absTo: rawTo });
583+
return resolveAbsoluteRange(storyEditor, { absFrom: rawFrom, absTo: rawTo, storyLocator });
476584
}

packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,9 @@ import {
7878
} from '../helpers/node-address-resolver.js';
7979
import { getInlinePropertyCapabilityIssue, getTrackedInlinePropertySupportIssue } from './inline-property-guards.js';
8080
import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js';
81-
import { resolveStoryFromInput } from '../story-runtime/resolve-story-context.js';
81+
import { resolveMutationStory } from '../story-runtime/resolve-story-context.js';
8282
import type { StoryRuntime } from '../story-runtime/story-types.js';
8383
import { decodeRef } from '../story-runtime/story-ref-codec.js';
84-
import { parseStoryKey } from '../story-runtime/story-key.js';
8584

8685
// ---------------------------------------------------------------------------
8786
// Helpers
@@ -146,31 +145,12 @@ export function disposeEphemeralWriteRuntime(runtime: StoryRuntime): void {
146145
}
147146
}
148147

149-
function resolveStoryFromMutationRef(ref: string | undefined): StoryLocator | undefined {
150-
if (!ref) return undefined;
151-
152-
const decodedRef = decodeRef(ref);
153-
if (!decodedRef || decodedRef.v !== 4) {
154-
return undefined;
155-
}
156-
157-
try {
158-
return parseStoryKey(decodedRef.storyKey);
159-
} catch (error) {
160-
const message = error instanceof Error ? error.message : String(error);
161-
throw new DocumentApiAdapterError('INVALID_TARGET', `Mutation ref carries an invalid story key: ${message}`, {
162-
ref,
163-
storyKey: decodedRef.storyKey,
164-
});
165-
}
166-
}
167-
168148
function resolveSelectionMutationStory(request: SelectionMutationRequest): StoryLocator | undefined {
169-
const targetWithStory = request.target as { story?: StoryLocator } | undefined;
170-
const storyFromRef = resolveStoryFromMutationRef(request.ref);
171-
const effectiveTargetStory = targetWithStory?.story ?? storyFromRef;
172-
173-
return resolveStoryFromInput({ in: request.in }, effectiveTargetStory ? { story: effectiveTargetStory } : undefined);
149+
return resolveMutationStory({
150+
in: request.in,
151+
target: request.target as { story?: StoryLocator } | undefined,
152+
ref: request.ref,
153+
});
174154
}
175155

176156
/**
@@ -1350,8 +1330,16 @@ export function replaceStructuredWrapper(
13501330
);
13511331
}
13521332

1353-
// Resolve story runtime from the input's `in` field.
1354-
const runtime = resolveWriteStoryRuntime(editor, (input as { in?: StoryLocator }).in);
1333+
// Resolve story from the full mutation context:
1334+
// - explicit input.in
1335+
// - target.story threaded by discovery APIs
1336+
// - V4 ref storyKey when the mutation is ref-only
1337+
const effectiveLocator = resolveMutationStory({
1338+
in: (input as { in?: StoryLocator }).in,
1339+
target: input.target as { story?: StoryLocator } | undefined,
1340+
ref: input.ref,
1341+
});
1342+
const runtime = resolveWriteStoryRuntime(editor, effectiveLocator);
13551343

13561344
try {
13571345
const storyEditor = runtime.editor;
@@ -1364,7 +1352,14 @@ export function replaceStructuredWrapper(
13641352
: undefined;
13651353

13661354
const textReceipt = executeStructuralReplaceWrapper(storyEditor, input, options);
1367-
if (runtime.commit) runtime.commit(editor);
1355+
1356+
// Only persist non-body story changes when the replace actually succeeded.
1357+
// Committing on failure would write unchanged content back to OOXML,
1358+
// potentially materializing inherited header/footer slots or emitting
1359+
// spurious partChanged events.
1360+
if (textReceipt.success && runtime.commit) {
1361+
runtime.commit(editor);
1362+
}
13681363

13691364
if (!blockTarget) return textReceiptToSDReceipt(textReceipt);
13701365

packages/super-editor/src/document-api-adapters/story-runtime/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export { StoryRuntimeCache } from './runtime-cache.js';
3232

3333
// Resolution
3434
export { resolveStoryRuntime, getStoryRuntimeCache } from './resolve-story-runtime.js';
35-
export { resolveStoryFromInput } from './resolve-story-context.js';
35+
export { resolveStoryFromInput, resolveStoryFromRef, resolveMutationStory } from './resolve-story-context.js';
3636

3737
// Story-specific resolvers
3838
export { resolveHeaderFooterSlotRuntime, resolveHeaderFooterPartRuntime } from './header-footer-story-runtime.js';

0 commit comments

Comments
 (0)