@beyondwork/docx-react-component 1.0.59 → 1.0.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +33 -44
  2. package/src/api/public-types.ts +43 -0
  3. package/src/core/state/editor-state.ts +2 -0
  4. package/src/io/docx-session.ts +167 -8
  5. package/src/io/export/serialize-footnotes.ts +36 -5
  6. package/src/io/export/serialize-headers-footers.ts +7 -0
  7. package/src/io/export/serialize-main-document.ts +25 -18
  8. package/src/io/export/serialize-paragraph-formatting.ts +6 -0
  9. package/src/io/export/serialize-settings.ts +130 -3
  10. package/src/io/normalize/normalize-text.ts +8 -4
  11. package/src/io/ooxml/parse-footnotes.ts +11 -0
  12. package/src/io/ooxml/parse-headers-footers.ts +117 -42
  13. package/src/io/ooxml/parse-main-document.ts +20 -8
  14. package/src/io/ooxml/parse-paragraph-formatting.ts +25 -1
  15. package/src/io/ooxml/parse-settings.ts +91 -1
  16. package/src/io/ooxml/workflow-payload.ts +6 -1
  17. package/src/model/canonical-document.ts +36 -2
  18. package/src/model/snapshot.ts +2 -0
  19. package/src/runtime/diagnostics/build-diagnostic.ts +2 -0
  20. package/src/runtime/diagnostics/code-metadata-table.ts +9 -0
  21. package/src/runtime/document-runtime.ts +770 -21
  22. package/src/runtime/footnote-resolver.ts +32 -8
  23. package/src/runtime/layout/layout-engine-version.ts +7 -1
  24. package/src/runtime/layout/measurement-backend-canvas.ts +1 -1
  25. package/src/runtime/layout/measurement-backend-empirical.ts +1 -1
  26. package/src/runtime/layout/paginated-layout-engine.ts +41 -8
  27. package/src/runtime/layout/resolved-formatting-document.ts +11 -9
  28. package/src/runtime/layout/resolved-formatting-state.ts +4 -0
  29. package/src/runtime/numbering-prefix.ts +26 -2
  30. package/src/runtime/query-scopes.ts +103 -2
  31. package/src/runtime/surface-projection.ts +75 -14
  32. package/src/runtime/table-schema.ts +26 -0
  33. package/src/ui/WordReviewEditor.tsx +25 -0
  34. package/src/ui/editor-runtime-boundary.ts +1 -0
  35. package/src/ui/editor-shell-view.tsx +8 -0
  36. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +514 -0
  37. package/src/ui-tailwind/editor-surface/pm-schema.ts +14 -0
  38. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +55 -6
  39. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  40. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +4 -0
  41. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -1
  42. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +16 -0
  43. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +319 -0
  44. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +248 -0
  45. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +4 -0
  46. package/src/ui-tailwind/tw-review-workspace.tsx +54 -3
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  BlockNode,
3
3
  CanonicalDocument,
4
+ DocumentSettings,
4
5
  EndnoteProperties,
5
6
  FootnoteCollection,
6
7
  FootnoteProperties,
