@beyondwork/docx-react-component 1.0.28 → 1.0.30

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 (92) hide show
  1. package/package.json +26 -37
  2. package/src/api/public-types.ts +531 -0
  3. package/src/api/session-state.ts +2 -0
  4. package/src/core/commands/index.ts +201 -79
  5. package/src/core/commands/table-structure-commands.ts +138 -5
  6. package/src/core/state/text-transaction.ts +370 -3
  7. package/src/index.ts +41 -0
  8. package/src/io/docx-session.ts +318 -25
  9. package/src/io/export/serialize-footnotes.ts +41 -46
  10. package/src/io/export/serialize-headers-footers.ts +36 -40
  11. package/src/io/export/serialize-main-document.ts +55 -89
  12. package/src/io/export/serialize-numbering.ts +104 -4
  13. package/src/io/export/serialize-runtime-revisions.ts +196 -2
  14. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +252 -0
  15. package/src/io/export/table-properties-xml.ts +318 -0
  16. package/src/io/normalize/normalize-text.ts +34 -3
  17. package/src/io/ooxml/parse-comments.ts +6 -0
  18. package/src/io/ooxml/parse-footnotes.ts +69 -13
  19. package/src/io/ooxml/parse-headers-footers.ts +54 -11
  20. package/src/io/ooxml/parse-main-document.ts +112 -42
  21. package/src/io/ooxml/parse-numbering.ts +341 -26
  22. package/src/io/ooxml/parse-revisions.ts +118 -4
  23. package/src/io/ooxml/parse-styles.ts +176 -0
  24. package/src/io/ooxml/parse-tables.ts +34 -25
  25. package/src/io/ooxml/revision-boundaries.ts +127 -3
  26. package/src/io/ooxml/workflow-payload.ts +544 -0
  27. package/src/model/canonical-document.ts +91 -1
  28. package/src/model/snapshot.ts +112 -1
  29. package/src/preservation/store.ts +73 -3
  30. package/src/review/store/comment-store.ts +19 -1
  31. package/src/review/store/revision-actions.ts +29 -0
  32. package/src/review/store/revision-store.ts +12 -1
  33. package/src/review/store/revision-types.ts +11 -0
  34. package/src/runtime/context-analytics.ts +824 -0
  35. package/src/runtime/document-locations.ts +521 -0
  36. package/src/runtime/document-navigation.ts +14 -1
  37. package/src/runtime/document-outline.ts +440 -0
  38. package/src/runtime/document-runtime.ts +941 -45
  39. package/src/runtime/event-refresh-hints.ts +137 -0
  40. package/src/runtime/numbering-prefix.ts +67 -39
  41. package/src/runtime/page-layout-estimation.ts +100 -7
  42. package/src/runtime/resolved-numbering-geometry.ts +293 -0
  43. package/src/runtime/session-capabilities.ts +2 -2
  44. package/src/runtime/suggestions-snapshot.ts +137 -0
  45. package/src/runtime/surface-projection.ts +223 -27
  46. package/src/runtime/table-style-resolver.ts +409 -0
  47. package/src/runtime/view-state.ts +17 -1
  48. package/src/runtime/workflow-markup.ts +54 -14
  49. package/src/ui/WordReviewEditor.tsx +1269 -87
  50. package/src/ui/editor-command-bag.ts +7 -0
  51. package/src/ui/editor-runtime-boundary.ts +111 -10
  52. package/src/ui/editor-shell-view.tsx +17 -15
  53. package/src/ui/editor-surface-controller.tsx +5 -0
  54. package/src/ui/headless/selection-tool-context.ts +19 -0
  55. package/src/ui/headless/selection-tool-resolver.ts +752 -0
  56. package/src/ui/headless/selection-tool-types.ts +129 -0
  57. package/src/ui/headless/selection-toolbar-model.ts +10 -33
  58. package/src/ui/runtime-shortcut-dispatch.ts +365 -0
  59. package/src/ui-tailwind/chrome/chrome-preset-model.ts +107 -0
  60. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +15 -0
  61. package/src/ui-tailwind/chrome/review-queue-bar.tsx +97 -0
  62. package/src/ui-tailwind/chrome/tw-context-analytics-summary.tsx +122 -0
  63. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +1 -9
  64. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +1 -5
  65. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +8 -29
  66. package/src/ui-tailwind/chrome/tw-selection-tool-blocked.tsx +23 -0
  67. package/src/ui-tailwind/chrome/tw-selection-tool-comment.tsx +35 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +37 -0
  69. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +298 -0
  70. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +116 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-suggestion.tsx +29 -0
  72. package/src/ui-tailwind/chrome/tw-selection-tool-workflow.tsx +27 -0
  73. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +3 -3
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +3 -3
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +86 -14
  76. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +57 -52
  77. package/src/ui-tailwind/editor-surface/pm-decorations.ts +36 -52
  78. package/src/ui-tailwind/editor-surface/pm-schema.ts +56 -5
  79. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +87 -24
  80. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +4 -0
  81. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +135 -32
  82. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +74 -7
  83. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +17 -17
  84. package/src/ui-tailwind/review/tw-review-rail.tsx +19 -17
  85. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +10 -10
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +10 -6
  87. package/src/ui-tailwind/theme/editor-theme.css +58 -40
  88. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -4
  89. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +250 -181
  90. package/src/ui-tailwind/tw-review-workspace.tsx +323 -280
  91. package/src/validation/compatibility-engine.ts +246 -2
  92. package/src/validation/docx-comment-proof.ts +24 -11
