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
1213import 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' ;
2224import type { Editor } from '../../core/Editor.js' ;
2325import { getBlockIndex } from './index-cache.js' ;
2426import { isTextBlockCandidate , type BlockCandidate , type BlockIndex } from './node-address-resolver.js' ;
@@ -27,7 +29,10 @@ import { encodeV3Ref } from '../plan-engine/query-match-adapter.js';
2729import { getRevision , checkRevision } from '../plan-engine/revision-tracker.js' ;
2830import { PlanError } from '../plan-engine/errors.js' ;
2931import { 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 */
420449export 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}
0 commit comments