@@ -15,14 +16,13 @@ export interface FootnoteResolver {
15
16
  getEndnoteCount(): number;
16
17
  /**
17
18
  * Resolve the effective `<w:footnotePr>` for a given section. Returns the
18
- * section's typed properties when present, or `undefined` when the section
19
- * has no override (callers should fall back to Word defaults). File-level
20
- * defaults from `settings.xml` are not yet wired in — that's a later slice.
19
+ * section's typed properties when present, otherwise the settings-level
20
+ * default from `settings.xml` when available.
21
21
  */
22
22
  getFootnoteProperties(sectionIndex?: number): FootnoteProperties | undefined;
23
23
  /**
24
24
  * Resolve the effective `<w:endnotePr>` for a given section. Same semantics
25
- * as `getFootnoteProperties` but reads `endnotePr` from the section list.
25
+ * as `getFootnoteProperties` but reads `endnotePr`.
26
26
  */
27
27
  getEndnoteProperties(sectionIndex?: number): EndnoteProperties | undefined;
28
28
  /**
@@ -70,6 +70,7 @@ export function createFootnoteResolver(
70
70
  collection: FootnoteCollection,
71
71
  sections?: readonly SectionProperties[],
72
72
  document?: CanonicalDocument,
73
+ settings?: DocumentSettings,
73
74
  ): FootnoteResolver {
74
75
  return {
75
76
  getContinuationSeparatorContent(kind) {
@@ -87,12 +88,20 @@ export function createFootnoteResolver(
87
88
  return Object.keys(collection.endnotes).length;
88
89
  },
89
90
  getFootnoteProperties(sectionIndex) {
90
- if (sectionIndex === undefined || !sections) return undefined;
91
- return sections[sectionIndex]?.footnotePr;
91
+ return resolveFootnoteLikeProperties(
92
+ sectionIndex,
93
+ sections,
94
+ settings?.footnotePr,
95
+ "footnotePr",
96
+ );
92
97
  },
93
98
  getEndnoteProperties(sectionIndex) {
94
- if (sectionIndex === undefined || !sections) return undefined;
95
- return sections[sectionIndex]?.endnotePr;
99
+ return resolveFootnoteLikeProperties(
100
+ sectionIndex,
101
+ sections,
102
+ settings?.endnotePr,
103
+ "endnotePr",
104
+ );
96
105
  },
97
106
  getEndnotesForSection(sectionIndex) {
98
107
  if (!sections || !document) return EMPTY_READONLY_STRING_ARRAY;
@@ -107,6 +116,21 @@ export function createFootnoteResolver(
107
116
 
108
117
  const EMPTY_READONLY_STRING_ARRAY: readonly string[] = Object.freeze([]);
109
118
 
119
+ function resolveFootnoteLikeProperties<T extends FootnoteProperties | EndnoteProperties>(
120
+ sectionIndex: number | undefined,
121
+ sections: readonly SectionProperties[] | undefined,
122
+ defaultProps: T | undefined,
123
+ key: "footnotePr" | "endnotePr",
124
+ ): T | undefined {
125
+ if (sectionIndex !== undefined && sections) {
126
+ const sectionProps = sections[sectionIndex]?.[key];
127
+ if (sectionProps) {
128
+ return sectionProps as T;
129
+ }
130
+ }
131
+ return defaultProps;
132
+ }
133
+
110
134
  /**
111
135
  * Walk the document's top-level block children and collect endnote IDs
112
136
  * referenced from the block range belonging to `targetSectionIndex`.
@@ -215,8 +215,14 @@
215
215
  * CO3.8 `isStaleParaInd` heuristic with a broader `isDegenerateParaInd`
216
216
  * guard (hanging===left → use level geometry) that fixes the APS
217
217
  * Supply paragraph pattern. Cache envelopes from v22 invalidate.
218
+ * 24 — Merge from `main` after the non-SDT closeout. The layout measurement
219
+ * and resolved-formatting stack changed in `measurement-backend-canvas`,
220
+ * `measurement-backend-empirical`, `paginated-layout-engine`,
221
+ * `resolved-formatting-document`, and `resolved-formatting-state`.
222
+ * Persisted prerender/layout caches must invalidate so geometry derived
223
+ * under v23 is never reused against the merged layout pipeline.
218
224
  */
219
- export const LAYOUT_ENGINE_VERSION = 23 as const;
225
+ export const LAYOUT_ENGINE_VERSION = 24 as const;
220
226
 
221
227
  /**
222
228
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -188,7 +188,7 @@ export function createCanvasBackend(
188
188
  case "tab": {
189
189
  // Advance to the next tab stop (in twips).
190
190
  const avgCharWidth = formatting.averageCharWidthTwips;
191
- const defaultTabInterval = 720;
191
+ const defaultTabInterval = formatting.defaultTabInterval;
192
192
  const position = currentLineWidth + formatting.indentLeft;
193
193
  let nextTab = Math.ceil((position + 1) / defaultTabInterval) * defaultTabInterval;
194
194
  for (const tab of formatting.tabStops) {
@@ -187,7 +187,7 @@ function resolveTabAdvance(
187
187
  currentChars: number,
188
188
  ): number {
189
189
  const avgCharWidth = formatting.averageCharWidthTwips;
190
- const defaultTabInterval = 720;
190
+ const defaultTabInterval = formatting.defaultTabInterval;
191
191
  const currentPosition = currentChars * avgCharWidth + formatting.indentLeft;
192
192
 
193
193
  if (formatting.tabStops.length === 0) {
@@ -197,6 +197,7 @@ export function buildPageStackWithSplits(
197
197
  mainSurface: EditorSurfaceSnapshot,
198
198
  measurementProvider?: LayoutMeasurementProvider,
199
199
  ): PageStackResultWithSplits {
200
+ const defaultTabInterval = document.subParts?.settings?.defaultTabStop ?? 720;
200
201
  const pages: DocumentPageSnapshot[] = [];
201
202
  const splitsByBlock = new Map<string, ParagraphLineSlice[]>();
202
203
  // P8.1b — aggregate note allocations and fragments across all sections,
@@ -270,6 +271,7 @@ export function buildPageStackWithSplits(
270
271
  document.subParts?.footnoteCollection,
271
272
  measurementProvider,
272
273
  cache,
274
+ defaultTabInterval,
273
275
  );
274
276
  const paginated = paginatedResult.pages;
275
277
 
@@ -795,6 +797,7 @@ function measureBlockHeight(
795
797
  columnWidth: number,
796
798
  measurementProvider?: LayoutMeasurementProvider,
797
799
  cache?: MeasurementCache,
800
+ defaultTabInterval = 720,
798
801
  ): number {
799
802
  if (!block) return 0;
800
803
 
@@ -804,7 +807,7 @@ function measureBlockHeight(
804
807
  const compute = (): number => {
805
808
  switch (block.kind) {
806
809
  case "paragraph": {
807
- const formatting = resolveBlockFormatting(block);
810
+ const formatting = resolveBlockFormatting(block, defaultTabInterval);
808
811
  if (formatting) {
809
812
  // Provider path: sum per-line heights so canvas-backed measurements
810
813
  // that emit variable line heights (mixed inline font sizes, etc.)
@@ -837,14 +840,26 @@ function measureBlockHeight(
837
840
  return estimateBlockHeight(block, columnWidth);
838
841
  }
839
842
  case "table":
840
- return measureTableHeight(block, columnWidth, measurementProvider, cache);
843
+ return measureTableHeight(
844
+ block,
845
+ columnWidth,
846
+ measurementProvider,
847
+ cache,
848
+ defaultTabInterval,
849
+ );
841
850
  case "sdt_block":
842
851
  return Math.max(
843
852
  MIN_BLOCK_HEIGHT_TWIPS,
844
853
  block.children.reduce(
845
854
  (total, child) =>
846
855
  total +
847
- measureBlockHeight(child, columnWidth, measurementProvider, cache),
856
+ measureBlockHeight(
857
+ child,
858
+ columnWidth,
859
+ measurementProvider,
860
+ cache,
861
+ defaultTabInterval,
862
+ ),
848
863
  0,
849
864
  ),
850
865
  );
@@ -875,6 +890,7 @@ function measureTableHeight(
875
890
  columnWidth: number,
876
891
  measurementProvider?: LayoutMeasurementProvider,
877
892
  cache?: MeasurementCache,
893
+ defaultTabInterval = 720,
878
894
  ): number {
879
895
  const TABLE_ROW_PADDING_TWIPS = 120;
880
896
  let totalHeight = 0;
@@ -920,6 +936,7 @@ function measureTableHeight(
920
936
  cellWidth,
921
937
  measurementProvider,
922
938
  cache,
939
+ defaultTabInterval,
923
940
  );
924
941
  }
925
942
  contentHeight = Math.max(contentHeight, cellContentHeight + TABLE_ROW_PADDING_TWIPS);
@@ -1091,8 +1108,7 @@ function resolveTabAdvance(
1091
1108
  avgCharWidth: number,
1092
1109
  columnWidth: number,
1093
1110
  ): number {
1094
- // Default tab stops every 720 twips (0.5 inch)
1095
- const defaultTabInterval = 720;
1111
+ const defaultTabInterval = formatting.defaultTabInterval;
1096
1112
  const currentPosition = currentChars * avgCharWidth + formatting.indentLeft;
1097
1113
 
1098
1114
  if (formatting.tabStops.length === 0) {
@@ -1156,6 +1172,7 @@ function paginateSectionBlocks(
1156
1172
  footnotes: FootnoteCollection | undefined,
1157
1173
  measurementProvider?: LayoutMeasurementProvider,
1158
1174
  cache?: MeasurementCache,
1175
+ defaultTabInterval = 720,
1159
1176
  ): Omit<DocumentPageSnapshot, "pageIndex">[] {
1160
1177
  return paginateSectionBlocksWithSplits(
1161
1178
  section,
@@ -1164,6 +1181,7 @@ function paginateSectionBlocks(
1164
1181
  footnotes,
1165
1182
  measurementProvider,
1166
1183
  cache,
1184
+ defaultTabInterval,
1167
1185
  ).pages;
1168
1186
  }
1169
1187
 
@@ -1174,6 +1192,7 @@ export function paginateSectionBlocksWithSplits(
1174
1192
  footnotes: FootnoteCollection | undefined,
1175
1193
  measurementProvider?: LayoutMeasurementProvider,
1176
1194
  cache?: MeasurementCache,
1195
+ defaultTabInterval = 720,
1177
1196
  ): SectionPaginationResult {
1178
1197
  if (blocks.length === 0) {
1179
1198
  return {
@@ -1329,18 +1348,32 @@ export function paginateSectionBlocksWithSplits(
1329
1348
  const columnWidth =
1330
1349
  columnMetrics[Math.min(columnIndex, columnMetrics.length - 1)]?.width ??
1331
1350
  getUsableColumnWidth(layout);
1332
- const baseHeight = measureBlockHeight(block, columnWidth, measurementProvider, cache);
1351
+ const baseHeight = measureBlockHeight(
1352
+ block,
1353
+ columnWidth,
1354
+ measurementProvider,
1355
+ cache,
1356
+ defaultTabInterval,
1357
+ );
1333
1358
 
1334
1359
  // keepNext: this paragraph must stay with the next one on the same page
1335
1360
  const keepWithNextHeight =
1336
1361
  block.kind === "paragraph" && block.keepNext
1337
1362
  ? baseHeight +
1338
- measureBlockHeight(blocks[index + 1], columnWidth, measurementProvider, cache)
1363
+ measureBlockHeight(
1364
+ blocks[index + 1],
1365
+ columnWidth,
1366
+ measurementProvider,
1367
+ cache,
1368
+ defaultTabInterval,
1369
+ )
1339
1370
  : baseHeight;
1340
1371
 
1341
1372
  // keepLines: the entire paragraph must fit on one page.
1342
1373
  // If it doesn't fit and there's already content on this page, break before it.
1343
- const formatting = block.kind === "paragraph" ? resolveBlockFormatting(block) : null;
1374
+ const formatting = block.kind === "paragraph"
1375
+ ? resolveBlockFormatting(block, defaultTabInterval)
1376
+ : null;
1344
1377
  const keepLinesActive = formatting?.keepLines ?? false;
1345
1378
 
1346
1379
  const noteHeight = estimateFootnoteReservation(block, footnotes, columnWidth, reservedNotes);
@@ -122,13 +122,14 @@ export function buildResolvedFormattingState(
122
122
  const usedFamilies = new Set<string>();
123
123
 
124
124
  const theme = document.subParts?.resolvedTheme;
125
- collectFormatting(mainSurface.blocks, paragraphs, runs, usedFamilies, theme);
125
+ const tabs = buildTabSettings(document.subParts?.settings);
126
+ collectFormatting(mainSurface.blocks, paragraphs, runs, usedFamilies, theme, tabs.defaultTabInterval);
126
127
 
127
128
  return {
128
129
  paragraphs,
129
130
  runs,
130
131
  fonts: buildFontCatalog(usedFamilies, document.subParts),
131
- tabs: buildTabSettings(),
132
+ tabs,
132
133
  numbering: buildNumberingGeometry(document.subParts),
133
134
  settings: buildDocumentSettings(document.subParts?.settings),
134
135
  revision: revisionCounter,
@@ -141,11 +142,12 @@ function collectFormatting(
141
142
  runs: Map<RunId, ResolvedRunFormatting>,
142
143
  usedFamilies: Set<string>,
143
144
  theme: ResolvedTheme | undefined,
145
+ defaultTabInterval: number,
144
146
  ): void {
145
147
  for (const block of blocks) {
146
148
  switch (block.kind) {
147
149
  case "paragraph": {
148
- const formatting = resolveBlockFormatting(block);
150
+ const formatting = resolveBlockFormatting(block, defaultTabInterval);
149
151
  if (formatting) {
150
152
  paragraphs.set(block.blockId, formatting);
151
153
  }
@@ -163,12 +165,12 @@ function collectFormatting(
163
165
  case "table":
164
166
  for (const row of block.rows) {
165
167
  for (const cell of row.cells) {
166
- collectFormatting(cell.content, paragraphs, runs, usedFamilies, theme);
168
+ collectFormatting(cell.content, paragraphs, runs, usedFamilies, theme, defaultTabInterval);
167
169
  }
168
170
  }
169
171
  break;
170
172
  case "sdt_block":
171
- collectFormatting(block.children, paragraphs, runs, usedFamilies, theme);
173
+ collectFormatting(block.children, paragraphs, runs, usedFamilies, theme, defaultTabInterval);
172
174
  break;
173
175
  }
174
176
  }
@@ -244,11 +246,11 @@ function buildFontCatalog(
244
246
  };
245
247
  }
246
248
 
247
- function buildTabSettings(): ResolvedTabSettings {
248
- // Current DocumentSettings model does not carry `defaultTabStop`.
249
- // Word's default is 720 twips (0.5"); retain that until settings exposes one.
249
+ function buildTabSettings(
250
+ settings: DocumentSettings | undefined,
251
+ ): ResolvedTabSettings {
250
252
  return {
251
- defaultTabInterval: 720,
253
+ defaultTabInterval: settings?.defaultTabStop ?? 720,
252
254
  };
253
255
  }
254
256
 
@@ -100,6 +100,8 @@ export interface ResolvedParagraphFormatting {
100
100
  averageCharWidthTwips: number;
101
101
  /** Effective tab stops sorted by position. */
102
102
  tabStops: LayoutTabStop[];
103
+ /** Document default tab interval in twips. */
104
+ defaultTabInterval: number;
103
105
  /** Keep with next paragraph. */
104
106
  keepNext: boolean;
105
107
  /** Keep all lines of this paragraph on the same page. */
@@ -140,6 +142,7 @@ export interface ResolvedTableRowFormatting {
140
142
 
141
143
  export function resolveBlockFormatting(
142
144
  block: SurfaceBlockSnapshot,
145
+ defaultTabInterval = 720,
143
146
  ): ResolvedParagraphFormatting | null {
144
147
  if (block.kind !== "paragraph") {
145
148
  return null;
@@ -163,6 +166,7 @@ export function resolveBlockFormatting(
163
166
  fontSizeHalfPoints: fontInfo.fontSizeHalfPoints,
164
167
  averageCharWidthTwips: fontInfo.avgCharWidth,
165
168
  tabStops: resolveTabStops(block),
169
+ defaultTabInterval,
166
170
  keepNext: Boolean(block.keepNext ?? block.resolvedParagraphFormatting?.keepNext),
167
171
  keepLines: Boolean(block.keepLines ?? block.resolvedParagraphFormatting?.keepLines),
168
172
  pageBreakBefore: Boolean(block.pageBreakBefore ?? block.resolvedParagraphFormatting?.pageBreakBefore),
@@ -144,8 +144,10 @@ function advanceSequence(
144
144
  currentLevel: number,
145
145
  levelDefinitions: Map<number, NumberingLevelDefinition>,
146
146
  ): void {
147
- if (state.lastLevel !== null && currentLevel <= state.lastLevel) {
148
- state.counters.length = currentLevel + 1;
147
+ for (let level = currentLevel + 1; level < state.counters.length; level += 1) {
148
+ if (shouldResetDeeperLevel(level, currentLevel, levelDefinitions)) {
149
+ state.counters[level] = undefined;
150
+ }
149
151
  }
150
152
 
151
153
  // Initialize any skipped parent levels so legal outline %1.%2.%3. patterns
@@ -163,6 +165,28 @@ function advanceSequence(
163
165
  state.lastLevel = currentLevel;
164
166
  }
165
167
 
168
+ function shouldResetDeeperLevel(
169
+ level: number,
170
+ triggeringLevel: number,
171
+ levelDefinitions: Map<number, NumberingLevelDefinition>,
172
+ ): boolean {
173
+ const restartAfterLevel = levelDefinitions.get(level)?.restartAfterLevel;
174
+ if (restartAfterLevel === 0) {
175
+ return false;
176
+ }
177
+
178
+ const defaultRestartLevel = level - 1;
179
+ const restartIndex = restartAfterLevel !== undefined
180
+ ? restartAfterLevel - 1
181
+ : defaultRestartLevel;
182
+
183
+ if (restartIndex >= level) {
184
+ return triggeringLevel <= defaultRestartLevel;
185
+ }
186
+
187
+ return triggeringLevel <= restartIndex;
188
+ }
189
+
166
190
  function getLevelStartAt(
167
191
  level: number,
168
192
  levelDefinitions: Map<number, NumberingLevelDefinition>,
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ EditorAnchorProjection,
2
3
  EditorStoryTarget,
3
4
  ScopeQueryFilter,
4
5
  ScopeQueryResult,
@@ -31,10 +32,97 @@ function storyTargetsEqual(a: EditorStoryTarget, b: EditorStoryTarget): boolean
31
32
 
32
33
  const MAIN_STORY: EditorStoryTarget = { kind: "main" };
33
34
 
35
+ function workflowAnchorsEqual(
36
+ left: EditorAnchorProjection,
37
+ right: EditorAnchorProjection,
38
+ ): boolean {
39
+ if (left.kind !== right.kind) return false;
40
+ switch (left.kind) {
41
+ case "range":
42
+ return (
43
+ right.kind === "range" &&
44
+ left.from === right.from &&
45
+ left.to === right.to &&
46
+ left.assoc.start === right.assoc.start &&
47
+ left.assoc.end === right.assoc.end
48
+ );
49
+ case "node":
50
+ return right.kind === "node" && left.at === right.at;
51
+ case "detached":
52
+ return (
53
+ right.kind === "detached" &&
54
+ left.reason === right.reason &&
55
+ left.lastKnownRange.from === right.lastKnownRange.from &&
56
+ left.lastKnownRange.to === right.lastKnownRange.to
57
+ );
58
+ default:
59
+ return false;
60
+ }
61
+ }
62
+
63
+ function buildScopeIdCounts(overlay: WorkflowOverlay): Map<string, number> {
64
+ const counts = new Map<string, number>();
65
+ for (const scope of overlay.scopes) {
66
+ counts.set(scope.scopeId, (counts.get(scope.scopeId) ?? 0) + 1);
67
+ }
68
+ return counts;
69
+ }
70
+
71
+ function normalizeScopeAnchor(
72
+ scope: WorkflowScope,
73
+ scopeIdCounts: ReadonlyMap<string, number>,
74
+ locations: ReadonlyMap<string, { startPos?: number; endPos?: number }>,
75
+ markerBackedScopeIds: ReadonlySet<string>,
76
+ ): WorkflowScope {
77
+ if ((scopeIdCounts.get(scope.scopeId) ?? 0) !== 1) {
78
+ return scope;
79
+ }
80
+
81
+ const location = locations.get(scope.scopeId);
82
+ let nextAnchor: EditorAnchorProjection | null = null;
83
+ if (
84
+ location &&
85
+ location.startPos !== undefined &&
86
+ location.endPos !== undefined
87
+ ) {
88
+ nextAnchor = {
89
+ kind: "range",
90
+ from: Math.min(location.startPos, location.endPos),
91
+ to: Math.max(location.startPos, location.endPos),
92
+ assoc: { start: -1, end: 1 },
93
+ };
94
+ } else if (markerBackedScopeIds.has(scope.scopeId)) {
95
+ const lastKnownRange =
96
+ scope.anchor.kind === "range"
97
+ ? { from: scope.anchor.from, to: scope.anchor.to }
98
+ : scope.anchor.kind === "node"
99
+ ? { from: scope.anchor.at, to: scope.anchor.at }
100
+ : scope.anchor.lastKnownRange;
101
+ nextAnchor = {
102
+ kind: "detached",
103
+ reason:
104
+ location && (location.startPos !== undefined || location.endPos !== undefined)
105
+ ? "deleted"
106
+ : "invalidatedByStructureChange",
107
+ lastKnownRange,
108
+ };
109
+ } else {
110
+ return scope;
111
+ }
112
+
113
+ return workflowAnchorsEqual(scope.anchor, nextAnchor)
114
+ ? scope
115
+ : {
116
+ ...scope,
117
+ anchor: nextAnchor,
118
+ };
119
+ }
120
+
34
121
  export interface ScopeQueryInputs {
35
122
  readonly overlay: WorkflowOverlay | null;
36
123
  readonly entries: readonly WorkflowMetadataEntry[];
37
124
  readonly document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope;
125
+ readonly markerBackedScopeIds?: ReadonlySet<string>;
38
126
  }
39
127
 
40
128
  /**
@@ -53,9 +141,17 @@ export function projectScopeQueryResults(
53
141
  if (!overlay) return [];
54
142
  const includeHidden = options.includeHidden === true;
55
143
  const includeInvisible = options.includeInvisible === true;
144
+ const scopeIdCounts = buildScopeIdCounts(overlay);
145
+ const locations = collectScopeLocations(inputs.document);
146
+ const markerBackedScopeIds = inputs.markerBackedScopeIds ?? new Set<string>();
56
147
 
57
148
  const scopesById = new Map<string, WorkflowScope>();
58
- for (const scope of overlay.scopes) scopesById.set(scope.scopeId, scope);
149
+ for (const scope of overlay.scopes) {
150
+ scopesById.set(
151
+ scope.scopeId,
152
+ normalizeScopeAnchor(scope, scopeIdCounts, locations, markerBackedScopeIds),
153
+ );
154
+ }
59
155
 
60
156
  const entriesByScope = new Map<string, WorkflowMetadataEntry[]>();
61
157
  for (const entry of inputs.entries) {
@@ -128,6 +224,8 @@ export function queryScopes(
128
224
  }
129
225
 
130
226
  const locations = collectScopeLocations(inputs.document);
227
+ const scopeIdCounts = buildScopeIdCounts(overlay);
228
+ const markerBackedScopeIds = inputs.markerBackedScopeIds ?? new Set<string>();
131
229
 
132
230
  const candidates: Array<{ scope: WorkflowScope; startPos: number }> = [];
133
231
  for (const scope of overlay.scopes) {
@@ -162,7 +260,10 @@ export function queryScopes(
162
260
  const loc = locations.get(scope.scopeId);
163
261
  const startPos =
164
262
  loc?.startPos ?? loc?.endPos ?? Number.POSITIVE_INFINITY;
165
- candidates.push({ scope, startPos });
263
+ candidates.push({
264
+ scope: normalizeScopeAnchor(scope, scopeIdCounts, locations, markerBackedScopeIds),
265
+ startPos,
266
+ });
166
267
  }
167
268
 
168
269
  candidates.sort((a, b) => {