@beyondwork/docx-react-component 1.0.57 → 1.0.59

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 (135) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/src/api/awareness-identity-types.ts +4 -2
  4. package/src/api/comment-negotiation-types.ts +4 -1
  5. package/src/api/external-custody-types.ts +16 -0
  6. package/src/api/internal/build-ref-projections.ts +108 -0
  7. package/src/api/package-version.ts +1 -1
  8. package/src/api/participants-types.ts +11 -1
  9. package/src/api/public-types.ts +1149 -8
  10. package/src/api/scope-metadata-resolver-types.ts +6 -0
  11. package/src/compare/diff-engine.ts +3 -0
  12. package/src/core/commands/formatting-commands.ts +1 -0
  13. package/src/core/commands/index.ts +225 -16
  14. package/src/core/commands/legacy-form-field-commands.ts +181 -0
  15. package/src/core/commands/table-structure-commands.ts +149 -31
  16. package/src/core/selection/mapping.ts +20 -0
  17. package/src/core/state/editor-state.ts +2 -1
  18. package/src/index.ts +28 -0
  19. package/src/io/docx-session.ts +22 -3
  20. package/src/io/export/export-session.ts +11 -7
  21. package/src/io/export/ooxml-namespaces.ts +47 -0
  22. package/src/io/export/reattach-preserved-parts.ts +4 -16
  23. package/src/io/export/serialize-comments.ts +3 -131
  24. package/src/io/export/serialize-ffdata.ts +89 -0
  25. package/src/io/export/serialize-headers-footers.ts +5 -0
  26. package/src/io/export/serialize-main-document.ts +224 -34
  27. package/src/io/export/serialize-numbering.ts +22 -2
  28. package/src/io/export/serialize-revisions.ts +99 -0
  29. package/src/io/export/serialize-tables.ts +9 -0
  30. package/src/io/export/split-review-boundaries.ts +1 -0
  31. package/src/io/export/table-properties-xml.ts +14 -0
  32. package/src/io/load-scheduler.ts +70 -28
  33. package/src/io/normalize/normalize-text.ts +13 -0
  34. package/src/io/ooxml/_mini-xml.ts +198 -0
  35. package/src/io/ooxml/canonicalize-payload.ts +1 -4
  36. package/src/io/ooxml/chart/chart-style-table.ts +4 -3
  37. package/src/io/ooxml/chart/parse-chart-space.ts +2 -4
  38. package/src/io/ooxml/chart/parse-series.ts +2 -1
  39. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  40. package/src/io/ooxml/chart/types.ts +6 -434
  41. package/src/io/ooxml/comment-presentation-payload.ts +6 -5
  42. package/src/io/ooxml/highlight-colors.ts +8 -5
  43. package/src/io/ooxml/parse-anchor.ts +68 -53
  44. package/src/io/ooxml/parse-comments.ts +14 -142
  45. package/src/io/ooxml/parse-complex-content.ts +3 -106
  46. package/src/io/ooxml/parse-drawing.ts +100 -195
  47. package/src/io/ooxml/parse-ffdata.ts +93 -0
  48. package/src/io/ooxml/parse-fields.ts +7 -146
  49. package/src/io/ooxml/parse-fill.ts +88 -8
  50. package/src/io/ooxml/parse-font-table.ts +5 -105
  51. package/src/io/ooxml/parse-footnotes.ts +28 -152
  52. package/src/io/ooxml/parse-headers-footers.ts +106 -212
  53. package/src/io/ooxml/parse-inline-media.ts +3 -200
  54. package/src/io/ooxml/parse-main-document.ts +180 -217
  55. package/src/io/ooxml/parse-numbering.ts +154 -335
  56. package/src/io/ooxml/parse-object.ts +147 -0
  57. package/src/io/ooxml/parse-ole-relationship.ts +82 -0
  58. package/src/io/ooxml/parse-paragraph-formatting.ts +7 -10
  59. package/src/io/ooxml/parse-picture-sdt.ts +85 -0
  60. package/src/io/ooxml/parse-picture.ts +120 -39
  61. package/src/io/ooxml/parse-revisions.ts +285 -51
  62. package/src/io/ooxml/parse-settings.ts +6 -99
  63. package/src/io/ooxml/parse-shapes.ts +25 -140
  64. package/src/io/ooxml/parse-styles.ts +3 -218
  65. package/src/io/ooxml/parse-tables.ts +76 -256
  66. package/src/io/ooxml/parse-theme.ts +1 -4
  67. package/src/io/ooxml/property-grab-bag.ts +5 -47
  68. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  69. package/src/io/ooxml/xml-parser.ts +183 -0
  70. package/src/legal/bookmarks.ts +1 -1
  71. package/src/legal/cross-references.ts +1 -1
  72. package/src/legal/defined-terms.ts +1 -1
  73. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  74. package/src/legal/signature-blocks.ts +1 -1
  75. package/src/model/canonical-document.ts +165 -6
  76. package/src/model/chart-types.ts +439 -0
  77. package/src/model/snapshot.ts +3 -1
  78. package/src/review/store/comment-remapping.ts +24 -11
  79. package/src/review/store/revision-actions.ts +482 -2
  80. package/src/review/store/revision-store.ts +15 -0
  81. package/src/review/store/revision-types.ts +76 -0
  82. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  83. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  84. package/src/runtime/diagnostics/build-diagnostic.ts +151 -0
  85. package/src/runtime/diagnostics/code-metadata-table.ts +221 -0
  86. package/src/runtime/document-runtime.ts +544 -35
  87. package/src/runtime/document-search.ts +176 -0
  88. package/src/runtime/edit-ops/index.ts +18 -2
  89. package/src/runtime/footnote-resolver.ts +130 -0
  90. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  91. package/src/runtime/layout/layout-engine-version.ts +37 -1
  92. package/src/runtime/layout/page-graph.ts +14 -1
  93. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  94. package/src/runtime/numbering-prefix.ts +17 -0
  95. package/src/runtime/query-scopes.ts +183 -0
  96. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  97. package/src/runtime/revision-runtime.ts +27 -1
  98. package/src/runtime/scope-resolver.ts +60 -0
  99. package/src/runtime/selection/post-edit-validator.ts +60 -6
  100. package/src/runtime/structure-ops/index.ts +20 -4
  101. package/src/runtime/surface-projection.ts +293 -18
  102. package/src/runtime/table-schema.ts +6 -0
  103. package/src/runtime/theme-color-resolver.ts +2 -2
  104. package/src/runtime/units.ts +9 -0
  105. package/src/runtime/workflow-rail-segments.ts +4 -0
  106. package/src/ui/WordReviewEditor.tsx +258 -44
  107. package/src/ui/editor-runtime-boundary.ts +13 -0
  108. package/src/ui/editor-shell-view.tsx +4 -1
  109. package/src/ui/headless/chrome-registry.ts +53 -0
  110. package/src/ui/headless/selection-tool-resolver.ts +11 -1
  111. package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
  112. package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
  113. package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
  114. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
  115. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
  116. package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
  117. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +23 -9
  118. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +158 -0
  119. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
  120. package/src/ui-tailwind/editor-surface/pm-schema.ts +105 -17
  121. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +13 -0
  122. package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
  123. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
  124. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
  125. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
  126. package/src/ui-tailwind/index.ts +9 -0
  127. package/src/ui-tailwind/page-chrome-model.ts +77 -5
  128. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
  129. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
  130. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
  131. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
  132. package/src/ui-tailwind/theme/tokens.ts +14 -0
  133. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
  134. package/src/ui-tailwind/tw-review-workspace.tsx +52 -87
  135. package/src/validation/diagnostics.ts +1 -0
