@beyondwork/docx-react-component 1.0.22 → 1.0.24-rc

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 (33) hide show
  1. package/README.md +81 -38
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +67 -1
  4. package/src/core/commands/index.ts +625 -5
  5. package/src/index.ts +5 -0
  6. package/src/io/docx-session.ts +181 -2
  7. package/src/io/export/serialize-main-document.ts +21 -1
  8. package/src/io/normalize/normalize-text.ts +4 -0
  9. package/src/io/ooxml/parse-main-document.ts +88 -7
  10. package/src/model/canonical-document.ts +22 -0
  11. package/src/review/store/revision-store.ts +1 -0
  12. package/src/review/store/revision-types.ts +2 -0
  13. package/src/runtime/document-runtime.ts +503 -51
  14. package/src/runtime/session-capabilities.ts +6 -5
  15. package/src/runtime/surface-projection.ts +2 -0
  16. package/src/runtime/table-schema.ts +2 -0
  17. package/src/runtime/workflow-markup.ts +5 -1
  18. package/src/ui/WordReviewEditor.tsx +667 -132
  19. package/src/ui/editor-runtime-boundary.ts +10 -1
  20. package/src/ui/editor-shell-view.tsx +8 -0
  21. package/src/ui/editor-surface-controller.tsx +6 -0
  22. package/src/ui/headless/selection-toolbar-model.ts +12 -0
  23. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +139 -0
  24. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +6 -0
  25. package/src/ui-tailwind/editor-surface/pm-decorations.ts +96 -28
  26. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +2 -0
  27. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +6 -0
  28. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +132 -10
  29. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +82 -1
  30. package/src/ui-tailwind/status/tw-status-bar.tsx +4 -1
  31. package/src/ui-tailwind/theme/editor-theme.css +10 -0
  32. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +21 -5
  33. package/src/ui-tailwind/tw-review-workspace.tsx +110 -32
@@ -41,12 +41,14 @@ import type {
41
41
  SelectionSnapshot as PublicSelectionSnapshot,
42
42
  StyleCatalogSnapshot,
43
43
  SurfaceBlockSnapshot,
44
+ TrackedChangeEntrySnapshot,
44
45
  TocRefreshResult,
45
46
  UpdateFieldsResult,
46
47
  ViewMode as EditorViewMode,
47
48
  WorkflowBlockedCommandReason,
48
49
  WorkflowMarkupSnapshot,
49
50
  WorkflowScopeSnapshot,
51
+ WordReviewEditorChromeVisibility,
50
52
  WordReviewEditorEvent,
51
53
  WordReviewEditorProps,
52
54
  WordReviewEditorRef,
@@ -150,6 +152,7 @@ import type { MarkupDisplay } from "./headless/comment-decoration-model";
150
152
  import type {
151
153
  SelectionToolbarAnchor,
152
154
  SelectionToolbarModel,
155
+ SuggestionCardModel,
153
156
  } from "./headless/selection-toolbar-model";
154
157
  import { type EditorCommandBag, useCommandBag } from "./editor-command-bag.ts";
155
158
  import { deriveVisibleWorkflowBlockedRails } from "./workflow-surface-blocked-rails.ts";
@@ -464,6 +467,15 @@ export function __createWordReviewEditorRefBridge(
464
467
  getWorkflowMarkupSnapshot: () => {
465
468
  return clonePublicValue(runtime.getWorkflowMarkupSnapshot());
466
469
  },
470
+ setHostAnnotationOverlay: (overlay) => {
471
+ runtime.setHostAnnotationOverlay(clonePublicValue(overlay));
472
+ },
473
+ clearHostAnnotationOverlay: () => {
474
+ runtime.clearHostAnnotationOverlay();
475
+ },
476
+ getHostAnnotationSnapshot: () => {
477
+ return clonePublicValue(runtime.getHostAnnotationSnapshot());
478
+ },
467
479
  getWorkflowCandidateRanges: (options) => {
468
480
  return clonePublicValue(runtime.getWorkflowCandidateRanges(options));
469
481
  },
@@ -473,6 +485,20 @@ export function __createWordReviewEditorRefBridge(
473
485
  };
474
486
  }
475
487
 
488
+ export function __applyRuntimeTextCommand(
489
+ runtime: WordReviewEditorRuntime,
490
+ command:
491
+ | { type: "insert-text"; text: string }
492
+ | { type: "delete-backward" }
493
+ | { type: "delete-forward" }
494
+ | { type: "insert-tab" }
495
+ | { type: "outdent-tab" }
496
+ | { type: "insert-hard-break" }
497
+ | { type: "split-paragraph" },
498
+ ): void {
499
+ applyRuntimeTextCommand(runtime, command);
500
+ }
501
+
476
502
  export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditorProps>(
477
503
  function WordReviewEditor(props, ref) {
478
504
  const {
@@ -494,12 +520,15 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
494
520
  onWarning,
495
521
  readOnly = false,
496
522
  reviewMode = "review",
523
+ suggestionsEnabled = false,
497
524
  showReviewPanel = true,
525
+ chromeVisibility,
498
526
  } = props;
499
527
 
500
528
  const [activeRailTab, setActiveRailTab] = useState<ReviewRailTab>("comments");
501
529
  const [showTrackedChanges, setShowTrackedChanges] = useState(false);
502
530
  const [activeRevisionId, setActiveRevisionId] = useState<string | undefined>();
531
+ const [suppressedSuggestionRevisionId, setSuppressedSuggestionRevisionId] = useState<string | null>(null);
503
532
  const [selectionToolbarAnchor, setSelectionToolbarAnchor] = useState<SelectionToolbarAnchor | null>(null);
504
533
  const [selectionToolbarDismissedKey, setSelectionToolbarDismissedKey] = useState<string | null>(null);
505
534
  const [selectionToolbarFocusWithin, setSelectionToolbarFocusWithin] = useState(false);
@@ -644,7 +673,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
644
673
  getValue: () => runtime.getInteractionGuardSnapshot(),
645
674
  }
646
675
  : null,
647
- { blockedReasons: [] } satisfies InteractionGuardSnapshot,
676
+ { effectiveMode: "edit", blockedReasons: [] } satisfies InteractionGuardSnapshot,
648
677
  interactionGuardSnapshotsEqual,
649
678
  );
650
679
  const workflowMarkupSnapshot = useMemo(
@@ -655,17 +684,20 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
655
684
  () => deriveVisibleWorkflowBlockedRails(snapshot.surface, workflowMarkupSnapshot),
656
685
  [snapshot.surface, workflowMarkupSnapshot],
657
686
  );
658
- const sessionState = useMemo(
659
- () => (runtime ? runtime.getSessionState() : loadingSessionState),
660
- [loadingSessionState, runtime, snapshot.revisionToken],
687
+ const canonicalDocument = useMemo(
688
+ () => (runtime ? runtime.getCanonicalDocument() : loadingSessionState.canonicalDocument),
689
+ [loadingSessionState.canonicalDocument, runtime, snapshot.revisionToken],
661
690
  );
662
- const canonicalDocument = sessionState.canonicalDocument;
663
691
  const effectiveViewMode = deriveEditorViewMode(snapshot.readOnly, reviewMode);
664
692
 
665
693
  useEffect(() => {
666
694
  activeRuntime.setViewMode(effectiveViewMode);
667
695
  }, [activeRuntime, effectiveViewMode]);
668
696
 
697
+ useEffect(() => {
698
+ activeRuntime.setDocumentMode(suggestionsEnabled ? "suggesting" : "editing");
699
+ }, [activeRuntime, suggestionsEnabled]);
700
+
669
701
  useEffect(() => {
670
702
  runtimeViewStateSeedRef.current = {
671
703
  workspaceMode: viewState.workspaceMode,
@@ -901,14 +933,20 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
901
933
  (r) => r.revisionId === revisionId,
902
934
  );
903
935
  if (!revision || revision.anchor.kind === "detached") return;
904
- applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(revision.anchor));
936
+ applyRuntimeSelection(
937
+ activeRuntime,
938
+ createSelectionFromAnchor(revision.anchor, revision.storyTarget),
939
+ );
905
940
  },
906
941
  scrollToComment: (commentId: string) => {
907
942
  const comment = activeRuntime.getRenderSnapshot().comments.threads.find(
908
943
  (t) => t.commentId === commentId,
909
944
  );
910
945
  if (!comment || comment.anchor.kind === "detached") return;
911
- applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(comment.anchor));
946
+ applyRuntimeSelection(
947
+ activeRuntime,
948
+ createSelectionFromAnchor(comment.anchor),
949
+ );
912
950
  },
913
951
  openStory: (target: EditorStoryTarget) => {
914
952
  activeRuntime.openStory(target);
@@ -982,6 +1020,15 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
982
1020
  getWorkflowMarkupSnapshot: () => {
983
1021
  return clonePublicValue(activeRuntime.getWorkflowMarkupSnapshot());
984
1022
  },
1023
+ setHostAnnotationOverlay: (overlay) => {
1024
+ activeRuntime.setHostAnnotationOverlay(clonePublicValue(overlay));
1025
+ },
1026
+ clearHostAnnotationOverlay: () => {
1027
+ activeRuntime.clearHostAnnotationOverlay();
1028
+ },
1029
+ getHostAnnotationSnapshot: () => {
1030
+ return clonePublicValue(activeRuntime.getHostAnnotationSnapshot());
1031
+ },
985
1032
  getWorkflowCandidateRanges: (options) => {
986
1033
  return clonePublicValue(activeRuntime.getWorkflowCandidateRanges(options));
987
1034
  },
@@ -1080,11 +1127,14 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1080
1127
  snapshot.trackedChanges.totalCount,
1081
1128
  ]);
