@beyondwork/docx-react-component 1.0.21 → 1.0.23

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 +763 -38
  2. package/package.json +25 -36
  3. package/src/api/public-types.ts +66 -1
  4. package/src/core/commands/index.ts +574 -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 +661 -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 +5 -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 +44 -16
  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 +4 -0
  28. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +127 -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 {
@@ -495,11 +521,13 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
495
521
  readOnly = false,
496
522
  reviewMode = "review",
497
523
  showReviewPanel = true,
524
+ chromeVisibility,
498
525
  } = props;
499
526
 
500
527
  const [activeRailTab, setActiveRailTab] = useState<ReviewRailTab>("comments");
501
528
  const [showTrackedChanges, setShowTrackedChanges] = useState(false);
502
529
  const [activeRevisionId, setActiveRevisionId] = useState<string | undefined>();
530
+ const [suppressedSuggestionRevisionId, setSuppressedSuggestionRevisionId] = useState<string | null>(null);
503
531
  const [selectionToolbarAnchor, setSelectionToolbarAnchor] = useState<SelectionToolbarAnchor | null>(null);
504
532
  const [selectionToolbarDismissedKey, setSelectionToolbarDismissedKey] = useState<string | null>(null);
505
533
  const [selectionToolbarFocusWithin, setSelectionToolbarFocusWithin] = useState(false);
@@ -644,7 +672,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
644
672
  getValue: () => runtime.getInteractionGuardSnapshot(),
645
673
  }
646
674
  : null,
647
- { blockedReasons: [] } satisfies InteractionGuardSnapshot,
675
+ { effectiveMode: "edit", blockedReasons: [] } satisfies InteractionGuardSnapshot,
648
676
  interactionGuardSnapshotsEqual,
649
677
  );
650
678
  const workflowMarkupSnapshot = useMemo(
@@ -655,11 +683,10 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
655
683
  () => deriveVisibleWorkflowBlockedRails(snapshot.surface, workflowMarkupSnapshot),
656
684
  [snapshot.surface, workflowMarkupSnapshot],
657
685
  );
658
- const sessionState = useMemo(
659
- () => (runtime ? runtime.getSessionState() : loadingSessionState),
660
- [loadingSessionState, runtime, snapshot.revisionToken],
686
+ const canonicalDocument = useMemo(
687
+ () => (runtime ? runtime.getCanonicalDocument() : loadingSessionState.canonicalDocument),
688
+ [loadingSessionState.canonicalDocument, runtime, snapshot.revisionToken],
661
689
  );
662
- const canonicalDocument = sessionState.canonicalDocument;
663
690
  const effectiveViewMode = deriveEditorViewMode(snapshot.readOnly, reviewMode);
664
691
 
665
692
  useEffect(() => {
@@ -901,14 +928,20 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
901
928
  (r) => r.revisionId === revisionId,
902
929
  );
903
930
  if (!revision || revision.anchor.kind === "detached") return;
904
- applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(revision.anchor));
931
+ applyRuntimeSelection(
932
+ activeRuntime,
933
+ createSelectionFromAnchor(revision.anchor, revision.storyTarget),
934
+ );
905
935
  },
906
936
  scrollToComment: (commentId: string) => {
907
937
  const comment = activeRuntime.getRenderSnapshot().comments.threads.find(
908
938
  (t) => t.commentId === commentId,
909
939
  );
910
940
  if (!comment || comment.anchor.kind === "detached") return;
911
- applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(comment.anchor));
941
+ applyRuntimeSelection(
942
+ activeRuntime,
943
+ createSelectionFromAnchor(comment.anchor),
944
+ );
912
945
  },
913
946
  openStory: (target: EditorStoryTarget) => {
914
947
  activeRuntime.openStory(target);
@@ -982,6 +1015,15 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
982
1015
  getWorkflowMarkupSnapshot: () => {
983
1016
  return clonePublicValue(activeRuntime.getWorkflowMarkupSnapshot());
984
1017
  },
1018
+ setHostAnnotationOverlay: (overlay) => {
1019
+ activeRuntime.setHostAnnotationOverlay(clonePublicValue(overlay));
1020
+ },
1021
+ clearHostAnnotationOverlay: () => {
1022
+ activeRuntime.clearHostAnnotationOverlay();
1023
+ },
1024
+ getHostAnnotationSnapshot: () => {
1025
+ return clonePublicValue(activeRuntime.getHostAnnotationSnapshot());
1026
+ },
985
1027
  getWorkflowCandidateRanges: (options) => {
986
1028
  return clonePublicValue(activeRuntime.getWorkflowCandidateRanges(options));
987
1029
  },
@@ -1080,11 +1122,14 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1080
1122
  snapshot.trackedChanges.totalCount,
1081
1123
  ]);
1082
1124
 
1083
- function focusAnchor(anchor: PublicSelectionSnapshot["activeRange"]): void {
1125
+ function focusAnchor(
1126
+ anchor: PublicSelectionSnapshot["activeRange"],
1127
+ storyTarget?: EditorStoryTarget,
1128
+ ): void {
1084
1129
  if (anchor.kind === "detached") {
1085
1130
  return;
1086
1131
  }
1087
- applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(anchor));
1132
+ applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(anchor, storyTarget));
1088
1133
  }
1089
1134
 
1090
1135
  function addReviewComment(): string | null {
@@ -1129,7 +1174,11 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1129
1174
  reviewMode,
1130
1175
  workflowScopeSnapshot,
1131
1176
  );
1132
- const capabilities = showReviewPanel
1177
+ const resolvedChromeVisibility = resolveWordReviewEditorChromeVisibility(
1178
+ chromeVisibility,
1179
+ showReviewPanel,
1180
+ );
1181
+ const capabilities = resolvedChromeVisibility.reviewRail
1133
1182
  ? derivedCapabilities
1134
1183
  : { ...derivedCapabilities, reviewRailVisible: false };
1135
1184
  const formattingState = getFormattingStateFromRenderSnapshot(snapshot);
@@ -1152,7 +1201,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1152
1201
  }),
1153
1202
  [canonicalDocument, snapshot.selection, snapshot.surface, viewState.activeStory],
1154
1203
  );
1155
- const sourcePackage = sessionState.sourcePackage;
1204
+ const sourcePackage = runtime
1205
+ ? runtime.getSourcePackage()
1206
+ : loadingSessionState.sourcePackage;
1156
1207
  const mediaPreviewCatalogKey = Object.values(canonicalDocument.media.items)