@@ -0,0 +1,183 @@
1
+ import type {
2
+ EditorStoryTarget,
3
+ ScopeQueryFilter,
4
+ ScopeQueryResult,
5
+ WorkflowMetadataEntry,
6
+ WorkflowOverlay,
7
+ WorkflowScope,
8
+ WorkflowWorkItem,
9
+ } from "../api/public-types.ts";
10
+ import type { CanonicalDocument } from "../model/canonical-document.ts";
11
+ import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
12
+ import { collectScopeLocations } from "./scope-resolver.ts";
13
+
14
+ function storyTargetsEqual(a: EditorStoryTarget, b: EditorStoryTarget): boolean {
15
+ if (a.kind !== b.kind) return false;
16
+ switch (a.kind) {
17
+ case "main":
18
+ return true;
19
+ case "header":
20
+ case "footer":
21
+ return (
22
+ a.relationshipId === (b as typeof a).relationshipId &&
23
+ a.variant === (b as typeof a).variant &&
24
+ a.sectionIndex === (b as typeof a).sectionIndex
25
+ );
26
+ case "footnote":
27
+ case "endnote":
28
+ return a.noteId === (b as typeof a).noteId;
29
+ }
30
+ }
31
+
32
+ const MAIN_STORY: EditorStoryTarget = { kind: "main" };
33
+
34
+ export interface ScopeQueryInputs {
35
+ readonly overlay: WorkflowOverlay | null;
36
+ readonly entries: readonly WorkflowMetadataEntry[];
37
+ readonly document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope;
38
+ }
39
+
40
+ /**
41
+ * Phase C §C2 helper — project an ordered list of scopeIds (already produced
42
+ * by a geometric walk like `findAllScopesAt` / `findScopesIntersecting`)
43
+ * into `ScopeQueryResult[]`. Preserves incoming order. Filters out scopeIds
44
+ * that are not in the overlay (e.g. orphan markers). Applies the §C8
45
+ * visibility filter via `includeHidden` / `includeInvisible`.
46
+ */
47
+ export function projectScopeQueryResults(
48
+ inputs: ScopeQueryInputs,
49
+ scopeIds: readonly string[],
50
+ options: { includeHidden?: boolean; includeInvisible?: boolean } = {},
51
+ ): ScopeQueryResult[] {
52
+ const overlay = inputs.overlay;
53
+ if (!overlay) return [];
54
+ const includeHidden = options.includeHidden === true;
55
+ const includeInvisible = options.includeInvisible === true;
56
+
57
+ const scopesById = new Map<string, WorkflowScope>();
58
+ for (const scope of overlay.scopes) scopesById.set(scope.scopeId, scope);
59
+
60
+ const entriesByScope = new Map<string, WorkflowMetadataEntry[]>();
61
+ for (const entry of inputs.entries) {
62
+ if (!entry.scopeId) continue;
63
+ let list = entriesByScope.get(entry.scopeId);
64
+ if (!list) {
65
+ list = [];
66
+ entriesByScope.set(entry.scopeId, list);
67
+ }
68
+ list.push(entry);
69
+ }
70
+
71
+ const workItemsById = new Map<string, WorkflowWorkItem>();
72
+ for (const item of overlay.workItems ?? []) {
73
+ workItemsById.set(item.workItemId, item);
74
+ }
75
+
76
+ const results: ScopeQueryResult[] = [];
77
+ for (const scopeId of scopeIds) {
78
+ const scope = scopesById.get(scopeId);
79
+ if (!scope) continue;
80
+ if (scope.visibility === "hidden" && !includeHidden) continue;
81
+ if (scope.visibility === "invisible" && !includeInvisible) continue;
82
+ const entries = entriesByScope.get(scopeId) ?? [];
83
+ const workItem = scope.workItemId
84
+ ? workItemsById.get(scope.workItemId) ?? null
85
+ : null;
86
+ results.push({ scope, entries, workItem });
87
+ }
88
+ return results;
89
+ }
90
+
91
+ /**
92
+ * Pure §C1 projector — filter + join scopes with entries + workItem, ordered
93
+ * by start-marker document position (scopeId ASC tiebreak). Snapshot-based;
94
+ * no runtime state mutation.
95
+ */
96
+ export function queryScopes(
97
+ inputs: ScopeQueryInputs,
98
+ filter: ScopeQueryFilter | undefined,
99
+ ): ScopeQueryResult[] {
100
+ const overlay = inputs.overlay;
101
+ if (!overlay) return [];
102
+
103
+ const normalizedStoryFilter = filter?.storyTarget ?? MAIN_STORY;
104
+ const workItemIdSet = filter?.workItemIds ? new Set(filter.workItemIds) : null;
105
+ const modeSet = filter?.modes ? new Set(filter.modes) : null;
106
+ const domainSet = filter?.domains ? new Set(filter.domains) : null;
107
+ const labelPrefix = filter?.labelPrefix?.toLowerCase();
108
+ const metadataId = filter?.metadataId;
109
+ const hasValue = filter?.hasValue;
110
+ const limit = filter?.limit;
111
+ const includeHidden = filter?.includeHidden === true;
112
+ const includeInvisible = filter?.includeInvisible === true;
113
+
114
+ const entriesByScope = new Map<string, WorkflowMetadataEntry[]>();
115
+ for (const entry of inputs.entries) {
116
+ if (!entry.scopeId) continue;
117
+ let list = entriesByScope.get(entry.scopeId);
118
+ if (!list) {
119
+ list = [];
120
+ entriesByScope.set(entry.scopeId, list);
121
+ }
122
+ list.push(entry);
123
+ }
124
+
125
+ const workItemsById = new Map<string, WorkflowWorkItem>();
126
+ for (const item of overlay.workItems ?? []) {
127
+ workItemsById.set(item.workItemId, item);
128
+ }
129
+
130
+ const locations = collectScopeLocations(inputs.document);
131
+
132
+ const candidates: Array<{ scope: WorkflowScope; startPos: number }> = [];
133
+ for (const scope of overlay.scopes) {
134
+ // §C8: respect visibility flags. Missing field treated as "visible".
135
+ if (scope.visibility === "hidden" && !includeHidden) continue;
136
+ if (scope.visibility === "invisible" && !includeInvisible) continue;
137
+
138
+ if (normalizedStoryFilter !== "*") {
139
+ const scopeStory = scope.storyTarget ?? MAIN_STORY;
140
+ if (!storyTargetsEqual(scopeStory, normalizedStoryFilter)) continue;
141
+ }
142
+ if (modeSet && !modeSet.has(scope.mode)) continue;
143
+ if (domainSet && !(scope.domain && domainSet.has(scope.domain))) continue;
144
+ if (workItemIdSet) {
145
+ if (!scope.workItemId || !workItemIdSet.has(scope.workItemId)) continue;
146
+ }
147
+ if (labelPrefix) {
148
+ const label = scope.label?.toLowerCase() ?? "";
149
+ if (!label.startsWith(labelPrefix)) continue;
150
+ }
151
+
152
+ const scopeEntries = entriesByScope.get(scope.scopeId) ?? [];
153
+
154
+ if (metadataId && !scopeEntries.some((e) => e.metadataId === metadataId)) continue;
155
+ if (hasValue) {
156
+ const anyMatch = scopeEntries.some(
157
+ (e) => e.value !== undefined && hasValue(e.value, e),
158
+ );
159
+ if (!anyMatch) continue;
160
+ }
161
+
162
+ const loc = locations.get(scope.scopeId);
163
+ const startPos =
164
+ loc?.startPos ?? loc?.endPos ?? Number.POSITIVE_INFINITY;
165
+ candidates.push({ scope, startPos });
166
+ }
167
+
168
+ candidates.sort((a, b) => {
169
+ if (a.startPos !== b.startPos) return a.startPos - b.startPos;
170
+ return a.scope.scopeId < b.scope.scopeId ? -1 : a.scope.scopeId > b.scope.scopeId ? 1 : 0;
171
+ });
172
+
173
+ const results: ScopeQueryResult[] = [];
174
+ for (const { scope } of candidates) {
175
+ if (limit !== undefined && results.length >= limit) break;
176
+ const entries = entriesByScope.get(scope.scopeId) ?? [];
177
+ const workItem = scope.workItemId
178
+ ? workItemsById.get(scope.workItemId) ?? null
179
+ : null;
180
+ results.push({ scope, entries, workItem });
181
+ }
182
+ return results;
183
+ }
@@ -199,8 +199,24 @@ function resolveNumberingGeometry(
199
199
  paragraph: Pick<ParagraphNode, "spacing" | "indentation" | "tabStops"> | undefined,
200
200
  levelRunProperties: CanonicalRunFormatting | undefined,
201
201
  ): ResolvedNumberingGeometry {
202
+ // LibreOffice SyncIndentWithList pattern: when a numbered paragraph's w:ind has
203
+ // hanging === left (e.g. left=851 hanging=851), it is a stale Word-written snapshot
204
+ // meaning "no custom indent beyond the list style" — use level geometry directly.
205
+ // This also covers the prior CO3.8 heuristic (para.left === level.left) since Word
206
+ // typically writes hanging=left in that situation too.
207
+ const paraInd = paragraph?.indentation;
208
+ const levelInd = levelGeometry?.indentation;
209
+ const isDegenerateParaInd =
210
+ paraInd !== undefined &&
211
+ paraInd.hanging !== undefined &&
212
+ paraInd.left !== undefined &&
213
+ paraInd.hanging === paraInd.left;
214
+
215
+ const indentation = isDegenerateParaInd
216
+ ? levelInd ? { ...levelInd } : undefined
217
+ : mergeParagraphIndentation(levelInd, paraInd);
218
+
202
219
  const spacing = mergeParagraphSpacing(levelGeometry?.spacing, paragraph?.spacing);
203
- const indentation = mergeParagraphIndentation(levelGeometry?.indentation, paragraph?.indentation);
204
220
  const tabStops = paragraph?.tabStops?.length
205
221
  ? cloneTabStops(paragraph.tabStops)
206
222
  : levelGeometry?.tabStops?.length
@@ -244,12 +260,27 @@ function mergeParagraphIndentation(
244
260
  return undefined;
245
261
  }
246
262
 
247
- const indentation: ParagraphIndentation = {
248
- ...(base ?? {}),
249
- ...(override ?? {}),
250
- };
263
+ const out: ParagraphIndentation = {};
264
+
265
+ const left = override?.left ?? base?.left;
266
+ if (left !== undefined) out.left = left;
267
+
268
+ const right = override?.right ?? base?.right;
269
+ if (right !== undefined) out.right = right;
270
+
271
+ // ECMA-376 §17.3.1.12: firstLine and hanging are mutually exclusive on a single
272
+ // w:ind element. When override specifies either, it wins exclusively.
273
+ const overrideTouchesFLorH =
274
+ override?.firstLine !== undefined || override?.hanging !== undefined;
275
+ if (overrideTouchesFLorH) {
276
+ if (override?.hanging !== undefined) out.hanging = override.hanging;
277
+ else if (override?.firstLine !== undefined) out.firstLine = override.firstLine;
278
+ } else {
279
+ if (base?.hanging !== undefined) out.hanging = base.hanging;
280
+ else if (base?.firstLine !== undefined) out.firstLine = base.firstLine;
281
+ }
251
282
 
252
- return Object.keys(indentation).length > 0 ? indentation : undefined;
283
+ return Object.keys(out).length > 0 ? out : undefined;
253
284
  }