@@ -17,8 +17,13 @@ import type {
17
17
  CommentSidebarSnapshot,
18
18
  CommentSidebarThreadSnapshot,
19
19
  CompatibilityReport,
20
+ DocumentChunkSnapshot,
21
+ DocumentLocationSnapshot,
20
22
  DocumentMode,
21
23
  DocumentNavigationSnapshot,
24
+ DocumentOutlineSnapshot,
25
+ DocumentSectionSnapshot,
26
+ DocumentTextToken,
22
27
  EditorSessionState,
23
28
  EditorAnchorProjection,
24
29
  EditorError,
@@ -36,8 +41,19 @@ import type {
36
41
  PageLayoutSnapshot,
37
42
  PersistedEditorSnapshot,
38
43
  ProtectionSnapshot,
44
+ RestorePointSnapshot,
45
+ RestoreResult,
46
+ ReviewWorkSnapshot,
47
+ RuntimeContextAnalyticsQuery,
48
+ RuntimeContextAnalyticsSnapshot,
39
49
  RuntimeRenderSnapshot,
40
50
  SelectionSnapshot,
51
+ SnapshotRefreshHints,
52
+ SuggestionsSnapshot,
53
+ SurfaceBlockSnapshot,
54
+ SurfaceInlineSegment,
55
+ StoryTextStreamSnapshot,
56
+ TocSnapshot,
41
57
  StyleCatalogSnapshot,
42
58
  TocRefreshOptions,
43
59
  TocRefreshResult,
@@ -49,6 +65,9 @@ import type {
49
65
  WorkflowCandidateRange,
50
66
  WorkflowCandidateRangeOptions,
51
67
  WorkflowBlockedCommandReason,
68
+ WorkflowMetadataDefinition,
69
+ WorkflowMetadataEntry,
70
+ WorkflowMetadataSnapshot,
52
71
  WorkflowMarkupSnapshot,
53
72
  WorkflowOverlay,
54
73
  WorkflowScopeSnapshot,
@@ -82,6 +101,7 @@ import { buildBookmarkNameMap } from "../legal/bookmarks.ts";
82
101
  import {
83
102
  describeOpaqueFragment,
84
103
  findOpaqueFragmentsIntersectingRange,
104
+ isBlockedImportFeatureKey,
85
105
  } from "../preservation/store.ts";
86
106
  import { createCommentSidebarProjection } from "../review/store/comment-store.ts";
87
107
  import { createCommentStoreFromRuntimeComments } from "../review/store/runtime-comment-store.ts";
@@ -89,6 +109,7 @@ import {
89
109
  createRevisionSidebarProjection,
90
110
  type RevisionStore,
91
111
  } from "../review/store/revision-store.ts";
112
+ import { createSuggestionsSnapshot } from "./suggestions-snapshot.ts";
92
113
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
93
114
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
94
115
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
@@ -100,6 +121,29 @@ import {
100
121
  createDocumentNavigationSnapshot,
101
122
  findPageForOffset,
102
123
  } from "./document-navigation.ts";
124
+ import {
125
+ createDocumentOutlineSnapshot,
126
+ createDocumentSectionSnapshots,
127
+ createSectionLocations,
128
+ createTocSnapshot,
129
+ findDocumentSectionSnapshot,
130
+ } from "./document-outline.ts";
131
+ import {
132
+ createCurrentLocation,
133
+ createDocumentChunks,
134
+ createDocumentLocationSnapshot,
135
+ createDocumentTextStreamSnapshots,
136
+ createLocationFromSelection,
137
+ createRestorePoint,
138
+ createReviewWorkSnapshot,
139
+ createWorkflowChunks,
140
+ } from "./document-locations.ts";
141
+ import { describeEventImpact } from "./event-refresh-hints.ts";
142
+ import {
143
+ createRuntimeContextAnalyticsSnapshot,
144
+ resolveCurrentContextAnalyticsQuery,
145
+ runtimeContextAnalyticsSnapshotsEqual,
146
+ } from "./context-analytics.ts";
103
147
  import {
104
148
  buildPageLayoutSnapshot,
105
149
  buildResolvedSections,
@@ -177,6 +221,7 @@ export interface DocumentRuntime {
177
221
  focus(): void;
178
222
  blur(): void;
179
223
  setDefaultAuthorId?(authorId?: string): void;
224
+ getDefaultAuthorId?(): string | undefined;
180
225
  addComment(params: AddCommentParams): string;
181
226
  openComment(commentId: string): void;
182
227
  resolveComment(commentId: string): void;
@@ -198,6 +243,28 @@ export interface DocumentRuntime {
198
243
  setZoom(level: ZoomLevel): void;
199
244
  getPageLayoutSnapshot(): PageLayoutSnapshot | null;
200
245
  getDocumentNavigationSnapshot(): DocumentNavigationSnapshot;
246
+ getCurrentLocation(): DocumentLocationSnapshot | null;
247
+ getLocationForSelection(selection: SelectionSnapshot): DocumentLocationSnapshot | null;
248
+ getLocationForAnchor(
249
+ anchor: EditorAnchorProjection,
250
+ storyTarget?: EditorStoryTarget,
251
+ ): DocumentLocationSnapshot | null;
252
+ captureRestorePoint(
253
+ input?: SelectionSnapshot | EditorAnchorProjection,
254
+ ): RestorePointSnapshot | null;
255
+ restoreToPoint(
256
+ point: RestorePointSnapshot,
257
+ options?: { behavior?: "exact" | "semantic"; scroll?: boolean },
258
+ ): RestoreResult;
259
+ getOutlineSnapshot(): DocumentOutlineSnapshot;
260
+ getTocSnapshot(): TocSnapshot | null;
261
+ getSections(): DocumentSectionSnapshot[];
262
+ getSectionSnapshot(input: {
263
+ sectionIndex?: number;
264
+ headingId?: string;
265
+ bookmarkName?: string;
266
+ }): DocumentSectionSnapshot | null;
267
+ describeEventImpact(event: WordReviewEditorEvent): SnapshotRefreshHints;
201
268
  getFieldSnapshot(): FieldSnapshot;
202
269
  updateFields(options?: UpdateFieldsOptions): UpdateFieldsResult;
203
270
  updateTableOfContents(options?: TocRefreshOptions): TocRefreshResult;
@@ -206,16 +273,30 @@ export interface DocumentRuntime {
206
273
  getCompatibilityReport(): CompatibilityReport;
207
274
  getWarnings(): EditorWarning[];
208
275
  exportDocx(options?: ExportDocxOptions): Promise<ExportResult>;
276
+ getSuggestionsSnapshot(): SuggestionsSnapshot;
209
277
  setWorkflowOverlay(overlay: WorkflowOverlay): void;
210
278
  clearWorkflowOverlay(): void;
211
279
  getWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null;
212
280
  getInteractionGuardSnapshot(): InteractionGuardSnapshot;
213
281
  getWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot;
282
+ setWorkflowMetadataDefinitions(definitions: WorkflowMetadataDefinition[]): void;
283
+ clearWorkflowMetadataDefinitions(): void;
284
+ setWorkflowMetadataEntries(entries: WorkflowMetadataEntry[]): void;
285
+ clearWorkflowMetadataEntries(): void;
286
+ getWorkflowMetadataSnapshot(): WorkflowMetadataSnapshot;
214
287
  setHostAnnotationOverlay(overlay: HostAnnotationOverlay): void;
215
288
  clearHostAnnotationOverlay(): void;
216
289
  getHostAnnotationSnapshot(): HostAnnotationSnapshot;
217
290
  getWorkflowCandidateRanges(options?: WorkflowCandidateRangeOptions): WorkflowCandidateRange[];
218
291
  replaceWorkflowMarkupText(markupId: string, text: string): void;
292
+ getDocumentTextStream(): StoryTextStreamSnapshot[];
293
+ getStoryTextStream(target: EditorStoryTarget): StoryTextStreamSnapshot | null;
294
+ getDocumentChunks(): DocumentChunkSnapshot[];
295
+ getWorkflowChunks(): DocumentChunkSnapshot[];
296
+ getReviewWorkSnapshot(): ReviewWorkSnapshot;
297
+ getRuntimeContextAnalytics(
298
+ query?: RuntimeContextAnalyticsQuery,
299
+ ): RuntimeContextAnalyticsSnapshot | null;
219
300
  }
220
301
 
221
302
  export interface CreateDocumentRuntimeOptions {
@@ -274,6 +355,14 @@ export function createDocumentRuntime(
274
355
  preservedRangeCount: 0,
275
356
  };
276
357
  let workflowOverlay: WorkflowOverlay | null = null;
358
+ let workflowMetadataDefinitions: WorkflowMetadataDefinition[] =
359
+ options.initialSessionState?.workflowMetadata?.definitions
360
+ ?? options.initialSnapshot?.workflowMetadata?.definitions
361
+ ?? [];
362
+ let workflowMetadataEntries: WorkflowMetadataEntry[] =
363
+ options.initialSessionState?.workflowMetadata?.entries
364
+ ?? options.initialSnapshot?.workflowMetadata?.entries
365
+ ?? [];
277
366
  let hostAnnotationOverlay: HostAnnotationOverlay | null = null;
278
367
  const initialPersistedSnapshot = options.initialSessionState
279
368
  ? persistedSnapshotFromEditorSessionState(options.initialSessionState, {
@@ -320,6 +409,12 @@ export function createDocumentRuntime(
320
409
  snapshot: TrackedChangesSnapshot;
321
410
  }
322
411
  | undefined;
412
+ let cachedSuggestions:
413
+ | {
414
+ trackedChanges: TrackedChangesSnapshot;
415
+ snapshot: SuggestionsSnapshot;
416
+ }
417
+ | undefined;
323
418
  let cachedPageLayout:
324
419
  | {
325
420
  revisionToken: string;
@@ -371,9 +466,30 @@ export function createDocumentRuntime(
371
466
  activeStoryKey: string;
372
467
  protectionSnapshot: ProtectionSnapshot;
373
468
  preservation: CanonicalDocumentEnvelope["preservation"];
469
+ workflowOverlay: WorkflowOverlay | null;
470
+ workflowMetadataDefinitions: WorkflowMetadataDefinition[];
471
+ workflowMetadataEntries: WorkflowMetadataEntry[];
374
472
  snapshot: WorkflowMarkupSnapshot;
375
473
  }
376
474
  | undefined;
475
+ const cachedContextAnalyticsSnapshots = new Map<
476
+ string,
477
+ {
478
+ revisionToken: string;
479
+ activeStoryKey: string;
480
+ selection: EditorState["selection"];
481
+ readOnly: boolean;
482
+ documentMode: DocumentMode;
483
+ workflowOverlay: WorkflowOverlay | null;
484
+ protectionSnapshot: ProtectionSnapshot;
485
+ warnings: EditorState["warnings"];
486
+ fatalError: EditorState["fatalError"];
487
+ snapshot: RuntimeContextAnalyticsSnapshot | null;
488
+ }
489
+ >();
490
+ let lastEmittedContextAnalyticsSnapshots:
491
+ | Map<string, RuntimeContextAnalyticsSnapshot | null>
492
+ | undefined;
377
493
 
378
494
  function getCachedSurface(
379
495
  document: CanonicalDocumentEnvelope,
@@ -471,6 +587,30 @@ export function createDocumentRuntime(
471
587
  return snapshot;
472
588
  }
473
589
 
590
+ function getCachedSuggestionsSnapshot(nextState: EditorState): SuggestionsSnapshot {
591
+ const trackedChanges = getCachedTrackedChangesSnapshot(nextState, undefined);
592
+ if (
593
+ cachedSuggestions &&
594
+ cachedSuggestions.trackedChanges === trackedChanges
595
+ ) {
596
+ return cachedSuggestions.snapshot;
597
+ }
598
+
599
+ const snapshot = createSuggestionsSnapshot(trackedChanges);
600
+ cachedSuggestions = {
601
+ trackedChanges,
602
+ snapshot,
603
+ };
604
+ return snapshot;
605
+ }
606
+
607
+ function findSuggestionByChangeId(
608
+ snapshot: SuggestionsSnapshot,
609
+ changeId: string,
610
+ ) {
611
+ return snapshot.suggestions.find((suggestion) => suggestion.changeIds.includes(changeId));
612
+ }
613
+
474
614
  function getCachedDocumentNavigationSnapshot(
475
615
  nextState: EditorState,
476
616
  nextActiveStory: EditorStoryTarget,
@@ -612,15 +752,6 @@ export function createDocumentRuntime(
612
752
  const effectiveDocumentMode = getEffectiveDocumentMode(selection);
613
753
 
614
754
  if (effectiveDocumentMode === "suggesting" && commandType) {
615
- if (
616
- activeStory.kind !== "main" &&
617
- SUGGESTING_SECONDARY_STORY_UNSUPPORTED_COMMANDS.has(commandType)
618
- ) {
619
- reasons.push({
620
- code: "suggesting_unsupported",
621
- message: "Suggesting mode is not yet export-safe in this story.",
622
- });
623
- }
624
755
  if (SUGGESTING_UNSUPPORTED_COMMANDS.has(commandType)) {
625
756
  reasons.push({
626
757
  code: "suggesting_unsupported",
@@ -708,6 +839,11 @@ export function createDocumentRuntime(
708
839
  function deriveOpaqueWorkflowBlockedReason(
709
840
  range: { from: number; to: number },
710
841
  ): WorkflowBlockedCommandReason | null {
842
+ const surfaceReason = deriveSurfaceOpaqueWorkflowBlockedReason(range);
843
+ if (surfaceReason) {
844
+ return surfaceReason;
845
+ }
846
+
711
847
  const targetPartPath = getStoryTargetOpaquePartPath(activeStory);
712
848
  if (!targetPartPath) {
713
849
  return null;
@@ -721,14 +857,9 @@ export function createDocumentRuntime(
721
857
  return null;
722
858
  }
723
859
 
724
- const blockedImportFeatureKeys = new Set([
725
- "alt-chunk",
726
- "alternate-content",
727
- "custom-xml",
728
- ]);
729
860
  const blockedImportFragment =
730
861
  fragments.find((fragment) =>
731
- blockedImportFeatureKeys.has(describeOpaqueFragment(fragment).featureKey),
862
+ isBlockedImportFeatureKey(describeOpaqueFragment(fragment).featureKey),
732
863
  ) ?? null;
733
864
  const fragment = blockedImportFragment ?? fragments[0]!;
734
865
  const descriptor = describeOpaqueFragment(fragment);
@@ -749,6 +880,149 @@ export function createDocumentRuntime(
749
880
  };
750
881
  }
751
882
 
883
+ function deriveSurfaceOpaqueWorkflowBlockedReason(
884
+ range: { from: number; to: number },
885
+ ): WorkflowBlockedCommandReason | null {
886
+ const blocks = getActiveStorySurfaceBlocks();
887
+ if (!blocks) {
888
+ return null;
889
+ }
890
+
891
+ const opaqueTarget = findSurfaceOpaqueTargetIntersectingRange(blocks, range);
892
+ if (!opaqueTarget) {
893
+ return null;
894
+ }
895
+
896
+ const code = opaqueTarget.blockedReasonCode ?? "workflow_preserve_only";
897
+ return {
898
+ code,
899
+ message:
900
+ code === "workflow_blocked_import"
901
+ ? `${opaqueTarget.label} remains a blocked import and cannot be edited.`
902
+ : `${opaqueTarget.label} remains preserve-only and cannot be edited.`,
903
+ anchor: toPublicAnchorProjection(
904
+ createRangeAnchor(opaqueTarget.from, opaqueTarget.to, {
905
+ start: -1,
906
+ end: 1,
907
+ }),
908
+ ),
909
+ storyTarget: activeStory,
910
+ };
911
+ }
912
+
913
+ function getActiveStorySurfaceBlocks(): readonly SurfaceBlockSnapshot[] | null {
914
+ const surface = cachedRenderSnapshot.surface;
915
+ if (!surface) {
916
+ return null;
917
+ }
918
+
919
+ if (activeStory.kind === "main") {
920
+ return surface.blocks;
921
+ }
922
+
923
+ const activeStoryKey = storyTargetKey(activeStory);
924
+ return (
925
+ surface.secondaryStories.find((story) => storyTargetKey(story.target) === activeStoryKey)?.blocks ??
926
+ null
927
+ );
928
+ }
929
+
930
+ function findSurfaceOpaqueTargetIntersectingRange(
931
+ blocks: readonly SurfaceBlockSnapshot[],
932
+ range: { from: number; to: number },
933
+ ): {
934
+ from: number;
935
+ to: number;
936
+ label: string;
937
+ blockedReasonCode: "workflow_preserve_only" | "workflow_blocked_import";
938
+ } | null {
939
+ for (const block of blocks) {
940
+ if (block.kind === "paragraph") {
941
+ for (const segment of block.segments) {
942
+ const match = matchOpaqueInlineSegment(segment, range);
943
+ if (match) {
944
+ return match;
945
+ }
946
+ }
947
+ continue;
948
+ }
949
+
950
+ if (block.kind === "table") {
951
+ for (const row of block.rows) {
952
+ for (const cell of row.cells) {
953
+ const match = findSurfaceOpaqueTargetIntersectingRange(cell.content, range);
954
+ if (match) {
955
+ return match;
956
+ }
957
+ }
958
+ }
959
+ continue;
960
+ }
961
+
962
+ if (block.kind === "sdt_block") {
963
+ const match = findSurfaceOpaqueTargetIntersectingRange(block.children, range);
964
+ if (match) {
965
+ return match;
966
+ }
967
+ continue;
968
+ }
969
+
970
+ if (block.kind !== "opaque_block") {
971
+ continue;
972
+ }
973
+
974
+ const blockRange = {
975
+ from: block.from,
976
+ to: block.to > block.from ? block.to : block.from + 1,
977
+ };
978
+ if (rangesIntersect(blockRange, range)) {
979
+ if (!block.blockedReasonCode) {
980
+ continue;
981
+ }
982
+ return {
983
+ from: blockRange.from,
984
+ to: blockRange.to,
985
+ label: block.label,
986
+ blockedReasonCode: block.blockedReasonCode,
987
+ };
988
+ }
989
+ }
990
+
991
+ return null;
992
+ }
993
+
994
+ function matchOpaqueInlineSegment(
995
+ segment: SurfaceInlineSegment,
996
+ range: { from: number; to: number },
997
+ ): {
998
+ from: number;
999
+ to: number;
1000
+ label: string;
1001
+ blockedReasonCode: "workflow_preserve_only" | "workflow_blocked_import";
1002
+ } | null {
1003
+ if (segment.kind !== "opaque_inline") {
1004
+ return null;
1005
+ }
1006
+ if (!segment.blockedReasonCode) {
1007
+ return null;
1008
+ }
1009
+
1010
+ const segmentRange = {
1011
+ from: segment.from,
1012
+ to: segment.to > segment.from ? segment.to : segment.from + 1,
1013
+ };
1014
+ if (!rangesIntersect(segmentRange, range)) {
1015
+ return null;
1016
+ }
1017
+
1018
+ return {
1019
+ from: segmentRange.from,
1020
+ to: segmentRange.to,
1021
+ label: segment.label,
1022
+ blockedReasonCode: segment.blockedReasonCode,
1023
+ };
1024
+ }
1025
+
752
1026
  function getStoryTargetOpaquePartPath(storyTarget: EditorStoryTarget): string | null {
753
1027
  if (storyTarget.kind === "main") {
754
1028
  return "/word/document.xml";
@@ -778,6 +1052,13 @@ export function createDocumentRuntime(
778
1052
  return null;
779
1053
  }
780
1054
 
1055
+ function rangesIntersect(
1056
+ left: { from: number; to: number },
1057
+ right: { from: number; to: number },
1058
+ ): boolean {
1059
+ return left.from < right.to && right.from < left.to;
1060
+ }
1061
+
781
1062
  function deriveWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null {
782
1063
  if (!workflowOverlay) return null;
783
1064
  const blockedReasons = getCachedInteractionGuardSnapshot().blockedReasons;
@@ -803,6 +1084,13 @@ export function createDocumentRuntime(
803
1084
  };
804
1085
  }
805
1086
 
1087
+ function deriveWorkflowMetadataSnapshot(): WorkflowMetadataSnapshot {
1088
+ return {
1089
+ definitions: structuredClone(workflowMetadataDefinitions),
1090
+ entries: structuredClone(workflowMetadataEntries),
1091
+ };
1092
+ }
1093
+
806
1094
  function getEffectiveWorkflowScopes(overlay: WorkflowOverlay): WorkflowOverlay["scopes"] {
807
1095
  const activeWorkItemId = overlay.activeWorkItemId ?? null;
808
1096
  const activeWorkItemScopeIds =
@@ -847,20 +1135,48 @@ export function createDocumentRuntime(
847
1135
  const blockedReasons = evaluateWorkflowBlockedReasons(state.selection);
848
1136
  const matchingScope = getMatchingWorkflowScope(state.selection);
849
1137
  const primaryBlockedReason = blockedReasons[0];
1138
+ const effectiveMode = primaryBlockedReason
1139
+ ? (
1140
+ primaryBlockedReason.code === "workflow_comment_only"
1141
+ ? "comment"
1142
+ : primaryBlockedReason.code === "workflow_view_only"
1143
+ ? "view"
1144
+ : "blocked"
1145
+ )
1146
+ : getEffectiveDocumentMode(state.selection) === "suggesting"
1147
+ ? "suggest"
1148
+ : matchingScope?.mode ?? "edit";
850
1149
  const snapshot: InteractionGuardSnapshot = {
851
- effectiveMode: primaryBlockedReason
852
- ? (
853
- primaryBlockedReason.code === "workflow_comment_only"
854
- ? "comment"
855
- : primaryBlockedReason.code === "workflow_view_only"
856
- ? "view"
857
- : "blocked"
858
- )
859
- : getEffectiveDocumentMode(state.selection) === "suggesting"
860
- ? "suggest"
861
- : matchingScope?.mode ?? "edit",
1150
+ effectiveMode,
862
1151
  ...(matchingScope?.scopeId ? { matchedScopeId: matchingScope.scopeId } : {}),
863
1152
  ...(matchingScope?.mode ? { matchedScopeMode: matchingScope.mode } : {}),
1153
+ targetAccess:
1154
+ effectiveMode === "edit"
1155
+ ? "direct-edit"
1156
+ : effectiveMode === "suggest"
1157
+ ? "suggest"
1158
+ : effectiveMode === "comment"
1159
+ ? "comment-only"
1160
+ : effectiveMode === "view"
1161
+ ? "view-only"
1162
+ : "blocked",
1163
+ commandCapabilities: [
1164
+ {
1165
+ family: "text",
1166
+ supported: evaluateWorkflowBlockedReasons(state.selection, "text.insert").length === 0,
1167
+ blockedReasons: evaluateWorkflowBlockedReasons(state.selection, "text.insert"),
1168
+ },
1169
+ {
1170
+ family: "formatting",
1171
+ supported: evaluateWorkflowBlockedReasons(state.selection, "toggleBold").length === 0,
1172
+ blockedReasons: evaluateWorkflowBlockedReasons(state.selection, "toggleBold"),
1173
+ },
1174
+ {
1175
+ family: "structure",
1176
+ supported: evaluateWorkflowBlockedReasons(state.selection, "insertTable").length === 0,
1177
+ blockedReasons: evaluateWorkflowBlockedReasons(state.selection, "insertTable"),
1178
+ },
1179
+ ],
864
1180
  ...(primaryBlockedReason ? { disabledReason: primaryBlockedReason.message } : {}),
865
1181
  blockedReasons,
866
1182
  };
@@ -907,7 +1223,10 @@ export function createDocumentRuntime(
907
1223
  cachedWorkflowMarkupSnapshot.revisionToken === state.revisionToken &&
908
1224
  cachedWorkflowMarkupSnapshot.activeStoryKey === activeStoryKey &&
909
1225
  cachedWorkflowMarkupSnapshot.protectionSnapshot === protectionSnapshot &&
910
- cachedWorkflowMarkupSnapshot.preservation === state.document.preservation
1226
+ cachedWorkflowMarkupSnapshot.preservation === state.document.preservation &&
1227
+ cachedWorkflowMarkupSnapshot.workflowOverlay === workflowOverlay &&
1228
+ cachedWorkflowMarkupSnapshot.workflowMetadataDefinitions === workflowMetadataDefinitions &&
1229
+ cachedWorkflowMarkupSnapshot.workflowMetadataEntries === workflowMetadataEntries
911
1230
  ) {
912
1231
  return cachedWorkflowMarkupSnapshot.snapshot;
913
1232
  }
@@ -917,17 +1236,150 @@ export function createDocumentRuntime(
917
1236
  fieldSnapshot: buildFieldSnapshot(state.document),
918
1237
  protectionSnapshot,
919
1238
  preservation: state.document.preservation,
1239
+ workflowMetadataSnapshot: deriveWorkflowMetadataSnapshot(),
920
1240
  });
921
1241
  cachedWorkflowMarkupSnapshot = {
922
1242
  revisionToken: state.revisionToken,
923
1243
  activeStoryKey,
924
1244
  protectionSnapshot,
925
1245
  preservation: state.document.preservation,
1246
+ workflowOverlay,
1247
+ workflowMetadataDefinitions,
1248
+ workflowMetadataEntries,
926
1249
  snapshot,
927
1250
  };
928
1251
  return snapshot;
929
1252
  }
930
1253
 
1254
+ function getCachedRuntimeContextAnalytics(
1255
+ query?: RuntimeContextAnalyticsQuery,
1256
+ ): RuntimeContextAnalyticsSnapshot | null {
1257
+ const activeStoryKey = storyTargetKey(activeStory);
1258
+ const queryKey = getRuntimeContextAnalyticsQueryKey(query);
1259
+ const cachedEntry = cachedContextAnalyticsSnapshots.get(queryKey);
1260
+ if (
1261
+ cachedEntry &&
1262
+ cachedEntry.revisionToken === state.revisionToken &&
1263
+ cachedEntry.activeStoryKey === activeStoryKey &&
1264
+ cachedEntry.selection === state.selection &&
1265
+ cachedEntry.readOnly === state.readOnly &&
1266
+ cachedEntry.documentMode === viewState.documentMode &&
1267
+ cachedEntry.workflowOverlay === workflowOverlay &&
1268
+ cachedEntry.protectionSnapshot === protectionSnapshot &&
1269
+ cachedEntry.warnings === state.warnings &&
1270
+ cachedEntry.fatalError === state.fatalError
1271
+ ) {
1272
+ return cachedEntry.snapshot;
1273
+ }
1274
+
1275
+ const snapshot = createRuntimeContextAnalyticsSnapshot({
1276
+ query,
1277
+ renderSnapshot: cachedRenderSnapshot,
1278
+ workflowOverlay,
1279
+ workflowScopeSnapshot: getCachedWorkflowScopeSnapshot(),
1280
+ interactionGuardSnapshot: getCachedInteractionGuardSnapshot(),
1281
+ workflowMarkupSnapshot: getCachedWorkflowMarkupSnapshot(),
1282
+ suggestionsSnapshot: getCachedSuggestionsSnapshot(state),
1283
+ reviewWorkSnapshot: createReviewWorkSnapshot({
1284
+ comments: cachedRenderSnapshot.comments,
1285
+ trackedChanges: cachedRenderSnapshot.trackedChanges,
1286
+ workflowMarkup: getCachedWorkflowMarkupSnapshot(),
1287
+ document: state.document,
1288
+ navigation: getCachedDocumentNavigationSnapshot(state, activeStory),
1289
+ }),
1290
+ warnings: state.warnings.map(toPublicWarning),
1291
+ compatibility: toPublicCompatibilityReport(createDerivedCompatibility(state)),
1292
+ });
1293
+ cachedContextAnalyticsSnapshots.set(queryKey, {
1294
+ revisionToken: state.revisionToken,
1295
+ activeStoryKey,
1296
+ selection: state.selection,
1297
+ readOnly: state.readOnly,
1298
+ documentMode: viewState.documentMode,
1299
+ workflowOverlay,
1300
+ protectionSnapshot,
1301
+ warnings: state.warnings,
1302
+ fatalError: state.fatalError,
1303
+ snapshot,
1304
+ });
1305
+ return snapshot;
1306
+ }
1307
+
1308
+ function getRuntimeContextAnalyticsQueryKey(query?: RuntimeContextAnalyticsQuery): string {
1309
+ return JSON.stringify({
1310
+ scopeKind: query?.scopeKind ?? "selection",
1311
+ scopeId: query?.scopeId ?? null,
1312
+ workItemId: query?.workItemId ?? null,
1313
+ });
1314
+ }
1315
+
1316
+ function getTrackedContextAnalyticsQueries(): Array<RuntimeContextAnalyticsQuery | undefined> {
1317
+ const queries: Array<RuntimeContextAnalyticsQuery | undefined> = [
1318
+ undefined,
1319
+ { scopeKind: "document" },
1320
+ ];
1321
+ const workflowScopeSnapshot = getCachedWorkflowScopeSnapshot();
1322
+ const seenScopeIds = new Set<string>();
1323
+ for (const scope of workflowScopeSnapshot?.scopes ?? []) {
1324
+ if (seenScopeIds.has(scope.scopeId)) {
1325
+ continue;
1326
+ }
1327
+ seenScopeIds.add(scope.scopeId);
1328
+ queries.push({
1329
+ scopeKind: "workflow_scope",
1330
+ scopeId: scope.scopeId,
1331
+ });
1332
+ }
1333
+ const seenWorkItemIds = new Set<string>();
1334
+ for (const workItem of workflowOverlay?.workItems ?? []) {
1335
+ if (seenWorkItemIds.has(workItem.workItemId)) {
1336
+ continue;
1337
+ }
1338
+ seenWorkItemIds.add(workItem.workItemId);
1339
+ queries.push({
1340
+ scopeKind: "work_item",
1341
+ workItemId: workItem.workItemId,
1342
+ });
1343
+ }
1344
+ const activeWorkItemId = workflowScopeSnapshot?.activeWorkItemId ?? null;
1345
+ if (activeWorkItemId && !seenWorkItemIds.has(activeWorkItemId)) {
1346
+ queries.push({
1347
+ scopeKind: "work_item",
1348
+ workItemId: activeWorkItemId,
1349
+ });
1350
+ }
1351
+ return queries;
1352
+ }
1353
+
1354
+ function collectTrackedContextAnalyticsSnapshots(): Map<string, RuntimeContextAnalyticsSnapshot | null> {
1355
+ const snapshots = new Map<string, RuntimeContextAnalyticsSnapshot | null>();
1356
+ for (const query of getTrackedContextAnalyticsQueries()) {
1357
+ snapshots.set(
1358
+ getRuntimeContextAnalyticsQueryKey(query),
1359
+ getCachedRuntimeContextAnalytics(query),
1360
+ );
1361
+ }
1362
+ return snapshots;
1363
+ }
1364
+
1365
+ function trackedContextAnalyticsSnapshotsEqual(
1366
+ left: Map<string, RuntimeContextAnalyticsSnapshot | null>,
1367
+ right: Map<string, RuntimeContextAnalyticsSnapshot | null>,
1368
+ ): boolean {
1369
+ if (left.size !== right.size) {
1370
+ return false;
1371
+ }
1372
+ for (const [key, snapshot] of left.entries()) {
1373
+ if (!right.has(key)) {
1374
+ return false;
1375
+ }
1376
+ if (!runtimeContextAnalyticsSnapshotsEqual(snapshot, right.get(key) ?? null)) {
1377
+ return false;
1378
+ }
1379
+ }
1380
+ return true;
1381
+ }
1382
+
931
1383
  function refreshRenderSnapshot(): RuntimeRenderSnapshot {
932
1384
  const surface = getCachedSurface(state.document, activeStory);
933
1385
  return {
@@ -1124,6 +1576,9 @@ export function createDocumentRuntime(
1124
1576
  setDefaultAuthorId(authorId) {
1125
1577
  defaultAuthorId = authorId;
1126
1578
  },
1579
+ getDefaultAuthorId() {
1580
+ return defaultAuthorId;
1581
+ },
1127
1582
  replaceText(text, target) {
1128
1583
  try {
1129
1584
  const timestamp = clock();
@@ -1151,7 +1606,18 @@ export function createDocumentRuntime(
1151
1606
  },
1152
1607
  addComment(params) {
1153
1608
  if (viewState.documentMode === "viewing") {
1154
- throw new Error("Cannot add comments in viewing mode.");
1609
+ const error: InternalEditorError = {
1610
+ errorId: createSessionId("comment-viewing-mode", clock()),
1611
+ code: "validation_failed",
1612
+ isFatal: false,
1613
+ message: "Cannot add comments in viewing mode.",
1614
+ source: "runtime",
1615
+ details: {
1616
+ reason: "viewing_mode",
1617
+ },
1618
+ };
1619
+ emitError(error);
1620
+ throw toStructuredRuntimeException(error);
1155
1621
  }
1156
1622
  const commentId = createEntityId("comment", state.document.review.comments, clock());
1157
1623
  const anchor = params.anchor
@@ -1161,16 +1627,19 @@ export function createDocumentRuntime(
1161
1627
  ? createSelectionFromPublicAnchor(params.anchor)
1162
1628
  : state.selection;
1163
1629
  if (!canCreateDocxCommentAnchor(state.document.content, anchor)) {
1164
- const message =
1165
- "DOCX comments must use a non-empty range that stays within a single paragraph.";
1166
- emitError({
1630
+ const error: InternalEditorError = {
1167
1631
  errorId: createSessionId("comment-anchor", clock()),
1168
1632
  code: "validation_failed",
1169
1633
  isFatal: false,
1170
- message,
1634
+ message:
1635
+ "DOCX comments must use a non-empty range that stays within a single paragraph.",
1171
1636
  source: "runtime",
1172
- });
1173
- throw new Error(message);
1637
+ details: {
1638
+ reason: "invalid_comment_anchor",
1639
+ },
1640
+ };
1641
+ emitError(error);
1642
+ throw toStructuredRuntimeException(error);
1174
1643
  }
1175
1644
  const authorId = params.authorId ?? defaultAuthorId ?? "unknown";
1176
1645
  const createdAt = clock();
@@ -1339,6 +1808,139 @@ export function createDocumentRuntime(
1339
1808
  getDocumentNavigationSnapshot() {
1340
1809
  return getCachedDocumentNavigationSnapshot(state, activeStory);
1341
1810
  },
1811
+ getCurrentLocation() {
1812
+ const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
1813
+ return createCurrentLocation({
1814
+ document: state.document,
1815
+ renderSnapshot: cachedRenderSnapshot,
1816
+ navigation,
1817
+ sections: createDocumentSectionSnapshots(state.document, navigation),
1818
+ });
1819
+ },
1820
+ getLocationForSelection(selection: SelectionSnapshot) {
1821
+ const selectionStory = selection.storyTarget ?? activeStory;
1822
+ const navigation = getCachedDocumentNavigationSnapshot(state, selectionStory);
1823
+ return createLocationFromSelection({
1824
+ document: state.document,
1825
+ navigation,
1826
+ sections: createDocumentSectionSnapshots(state.document, navigation),
1827
+ selection,
1828
+ });
1829
+ },
1830
+ getLocationForAnchor(anchor: EditorAnchorProjection, storyTarget?: EditorStoryTarget) {
1831
+ const resolvedStory = storyTarget ?? activeStory;
1832
+ const navigation = getCachedDocumentNavigationSnapshot(state, resolvedStory);
1833
+ return createDocumentLocationSnapshot({
1834
+ document: state.document,
1835
+ navigation,
1836
+ sections: createDocumentSectionSnapshots(state.document, navigation),
1837
+ anchor,
1838
+ storyTarget: resolvedStory,
1839
+ source: { kind: "navigation" },
1840
+ });
1841
+ },
1842
+ captureRestorePoint(input?: SelectionSnapshot | EditorAnchorProjection) {
1843
+ const location =
1844
+ input === undefined
1845
+ ? this.getCurrentLocation()
1846
+ : "activeRange" in input
1847
+ ? this.getLocationForSelection(input)
1848
+ : this.getLocationForAnchor(input, activeStory);
1849
+ if (!location) {
1850
+ return null;
1851
+ }
1852
+ return createRestorePoint({
1853
+ location,
1854
+ revisionToken: state.revisionToken,
1855
+ createdAt: clock(),
1856
+ checkpointType:
1857
+ input === undefined
1858
+ ? "selection"
1859
+ : "activeRange" in input
1860
+ ? "selection"
1861
+ : "manual",
1862
+ });
1863
+ },
1864
+ restoreToPoint(point, options) {
1865
+ if (point.location.anchor.kind === "detached") {
1866
+ return {
1867
+ status: "detached",
1868
+ reasons: [point.location.anchor.reason],
1869
+ location: point.location,
1870
+ };
1871
+ }
1872
+
1873
+ const behavior = options?.behavior ?? "semantic";
1874
+ if (behavior === "exact" && point.revisionToken !== state.revisionToken) {
1875
+ return {
1876
+ status: "blocked",
1877
+ reasons: [
1878
+ "restore point no longer matches the current revision; use semantic replay after content changes",
1879
+ ],
1880
+ location: point.location,
1881
+ };
1882
+ }
1883
+
1884
+ if (
1885
+ point.location.storyTarget &&
1886
+ !storyTargetsEqual(activeStory, point.location.storyTarget)
1887
+ ) {
1888
+ if (point.location.storyTarget.kind === "main") {
1889
+ this.closeStory();
1890
+ } else {
1891
+ this.openStory(point.location.storyTarget);
1892
+ }
1893
+ }
1894
+ const selection = createSelectionFromPublicAnchor(point.location.anchor);
1895
+ this.dispatch({
1896
+ type: "selection.set",
1897
+ selection,
1898
+ origin: createOrigin("api", clock()),
1899
+ });
1900
+ const location = this.getCurrentLocation() ?? point.location;
1901
+ return {
1902
+ status: point.revisionToken === state.revisionToken ? "restored" : "remapped",
1903
+ selection: cachedRenderSnapshot.selection,
1904
+ location,
1905
+ };
1906
+ },
1907
+ getOutlineSnapshot() {
1908
+ return createDocumentOutlineSnapshot({
1909
+ navigation: getCachedDocumentNavigationSnapshot(state, activeStory),
1910
+ activeStory,
1911
+ selectionHead: state.selection.head,
1912
+ });
1913
+ },
1914
+ getTocSnapshot() {
1915
+ return createTocSnapshot(
1916
+ state.document,
1917
+ getCachedDocumentNavigationSnapshot(state, activeStory),
1918
+ );
1919
+ },
1920
+ getSections() {
1921
+ return createDocumentSectionSnapshots(
1922
+ state.document,
1923
+ getCachedDocumentNavigationSnapshot(state, activeStory),
1924
+ );
1925
+ },
1926
+ getSectionSnapshot(input) {
1927
+ const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
1928
+ const sections = createDocumentSectionSnapshots(state.document, navigation);
1929
+ if (input.headingId) {
1930
+ const heading = navigation.headings.find((entry) => entry.headingId === input.headingId);
1931
+ if (heading) {
1932
+ return sections.find((section) => section.sectionIndex === heading.sectionIndex) ?? null;
1933
+ }
1934
+ }
1935
+ return findDocumentSectionSnapshot(
1936
+ sections,
1937
+ input,
1938
+ createSectionLocations(state.document, sections),
1939
+ );
1940
+ },
1941
+ describeEventImpact(event) {
1942
+ return describeEventImpact(event);
1943
+ },
1342
1944
  getFieldSnapshot() {
1343
1945
  return buildFieldSnapshot(state.document);
1344
1946
  },
@@ -1386,12 +1988,15 @@ export function createDocumentRuntime(
1386
1988
  getSessionState() {
1387
1989
  const compatibility = createDerivedCompatibility(state);
1388
1990
  return editorSessionStateFromPersistedSnapshot(
1389
- createPersistedEditorSnapshot(state, {
1390
- editorBuild,
1391
- savedAt: clock(),
1392
- compatibility,
1393
- protectionSnapshot,
1394
- }) as unknown as PersistedEditorSnapshot,
1991
+ {
1992
+ ...(createPersistedEditorSnapshot(state, {
1993
+ editorBuild,
1994
+ savedAt: clock(),
1995
+ compatibility,
1996
+ protectionSnapshot,
1997
+ }) as unknown as PersistedEditorSnapshot),
1998
+ workflowMetadata: deriveWorkflowMetadataSnapshot(),
1999
+ },
1395
2000
  );
1396
2001
  },
1397
2002
  getPersistedSnapshot() {
@@ -1405,6 +2010,9 @@ export function createDocumentRuntime(
1405
2010
  getWarnings() {
1406
2011
  return state.warnings.map((warning) => toPublicWarning(warning));
1407
2012
  },
2013
+ getSuggestionsSnapshot() {
2014
+ return getCachedSuggestionsSnapshot(state);
2015
+ },
1408
2016
  async exportDocx(exportOptions) {
1409
2017
  if (!options.exportDocx) {
1410
2018
  const error: InternalEditorError = {
@@ -1418,7 +2026,7 @@ export function createDocumentRuntime(
1418
2026
  },
1419
2027
  };
1420
2028
  emitError(error);
1421
- throw new Error(error.message);
2029
+ throw toStructuredRuntimeException(error);
1422
2030
  }
1423
2031
 
1424
2032
  const result = await options.exportDocx(this.getSessionState(), exportOptions);
@@ -1483,6 +2091,57 @@ export function createDocumentRuntime(
1483
2091
  getWorkflowMarkupSnapshot() {
1484
2092
  return getCachedWorkflowMarkupSnapshot();
1485
2093
  },
2094
+ setWorkflowMetadataDefinitions(definitions) {
2095
+ workflowMetadataDefinitions = structuredClone(definitions);
2096
+ const snapshot = deriveWorkflowMetadataSnapshot();
2097
+ emit({
2098
+ type: "workflow_metadata_changed",
2099
+ documentId: state.documentId,
2100
+ snapshot,
2101
+ });
2102
+ for (const listener of listeners) {
2103
+ listener();
2104
+ }
2105
+ },
2106
+ clearWorkflowMetadataDefinitions() {
2107
+ workflowMetadataDefinitions = [];
2108
+ const snapshot = deriveWorkflowMetadataSnapshot();
2109
+ emit({
2110
+ type: "workflow_metadata_changed",
2111
+ documentId: state.documentId,
2112
+ snapshot,
2113
+ });
2114
+ for (const listener of listeners) {
2115
+ listener();
2116
+ }
2117
+ },
2118
+ setWorkflowMetadataEntries(entries) {
2119
+ workflowMetadataEntries = structuredClone(entries);
2120
+ const snapshot = deriveWorkflowMetadataSnapshot();
2121
+ emit({
2122
+ type: "workflow_metadata_changed",
2123
+ documentId: state.documentId,
2124
+ snapshot,
2125
+ });
2126
+ for (const listener of listeners) {
2127
+ listener();
2128
+ }
2129
+ },
2130
+ clearWorkflowMetadataEntries() {
2131
+ workflowMetadataEntries = [];
2132
+ const snapshot = deriveWorkflowMetadataSnapshot();
2133
+ emit({
2134
+ type: "workflow_metadata_changed",
2135
+ documentId: state.documentId,
2136
+ snapshot,
2137
+ });
2138
+ for (const listener of listeners) {
2139
+ listener();
2140
+ }
2141
+ },
2142
+ getWorkflowMetadataSnapshot() {
2143
+ return deriveWorkflowMetadataSnapshot();
2144
+ },
1486
2145
  setHostAnnotationOverlay(overlay) {
1487
2146
  hostAnnotationOverlay = structuredClone(overlay);
1488
2147
  emit({
@@ -1528,6 +2187,43 @@ export function createDocumentRuntime(
1528
2187
  }
1529
2188
  this.replaceText(text, target.anchor);
1530
2189
  },
2190
+ getDocumentTextStream() {
2191
+ return cachedRenderSnapshot.surface
2192
+ ? createDocumentTextStreamSnapshots({
2193
+ surface: cachedRenderSnapshot.surface,
2194
+ navigation: getCachedDocumentNavigationSnapshot(state, activeStory),
2195
+ })
2196
+ : [];
2197
+ },
2198
+ getStoryTextStream(target: EditorStoryTarget) {
2199
+ return this.getDocumentTextStream().find((stream) => storyTargetsEqual(stream.target, target)) ?? null;
2200
+ },
2201
+ getDocumentChunks() {
2202
+ return createDocumentChunks({
2203
+ document: state.document,
2204
+ renderSnapshot: cachedRenderSnapshot,
2205
+ navigation: getCachedDocumentNavigationSnapshot(state, activeStory),
2206
+ });
2207
+ },
2208
+ getWorkflowChunks() {
2209
+ return createWorkflowChunks({
2210
+ document: state.document,
2211
+ navigation: getCachedDocumentNavigationSnapshot(state, activeStory),
2212
+ workflowMarkup: getCachedWorkflowMarkupSnapshot(),
2213
+ });
2214
+ },
2215
+ getReviewWorkSnapshot() {
2216
+ return createReviewWorkSnapshot({
2217
+ comments: cachedRenderSnapshot.comments,
2218
+ trackedChanges: cachedRenderSnapshot.trackedChanges,
2219
+ workflowMarkup: getCachedWorkflowMarkupSnapshot(),
2220
+ document: state.document,
2221
+ navigation: getCachedDocumentNavigationSnapshot(state, activeStory),
2222
+ });
2223
+ },
2224
+ getRuntimeContextAnalytics(query) {
2225
+ return getCachedRuntimeContextAnalytics(query);
2226
+ },
1531
2227
  };
1532
2228
 
1533
2229
  function applyHistory(direction: "undo" | "redo"): void {
@@ -1579,6 +2275,7 @@ export function createDocumentRuntime(
1579
2275
  next: EditorState,
1580
2276
  transaction: EditorTransaction,
1581
2277
  ): void {
2278
+ const emittedSuggestionIds = new Set<string>();
1582
2279
  if (previous.isDirty !== next.isDirty) {
1583
2280
  emit({
1584
2281
  type: "dirty_changed",
@@ -1618,6 +2315,22 @@ export function createDocumentRuntime(
1618
2315
  documentId: next.documentId,
1619
2316
  changeId: transaction.effects.changeAccepted.changeId,
1620
2317
  });
2318
+ const updatedSuggestion = findSuggestionByChangeId(
2319
+ getCachedSuggestionsSnapshot(next),
2320
+ transaction.effects.changeAccepted.changeId,
2321
+ );
2322
+ if (updatedSuggestion) {
2323
+ emit({
2324
+ type: "suggestion_updated",
2325
+ documentId: next.documentId,
2326
+ suggestionId: updatedSuggestion.suggestionId,
2327
+ changeIds: updatedSuggestion.changeIds,
2328
+ suggestionKind: updatedSuggestion.kind,
2329
+ status: updatedSuggestion.status,
2330
+ storyTarget: updatedSuggestion.storyTarget,
2331
+ anchor: updatedSuggestion.anchor,
2332
+ });
2333
+ }
1621
2334
  }
1622
2335
 
1623
2336
  if (transaction.effects.changeRejected) {
@@ -1626,6 +2339,22 @@ export function createDocumentRuntime(
1626
2339
  documentId: next.documentId,
1627
2340
  changeId: transaction.effects.changeRejected.changeId,
1628
2341
  });
2342
+ const updatedSuggestion = findSuggestionByChangeId(
2343
+ getCachedSuggestionsSnapshot(next),
2344
+ transaction.effects.changeRejected.changeId,
2345
+ );
2346
+ if (updatedSuggestion) {
2347
+ emit({
2348
+ type: "suggestion_updated",
2349
+ documentId: next.documentId,
2350
+ suggestionId: updatedSuggestion.suggestionId,
2351
+ changeIds: updatedSuggestion.changeIds,
2352
+ suggestionKind: updatedSuggestion.kind,
2353
+ status: updatedSuggestion.status,
2354
+ storyTarget: updatedSuggestion.storyTarget,
2355
+ anchor: updatedSuggestion.anchor,
2356
+ });
2357
+ }
1629
2358
  }
1630
2359
 
1631
2360
  if (transaction.effects.revisionAuthored) {
@@ -1635,6 +2364,47 @@ export function createDocumentRuntime(
1635
2364
  changeId: transaction.effects.revisionAuthored.changeId,
1636
2365
  kind: transaction.effects.revisionAuthored.kind,
1637
2366
  });
2367
+ const authoredSuggestion = findSuggestionByChangeId(
2368
+ getCachedSuggestionsSnapshot(next),
2369
+ transaction.effects.revisionAuthored.changeId,
2370
+ );
2371
+ if (authoredSuggestion) {
2372
+ emittedSuggestionIds.add(authoredSuggestion.suggestionId);
2373
+ emit({
2374
+ type: "suggestion_authored",
2375
+ documentId: next.documentId,
2376
+ suggestionId: authoredSuggestion.suggestionId,
2377
+ changeIds: authoredSuggestion.changeIds,
2378
+ suggestionKind: authoredSuggestion.kind,
2379
+ storyTarget: authoredSuggestion.storyTarget,
2380
+ anchor: authoredSuggestion.anchor,
2381
+ isReplacement: authoredSuggestion.isReplacement,
2382
+ });
2383
+ }
2384
+ }
2385
+
2386
+ const addedRevisionIds = Object.keys(next.document.review.revisions).filter(
2387
+ (revisionId) => previous.document.review.revisions[revisionId] === undefined,
2388
+ );
2389
+ if (addedRevisionIds.length > 0) {
2390
+ const suggestionsSnapshot = getCachedSuggestionsSnapshot(next);
2391
+ for (const revisionId of addedRevisionIds) {
2392
+ const authoredSuggestion = findSuggestionByChangeId(suggestionsSnapshot, revisionId);
2393
+ if (!authoredSuggestion || emittedSuggestionIds.has(authoredSuggestion.suggestionId)) {
2394
+ continue;
2395
+ }
2396
+ emittedSuggestionIds.add(authoredSuggestion.suggestionId);
2397
+ emit({
2398
+ type: "suggestion_authored",
2399
+ documentId: next.documentId,
2400
+ suggestionId: authoredSuggestion.suggestionId,
2401
+ changeIds: authoredSuggestion.changeIds,
2402
+ suggestionKind: authoredSuggestion.kind,
2403
+ storyTarget: authoredSuggestion.storyTarget,
2404
+ anchor: authoredSuggestion.anchor,
2405
+ isReplacement: authoredSuggestion.isReplacement,
2406
+ });
2407
+ }
1638
2408
  }
1639
2409
 
1640
2410
  if (transaction.effects.commandBlocked) {
@@ -1681,6 +2451,23 @@ export function createDocumentRuntime(
1681
2451
  } = {},
1682
2452
  ): void {
1683
2453
  const selection = options.selection ?? state.selection;
2454
+ if (
2455
+ activeStory.kind !== "main" &&
2456
+ getEffectiveDocumentMode(selection) === "suggesting" &&
2457
+ command.type === "paragraph.split"
2458
+ ) {
2459
+ emit({
2460
+ type: "command_blocked",
2461
+ documentId: state.documentId,
2462
+ command: options.blockedCommandName ?? command.type,
2463
+ reasons: [{
2464
+ code: "suggesting_unsupported",
2465
+ message: `"${command.type}" is not supported in suggesting mode for this story.`,
2466
+ storyTarget: activeStory,
2467
+ }],
2468
+ });
2469
+ return;
2470
+ }
1684
2471
  const blockedReasons = evaluateWorkflowBlockedReasons(selection, command.type);
1685
2472
  if (blockedReasons.length > 0) {
1686
2473
  emit({
@@ -1832,12 +2619,75 @@ export function createDocumentRuntime(
1832
2619
  }
1833
2620
 
1834
2621
  function emit(event: DocumentRuntimeEvent): void {
2622
+ emitInternal(event);
2623
+ if (shouldEmitContextAnalyticsChanged(event)) {
2624
+ emitContextAnalyticsChanged();
2625
+ }
2626
+ }
2627
+
2628
+ function emitInternal(event: DocumentRuntimeEvent): void {
1835
2629
  options.onEvent?.(event);
1836
2630
  for (const listener of eventListeners) {
1837
2631
  listener(event);
1838
2632
  }
1839
2633
  }
1840
2634
 
2635
+ function shouldEmitContextAnalyticsChanged(event: DocumentRuntimeEvent): boolean {
2636
+ switch (event.type) {
2637
+ case "selection_changed":
2638
+ case "story_changed":
2639
+ case "workflow_overlay_changed":
2640
+ case "workflow_active_work_item_changed":
2641
+ case "change_authored":
2642
+ case "change_accepted":
2643
+ case "change_rejected":
2644
+ case "comment_added":
2645
+ case "comment_resolved":
2646
+ case "warning_added":
2647
+ case "warning_cleared":
2648
+ case "error":
2649
+ case "ready":
2650
+ case "command_blocked":
2651
+ case "suggestion_authored":
2652
+ case "suggestion_updated":
2653
+ return true;
2654
+ case "context_analytics_changed":
2655
+ case "dirty_changed":
2656
+ case "autosave_state":
2657
+ case "snapshot_saved":
2658
+ case "session_saved":
2659
+ case "export_completed":
2660
+ case "host_annotation_overlay_changed":
2661
+ return false;
2662
+ default:
2663
+ return false;
2664
+ }
2665
+ }
2666
+
2667
+ function emitContextAnalyticsChanged(): void {
2668
+ const trackedSnapshots = collectTrackedContextAnalyticsSnapshots();
2669
+ if (
2670
+ lastEmittedContextAnalyticsSnapshots !== undefined &&
2671
+ trackedContextAnalyticsSnapshotsEqual(lastEmittedContextAnalyticsSnapshots, trackedSnapshots)
2672
+ ) {
2673
+ return;
2674
+ }
2675
+ lastEmittedContextAnalyticsSnapshots = trackedSnapshots;
2676
+ emitInternal({
2677
+ type: "context_analytics_changed",
2678
+ documentId: state.documentId,
2679
+ snapshot:
2680
+ trackedSnapshots.get(
2681
+ getRuntimeContextAnalyticsQueryKey(
2682
+ resolveCurrentContextAnalyticsQuery({
2683
+ workflowScopeSnapshot: getCachedWorkflowScopeSnapshot(),
2684
+ interactionGuardSnapshot: getCachedInteractionGuardSnapshot(),
2685
+ }),
2686
+ ),
2687
+ ) ?? null,
2688
+ });
2689
+ }
2690
+
1841
2691
  function emitError(error: InternalEditorError): void {
1842
2692
  const nextState: EditorState = {
1843
2693
  ...state,
@@ -1949,6 +2799,30 @@ function finalizeState(
1949
2799
 
1950
2800
  function toRuntimeError(error: unknown): InternalEditorError {
1951
2801
  if (typeof error === "object" && error && "message" in error) {
2802
+ if (
2803
+ "code" in error
2804
+ && "source" in error
2805
+ && "isFatal" in error
2806
+ && typeof (error as { code?: unknown }).code === "string"
2807
+ && typeof (error as { source?: unknown }).source === "string"
2808
+ && typeof (error as { isFatal?: unknown }).isFatal === "boolean"
2809
+ ) {
2810
+ return {
2811
+ errorId:
2812
+ typeof (error as { errorId?: unknown }).errorId === "string"
2813
+ ? String((error as { errorId?: unknown }).errorId)
2814
+ : createSessionId("runtime-error", new Date().toISOString()),
2815
+ code: (error as { code: InternalEditorError["code"] }).code,
2816
+ isFatal: (error as { isFatal: boolean }).isFatal,
2817
+ message: String((error as { message?: unknown }).message ?? "Runtime error"),
2818
+ source: (error as { source: InternalEditorError["source"] }).source,
2819
+ details:
2820
+ typeof (error as { details?: unknown }).details === "object"
2821
+ && (error as { details?: unknown }).details !== null
2822
+ ? ((error as { details?: Record<string, unknown> }).details)
2823
+ : undefined,
2824
+ };
2825
+ }
1952
2826
  return {
1953
2827
  errorId: createSessionId("runtime-error", new Date().toISOString()),
1954
2828
  code: "internal_invariant",
@@ -1967,6 +2841,12 @@ function toRuntimeError(error: unknown): InternalEditorError {
1967
2841
  };
1968
2842
  }
1969
2843
 
2844
+ function toStructuredRuntimeException<T extends InternalEditorError>(
2845
+ error: T,
2846
+ ): Error & T {
2847
+ return Object.assign(new Error(error.message), error);
2848
+ }
2849
+
1970
2850
  function toPublicDocumentStats(state: Pick<EditorState, "document">) {
1971
2851
  const stats = deriveDocumentStats(state);
1972
2852
  return {
@@ -2150,6 +3030,8 @@ function toPublicCommentSidebarSnapshot(
2150
3030
  createdBy: thread.createdBy,
2151
3031
  warningCount: thread.warningCount,
2152
3032
  anchorLabel: thread.anchorLabel,
3033
+ detachedReason: sourceThread?.metadata?.detachedReason,
3034
+ actionabilityNote: sourceThread?.metadata?.actionabilityNote,
2153
3035
  isActive: thread.isActive,
2154
3036
  resolvedAt: thread.resolvedAt,
2155
3037
  resolvedBy: thread.resolvedBy,
@@ -2188,6 +3070,11 @@ function toPublicTrackedChangesSnapshot(
2188
3070
  return {
2189
3071
  revisionId: revision.revisionId,
2190
3072
  kind: revision.kind,
3073
+ source: sourceRevision?.metadata?.source ?? "runtime",
3074
+ suggestionId: sourceRevision?.metadata?.suggestionId,
3075
+ semanticKind: sourceRevision?.metadata?.semanticKind,
3076
+ linkedRevisionIds: sourceRevision?.metadata?.linkedRevisionIds,
3077
+ predecessorSuggestionId: sourceRevision?.metadata?.predecessorSuggestionId,
2191
3078
  label: revision.label,
2192
3079
  status: revision.status,
2193
3080
  actionability: revision.actionability,
@@ -2234,6 +3121,10 @@ function createRevisionStoreFromDocument(
2234
3121
  source: revision.metadata?.source ?? "runtime",
2235
3122
  storyTarget: revision.metadata?.storyTarget,
2236
3123
  preserveOnlyReason: revision.metadata?.preserveOnlyReason,
3124
+ suggestionId: revision.metadata?.suggestionId,
3125
+ semanticKind: revision.metadata?.semanticKind,
3126
+ linkedRevisionIds: revision.metadata?.linkedRevisionIds,
3127
+ predecessorSuggestionId: revision.metadata?.predecessorSuggestionId,
2237
3128
  importedRevisionForm: revision.metadata?.importedRevisionForm,
2238
3129
  originalRevisionType: revision.metadata?.originalRevisionType,
2239
3130
  ooxmlRevisionId: revision.metadata?.ooxmlRevisionId,
@@ -2251,6 +3142,13 @@ function getRevisionStoryTarget(
2251
3142
  return storyTarget ? { ...storyTarget } : MAIN_STORY_TARGET;
2252
3143
  }
2253
3144
 
3145
+ function findSuggestionByChangeId(
3146
+ snapshot: SuggestionsSnapshot,
3147
+ changeId: string,
3148
+ ) {
3149
+ return snapshot.suggestions.find((suggestion) => suggestion.changeIds.includes(changeId));
3150
+ }
3151
+
2254
3152
  function createSecondaryStoryLocalReviewState(
2255
3153
  review: EditorState["document"]["review"],
2256
3154
  storyTarget: EditorStoryTarget,
@@ -2451,11 +3349,9 @@ const NON_MUTATION_COMMANDS = new Set([
2451
3349
  ]);
2452
3350
 
2453
3351
  /** Mutation commands that are not yet supported in suggesting mode. */
2454
- const SUGGESTING_UNSUPPORTED_COMMANDS = new Set([
2455
- "paragraph.split",
2456
- ]);
3352
+ const SUGGESTING_UNSUPPORTED_COMMANDS = new Set<string>([]);
2457
3353
 
2458
- const SUGGESTING_SECONDARY_STORY_UNSUPPORTED_COMMANDS = new Set([
3354
+ const SUGGESTING_SECONDARY_STORY_UNSUPPORTED_COMMANDS = new Set<string>([
2459
3355
  "text.insert",
2460
3356
  "text.delete-backward",
2461
3357
  "text.delete-forward",