1157
1208
  .map((item) =>
1158
1209
  [
@@ -1214,14 +1265,26 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1214
1265
  documentNavigation,
1215
1266
  styleCatalog,
1216
1267
  formattingState,
1268
+ workflowScopeSnapshot,
1269
+ interactionGuardSnapshot,
1270
+ addCommentDisabledReason,
1271
+ });
1272
+ const suggestionCard = buildSuggestionCardModel({
1273
+ snapshot,
1274
+ viewState,
1275
+ capabilities,
1276
+ workflowScopeSnapshot,
1277
+ interactionGuardSnapshot,
1278
+ activeRevisionId,
1279
+ suppressedSuggestionRevisionId,
1217
1280
  addCommentDisabledReason,
1218
1281
  });
1219
1282
  const selectionToolbarSelectionKey = useMemo(
1220
- () => createSelectionToolbarSelectionKey(snapshot.selection, viewState.activeStory),
1221
- [snapshot.selection, viewState.activeStory],
1283
+ () => createSelectionToolbarSelectionKey(snapshot.selection, viewState.activeStory, activeRevisionId),
1284
+ [activeRevisionId, snapshot.selection, viewState.activeStory],
1222
1285
  );
1223
- const shouldRenderSelectionToolbar = Boolean(
1224
- selectionToolbar &&
1286
+ const shouldRenderSelectionChrome = Boolean(
1287
+ (selectionToolbar || suggestionCard) &&
1225
1288
  selectionToolbarSelectionKey &&
1226
1289
  selectionToolbarDismissedKey !== selectionToolbarSelectionKey &&
1227
1290
  (viewState.isFocused || selectionToolbarFocusWithin),
@@ -1341,6 +1404,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1341
1404
 
1342
1405
  useEffect(() => {
1343
1406
  if (!selectionToolbarSelectionKey) {
1407
+ setSuppressedSuggestionRevisionId(null);
1344
1408
  setSelectionToolbarDismissedKey(null);
1345
1409
  setSelectionToolbarFocusWithin(false);
1346
1410
  setSelectionToolbarAnchor(null);
@@ -1350,17 +1414,18 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1350
1414
 
1351
1415
  if (lastSelectionToolbarKeyRef.current !== selectionToolbarSelectionKey) {
1352
1416
  lastSelectionToolbarKeyRef.current = selectionToolbarSelectionKey;
1417
+ setSuppressedSuggestionRevisionId(null);
1353
1418
  setSelectionToolbarDismissedKey(null);
1354
1419
  setSelectionToolbarFocusWithin(false);
1355
1420
  }
1356
1421
  }, [selectionToolbarSelectionKey]);
1357
1422
 
1358
1423
  useEffect(() => {
1359
- if (!selectionToolbar) {
1424
+ if (!selectionToolbar && !suggestionCard) {
1360
1425
  setSelectionToolbarAnchor(null);
1361
1426
  setSelectionToolbarFocusWithin(false);
1362
1427
  }
1363
- }, [selectionToolbar]);
1428
+ }, [selectionToolbar, suggestionCard]);
1364
1429
 
1365
1430
  useEffect(() => {
1366
1431
  const shell = shellRef.current;
@@ -1389,9 +1454,26 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1389
1454
  }, [loadError, snapshot.fatalError]);
1390
1455
 
1391
1456
  function handleShellKeyDownCapture(event: React.KeyboardEvent<HTMLDivElement>): void {
1457
+ const targetWithinDocument = isTargetWithinDocumentSurface(event.target);
1458
+ const isUndoShortcut = (event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === "z";
1459
+ const isRedoShortcut =
1460
+ ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === "z") ||
1461
+ (event.ctrlKey && event.key.toLowerCase() === "y");
1462
+
1463
+ if ((isUndoShortcut || isRedoShortcut) && targetWithinDocument) {
1464
+ event.preventDefault();
1465
+ event.stopPropagation();
1466
+ if (isUndoShortcut) {
1467
+ activeRuntime.undo();
1468
+ } else {
1469
+ activeRuntime.redo();
1470
+ }
1471
+ return;
1472
+ }
1473
+
1392
1474
  if (
1393
1475
  event.key === "Escape" &&
1394
- shouldRenderSelectionToolbar &&
1476
+ shouldRenderSelectionChrome &&
1395
1477
  (isTargetWithinDocumentSurface(event.target) || isTargetWithinSelectionToolbar(event.target))
1396
1478
  ) {
1397
1479
  event.preventDefault();
@@ -1430,6 +1512,13 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1430
1512
  onOutdentTab: () => applyRuntimeTextCommand(activeRuntime, { type: "outdent-tab" }),
1431
1513
  onInsertHardBreak: () => applyRuntimeTextCommand(activeRuntime, { type: "insert-hard-break" }),
1432
1514
  onSplitParagraph: () => applyRuntimeTextCommand(activeRuntime, { type: "split-paragraph" }),
1515
+ onUndo: () => activeRuntime.undo(),
1516
+ onRedo: () => activeRuntime.redo(),
1517
+ onBlockedInput: (command: "paste" | "drop", message: string) =>
1518
+ activeRuntime.emitBlockedCommand(command, [{
1519
+ code: "unsupported_surface",
1520
+ message,
1521
+ }]),
1433
1522
  };
1434
1523
 
1435
1524
  const reviewCallbacks = {
@@ -1458,7 +1547,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1458
1547
  },
1459
1548
  onOpenRevision: (revision: typeof snapshot.trackedChanges.revisions[number]) => {
1460
1549
  setActiveRevisionId(revision.revisionId);
1461
- focusAnchor(revision.anchor);
1550
+ focusAnchor(revision.anchor, revision.storyTarget);
1462
1551
  setActiveRailTab("changes");
1463
1552
  },
1464
1553
  onAcceptRevision: (revisionId: string) => {
@@ -1623,6 +1712,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1623
1712
  workflowScopes={workflowScopeSnapshot?.scopes}
1624
1713
  workflowCandidates={workflowScopeSnapshot?.candidates}
1625
1714
  workflowBlockedReasons={workflowBlockedRails}
1715
+ activeWorkflowWorkItemId={workflowScopeSnapshot?.activeWorkItemId ?? null}
1716
+ activeWorkflowScopeIds={workflowScopeSnapshot?.activeWorkItem?.scopeIds ?? []}
1626
1717
  onSelectionToolbarAnchorChange={handleSelectionToolbarAnchorChange}
1627
1718
  {...editorCallbacks}
1628
1719
  onCommentActivated={(commentId) => {
@@ -1656,6 +1747,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1656
1747
  markupDisplay={liveMarkupDisplay}
1657
1748
  currentUserId={currentUser.userId}
1658
1749
  capabilities={capabilities}
1750
+ chromeVisibility={resolvedChromeVisibility}
1659
1751
  documentNavigation={documentNavigation}
1660
1752
  reviewMode={reviewMode}
1661
1753
  workspaceMode={viewState.workspaceMode}
@@ -1668,9 +1760,28 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1668
1760
  showTrackedChanges={showTrackedChanges}
1669
1761
  workflowScopeSnapshot={workflowScopeSnapshot}
1670
1762
  interactionGuardSnapshot={interactionGuardSnapshot}
1671
- selectionToolbar={shouldRenderSelectionToolbar ? selectionToolbar : null}
1672
- selectionToolbarAnchor={shouldRenderSelectionToolbar ? selectionToolbarAnchor : null}
1763
+ selectionToolbar={shouldRenderSelectionChrome ? selectionToolbar : null}
1764
+ suggestionCard={shouldRenderSelectionChrome ? suggestionCard : null}
1765
+ selectionToolbarAnchor={shouldRenderSelectionChrome ? selectionToolbarAnchor : null}
1673
1766
  onAddCommentFromSelection={addSelectionToolbarComment}
1767
+ onAddCommentFromSuggestion={addSelectionToolbarComment}
1768
+ onAcceptSuggestion={suggestionCard
1769
+ ? () => {
1770
+ activeRuntime.acceptChange(suggestionCard.revisionId);
1771
+ dismissSelectionToolbar("chrome-action");
1772
+ }
1773
+ : undefined}
1774
+ onRejectSuggestion={suggestionCard
1775
+ ? () => {
1776
+ activeRuntime.rejectChange(suggestionCard.revisionId);
1777
+ dismissSelectionToolbar("chrome-action");
1778
+ }
1779
+ : undefined}
1780
+ onEditSuggestion={suggestionCard
1781
+ ? () => {
1782
+ setSuppressedSuggestionRevisionId(suggestionCard.revisionId);
1783
+ }
1784
+ : undefined}
1674
1785
  onDismissSelectionToolbar={() => dismissSelectionToolbar("chrome-action")}
1675
1786
  onSelectionToolbarFocusCapture={handleSelectionToolbarFocusCapture}
1676
1787
  onSelectionToolbarBlurCapture={handleSelectionToolbarBlurCapture}
@@ -1696,7 +1807,10 @@ function applyRuntimeFormattingOperation(
1696
1807
  | { type: "indent" }
1697
1808
  | { type: "outdent" },
1698
1809
  ): void {
1699
- const context = getStoryMutationContext(runtime);
1810
+ if (emitSuggestingUnsupportedMutation(runtime, getFormattingOperationCommandName(operation))) {
1811
+ return;
1812
+ }
1813
+ const context = getStoryMutationContext(runtime, getFormattingOperationCommandName(operation));
1700
1814
  if (!context) {
1701
1815
  return;
1702
1816
  }
@@ -1764,7 +1878,10 @@ function applyRuntimeParagraphStyle(
1764
1878
  runtime: WordReviewEditorRuntime,
1765
1879
  styleId: string | null,
1766
1880
  ): void {
1767
- const context = getStoryMutationContext(runtime);
1881
+ if (emitSuggestingUnsupportedMutation(runtime, "setParagraphStyle")) {
1882
+ return;
1883
+ }
1884
+ const context = getStoryMutationContext(runtime, "setParagraphStyle");
1768
1885
  if (!context) {
1769
1886
  return;
1770
1887
  }
@@ -1789,7 +1906,10 @@ function applyRuntimeTableStyle(
1789
1906
  runtime: WordReviewEditorRuntime,
1790
1907
  styleId: string | null,
1791
1908
  ): void {
1792
- const context = getStoryMutationContext(runtime);
1909
+ if (emitSuggestingUnsupportedMutation(runtime, "setTableStyle")) {
1910
+ return;
1911
+ }
1912
+ const context = getStoryMutationContext(runtime, "setTableStyle");
1793
1913
  if (!context) {
1794
1914
  return;
1795
1915
  }
@@ -1819,7 +1939,10 @@ function applyRuntimeParagraphIndentation(
1819
1939
  hanging?: number;
1820
1940
  },
1821
1941
  ): void {
1822
- const context = getStoryMutationContext(runtime);
1942
+ if (emitSuggestingUnsupportedMutation(runtime, "setParagraphIndentation")) {
1943
+ return;
1944
+ }
1945
+ const context = getStoryMutationContext(runtime, "setParagraphIndentation");
1823
1946
  if (!context) {
1824
1947
  return;
1825
1948
  }
@@ -1845,7 +1968,10 @@ function applyRuntimeParagraphTabStops(
1845
1968
  runtime: WordReviewEditorRuntime,
1846
1969
  tabStops: Array<{ pos: number; val?: string; leader?: string }>,
1847
1970
  ): void {
1848
- const context = getStoryMutationContext(runtime);
1971
+ if (emitSuggestingUnsupportedMutation(runtime, "setParagraphTabStops")) {
1972
+ return;
1973
+ }
1974
+ const context = getStoryMutationContext(runtime, "setParagraphTabStops");
1849
1975
  if (!context) {
1850
1976
  return;
1851
1977
  }
@@ -1871,7 +1997,18 @@ function applyRuntimeNumberingFlow(
1871
1997
  runtime: WordReviewEditorRuntime,
1872
1998
  operation: { type: "restart"; startAt?: number } | { type: "continue" },
1873
1999
  ): void {
1874
- const context = getStoryMutationContext(runtime);
2000
+ if (
2001
+ emitSuggestingUnsupportedMutation(
2002
+ runtime,
2003
+ operation.type === "restart" ? "restartNumbering" : "continueNumbering",
2004
+ )
2005
+ ) {
2006
+ return;
2007
+ }
2008
+ const context = getStoryMutationContext(
2009
+ runtime,
2010
+ operation.type === "restart" ? "restartNumbering" : "continueNumbering",
2011
+ );
1875
2012
  if (!context) {
1876
2013
  return;
1877
2014
  }
@@ -1916,7 +2053,10 @@ function applyRuntimeInsertSectionBreak(
1916
2053
  if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
1917
2054
  return;
1918
2055
  }
1919
- if (snapshot.documentMode === "suggesting") {
2056
+ if (emitWorkflowBlockedMutation(runtime, "insertSectionBreak")) {
2057
+ return;
2058
+ }
2059
+ if (isSelectionSuggesting(runtime)) {
1920
2060
  runtime.emitBlockedCommand("insertSectionBreak", [{
1921
2061
  code: "unsupported_surface",
1922
2062
  message: "Section break insertion is not supported in suggesting mode.",
@@ -1952,6 +2092,56 @@ function applyRuntimeInsertSectionBreak(
1952
2092
  );
1953
2093
  }
1954
2094
 
2095
+ function emitSuggestingUnsupportedMutation(
2096
+ runtime: WordReviewEditorRuntime,
2097
+ command: string,
2098
+ ): boolean {
2099
+ if (!isSelectionSuggesting(runtime)) {
2100
+ return false;
2101
+ }
2102
+
2103
+ runtime.emitBlockedCommand(command, [{
2104
+ code: "suggesting_unsupported",
2105
+ message: `"${command}" is not supported in suggesting mode.`,
2106
+ }]);
2107
+ return true;
2108
+ }
2109
+
2110
+ function isSelectionSuggesting(runtime: WordReviewEditorRuntime): boolean {
2111
+ return runtime.getInteractionGuardSnapshot().effectiveMode === "suggest";
2112
+ }
2113
+
2114
+ function getFormattingOperationCommandName(
2115
+ operation:
2116
+ | { type: "toggle"; mark: "bold" | "italic" | "underline" | "strikethrough" | "superscript" | "subscript" }
2117
+ | { type: "set-font-family"; fontFamily: string | null }
2118
+ | { type: "set-font-size"; size: number | null }
2119
+ | { type: "set-text-color"; color: string | null }
2120
+ | { type: "set-highlight-color"; color: string | null }
2121
+ | { type: "set-alignment"; alignment: FormattingAlignment }
2122
+ | { type: "indent" }
2123
+ | { type: "outdent" },
2124
+ ): string {
2125
+ switch (operation.type) {
2126
+ case "toggle":
2127
+ return `toggle${operation.mark.charAt(0).toUpperCase()}${operation.mark.slice(1)}`;
2128
+ case "set-font-family":
2129
+ return "setFontFamily";
2130
+ case "set-font-size":
2131
+ return "setFontSize";
2132
+ case "set-text-color":
2133
+ return "setTextColor";
2134
+ case "set-highlight-color":
2135
+ return "setHighlightColor";
2136
+ case "set-alignment":
2137
+ return "setAlignment";
2138
+ case "indent":
2139
+ return "indent";
2140
+ case "outdent":
2141
+ return "outdent";
2142
+ }
2143
+ }
2144
+
1955
2145
  function applyRuntimeDeleteSectionBreak(
1956
2146
  runtime: WordReviewEditorRuntime,
1957
2147
  sectionIndex: number,
@@ -1960,6 +2150,16 @@ function applyRuntimeDeleteSectionBreak(
1960
2150
  if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
1961
2151
  return;
1962
2152
  }
2153
+ if (emitWorkflowBlockedMutation(runtime, "deleteSectionBreak")) {
2154
+ return;
2155
+ }
2156
+ if (isSelectionSuggesting(runtime)) {
2157
+ runtime.emitBlockedCommand("deleteSectionBreak", [{
2158
+ code: "unsupported_surface",
2159
+ message: "Section break deletion is not supported in suggesting mode.",
2160
+ }]);
2161
+ return;
2162
+ }
1963
2163
 
1964
2164
  const sessionState = runtime.getSessionState();
1965
2165
  const timestamp = new Date().toISOString();
@@ -1989,6 +2189,16 @@ function applyRuntimeUpdateSectionLayout(
1989
2189
  if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
1990
2190
  return;
1991
2191
  }
2192
+ if (emitWorkflowBlockedMutation(runtime, "updateSectionLayout")) {
2193
+ return;
2194
+ }
2195
+ if (isSelectionSuggesting(runtime)) {
2196
+ runtime.emitBlockedCommand("updateSectionLayout", [{
2197
+ code: "unsupported_surface",
2198
+ message: "Section layout updates are not supported in suggesting mode.",
2199
+ }]);
2200
+ return;
2201
+ }
1992
2202
 
1993
2203
  const sessionState = runtime.getSessionState();
1994
2204
  const timestamp = new Date().toISOString();
@@ -2025,6 +2235,16 @@ function applyRuntimeSetSectionPageNumbering(
2025
2235
  if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
2026
2236
  return;
2027
2237
  }
2238
+ if (emitWorkflowBlockedMutation(runtime, "setSectionPageNumbering")) {
2239
+ return;
2240
+ }
2241
+ if (isSelectionSuggesting(runtime)) {
2242
+ runtime.emitBlockedCommand("setSectionPageNumbering", [{
2243
+ code: "unsupported_surface",
2244
+ message: "Section page numbering updates are not supported in suggesting mode.",
2245
+ }]);
2246
+ return;
2247
+ }
2028
2248
 
2029
2249
  const sessionState = runtime.getSessionState();
2030
2250
  const timestamp = new Date().toISOString();
@@ -2072,6 +2292,16 @@ function applyRuntimeSetHeaderFooterLink(
2072
2292
  if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
2073
2293
  return;
2074
2294
  }
2295
+ if (emitWorkflowBlockedMutation(runtime, "setHeaderFooterLink")) {
2296
+ return;
2297
+ }
2298
+ if (isSelectionSuggesting(runtime)) {
2299
+ runtime.emitBlockedCommand("setHeaderFooterLink", [{
2300
+ code: "unsupported_surface",
2301
+ message: "Header and footer linkage updates are not supported in suggesting mode.",
2302
+ }]);
2303
+ return;
2304
+ }
2075
2305
 
2076
2306
  const sessionState = runtime.getSessionState();
2077
2307
  const timestamp = new Date().toISOString();
@@ -2094,15 +2324,14 @@ function applyRuntimeSetHeaderFooterLink(
2094
2324
  }
2095
2325
 
2096
2326
  function applyRuntimeInsertPageBreak(runtime: WordReviewEditorRuntime): void {
2097
- const snapshot = runtime.getRenderSnapshot();
2098
- if (snapshot.documentMode === "suggesting") {
2327
+ if (isSelectionSuggesting(runtime)) {
2099
2328
  runtime.emitBlockedCommand("insertPageBreak", [{
2100
2329
  code: "unsupported_surface",
2101
2330
  message: "Page break insertion is not supported in suggesting mode.",
2102
2331
  }]);
2103
2332
  return;
2104
2333
  }
2105
- const context = getStoryMutationContext(runtime);
2334
+ const context = getStoryMutationContext(runtime, "insertPageBreak");
2106
2335
  if (!context) {
2107
2336
  return;
2108
2337
  }
@@ -2119,15 +2348,14 @@ function applyRuntimeInsertTable(
2119
2348
  runtime: WordReviewEditorRuntime,
2120
2349
  options: InsertTableOptions,
2121
2350
  ): void {
2122
- const snapshot = runtime.getRenderSnapshot();
2123
- if (snapshot.documentMode === "suggesting") {
2351
+ if (isSelectionSuggesting(runtime)) {
2124
2352
  runtime.emitBlockedCommand("insertTable", [{
2125
2353
  code: "unsupported_surface",
2126
2354
  message: "Table insertion is not supported in suggesting mode.",
2127
2355
  }]);
2128
2356
  return;
2129
2357
  }
2130
- const context = getStoryMutationContext(runtime);
2358
+ const context = getStoryMutationContext(runtime, "insertTable");
2131
2359
  if (!context) {
2132
2360
  return;
2133
2361
  }
@@ -2145,15 +2373,14 @@ function applyRuntimeInsertImage(
2145
2373
  runtime: WordReviewEditorRuntime,
2146
2374
  options: InsertImageOptions,
2147
2375
  ): void {
2148
- const snapshot = runtime.getRenderSnapshot();
2149
- if (snapshot.documentMode === "suggesting") {
2376
+ if (isSelectionSuggesting(runtime)) {
2150
2377
  runtime.emitBlockedCommand("insertImage", [{
2151
2378
  code: "unsupported_surface",
2152
2379
  message: "Image insertion is not supported in suggesting mode.",
2153
2380
  }]);
2154
2381
  return;
2155
2382
  }
2156
- const context = getStoryMutationContext(runtime);
2383
+ const context = getStoryMutationContext(runtime, "insertImage");
2157
2384
  if (!context) {
2158
2385
  return;
2159
2386
  }
@@ -2191,7 +2418,10 @@ function applyRuntimeImageResize(
2191
2418
  if (!canApplyRuntimeMutation(snapshot)) {
2192
2419
  return;
2193
2420
  }
2194
- if (snapshot.documentMode === "suggesting") {
2421
+ if (emitWorkflowBlockedMutation(runtime, "setImageLayout")) {
2422
+ return;
2423
+ }
2424
+ if (isSelectionSuggesting(runtime)) {
2195
2425
  runtime.emitBlockedCommand("setImageLayout", [{
2196
2426
  code: "unsupported_surface",
2197
2427
  message: "Image resize is not supported in suggesting mode.",
@@ -2222,15 +2452,17 @@ function applyRuntimeImageReposition(
2222
2452
  mediaId: string,
2223
2453
  offsets: { horizontalOffsetEmu?: number; verticalOffsetEmu?: number },
2224
2454
  ): void {
2225
- const snapshot = runtime.getRenderSnapshot();
2226
- if (snapshot.documentMode === "suggesting") {
2455
+ if (emitWorkflowBlockedMutation(runtime, "setImageFrame")) {
2456
+ return;
2457
+ }
2458
+ if (isSelectionSuggesting(runtime)) {
2227
2459
  runtime.emitBlockedCommand("setImageFrame", [{
2228
2460
  code: "unsupported_surface",
2229
2461
  message: "Image reposition is not supported in suggesting mode.",
2230
2462
  }]);
2231
2463
  return;
2232
2464
  }
2233
- const context = getStoryMutationContext(runtime);
2465
+ const context = getStoryMutationContext(runtime, "setImageFrame");
2234
2466
  if (!context) {
2235
2467
  return;
2236
2468
  }
@@ -2275,15 +2507,14 @@ function applyRuntimeTableStructureOperation(
2275
2507
  | { type: "split-cell" }
2276
2508
  | { type: "set-cell-background"; color: string },
2277
2509
  ): void {
2278
- const snapshot = runtime.getRenderSnapshot();
2279
- if (snapshot.documentMode === "suggesting") {
2510
+ if (isSelectionSuggesting(runtime)) {
2280
2511
  runtime.emitBlockedCommand(`table.${operation.type}`, [{
2281
2512
  code: "unsupported_surface",
2282
2513
  message: `Table operation "${operation.type}" is not supported in suggesting mode.`,
2283
2514
  }]);
2284
2515
  return;
2285
2516
  }
2286
- const context = getStoryMutationContext(runtime);
2517
+ const context = getStoryMutationContext(runtime, `table.${operation.type}`);
2287
2518
  if (!context) {
2288
2519
  return;
2289
2520
  }
@@ -2308,102 +2539,76 @@ function applyRuntimeTextCommand(
2308
2539
  | { type: "insert-hard-break" }
2309
2540
  | { type: "split-paragraph" },
2310
2541
  ): void {
2311
- const context = getStoryMutationContext(runtime);
2542
+ const snapshot = runtime.getRenderSnapshot();
2543
+ const context = getStoryMutationContext(runtime, getMountedTextCommandName(command));
2312
2544
  if (!context) {
2313
2545
  return;
2314
2546
  }
2315
2547
 
2548
+ const effectiveSelectionMode = runtime.getInteractionGuardSnapshot().effectiveMode;
2316
2549
  const listAwareResult = applyListAwareTextCommand(context, command);
2550
+ if (effectiveSelectionMode === "suggest" && listAwareResult) {
2551
+ runtime.emitBlockedCommand(getMountedTextCommandName(command), [{
2552
+ code: "suggesting_unsupported",
2553
+ message: "List structure changes are not supported in suggesting mode.",
2554
+ }]);
2555
+ return;
2556
+ }
2557
+
2317
2558
  if (listAwareResult) {
2318
2559
  dispatchStoryMutationResult(runtime, context, listAwareResult, context.timestamp);
2319
2560
  return;
2320
2561
  }
2321
2562
 
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
- }
2563
+ switch (command.type) {
2564
+ case "insert-text":
2565
+ runtime.applyActiveStoryTextCommand({ type: "text.insert", text: command.text });
2566
+ return;
2567
+ case "delete-backward":
2568
+ runtime.applyActiveStoryTextCommand({ type: "text.delete-backward" });
2569
+ return;
2570
+ case "delete-forward":
2571
+ runtime.applyActiveStoryTextCommand({ type: "text.delete-forward" });
2572
+ return;
2573
+ case "insert-tab":
2574
+ runtime.applyActiveStoryTextCommand({ type: "text.insert-tab" });
2575
+ return;
2576
+ case "outdent-tab":
2577
+ return;
2578
+ case "insert-hard-break":
2579
+ runtime.applyActiveStoryTextCommand({ type: "text.insert-hard-break" });
2580
+ return;
2581
+ case "split-paragraph":
2582
+ runtime.applyActiveStoryTextCommand({ type: "paragraph.split" });
2583
+ return;
2345
2584
  }
2585
+ }
2346
2586
 
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
- );
2587
+ function getMountedTextCommandName(
2588
+ command:
2589
+ | { type: "insert-text"; text: string }
2590
+ | { type: "delete-backward" }
2591
+ | { type: "delete-forward" }
2592
+ | { type: "insert-tab" }
2593
+ | { type: "outdent-tab" }
2594
+ | { type: "insert-hard-break" }
2595
+ | { type: "split-paragraph" },
2596
+ ): string {
2597
+ switch (command.type) {
2598
+ case "insert-text":
2599
+ return "text.insert";
2600
+ case "delete-backward":
2601
+ return "text.delete-backward";
2602
+ case "delete-forward":
2603
+ return "text.delete-forward";
2604
+ case "insert-tab":
2605
+ case "outdent-tab":
2606
+ return "text.insert-tab";
2607
+ case "insert-hard-break":
2608
+ return "text.insert-hard-break";
2609
+ case "split-paragraph":
2610
+ return "paragraph.split";
2611
+ }
2407
2612
  }
2408
2613
 
2409
2614
  function applyListAwareTextCommand(
@@ -2598,8 +2803,21 @@ function canApplyRuntimeMutation(snapshot: RuntimeRenderSnapshot): boolean {
2598
2803
  return snapshot.isReady && !snapshot.readOnly && !snapshot.fatalError;
2599
2804
  }
2600
2805
 
2806
+ function emitWorkflowBlockedMutation(
2807
+ runtime: WordReviewEditorRuntime,
2808
+ command: string,
2809
+ ): boolean {
2810
+ const interactionGuardSnapshot = runtime.getInteractionGuardSnapshot();
2811
+ if (interactionGuardSnapshot.blockedReasons.length === 0) {
2812
+ return false;
2813
+ }
2814
+ runtime.emitBlockedCommand(command, interactionGuardSnapshot.blockedReasons);
2815
+ return true;
2816
+ }
2817
+
2601
2818
  function getStoryMutationContext(
2602
2819
  runtime: WordReviewEditorRuntime,
2820
+ command?: string,
2603
2821
  ): {
2604
2822
  timestamp: string;
2605
2823
  activeStory: EditorStoryTarget;
@@ -2611,6 +2829,9 @@ function getStoryMutationContext(
2611
2829
  if (!canApplyRuntimeMutation(snapshot)) {
2612
2830
  return null;
2613
2831
  }
2832
+ if (command && emitWorkflowBlockedMutation(runtime, command)) {
2833
+ return null;
2834
+ }
2614
2835
 
2615
2836
  const persistedDocument = runtime.getSessionState().canonicalDocument;
2616
2837
  const activeStory = snapshot.activeStory;
@@ -3034,6 +3255,20 @@ function deriveEditorViewMode(
3034
3255
  return reviewMode === "editing" ? "editing" : "review";
3035
3256
  }
3036
3257
 
3258
+ function resolveWordReviewEditorChromeVisibility(
3259
+ chromeVisibility: WordReviewEditorProps["chromeVisibility"],
3260
+ showReviewPanel: boolean,
3261
+ ): Partial<WordReviewEditorChromeVisibility> {
3262
+ const legacyVisibility =
3263
+ showReviewPanel
3264
+ ? {}
3265
+ : { reviewRail: false } satisfies Partial<WordReviewEditorChromeVisibility>;
3266
+ return {
3267
+ ...legacyVisibility,
3268
+ ...chromeVisibility,
3269
+ };
3270
+ }
3271
+
3037
3272
  function toRuntimeSelectionSnapshot(selection: PublicSelectionSnapshot) {
3038
3273
  return {
3039
3274
  anchor: selection.anchor,
@@ -3057,6 +3292,7 @@ function toRuntimeSelectionSnapshot(selection: PublicSelectionSnapshot) {
3057
3292
 
3058
3293
  function createSelectionFromAnchor(
3059
3294
  anchor: PublicSelectionSnapshot["activeRange"],
3295
+ storyTarget?: EditorStoryTarget,
3060
3296
  ): PublicSelectionSnapshot {
3061
3297
  switch (anchor.kind) {
3062
3298
  case "range":
@@ -3065,6 +3301,7 @@ function createSelectionFromAnchor(
3065
3301
  head: anchor.to,
3066
3302
  isCollapsed: anchor.from === anchor.to,
3067
3303
  activeRange: anchor,
3304
+ ...(storyTarget ? { storyTarget } : {}),
3068
3305
  };
3069
3306
  case "node":
3070
3307
  return {
@@ -3072,6 +3309,7 @@ function createSelectionFromAnchor(
3072
3309
  head: anchor.at,
3073
3310
  isCollapsed: true,
3074
3311
  activeRange: anchor,
3312
+ ...(storyTarget ? { storyTarget } : {}),
3075
3313
  };
3076
3314
  case "detached":
3077
3315
  return {
@@ -3079,6 +3317,7 @@ function createSelectionFromAnchor(
3079
3317
  head: anchor.lastKnownRange.to,
3080
3318
  isCollapsed: anchor.lastKnownRange.from === anchor.lastKnownRange.to,
3081
3319
  activeRange: anchor,
3320
+ ...(storyTarget ? { storyTarget } : {}),
3082
3321
  };
3083
3322
  }
3084
3323
  }
@@ -3184,7 +3423,13 @@ function interactionGuardSnapshotsEqual(
3184
3423
  if (left === right) {
3185
3424
  return true;
3186
3425
  }
3187
- return workflowBlockedReasonsEqual(left.blockedReasons, right.blockedReasons);
3426
+ return (
3427
+ left.effectiveMode === right.effectiveMode &&
3428
+ left.matchedScopeId === right.matchedScopeId &&
3429
+ left.matchedScopeMode === right.matchedScopeMode &&
3430
+ left.disabledReason === right.disabledReason &&
3431
+ workflowBlockedReasonsEqual(left.blockedReasons, right.blockedReasons)
3432
+ );
3188
3433
  }
3189
3434
 
3190
3435
  function workflowBlockedReasonsEqual(
@@ -3256,7 +3501,14 @@ function editorAnchorProjectionEqual(
3256
3501
  function createSelectionToolbarSelectionKey(
3257
3502
  selection: RuntimeRenderSnapshot["selection"],
3258
3503
  activeStory: EditorStoryTarget,
3504
+ activeRevisionId?: string,
3259
3505
  ): string | null {
3506
+ if (activeRevisionId) {
3507
+ return JSON.stringify({
3508
+ story: activeStory,
3509
+ revisionId: activeRevisionId,
3510
+ });
3511
+ }
3260
3512
  if (selection.isCollapsed || selection.activeRange.kind !== "range") {
3261
3513
  return null;
3262
3514
  }
@@ -3275,6 +3527,8 @@ function buildSelectionToolbarModel(args: {
3275
3527
  documentNavigation: ReturnType<WordReviewEditorRuntime["getDocumentNavigationSnapshot"]>;
3276
3528
  styleCatalog: StyleCatalogSnapshot;
3277
3529
  formattingState: FormattingStateSnapshot;
3530
+ workflowScopeSnapshot?: WorkflowScopeSnapshot | null;
3531
+ interactionGuardSnapshot?: InteractionGuardSnapshot;
3278
3532
  addCommentDisabledReason?: string;
3279
3533
  }): SelectionToolbarModel | null {
3280
3534
  const {
@@ -3284,6 +3538,8 @@ function buildSelectionToolbarModel(args: {
3284
3538
  documentNavigation,
3285
3539
  styleCatalog,
3286
3540
  formattingState,
3541
+ workflowScopeSnapshot,
3542
+ interactionGuardSnapshot,
3287
3543
  addCommentDisabledReason,
3288
3544
  } = args;
3289
3545
 
@@ -3304,6 +3560,14 @@ function buildSelectionToolbarModel(args: {
3304
3560
 
3305
3561
  const badges = [
3306
3562
  createSelectionToolbarStoryBadge(viewState.activeStory),
3563
+ createSelectionToolbarWorkflowBadge(
3564
+ resolveSelectionWorkflowPosture(
3565
+ snapshot,
3566
+ viewState,
3567
+ workflowScopeSnapshot,
3568
+ interactionGuardSnapshot,
3569
+ ),
3570
+ ),
3307
3571
  viewState.workspaceMode === "page" && documentNavigation.pageCount > 0
3308
3572
  ? { label: `Page ${documentNavigation.activePageIndex + 1}` as const }
3309
3573
  : null,
@@ -3311,15 +3575,137 @@ function buildSelectionToolbarModel(args: {
3311
3575
  createSelectionToolbarListBadge(viewState),
3312
3576
  ].filter((badge): badge is SelectionToolbarModel["badges"][number] => Boolean(badge));
3313
3577
 
3578
+ const workflowPosture = resolveSelectionWorkflowPosture(
3579
+ snapshot,
3580
+ viewState,
3581
+ workflowScopeSnapshot,
3582
+ interactionGuardSnapshot,
3583
+ );
3584
+ const canToggleFormatting = workflowPosture.mode === "edit";
3585
+ const canAddComment = workflowPosture.mode === "view" || workflowPosture.mode === "blocked"
3586
+ ? false
3587
+ : capabilities.canAddComment;
3588
+ const disabledReason =
3589
+ workflowPosture.disabledReason ??
3590
+ (canAddComment ? undefined : addCommentDisabledReason);
3591
+
3314
3592
  return {
3315
3593
  previewText,
3316
3594
  badges,
3317
- canToggleFormatting: true,
3595
+ canToggleFormatting,
3318
3596
  boldActive: formattingState.bold,
3319
3597
  italicActive: formattingState.italic,
3320
3598
  underlineActive: formattingState.underline,
3321
- canAddComment: capabilities.canAddComment,
3322
- ...(addCommentDisabledReason ? { disabledReason: addCommentDisabledReason } : {}),
3599
+ canAddComment,
3600
+ ...(disabledReason ? { disabledReason } : {}),
3601
+ };
3602
+ }
3603
+
3604
+ function buildSuggestionCardModel(args: {
3605
+ snapshot: RuntimeRenderSnapshot;
3606
+ viewState: ReturnType<WordReviewEditorRuntime["getViewState"]>;
3607
+ capabilities: ReturnType<typeof deriveCapabilities>;
3608
+ workflowScopeSnapshot?: WorkflowScopeSnapshot | null;
3609
+ interactionGuardSnapshot?: InteractionGuardSnapshot;
3610
+ activeRevisionId?: string;
3611
+ suppressedSuggestionRevisionId?: string | null;
3612
+ addCommentDisabledReason?: string;
3613
+ }): SuggestionCardModel | null {
3614
+ const {
3615
+ snapshot,
3616
+ viewState,
3617
+ capabilities,
3618
+ workflowScopeSnapshot,
3619
+ interactionGuardSnapshot,
3620
+ activeRevisionId,
3621
+ suppressedSuggestionRevisionId,
3622
+ addCommentDisabledReason,
3623
+ } = args;
3624
+
3625
+ if (
3626
+ !snapshot.surface ||
3627
+ !capabilities.canEdit ||
3628
+ viewState.viewMode === "view"
3629
+ ) {
3630
+ return null;
3631
+ }
3632
+
3633
+ const activeRange =
3634
+ !snapshot.selection.isCollapsed && snapshot.selection.activeRange.kind === "range"
3635
+ ? snapshot.selection.activeRange
3636
+ : null;
3637
+ const selectionFrom = activeRange
3638
+ ? Math.min(activeRange.from, activeRange.to)
3639
+ : null;
3640
+ const selectionTo = activeRange
3641
+ ? Math.max(activeRange.from, activeRange.to)
3642
+ : null;
3643
+ const candidateRevisions = snapshot.trackedChanges.revisions.filter((revision) =>
3644
+ storyTargetsEqual(revision.storyTarget ?? { kind: "main" }, viewState.activeStory) &&
3645
+ revision.status === "active" &&
3646
+ revision.actionability === "actionable" &&
3647
+ revision.anchor.kind === "range" &&
3648
+ (
3649
+ activeRevisionId === revision.revisionId ||
3650
+ (
3651
+ selectionFrom !== null &&
3652
+ selectionTo !== null &&
3653
+ rangesOverlap(
3654
+ selectionFrom,
3655
+ selectionTo,
3656
+ revision.anchor.from,
3657
+ revision.anchor.to,
3658
+ )
3659
+ )
3660
+ )
3661
+ );
3662
+ const focusedRevision = (
3663
+ activeRevisionId
3664
+ ? candidateRevisions.find((revision) => revision.revisionId === activeRevisionId)
3665
+ : null
3666
+ ) ?? candidateRevisions[0];
3667
+
3668
+ if (!focusedRevision || focusedRevision.revisionId === suppressedSuggestionRevisionId) {
3669
+ return null;
3670
+ }
3671
+
3672
+ const badges = [
3673
+ createSelectionToolbarStoryBadge(viewState.activeStory),
3674
+ workflowScopeSnapshot?.activeWorkItem?.title
3675
+ ? {
3676
+ label: workflowScopeSnapshot.activeWorkItem.title,
3677
+ tone: "accent" as const,
3678
+ }
3679
+ : null,
3680
+ ].filter((badge): badge is SuggestionCardModel["badges"][number] => Boolean(badge));
3681
+ const workflowPosture = resolveSelectionWorkflowPosture(
3682
+ snapshot,
3683
+ viewState,
3684
+ workflowScopeSnapshot,
3685
+ interactionGuardSnapshot,
3686
+ );
3687
+ const canReviewSuggestion = workflowPosture.mode === "edit" || workflowPosture.mode === "suggest";
3688
+ const canAddComment = workflowPosture.mode === "view" || workflowPosture.mode === "blocked"
3689
+ ? false
3690
+ : capabilities.canAddComment;
3691
+ const disabledReason =
3692
+ workflowPosture.disabledReason ??
3693
+ (canAddComment ? undefined : addCommentDisabledReason);
3694
+
3695
+ return {
3696
+ revisionId: focusedRevision.revisionId,
3697
+ kindLabel: getSuggestionKindLabel(focusedRevision.kind),
3698
+ previewText:
3699
+ focusedRevision.excerpt ??
3700
+ focusedRevision.detail ??
3701
+ focusedRevision.label ??
3702
+ "Suggested change",
3703
+ badges,
3704
+ canAccept: canReviewSuggestion && capabilities.canAcceptChange && focusedRevision.canAccept,
3705
+ canReject: canReviewSuggestion && capabilities.canRejectChange && focusedRevision.canReject,
3706
+ canEditSuggestion: canReviewSuggestion,
3707
+ canAddComment,
3708
+ ...(disabledReason ? { disabledReason } : {}),
3323
3709
  };
3324
3710
  }
3325
3711
 
@@ -3377,6 +3763,149 @@ function createSelectionToolbarListBadge(
3377
3763
  };
3378
3764
  }
3379
3765
 
3766
+ function createSelectionToolbarWorkflowBadge(
3767
+ posture: ReturnType<typeof resolveSelectionWorkflowPosture>,
3768
+ ): SelectionToolbarModel["badges"][number] | null {
3769
+ switch (posture.mode) {
3770
+ case "suggest":
3771
+ return { label: "Suggest", tone: "accent" };
3772
+ case "comment":
3773
+ return { label: "Comment only", tone: "accent" };
3774
+ case "view":
3775
+ return { label: "View only" };
3776
+ case "blocked":
3777
+ return { label: "Blocked" };
3778
+ default:
3779
+ return null;
3780
+ }
3781
+ }
3782
+
3783
+ function resolveSelectionWorkflowPosture(
3784
+ snapshot: RuntimeRenderSnapshot,
3785
+ viewState: ReturnType<WordReviewEditorRuntime["getViewState"]>,
3786
+ workflowScopeSnapshot?: WorkflowScopeSnapshot | null,
3787
+ interactionGuardSnapshot?: InteractionGuardSnapshot,
3788
+ ): {
3789
+ mode: "edit" | "suggest" | "comment" | "view" | "blocked";
3790
+ disabledReason?: string;
3791
+ } {
3792
+ const blockedReasons =
3793
+ interactionGuardSnapshot?.blockedReasons ??
3794
+ workflowScopeSnapshot?.blockedReasons ??
3795
+ [];
3796
+ const blockingReason = blockedReasons[0];
3797
+ if (blockingReason) {
3798
+ if (blockingReason.code === "workflow_comment_only") {
3799
+ return {
3800
+ mode: "comment",
3801
+ disabledReason: blockingReason.message,
3802
+ };
3803
+ }
3804
+ if (blockingReason.code === "workflow_view_only") {
3805
+ return {
3806
+ mode: "view",
3807
+ disabledReason: blockingReason.message,
3808
+ };
3809
+ }
3810
+ return {
3811
+ mode: "blocked",
3812
+ disabledReason: blockingReason.message,
3813
+ };
3814
+ }
3815
+
3816
+ if (interactionGuardSnapshot) {
3817
+ if (interactionGuardSnapshot.effectiveMode === "suggest") {
3818
+ return {
3819
+ mode: "suggest",
3820
+ disabledReason:
3821
+ interactionGuardSnapshot.disabledReason ??
3822
+ "Suggestion authoring is active here; direct formatting changes are blocked.",
3823
+ };
3824
+ }
3825
+ if (interactionGuardSnapshot.effectiveMode === "comment") {
3826
+ return {
3827
+ mode: "comment",
3828
+ disabledReason: interactionGuardSnapshot.disabledReason,
3829
+ };
3830
+ }
3831
+ if (interactionGuardSnapshot.effectiveMode === "view") {
3832
+ return {
3833
+ mode: "view",
3834
+ disabledReason: interactionGuardSnapshot.disabledReason,
3835
+ };
3836
+ }
3837
+ if (interactionGuardSnapshot.effectiveMode === "blocked") {
3838
+ return {
3839
+ mode: "blocked",
3840
+ disabledReason: interactionGuardSnapshot.disabledReason,
3841
+ };
3842
+ }
3843
+ }
3844
+
3845
+ const activeRange =
3846
+ !snapshot.selection.isCollapsed && snapshot.selection.activeRange.kind === "range"
3847
+ ? snapshot.selection.activeRange
3848
+ : null;
3849
+ const matchingScope = activeRange && workflowScopeSnapshot
3850
+ ? workflowScopeSnapshot.scopes.find((scope) => {
3851
+ const scopeStoryTarget = scope.storyTarget ?? { kind: "main" as const };
3852
+ if (!storyTargetsEqual(scopeStoryTarget, viewState.activeStory)) {
3853
+ return false;
3854
+ }
3855
+ if (scope.anchor.kind === "detached") {
3856
+ return false;
3857
+ }
3858
+ const scopeFrom = scope.anchor.kind === "range" ? scope.anchor.from : scope.anchor.at;
3859
+ const scopeTo = scope.anchor.kind === "range" ? scope.anchor.to : scope.anchor.at;
3860
+ return activeRange.from >= scopeFrom && activeRange.to <= scopeTo;
3861
+ })
3862
+ : null;
3863
+
3864
+ if (matchingScope?.mode === "suggest") {
3865
+ return {
3866
+ mode: "suggest",
3867
+ disabledReason: "Suggestion authoring is active here; direct formatting changes are blocked.",
3868
+ };
3869
+ }
3870
+ if (matchingScope?.mode === "comment") {
3871
+ return {
3872
+ mode: "comment",
3873
+ disabledReason: `Scope "${matchingScope.label ?? matchingScope.scopeId}" allows comments only.`,
3874
+ };
3875
+ }
3876
+ if (matchingScope?.mode === "view") {
3877
+ return {
3878
+ mode: "view",
3879
+ disabledReason: `Scope "${matchingScope.label ?? matchingScope.scopeId}" is view-only.`,
3880
+ };
3881
+ }
3882
+ return { mode: "edit" };
3883
+ }
3884
+
3885
+ function rangesOverlap(
3886
+ leftFrom: number,
3887
+ leftTo: number,
3888
+ rightFrom: number,
3889
+ rightTo: number,
3890
+ ): boolean {
3891
+ return leftFrom < rightTo && rightFrom < leftTo;
3892
+ }
3893
+
3894
+ function getSuggestionKindLabel(kind: TrackedChangeEntrySnapshot["kind"]): string {
3895
+ switch (kind) {
3896
+ case "insertion":
3897
+ return "Suggested insertion";
3898
+ case "deletion":
3899
+ return "Suggested deletion";
3900
+ case "formatting":
3901
+ return "Suggested formatting change";
3902
+ case "property-change":
3903
+ return "Suggested property change";
3904
+ case "move":
3905
+ return "Suggested move";
3906
+ }
3907
+ }
3908
+
3380
3909
  function buildActiveImageContext(args: {
3381
3910
  canonicalDocument: PersistedEditorSnapshot["canonicalDocument"];
3382
3911
  selection: RuntimeRenderSnapshot["selection"];