254
285
 
255
286
  function cloneTabStops(tabStops: readonly TabStop[]): TabStop[] {
@@ -37,10 +37,12 @@ export interface RevisionRuntimeCommandResult extends RevisionRuntimeState {
37
37
  export function applyRevisionRuntimeCommand(
38
38
  options: ApplyRevisionRuntimeCommandOptions,
39
39
  ): RevisionRuntimeCommandResult {
40
- const revisionIds = isSingleRevisionReviewCommand(options.command)
40
+ const rawRevisionIds = isSingleRevisionReviewCommand(options.command)
41
41
  ? [options.command.revisionId]
42
42
  : listBatchRevisionIds(options.state.store);
43
43
 
44
+ const revisionIds = expandLinkedMovePartners(rawRevisionIds, options.state.store);
45
+
44
46
  const outcomes: RevisionActionOutcome[] = [];
45
47
  const mappings: Array<{ revisionId: string; mapping: TransactionMapping; steps: number }> = [];
46
48
  const appliedRevisionIds: string[] = [];
@@ -92,6 +94,30 @@ export function applyRevisionRuntimeCommand(
92
94
  };
93
95
  }
94
96
 
97
+ function expandLinkedMovePartners(
98
+ revisionIds: readonly string[],
99
+ store: RevisionStore,
100
+ ): string[] {
101
+ const expanded = new Set<string>(revisionIds);
102
+ for (const revisionId of revisionIds) {
103
+ const revision = store.revisions[revisionId];
104
+ const partner = revision?.metadata.moveData?.linkedRevisionId;
105
+ if (!partner) {
106
+ continue;
107
+ }
108
+ const partnerRevision = store.revisions[partner];
109
+ // Only pull the partner in if it's still eligible. Partners that have
110
+ // already been accepted/rejected/detached don't belong in the
111
+ // current-intent batch — adding them would surface a cosmetic
112
+ // "already-resolved" skip in `effects.skippedRevisions` that the user
113
+ // never targeted.
114
+ if (partnerRevision && partnerRevision.status === "active") {
115
+ expanded.add(partner);
116
+ }
117
+ }
118
+ return [...expanded];
119
+ }
120
+
95
121
  function listBatchRevisionIds(store: RevisionStore): string[] {
96
122
  return Object.values(store.revisions)
97
123
  .filter((revision) => revision.status === "active")
@@ -146,3 +146,63 @@ export function findScopeAt(
146
146
  }
147
147
  return best;
148
148
  }
149
+
150
+ /**
151
+ * Phase C §C2 — every enclosing scope at `position`, ordered outermost →
152
+ * innermost (lowest startPos first; ties broken on scopeId ASC). Includes
153
+ * scopes that touch the position exactly (`startPos <= position <= endPos`).
154
+ * Companion to `findScopeAt`, which keeps the innermost-only contract.
155
+ */
156
+ export function findAllScopesAt(
157
+ document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope,
158
+ position: number,
159
+ ): ResolvedScopeLocation[] {
160
+ const locations = collectScopeLocations(document);
161
+ const hits: ResolvedScopeLocation[] = [];
162
+ for (const [scopeId, loc] of locations) {
163
+ if (loc.startPos === undefined || loc.endPos === undefined) continue;
164
+ if (position < loc.startPos || position > loc.endPos) continue;
165
+ hits.push({ scopeId, startPos: loc.startPos, endPos: loc.endPos });
166
+ }
167
+ hits.sort((a, b) => {
168
+ if (a.startPos !== b.startPos) return a.startPos - b.startPos;
169
+ return a.scopeId < b.scopeId ? -1 : a.scopeId > b.scopeId ? 1 : 0;
170
+ });
171
+ return hits;
172
+ }
173
+
174
+ /**
175
+ * Phase C §C2 — every scope whose marker range intersects `[rangeFrom,
176
+ * rangeTo]`. `mode: "overlap"` (default) accepts any intersection including
177
+ * touching endpoints; `mode: "contain"` requires the scope's entire marker
178
+ * range to lie within `[rangeFrom, rangeTo]`. Deterministic order: startPos
179
+ * ASC, scopeId ASC.
180
+ */
181
+ export function findScopesIntersecting(
182
+ document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope,
183
+ rangeFrom: number,
184
+ rangeTo: number,
185
+ mode: "overlap" | "contain" = "overlap",
186
+ ): ResolvedScopeLocation[] {
187
+ const from = Math.min(rangeFrom, rangeTo);
188
+ const to = Math.max(rangeFrom, rangeTo);
189
+ const locations = collectScopeLocations(document);
190
+ const hits: ResolvedScopeLocation[] = [];
191
+ for (const [scopeId, loc] of locations) {
192
+ if (loc.startPos === undefined || loc.endPos === undefined) continue;
193
+ const sFrom = Math.min(loc.startPos, loc.endPos);
194
+ const sTo = Math.max(loc.startPos, loc.endPos);
195
+ if (mode === "contain") {
196
+ if (sFrom < from || sTo > to) continue;
197
+ } else {
198
+ // overlap — any intersection including touching endpoints
199
+ if (sTo < from || sFrom > to) continue;
200
+ }
201
+ hits.push({ scopeId, startPos: loc.startPos, endPos: loc.endPos });
202
+ }
203
+ hits.sort((a, b) => {
204
+ if (a.startPos !== b.startPos) return a.startPos - b.startPos;
205
+ return a.scopeId < b.scopeId ? -1 : a.scopeId > b.scopeId ? 1 : 0;
206
+ });
207
+ return hits;
208
+ }
@@ -1,5 +1,14 @@
1
1
  import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
2
2
  import type { SelectionSnapshot } from "../../core/state/editor-state.ts";
3
+ import type {
4
+ EditorSurfaceSnapshot,
5
+ SurfaceBlockSnapshot,
6
+ SurfaceInlineSegment,
7
+ } from "../../api/public-types.ts";
8
+
9
+ export interface SelectionValidationOptions {
10
+ isValidNodeTarget?: (at: number) => boolean;
11
+ }
3
12
 
4
13
  /**
5
14
  * Snap a selection to a valid position relative to the document.
@@ -16,9 +25,9 @@ import type { SelectionSnapshot } from "../../core/state/editor-state.ts";
16
25
  * POST-mutation `surface.storySize`, primed via
17
26
  * `getCachedSurface(state.document, activeStory).storySize`).
18
27
  *
19
- * NodeAnchor invalidation is deferred until CanonicalDocumentEnvelope
20
- * grows an O(1) node-by-id accessor. Until then, NodeAnchor selections
21
- * are returned unchanged (identity).
28
+ * NodeAnchor invalidation stays caller-driven: pass a cheap node-target probe
29
+ * when the caller already has the post-mutation surface. Without that probe,
30
+ * NodeAnchor selections are returned unchanged (identity).
22
31
  *
23
32
  * @param document The post-mutation canonical document. Currently
24
33
  * unused except for the deferred NodeAnchor branch;
@@ -42,11 +51,28 @@ export function validateSelectionAgainstDocument(
42
51
  document: CanonicalDocumentEnvelope,
43
52
  selection: SelectionSnapshot,
44
53
  maxOffset: number,
54
+ options?: SelectionValidationOptions,
45
55
  ): SelectionSnapshot {
46
56
  if (selection.activeRange.kind === "node") {
47
- // Deferred: NodeAnchor invalidation requires an O(1) node-by-id
48
- // accessor on CanonicalDocumentEnvelope. Until that lands, return
49
- // identity so we never falsely invalidate a still-valid node anchor.
57
+ if (
58
+ options?.isValidNodeTarget &&
59
+ !options.isValidNodeTarget(selection.activeRange.at)
60
+ ) {
61
+ const collapsed = clamp(selection.activeRange.at, 0, maxOffset);
62
+ return {
63
+ anchor: collapsed,
64
+ head: collapsed,
65
+ isCollapsed: true,
66
+ activeRange: {
67
+ kind: "range",
68
+ range: { from: collapsed, to: collapsed },
69
+ assoc: {
70
+ start: selection.activeRange.assoc,
71
+ end: selection.activeRange.assoc,
72
+ },
73
+ },
74
+ };
75
+ }
50
76
  return selection;
51
77
  }
52
78
 
@@ -75,3 +101,31 @@ export function validateSelectionAgainstDocument(
75
101
  function clamp(n: number, lo: number, hi: number): number {
76
102
  return n < lo ? lo : n > hi ? hi : n;
77
103
  }
104
+
105
+ export function createSurfaceNodeSelectionProbe(
106
+ surface: EditorSurfaceSnapshot,
107
+ ): (at: number) => boolean {
108
+ return (at) => surface.blocks.some((block) => blockHasNodeTarget(block, at));
109
+ }
110
+
111
+ function blockHasNodeTarget(block: SurfaceBlockSnapshot, at: number): boolean {
112
+ switch (block.kind) {
113
+ case "paragraph":
114
+ return block.segments.some((segment) => isNodeSelectableSegment(segment) && segment.from === at);
115
+ case "table":
116
+ return (
117
+ block.from === at ||
118
+ block.rows.some((row) =>
119
+ row.cells.some((cell) => cell.content.some((child) => blockHasNodeTarget(child, at))),
120
+ )
121
+ );
122
+ case "sdt_block":
123
+ return block.from === at || block.children.some((child) => blockHasNodeTarget(child, at));
124
+ case "opaque_block":
125
+ return block.from === at;
126
+ }
127
+ }
128
+
129
+ function isNodeSelectableSegment(segment: SurfaceInlineSegment): boolean {
130
+ return segment.kind !== "text" && segment.kind !== "tab" && segment.kind !== "hard_break";
131
+ }
@@ -25,7 +25,11 @@ import type {
25
25
  import type { StructuralMutationResult } from "../../core/commands/structural-helpers.ts";
26
26
  import type { TextCommandContext } from "../../core/commands/text-commands.ts";
27
27
  import { countLogicalPositions, parseTextStory } from "../../core/schema/text-schema.ts";
28
- import { validateSelectionAgainstDocument } from "../selection/post-edit-validator.ts";
28
+ import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
29
+ import {
30
+ createSurfaceNodeSelectionProbe,
31
+ validateSelectionAgainstDocument,
32
+ } from "../selection/post-edit-validator.ts";
29
33
  import { applyFragmentInsert } from "./fragment-insert.ts";
30
34
 
31
35
  export interface StructureLayer {
@@ -58,9 +62,21 @@ function validateResult(result: StructuralMutationResult): StructuralMutationRes
58
62
  if (!result.changed) {
59
63
  return result;
60
64
  }
61
- const story = parseTextStory(result.document.content);
62
- const maxOffset = countLogicalPositions(story.units);
63
- const validated = validateSelectionAgainstDocument(result.document, result.selection, maxOffset);
65
+ const surface = result.selection.activeRange.kind === "node"
66
+ ? createEditorSurfaceSnapshot(result.document, result.selection)
67
+ : null;
68
+ const options = surface
69
+ ? { isValidNodeTarget: createSurfaceNodeSelectionProbe(surface) }
70
+ : undefined;
71
+ const maxOffset = surface
72
+ ? surface.storySize
73
+ : countLogicalPositions(parseTextStory(result.document.content).units);
74
+ const validated = validateSelectionAgainstDocument(
75
+ result.document,
76
+ result.selection,
77
+ maxOffset,
78
+ options,
79
+ );
64
80
  if (validated === result.selection) {
65
81
  return result;
66
82
  }