@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
@@ -86,6 +86,13 @@ import type {
86
86
  WorkflowOverlay,
87
87
  WorkflowScope,
88
88
  WorkflowScopeSnapshot,
89
+ ScopeQueryFilter,
90
+ ScopeQueryResult,
91
+ ScopeVisibility,
92
+ ScopeChromeVisibilityState,
93
+ SearchOptions,
94
+ TextStyleFilter,
95
+ WorkflowScopeMode,
89
96
  WorkspaceMode,
90
97
  WordReviewEditorEvent,
91
98
  ZoomLevel,
@@ -136,7 +143,17 @@ import {
136
143
  } from "../review/store/revision-store.ts";
137
144
  import { createSuggestionsSnapshot } from "./suggestions-snapshot.ts";
138
145
  import { validateSelectionAgainstDocument } from "./selection/post-edit-validator.ts";
139
- import { collectScopeLocations, resolveScope } from "./scope-resolver.ts";
146
+ import { createSurfaceNodeSelectionProbe } from "./selection/post-edit-validator.ts";
147
+ import {
148
+ collectScopeLocations,
149
+ findAllScopesAt,
150
+ findScopesIntersecting,
151
+ resolveScope,
152
+ } from "./scope-resolver.ts";
153
+ import {
154
+ projectScopeQueryResults,
155
+ queryScopes as runQueryScopes,
156
+ } from "./query-scopes.ts";
140
157
  import {
141
158
  insertScopeMarkers,
142
159
  removeScopeMarkers,
@@ -144,6 +161,10 @@ import {
144
161
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
145
162
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
146
163
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
164
+ import {
165
+ findTextMatches,
166
+ findTextWithStyleMatches,
167
+ } from "./document-search.ts";
147
168
  import {
148
169
  collectWorkflowMarkupSnapshot,
149
170
  deriveWorkflowCandidateRangesFromMarkup,
@@ -409,6 +430,15 @@ export interface DocumentRuntime {
409
430
  */
410
431
  applyRemoteCommandBatch(envelopes: ReadonlyArray<RemoteCommandEnvelope>): void;
411
432
  emitBlockedCommand(command: string, reasons: WorkflowBlockedCommandReason[]): void;
433
+ /**
434
+ * Emit a fire-and-forget `warning_added` event + `onWarning` callback for
435
+ * a host-layer no-op (e.g. `applyRuntimeDeleteComment` invoked with an
436
+ * unknown id). The warning is NOT persisted to `state.warnings` or the
437
+ * compatibility report — same semantics as a reducer-emitted
438
+ * `effects.transientWarnings` entry, exposed here for host-side paths
439
+ * that never reach a reducer.
440
+ */
441
+ emitTransientWarning(warning: InternalEditorWarning): void;
412
442
  undo(): void;
413
443
  redo(): void;
414
444
  focus(): void;
@@ -419,11 +449,33 @@ export interface DocumentRuntime {
419
449
  openComment(commentId: string): void;
420
450
  resolveComment(commentId: string): void;
421
451
  reopenComment(commentId: string): void;
422
- addCommentReply(commentId: string, body: string, authorId?: string): AddCommentReplyResult;
452
+ /**
453
+ * Append a reply entry to an existing thread. Returns the minted
454
+ * `{ commentId, entryId }` on success. Returns `null` when the thread
455
+ * is unknown, resolved, or detached — in that case a
456
+ * `review_target_not_found` warning fires on `onWarning` / `warning_added`
457
+ * with structured `details.op = "addCommentReply"` and `details.reason`
458
+ * ∈ `{ "comment_unknown", "comment_status" }`.
459
+ */
460
+ addCommentReply(
461
+ commentId: string,
462
+ body: string,
463
+ authorId?: string,
464
+ ): AddCommentReplyResult | null;
423
465
  editCommentBody(commentId: string, body: string): void;
424
466
  addScope(params: AddScopeParams): AddScopeResult;
425
467
  getScope(scopeId: string): WorkflowScope | null;
426
468
  removeScope(scopeId: string): void;
469
+ /** §C8 — Add a scope with visibility: "invisible" atomically. */
470
+ addInvisibleScope(params: Omit<AddScopeParams, "mode"> & { mode?: WorkflowScopeMode }): AddScopeResult;
471
+ /** §C8 — Set a scope's visibility (collab-replicated). */
472
+ setScopeVisibility(scopeId: string, visibility: ScopeVisibility): void;
473
+ /** §C8 — Get a scope's current visibility (absent = "visible"). */
474
+ getScopeVisibility(scopeId: string): ScopeVisibility;
475
+ /** §C7 — Set local chrome visibility state (never collab-replicated). */
476
+ setScopeChromeVisibility(state: ScopeChromeVisibilityState): void;
477
+ /** §C7 — Get local chrome visibility state (default: { mode: "all" }). */
478
+ getScopeChromeVisibility(): ScopeChromeVisibilityState;
427
479
  acceptChange(changeId: string): void;
428
480
  rejectChange(changeId: string): void;
429
481
  acceptAllChanges(): void;
@@ -504,6 +556,44 @@ export interface DocumentRuntime {
504
556
  setWorkflowMetadataEntries(entries: WorkflowMetadataEntry[]): void;
505
557
  clearWorkflowMetadataEntries(): void;
506
558
  getWorkflowMetadataSnapshot(): WorkflowMetadataSnapshot;
559
+ /**
560
+ * Phase C §C1 — snapshot-based filter + join projection. See
561
+ * `WordReviewEditorRef.queryScopes` for contract.
562
+ */
563
+ queryScopes(filter?: ScopeQueryFilter): ScopeQueryResult[];
564
+ /** Phase C §C1 — live subscription; returns unsubscribe fn. */
565
+ subscribeToScopeQuery(
566
+ filter: ScopeQueryFilter,
567
+ callback: (results: ScopeQueryResult[]) => void,
568
+ ): () => void;
569
+ /** Phase C §C4 — text search + style filter. */
570
+ findAllText(query: string, options?: SearchOptions): EditorAnchorProjection[];
571
+ findTextWithStyle(
572
+ query: string,
573
+ filter: TextStyleFilter,
574
+ options?: SearchOptions,
575
+ ): EditorAnchorProjection[];
576
+ selectTextWithStyle(
577
+ query: string,
578
+ filter: TextStyleFilter,
579
+ options?: SearchOptions,
580
+ ): number;
581
+ /**
582
+ * Phase C §C2 — geometric scope queries. See `WordReviewEditorRef`
583
+ * for contract. Non-range anchors yield `[]`.
584
+ */
585
+ findScopesAt(
586
+ position: EditorAnchorProjection,
587
+ options?: { includeHidden?: boolean; includeInvisible?: boolean },
588
+ ): ScopeQueryResult[];
589
+ findScopesIntersecting(
590
+ range: EditorAnchorProjection,
591
+ options?: {
592
+ includeHidden?: boolean;
593
+ includeInvisible?: boolean;
594
+ mode?: "overlap" | "contain";
595
+ },
596
+ ): ScopeQueryResult[];
507
597
  setHostAnnotationOverlay(overlay: HostAnnotationOverlay): void;
508
598
  clearHostAnnotationOverlay(): void;
509
599
  getHostAnnotationSnapshot(): HostAnnotationSnapshot;
@@ -783,6 +873,8 @@ export function createDocumentRuntime(
783
873
  ?? options.initialSnapshot?.workflowMetadata?.entries
784
874
  ?? [];
785
875
  let hostAnnotationOverlay: HostAnnotationOverlay | null = null;
876
+ // §C7 — local view-state for scope chrome visibility; never collab-replicated.
877
+ let scopeChromeVisibilityState: ScopeChromeVisibilityState = { mode: "all" };
786
878
  // P13 Slice B: shared workflow state from the collab Y.Map "workflow".
787
879
  // Set via setSharedWorkflowState(); observed by evaluateWorkflowBlockedReasons.
788
880
  let sharedWorkflowState: SharedWorkflowState | null = null;
@@ -925,6 +1017,10 @@ export function createDocumentRuntime(
925
1017
  let cachedCompatibility:
926
1018
  | {
927
1019
  revisionToken: string;
1020
+ /** Block count at the time of the last full rebuild. O(1) proxy for structural stability. */
1021
+ blockCount: number;
1022
+ /** Warning count at time of rebuild — warnings array gets remapped on every edit. */
1023
+ warningCount: number;
928
1024
  warnings: EditorState["warnings"];
929
1025
  fatalError: EditorState["fatalError"];
930
1026
  report: RuntimeRenderSnapshot["compatibility"];
@@ -1075,6 +1171,14 @@ export function createDocumentRuntime(
1075
1171
  snapshot: WorkflowMarkupSnapshot;
1076
1172
  }
1077
1173
  | undefined;
1174
+ // Keyed on block count + subParts identity (not revisionToken) — fields only
1175
+ // change when blocks are inserted/deleted or subParts (headers/footers) change,
1176
+ // NOT on text-only edits. Cleared explicitly by updateFields() for field-refresh.
1177
+ let cachedFieldSnapshotEntry: {
1178
+ blockCount: number;
1179
+ subParts: CanonicalDocumentEnvelope["subParts"];
1180
+ snapshot: FieldSnapshot;
1181
+ } | null = null;
1078
1182
  const cachedContextAnalyticsSnapshots = new Map<
1079
1183
  string,
1080
1184
  {
@@ -1120,16 +1224,53 @@ export function createDocumentRuntime(
1120
1224
  return snapshot;
1121
1225
  }
1122
1226
 
1227
+ function getCachedFieldSnapshot(document: CanonicalDocumentEnvelope): FieldSnapshot {
1228
+ const blockCount = document.content.children.length;
1229
+ if (
1230
+ cachedFieldSnapshotEntry &&
1231
+ cachedFieldSnapshotEntry.blockCount === blockCount &&
1232
+ cachedFieldSnapshotEntry.subParts === document.subParts
1233
+ ) {
1234
+ return cachedFieldSnapshotEntry.snapshot;
1235
+ }
1236
+ const snapshot = buildFieldSnapshot(document);
1237
+ cachedFieldSnapshotEntry = { blockCount, subParts: document.subParts, snapshot };
1238
+ return snapshot;
1239
+ }
1240
+
1123
1241
  function getCachedCompatibilityReport(
1124
1242
  nextState: EditorState,
1125
1243
  ): RuntimeRenderSnapshot["compatibility"] {
1126
- if (
1127
- cachedCompatibility &&
1128
- cachedCompatibility.revisionToken === nextState.revisionToken &&
1129
- cachedCompatibility.warnings === nextState.warnings &&
1130
- cachedCompatibility.fatalError === nextState.fatalError
1131
- ) {
1132
- return cachedCompatibility.report;
1244
+ const blockCount = nextState.document.content.children.length;
1245
+ const warningCount = nextState.warnings?.length ?? 0;
1246
+ if (cachedCompatibility) {
1247
+ // Fast path 1: same revisionToken (selection move, surface-only refresh).
1248
+ if (
1249
+ cachedCompatibility.revisionToken === nextState.revisionToken &&
1250
+ cachedCompatibility.warnings === nextState.warnings &&
1251
+ cachedCompatibility.fatalError === nextState.fatalError
1252
+ ) {
1253
+ return cachedCompatibility.report;
1254
+ }
1255
+ // Fast path 2: revisionToken changed but block structure and warning
1256
+ // count are stable (text-only edit). buildCompatibilityReport reads
1257
+ // block types, review counts, preservation, and warnings — NOT run text.
1258
+ // Block count + warning count + fatalError identity is an O(1) proxy:
1259
+ // if these are stable, the compatibility report output is unchanged.
1260
+ // `warnings` array gets remapped to a new reference on every commit via
1261
+ // remapReviewStateAfterContentChange, so we can't use reference equality.
1262
+ if (
1263
+ cachedCompatibility.blockCount === blockCount &&
1264
+ cachedCompatibility.warningCount === warningCount &&
1265
+ cachedCompatibility.fatalError === nextState.fatalError
1266
+ ) {
1267
+ cachedCompatibility = {
1268
+ ...cachedCompatibility,
1269
+ revisionToken: nextState.revisionToken,
1270
+ warnings: nextState.warnings,
1271
+ };
1272
+ return cachedCompatibility.report;
1273
+ }
1133
1274
  }
1134
1275
 
1135
1276
  const derived = createDerivedCompatibility(nextState);
@@ -1146,6 +1287,8 @@ export function createDocumentRuntime(
1146
1287
  };
1147
1288
  cachedCompatibility = {
1148
1289
  revisionToken: nextState.revisionToken,
1290
+ blockCount,
1291
+ warningCount,
1149
1292
  warnings: nextState.warnings,
1150
1293
  fatalError: nextState.fatalError,
1151
1294
  report,
@@ -1392,8 +1535,13 @@ export function createDocumentRuntime(
1392
1535
  if (normalizedWorkflowOverlay) {
1393
1536
  const matchingScope = getMatchingWorkflowScope(selection);
1394
1537
  const activeScopes = getEffectiveWorkflowScopes(normalizedWorkflowOverlay);
1538
+ // §C8: invisible non-view scopes are transparent to the interaction guard.
1539
+ // Don't count them toward the "outside_workflow_scope" threshold.
1540
+ const guardingScopes = activeScopes.filter(
1541
+ (s) => !(s.visibility === "invisible" && s.mode !== "view"),
1542
+ );
1395
1543
 
1396
- if (!matchingScope && activeScopes.length > 0) {
1544
+ if (!matchingScope && guardingScopes.length > 0) {
1397
1545
  reasons.push({
1398
1546
  code: "outside_workflow_scope",
1399
1547
  message: "Selection is outside any active workflow scope.",
@@ -1424,24 +1572,60 @@ export function createDocumentRuntime(
1424
1572
  return reasons;
1425
1573
  }
1426
1574
 
1427
- function getMatchingWorkflowScope(
1428
- selection: EditorState["selection"],
1429
- ): WorkflowOverlay["scopes"][number] | null {
1430
- if (!workflowOverlay) {
1431
- return null;
1432
- }
1575
+ // §C6 — most-restrictive-wins ordering for overlap layering.
1576
+ const MODE_RESTRICTIVENESS: Record<WorkflowScopeMode, number> = {
1577
+ edit: 0,
1578
+ suggest: 1,
1579
+ comment: 2,
1580
+ view: 3,
1581
+ };
1433
1582
 
1583
+ /**
1584
+ * §C6 — Collect all guard-eligible scopes that contain `selection`,
1585
+ * sorted outermost→innermost (startPos ASC, endPos DESC, scopeId ASC).
1586
+ * Excludes invisible non-view scopes per §C8.
1587
+ */
1588
+ function buildMatchingScopeStack(
1589
+ selection: EditorState["selection"],
1590
+ ): WorkflowOverlay["scopes"] {
1591
+ if (!workflowOverlay) return [];
1434
1592
  const selectionBounds = {
1435
1593
  from: Math.min(selection.anchor, selection.head),
1436
1594
  to: Math.max(selection.anchor, selection.head),
1437
1595
  };
1438
1596
  const activeScopes = getEffectiveWorkflowScopes(workflowOverlay);
1439
- return activeScopes.find((scope) => {
1597
+ const matching = activeScopes.filter((scope) => {
1598
+ // §C8
1599
+ if (scope.visibility === "invisible" && scope.mode !== "view") return false;
1440
1600
  if (scope.anchor.kind === "detached") return false;
1441
1601
  const scopeFrom = scope.anchor.kind === "range" ? scope.anchor.from : scope.anchor.at;
1442
1602
  const scopeTo = scope.anchor.kind === "range" ? scope.anchor.to : scope.anchor.at;
1443
1603
  return selectionBounds.from >= scopeFrom && selectionBounds.to <= scopeTo;
1444
- }) ?? null;
1604
+ });
1605
+ // Outermost first: startPos ASC, endPos DESC (wider span = outer), scopeId ASC tiebreak
1606
+ matching.sort((a, b) => {
1607
+ const aFrom = a.anchor.kind === "range" ? a.anchor.from : (a.anchor as { at: number }).at;
1608
+ const bFrom = b.anchor.kind === "range" ? b.anchor.from : (b.anchor as { at: number }).at;
1609
+ if (aFrom !== bFrom) return aFrom - bFrom;
1610
+ const aTo = a.anchor.kind === "range" ? a.anchor.to : (a.anchor as { at: number }).at;
1611
+ const bTo = b.anchor.kind === "range" ? b.anchor.to : (b.anchor as { at: number }).at;
1612
+ if (aTo !== bTo) return bTo - aTo; // wider first
1613
+ return a.scopeId < b.scopeId ? -1 : a.scopeId > b.scopeId ? 1 : 0;
1614
+ });
1615
+ return matching;
1616
+ }
1617
+
1618
+ function getMatchingWorkflowScope(
1619
+ selection: EditorState["selection"],
1620
+ ): WorkflowOverlay["scopes"][number] | null {
1621
+ const stack = buildMatchingScopeStack(selection);
1622
+ if (stack.length === 0) return null;
1623
+ // §C6 — most-restrictive scope wins across all overlapping scopes.
1624
+ return stack.reduce((best, scope) =>
1625
+ (MODE_RESTRICTIVENESS[scope.mode] ?? 0) > (MODE_RESTRICTIVENESS[best.mode] ?? 0)
1626
+ ? scope
1627
+ : best,
1628
+ );
1445
1629
  }
1446
1630
 
1447
1631
  function getEffectiveDocumentMode(
@@ -1860,6 +2044,7 @@ export function createDocumentRuntime(
1860
2044
 
1861
2045
  const blockedReasons = evaluateWorkflowBlockedReasons(state.selection);
1862
2046
  const matchingScope = getMatchingWorkflowScope(state.selection);
2047
+ const scopeStack = buildMatchingScopeStack(state.selection);
1863
2048
  const primaryBlockedReason = blockedReasons[0];
1864
2049
  const effectiveMode = primaryBlockedReason
1865
2050
  ? (
@@ -1872,10 +2057,19 @@ export function createDocumentRuntime(
1872
2057
  : getEffectiveDocumentMode(state.selection) === "suggesting"
1873
2058
  ? "suggest"
1874
2059
  : matchingScope?.mode ?? "edit";
2060
+ const matchedScopeStack: InteractionGuardSnapshot["matchedScopeStack"] =
2061
+ scopeStack.length > 0
2062
+ ? scopeStack.map((s) => ({
2063
+ scopeId: s.scopeId,
2064
+ mode: s.mode,
2065
+ visibility: s.visibility ?? "visible",
2066
+ }))
2067
+ : undefined;
1875
2068
  const snapshot: InteractionGuardSnapshot = {
1876
2069
  effectiveMode,
1877
2070
  ...(matchingScope?.scopeId ? { matchedScopeId: matchingScope.scopeId } : {}),
1878
2071
  ...(matchingScope?.mode ? { matchedScopeMode: matchingScope.mode } : {}),
2072
+ ...(matchedScopeStack ? { matchedScopeStack } : {}),
1879
2073
  targetAccess:
1880
2074
  effectiveMode === "edit"
1881
2075
  ? "direct-edit"
@@ -1960,7 +2154,7 @@ export function createDocumentRuntime(
1960
2154
 
1961
2155
  const snapshot = collectWorkflowMarkupSnapshot({
1962
2156
  renderSnapshot: cachedRenderSnapshot,
1963
- fieldSnapshot: buildFieldSnapshot(state.document),
2157
+ fieldSnapshot: getCachedFieldSnapshot(state.document),
1964
2158
  protectionSnapshot,
1965
2159
  preservation: state.document.preservation,
1966
2160
  workflowMetadataSnapshot: deriveWorkflowMetadataSnapshot(),
@@ -2215,6 +2409,7 @@ export function createDocumentRuntime(
2215
2409
  },
2216
2410
  surface,
2217
2411
  protectionSnapshot,
2412
+ grabbedObjectId: grabState.objectId,
2218
2413
  };
2219
2414
  }
2220
2415
 
@@ -2264,6 +2459,7 @@ export function createDocumentRuntime(
2264
2459
  cachedWorkflowScopeSnapshot = undefined;
2265
2460
  cachedNormalizedWorkflowOverlay = undefined;
2266
2461
  cachedWorkflowMarkupSnapshot = undefined;
2462
+ cachedFieldSnapshotEntry = null;
2267
2463
  cachedContextAnalyticsSnapshots.clear();
2268
2464
  lastEmittedContextAnalyticsSnapshots = undefined;
2269
2465
  }
@@ -2280,8 +2476,11 @@ export function createDocumentRuntime(
2280
2476
  document,
2281
2477
  };
2282
2478
  if (previousDocument.subParts !== document.subParts) {
2283
- fontLoader.refresh(collectFontLoaderInput(document));
2284
- layoutEngine.invalidateMeasurementCache();
2479
+ const fontInput = collectFontLoaderInput(document);
2480
+ if (!fontFamiliesEqual(fontInput.families, collectFontLoaderInput(previousDocument).families)) {
2481
+ fontLoader.refresh(fontInput);
2482
+ layoutEngine.invalidateMeasurementCache();
2483
+ }
2285
2484
  }
2286
2485
  invalidateDerivedRuntimeCaches();
2287
2486
  cachedRenderSnapshot = refreshRenderSnapshot();
@@ -2440,6 +2639,15 @@ export function createDocumentRuntime(
2440
2639
  reasons,
2441
2640
  });
2442
2641
  },
2642
+ emitTransientWarning(warning) {
2643
+ const publicWarning = toPublicWarning(warning);
2644
+ emit({
2645
+ type: "warning_added",
2646
+ documentId: state.documentId,
2647
+ warning: publicWarning,
2648
+ });
2649
+ options.onWarning?.(publicWarning);
2650
+ },
2443
2651
  dispatch(command) {
2444
2652
  const commandSelection = getCommandSelection(command, state.selection);
2445
2653
  if (isMutationCommand(command)) {
@@ -3050,7 +3258,7 @@ export function createDocumentRuntime(
3050
3258
  });
3051
3259
  },
3052
3260
  addCommentReply(commentId, body, authorId) {
3053
- const priorEntryCount =
3261
+ const priorCount =
3054
3262
  state.document.review.comments[commentId]?.entries?.length ?? 0;
3055
3263
  this.dispatch({
3056
3264
  type: "comment.add-reply",
@@ -3059,8 +3267,16 @@ export function createDocumentRuntime(
3059
3267
  authorId: authorId ?? defaultAuthorId,
3060
3268
  origin: createOrigin("api", clock()),
3061
3269
  });
3062
- const entryId = `${commentId}-entry-${priorEntryCount + 1}`;
3063
- return { commentId, entryId };
3270
+ // Read post-dispatch state. The reducer skips silently on unknown /
3271
+ // resolved / detached threads and emits a `review_target_not_found`
3272
+ // transient warning from `effects.transientWarnings` — callers who
3273
+ // need to know about the skip listen on `onWarning`.
3274
+ const entries = state.document.review.comments[commentId]?.entries ?? [];
3275
+ if (entries.length <= priorCount) {
3276
+ return null;
3277
+ }
3278
+ const last = entries[entries.length - 1]!;
3279
+ return { commentId, entryId: last.entryId };
3064
3280
  },
3065
3281
  editCommentBody(commentId, body) {
3066
3282
  this.dispatch({
@@ -3217,6 +3433,38 @@ export function createDocumentRuntime(
3217
3433
  }
3218
3434
  }
3219
3435
  },
3436
+ addInvisibleScope(params) {
3437
+ const result = this.addScope({
3438
+ ...params,
3439
+ mode: params.mode ?? "comment",
3440
+ });
3441
+ this.setScopeVisibility(result.scopeId, "invisible");
3442
+ return result;
3443
+ },
3444
+ setScopeVisibility(scopeId, visibility) {
3445
+ if (!workflowOverlay) return;
3446
+ const idx = workflowOverlay.scopes.findIndex((s) => s.scopeId === scopeId);
3447
+ if (idx === -1) return;
3448
+ const nextScopes = workflowOverlay.scopes.map((s) =>
3449
+ s.scopeId === scopeId ? { ...s, visibility } : s,
3450
+ );
3451
+ this.dispatch({
3452
+ type: "workflow.set-overlay",
3453
+ overlay: { ...workflowOverlay, scopes: nextScopes },
3454
+ origin: createOrigin("api", clock()),
3455
+ });
3456
+ },
3457
+ getScopeVisibility(scopeId): ScopeVisibility {
3458
+ if (!workflowOverlay) return "visible";
3459
+ const scope = workflowOverlay.scopes.find((s) => s.scopeId === scopeId);
3460
+ return scope?.visibility ?? "visible";
3461
+ },
3462
+ setScopeChromeVisibility(state) {
3463
+ scopeChromeVisibilityState = state;
3464
+ },
3465
+ getScopeChromeVisibility(): ScopeChromeVisibilityState {
3466
+ return scopeChromeVisibilityState;
3467
+ },
3220
3468
  acceptChange(changeId) {
3221
3469
  this.dispatch({
3222
3470
  type: "change.accept",
@@ -3349,7 +3597,11 @@ export function createDocumentRuntime(
3349
3597
  getFootnoteResolver(): FootnoteResolver | undefined {
3350
3598
  const collection = state.document.subParts?.footnoteCollection;
3351
3599
  if (!collection) return undefined;
3352
- return createFootnoteResolver(collection, collectSectionPropertiesInOrder(state.document));
3600
+ return createFootnoteResolver(
3601
+ collection,
3602
+ collectSectionPropertiesInOrder(state.document),
3603
+ state.document,
3604
+ );
3353
3605
  },
3354
3606
  layout: layoutFacet,
3355
3607
  getCurrentLocation() {
@@ -3496,6 +3748,9 @@ export function createDocumentRuntime(
3496
3748
  options,
3497
3749
  );
3498
3750
  if (refreshed.changed) {
3751
+ // Field display text changed — clear field snapshot cache so the
3752
+ // next getCachedFieldSnapshot call rebuilds with fresh displayText.
3753
+ cachedFieldSnapshotEntry = null;
3499
3754
  this.dispatch({
3500
3755
  type: "document.replace",
3501
3756
  document: refreshed.document,
@@ -3690,6 +3945,102 @@ export function createDocumentRuntime(
3690
3945
  getWorkflowMetadataSnapshot() {
3691
3946
  return deriveWorkflowMetadataSnapshot();
3692
3947
  },
3948
+ queryScopes(filter) {
3949
+ return runQueryScopes(
3950
+ {
3951
+ overlay: workflowOverlay,
3952
+ entries: workflowMetadataEntries,
3953
+ document: state.document,
3954
+ },
3955
+ filter,
3956
+ );
3957
+ },
3958
+ subscribeToScopeQuery(filter, callback) {
3959
+ const buildKey = (results: ScopeQueryResult[]) =>
3960
+ results
3961
+ .map(
3962
+ (r) =>
3963
+ `${r.scope.scopeId}:${r.scope.version ?? 0}:${r.scope.visibility ?? "visible"}`,
3964
+ )
3965
+ .join(",");
3966
+
3967
+ let lastKey = "";
3968
+ const fire = () => {
3969
+ const results = this.queryScopes(filter);
3970
+ const key = buildKey(results);
3971
+ if (key !== lastKey) {
3972
+ lastKey = key;
3973
+ callback(results);
3974
+ }
3975
+ };
3976
+
3977
+ // Immediate initial fire
3978
+ fire();
3979
+
3980
+ let pendingTimer: ReturnType<typeof setTimeout> | null = null;
3981
+ const unsub = this.subscribe(() => {
3982
+ if (pendingTimer !== null) return;
3983
+ pendingTimer = setTimeout(() => {
3984
+ pendingTimer = null;
3985
+ fire();
3986
+ }, 0);
3987
+ });
3988
+
3989
+ return () => {
3990
+ unsub();
3991
+ if (pendingTimer !== null) {
3992
+ clearTimeout(pendingTimer);
3993
+ pendingTimer = null;
3994
+ }
3995
+ };
3996
+ },
3997
+ findAllText(query, options) {
3998
+ const sel = makePublicSelectionSnapshot(state.selection.anchor, state.selection.head);
3999
+ return findTextMatches(state.document, sel, query, options ?? {});
4000
+ },
4001
+ findTextWithStyle(query, filter, options) {
4002
+ const sel = makePublicSelectionSnapshot(state.selection.anchor, state.selection.head);
4003
+ return findTextWithStyleMatches(state.document, sel, query, filter, options ?? {});
4004
+ },
4005
+ selectTextWithStyle(query, filter, options) {
4006
+ const sel = makePublicSelectionSnapshot(state.selection.anchor, state.selection.head);
4007
+ const hits = findTextWithStyleMatches(state.document, sel, query, filter, options ?? {});
4008
+ if (hits.length > 0 && hits[0]) {
4009
+ const first = hits[0];
4010
+ if (first.kind === "range") {
4011
+ this.dispatch({
4012
+ type: "selection.set",
4013
+ selection: createSelectionSnapshot(first.from, first.to),
4014
+ origin: createOrigin("api", clock()),
4015
+ });
4016
+ }
4017
+ }
4018
+ return hits.length;
4019
+ },
4020
+ findScopesAt(position, options) {
4021
+ const pos =
4022
+ position.kind === "range"
4023
+ ? position.from
4024
+ : position.kind === "node"
4025
+ ? position.at
4026
+ : null;
4027
+ if (pos === null) return [];
4028
+ const hits = findAllScopesAt(state.document, pos);
4029
+ return projectScopeQueryResults(
4030
+ { overlay: workflowOverlay, entries: workflowMetadataEntries, document: state.document },
4031
+ hits.map((h) => h.scopeId),
4032
+ options,
4033
+ );
4034
+ },
4035
+ findScopesIntersecting(range, options) {
4036
+ if (range.kind !== "range") return [];
4037
+ const hits = findScopesIntersecting(state.document, range.from, range.to, options?.mode);
4038
+ return projectScopeQueryResults(
4039
+ { overlay: workflowOverlay, entries: workflowMetadataEntries, document: state.document },
4040
+ hits.map((h) => h.scopeId),
4041
+ options,
4042
+ );
4043
+ },
3693
4044
  setHostAnnotationOverlay(overlay) {
3694
4045
  this.dispatch({
3695
4046
  type: "host-annotation.set-overlay",
@@ -3858,6 +4209,38 @@ export function createDocumentRuntime(
3858
4209
  }
3859
4210
 
3860
4211
  function applyTransactionToState(transaction: EditorTransaction): void {
4212
+ // Pure-no-op short-circuit: when a reducer skipped without emitting any
4213
+ // observable effect AND the selection is identical, skip finalizeState
4214
+ // / invalidate / refresh / notify entirely. This keeps hot paths (e.g.
4215
+ // host loops that query invalid ids) cheap — no clock allocation, no
4216
+ // listener loop, no snapshot rebuild for nothing. Transient-warning
4217
+ // commits (the `review_target_not_found` path) carry
4218
+ // `transientWarnings.length > 0` and MUST fall through so `notify`
4219
+ // can emit them. Selection-only dispatches (e.g. `selection.set`) also
4220
+ // fall through because `nextState.selection !== state.selection`.
4221
+ const effects = transaction.effects;
4222
+ const selectionUnchanged = transaction.nextState.selection === state.selection;
4223
+ const isPureNoop =
4224
+ !transaction.markDirty &&
4225
+ transaction.historyBoundary === "skip" &&
4226
+ transaction.mapping.steps.length === 0 &&
4227
+ selectionUnchanged &&
4228
+ effects.warningsAdded.length === 0 &&
4229
+ effects.warningsCleared.length === 0 &&
4230
+ (effects.transientWarnings?.length ?? 0) === 0 &&
4231
+ !effects.commentAdded &&
4232
+ !effects.commentResolved &&
4233
+ !effects.commentReopened &&
4234
+ !effects.commentReplyAdded &&
4235
+ !effects.commentBodyEdited &&
4236
+ !effects.changeAccepted &&
4237
+ !effects.changeRejected &&
4238
+ !effects.revisionAuthored &&
4239
+ !effects.commandBlocked;
4240
+ if (isPureNoop) {
4241
+ return;
4242
+ }
4243
+
3861
4244
  const previous = state;
3862
4245
 
3863
4246
  const tApply0 = performance.now();
@@ -3881,8 +4264,11 @@ export function createDocumentRuntime(
3881
4264
  }
3882
4265
  }
3883
4266
  if (previous.document.subParts !== state.document.subParts) {
3884
- fontLoader.refresh(collectFontLoaderInput(state.document));
3885
- layoutEngine.invalidateMeasurementCache();
4267
+ const fontInput = collectFontLoaderInput(state.document);
4268
+ if (!fontFamiliesEqual(fontInput.families, collectFontLoaderInput(previous.document).families)) {
4269
+ fontLoader.refresh(fontInput);
4270
+ layoutEngine.invalidateMeasurementCache();
4271
+ }
3886
4272
  }
3887
4273
  perfCounters.increment("commit.invalidate.us", Math.round((performance.now() - tInvalidate0) * 1000));
3888
4274
 
@@ -3912,10 +4298,16 @@ export function createDocumentRuntime(
3912
4298
  // which is optional in the public API for shape-only reasons — the helper
3913
4299
  // itself always returns a defined snapshot.
3914
4300
  const surfaceForValidation = getCachedSurface(state.document, activeStory)!;
4301
+ const validationOptions = state.selection.activeRange.kind === "node"
4302
+ ? {
4303
+ isValidNodeTarget: createSurfaceNodeSelectionProbe(surfaceForValidation),
4304
+ }
4305
+ : undefined;
3915
4306
  const validatedSelection = validateSelectionAgainstDocument(
3916
4307
  state.document,
3917
4308
  state.selection,
3918
4309
  surfaceForValidation.storySize,
4310
+ validationOptions,
3919
4311
  );
3920
4312
  if (validatedSelection !== state.selection) {
3921
4313
  state = { ...state, selection: validatedSelection };
@@ -4117,6 +4509,23 @@ export function createDocumentRuntime(
4117
4509
  options.onWarning?.(publicWarning);
4118
4510
  }
4119
4511
 
4512
+ if (transaction.effects.transientWarnings) {
4513
+ // Fire-and-forget diagnostics (e.g. `review_target_not_found` on a
4514
+ // silent-skip reducer). Surfaces on `warning_added` + `onWarning`
4515
+ // like any other warning, but never touches `state.warnings` or the
4516
+ // compatibility report, so repeated no-op calls (e.g. a host
4517
+ // looping `resolveComment` over stale ids) do not accumulate entries.
4518
+ for (const warning of transaction.effects.transientWarnings) {
4519
+ const publicWarning = toPublicWarning(warning);
4520
+ emit({
4521
+ type: "warning_added",
4522
+ documentId: next.documentId,
4523
+ warning: publicWarning,
4524
+ });
4525
+ options.onWarning?.(publicWarning);
4526
+ }
4527
+ }
4528
+
4120
4529
  for (const cleared of transaction.effects.warningsCleared) {
4121
4530
  emit({
4122
4531
  type: "warning_cleared",
@@ -4366,9 +4775,16 @@ export function createDocumentRuntime(
4366
4775
  base: EditorTransaction["effects"],
4367
4776
  local: EditorTransaction["effects"],
4368
4777
  ): EditorTransaction["effects"] {
4778
+ const baseTransient = base.transientWarnings ?? [];
4779
+ const localTransient = local.transientWarnings ?? [];
4780
+ const mergedTransient =
4781
+ baseTransient.length + localTransient.length === 0
4782
+ ? undefined
4783
+ : [...baseTransient, ...localTransient];
4369
4784
  return {
4370
4785
  warningsAdded: [...base.warningsAdded, ...local.warningsAdded],
4371
4786
  warningsCleared: [...base.warningsCleared, ...local.warningsCleared],
4787
+ ...(mergedTransient ? { transientWarnings: mergedTransient } : {}),
4372
4788
  commentAdded: base.commentAdded ?? local.commentAdded,
4373
4789
  commentResolved: base.commentResolved ?? local.commentResolved,
4374
4790
  commentReopened: base.commentReopened ?? local.commentReopened,
@@ -4912,7 +5328,13 @@ function toRuntimeError(error: unknown): InternalEditorError {
4912
5328
  function toStructuredRuntimeException<T extends InternalEditorError>(
4913
5329
  error: T,
4914
5330
  ): Error & T {
4915
- return Object.assign(new Error(error.message), error);
5331
+ const exception = Object.assign(new Error(error.message), error);
5332
+ // Set `name` so host wrappers that must branch across process / bundle
5333
+ // boundaries (e.g. the gRPC handlers in `vendor/beyondwork/src/ts/docx-api`)
5334
+ // can discriminate structured editor errors from generic `Error` objects
5335
+ // via `error.name === "EditorError"` — `instanceof` is unreliable there.
5336
+ exception.name = "EditorError";
5337
+ return exception;
4916
5338
  }
4917
5339
 
4918
5340
  function toPublicDocumentStats(state: Pick<EditorState, "document">) {
@@ -5010,11 +5432,14 @@ function extractSelectionFragment(
5010
5432
  }
5011
5433
 
5012
5434
  /**
5013
- * Collect the stable ids of comment threads whose entry differs
5014
- * (present in one side but not the other, OR present in both but
5015
- * referencing a different object which indicates any mutation to
5016
- * the thread, since the runtime treats `review.comments` as
5017
- * immutable per commit). Used by the `comments_changed` event.
5435
+ * Collect the stable ids of comment threads whose host-observable state
5436
+ * differs between two commits. A reference-identity diff was too noisy
5437
+ * any `remapCommentStore` pass (fired on every text edit that touches a
5438
+ * thread's anchor region) rebuilt the thread objects and would have marked
5439
+ * every thread as "changed" even when only their anchor numerics shifted
5440
+ * in a way that doesn't affect what the host renders. The semantic diff
5441
+ * below compares the fields hosts actually consume: status, anchor shape,
5442
+ * entry count + ordering, resolution metadata, and warning ids.
5018
5443
  */
5019
5444
  function diffCommentMapKeys(
5020
5445
  previous: CanonicalDocumentEnvelope["review"]["comments"],
@@ -5022,14 +5447,65 @@ function diffCommentMapKeys(
5022
5447
  ): string[] {
5023
5448
  const changed = new Set<string>();
5024
5449
  for (const id of Object.keys(previous)) {
5025
- if (previous[id] !== next[id]) changed.add(id);
5450
+ if (!next[id]) changed.add(id);
5026
5451
  }
5027
5452
  for (const id of Object.keys(next)) {
5028
- if (previous[id] !== next[id]) changed.add(id);
5453
+ const prev = previous[id];
5454
+ if (!prev) {
5455
+ changed.add(id);
5456
+ continue;
5457
+ }
5458
+ if (prev === next[id]) continue;
5459
+ if (!semanticallyEqualCommentThreads(prev, next[id]!)) changed.add(id);
5029
5460
  }
5030
5461
  return Array.from(changed);
5031
5462
  }
5032
5463
 
5464
+ function semanticallyEqualCommentThreads(
5465
+ prev: CanonicalDocumentEnvelope["review"]["comments"][string],
5466
+ next: CanonicalDocumentEnvelope["review"]["comments"][string],
5467
+ ): boolean {
5468
+ if (prev.status !== next.status) return false;
5469
+ if (prev.isResolved !== next.isResolved) return false;
5470
+ if (prev.resolvedAt !== next.resolvedAt) return false;
5471
+ if (prev.resolution?.resolvedAt !== next.resolution?.resolvedAt) return false;
5472
+ if (prev.resolution?.resolvedBy !== next.resolution?.resolvedBy) return false;
5473
+ if (prev.body !== next.body) return false;
5474
+ if ((prev.entries?.length ?? 0) !== (next.entries?.length ?? 0)) return false;
5475
+ const prevEntries = prev.entries ?? [];
5476
+ const nextEntries = next.entries ?? [];
5477
+ for (let i = 0; i < prevEntries.length; i++) {
5478
+ const pe = prevEntries[i]!;
5479
+ const ne = nextEntries[i]!;
5480
+ if (pe.entryId !== ne.entryId) return false;
5481
+ if (pe.body !== ne.body) return false;
5482
+ if (pe.authorId !== ne.authorId) return false;
5483
+ }
5484
+ const prevWarnings = prev.warningIds ?? [];
5485
+ const nextWarnings = next.warningIds ?? [];
5486
+ if (prevWarnings.length !== nextWarnings.length) return false;
5487
+ for (let i = 0; i < prevWarnings.length; i++) {
5488
+ if (prevWarnings[i] !== nextWarnings[i]) return false;
5489
+ }
5490
+ const prevAnchor = prev.anchor;
5491
+ const nextAnchor = next.anchor;
5492
+ if (prevAnchor === nextAnchor) return true;
5493
+ if (prevAnchor.kind !== nextAnchor.kind) return false;
5494
+ if (prevAnchor.kind === "range" && nextAnchor.kind === "range") {
5495
+ return (
5496
+ prevAnchor.range.from === nextAnchor.range.from &&
5497
+ prevAnchor.range.to === nextAnchor.range.to
5498
+ );
5499
+ }
5500
+ if (prevAnchor.kind === "node" && nextAnchor.kind === "node") {
5501
+ return prevAnchor.at === nextAnchor.at;
5502
+ }
5503
+ if (prevAnchor.kind === "detached" && nextAnchor.kind === "detached") {
5504
+ return prevAnchor.reason === nextAnchor.reason;
5505
+ }
5506
+ return false;
5507
+ }
5508
+
5033
5509
  function toPublicCompatibilityReport(
5034
5510
  report: InternalCompatibilityReport,
5035
5511
  ): CompatibilityReport {
@@ -5443,6 +5919,22 @@ function isRecord(value: unknown): value is Record<string, unknown> {
5443
5919
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
5444
5920
  }
5445
5921
 
5922
+ function makePublicSelectionSnapshot(anchor: number, head: number): SelectionSnapshot {
5923
+ const from = Math.min(anchor, head);
5924
+ const to = Math.max(anchor, head);
5925
+ return {
5926
+ anchor,
5927
+ head,
5928
+ isCollapsed: anchor === head,
5929
+ activeRange: {
5930
+ kind: "range",
5931
+ from,
5932
+ to,
5933
+ assoc: { start: -1, end: 1 },
5934
+ },
5935
+ };
5936
+ }
5937
+
5446
5938
  /** Commands that are safe in viewing mode (no document mutation). */
5447
5939
  const NON_MUTATION_COMMANDS = new Set([
5448
5940
  "selection.set",
@@ -5459,6 +5951,9 @@ const NON_MUTATION_COMMANDS = new Set([
5459
5951
  "workflow.clear-metadata-entries",
5460
5952
  "host-annotation.set-overlay",
5461
5953
  "host-annotation.clear-overlay",
5954
+ // API-level full-document replacement (scope markers, protection refresh,
5955
+ // collab replay). Not user-initiated — must bypass the workflow guard.
5956
+ "document.replace",
5462
5957
  ]);
5463
5958
 
5464
5959
  /** Mutation commands that are not yet supported in suggesting mode. */
@@ -6559,6 +7054,17 @@ const fontLoaderInputCache = new WeakMap<
6559
7054
  WeakMap<object, { families: readonly string[] }>
6560
7055
  >();
6561
7056
 
7057
+ function fontFamiliesEqual(
7058
+ a: readonly string[],
7059
+ b: readonly string[],
7060
+ ): boolean {
7061
+ if (a.length !== b.length) return false;
7062
+ // Both arrays are Set-derived (no duplicates); sort for order-independence.
7063
+ const sortedA = [...a].sort();
7064
+ const sortedB = [...b].sort();
7065
+ return sortedA.every((v, i) => v === sortedB[i]);
7066
+ }
7067
+
6562
7068
  function collectFontLoaderInput(
6563
7069
  document: CanonicalDocumentEnvelope,
6564
7070
  ): { families: readonly string[] } {
@@ -6611,6 +7117,9 @@ function collectFontLoaderInputUncached(
6611
7117
  /** Test-only export of the uncached walk so memoization tests can spy on it. */
6612
7118
  export const __collectFontLoaderInputUncached = collectFontLoaderInputUncached;
6613
7119
 
7120
+ /** Test-only export of the font-family set equality helper. */
7121
+ export const __fontFamiliesEqual = fontFamiliesEqual;
7122
+
6614
7123
  /**
6615
7124
  * Asynchronously upgrade the engine's measurement backend to canvas once
6616
7125
  * the platform supports it and fonts have resolved. Errors are swallowed