@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
@@ -30,6 +30,8 @@ import type {
30
30
  HeaderFooterLinkPatch,
31
31
  ExportDocxOptions,
32
32
  ExportResult,
33
+ HostAnnotationOverlay,
34
+ HostAnnotationSnapshot,
33
35
  InteractionGuardSnapshot,
34
36
  PageLayoutSnapshot,
35
37
  PersistedEditorSnapshot,
@@ -65,7 +67,6 @@ import {
65
67
  type EditorCommand,
66
68
  type EditorTransaction,
67
69
  } from "../core/commands/index.ts";
68
- import { insertText } from "../core/commands/text-commands.ts";
69
70
  import {
70
71
  createDetachedAnchor,
71
72
  createEmptyMapping,
@@ -105,7 +106,11 @@ import {
105
106
  resolveActiveSection,
106
107
  } from "./document-layout.ts";
107
108
  import { normalizeHeaderFooterTarget } from "./story-context.ts";
108
- import { storyTargetKey } from "./story-targeting.ts";
109
+ import {
110
+ getStoryBlocks,
111
+ replaceStoryBlocks,
112
+ storyTargetKey,
113
+ } from "./story-targeting.ts";
109
114
  import {
110
115
  createViewState,
111
116
  setViewMode as applyViewMode,
@@ -149,12 +154,24 @@ export type DocumentRuntimeEvent =
149
154
  })
150
155
  | Exclude<WordReviewEditorEvent, { type: "ready" }>;
151
156
 
157
+ export type ActiveStoryTextCommand =
158
+ | Extract<EditorCommand, { type: "text.insert" }>
159
+ | Extract<EditorCommand, { type: "text.delete-backward" }>
160
+ | Extract<EditorCommand, { type: "text.delete-forward" }>
161
+ | Extract<EditorCommand, { type: "text.insert-tab" }>
162
+ | Extract<EditorCommand, { type: "text.insert-hard-break" }>
163
+ | Extract<EditorCommand, { type: "paragraph.split" }>;
164
+
152
165
  export interface DocumentRuntime {
153
166
  subscribe(listener: () => void): Unsubscribe;
154
167
  subscribeToEvents(listener: (event: DocumentRuntimeEvent) => void): Unsubscribe;
155
168
  getRenderSnapshot(): RuntimeRenderSnapshot;
169
+ getCanonicalDocument(): CanonicalDocumentEnvelope;
170
+ getSourcePackage(): EditorSessionState["sourcePackage"] | undefined;
156
171
  replaceText(text: string, target?: EditorAnchorProjection): void;
172
+ applyActiveStoryTextCommand(command: ActiveStoryTextCommand): void;
157
173
  dispatch(command: EditorCommand): void;
174
+ emitBlockedCommand(command: string, reasons: WorkflowBlockedCommandReason[]): void;
158
175
  undo(): void;
159
176
  redo(): void;
160
177
  focus(): void;
@@ -194,6 +211,9 @@ export interface DocumentRuntime {
194
211
  getWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null;
195
212
  getInteractionGuardSnapshot(): InteractionGuardSnapshot;
196
213
  getWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot;
214
+ setHostAnnotationOverlay(overlay: HostAnnotationOverlay): void;
215
+ clearHostAnnotationOverlay(): void;
216
+ getHostAnnotationSnapshot(): HostAnnotationSnapshot;
197
217
  getWorkflowCandidateRanges(options?: WorkflowCandidateRangeOptions): WorkflowCandidateRange[];
198
218
  replaceWorkflowMarkupText(markupId: string, text: string): void;
199
219
  }
@@ -254,6 +274,7 @@ export function createDocumentRuntime(
254
274
  preservedRangeCount: 0,
255
275
  };
256
276
  let workflowOverlay: WorkflowOverlay | null = null;
277
+ let hostAnnotationOverlay: HostAnnotationOverlay | null = null;
257
278
  const initialPersistedSnapshot = options.initialSessionState
258
279
  ? persistedSnapshotFromEditorSessionState(options.initialSessionState, {
259
280
  savedAt: options.initialSessionState.updatedAt,
@@ -295,7 +316,7 @@ export function createDocumentRuntime(
295
316
  let cachedTrackedChanges:
296
317
  | {
297
318
  revisions: CanonicalDocumentEnvelope["review"]["revisions"];
298
- plainText: string;
319
+ revisionToken: string;
299
320
  snapshot: TrackedChangesSnapshot;
300
321
  }
301
322
  | undefined;
@@ -344,6 +365,15 @@ export function createDocumentRuntime(
344
365
  snapshot: WorkflowScopeSnapshot;
345
366
  }
346
367
  | undefined;
368
+ let cachedWorkflowMarkupSnapshot:
369
+ | {
370
+ revisionToken: string;
371
+ activeStoryKey: string;
372
+ protectionSnapshot: ProtectionSnapshot;
373
+ preservation: CanonicalDocumentEnvelope["preservation"];
374
+ snapshot: WorkflowMarkupSnapshot;
375
+ }
376
+ | undefined;
347
377
 
348
378
  function getCachedSurface(
349
379
  document: CanonicalDocumentEnvelope,
@@ -422,21 +452,20 @@ export function createDocumentRuntime(
422
452
 
423
453
  function getCachedTrackedChangesSnapshot(
424
454
  nextState: EditorState,
425
- surface: RuntimeRenderSnapshot["surface"],
455
+ _surface: RuntimeRenderSnapshot["surface"],
426
456
  ): TrackedChangesSnapshot {
427
- const plainText = surface?.plainText ?? "";
428
457
  if (
429
458
  cachedTrackedChanges &&
430
459
  cachedTrackedChanges.revisions === nextState.document.review.revisions &&
431
- cachedTrackedChanges.plainText === plainText
460
+ cachedTrackedChanges.revisionToken === nextState.revisionToken
432
461
  ) {
433
462
  return cachedTrackedChanges.snapshot;
434
463
  }
435
464
 
436
- const snapshot = toPublicTrackedChangesSnapshot(nextState, plainText);
465
+ const snapshot = toPublicTrackedChangesSnapshot(nextState);
437
466
  cachedTrackedChanges = {
438
467
  revisions: nextState.document.review.revisions,
439
- plainText,
468
+ revisionToken: nextState.revisionToken,
440
469
  snapshot,
441
470
  };
442
471
  return snapshot;
@@ -580,14 +609,28 @@ export function createDocumentRuntime(
580
609
  });
581
610
  }
582
611
 
612
+ const effectiveDocumentMode = getEffectiveDocumentMode(selection);
613
+
614
+ if (effectiveDocumentMode === "suggesting" && commandType) {
615
+ if (
616
+ activeStory.kind !== "main" &&
617
+ SUGGESTING_SECONDARY_STORY_UNSUPPORTED_COMMANDS.has(commandType)
618
+ ) {
619
+ reasons.push({
620
+ code: "suggesting_unsupported",
621
+ message: "Suggesting mode is not yet export-safe in this story.",
622
+ });
623
+ }
624
+ if (SUGGESTING_UNSUPPORTED_COMMANDS.has(commandType)) {
625
+ reasons.push({
626
+ code: "suggesting_unsupported",
627
+ message: `"${commandType}" is not supported in suggesting mode.`,
628
+ });
629
+ }
630
+ }
631
+
583
632
  if (workflowOverlay) {
584
- const activeScopes = getEffectiveWorkflowScopes(workflowOverlay);
585
- const matchingScope = activeScopes.find((scope) => {
586
- if (scope.anchor.kind === "detached") return false;
587
- const scopeFrom = scope.anchor.kind === "range" ? scope.anchor.from : scope.anchor.at;
588
- const scopeTo = scope.anchor.kind === "range" ? scope.anchor.to : scope.anchor.at;
589
- return selectionBounds.from >= scopeFrom && selectionBounds.to <= scopeTo;
590
- });
633
+ const matchingScope = getMatchingWorkflowScope(selection);
591
634
 
592
635
  if (!matchingScope && workflowOverlay.scopes.length > 0) {
593
636
  reasons.push({
@@ -620,6 +663,39 @@ export function createDocumentRuntime(
620
663
  return reasons;
621
664
  }
622
665
 
666
+ function getMatchingWorkflowScope(
667
+ selection: EditorState["selection"],
668
+ ): WorkflowOverlay["scopes"][number] | null {
669
+ if (!workflowOverlay) {
670
+ return null;
671
+ }
672
+
673
+ const selectionBounds = {
674
+ from: Math.min(selection.anchor, selection.head),
675
+ to: Math.max(selection.anchor, selection.head),
676
+ };
677
+ const activeScopes = getEffectiveWorkflowScopes(workflowOverlay);
678
+ return activeScopes.find((scope) => {
679
+ if (scope.anchor.kind === "detached") return false;
680
+ const scopeFrom = scope.anchor.kind === "range" ? scope.anchor.from : scope.anchor.at;
681
+ const scopeTo = scope.anchor.kind === "range" ? scope.anchor.to : scope.anchor.at;
682
+ return selectionBounds.from >= scopeFrom && selectionBounds.to <= scopeTo;
683
+ }) ?? null;
684
+ }
685
+
686
+ function getEffectiveDocumentMode(
687
+ selection: EditorState["selection"],
688
+ ): DocumentMode {
689
+ if (viewState.documentMode === "viewing") {
690
+ return "viewing";
691
+ }
692
+ const matchingScope = getMatchingWorkflowScope(selection);
693
+ if (matchingScope?.mode === "suggest") {
694
+ return "suggesting";
695
+ }
696
+ return viewState.documentMode;
697
+ }
698
+
623
699
  function expandSelectionRange(
624
700
  range: { from: number; to: number },
625
701
  ): { from: number; to: number } {
@@ -632,10 +708,14 @@ export function createDocumentRuntime(
632
708
  function deriveOpaqueWorkflowBlockedReason(
633
709
  range: { from: number; to: number },
634
710
  ): WorkflowBlockedCommandReason | null {
711
+ const targetPartPath = getStoryTargetOpaquePartPath(activeStory);
712
+ if (!targetPartPath) {
713
+ return null;
714
+ }
635
715
  const fragments = findOpaqueFragmentsIntersectingRange(
636
716
  state.document.preservation,
637
717
  range,
638
- );
718
+ ).filter((fragment) => fragment.packagePartName === targetPartPath);
639
719
 
640
720
  if (fragments.length === 0) {
641
721
  return null;
@@ -669,6 +749,35 @@ export function createDocumentRuntime(
669
749
  };
670
750
  }
671
751
 
752
+ function getStoryTargetOpaquePartPath(storyTarget: EditorStoryTarget): string | null {
753
+ if (storyTarget.kind === "main") {
754
+ return "/word/document.xml";
755
+ }
756
+ if (storyTarget.kind === "header") {
757
+ return state.document.subParts?.headers.find(
758
+ (header) =>
759
+ header.relationshipId === storyTarget.relationshipId
760
+ && header.variant === storyTarget.variant
761
+ && header.sectionIndex === storyTarget.sectionIndex,
762
+ )?.partPath ?? null;
763
+ }
764
+ if (storyTarget.kind === "footer") {
765
+ return state.document.subParts?.footers.find(
766
+ (footer) =>
767
+ footer.relationshipId === storyTarget.relationshipId
768
+ && footer.variant === storyTarget.variant
769
+ && footer.sectionIndex === storyTarget.sectionIndex,
770
+ )?.partPath ?? null;
771
+ }
772
+ if (storyTarget.kind === "footnote") {
773
+ return "/word/footnotes.xml";
774
+ }
775
+ if (storyTarget.kind === "endnote") {
776
+ return "/word/endnotes.xml";
777
+ }
778
+ return null;
779
+ }
780
+
672
781
  function deriveWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null {
673
782
  if (!workflowOverlay) return null;
674
783
  const blockedReasons = getCachedInteractionGuardSnapshot().blockedReasons;
@@ -687,6 +796,13 @@ export function createDocumentRuntime(
687
796
  };
688
797
  }
689
798
 
799
+ function deriveHostAnnotationSnapshot(): HostAnnotationSnapshot {
800
+ return {
801
+ totalCount: hostAnnotationOverlay?.annotations.length ?? 0,
802
+ annotations: structuredClone(hostAnnotationOverlay?.annotations ?? []),
803
+ };
804
+ }
805
+
690
806
  function getEffectiveWorkflowScopes(overlay: WorkflowOverlay): WorkflowOverlay["scopes"] {
691
807
  const activeWorkItemId = overlay.activeWorkItemId ?? null;
692
808
  const activeWorkItemScopeIds =
@@ -728,8 +844,25 @@ export function createDocumentRuntime(
728
844
  return cachedInteractionGuardSnapshot.snapshot;
729
845
  }
730
846
 
731
- const snapshot = {
732
- blockedReasons: evaluateWorkflowBlockedReasons(state.selection),
847
+ const blockedReasons = evaluateWorkflowBlockedReasons(state.selection);
848
+ const matchingScope = getMatchingWorkflowScope(state.selection);
849
+ const primaryBlockedReason = blockedReasons[0];
850
+ const snapshot: InteractionGuardSnapshot = {
851
+ effectiveMode: primaryBlockedReason
852
+ ? (
853
+ primaryBlockedReason.code === "workflow_comment_only"
854
+ ? "comment"
855
+ : primaryBlockedReason.code === "workflow_view_only"
856
+ ? "view"
857
+ : "blocked"
858
+ )
859
+ : getEffectiveDocumentMode(state.selection) === "suggesting"
860
+ ? "suggest"
861
+ : matchingScope?.mode ?? "edit",
862
+ ...(matchingScope?.scopeId ? { matchedScopeId: matchingScope.scopeId } : {}),
863
+ ...(matchingScope?.mode ? { matchedScopeMode: matchingScope.mode } : {}),
864
+ ...(primaryBlockedReason ? { disabledReason: primaryBlockedReason.message } : {}),
865
+ blockedReasons,
733
866
  };
734
867
  cachedInteractionGuardSnapshot = {
735
868
  revisionToken: state.revisionToken,
@@ -767,6 +900,34 @@ export function createDocumentRuntime(
767
900
  return snapshot;
768
901
  }
769
902
 
903
+ function getCachedWorkflowMarkupSnapshot(): WorkflowMarkupSnapshot {
904
+ const activeStoryKey = storyTargetKey(activeStory);
905
+ if (
906
+ cachedWorkflowMarkupSnapshot &&
907
+ cachedWorkflowMarkupSnapshot.revisionToken === state.revisionToken &&
908
+ cachedWorkflowMarkupSnapshot.activeStoryKey === activeStoryKey &&
909
+ cachedWorkflowMarkupSnapshot.protectionSnapshot === protectionSnapshot &&
910
+ cachedWorkflowMarkupSnapshot.preservation === state.document.preservation
911
+ ) {
912
+ return cachedWorkflowMarkupSnapshot.snapshot;
913
+ }
914
+
915
+ const snapshot = collectWorkflowMarkupSnapshot({
916
+ renderSnapshot: cachedRenderSnapshot,
917
+ fieldSnapshot: buildFieldSnapshot(state.document),
918
+ protectionSnapshot,
919
+ preservation: state.document.preservation,
920
+ });
921
+ cachedWorkflowMarkupSnapshot = {
922
+ revisionToken: state.revisionToken,
923
+ activeStoryKey,
924
+ protectionSnapshot,
925
+ preservation: state.document.preservation,
926
+ snapshot,
927
+ };
928
+ return snapshot;
929
+ }
930
+
770
931
  function refreshRenderSnapshot(): RuntimeRenderSnapshot {
771
932
  const surface = getCachedSurface(state.document, activeStory);
772
933
  return {
@@ -878,10 +1039,25 @@ export function createDocumentRuntime(
878
1039
  getRenderSnapshot() {
879
1040
  return cachedRenderSnapshot;
880
1041
  },
1042
+ getCanonicalDocument() {
1043
+ return state.document;
1044
+ },
1045
+ getSourcePackage() {
1046
+ return state.sourcePackage;
1047
+ },
1048
+ emitBlockedCommand(command, reasons) {
1049
+ emit({
1050
+ type: "command_blocked",
1051
+ documentId: state.documentId,
1052
+ command,
1053
+ reasons,
1054
+ });
1055
+ },
881
1056
  dispatch(command) {
1057
+ const commandSelection = getCommandSelection(command, state.selection);
882
1058
  if (isMutationCommand(command)) {
883
1059
  const blockedReasons = evaluateWorkflowBlockedReasons(
884
- getCommandSelection(command, state.selection),
1060
+ commandSelection,
885
1061
  command.type,
886
1062
  );
887
1063
  if (blockedReasons.length > 0) {
@@ -909,6 +1085,8 @@ export function createDocumentRuntime(
909
1085
  try {
910
1086
  const transaction = executeEditorCommand(state, command, {
911
1087
  timestamp: command.origin?.timestamp ?? clock(),
1088
+ documentMode: getEffectiveDocumentMode(commandSelection),
1089
+ defaultAuthorId: defaultAuthorId ?? undefined,
912
1090
  });
913
1091
  commit(transaction);
914
1092
  } catch (error) {
@@ -949,29 +1127,24 @@ export function createDocumentRuntime(
949
1127
  replaceText(text, target) {
950
1128
  try {
951
1129
  const timestamp = clock();
952
- const selection = target
953
- ? createSelectionFromPublicAnchor(target)
954
- : state.selection;
955
- const blockedReasons = evaluateWorkflowBlockedReasons(selection, "text.insert");
956
- if (blockedReasons.length > 0) {
957
- emit({
958
- type: "command_blocked",
959
- documentId: state.documentId,
960
- command: "replaceText",
961
- reasons: blockedReasons,
962
- });
963
- return;
964
- }
965
- const result = insertText(state.document, selection, text, { timestamp });
966
-
967
- this.dispatch({
968
- type: "document.replace",
969
- document: result.document,
970
- mapping: result.mapping,
971
- selection: result.selection,
972
- protectionSelection: selection,
973
- origin: createOrigin("api", timestamp),
974
- });
1130
+ applyTextCommandInActiveStory(
1131
+ {
1132
+ type: "text.insert",
1133
+ text,
1134
+ origin: createOrigin("api", timestamp),
1135
+ },
1136
+ {
1137
+ selection: target ? createSelectionFromPublicAnchor(target) : state.selection,
1138
+ blockedCommandName: "replaceText",
1139
+ },
1140
+ );
1141
+ } catch (error) {
1142
+ emitError(toRuntimeError(error));
1143
+ }
1144
+ },
1145
+ applyActiveStoryTextCommand(command) {
1146
+ try {
1147
+ applyTextCommandInActiveStory(command);
975
1148
  } catch (error) {
976
1149
  emitError(toRuntimeError(error));
977
1150
  }
@@ -1308,12 +1481,32 @@ export function createDocumentRuntime(
1308
1481
  return getCachedInteractionGuardSnapshot();
1309
1482
  },
1310
1483
  getWorkflowMarkupSnapshot() {
1311
- return collectWorkflowMarkupSnapshot({
1312
- renderSnapshot: this.getRenderSnapshot(),
1313
- fieldSnapshot: this.getFieldSnapshot(),
1314
- protectionSnapshot,
1315
- preservation: state.document.preservation,
1484
+ return getCachedWorkflowMarkupSnapshot();
1485
+ },
1486
+ setHostAnnotationOverlay(overlay) {
1487
+ hostAnnotationOverlay = structuredClone(overlay);
1488
+ emit({
1489
+ type: "host_annotation_overlay_changed",
1490
+ documentId: state.documentId,
1491
+ snapshot: deriveHostAnnotationSnapshot(),
1316
1492
  });
1493
+ for (const listener of listeners) {
1494
+ listener();
1495
+ }
1496
+ },
1497
+ clearHostAnnotationOverlay() {
1498
+ hostAnnotationOverlay = null;
1499
+ emit({
1500
+ type: "host_annotation_overlay_changed",
1501
+ documentId: state.documentId,
1502
+ snapshot: deriveHostAnnotationSnapshot(),
1503
+ });
1504
+ for (const listener of listeners) {
1505
+ listener();
1506
+ }
1507
+ },
1508
+ getHostAnnotationSnapshot() {
1509
+ return deriveHostAnnotationSnapshot();
1317
1510
  },
1318
1511
  getWorkflowCandidateRanges(options) {
1319
1512
  return deriveWorkflowCandidateRangesFromMarkup(this.getWorkflowMarkupSnapshot(), options);
@@ -1325,6 +1518,14 @@ export function createDocumentRuntime(
1325
1518
  if (!target || target.anchor.kind === "detached") {
1326
1519
  return;
1327
1520
  }
1521
+ const targetStory = target.storyTarget ?? MAIN_STORY_TARGET;
1522
+ if (!storyTargetsEqual(activeStory, targetStory)) {
1523
+ if (targetStory.kind === "main") {
1524
+ this.closeStory();
1525
+ } else if (!this.openStory(targetStory)) {
1526
+ return;
1527
+ }
1528
+ }
1328
1529
  this.replaceText(text, target.anchor);
1329
1530
  },
1330
1531
  };
@@ -1343,7 +1544,7 @@ export function createDocumentRuntime(
1343
1544
  const previous = state;
1344
1545
  // Undo/redo changes the document — must mint a new revisionToken so
1345
1546
  // autosave/export checkpoint dedup treats it as fresh content.
1346
- state = finalizeState(target, true, clock());
1547
+ state = finalizeState(target, true, clock(), previous.revision);
1347
1548
  storySelections.set(storyTargetKey(activeStory), state.selection);
1348
1549
  cachedRenderSnapshot = refreshRenderSnapshot();
1349
1550
  notify(previous, state, {
@@ -1427,6 +1628,27 @@ export function createDocumentRuntime(
1427
1628
  });
1428
1629
  }
1429
1630
 
1631
+ if (transaction.effects.revisionAuthored) {
1632
+ emit({
1633
+ type: "change_authored",
1634
+ documentId: next.documentId,
1635
+ changeId: transaction.effects.revisionAuthored.changeId,
1636
+ kind: transaction.effects.revisionAuthored.kind,
1637
+ });
1638
+ }
1639
+
1640
+ if (transaction.effects.commandBlocked) {
1641
+ emit({
1642
+ type: "command_blocked",
1643
+ documentId: next.documentId,
1644
+ command: transaction.effects.commandBlocked.code,
1645
+ reasons: [{
1646
+ code: transaction.effects.commandBlocked.code as WorkflowBlockedCommandReason["code"],
1647
+ message: transaction.effects.commandBlocked.message,
1648
+ }],
1649
+ });
1650
+ }
1651
+
1430
1652
  for (const warning of transaction.effects.warningsAdded) {
1431
1653
  const publicWarning = toPublicWarning(warning);
1432
1654
  emit({
@@ -1451,6 +1673,164 @@ export function createDocumentRuntime(
1451
1673
  }
1452
1674
  }
1453
1675
 
1676
+ function applyTextCommandInActiveStory(
1677
+ command: ActiveStoryTextCommand,
1678
+ options: {
1679
+ selection?: EditorState["selection"];
1680
+ blockedCommandName?: string;
1681
+ } = {},
1682
+ ): void {
1683
+ const selection = options.selection ?? state.selection;
1684
+ const blockedReasons = evaluateWorkflowBlockedReasons(selection, command.type);
1685
+ if (blockedReasons.length > 0) {
1686
+ emit({
1687
+ type: "command_blocked",
1688
+ documentId: state.documentId,
1689
+ command: options.blockedCommandName ?? command.type,
1690
+ reasons: blockedReasons,
1691
+ });
1692
+ return;
1693
+ }
1694
+
1695
+ const timestamp = command.origin?.timestamp ?? clock();
1696
+ const context = {
1697
+ timestamp,
1698
+ documentMode: getEffectiveDocumentMode(selection),
1699
+ defaultAuthorId: defaultAuthorId ?? undefined,
1700
+ } as const;
1701
+ const baseState = selection === state.selection
1702
+ ? state
1703
+ : {
1704
+ ...state,
1705
+ selection,
1706
+ };
1707
+
1708
+ if (activeStory.kind === "main") {
1709
+ commit(executeEditorCommand(baseState, command, context));
1710
+ return;
1711
+ }
1712
+
1713
+ const localState = createEditorState({
1714
+ documentId: state.documentId,
1715
+ sessionId,
1716
+ sourceLabel: state.sourceLabel,
1717
+ readOnly: state.readOnly,
1718
+ canonicalDocument: {
1719
+ ...state.document,
1720
+ content: {
1721
+ type: "doc",
1722
+ children: [...getStoryBlocks(state.document, activeStory)],
1723
+ },
1724
+ review: createSecondaryStoryLocalReviewState(state.document.review, activeStory),
1725
+ },
1726
+ compatibility: state.compatibility,
1727
+ warnings: state.warnings,
1728
+ fatalError: state.fatalError,
1729
+ });
1730
+ localState.selection = selection;
1731
+ const localTransaction = executeEditorCommand(localState, command, context);
1732
+
1733
+ if (!localTransaction.markDirty) {
1734
+ notify(state, state, {
1735
+ nextState: state,
1736
+ mapping: createEmptyMapping(),
1737
+ effects: localTransaction.effects,
1738
+ historyBoundary: "skip",
1739
+ markDirty: false,
1740
+ });
1741
+ return;
1742
+ }
1743
+
1744
+ const nextDocument = replaceStoryBlocks(
1745
+ state.document,
1746
+ activeStory,
1747
+ localTransaction.nextState.document.content.children,
1748
+ );
1749
+ const nextDocumentWithReview = {
1750
+ ...nextDocument,
1751
+ review: mergeSecondaryStoryReviewState(
1752
+ state.document.review,
1753
+ localTransaction.nextState.document.review,
1754
+ localTransaction.effects,
1755
+ activeStory,
1756
+ ),
1757
+ };
1758
+ const fullTransaction = executeEditorCommand(
1759
+ baseState,
1760
+ {
1761
+ type: "document.replace",
1762
+ document: nextDocumentWithReview,
1763
+ selection: localTransaction.nextState.selection,
1764
+ mapping: createEmptyMapping(),
1765
+ protectionSelection: selection,
1766
+ origin: command.origin,
1767
+ },
1768
+ context,
1769
+ );
1770
+
1771
+ commit({
1772
+ ...fullTransaction,
1773
+ effects: mergeTransactionEffects(fullTransaction.effects, localTransaction.effects),
1774
+ });
1775
+ }
1776
+
1777
+ function mergeTransactionEffects(
1778
+ base: EditorTransaction["effects"],
1779
+ local: EditorTransaction["effects"],
1780
+ ): EditorTransaction["effects"] {
1781
+ return {
1782
+ warningsAdded: [...base.warningsAdded, ...local.warningsAdded],
1783
+ warningsCleared: [...base.warningsCleared, ...local.warningsCleared],
1784
+ commentAdded: base.commentAdded ?? local.commentAdded,
1785
+ commentResolved: base.commentResolved ?? local.commentResolved,
1786
+ commentReopened: base.commentReopened ?? local.commentReopened,
1787
+ commentReplyAdded: base.commentReplyAdded ?? local.commentReplyAdded,
1788
+ commentBodyEdited: base.commentBodyEdited ?? local.commentBodyEdited,
1789
+ changeAccepted: base.changeAccepted ?? local.changeAccepted,
1790
+ changeRejected: base.changeRejected ?? local.changeRejected,
1791
+ revisionAuthored: base.revisionAuthored ?? local.revisionAuthored,
1792
+ commandBlocked: base.commandBlocked ?? local.commandBlocked,
1793
+ };
1794
+ }
1795
+
1796
+ function mergeSecondaryStoryReviewState(
1797
+ currentReview: EditorState["document"]["review"],
1798
+ localReview: EditorState["document"]["review"],
1799
+ effects: EditorTransaction["effects"],
1800
+ storyTarget: EditorStoryTarget,
1801
+ ): EditorState["document"]["review"] {
1802
+ const nextReview: EditorState["document"]["review"] = {
1803
+ comments: { ...currentReview.comments },
1804
+ revisions: { ...currentReview.revisions },
1805
+ };
1806
+
1807
+ const currentStoryRevisionIds = Object.values(currentReview.revisions)
1808
+ .filter((revision) => storyTargetsEqual(getRevisionStoryTarget(revision), storyTarget))
1809
+ .map((revision) => revision.changeId);
1810
+ for (const revisionId of currentStoryRevisionIds) {
1811
+ delete nextReview.revisions[revisionId];
1812
+ }
1813
+ for (const revision of Object.values(localReview.revisions)) {
1814
+ nextReview.revisions[revision.changeId] = {
1815
+ ...revision,
1816
+ metadata: {
1817
+ ...revision.metadata,
1818
+ storyTarget: createRevisionStoryTargetRecord(storyTarget),
1819
+ },
1820
+ };
1821
+ }
1822
+
1823
+ if (effects.commentAdded) {
1824
+ const commentId = effects.commentAdded.commentId;
1825
+ const comment = localReview.comments[commentId];
1826
+ if (comment) {
1827
+ nextReview.comments[commentId] = comment;
1828
+ }
1829
+ }
1830
+
1831
+ return nextReview;
1832
+ }
1833
+
1454
1834
  function emit(event: DocumentRuntimeEvent): void {
1455
1835
  options.onEvent?.(event);
1456
1836
  for (const listener of eventListeners) {
@@ -1547,11 +1927,12 @@ function finalizeState(
1547
1927
  state: EditorState,
1548
1928
  markDirty: boolean,
1549
1929
  timestamp: string,
1930
+ baseRevision?: number,
1550
1931
  ): EditorState {
1551
1932
  // Only increment revision on actual document mutations (markDirty=true).
1552
1933
  // Selection-only changes must not churn the revisionToken, which would
1553
1934
  // cause autosave/checkpoint dedup to treat cursor movement as new content.
1554
- const revision = markDirty ? state.revision + 1 : state.revision;
1935
+ const revision = markDirty ? (baseRevision ?? state.revision) + 1 : state.revision;
1555
1936
 
1556
1937
  return {
1557
1938
  ...state,
@@ -1780,11 +2161,11 @@ function toPublicCommentSidebarSnapshot(
1780
2161
 
1781
2162
  function toPublicTrackedChangesSnapshot(
1782
2163
  state: EditorState,
1783
- surfaceText = "",
1784
2164
  ): TrackedChangesSnapshot {
1785
2165
  const projection = createRevisionSidebarProjection(
1786
2166
  createRevisionStoreFromDocument(state),
1787
2167
  );
2168
+ const storyPlainTextCache = new Map<string, string>();
1788
2169
 
1789
2170
  return {
1790
2171
  pendingChangeIds: projection.activeRevisionIds,
@@ -1796,11 +2177,12 @@ function toPublicTrackedChangesSnapshot(
1796
2177
  totalCount: projection.totalCount,
1797
2178
  revisions: projection.revisions.map((revision): TrackedChangeEntrySnapshot => {
1798
2179
  const sourceRevision = state.document.review.revisions[revision.revisionId];
2180
+ const storyTarget = getRevisionStoryTarget(sourceRevision);
1799
2181
  const preview = describeRevisionPreview(
1800
2182
  revision,
1801
2183
  sourceRevision?.anchor ??
1802
2184
  createDetachedAnchor({ from: 0, to: 0 }, "importAmbiguity"),
1803
- surfaceText,
2185
+ getStoryPlainText(state.document, storyTarget, storyPlainTextCache),
1804
2186
  );
1805
2187
 
1806
2188
  return {
@@ -1809,6 +2191,7 @@ function toPublicTrackedChangesSnapshot(
1809
2191
  label: revision.label,
1810
2192
  status: revision.status,
1811
2193
  actionability: revision.actionability,
2194
+ storyTarget,
1812
2195
  anchor: toPublicAnchorProjection(
1813
2196
  sourceRevision?.anchor ??
1814
2197
  createDetachedAnchor({ from: 0, to: 0 }, "importAmbiguity"),
@@ -1849,6 +2232,7 @@ function createRevisionStoreFromDocument(
1849
2232
  warningIds: [...(revision.warningIds ?? [])],
1850
2233
  metadata: {
1851
2234
  source: revision.metadata?.source ?? "runtime",
2235
+ storyTarget: revision.metadata?.storyTarget,
1852
2236
  preserveOnlyReason: revision.metadata?.preserveOnlyReason,
1853
2237
  importedRevisionForm: revision.metadata?.importedRevisionForm,
1854
2238
  originalRevisionType: revision.metadata?.originalRevisionType,
@@ -1860,6 +2244,61 @@ function createRevisionStoreFromDocument(
1860
2244
  };
1861
2245
  }
1862
2246
 
2247
+ function getRevisionStoryTarget(
2248
+ revision: EditorState["document"]["review"]["revisions"][string] | undefined,
2249
+ ): EditorStoryTarget {
2250
+ const storyTarget = revision?.metadata?.storyTarget;
2251
+ return storyTarget ? { ...storyTarget } : MAIN_STORY_TARGET;
2252
+ }
2253
+
2254
+ function createSecondaryStoryLocalReviewState(
2255
+ review: EditorState["document"]["review"],
2256
+ storyTarget: EditorStoryTarget,
2257
+ ): EditorState["document"]["review"] {
2258
+ return {
2259
+ comments: {},
2260
+ revisions: Object.fromEntries(
2261
+ Object.values(review.revisions)
2262
+ .filter((revision) => storyTargetsEqual(getRevisionStoryTarget(revision), storyTarget))
2263
+ .map((revision) => [
2264
+ revision.changeId,
2265
+ {
2266
+ ...revision,
2267
+ metadata: {
2268
+ ...revision.metadata,
2269
+ storyTarget: createRevisionStoryTargetRecord(storyTarget),
2270
+ },
2271
+ },
2272
+ ]),
2273
+ ),
2274
+ };
2275
+ }
2276
+
2277
+ function getStoryPlainText(
2278
+ document: CanonicalDocumentEnvelope,
2279
+ storyTarget: EditorStoryTarget,
2280
+ cache: Map<string, string>,
2281
+ ): string {
2282
+ const key = storyTargetKey(storyTarget);
2283
+ const cached = cache.get(key);
2284
+ if (cached !== undefined) {
2285
+ return cached;
2286
+ }
2287
+ const plainText = createEditorSurfaceSnapshot(
2288
+ document,
2289
+ createSelectionSnapshot(0, 0),
2290
+ storyTarget,
2291
+ ).plainText;
2292
+ cache.set(key, plainText);
2293
+ return plainText;
2294
+ }
2295
+
2296
+ function createRevisionStoryTargetRecord(
2297
+ storyTarget: EditorStoryTarget,
2298
+ ): NonNullable<NonNullable<EditorState["document"]["review"]["revisions"][string]["metadata"]>["storyTarget"]> {
2299
+ return { ...storyTarget };
2300
+ }
2301
+
1863
2302
  function listBlockExportReasons(
1864
2303
  report: InternalCompatibilityReport,
1865
2304
  ): string[] {
@@ -2011,6 +2450,19 @@ const NON_MUTATION_COMMANDS = new Set([
2011
2450
  "comment.open",
2012
2451
  ]);
2013
2452
 
2453
+ /** Mutation commands that are not yet supported in suggesting mode. */
2454
+ const SUGGESTING_UNSUPPORTED_COMMANDS = new Set([
2455
+ "paragraph.split",
2456
+ ]);
2457
+
2458
+ const SUGGESTING_SECONDARY_STORY_UNSUPPORTED_COMMANDS = new Set([
2459
+ "text.insert",
2460
+ "text.delete-backward",
2461
+ "text.delete-forward",
2462
+ "text.insert-tab",
2463
+ "text.insert-hard-break",
2464
+ ]);
2465
+
2014
2466
  function isMutationCommand(command: EditorCommand): boolean {
2015
2467
  return !NON_MUTATION_COMMANDS.has(command.type);
2016
2468
  }