@beyondwork/docx-react-component 1.0.58 → 1.0.60

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 +2 -2
  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 +980 -10
  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 +4 -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 +72 -42
  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/workflow-payload.ts +6 -1
  69. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  70. package/src/io/ooxml/xml-parser.ts +183 -0
  71. package/src/legal/bookmarks.ts +1 -1
  72. package/src/legal/cross-references.ts +1 -1
  73. package/src/legal/defined-terms.ts +1 -1
  74. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  75. package/src/legal/signature-blocks.ts +1 -1
  76. package/src/model/canonical-document.ts +159 -6
  77. package/src/model/chart-types.ts +439 -0
  78. package/src/model/snapshot.ts +5 -1
  79. package/src/review/store/comment-remapping.ts +24 -11
  80. package/src/review/store/revision-actions.ts +482 -2
  81. package/src/review/store/revision-store.ts +15 -0
  82. package/src/review/store/revision-types.ts +76 -0
  83. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  84. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  85. package/src/runtime/diagnostics/build-diagnostic.ts +153 -0
  86. package/src/runtime/diagnostics/code-metadata-table.ts +230 -0
  87. package/src/runtime/document-runtime.ts +821 -54
  88. package/src/runtime/document-search.ts +115 -0
  89. package/src/runtime/edit-ops/index.ts +18 -2
  90. package/src/runtime/footnote-resolver.ts +130 -0
  91. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  92. package/src/runtime/layout/layout-engine-version.ts +37 -1
  93. package/src/runtime/layout/page-graph.ts +14 -1
  94. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  95. package/src/runtime/numbering-prefix.ts +17 -0
  96. package/src/runtime/query-scopes.ts +108 -10
  97. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  98. package/src/runtime/revision-runtime.ts +27 -1
  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 +290 -21
  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 +187 -43
  107. package/src/ui/editor-runtime-boundary.ts +10 -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 +0 -9
  118. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +1 -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 +87 -25
  121. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +9 -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 +29 -87
  135. package/src/validation/diagnostics.ts +1 -0
@@ -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) {
@@ -77,9 +173,8 @@ export function projectScopeQueryResults(
77
173
  for (const scopeId of scopeIds) {
78
174
  const scope = scopesById.get(scopeId);
79
175
  if (!scope) continue;
80
- const visibility = (scope as WorkflowScope & { visibility?: string }).visibility;
81
- if (visibility === "hidden" && !includeHidden) continue;
82
- if (visibility === "invisible" && !includeInvisible) continue;
176
+ if (scope.visibility === "hidden" && !includeHidden) continue;
177
+ if (scope.visibility === "invisible" && !includeInvisible) continue;
83
178
  const entries = entriesByScope.get(scopeId) ?? [];
84
179
  const workItem = scope.workItemId
85
180
  ? workItemsById.get(scope.workItemId) ?? null
@@ -129,14 +224,14 @@ export function queryScopes(
129
224
  }
130
225
 
131
226
  const locations = collectScopeLocations(inputs.document);
227
+ const scopeIdCounts = buildScopeIdCounts(overlay);
228
+ const markerBackedScopeIds = inputs.markerBackedScopeIds ?? new Set<string>();
132
229
 
133
230
  const candidates: Array<{ scope: WorkflowScope; startPos: number }> = [];
134
231
  for (const scope of overlay.scopes) {
135
- // §C8 pre-wire: respect visibility flags. Visibility field itself lands
136
- // in §C8; treating missing as "visible" keeps this forward-compatible.
137
- const visibility = (scope as WorkflowScope & { visibility?: string }).visibility;
138
- if (visibility === "hidden" && !includeHidden) continue;
139
- if (visibility === "invisible" && !includeInvisible) continue;
232
+ // §C8: respect visibility flags. Missing field treated as "visible".
233
+ if (scope.visibility === "hidden" && !includeHidden) continue;
234
+ if (scope.visibility === "invisible" && !includeInvisible) continue;
140
235
 
141
236
  if (normalizedStoryFilter !== "*") {
142
237
  const scopeStory = scope.storyTarget ?? MAIN_STORY;
@@ -165,7 +260,10 @@ export function queryScopes(
165
260
  const loc = locations.get(scope.scopeId);
166
261
  const startPos =
167
262
  loc?.startPos ?? loc?.endPos ?? Number.POSITIVE_INFINITY;
168
- candidates.push({ scope, startPos });
263
+ candidates.push({
264
+ scope: normalizeScopeAnchor(scope, scopeIdCounts, locations, markerBackedScopeIds),
265
+ startPos,
266
+ });
169
267
  }
170
268
 
171
269
  candidates.sort((a, b) => {
@@ -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")
@@ -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
  }