@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
@@ -88,6 +88,11 @@ import type {
88
88
  WorkflowScopeSnapshot,
89
89
  ScopeQueryFilter,
90
90
  ScopeQueryResult,
91
+ ScopeVisibility,
92
+ ScopeChromeVisibilityState,
93
+ SearchOptions,
94
+ TextStyleFilter,
95
+ WorkflowScopeMode,
91
96
  WorkspaceMode,
92
97
  WordReviewEditorEvent,
93
98
  ZoomLevel,
@@ -138,12 +143,14 @@ import {
138
143
  } from "../review/store/revision-store.ts";
139
144
  import { createSuggestionsSnapshot } from "./suggestions-snapshot.ts";
140
145
  import { validateSelectionAgainstDocument } from "./selection/post-edit-validator.ts";
146
+ import { createSurfaceNodeSelectionProbe } from "./selection/post-edit-validator.ts";
141
147
  import {
142
148
  collectScopeLocations,
143
149
  findAllScopesAt,
144
150
  findScopesIntersecting,
145
151
  resolveScope,
146
152
  } from "./scope-resolver.ts";
153
+ import { buildDiagnosticFromLegacyWarningCode } from "./diagnostics/build-diagnostic.ts";
147
154
  import {
148
155
  projectScopeQueryResults,
149
156
  queryScopes as runQueryScopes,
@@ -155,6 +162,10 @@ import {
155
162
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
156
163
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
157
164
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
165
+ import {
166
+ findTextMatches,
167
+ findTextWithStyleMatches,
168
+ } from "./document-search.ts";
158
169
  import {
159
170
  collectWorkflowMarkupSnapshot,
160
171
  deriveWorkflowCandidateRangesFromMarkup,
@@ -420,6 +431,15 @@ export interface DocumentRuntime {
420
431
  */
421
432
  applyRemoteCommandBatch(envelopes: ReadonlyArray<RemoteCommandEnvelope>): void;
422
433
  emitBlockedCommand(command: string, reasons: WorkflowBlockedCommandReason[]): void;
434
+ /**
435
+ * Emit a fire-and-forget `warning_added` event + `onWarning` callback for
436
+ * a host-layer no-op (e.g. `applyRuntimeDeleteComment` invoked with an
437
+ * unknown id). The warning is NOT persisted to `state.warnings` or the
438
+ * compatibility report — same semantics as a reducer-emitted
439
+ * `effects.transientWarnings` entry, exposed here for host-side paths
440
+ * that never reach a reducer.
441
+ */
442
+ emitTransientWarning(warning: InternalEditorWarning): void;
423
443
  undo(): void;
424
444
  redo(): void;
425
445
  focus(): void;
@@ -430,11 +450,33 @@ export interface DocumentRuntime {
430
450
  openComment(commentId: string): void;
431
451
  resolveComment(commentId: string): void;
432
452
  reopenComment(commentId: string): void;
433
- addCommentReply(commentId: string, body: string, authorId?: string): AddCommentReplyResult;
453
+ /**
454
+ * Append a reply entry to an existing thread. Returns the minted
455
+ * `{ commentId, entryId }` on success. Returns `null` when the thread
456
+ * is unknown, resolved, or detached — in that case a
457
+ * `review_target_not_found` warning fires on `onWarning` / `warning_added`
458
+ * with structured `details.op = "addCommentReply"` and `details.reason`
459
+ * ∈ `{ "comment_unknown", "comment_status" }`.
460
+ */
461
+ addCommentReply(
462
+ commentId: string,
463
+ body: string,
464
+ authorId?: string,
465
+ ): AddCommentReplyResult | null;
434
466
  editCommentBody(commentId: string, body: string): void;
435
467
  addScope(params: AddScopeParams): AddScopeResult;
436
468
  getScope(scopeId: string): WorkflowScope | null;
437
469
  removeScope(scopeId: string): void;
470
+ /** §C8 — Add a scope with visibility: "invisible" atomically. */
471
+ addInvisibleScope(params: Omit<AddScopeParams, "mode"> & { mode?: WorkflowScopeMode }): AddScopeResult;
472
+ /** §C8 — Set a scope's visibility (collab-replicated). */
473
+ setScopeVisibility(scopeId: string, visibility: ScopeVisibility): void;
474
+ /** §C8 — Get a scope's current visibility (absent = "visible"). */
475
+ getScopeVisibility(scopeId: string): ScopeVisibility;
476
+ /** §C7 — Set local chrome visibility state (never collab-replicated). */
477
+ setScopeChromeVisibility(state: ScopeChromeVisibilityState): void;
478
+ /** §C7 — Get local chrome visibility state (default: { mode: "all" }). */
479
+ getScopeChromeVisibility(): ScopeChromeVisibilityState;
438
480
  acceptChange(changeId: string): void;
439
481
  rejectChange(changeId: string): void;
440
482
  acceptAllChanges(): void;
@@ -520,6 +562,23 @@ export interface DocumentRuntime {
520
562
  * `WordReviewEditorRef.queryScopes` for contract.
521
563
  */
522
564
  queryScopes(filter?: ScopeQueryFilter): ScopeQueryResult[];
565
+ /** Phase C §C1 — live subscription; returns unsubscribe fn. */
566
+ subscribeToScopeQuery(
567
+ filter: ScopeQueryFilter,
568
+ callback: (results: ScopeQueryResult[]) => void,
569
+ ): () => void;
570
+ /** Phase C §C4 — text search + style filter. */
571
+ findAllText(query: string, options?: SearchOptions): EditorAnchorProjection[];
572
+ findTextWithStyle(
573
+ query: string,
574
+ filter: TextStyleFilter,
575
+ options?: SearchOptions,
576
+ ): EditorAnchorProjection[];
577
+ selectTextWithStyle(
578
+ query: string,
579
+ filter: TextStyleFilter,
580
+ options?: SearchOptions,
581
+ ): number;
523
582
  /**
524
583
  * Phase C §C2 — geometric scope queries. See `WordReviewEditorRef`
525
584
  * for contract. Non-range anchors yield `[]`.
@@ -814,7 +873,10 @@ export function createDocumentRuntime(
814
873
  options.initialSessionState?.workflowMetadata?.entries
815
874
  ?? options.initialSnapshot?.workflowMetadata?.entries
816
875
  ?? [];
876
+ let markerBackedScopeIds = new Set<string>();
817
877
  let hostAnnotationOverlay: HostAnnotationOverlay | null = null;
878
+ // §C7 — local view-state for scope chrome visibility; never collab-replicated.
879
+ let scopeChromeVisibilityState: ScopeChromeVisibilityState = { mode: "all" };
818
880
  // P13 Slice B: shared workflow state from the collab Y.Map "workflow".
819
881
  // Set via setSharedWorkflowState(); observed by evaluateWorkflowBlockedReasons.
820
882
  let sharedWorkflowState: SharedWorkflowState | null = null;
@@ -849,6 +911,32 @@ export function createDocumentRuntime(
849
911
  storySelections.set(storyTargetKey(MAIN_STORY_TARGET), state.selection);
850
912
  lastHeadingFingerprint = computeHeadingFingerprint(state.document);
851
913
 
914
+ function syncMarkerBackedScopeIds(
915
+ document: CanonicalDocumentEnvelope,
916
+ overlay: WorkflowOverlay | null,
917
+ ): void {
918
+ const presentScopeIds = new Set(collectScopeLocations(document).keys());
919
+ if (!overlay) {
920
+ markerBackedScopeIds = presentScopeIds;
921
+ return;
922
+ }
923
+ const overlayScopeIds = new Set(overlay.scopes.map((scope) => scope.scopeId));
924
+ const next = new Set<string>();
925
+ for (const scopeId of markerBackedScopeIds) {
926
+ if (overlayScopeIds.has(scopeId)) {
927
+ next.add(scopeId);
928
+ }
929
+ }
930
+ for (const scopeId of presentScopeIds) {
931
+ if (overlayScopeIds.has(scopeId)) {
932
+ next.add(scopeId);
933
+ }
934
+ }
935
+ markerBackedScopeIds = next;
936
+ }
937
+
938
+ syncMarkerBackedScopeIds(state.document, workflowOverlay);
939
+
852
940
  // Runtime-owned paginated layout engine (Phase 1+ of the layout facet work).
853
941
  // The engine caches graph + resolved-formatting + fragment mapper keyed on
854
942
  // (content, styles, subParts). It is the single internal source of truth
@@ -957,6 +1045,10 @@ export function createDocumentRuntime(
957
1045
  let cachedCompatibility:
958
1046
  | {
959
1047
  revisionToken: string;
1048
+ /** Block count at the time of the last full rebuild. O(1) proxy for structural stability. */
1049
+ blockCount: number;
1050
+ /** Warning count at time of rebuild — warnings array gets remapped on every edit. */
1051
+ warningCount: number;
960
1052
  warnings: EditorState["warnings"];
961
1053
  fatalError: EditorState["fatalError"];
962
1054
  report: RuntimeRenderSnapshot["compatibility"];
@@ -1107,6 +1199,14 @@ export function createDocumentRuntime(
1107
1199
  snapshot: WorkflowMarkupSnapshot;
1108
1200
  }
1109
1201
  | undefined;
1202
+ // Keyed on block count + subParts identity (not revisionToken) — fields only
1203
+ // change when blocks are inserted/deleted or subParts (headers/footers) change,
1204
+ // NOT on text-only edits. Cleared explicitly by updateFields() for field-refresh.
1205
+ let cachedFieldSnapshotEntry: {
1206
+ blockCount: number;
1207
+ subParts: CanonicalDocumentEnvelope["subParts"];
1208
+ snapshot: FieldSnapshot;
1209
+ } | null = null;
1110
1210
  const cachedContextAnalyticsSnapshots = new Map<
1111
1211
  string,
1112
1212
  {
@@ -1152,16 +1252,53 @@ export function createDocumentRuntime(
1152
1252
  return snapshot;
1153
1253
  }
1154
1254
 
1255
+ function getCachedFieldSnapshot(document: CanonicalDocumentEnvelope): FieldSnapshot {
1256
+ const blockCount = document.content.children.length;
1257
+ if (
1258
+ cachedFieldSnapshotEntry &&
1259
+ cachedFieldSnapshotEntry.blockCount === blockCount &&
1260
+ cachedFieldSnapshotEntry.subParts === document.subParts
1261
+ ) {
1262
+ return cachedFieldSnapshotEntry.snapshot;
1263
+ }
1264
+ const snapshot = buildFieldSnapshot(document);
1265
+ cachedFieldSnapshotEntry = { blockCount, subParts: document.subParts, snapshot };
1266
+ return snapshot;
1267
+ }
1268
+
1155
1269
  function getCachedCompatibilityReport(
1156
1270
  nextState: EditorState,
1157
1271
  ): RuntimeRenderSnapshot["compatibility"] {
1158
- if (
1159
- cachedCompatibility &&
1160
- cachedCompatibility.revisionToken === nextState.revisionToken &&
1161
- cachedCompatibility.warnings === nextState.warnings &&
1162
- cachedCompatibility.fatalError === nextState.fatalError
1163
- ) {
1164
- return cachedCompatibility.report;
1272
+ const blockCount = nextState.document.content.children.length;
1273
+ const warningCount = nextState.warnings?.length ?? 0;
1274
+ if (cachedCompatibility) {
1275
+ // Fast path 1: same revisionToken (selection move, surface-only refresh).
1276
+ if (
1277
+ cachedCompatibility.revisionToken === nextState.revisionToken &&
1278
+ cachedCompatibility.warnings === nextState.warnings &&
1279
+ cachedCompatibility.fatalError === nextState.fatalError
1280
+ ) {
1281
+ return cachedCompatibility.report;
1282
+ }
1283
+ // Fast path 2: revisionToken changed but block structure and warning
1284
+ // count are stable (text-only edit). buildCompatibilityReport reads
1285
+ // block types, review counts, preservation, and warnings — NOT run text.
1286
+ // Block count + warning count + fatalError identity is an O(1) proxy:
1287
+ // if these are stable, the compatibility report output is unchanged.
1288
+ // `warnings` array gets remapped to a new reference on every commit via
1289
+ // remapReviewStateAfterContentChange, so we can't use reference equality.
1290
+ if (
1291
+ cachedCompatibility.blockCount === blockCount &&
1292
+ cachedCompatibility.warningCount === warningCount &&
1293
+ cachedCompatibility.fatalError === nextState.fatalError
1294
+ ) {
1295
+ cachedCompatibility = {
1296
+ ...cachedCompatibility,
1297
+ revisionToken: nextState.revisionToken,
1298
+ warnings: nextState.warnings,
1299
+ };
1300
+ return cachedCompatibility.report;
1301
+ }
1165
1302
  }
1166
1303
 
1167
1304
  const derived = createDerivedCompatibility(nextState);
@@ -1178,6 +1315,8 @@ export function createDocumentRuntime(
1178
1315
  };
1179
1316
  cachedCompatibility = {
1180
1317
  revisionToken: nextState.revisionToken,
1318
+ blockCount,
1319
+ warningCount,
1181
1320
  warnings: nextState.warnings,
1182
1321
  fatalError: nextState.fatalError,
1183
1322
  report,
@@ -1424,8 +1563,13 @@ export function createDocumentRuntime(
1424
1563
  if (normalizedWorkflowOverlay) {
1425
1564
  const matchingScope = getMatchingWorkflowScope(selection);
1426
1565
  const activeScopes = getEffectiveWorkflowScopes(normalizedWorkflowOverlay);
1566
+ // §C8: invisible non-view scopes are transparent to the interaction guard.
1567
+ // Don't count them toward the "outside_workflow_scope" threshold.
1568
+ const guardingScopes = activeScopes.filter(
1569
+ (s) => !(s.visibility === "invisible" && s.mode !== "view"),
1570
+ );
1427
1571
 
1428
- if (!matchingScope && activeScopes.length > 0) {
1572
+ if (!matchingScope && guardingScopes.length > 0) {
1429
1573
  reasons.push({
1430
1574
  code: "outside_workflow_scope",
1431
1575
  message: "Selection is outside any active workflow scope.",
@@ -1456,24 +1600,60 @@ export function createDocumentRuntime(
1456
1600
  return reasons;
1457
1601
  }
1458
1602
 
1459
- function getMatchingWorkflowScope(
1460
- selection: EditorState["selection"],
1461
- ): WorkflowOverlay["scopes"][number] | null {
1462
- if (!workflowOverlay) {
1463
- return null;
1464
- }
1603
+ // §C6 — most-restrictive-wins ordering for overlap layering.
1604
+ const MODE_RESTRICTIVENESS: Record<WorkflowScopeMode, number> = {
1605
+ edit: 0,
1606
+ suggest: 1,
1607
+ comment: 2,
1608
+ view: 3,
1609
+ };
1465
1610
 
1611
+ /**
1612
+ * §C6 — Collect all guard-eligible scopes that contain `selection`,
1613
+ * sorted outermost→innermost (startPos ASC, endPos DESC, scopeId ASC).
1614
+ * Excludes invisible non-view scopes per §C8.
1615
+ */
1616
+ function buildMatchingScopeStack(
1617
+ selection: EditorState["selection"],
1618
+ ): WorkflowOverlay["scopes"] {
1619
+ if (!workflowOverlay) return [];
1466
1620
  const selectionBounds = {
1467
1621
  from: Math.min(selection.anchor, selection.head),
1468
1622
  to: Math.max(selection.anchor, selection.head),
1469
1623
  };
1470
1624
  const activeScopes = getEffectiveWorkflowScopes(workflowOverlay);
1471
- return activeScopes.find((scope) => {
1625
+ const matching = activeScopes.filter((scope) => {
1626
+ // §C8
1627
+ if (scope.visibility === "invisible" && scope.mode !== "view") return false;
1472
1628
  if (scope.anchor.kind === "detached") return false;
1473
1629
  const scopeFrom = scope.anchor.kind === "range" ? scope.anchor.from : scope.anchor.at;
1474
1630
  const scopeTo = scope.anchor.kind === "range" ? scope.anchor.to : scope.anchor.at;
1475
1631
  return selectionBounds.from >= scopeFrom && selectionBounds.to <= scopeTo;
1476
- }) ?? null;
1632
+ });
1633
+ // Outermost first: startPos ASC, endPos DESC (wider span = outer), scopeId ASC tiebreak
1634
+ matching.sort((a, b) => {
1635
+ const aFrom = a.anchor.kind === "range" ? a.anchor.from : (a.anchor as { at: number }).at;
1636
+ const bFrom = b.anchor.kind === "range" ? b.anchor.from : (b.anchor as { at: number }).at;
1637
+ if (aFrom !== bFrom) return aFrom - bFrom;
1638
+ const aTo = a.anchor.kind === "range" ? a.anchor.to : (a.anchor as { at: number }).at;
1639
+ const bTo = b.anchor.kind === "range" ? b.anchor.to : (b.anchor as { at: number }).at;
1640
+ if (aTo !== bTo) return bTo - aTo; // wider first
1641
+ return a.scopeId < b.scopeId ? -1 : a.scopeId > b.scopeId ? 1 : 0;
1642
+ });
1643
+ return matching;
1644
+ }
1645
+
1646
+ function getMatchingWorkflowScope(
1647
+ selection: EditorState["selection"],
1648
+ ): WorkflowOverlay["scopes"][number] | null {
1649
+ const stack = buildMatchingScopeStack(selection);
1650
+ if (stack.length === 0) return null;
1651
+ // §C6 — most-restrictive scope wins across all overlapping scopes.
1652
+ return stack.reduce((best, scope) =>
1653
+ (MODE_RESTRICTIVENESS[scope.mode] ?? 0) > (MODE_RESTRICTIVENESS[best.mode] ?? 0)
1654
+ ? scope
1655
+ : best,
1656
+ );
1477
1657
  }
1478
1658
 
1479
1659
  function getEffectiveDocumentMode(
@@ -1772,19 +1952,37 @@ export function createDocumentRuntime(
1772
1952
  return scope;
1773
1953
  }
1774
1954
  const location = locations.get(scope.scopeId);
1955
+ const isMarkerBacked = markerBackedScopeIds.has(scope.scopeId);
1956
+ let nextAnchor: EditorAnchorProjection | null = null;
1775
1957
  if (
1776
- !location ||
1777
- location.startPos === undefined ||
1778
- location.endPos === undefined
1958
+ location &&
1959
+ location.startPos !== undefined &&
1960
+ location.endPos !== undefined
1779
1961
  ) {
1962
+ nextAnchor = {
1963
+ kind: "range",
1964
+ from: Math.min(location.startPos, location.endPos),
1965
+ to: Math.max(location.startPos, location.endPos),
1966
+ assoc: { start: -1, end: 1 },
1967
+ };
1968
+ } else if (isMarkerBacked) {
1969
+ const lastKnownRange =
1970
+ scope.anchor.kind === "range"
1971
+ ? { from: scope.anchor.from, to: scope.anchor.to }
1972
+ : scope.anchor.kind === "node"
1973
+ ? { from: scope.anchor.at, to: scope.anchor.at }
1974
+ : scope.anchor.lastKnownRange;
1975
+ nextAnchor = {
1976
+ kind: "detached",
1977
+ reason:
1978
+ location && (location.startPos !== undefined || location.endPos !== undefined)
1979
+ ? "deleted"
1980
+ : "invalidatedByStructureChange",
1981
+ lastKnownRange,
1982
+ };
1983
+ } else {
1780
1984
  return scope;
1781
1985
  }
1782
- const nextAnchor: EditorAnchorProjection = {
1783
- kind: "range",
1784
- from: Math.min(location.startPos, location.endPos),
1785
- to: Math.max(location.startPos, location.endPos),
1786
- assoc: { start: -1, end: 1 },
1787
- };
1788
1986
  if (workflowAnchorsEqual(scope.anchor, nextAnchor)) {
1789
1987
  return scope;
1790
1988
  }
@@ -1814,6 +2012,161 @@ export function createDocumentRuntime(
1814
2012
  return normalizeWorkflowOverlayForDocument(state.document, workflowOverlay);
1815
2013
  }
1816
2014
 
2015
+ function buildWarningSignature(warning: InternalEditorWarning): string {
2016
+ return JSON.stringify({
2017
+ code: warning.code,
2018
+ severity: warning.severity,
2019
+ message: warning.message,
2020
+ source: warning.source,
2021
+ featureEntryId: warning.featureEntryId ?? null,
2022
+ details: warning.details ?? null,
2023
+ affectedAnchor: warning.affectedAnchor ?? null,
2024
+ });
2025
+ }
2026
+
2027
+ function mergeDetachedWorkflowScopeWarnings(
2028
+ overlay: WorkflowOverlay | null,
2029
+ existingWarnings: readonly InternalEditorWarning[],
2030
+ ): {
2031
+ nextWarnings: InternalEditorWarning[];
2032
+ added: InternalEditorWarning[];
2033
+ cleared: Array<{ warningId: string; code: InternalEditorWarning["code"] }>;
2034
+ } {
2035
+ const detachedScopesById = new Map<string, WorkflowScope>();
2036
+ for (const scope of overlay?.scopes ?? []) {
2037
+ if (scope.anchor.kind === "detached") {
2038
+ detachedScopesById.set(scope.scopeId, scope);
2039
+ }
2040
+ }
2041
+
2042
+ const retainedWarnings = existingWarnings.filter(
2043
+ (warning) => warning.code !== "workflow_scope_invalidated",
2044
+ );
2045
+ const existingDetachedWarnings = existingWarnings.filter(
2046
+ (warning) => warning.code === "workflow_scope_invalidated",
2047
+ );
2048
+ const existingById = new Map(
2049
+ existingDetachedWarnings.map((warning) => [warning.warningId, warning] as const),
2050
+ );
2051
+ const desiredById = new Map(
2052
+ [...detachedScopesById.values()].map((scope) => {
2053
+ const warning = createInvalidatedWorkflowScopeWarning(scope);
2054
+ return [warning.warningId, warning] as const;
2055
+ }),
2056
+ );
2057
+
2058
+ const added: InternalEditorWarning[] = [];
2059
+ const cleared: Array<{ warningId: string; code: InternalEditorWarning["code"] }> = [];
2060
+
2061
+ for (const [warningId, existingWarning] of existingById) {
2062
+ const desiredWarning = desiredById.get(warningId);
2063
+ if (!desiredWarning) {
2064
+ cleared.push({ warningId, code: existingWarning.code });
2065
+ continue;
2066
+ }
2067
+ if (buildWarningSignature(existingWarning) !== buildWarningSignature(desiredWarning)) {
2068
+ cleared.push({ warningId, code: existingWarning.code });
2069
+ added.push(desiredWarning);
2070
+ }
2071
+ }
2072
+
2073
+ for (const [warningId, desiredWarning] of desiredById) {
2074
+ if (!existingById.has(warningId)) {
2075
+ added.push(desiredWarning);
2076
+ }
2077
+ }
2078
+
2079
+ return {
2080
+ nextWarnings: [...retainedWarnings, ...desiredById.values()],
2081
+ added,
2082
+ cleared,
2083
+ };
2084
+ }
2085
+
2086
+ function createInvalidatedWorkflowScopeWarning(
2087
+ scope: WorkflowScope,
2088
+ ): InternalEditorWarning {
2089
+ const anchor = scope.anchor.kind === "detached" ? scope.anchor : null;
2090
+ const subject = scope.label
2091
+ ? `Workflow scope "${scope.label}" (${scope.scopeId})`
2092
+ : `Workflow scope ${scope.scopeId}`;
2093
+ const reasonPhrase =
2094
+ anchor?.reason === "deleted"
2095
+ ? "its anchored text was deleted"
2096
+ : anchor?.reason === "invalidatedByStructureChange"
2097
+ ? "document structure changed around it"
2098
+ : "its anchor could not be resolved unambiguously";
2099
+ const modePhrase =
2100
+ scope.mode === "view"
2101
+ ? "read-only enforcement"
2102
+ : `${scope.mode} enforcement`;
2103
+
2104
+ return {
2105
+ warningId: `warning:workflow-scope-invalidated:${scope.scopeId}`,
2106
+ code: "workflow_scope_invalidated",
2107
+ severity: "warning",
2108
+ message: `${subject} was invalidated because ${reasonPhrase}. Reapply the scope before relying on ${modePhrase}.`,
2109
+ source: "runtime",
2110
+ affectedAnchor: anchor ? toInternalAnchorProjection(anchor) : undefined,
2111
+ diagnostic: buildDiagnosticFromLegacyWarningCode("workflow_scope_invalidated", {
2112
+ diagnosticId: `warning-diag:workflow-scope-invalidated:${scope.scopeId}`,
2113
+ technical: {
2114
+ message: `${subject} lost its trusted anchor and is now detached.`,
2115
+ source: "runtime",
2116
+ },
2117
+ details: {
2118
+ scopeId: scope.scopeId,
2119
+ label: scope.label,
2120
+ mode: scope.mode,
2121
+ reason: anchor?.reason,
2122
+ lastKnownRange: anchor?.lastKnownRange,
2123
+ storyTarget: scope.storyTarget,
2124
+ reapplySuggested: true,
2125
+ },
2126
+ affectedAnchor: anchor ? scope.anchor : undefined,
2127
+ llmMetadata: {
2128
+ userSummary: `${subject} is no longer attached to trusted document content. Reapply the scope before relying on it.`,
2129
+ remediation: {
2130
+ kind: "fallback",
2131
+ suggestion:
2132
+ "Locate the intended text using warning.details.scopeId and warning.details.lastKnownRange, then call addScope again for the repaired range.",
2133
+ },
2134
+ recoveryClass: "requires-input",
2135
+ echoedInput: {
2136
+ scopeId: scope.scopeId,
2137
+ lastKnownRange: anchor?.lastKnownRange,
2138
+ },
2139
+ },
2140
+ }),
2141
+ details: {
2142
+ scopeId: scope.scopeId,
2143
+ label: scope.label,
2144
+ mode: scope.mode,
2145
+ reason: anchor?.reason,
2146
+ lastKnownRange: anchor?.lastKnownRange,
2147
+ storyTarget: scope.storyTarget,
2148
+ reapplySuggested: true,
2149
+ actionabilityNote:
2150
+ "Resolve the intended text again, then reapply the scope; the previous anchor is no longer trusted.",
2151
+ },
2152
+ };
2153
+ }
2154
+
2155
+ function syncDetachedWorkflowScopeWarningsInState(): {
2156
+ added: InternalEditorWarning[];
2157
+ cleared: Array<{ warningId: string; code: InternalEditorWarning["code"] }>;
2158
+ } {
2159
+ const { nextWarnings, added, cleared } = mergeDetachedWorkflowScopeWarnings(
2160
+ getNormalizedWorkflowOverlay(),
2161
+ state.warnings,
2162
+ );
2163
+ if (added.length === 0 && cleared.length === 0) {
2164
+ return { added, cleared };
2165
+ }
2166
+ state = { ...state, warnings: nextWarnings };
2167
+ return { added, cleared };
2168
+ }
2169
+
1817
2170
  function deriveWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null {
1818
2171
  const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
1819
2172
  if (!normalizedWorkflowOverlay) return null;
@@ -1892,6 +2245,7 @@ export function createDocumentRuntime(
1892
2245
 
1893
2246
  const blockedReasons = evaluateWorkflowBlockedReasons(state.selection);
1894
2247
  const matchingScope = getMatchingWorkflowScope(state.selection);
2248
+ const scopeStack = buildMatchingScopeStack(state.selection);
1895
2249
  const primaryBlockedReason = blockedReasons[0];
1896
2250
  const effectiveMode = primaryBlockedReason
1897
2251
  ? (
@@ -1904,10 +2258,19 @@ export function createDocumentRuntime(
1904
2258
  : getEffectiveDocumentMode(state.selection) === "suggesting"
1905
2259
  ? "suggest"
1906
2260
  : matchingScope?.mode ?? "edit";
2261
+ const matchedScopeStack: InteractionGuardSnapshot["matchedScopeStack"] =
2262
+ scopeStack.length > 0
2263
+ ? scopeStack.map((s) => ({
2264
+ scopeId: s.scopeId,
2265
+ mode: s.mode,
2266
+ visibility: s.visibility ?? "visible",
2267
+ }))
2268
+ : undefined;
1907
2269
  const snapshot: InteractionGuardSnapshot = {
1908
2270
  effectiveMode,
1909
2271
  ...(matchingScope?.scopeId ? { matchedScopeId: matchingScope.scopeId } : {}),
1910
2272
  ...(matchingScope?.mode ? { matchedScopeMode: matchingScope.mode } : {}),
2273
+ ...(matchedScopeStack ? { matchedScopeStack } : {}),
1911
2274
  targetAccess:
1912
2275
  effectiveMode === "edit"
1913
2276
  ? "direct-edit"
@@ -1992,7 +2355,7 @@ export function createDocumentRuntime(
1992
2355
 
1993
2356
  const snapshot = collectWorkflowMarkupSnapshot({
1994
2357
  renderSnapshot: cachedRenderSnapshot,
1995
- fieldSnapshot: buildFieldSnapshot(state.document),
2358
+ fieldSnapshot: getCachedFieldSnapshot(state.document),
1996
2359
  protectionSnapshot,
1997
2360
  preservation: state.document.preservation,
1998
2361
  workflowMetadataSnapshot: deriveWorkflowMetadataSnapshot(),
@@ -2297,6 +2660,7 @@ export function createDocumentRuntime(
2297
2660
  cachedWorkflowScopeSnapshot = undefined;
2298
2661
  cachedNormalizedWorkflowOverlay = undefined;
2299
2662
  cachedWorkflowMarkupSnapshot = undefined;
2663
+ cachedFieldSnapshotEntry = null;
2300
2664
  cachedContextAnalyticsSnapshots.clear();
2301
2665
  lastEmittedContextAnalyticsSnapshots = undefined;
2302
2666
  }
@@ -2313,8 +2677,11 @@ export function createDocumentRuntime(
2313
2677
  document,
2314
2678
  };
2315
2679
  if (previousDocument.subParts !== document.subParts) {
2316
- fontLoader.refresh(collectFontLoaderInput(document));
2317
- layoutEngine.invalidateMeasurementCache();
2680
+ const fontInput = collectFontLoaderInput(document);
2681
+ if (!fontFamiliesEqual(fontInput.families, collectFontLoaderInput(previousDocument).families)) {
2682
+ fontLoader.refresh(fontInput);
2683
+ layoutEngine.invalidateMeasurementCache();
2684
+ }
2318
2685
  }
2319
2686
  invalidateDerivedRuntimeCaches();
2320
2687
  cachedRenderSnapshot = refreshRenderSnapshot();
@@ -2363,6 +2730,7 @@ export function createDocumentRuntime(
2363
2730
  return snapshot;
2364
2731
  }
2365
2732
 
2733
+ syncDetachedWorkflowScopeWarningsInState();
2366
2734
  let cachedRenderSnapshot = refreshRenderSnapshot();
2367
2735
 
2368
2736
  emit({
@@ -2473,6 +2841,15 @@ export function createDocumentRuntime(
2473
2841
  reasons,
2474
2842
  });
2475
2843
  },
2844
+ emitTransientWarning(warning) {
2845
+ const publicWarning = toPublicWarning(warning);
2846
+ emit({
2847
+ type: "warning_added",
2848
+ documentId: state.documentId,
2849
+ warning: publicWarning,
2850
+ });
2851
+ options.onWarning?.(publicWarning);
2852
+ },
2476
2853
  dispatch(command) {
2477
2854
  const commandSelection = getCommandSelection(command, state.selection);
2478
2855
  if (isMutationCommand(command)) {
@@ -3083,7 +3460,7 @@ export function createDocumentRuntime(
3083
3460
  });
3084
3461
  },
3085
3462
  addCommentReply(commentId, body, authorId) {
3086
- const priorEntryCount =
3463
+ const priorCount =
3087
3464
  state.document.review.comments[commentId]?.entries?.length ?? 0;
3088
3465
  this.dispatch({
3089
3466
  type: "comment.add-reply",
@@ -3092,8 +3469,16 @@ export function createDocumentRuntime(
3092
3469
  authorId: authorId ?? defaultAuthorId,
3093
3470
  origin: createOrigin("api", clock()),
3094
3471
  });
3095
- const entryId = `${commentId}-entry-${priorEntryCount + 1}`;
3096
- return { commentId, entryId };
3472
+ // Read post-dispatch state. The reducer skips silently on unknown /
3473
+ // resolved / detached threads and emits a `review_target_not_found`
3474
+ // transient warning from `effects.transientWarnings` — callers who
3475
+ // need to know about the skip listen on `onWarning`.
3476
+ const entries = state.document.review.comments[commentId]?.entries ?? [];
3477
+ if (entries.length <= priorCount) {
3478
+ return null;
3479
+ }
3480
+ const last = entries[entries.length - 1]!;
3481
+ return { commentId, entryId: last.entryId };
3097
3482
  },
3098
3483
  editCommentBody(commentId, body) {
3099
3484
  this.dispatch({
@@ -3168,17 +3553,29 @@ export function createDocumentRuntime(
3168
3553
  });
3169
3554
 
3170
3555
  if (params.persistence && params.persistence !== "runtime-only") {
3556
+ const requestedMetadata = params.metadata ?? {};
3557
+ const entryPersistence =
3558
+ requestedMetadata.metadataPersistence ??
3559
+ (params.persistence === "session" ? "external" : "internal");
3171
3560
  const entry: WorkflowMetadataEntry = {
3172
- entryId: `scope-metadata-${scopeId}`,
3173
- metadataId: "workflow.scope",
3561
+ entryId: requestedMetadata.entryId ?? `scope-metadata-${scopeId}`,
3562
+ metadataId: requestedMetadata.metadataId ?? "workflow.scope",
3174
3563
  anchor: publicAnchor,
3564
+ ...(params.storyTarget ? { storyTarget: params.storyTarget } : {}),
3175
3565
  scopeId,
3176
- value:
3177
- params.persistence === "document-metadata"
3178
- ? { ...(params.metadata?.value ?? {}), label: params.label }
3179
- : params.metadata?.value,
3180
- metadataPersistence:
3181
- params.persistence === "session" ? "external" : "internal",
3566
+ ...(requestedMetadata.workItemId ? { workItemId: requestedMetadata.workItemId } : {}),
3567
+ ...(requestedMetadata.value !== undefined
3568
+ ? { value: requestedMetadata.value }
3569
+ : params.persistence === "document-metadata" && params.label
3570
+ ? { value: { label: params.label } }
3571
+ : {}),
3572
+ metadataPersistence: entryPersistence,
3573
+ ...(requestedMetadata.storageRef !== undefined
3574
+ ? { storageRef: requestedMetadata.storageRef }
3575
+ : {}),
3576
+ ...(requestedMetadata.metadataVersion !== undefined
3577
+ ? { metadataVersion: requestedMetadata.metadataVersion }
3578
+ : {}),
3182
3579
  };
3183
3580
  this.dispatch({
3184
3581
  type: "workflow.set-metadata-entries",
@@ -3250,6 +3647,38 @@ export function createDocumentRuntime(
3250
3647
  }
3251
3648
  }
3252
3649
  },
3650
+ addInvisibleScope(params) {
3651
+ const result = this.addScope({
3652
+ ...params,
3653
+ mode: params.mode ?? "comment",
3654
+ });
3655
+ this.setScopeVisibility(result.scopeId, "invisible");
3656
+ return result;
3657
+ },
3658
+ setScopeVisibility(scopeId, visibility) {
3659
+ if (!workflowOverlay) return;
3660
+ const idx = workflowOverlay.scopes.findIndex((s) => s.scopeId === scopeId);
3661
+ if (idx === -1) return;
3662
+ const nextScopes = workflowOverlay.scopes.map((s) =>
3663
+ s.scopeId === scopeId ? { ...s, visibility } : s,
3664
+ );
3665
+ this.dispatch({
3666
+ type: "workflow.set-overlay",
3667
+ overlay: { ...workflowOverlay, scopes: nextScopes },
3668
+ origin: createOrigin("api", clock()),
3669
+ });
3670
+ },
3671
+ getScopeVisibility(scopeId): ScopeVisibility {
3672
+ if (!workflowOverlay) return "visible";
3673
+ const scope = workflowOverlay.scopes.find((s) => s.scopeId === scopeId);
3674
+ return scope?.visibility ?? "visible";
3675
+ },
3676
+ setScopeChromeVisibility(state) {
3677
+ scopeChromeVisibilityState = state;
3678
+ },
3679
+ getScopeChromeVisibility(): ScopeChromeVisibilityState {
3680
+ return scopeChromeVisibilityState;
3681
+ },
3253
3682
  acceptChange(changeId) {
3254
3683
  this.dispatch({
3255
3684
  type: "change.accept",
@@ -3382,7 +3811,11 @@ export function createDocumentRuntime(
3382
3811
  getFootnoteResolver(): FootnoteResolver | undefined {
3383
3812
  const collection = state.document.subParts?.footnoteCollection;
3384
3813
  if (!collection) return undefined;
3385
- return createFootnoteResolver(collection, collectSectionPropertiesInOrder(state.document));
3814
+ return createFootnoteResolver(
3815
+ collection,
3816
+ collectSectionPropertiesInOrder(state.document),
3817
+ state.document,
3818
+ );
3386
3819
  },
3387
3820
  layout: layoutFacet,
3388
3821
  getCurrentLocation() {
@@ -3529,6 +3962,9 @@ export function createDocumentRuntime(
3529
3962
  options,
3530
3963
  );
3531
3964
  if (refreshed.changed) {
3965
+ // Field display text changed — clear field snapshot cache so the
3966
+ // next getCachedFieldSnapshot call rebuilds with fresh displayText.
3967
+ cachedFieldSnapshotEntry = null;
3532
3968
  this.dispatch({
3533
3969
  type: "document.replace",
3534
3970
  document: refreshed.document,
@@ -3729,10 +4165,98 @@ export function createDocumentRuntime(
3729
4165
  overlay: workflowOverlay,
3730
4166
  entries: workflowMetadataEntries,
3731
4167
  document: state.document,
4168
+ markerBackedScopeIds,
3732
4169
  },
3733
4170
  filter,
3734
4171
  );
3735
4172
  },
4173
+ subscribeToScopeQuery(filter, callback) {
4174
+ const buildAnchorKey = (anchor: EditorAnchorProjection): string => {
4175
+ switch (anchor.kind) {
4176
+ case "range":
4177
+ return `range:${anchor.from}:${anchor.to}:${anchor.assoc.start}:${anchor.assoc.end}`;
4178
+ case "node":
4179
+ return `node:${anchor.at}`;
4180
+ case "detached":
4181
+ return `detached:${anchor.reason}:${anchor.lastKnownRange.from}:${anchor.lastKnownRange.to}`;
4182
+ default:
4183
+ return "unknown";
4184
+ }
4185
+ };
4186
+
4187
+ const buildKey = (results: ScopeQueryResult[]) =>
4188
+ results
4189
+ .map(
4190
+ (r) =>
4191
+ [
4192
+ r.scope.scopeId,
4193
+ r.scope.version ?? 0,
4194
+ r.scope.visibility ?? "visible",
4195
+ buildAnchorKey(r.scope.anchor),
4196
+ r.workItem?.workItemId ?? "",
4197
+ r.entries
4198
+ .map(
4199
+ (entry) =>
4200
+ `${entry.entryId}:${entry.metadataVersion ?? 0}:${buildAnchorKey(entry.anchor)}`,
4201
+ )
4202
+ .join("|"),
4203
+ ].join(":"),
4204
+ )
4205
+ .join(",");
4206
+
4207
+ let lastKey = "";
4208
+ const fire = () => {
4209
+ const results = this.queryScopes(filter);
4210
+ const key = buildKey(results);
4211
+ if (key !== lastKey) {
4212
+ lastKey = key;
4213
+ callback(results);
4214
+ }
4215
+ };
4216
+
4217
+ // Immediate initial fire
4218
+ fire();
4219
+
4220
+ let pendingTimer: ReturnType<typeof setTimeout> | null = null;
4221
+ const unsub = this.subscribe(() => {
4222
+ if (pendingTimer !== null) return;
4223
+ pendingTimer = setTimeout(() => {
4224
+ pendingTimer = null;
4225
+ fire();
4226
+ }, 0);
4227
+ });
4228
+
4229
+ return () => {
4230
+ unsub();
4231
+ if (pendingTimer !== null) {
4232
+ clearTimeout(pendingTimer);
4233
+ pendingTimer = null;
4234
+ }
4235
+ };
4236
+ },
4237
+ findAllText(query, options) {
4238
+ const sel = makePublicSelectionSnapshot(state.selection.anchor, state.selection.head);
4239
+ return findTextMatches(state.document, sel, query, options ?? {});
4240
+ },
4241
+ findTextWithStyle(query, filter, options) {
4242
+ const sel = makePublicSelectionSnapshot(state.selection.anchor, state.selection.head);
4243
+ return findTextWithStyleMatches(state.document, sel, query, filter, options ?? {});
4244
+ },
4245
+ selectTextWithStyle(query, filter, options) {
4246
+ const sel = makePublicSelectionSnapshot(state.selection.anchor, state.selection.head);
4247
+ const hits = findTextWithStyleMatches(state.document, sel, query, filter, options ?? {});
4248
+ if (hits.length > 0 && hits[0]) {
4249
+ const first = hits[0];
4250
+ if (first.kind === "range") {
4251
+ this.dispatch({
4252
+ type: "selection.set",
4253
+ selection: createSelectionSnapshot(first.from, first.to),
4254
+ origin: createOrigin("api", clock()),
4255
+ });
4256
+ }
4257
+ }
4258
+ return hits.length;
4259
+ },
3736
4260
  findScopesAt(position, options) {
3737
4261
  const pos =
3738
4262
  position.kind === "range"
@@ -3743,7 +4267,12 @@ export function createDocumentRuntime(
3743
4267
  if (pos === null) return [];
3744
4268
  const hits = findAllScopesAt(state.document, pos);
3745
4269
  return projectScopeQueryResults(
3746
- { overlay: workflowOverlay, entries: workflowMetadataEntries, document: state.document },
4270
+ {
4271
+ overlay: workflowOverlay,
4272
+ entries: workflowMetadataEntries,
4273
+ document: state.document,
4274
+ markerBackedScopeIds,
4275
+ },
3747
4276
  hits.map((h) => h.scopeId),
3748
4277
  options,
3749
4278
  );
@@ -3752,7 +4281,12 @@ export function createDocumentRuntime(
3752
4281
  if (range.kind !== "range") return [];
3753
4282
  const hits = findScopesIntersecting(state.document, range.from, range.to, options?.mode);
3754
4283
  return projectScopeQueryResults(
3755
- { overlay: workflowOverlay, entries: workflowMetadataEntries, document: state.document },
4284
+ {
4285
+ overlay: workflowOverlay,
4286
+ entries: workflowMetadataEntries,
4287
+ document: state.document,
4288
+ markerBackedScopeIds,
4289
+ },
3756
4290
  hits.map((h) => h.scopeId),
3757
4291
  options,
3758
4292
  );
@@ -3925,6 +4459,38 @@ export function createDocumentRuntime(
3925
4459
  }
3926
4460
 
3927
4461
  function applyTransactionToState(transaction: EditorTransaction): void {
4462
+ // Pure-no-op short-circuit: when a reducer skipped without emitting any
4463
+ // observable effect AND the selection is identical, skip finalizeState
4464
+ // / invalidate / refresh / notify entirely. This keeps hot paths (e.g.
4465
+ // host loops that query invalid ids) cheap — no clock allocation, no
4466
+ // listener loop, no snapshot rebuild for nothing. Transient-warning
4467
+ // commits (the `review_target_not_found` path) carry
4468
+ // `transientWarnings.length > 0` and MUST fall through so `notify`
4469
+ // can emit them. Selection-only dispatches (e.g. `selection.set`) also
4470
+ // fall through because `nextState.selection !== state.selection`.
4471
+ const effects = transaction.effects;
4472
+ const selectionUnchanged = transaction.nextState.selection === state.selection;
4473
+ const isPureNoop =
4474
+ !transaction.markDirty &&
4475
+ transaction.historyBoundary === "skip" &&
4476
+ transaction.mapping.steps.length === 0 &&
4477
+ selectionUnchanged &&
4478
+ effects.warningsAdded.length === 0 &&
4479
+ effects.warningsCleared.length === 0 &&
4480
+ (effects.transientWarnings?.length ?? 0) === 0 &&
4481
+ !effects.commentAdded &&
4482
+ !effects.commentResolved &&
4483
+ !effects.commentReopened &&
4484
+ !effects.commentReplyAdded &&
4485
+ !effects.commentBodyEdited &&
4486
+ !effects.changeAccepted &&
4487
+ !effects.changeRejected &&
4488
+ !effects.revisionAuthored &&
4489
+ !effects.commandBlocked;
4490
+ if (isPureNoop) {
4491
+ return;
4492
+ }
4493
+
3928
4494
  const previous = state;
3929
4495
 
3930
4496
  const tApply0 = performance.now();
@@ -3933,6 +4499,8 @@ export function createDocumentRuntime(
3933
4499
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
3934
4500
  perfCounters.increment("commit.finalizeState.us", Math.round((performance.now() - tFinalize0) * 1000));
3935
4501
  storySelections.set(storyTargetKey(activeStory), state.selection);
4502
+ syncMarkerBackedScopeIds(state.document, workflowOverlay);
4503
+ const detachedWorkflowScopeWarnings = syncDetachedWorkflowScopeWarningsInState();
3936
4504
 
3937
4505
  const tInvalidate0 = performance.now();
3938
4506
  if (transaction.markDirty && transaction.mapping.steps.length > 0) {
@@ -3948,8 +4516,11 @@ export function createDocumentRuntime(
3948
4516
  }
3949
4517
  }
3950
4518
  if (previous.document.subParts !== state.document.subParts) {
3951
- fontLoader.refresh(collectFontLoaderInput(state.document));
3952
- layoutEngine.invalidateMeasurementCache();
4519
+ const fontInput = collectFontLoaderInput(state.document);
4520
+ if (!fontFamiliesEqual(fontInput.families, collectFontLoaderInput(previous.document).families)) {
4521
+ fontLoader.refresh(fontInput);
4522
+ layoutEngine.invalidateMeasurementCache();
4523
+ }
3953
4524
  }
3954
4525
  perfCounters.increment("commit.invalidate.us", Math.round((performance.now() - tInvalidate0) * 1000));
3955
4526
 
@@ -3979,10 +4550,16 @@ export function createDocumentRuntime(
3979
4550
  // which is optional in the public API for shape-only reasons — the helper
3980
4551
  // itself always returns a defined snapshot.
3981
4552
  const surfaceForValidation = getCachedSurface(state.document, activeStory)!;
4553
+ const validationOptions = state.selection.activeRange.kind === "node"
4554
+ ? {
4555
+ isValidNodeTarget: createSurfaceNodeSelectionProbe(surfaceForValidation),
4556
+ }
4557
+ : undefined;
3982
4558
  const validatedSelection = validateSelectionAgainstDocument(
3983
4559
  state.document,
3984
4560
  state.selection,
3985
4561
  surfaceForValidation.storySize,
4562
+ validationOptions,
3986
4563
  );
3987
4564
  if (validatedSelection !== state.selection) {
3988
4565
  state = { ...state, selection: validatedSelection };
@@ -3994,7 +4571,20 @@ export function createDocumentRuntime(
3994
4571
  perfCounters.increment("commit.refresh.us", Math.round((performance.now() - tRefresh0) * 1000));
3995
4572
 
3996
4573
  const tNotify0 = performance.now();
3997
- notify(previous, state, transaction);
4574
+ notify(previous, state, {
4575
+ ...transaction,
4576
+ effects: {
4577
+ ...transaction.effects,
4578
+ warningsAdded: [
4579
+ ...transaction.effects.warningsAdded,
4580
+ ...detachedWorkflowScopeWarnings.added,
4581
+ ],
4582
+ warningsCleared: [
4583
+ ...transaction.effects.warningsCleared,
4584
+ ...detachedWorkflowScopeWarnings.cleared,
4585
+ ],
4586
+ },
4587
+ });
3998
4588
  perfCounters.increment("commit.notify.us", Math.round((performance.now() - tNotify0) * 1000));
3999
4589
  perfCounters.increment("commit.total.us", Math.round((performance.now() - tApply0) * 1000));
4000
4590
  }
@@ -4184,6 +4774,23 @@ export function createDocumentRuntime(
4184
4774
  options.onWarning?.(publicWarning);
4185
4775
  }
4186
4776
 
4777
+ if (transaction.effects.transientWarnings) {
4778
+ // Fire-and-forget diagnostics (e.g. `review_target_not_found` on a
4779
+ // silent-skip reducer). Surfaces on `warning_added` + `onWarning`
4780
+ // like any other warning, but never touches `state.warnings` or the
4781
+ // compatibility report, so repeated no-op calls (e.g. a host
4782
+ // looping `resolveComment` over stale ids) do not accumulate entries.
4783
+ for (const warning of transaction.effects.transientWarnings) {
4784
+ const publicWarning = toPublicWarning(warning);
4785
+ emit({
4786
+ type: "warning_added",
4787
+ documentId: next.documentId,
4788
+ warning: publicWarning,
4789
+ });
4790
+ options.onWarning?.(publicWarning);
4791
+ }
4792
+ }
4793
+
4187
4794
  for (const cleared of transaction.effects.warningsCleared) {
4188
4795
  emit({
4189
4796
  type: "warning_cleared",
@@ -4433,9 +5040,16 @@ export function createDocumentRuntime(
4433
5040
  base: EditorTransaction["effects"],
4434
5041
  local: EditorTransaction["effects"],
4435
5042
  ): EditorTransaction["effects"] {
5043
+ const baseTransient = base.transientWarnings ?? [];
5044
+ const localTransient = local.transientWarnings ?? [];
5045
+ const mergedTransient =
5046
+ baseTransient.length + localTransient.length === 0
5047
+ ? undefined
5048
+ : [...baseTransient, ...localTransient];
4436
5049
  return {
4437
5050
  warningsAdded: [...base.warningsAdded, ...local.warningsAdded],
4438
5051
  warningsCleared: [...base.warningsCleared, ...local.warningsCleared],
5052
+ ...(mergedTransient ? { transientWarnings: mergedTransient } : {}),
4439
5053
  commentAdded: base.commentAdded ?? local.commentAdded,
4440
5054
  commentResolved: base.commentResolved ?? local.commentResolved,
4441
5055
  commentReopened: base.commentReopened ?? local.commentReopened,
@@ -4599,10 +5213,18 @@ export function createDocumentRuntime(
4599
5213
  function applyRuntimeStateOverlayCommand(
4600
5214
  command: RuntimeStateOverlayCommand,
4601
5215
  ): void {
5216
+ let detachedWorkflowScopeWarnings:
5217
+ | {
5218
+ added: InternalEditorWarning[];
5219
+ cleared: Array<{ warningId: string; code: InternalEditorWarning["code"] }>;
5220
+ }
5221
+ | null = null;
4602
5222
  switch (command.type) {
4603
5223
  case "workflow.set-overlay": {
4604
5224
  workflowOverlay = structuredClone(command.overlay);
5225
+ syncMarkerBackedScopeIds(state.document, workflowOverlay);
4605
5226
  cachedNormalizedWorkflowOverlay = undefined;
5227
+ detachedWorkflowScopeWarnings = syncDetachedWorkflowScopeWarningsInState();
4606
5228
  cachedRenderSnapshot = refreshRenderSnapshot();
4607
5229
  const snapshot = deriveWorkflowScopeSnapshot()!;
4608
5230
  emit({
@@ -4621,7 +5243,9 @@ export function createDocumentRuntime(
4621
5243
  }
4622
5244
  case "workflow.clear-overlay": {
4623
5245
  workflowOverlay = null;
5246
+ syncMarkerBackedScopeIds(state.document, workflowOverlay);
4624
5247
  cachedNormalizedWorkflowOverlay = undefined;
5248
+ detachedWorkflowScopeWarnings = syncDetachedWorkflowScopeWarningsInState();
4625
5249
  cachedRenderSnapshot = refreshRenderSnapshot();
4626
5250
  emit({
4627
5251
  type: "workflow_active_work_item_changed",
@@ -4696,6 +5320,25 @@ export function createDocumentRuntime(
4696
5320
  break;
4697
5321
  }
4698
5322
  }
5323
+ if (detachedWorkflowScopeWarnings) {
5324
+ for (const warning of detachedWorkflowScopeWarnings.added) {
5325
+ const publicWarning = toPublicWarning(warning);
5326
+ emit({
5327
+ type: "warning_added",
5328
+ documentId: state.documentId,
5329
+ warning: publicWarning,
5330
+ });
5331
+ options.onWarning?.(publicWarning);
5332
+ }
5333
+ for (const cleared of detachedWorkflowScopeWarnings.cleared) {
5334
+ emit({
5335
+ type: "warning_cleared",
5336
+ documentId: state.documentId,
5337
+ warningId: cleared.warningId,
5338
+ code: cleared.code,
5339
+ });
5340
+ }
5341
+ }
4699
5342
  for (const listener of listeners) {
4700
5343
  listener();
4701
5344
  }
@@ -4979,7 +5622,13 @@ function toRuntimeError(error: unknown): InternalEditorError {
4979
5622
  function toStructuredRuntimeException<T extends InternalEditorError>(
4980
5623
  error: T,
4981
5624
  ): Error & T {
4982
- return Object.assign(new Error(error.message), error);
5625
+ const exception = Object.assign(new Error(error.message), error);
5626
+ // Set `name` so host wrappers that must branch across process / bundle
5627
+ // boundaries (e.g. the gRPC handlers in `vendor/beyondwork/src/ts/docx-api`)
5628
+ // can discriminate structured editor errors from generic `Error` objects
5629
+ // via `error.name === "EditorError"` — `instanceof` is unreliable there.
5630
+ exception.name = "EditorError";
5631
+ return exception;
4983
5632
  }
4984
5633
 
4985
5634
  function toPublicDocumentStats(state: Pick<EditorState, "document">) {
@@ -5077,11 +5726,14 @@ function extractSelectionFragment(
5077
5726
  }
5078
5727
 
5079
5728
  /**
5080
- * Collect the stable ids of comment threads whose entry differs
5081
- * (present in one side but not the other, OR present in both but
5082
- * referencing a different object which indicates any mutation to
5083
- * the thread, since the runtime treats `review.comments` as
5084
- * immutable per commit). Used by the `comments_changed` event.
5729
+ * Collect the stable ids of comment threads whose host-observable state
5730
+ * differs between two commits. A reference-identity diff was too noisy
5731
+ * any `remapCommentStore` pass (fired on every text edit that touches a
5732
+ * thread's anchor region) rebuilt the thread objects and would have marked
5733
+ * every thread as "changed" even when only their anchor numerics shifted
5734
+ * in a way that doesn't affect what the host renders. The semantic diff
5735
+ * below compares the fields hosts actually consume: status, anchor shape,
5736
+ * entry count + ordering, resolution metadata, and warning ids.
5085
5737
  */
5086
5738
  function diffCommentMapKeys(
5087
5739
  previous: CanonicalDocumentEnvelope["review"]["comments"],
@@ -5089,14 +5741,65 @@ function diffCommentMapKeys(
5089
5741
  ): string[] {
5090
5742
  const changed = new Set<string>();
5091
5743
  for (const id of Object.keys(previous)) {
5092
- if (previous[id] !== next[id]) changed.add(id);
5744
+ if (!next[id]) changed.add(id);
5093
5745
  }
5094
5746
  for (const id of Object.keys(next)) {
5095
- if (previous[id] !== next[id]) changed.add(id);
5747
+ const prev = previous[id];
5748
+ if (!prev) {
5749
+ changed.add(id);
5750
+ continue;
5751
+ }
5752
+ if (prev === next[id]) continue;
5753
+ if (!semanticallyEqualCommentThreads(prev, next[id]!)) changed.add(id);
5096
5754
  }
5097
5755
  return Array.from(changed);
5098
5756
  }
5099
5757
 
5758
+ function semanticallyEqualCommentThreads(
5759
+ prev: CanonicalDocumentEnvelope["review"]["comments"][string],
5760
+ next: CanonicalDocumentEnvelope["review"]["comments"][string],
5761
+ ): boolean {
5762
+ if (prev.status !== next.status) return false;
5763
+ if (prev.isResolved !== next.isResolved) return false;
5764
+ if (prev.resolvedAt !== next.resolvedAt) return false;
5765
+ if (prev.resolution?.resolvedAt !== next.resolution?.resolvedAt) return false;
5766
+ if (prev.resolution?.resolvedBy !== next.resolution?.resolvedBy) return false;
5767
+ if (prev.body !== next.body) return false;
5768
+ if ((prev.entries?.length ?? 0) !== (next.entries?.length ?? 0)) return false;
5769
+ const prevEntries = prev.entries ?? [];
5770
+ const nextEntries = next.entries ?? [];
5771
+ for (let i = 0; i < prevEntries.length; i++) {
5772
+ const pe = prevEntries[i]!;
5773
+ const ne = nextEntries[i]!;
5774
+ if (pe.entryId !== ne.entryId) return false;
5775
+ if (pe.body !== ne.body) return false;
5776
+ if (pe.authorId !== ne.authorId) return false;
5777
+ }
5778
+ const prevWarnings = prev.warningIds ?? [];
5779
+ const nextWarnings = next.warningIds ?? [];
5780
+ if (prevWarnings.length !== nextWarnings.length) return false;
5781
+ for (let i = 0; i < prevWarnings.length; i++) {
5782
+ if (prevWarnings[i] !== nextWarnings[i]) return false;
5783
+ }
5784
+ const prevAnchor = prev.anchor;
5785
+ const nextAnchor = next.anchor;
5786
+ if (prevAnchor === nextAnchor) return true;
5787
+ if (prevAnchor.kind !== nextAnchor.kind) return false;
5788
+ if (prevAnchor.kind === "range" && nextAnchor.kind === "range") {
5789
+ return (
5790
+ prevAnchor.range.from === nextAnchor.range.from &&
5791
+ prevAnchor.range.to === nextAnchor.range.to
5792
+ );
5793
+ }
5794
+ if (prevAnchor.kind === "node" && nextAnchor.kind === "node") {
5795
+ return prevAnchor.at === nextAnchor.at;
5796
+ }
5797
+ if (prevAnchor.kind === "detached" && nextAnchor.kind === "detached") {
5798
+ return prevAnchor.reason === nextAnchor.reason;
5799
+ }
5800
+ return false;
5801
+ }
5802
+
5100
5803
  function toPublicCompatibilityReport(
5101
5804
  report: InternalCompatibilityReport,
5102
5805
  ): CompatibilityReport {
@@ -5124,11 +5827,42 @@ function toPublicCompatibilityFeatureEntry(
5124
5827
  }
5125
5828
 
5126
5829
  function toPublicWarning(warning: InternalEditorWarning): EditorWarning {
5830
+ const diagnostic =
5831
+ warning.diagnostic ??
5832
+ (() => {
5833
+ switch (warning.code) {
5834
+ case "unsupported_ooxml_preserved":
5835
+ case "unsupported_ooxml_locked":
5836
+ case "import_normalized":
5837
+ case "export_roundtrip_risk":
5838
+ case "comment_anchor_detached":
5839
+ case "revision_anchor_detached":
5840
+ case "workflow_scope_invalidated":
5841
+ case "large_document_degraded":
5842
+ case "font_substitution":
5843
+ case "image_missing":
5844
+ return buildDiagnosticFromLegacyWarningCode(warning.code, {
5845
+ diagnosticId: `warning-diag:${warning.warningId}`,
5846
+ emittedAt: new Date(0).toISOString(),
5847
+ technical: {
5848
+ message: warning.message,
5849
+ source: warning.source,
5850
+ },
5851
+ details: warning.details,
5852
+ affectedAnchor: warning.affectedAnchor
5853
+ ? toPublicAnchorProjection(warning.affectedAnchor)
5854
+ : undefined,
5855
+ });
5856
+ default:
5857
+ return undefined;
5858
+ }
5859
+ })();
5127
5860
  return {
5128
5861
  ...warning,
5129
5862
  affectedAnchor: warning.affectedAnchor
5130
5863
  ? toPublicAnchorProjection(warning.affectedAnchor)
5131
5864
  : undefined,
5865
+ diagnostic,
5132
5866
  };
5133
5867
  }
5134
5868
 
@@ -5510,6 +6244,22 @@ function isRecord(value: unknown): value is Record<string, unknown> {
5510
6244
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
5511
6245
  }
5512
6246
 
6247
+ function makePublicSelectionSnapshot(anchor: number, head: number): SelectionSnapshot {
6248
+ const from = Math.min(anchor, head);
6249
+ const to = Math.max(anchor, head);
6250
+ return {
6251
+ anchor,
6252
+ head,
6253
+ isCollapsed: anchor === head,
6254
+ activeRange: {
6255
+ kind: "range",
6256
+ from,
6257
+ to,
6258
+ assoc: { start: -1, end: 1 },
6259
+ },
6260
+ };
6261
+ }
6262
+
5513
6263
  /** Commands that are safe in viewing mode (no document mutation). */
5514
6264
  const NON_MUTATION_COMMANDS = new Set([
5515
6265
  "selection.set",
@@ -5526,6 +6276,9 @@ const NON_MUTATION_COMMANDS = new Set([
5526
6276
  "workflow.clear-metadata-entries",
5527
6277
  "host-annotation.set-overlay",
5528
6278
  "host-annotation.clear-overlay",
6279
+ // API-level full-document replacement (scope markers, protection refresh,
6280
+ // collab replay). Not user-initiated — must bypass the workflow guard.
6281
+ "document.replace",
5529
6282
  ]);
5530
6283
 
5531
6284
  /** Mutation commands that are not yet supported in suggesting mode. */
@@ -6626,6 +7379,17 @@ const fontLoaderInputCache = new WeakMap<
6626
7379
  WeakMap<object, { families: readonly string[] }>
6627
7380
  >();
6628
7381
 
7382
+ function fontFamiliesEqual(
7383
+ a: readonly string[],
7384
+ b: readonly string[],
7385
+ ): boolean {
7386
+ if (a.length !== b.length) return false;
7387
+ // Both arrays are Set-derived (no duplicates); sort for order-independence.
7388
+ const sortedA = [...a].sort();
7389
+ const sortedB = [...b].sort();
7390
+ return sortedA.every((v, i) => v === sortedB[i]);
7391
+ }
7392
+
6629
7393
  function collectFontLoaderInput(
6630
7394
  document: CanonicalDocumentEnvelope,
6631
7395
  ): { families: readonly string[] } {
@@ -6678,6 +7442,9 @@ function collectFontLoaderInputUncached(
6678
7442
  /** Test-only export of the uncached walk so memoization tests can spy on it. */
6679
7443
  export const __collectFontLoaderInputUncached = collectFontLoaderInputUncached;
6680
7444
 
7445
+ /** Test-only export of the font-family set equality helper. */
7446
+ export const __fontFamiliesEqual = fontFamiliesEqual;
7447
+
6681
7448
  /**
6682
7449
  * Asynchronously upgrade the engine's measurement backend to canvas once
6683
7450
  * the platform supports it and fonts have resolved. Errors are swallowed