1082
1129
 
1083
- function focusAnchor(anchor: PublicSelectionSnapshot["activeRange"]): void {
1130
+ function focusAnchor(
1131
+ anchor: PublicSelectionSnapshot["activeRange"],
1132
+ storyTarget?: EditorStoryTarget,
1133
+ ): void {
1084
1134
  if (anchor.kind === "detached") {
1085
1135
  return;
1086
1136
  }
1087
- applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(anchor));
1137
+ applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(anchor, storyTarget));
1088
1138
  }
1089
1139
 
1090
1140
  function addReviewComment(): string | null {
@@ -1129,7 +1179,11 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1129
1179
  reviewMode,
1130
1180
  workflowScopeSnapshot,
1131
1181
  );
1132
- const capabilities = showReviewPanel
1182
+ const resolvedChromeVisibility = resolveWordReviewEditorChromeVisibility(
1183
+ chromeVisibility,
1184
+ showReviewPanel,
1185
+ );
1186
+ const capabilities = resolvedChromeVisibility.reviewRail
1133
1187
  ? derivedCapabilities
1134
1188
  : { ...derivedCapabilities, reviewRailVisible: false };
1135
1189
  const formattingState = getFormattingStateFromRenderSnapshot(snapshot);
@@ -1152,7 +1206,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1152
1206
  }),
1153
1207
  [canonicalDocument, snapshot.selection, snapshot.surface, viewState.activeStory],
1154
1208
  );
1155
- const sourcePackage = sessionState.sourcePackage;
1209
+ const sourcePackage = runtime
1210
+ ? runtime.getSourcePackage()
1211
+ : loadingSessionState.sourcePackage;
1156
1212
  const mediaPreviewCatalogKey = Object.values(canonicalDocument.media.items)
1157
1213
  .map((item) =>
1158
1214
  [
@@ -1214,14 +1270,26 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1214
1270
  documentNavigation,
1215
1271
  styleCatalog,
1216
1272
  formattingState,
1273
+ workflowScopeSnapshot,
1274
+ interactionGuardSnapshot,
1275
+ addCommentDisabledReason,
1276
+ });
1277
+ const suggestionCard = buildSuggestionCardModel({
1278
+ snapshot,
1279
+ viewState,
1280
+ capabilities,
1281
+ workflowScopeSnapshot,
1282
+ interactionGuardSnapshot,
1283
+ activeRevisionId,
1284
+ suppressedSuggestionRevisionId,
1217
1285
  addCommentDisabledReason,
1218
1286
  });
1219
1287
  const selectionToolbarSelectionKey = useMemo(
1220
- () => createSelectionToolbarSelectionKey(snapshot.selection, viewState.activeStory),
1221
- [snapshot.selection, viewState.activeStory],
1288
+ () => createSelectionToolbarSelectionKey(snapshot.selection, viewState.activeStory, activeRevisionId),
1289
+ [activeRevisionId, snapshot.selection, viewState.activeStory],
1222
1290
  );
1223
- const shouldRenderSelectionToolbar = Boolean(
1224
- selectionToolbar &&
1291
+ const shouldRenderSelectionChrome = Boolean(
1292
+ (selectionToolbar || suggestionCard) &&
1225
1293
  selectionToolbarSelectionKey &&
1226
1294
  selectionToolbarDismissedKey !== selectionToolbarSelectionKey &&
1227
1295
  (viewState.isFocused || selectionToolbarFocusWithin),
@@ -1341,6 +1409,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1341
1409
 
1342
1410
  useEffect(() => {
1343
1411
  if (!selectionToolbarSelectionKey) {
1412
+ setSuppressedSuggestionRevisionId(null);
1344
1413
  setSelectionToolbarDismissedKey(null);
1345
1414
  setSelectionToolbarFocusWithin(false);
1346
1415
  setSelectionToolbarAnchor(null);
@@ -1350,17 +1419,18 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1350
1419
 
1351
1420
  if (lastSelectionToolbarKeyRef.current !== selectionToolbarSelectionKey) {
1352
1421
  lastSelectionToolbarKeyRef.current = selectionToolbarSelectionKey;
1422
+ setSuppressedSuggestionRevisionId(null);
1353
1423
  setSelectionToolbarDismissedKey(null);
1354
1424
  setSelectionToolbarFocusWithin(false);
1355
1425
  }
1356
1426
  }, [selectionToolbarSelectionKey]);
1357
1427
 
1358
1428
  useEffect(() => {
1359
- if (!selectionToolbar) {
1429
+ if (!selectionToolbar && !suggestionCard) {
1360
1430
  setSelectionToolbarAnchor(null);
1361
1431
  setSelectionToolbarFocusWithin(false);
1362
1432
  }
1363
- }, [selectionToolbar]);
1433
+ }, [selectionToolbar, suggestionCard]);
1364
1434
 
1365
1435
  useEffect(() => {
1366
1436
  const shell = shellRef.current;
@@ -1389,9 +1459,26 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1389
1459
  }, [loadError, snapshot.fatalError]);
1390
1460
 
1391
1461
  function handleShellKeyDownCapture(event: React.KeyboardEvent<HTMLDivElement>): void {
1462
+ const targetWithinDocument = isTargetWithinDocumentSurface(event.target);
1463
+ const isUndoShortcut = (event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === "z";
1464
+ const isRedoShortcut =
1465
+ ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === "z") ||
1466
+ (event.ctrlKey && event.key.toLowerCase() === "y");
1467
+
1468
+ if ((isUndoShortcut || isRedoShortcut) && targetWithinDocument) {
1469
+ event.preventDefault();
1470
+ event.stopPropagation();
1471
+ if (isUndoShortcut) {
1472
+ activeRuntime.undo();
1473
+ } else {
1474
+ activeRuntime.redo();
1475
+ }
1476
+ return;
1477
+ }
1478
+
1392
1479
  if (
1393
1480
  event.key === "Escape" &&
1394
- shouldRenderSelectionToolbar &&
1481
+ shouldRenderSelectionChrome &&
1395
1482
  (isTargetWithinDocumentSurface(event.target) || isTargetWithinSelectionToolbar(event.target))
1396
1483
  ) {
1397
1484
  event.preventDefault();
@@ -1430,6 +1517,13 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1430
1517
  onOutdentTab: () => applyRuntimeTextCommand(activeRuntime, { type: "outdent-tab" }),
1431
1518
  onInsertHardBreak: () => applyRuntimeTextCommand(activeRuntime, { type: "insert-hard-break" }),
1432
1519
  onSplitParagraph: () => applyRuntimeTextCommand(activeRuntime, { type: "split-paragraph" }),
1520
+ onUndo: () => activeRuntime.undo(),
1521
+ onRedo: () => activeRuntime.redo(),
1522
+ onBlockedInput: (command: "paste" | "drop", message: string) =>
1523
+ activeRuntime.emitBlockedCommand(command, [{
1524
+ code: "unsupported_surface",
1525
+ message,
1526
+ }]),
1433
1527
  };
1434
1528
 
1435
1529
  const reviewCallbacks = {
@@ -1458,7 +1552,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1458
1552
  },
1459
1553
  onOpenRevision: (revision: typeof snapshot.trackedChanges.revisions[number]) => {
1460
1554
  setActiveRevisionId(revision.revisionId);
1461
- focusAnchor(revision.anchor);
1555
+ focusAnchor(revision.anchor, revision.storyTarget);
1462
1556
  setActiveRailTab("changes");
1463
1557
  },
1464
1558
  onAcceptRevision: (revisionId: string) => {
@@ -1618,11 +1712,14 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1618
1712
  markupDisplay={liveMarkupDisplay}
1619
1713
  activeRevisionId={activeRevisionId}
1620
1714
  showTrackedChanges={showTrackedChanges}
1715
+ suggestionsEnabled={suggestionsEnabled}
1621
1716
  mediaPreviews={mediaPreviews}
1622
1717
  isPageWorkspace={isPageWorkspace}
1623
1718
  workflowScopes={workflowScopeSnapshot?.scopes}
1624
1719
  workflowCandidates={workflowScopeSnapshot?.candidates}
1625
1720
  workflowBlockedReasons={workflowBlockedRails}
1721
+ activeWorkflowWorkItemId={workflowScopeSnapshot?.activeWorkItemId ?? null}
1722
+ activeWorkflowScopeIds={workflowScopeSnapshot?.activeWorkItem?.scopeIds ?? []}
1626
1723
  onSelectionToolbarAnchorChange={handleSelectionToolbarAnchorChange}
1627
1724
  {...editorCallbacks}
1628
1725
  onCommentActivated={(commentId) => {
@@ -1656,6 +1753,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1656
1753
  markupDisplay={liveMarkupDisplay}
1657
1754
  currentUserId={currentUser.userId}
1658
1755
  capabilities={capabilities}
1756
+ chromeVisibility={resolvedChromeVisibility}
1659
1757
  documentNavigation={documentNavigation}
1660
1758
  reviewMode={reviewMode}
1661
1759
  workspaceMode={viewState.workspaceMode}
@@ -1668,9 +1766,28 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1668
1766
  showTrackedChanges={showTrackedChanges}
1669
1767
  workflowScopeSnapshot={workflowScopeSnapshot}
1670
1768
  interactionGuardSnapshot={interactionGuardSnapshot}
1671
- selectionToolbar={shouldRenderSelectionToolbar ? selectionToolbar : null}
1672
- selectionToolbarAnchor={shouldRenderSelectionToolbar ? selectionToolbarAnchor : null}
1769
+ selectionToolbar={shouldRenderSelectionChrome ? selectionToolbar : null}
1770
+ suggestionCard={shouldRenderSelectionChrome ? suggestionCard : null}
1771
+ selectionToolbarAnchor={shouldRenderSelectionChrome ? selectionToolbarAnchor : null}
1673
1772
  onAddCommentFromSelection={addSelectionToolbarComment}
1773
+ onAddCommentFromSuggestion={addSelectionToolbarComment}
1774
+ onAcceptSuggestion={suggestionCard
1775
+ ? () => {
1776
+ activeRuntime.acceptChange(suggestionCard.revisionId);
1777
+ dismissSelectionToolbar("chrome-action");
1778
+ }
1779
+ : undefined}
1780
+ onRejectSuggestion={suggestionCard
1781
+ ? () => {
1782
+ activeRuntime.rejectChange(suggestionCard.revisionId);
1783
+ dismissSelectionToolbar("chrome-action");
1784
+ }
1785
+ : undefined}
1786
+ onEditSuggestion={suggestionCard
1787
+ ? () => {
1788
+ setSuppressedSuggestionRevisionId(suggestionCard.revisionId);
1789
+ }
1790
+ : undefined}
1674
1791
  onDismissSelectionToolbar={() => dismissSelectionToolbar("chrome-action")}
1675
1792
  onSelectionToolbarFocusCapture={handleSelectionToolbarFocusCapture}
1676
1793
  onSelectionToolbarBlurCapture={handleSelectionToolbarBlurCapture}
@@ -1696,7 +1813,10 @@ function applyRuntimeFormattingOperation(
1696
1813
  | { type: "indent" }
1697
1814
  | { type: "outdent" },
1698
1815
  ): void {
1699
- const context = getStoryMutationContext(runtime);
1816
+ if (emitSuggestingUnsupportedMutation(runtime, getFormattingOperationCommandName(operation))) {
1817
+ return;
1818
+ }
1819
+ const context = getStoryMutationContext(runtime, getFormattingOperationCommandName(operation));
1700
1820
  if (!context) {
1701
1821
  return;
1702
1822
  }
@@ -1764,7 +1884,10 @@ function applyRuntimeParagraphStyle(
1764
1884
  runtime: WordReviewEditorRuntime,
1765
1885
  styleId: string | null,
1766
1886
  ): void {
1767
- const context = getStoryMutationContext(runtime);
1887
+ if (emitSuggestingUnsupportedMutation(runtime, "setParagraphStyle")) {
1888
+ return;
1889
+ }
1890
+ const context = getStoryMutationContext(runtime, "setParagraphStyle");
1768
1891
  if (!context) {
1769
1892
  return;
1770
1893
  }
@@ -1789,7 +1912,10 @@ function applyRuntimeTableStyle(
1789
1912
  runtime: WordReviewEditorRuntime,
1790
1913
  styleId: string | null,
1791
1914
  ): void {
1792
- const context = getStoryMutationContext(runtime);
1915
+ if (emitSuggestingUnsupportedMutation(runtime, "setTableStyle")) {
1916
+ return;
1917
+ }
1918
+ const context = getStoryMutationContext(runtime, "setTableStyle");
1793
1919
  if (!context) {
1794
1920
  return;
1795
1921
  }
@@ -1819,7 +1945,10 @@ function applyRuntimeParagraphIndentation(
1819
1945
  hanging?: number;
1820
1946
  },
1821
1947
  ): void {
1822
- const context = getStoryMutationContext(runtime);
1948
+ if (emitSuggestingUnsupportedMutation(runtime, "setParagraphIndentation")) {
1949
+ return;
1950
+ }
1951
+ const context = getStoryMutationContext(runtime, "setParagraphIndentation");
1823
1952
  if (!context) {
1824
1953
  return;
1825
1954
  }
@@ -1845,7 +1974,10 @@ function applyRuntimeParagraphTabStops(
1845
1974
  runtime: WordReviewEditorRuntime,
1846
1975
  tabStops: Array<{ pos: number; val?: string; leader?: string }>,
1847
1976
  ): void {
1848
- const context = getStoryMutationContext(runtime);
1977
+ if (emitSuggestingUnsupportedMutation(runtime, "setParagraphTabStops")) {
1978
+ return;
1979
+ }
1980
+ const context = getStoryMutationContext(runtime, "setParagraphTabStops");
1849
1981
  if (!context) {
1850
1982
  return;
1851
1983
  }
@@ -1871,7 +2003,18 @@ function applyRuntimeNumberingFlow(
1871
2003
  runtime: WordReviewEditorRuntime,
1872
2004
  operation: { type: "restart"; startAt?: number } | { type: "continue" },
1873
2005
  ): void {
1874
- const context = getStoryMutationContext(runtime);
2006
+ if (
2007
+ emitSuggestingUnsupportedMutation(
2008
+ runtime,
2009
+ operation.type === "restart" ? "restartNumbering" : "continueNumbering",
2010
+ )
2011
+ ) {
2012
+ return;
2013
+ }
2014
+ const context = getStoryMutationContext(
2015
+ runtime,
2016
+ operation.type === "restart" ? "restartNumbering" : "continueNumbering",
2017
+ );
1875
2018
  if (!context) {
1876
2019
  return;
1877
2020
  }
@@ -1916,7 +2059,10 @@ function applyRuntimeInsertSectionBreak(
1916
2059
  if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
1917
2060
  return;
1918
2061
  }
1919
- if (snapshot.documentMode === "suggesting") {
2062
+ if (emitWorkflowBlockedMutation(runtime, "insertSectionBreak")) {
2063
+ return;
2064
+ }
2065
+ if (isSelectionSuggesting(runtime)) {
1920
2066
  runtime.emitBlockedCommand("insertSectionBreak", [{
1921
2067
  code: "unsupported_surface",
1922
2068
  message: "Section break insertion is not supported in suggesting mode.",
@@ -1952,6 +2098,56 @@ function applyRuntimeInsertSectionBreak(
1952
2098
  );
1953
2099
  }
1954
2100
 
2101
+ function emitSuggestingUnsupportedMutation(
2102
+ runtime: WordReviewEditorRuntime,
2103
+ command: string,
2104
+ ): boolean {
2105
+ if (!isSelectionSuggesting(runtime)) {
2106
+ return false;
2107
+ }
2108
+
2109
+ runtime.emitBlockedCommand(command, [{
2110
+ code: "suggesting_unsupported",
2111
+ message: `"${command}" is not supported in suggesting mode.`,
2112
+ }]);
2113
+ return true;
2114
+ }
2115
+
2116
+ function isSelectionSuggesting(runtime: WordReviewEditorRuntime): boolean {
2117
+ return runtime.getInteractionGuardSnapshot().effectiveMode === "suggest";
2118
+ }
2119
+
2120
+ function getFormattingOperationCommandName(
2121
+ operation:
2122
+ | { type: "toggle"; mark: "bold" | "italic" | "underline" | "strikethrough" | "superscript" | "subscript" }
2123
+ | { type: "set-font-family"; fontFamily: string | null }
2124
+ | { type: "set-font-size"; size: number | null }
2125
+ | { type: "set-text-color"; color: string | null }
2126
+ | { type: "set-highlight-color"; color: string | null }
2127
+ | { type: "set-alignment"; alignment: FormattingAlignment }
2128
+ | { type: "indent" }
2129
+ | { type: "outdent" },
2130
+ ): string {
2131
+ switch (operation.type) {
2132
+ case "toggle":
2133
+ return `toggle${operation.mark.charAt(0).toUpperCase()}${operation.mark.slice(1)}`;
2134
+ case "set-font-family":
2135
+ return "setFontFamily";
2136
+ case "set-font-size":
2137
+ return "setFontSize";
2138
+ case "set-text-color":
2139
+ return "setTextColor";
2140
+ case "set-highlight-color":
2141
+ return "setHighlightColor";
2142
+ case "set-alignment":
2143
+ return "setAlignment";
2144
+ case "indent":
2145
+ return "indent";
2146
+ case "outdent":
2147
+ return "outdent";
2148
+ }
2149
+ }
2150
+
1955
2151
  function applyRuntimeDeleteSectionBreak(
1956
2152
  runtime: WordReviewEditorRuntime,
1957
2153
  sectionIndex: number,
@@ -1960,6 +2156,16 @@ function applyRuntimeDeleteSectionBreak(
1960
2156
  if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
1961
2157
  return;
1962
2158
  }
2159
+ if (emitWorkflowBlockedMutation(runtime, "deleteSectionBreak")) {
2160
+ return;
2161
+ }
2162
+ if (isSelectionSuggesting(runtime)) {
2163
+ runtime.emitBlockedCommand("deleteSectionBreak", [{
2164
+ code: "unsupported_surface",
2165
+ message: "Section break deletion is not supported in suggesting mode.",
2166
+ }]);
2167
+ return;
2168
+ }
1963
2169
 
1964
2170
  const sessionState = runtime.getSessionState();
1965
2171
  const timestamp = new Date().toISOString();
@@ -1989,6 +2195,16 @@ function applyRuntimeUpdateSectionLayout(
1989
2195
  if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
1990
2196
  return;
1991
2197
  }
2198
+ if (emitWorkflowBlockedMutation(runtime, "updateSectionLayout")) {
2199
+ return;
2200
+ }
2201
+ if (isSelectionSuggesting(runtime)) {
2202
+ runtime.emitBlockedCommand("updateSectionLayout", [{
2203
+ code: "unsupported_surface",
2204
+ message: "Section layout updates are not supported in suggesting mode.",
2205
+ }]);
2206
+ return;
2207
+ }
1992
2208
 
1993
2209
  const sessionState = runtime.getSessionState();
1994
2210
  const timestamp = new Date().toISOString();
@@ -2025,6 +2241,16 @@ function applyRuntimeSetSectionPageNumbering(
2025
2241
  if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
2026
2242
  return;
2027
2243
  }
2244
+ if (emitWorkflowBlockedMutation(runtime, "setSectionPageNumbering")) {
2245
+ return;
2246
+ }
2247
+ if (isSelectionSuggesting(runtime)) {
2248
+ runtime.emitBlockedCommand("setSectionPageNumbering", [{
2249
+ code: "unsupported_surface",
2250
+ message: "Section page numbering updates are not supported in suggesting mode.",
2251
+ }]);
2252
+ return;
2253
+ }
2028
2254
 
2029
2255
  const sessionState = runtime.getSessionState();
2030
2256
  const timestamp = new Date().toISOString();
@@ -2072,6 +2298,16 @@ function applyRuntimeSetHeaderFooterLink(
2072
2298
  if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
2073
2299
  return;
2074
2300
  }
2301
+ if (emitWorkflowBlockedMutation(runtime, "setHeaderFooterLink")) {
2302
+ return;
2303
+ }
2304
+ if (isSelectionSuggesting(runtime)) {
2305
+ runtime.emitBlockedCommand("setHeaderFooterLink", [{
2306
+ code: "unsupported_surface",
2307
+ message: "Header and footer linkage updates are not supported in suggesting mode.",
2308
+ }]);
2309
+ return;
2310
+ }
2075
2311
 
2076
2312
  const sessionState = runtime.getSessionState();
2077
2313
  const timestamp = new Date().toISOString();
@@ -2094,15 +2330,14 @@ function applyRuntimeSetHeaderFooterLink(
2094
2330
  }
2095
2331
 
2096
2332
  function applyRuntimeInsertPageBreak(runtime: WordReviewEditorRuntime): void {
2097
- const snapshot = runtime.getRenderSnapshot();
2098
- if (snapshot.documentMode === "suggesting") {
2333
+ if (isSelectionSuggesting(runtime)) {
2099
2334
  runtime.emitBlockedCommand("insertPageBreak", [{
2100
2335
  code: "unsupported_surface",
2101
2336
  message: "Page break insertion is not supported in suggesting mode.",
2102
2337
  }]);
2103
2338
  return;
2104
2339
  }
2105
- const context = getStoryMutationContext(runtime);
2340
+ const context = getStoryMutationContext(runtime, "insertPageBreak");
2106
2341
  if (!context) {
2107
2342
  return;
2108
2343
  }
@@ -2119,15 +2354,14 @@ function applyRuntimeInsertTable(
2119
2354
  runtime: WordReviewEditorRuntime,
2120
2355
  options: InsertTableOptions,
2121
2356
  ): void {
2122
- const snapshot = runtime.getRenderSnapshot();
2123
- if (snapshot.documentMode === "suggesting") {
2357
+ if (isSelectionSuggesting(runtime)) {
2124
2358
  runtime.emitBlockedCommand("insertTable", [{
2125
2359
  code: "unsupported_surface",
2126
2360
  message: "Table insertion is not supported in suggesting mode.",
2127
2361
  }]);
2128
2362
  return;
2129
2363
  }
2130
- const context = getStoryMutationContext(runtime);
2364
+ const context = getStoryMutationContext(runtime, "insertTable");
2131
2365
  if (!context) {
2132
2366
  return;
2133
2367
  }
@@ -2145,15 +2379,14 @@ function applyRuntimeInsertImage(
2145
2379
  runtime: WordReviewEditorRuntime,
2146
2380
  options: InsertImageOptions,
2147
2381
  ): void {
2148
- const snapshot = runtime.getRenderSnapshot();
2149
- if (snapshot.documentMode === "suggesting") {
2382
+ if (isSelectionSuggesting(runtime)) {
2150
2383
  runtime.emitBlockedCommand("insertImage", [{
2151
2384
  code: "unsupported_surface",
2152
2385
  message: "Image insertion is not supported in suggesting mode.",
2153
2386
  }]);
2154
2387
  return;
2155
2388
  }
2156
- const context = getStoryMutationContext(runtime);
2389
+ const context = getStoryMutationContext(runtime, "insertImage");
2157
2390
  if (!context) {
2158
2391
  return;
2159
2392
  }
@@ -2191,7 +2424,10 @@ function applyRuntimeImageResize(
2191
2424
  if (!canApplyRuntimeMutation(snapshot)) {
2192
2425
  return;
2193
2426
  }
2194
- if (snapshot.documentMode === "suggesting") {
2427
+ if (emitWorkflowBlockedMutation(runtime, "setImageLayout")) {
2428
+ return;
2429
+ }
2430
+ if (isSelectionSuggesting(runtime)) {
2195
2431
  runtime.emitBlockedCommand("setImageLayout", [{
2196
2432
  code: "unsupported_surface",
2197
2433
  message: "Image resize is not supported in suggesting mode.",
@@ -2222,15 +2458,17 @@ function applyRuntimeImageReposition(
2222
2458
  mediaId: string,
2223
2459
  offsets: { horizontalOffsetEmu?: number; verticalOffsetEmu?: number },
2224
2460
  ): void {
2225
- const snapshot = runtime.getRenderSnapshot();
2226
- if (snapshot.documentMode === "suggesting") {
2461
+ if (emitWorkflowBlockedMutation(runtime, "setImageFrame")) {
2462
+ return;
2463
+ }
2464
+ if (isSelectionSuggesting(runtime)) {
2227
2465
  runtime.emitBlockedCommand("setImageFrame", [{
2228
2466
  code: "unsupported_surface",
2229
2467
  message: "Image reposition is not supported in suggesting mode.",
2230
2468
  }]);
2231
2469
  return;
2232
2470
  }
2233
- const context = getStoryMutationContext(runtime);
2471
+ const context = getStoryMutationContext(runtime, "setImageFrame");
2234
2472
  if (!context) {
2235
2473
  return;
2236
2474
  }
@@ -2275,15 +2513,14 @@ function applyRuntimeTableStructureOperation(
2275
2513
  | { type: "split-cell" }
2276
2514
  | { type: "set-cell-background"; color: string },
2277
2515
  ): void {
2278
- const snapshot = runtime.getRenderSnapshot();
2279
- if (snapshot.documentMode === "suggesting") {
2516
+ if (isSelectionSuggesting(runtime)) {
2280
2517
  runtime.emitBlockedCommand(`table.${operation.type}`, [{
2281
2518
  code: "unsupported_surface",
2282
2519
  message: `Table operation "${operation.type}" is not supported in suggesting mode.`,
2283
2520
  }]);
2284
2521
  return;
2285
2522
  }
2286
- const context = getStoryMutationContext(runtime);
2523
+ const context = getStoryMutationContext(runtime, `table.${operation.type}`);
2287
2524
  if (!context) {
2288
2525
  return;
2289
2526
  }
@@ -2308,102 +2545,76 @@ function applyRuntimeTextCommand(
2308
2545
  | { type: "insert-hard-break" }
2309
2546
  | { type: "split-paragraph" },
2310
2547
  ): void {
2311
- const context = getStoryMutationContext(runtime);
2548
+ const snapshot = runtime.getRenderSnapshot();
2549
+ const context = getStoryMutationContext(runtime, getMountedTextCommandName(command));
2312
2550
  if (!context) {
2313
2551
  return;
2314
2552
  }
2315
2553
 
2554
+ const effectiveSelectionMode = runtime.getInteractionGuardSnapshot().effectiveMode;
2316
2555
  const listAwareResult = applyListAwareTextCommand(context, command);
2556
+ if (effectiveSelectionMode === "suggest" && listAwareResult) {
2557
+ runtime.emitBlockedCommand(getMountedTextCommandName(command), [{
2558
+ code: "suggesting_unsupported",
2559
+ message: "List structure changes are not supported in suggesting mode.",
2560
+ }]);
2561
+ return;
2562
+ }
2563
+
2317
2564
  if (listAwareResult) {
2318
2565
  dispatchStoryMutationResult(runtime, context, listAwareResult, context.timestamp);
2319
2566
  return;
2320
2567
  }
2321
2568
 
2322
- if (context.activeStory.kind === "main") {
2323
- switch (command.type) {
2324
- case "insert-text":
2325
- runtime.dispatch({ type: "text.insert", text: command.text });
2326
- return;
2327
- case "delete-backward":
2328
- runtime.dispatch({ type: "text.delete-backward" });
2329
- return;
2330
- case "delete-forward":
2331
- runtime.dispatch({ type: "text.delete-forward" });
2332
- return;
2333
- case "insert-tab":
2334
- runtime.dispatch({ type: "text.insert-tab" });
2335
- return;
2336
- case "outdent-tab":
2337
- return;
2338
- case "insert-hard-break":
2339
- runtime.dispatch({ type: "text.insert-hard-break" });
2340
- return;
2341
- case "split-paragraph":
2342
- runtime.dispatch({ type: "paragraph.split" });
2343
- return;
2344
- }
2569
+ switch (command.type) {
2570
+ case "insert-text":
2571
+ runtime.applyActiveStoryTextCommand({ type: "text.insert", text: command.text });
2572
+ return;
2573
+ case "delete-backward":
2574
+ runtime.applyActiveStoryTextCommand({ type: "text.delete-backward" });
2575
+ return;
2576
+ case "delete-forward":
2577
+ runtime.applyActiveStoryTextCommand({ type: "text.delete-forward" });
2578
+ return;
2579
+ case "insert-tab":
2580
+ runtime.applyActiveStoryTextCommand({ type: "text.insert-tab" });
2581
+ return;
2582
+ case "outdent-tab":
2583
+ return;
2584
+ case "insert-hard-break":
2585
+ runtime.applyActiveStoryTextCommand({ type: "text.insert-hard-break" });
2586
+ return;
2587
+ case "split-paragraph":
2588
+ runtime.applyActiveStoryTextCommand({ type: "paragraph.split" });
2589
+ return;
2345
2590
  }
2591
+ }
2346
2592
 
2347
- const selection = toRuntimeSelectionSnapshot(context.localSnapshot.selection);
2348
- const localResult = (() => {
2349
- switch (command.type) {
2350
- case "insert-text":
2351
- return insertTextInDocument(
2352
- context.localDocument,
2353
- selection,
2354
- command.text,
2355
- { timestamp: context.timestamp },
2356
- );
2357
- case "delete-backward":
2358
- return deleteSelectionOrBackward(
2359
- context.localDocument,
2360
- selection,
2361
- { timestamp: context.timestamp },
2362
- );
2363
- case "delete-forward":
2364
- return deleteSelectionOrForward(
2365
- context.localDocument,
2366
- selection,
2367
- { timestamp: context.timestamp },
2368
- );
2369
- case "insert-tab":
2370
- return insertTabInDocument(
2371
- context.localDocument,
2372
- selection,
2373
- { timestamp: context.timestamp },
2374
- );
2375
- case "outdent-tab":
2376
- return {
2377
- changed: false,
2378
- document: context.localDocument,
2379
- selection,
2380
- };
2381
- case "insert-hard-break":
2382
- return insertHardBreakInDocument(
2383
- context.localDocument,
2384
- selection,
2385
- { timestamp: context.timestamp },
2386
- );
2387
- case "split-paragraph":
2388
- return splitParagraphInDocument(
2389
- context.localDocument,
2390
- selection,
2391
- { timestamp: context.timestamp },
2392
- );
2393
- }
2394
- })();
2395
-
2396
- dispatchStoryMutationResult(
2397
- runtime,
2398
- context,
2399
- {
2400
- changed: "changed" in localResult ? localResult.changed : true,
2401
- document: localResult.document,
2402
- selection: localResult.selection,
2403
- mapping: "mapping" in localResult ? localResult.mapping : undefined,
2404
- },
2405
- context.timestamp,
2406
- );
2593
+ function getMountedTextCommandName(
2594
+ command:
2595
+ | { type: "insert-text"; text: string }
2596
+ | { type: "delete-backward" }
2597
+ | { type: "delete-forward" }
2598
+ | { type: "insert-tab" }
2599
+ | { type: "outdent-tab" }
2600
+ | { type: "insert-hard-break" }
2601
+ | { type: "split-paragraph" },
2602
+ ): string {
2603
+ switch (command.type) {
2604
+ case "insert-text":
2605
+ return "text.insert";
2606
+ case "delete-backward":
2607
+ return "text.delete-backward";
2608
+ case "delete-forward":
2609
+ return "text.delete-forward";
2610
+ case "insert-tab":
2611
+ case "outdent-tab":
2612
+ return "text.insert-tab";
2613
+ case "insert-hard-break":
2614
+ return "text.insert-hard-break";
2615
+ case "split-paragraph":
2616
+ return "paragraph.split";
2617
+ }
2407
2618
  }
2408
2619
 
2409
2620
  function applyListAwareTextCommand(
@@ -2598,8 +2809,21 @@ function canApplyRuntimeMutation(snapshot: RuntimeRenderSnapshot): boolean {
2598
2809
  return snapshot.isReady && !snapshot.readOnly && !snapshot.fatalError;
2599
2810
  }
2600
2811
 
2812
+ function emitWorkflowBlockedMutation(
2813
+ runtime: WordReviewEditorRuntime,
2814
+ command: string,
2815
+ ): boolean {
2816
+ const interactionGuardSnapshot = runtime.getInteractionGuardSnapshot();
2817
+ if (interactionGuardSnapshot.blockedReasons.length === 0) {
2818
+ return false;
2819
+ }
2820
+ runtime.emitBlockedCommand(command, interactionGuardSnapshot.blockedReasons);
2821
+ return true;
2822
+ }
2823
+
2601
2824
  function getStoryMutationContext(
2602
2825
  runtime: WordReviewEditorRuntime,
2826
+ command?: string,
2603
2827
  ): {
2604
2828
  timestamp: string;
2605
2829
  activeStory: EditorStoryTarget;
@@ -2611,6 +2835,9 @@ function getStoryMutationContext(
2611
2835
  if (!canApplyRuntimeMutation(snapshot)) {
2612
2836
  return null;
2613
2837
  }
2838
+ if (command && emitWorkflowBlockedMutation(runtime, command)) {
2839
+ return null;
2840
+ }
2614
2841
 
2615
2842
  const persistedDocument = runtime.getSessionState().canonicalDocument;
2616
2843
  const activeStory = snapshot.activeStory;
@@ -3034,6 +3261,20 @@ function deriveEditorViewMode(
3034
3261
  return reviewMode === "editing" ? "editing" : "review";
3035
3262
  }
3036
3263
 
3264
+ function resolveWordReviewEditorChromeVisibility(
3265
+ chromeVisibility: WordReviewEditorProps["chromeVisibility"],
3266
+ showReviewPanel: boolean,
3267
+ ): Partial<WordReviewEditorChromeVisibility> {
3268
+ const legacyVisibility =
3269
+ showReviewPanel
3270
+ ? {}
3271
+ : { reviewRail: false } satisfies Partial<WordReviewEditorChromeVisibility>;
3272
+ return {
3273
+ ...legacyVisibility,
3274
+ ...chromeVisibility,
3275
+ };
3276
+ }
3277
+
3037
3278
  function toRuntimeSelectionSnapshot(selection: PublicSelectionSnapshot) {
3038
3279
  return {
3039
3280
  anchor: selection.anchor,
@@ -3057,6 +3298,7 @@ function toRuntimeSelectionSnapshot(selection: PublicSelectionSnapshot) {
3057
3298
 
3058
3299
  function createSelectionFromAnchor(
3059
3300
  anchor: PublicSelectionSnapshot["activeRange"],
3301
+ storyTarget?: EditorStoryTarget,
3060
3302
  ): PublicSelectionSnapshot {
3061
3303
  switch (anchor.kind) {
3062
3304
  case "range":
@@ -3065,6 +3307,7 @@ function createSelectionFromAnchor(
3065
3307
  head: anchor.to,
3066
3308
  isCollapsed: anchor.from === anchor.to,
3067
3309
  activeRange: anchor,
3310
+ ...(storyTarget ? { storyTarget } : {}),
3068
3311
  };
3069
3312
  case "node":
3070
3313
  return {
@@ -3072,6 +3315,7 @@ function createSelectionFromAnchor(
3072
3315
  head: anchor.at,
3073
3316
  isCollapsed: true,
3074
3317
  activeRange: anchor,
3318
+ ...(storyTarget ? { storyTarget } : {}),
3075
3319
  };
3076
3320
  case "detached":
3077
3321
  return {
@@ -3079,6 +3323,7 @@ function createSelectionFromAnchor(
3079
3323
  head: anchor.lastKnownRange.to,
3080
3324
  isCollapsed: anchor.lastKnownRange.from === anchor.lastKnownRange.to,
3081
3325
  activeRange: anchor,
3326
+ ...(storyTarget ? { storyTarget } : {}),
3082
3327
  };
3083
3328
  }
3084
3329
  }
@@ -3184,7 +3429,13 @@ function interactionGuardSnapshotsEqual(
3184
3429
  if (left === right) {
3185
3430
  return true;
3186
3431
  }
3187
- return workflowBlockedReasonsEqual(left.blockedReasons, right.blockedReasons);
3432
+ return (
3433
+ left.effectiveMode === right.effectiveMode &&
3434
+ left.matchedScopeId === right.matchedScopeId &&
3435
+ left.matchedScopeMode === right.matchedScopeMode &&
3436
+ left.disabledReason === right.disabledReason &&
3437
+ workflowBlockedReasonsEqual(left.blockedReasons, right.blockedReasons)
3438
+ );
3188
3439
  }
3189
3440
 
3190
3441
  function workflowBlockedReasonsEqual(
@@ -3256,7 +3507,14 @@ function editorAnchorProjectionEqual(
3256
3507
  function createSelectionToolbarSelectionKey(
3257
3508
  selection: RuntimeRenderSnapshot["selection"],
3258
3509
  activeStory: EditorStoryTarget,
3510
+ activeRevisionId?: string,
3259
3511
  ): string | null {
3512
+ if (activeRevisionId) {
3513
+ return JSON.stringify({
3514
+ story: activeStory,
3515
+ revisionId: activeRevisionId,
3516
+ });
3517
+ }
3260
3518
  if (selection.isCollapsed || selection.activeRange.kind !== "range") {
3261
3519
  return null;
3262
3520
  }
@@ -3275,6 +3533,8 @@ function buildSelectionToolbarModel(args: {
3275
3533
  documentNavigation: ReturnType<WordReviewEditorRuntime["getDocumentNavigationSnapshot"]>;
3276
3534
  styleCatalog: StyleCatalogSnapshot;
3277
3535
  formattingState: FormattingStateSnapshot;
3536
+ workflowScopeSnapshot?: WorkflowScopeSnapshot | null;
3537
+ interactionGuardSnapshot?: InteractionGuardSnapshot;
3278
3538
  addCommentDisabledReason?: string;
3279
3539
  }): SelectionToolbarModel | null {
3280
3540
  const {
@@ -3284,6 +3544,8 @@ function buildSelectionToolbarModel(args: {
3284
3544
  documentNavigation,
3285
3545
  styleCatalog,
3286
3546
  formattingState,
3547
+ workflowScopeSnapshot,
3548
+ interactionGuardSnapshot,
3287
3549
  addCommentDisabledReason,
3288
3550
  } = args;
3289
3551
 
@@ -3304,6 +3566,14 @@ function buildSelectionToolbarModel(args: {
3304
3566
 
3305
3567
  const badges = [
3306
3568
  createSelectionToolbarStoryBadge(viewState.activeStory),
3569
+ createSelectionToolbarWorkflowBadge(
3570
+ resolveSelectionWorkflowPosture(
3571
+ snapshot,
3572
+ viewState,
3573
+ workflowScopeSnapshot,
3574
+ interactionGuardSnapshot,
3575
+ ),
3576
+ ),
3307
3577
  viewState.workspaceMode === "page" && documentNavigation.pageCount > 0
3308
3578
  ? { label: `Page ${documentNavigation.activePageIndex + 1}` as const }
3309
3579
  : null,
@@ -3311,15 +3581,137 @@ function buildSelectionToolbarModel(args: {
3311
3581
  createSelectionToolbarListBadge(viewState),
3312
3582
  ].filter((badge): badge is SelectionToolbarModel["badges"][number] => Boolean(badge));
3313
3583
 
3584
+ const workflowPosture = resolveSelectionWorkflowPosture(
3585
+ snapshot,
3586
+ viewState,
3587
+ workflowScopeSnapshot,
3588
+ interactionGuardSnapshot,
3589
+ );
3590
+ const canToggleFormatting = workflowPosture.mode === "edit";
3591
+ const canAddComment = workflowPosture.mode === "view" || workflowPosture.mode === "blocked"
3592
+ ? false
3593
+ : capabilities.canAddComment;
3594
+ const disabledReason =
3595
+ workflowPosture.disabledReason ??
3596
+ (canAddComment ? undefined : addCommentDisabledReason);
3597
+
3314
3598
  return {
3315
3599
  previewText,
3316
3600
  badges,
3317
- canToggleFormatting: true,
3601
+ canToggleFormatting,
3318
3602
  boldActive: formattingState.bold,
3319
3603
  italicActive: formattingState.italic,
3320
3604
  underlineActive: formattingState.underline,
3321
- canAddComment: capabilities.canAddComment,
3322
- ...(addCommentDisabledReason ? { disabledReason: addCommentDisabledReason } : {}),
3605
+ canAddComment,
3606
+ ...(disabledReason ? { disabledReason } : {}),
3607
+ };
3608
+ }
3609
+
3610
+ function buildSuggestionCardModel(args: {
3611
+ snapshot: RuntimeRenderSnapshot;
3612
+ viewState: ReturnType<WordReviewEditorRuntime["getViewState"]>;
3613
+ capabilities: ReturnType<typeof deriveCapabilities>;
3614
+ workflowScopeSnapshot?: WorkflowScopeSnapshot | null;
3615
+ interactionGuardSnapshot?: InteractionGuardSnapshot;
3616
+ activeRevisionId?: string;
3617
+ suppressedSuggestionRevisionId?: string | null;
3618
+ addCommentDisabledReason?: string;
3619
+ }): SuggestionCardModel | null {
3620
+ const {
3621
+ snapshot,
3622
+ viewState,
3623
+ capabilities,
3624
+ workflowScopeSnapshot,
3625
+ interactionGuardSnapshot,
3626
+ activeRevisionId,
3627
+ suppressedSuggestionRevisionId,
3628
+ addCommentDisabledReason,
3629
+ } = args;
3630
+
3631
+ if (
3632
+ !snapshot.surface ||
3633
+ !capabilities.canEdit ||
3634
+ viewState.viewMode === "view"
3635
+ ) {
3636
+ return null;
3637
+ }
3638
+
3639
+ const activeRange =
3640
+ !snapshot.selection.isCollapsed && snapshot.selection.activeRange.kind === "range"
3641
+ ? snapshot.selection.activeRange
3642
+ : null;
3643
+ const selectionFrom = activeRange
3644
+ ? Math.min(activeRange.from, activeRange.to)
3645
+ : null;
3646
+ const selectionTo = activeRange
3647
+ ? Math.max(activeRange.from, activeRange.to)
3648
+ : null;
3649
+ const candidateRevisions = snapshot.trackedChanges.revisions.filter((revision) =>
3650
+ storyTargetsEqual(revision.storyTarget ?? { kind: "main" }, viewState.activeStory) &&
3651
+ revision.status === "active" &&
3652
+ revision.actionability === "actionable" &&
3653
+ revision.anchor.kind === "range" &&
3654
+ (
3655
+ activeRevisionId === revision.revisionId ||
3656
+ (
3657
+ selectionFrom !== null &&
3658
+ selectionTo !== null &&
3659
+ rangesOverlap(
3660
+ selectionFrom,
3661
+ selectionTo,
3662
+ revision.anchor.from,
3663
+ revision.anchor.to,
3664
+ )
3665
+ )
3666
+ )
3667
+ );
3668
+ const focusedRevision = (
3669
+ activeRevisionId
3670
+ ? candidateRevisions.find((revision) => revision.revisionId === activeRevisionId)
3671
+ : null
3672
+ ) ?? candidateRevisions[0];
3673
+
3674
+ if (!focusedRevision || focusedRevision.revisionId === suppressedSuggestionRevisionId) {
3675
+ return null;
3676
+ }
3677
+
3678
+ const badges = [
3679
+ createSelectionToolbarStoryBadge(viewState.activeStory),
3680
+ workflowScopeSnapshot?.activeWorkItem?.title
3681
+ ? {
3682
+ label: workflowScopeSnapshot.activeWorkItem.title,
3683
+ tone: "accent" as const,
3684
+ }
3685
+ : null,
3686
+ ].filter((badge): badge is SuggestionCardModel["badges"][number] => Boolean(badge));
3687
+ const workflowPosture = resolveSelectionWorkflowPosture(
3688
+ snapshot,
3689
+ viewState,
3690
+ workflowScopeSnapshot,
3691
+ interactionGuardSnapshot,
3692
+ );
3693
+ const canReviewSuggestion = workflowPosture.mode === "edit" || workflowPosture.mode === "suggest";
3694
+ const canAddComment = workflowPosture.mode === "view" || workflowPosture.mode === "blocked"
3695
+ ? false
3696
+ : capabilities.canAddComment;
3697
+ const disabledReason =
3698
+ workflowPosture.disabledReason ??
3699
+ (canAddComment ? undefined : addCommentDisabledReason);
3700
+
3701
+ return {
3702
+ revisionId: focusedRevision.revisionId,
3703
+ kindLabel: getSuggestionKindLabel(focusedRevision.kind),
3704
+ previewText:
3705
+ focusedRevision.excerpt ??
3706
+ focusedRevision.detail ??
3707
+ focusedRevision.label ??
3708
+ "Suggested change",
3709
+ badges,
3710
+ canAccept: canReviewSuggestion && capabilities.canAcceptChange && focusedRevision.canAccept,
3711
+ canReject: canReviewSuggestion && capabilities.canRejectChange && focusedRevision.canReject,
3712
+ canEditSuggestion: canReviewSuggestion,
3713
+ canAddComment,
3714
+ ...(disabledReason ? { disabledReason } : {}),
3323
3715
  };
3324
3716
  }
3325
3717
 
@@ -3377,6 +3769,149 @@ function createSelectionToolbarListBadge(
3377
3769
  };
3378
3770
  }
3379
3771
 
3772
+ function createSelectionToolbarWorkflowBadge(
3773
+ posture: ReturnType<typeof resolveSelectionWorkflowPosture>,
3774
+ ): SelectionToolbarModel["badges"][number] | null {
3775
+ switch (posture.mode) {
3776
+ case "suggest":
3777
+ return { label: "Suggest", tone: "accent" };
3778
+ case "comment":
3779
+ return { label: "Comment only", tone: "accent" };
3780
+ case "view":
3781
+ return { label: "View only" };
3782
+ case "blocked":
3783
+ return { label: "Blocked" };
3784
+ default:
3785
+ return null;
3786
+ }
3787
+ }
3788
+
3789
+ function resolveSelectionWorkflowPosture(
3790
+ snapshot: RuntimeRenderSnapshot,
3791
+ viewState: ReturnType<WordReviewEditorRuntime["getViewState"]>,
3792
+ workflowScopeSnapshot?: WorkflowScopeSnapshot | null,
3793
+ interactionGuardSnapshot?: InteractionGuardSnapshot,
3794
+ ): {
3795
+ mode: "edit" | "suggest" | "comment" | "view" | "blocked";
3796
+ disabledReason?: string;
3797
+ } {
3798
+ const blockedReasons =
3799
+ interactionGuardSnapshot?.blockedReasons ??
3800
+ workflowScopeSnapshot?.blockedReasons ??
3801
+ [];
3802
+ const blockingReason = blockedReasons[0];
3803
+ if (blockingReason) {
3804
+ if (blockingReason.code === "workflow_comment_only") {
3805
+ return {
3806
+ mode: "comment",
3807
+ disabledReason: blockingReason.message,
3808
+ };
3809
+ }
3810
+ if (blockingReason.code === "workflow_view_only") {
3811
+ return {
3812
+ mode: "view",
3813
+ disabledReason: blockingReason.message,
3814
+ };
3815
+ }
3816
+ return {
3817
+ mode: "blocked",
3818
+ disabledReason: blockingReason.message,
3819
+ };
3820
+ }
3821
+
3822
+ if (interactionGuardSnapshot) {
3823
+ if (interactionGuardSnapshot.effectiveMode === "suggest") {
3824
+ return {
3825
+ mode: "suggest",
3826
+ disabledReason:
3827
+ interactionGuardSnapshot.disabledReason ??
3828
+ "Suggestion authoring is active here; direct formatting changes are blocked.",
3829
+ };
3830
+ }
3831
+ if (interactionGuardSnapshot.effectiveMode === "comment") {
3832
+ return {
3833
+ mode: "comment",
3834
+ disabledReason: interactionGuardSnapshot.disabledReason,
3835
+ };
3836
+ }
3837
+ if (interactionGuardSnapshot.effectiveMode === "view") {
3838
+ return {
3839
+ mode: "view",
3840
+ disabledReason: interactionGuardSnapshot.disabledReason,
3841
+ };
3842
+ }
3843
+ if (interactionGuardSnapshot.effectiveMode === "blocked") {
3844
+ return {
3845
+ mode: "blocked",
3846
+ disabledReason: interactionGuardSnapshot.disabledReason,
3847
+ };
3848
+ }
3849
+ }
3850
+
3851
+ const activeRange =
3852
+ !snapshot.selection.isCollapsed && snapshot.selection.activeRange.kind === "range"
3853
+ ? snapshot.selection.activeRange
3854
+ : null;
3855
+ const matchingScope = activeRange && workflowScopeSnapshot
3856
+ ? workflowScopeSnapshot.scopes.find((scope) => {
3857
+ const scopeStoryTarget = scope.storyTarget ?? { kind: "main" as const };
3858
+ if (!storyTargetsEqual(scopeStoryTarget, viewState.activeStory)) {
3859
+ return false;
3860
+ }
3861
+ if (scope.anchor.kind === "detached") {
3862
+ return false;
3863
+ }
3864
+ const scopeFrom = scope.anchor.kind === "range" ? scope.anchor.from : scope.anchor.at;
3865
+ const scopeTo = scope.anchor.kind === "range" ? scope.anchor.to : scope.anchor.at;
3866
+ return activeRange.from >= scopeFrom && activeRange.to <= scopeTo;
3867
+ })
3868
+ : null;
3869
+
3870
+ if (matchingScope?.mode === "suggest") {
3871
+ return {
3872
+ mode: "suggest",
3873
+ disabledReason: "Suggestion authoring is active here; direct formatting changes are blocked.",
3874
+ };
3875
+ }
3876
+ if (matchingScope?.mode === "comment") {
3877
+ return {
3878
+ mode: "comment",
3879
+ disabledReason: `Scope "${matchingScope.label ?? matchingScope.scopeId}" allows comments only.`,
3880
+ };
3881
+ }
3882
+ if (matchingScope?.mode === "view") {
3883
+ return {
3884
+ mode: "view",
3885
+ disabledReason: `Scope "${matchingScope.label ?? matchingScope.scopeId}" is view-only.`,
3886
+ };
3887
+ }
3888
+ return { mode: "edit" };
3889
+ }
3890
+
3891
+ function rangesOverlap(
3892
+ leftFrom: number,
3893
+ leftTo: number,
3894
+ rightFrom: number,
3895
+ rightTo: number,
3896
+ ): boolean {
3897
+ return leftFrom < rightTo && rightFrom < leftTo;
3898
+ }
3899
+
3900
+ function getSuggestionKindLabel(kind: TrackedChangeEntrySnapshot["kind"]): string {
3901
+ switch (kind) {
3902
+ case "insertion":
3903
+ return "Suggested insertion";
3904
+ case "deletion":
3905
+ return "Suggested deletion";
3906
+ case "formatting":
3907
+ return "Suggested formatting change";
3908
+ case "property-change":
3909
+ return "Suggested property change";
3910
+ case "move":
3911
+ return "Suggested move";
3912
+ }
3913
+ }
3914
+
3380
3915
  function buildActiveImageContext(args: {
3381
3916
  canonicalDocument: PersistedEditorSnapshot["canonicalDocument"];
3382
3917
  selection: RuntimeRenderSnapshot["selection"];