@beyondwork/docx-react-component 1.0.28 → 1.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/package.json +26 -37
  2. package/src/api/public-types.ts +531 -0
  3. package/src/api/session-state.ts +2 -0
  4. package/src/core/commands/index.ts +201 -79
  5. package/src/core/commands/table-structure-commands.ts +138 -5
  6. package/src/core/state/text-transaction.ts +370 -3
  7. package/src/index.ts +41 -0
  8. package/src/io/docx-session.ts +318 -25
  9. package/src/io/export/serialize-footnotes.ts +41 -46
  10. package/src/io/export/serialize-headers-footers.ts +36 -40
  11. package/src/io/export/serialize-main-document.ts +55 -89
  12. package/src/io/export/serialize-numbering.ts +104 -4
  13. package/src/io/export/serialize-runtime-revisions.ts +196 -2
  14. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +252 -0
  15. package/src/io/export/table-properties-xml.ts +318 -0
  16. package/src/io/normalize/normalize-text.ts +34 -3
  17. package/src/io/ooxml/parse-comments.ts +6 -0
  18. package/src/io/ooxml/parse-footnotes.ts +69 -13
  19. package/src/io/ooxml/parse-headers-footers.ts +54 -11
  20. package/src/io/ooxml/parse-main-document.ts +112 -42
  21. package/src/io/ooxml/parse-numbering.ts +341 -26
  22. package/src/io/ooxml/parse-revisions.ts +118 -4
  23. package/src/io/ooxml/parse-styles.ts +176 -0
  24. package/src/io/ooxml/parse-tables.ts +34 -25
  25. package/src/io/ooxml/revision-boundaries.ts +127 -3
  26. package/src/io/ooxml/workflow-payload.ts +544 -0
  27. package/src/model/canonical-document.ts +91 -1
  28. package/src/model/snapshot.ts +112 -1
  29. package/src/preservation/store.ts +73 -3
  30. package/src/review/store/comment-store.ts +19 -1
  31. package/src/review/store/revision-actions.ts +29 -0
  32. package/src/review/store/revision-store.ts +12 -1
  33. package/src/review/store/revision-types.ts +11 -0
  34. package/src/runtime/context-analytics.ts +824 -0
  35. package/src/runtime/document-locations.ts +521 -0
  36. package/src/runtime/document-navigation.ts +14 -1
  37. package/src/runtime/document-outline.ts +440 -0
  38. package/src/runtime/document-runtime.ts +941 -45
  39. package/src/runtime/event-refresh-hints.ts +137 -0
  40. package/src/runtime/numbering-prefix.ts +67 -39
  41. package/src/runtime/page-layout-estimation.ts +100 -7
  42. package/src/runtime/resolved-numbering-geometry.ts +293 -0
  43. package/src/runtime/session-capabilities.ts +2 -2
  44. package/src/runtime/suggestions-snapshot.ts +137 -0
  45. package/src/runtime/surface-projection.ts +223 -27
  46. package/src/runtime/table-style-resolver.ts +409 -0
  47. package/src/runtime/view-state.ts +17 -1
  48. package/src/runtime/workflow-markup.ts +54 -14
  49. package/src/ui/WordReviewEditor.tsx +1269 -87
  50. package/src/ui/editor-command-bag.ts +7 -0
  51. package/src/ui/editor-runtime-boundary.ts +111 -10
  52. package/src/ui/editor-shell-view.tsx +17 -15
  53. package/src/ui/editor-surface-controller.tsx +5 -0
  54. package/src/ui/headless/selection-tool-context.ts +19 -0
  55. package/src/ui/headless/selection-tool-resolver.ts +752 -0
  56. package/src/ui/headless/selection-tool-types.ts +129 -0
  57. package/src/ui/headless/selection-toolbar-model.ts +10 -33
  58. package/src/ui/runtime-shortcut-dispatch.ts +365 -0
  59. package/src/ui-tailwind/chrome/chrome-preset-model.ts +107 -0
  60. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +15 -0
  61. package/src/ui-tailwind/chrome/review-queue-bar.tsx +97 -0
  62. package/src/ui-tailwind/chrome/tw-context-analytics-summary.tsx +122 -0
  63. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +1 -9
  64. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +1 -5
  65. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +8 -29
  66. package/src/ui-tailwind/chrome/tw-selection-tool-blocked.tsx +23 -0
  67. package/src/ui-tailwind/chrome/tw-selection-tool-comment.tsx +35 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +37 -0
  69. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +298 -0
  70. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +116 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-suggestion.tsx +29 -0
  72. package/src/ui-tailwind/chrome/tw-selection-tool-workflow.tsx +27 -0
  73. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +3 -3
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +3 -3
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +86 -14
  76. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +57 -52
  77. package/src/ui-tailwind/editor-surface/pm-decorations.ts +36 -52
  78. package/src/ui-tailwind/editor-surface/pm-schema.ts +56 -5
  79. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +87 -24
  80. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +4 -0
  81. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +135 -32
  82. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +74 -7
  83. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +17 -17
  84. package/src/ui-tailwind/review/tw-review-rail.tsx +19 -17
  85. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +10 -10
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +10 -6
  87. package/src/ui-tailwind/theme/editor-theme.css +58 -40
  88. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -4
  89. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +250 -181
  90. package/src/ui-tailwind/tw-review-workspace.tsx +323 -280
  91. package/src/validation/compatibility-engine.ts +246 -2
  92. package/src/validation/docx-comment-proof.ts +24 -11
@@ -23,6 +23,7 @@ import type {
23
23
  import { createCanonicalDocumentId } from "../core/state/editor-state.ts";
24
24
  import {
25
25
  createDetachedAnchor,
26
+ storyTargetsEqual,
26
27
  type EditorAnchorProjection as InternalEditorAnchorProjection,
27
28
  } from "../core/selection/mapping.ts";
28
29
  import { DOCX_MIME_TYPE } from "./opc/docx-package.ts";
@@ -42,6 +43,18 @@ import {
42
43
  resolveRelationshipTarget,
43
44
  type OpcRelationship,
44
45
  } from "./ooxml/part-manifest.ts";
46
+ import {
47
+ buildWorkflowPayloadParts,
48
+ getDocumentBackedWorkflowMetadata,
49
+ parseWorkflowPayloadFromPackage,
50
+ WORKFLOW_PAYLOAD_CONTENT_TYPE,
51
+ WORKFLOW_PAYLOAD_CUSTOM_PROPS_CONTENT_TYPE,
52
+ WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
53
+ WORKFLOW_PAYLOAD_CUSTOM_PROPS_RELATIONSHIP_TYPE,
54
+ WORKFLOW_PAYLOAD_ITEM_PROPS_CONTENT_TYPE,
55
+ WORKFLOW_PAYLOAD_ITEM_PROPS_PART_PATH,
56
+ WORKFLOW_PAYLOAD_PART_PATH,
57
+ } from "./ooxml/workflow-payload.ts";
45
58
  import {
46
59
  classifyCorruptPackageError,
47
60
  createBrokenRelationshipIssue,
@@ -49,7 +62,11 @@ import {
49
62
  } from "./opc/corrupt-package.ts";
50
63
  import { createExportSession } from "./export/export-session.ts";
51
64
  import { serializeMainDocument } from "./export/serialize-main-document.ts";
52
- import { parseRevisionsFromDocumentXml, type ParsedRevisionsResult } from "./ooxml/parse-revisions.ts";
65
+ import {
66
+ parseRevisionsFromDocumentXml,
67
+ parseRevisionsFromStoryXml,
68
+ type ParsedRevisionsResult,
69
+ } from "./ooxml/parse-revisions.ts";
53
70
  import { parseCommentsFromOoxml } from "./ooxml/parse-comments.ts";
54
71
  import { parseNumberingXml } from "./ooxml/parse-numbering.ts";
55
72
  import {
@@ -59,7 +76,11 @@ import {
59
76
  serializeMergedCommentsXml,
60
77
  } from "./export/serialize-comments.ts";
61
78
  import { splitDocumentAtReviewBoundaries } from "./export/split-review-boundaries.ts";
62
- import { serializeRuntimeRevisionsIntoDocumentXml } from "./export/serialize-runtime-revisions.ts";
79
+ import { splitStoryBlocksForRuntimeRevisions } from "./export/split-story-blocks-for-runtime-revisions.ts";
80
+ import {
81
+ serializeRuntimeRevisionsIntoDocumentXml,
82
+ serializeRuntimeRevisionsIntoStoryXml,
83
+ } from "./export/serialize-runtime-revisions.ts";
63
84
  import { createCommentStoreFromRuntimeComments } from "../review/store/runtime-comment-store.ts";
64
85
  import type { CommentThread } from "../review/store/comment-store.ts";
65
86
  import type { RevisionRecord as ReviewRevisionRecord } from "../review/store/revision-types.ts";
@@ -105,7 +126,9 @@ import { parseSettingsXml } from "./ooxml/parse-settings.ts";
105
126
  import { parseStylesXml, type ParseStylesResult } from "./ooxml/parse-styles.ts";
106
127
  import {
107
128
  serializeHeaderXml,
129
+ serializeHeaderXmlWithRevisions,
108
130
  serializeFooterXml,
131
+ serializeFooterXmlWithRevisions,
109
132
  WORD_HEADER_CONTENT_TYPE,
110
133
  WORD_FOOTER_CONTENT_TYPE,
111
134
  } from "./export/serialize-headers-footers.ts";
@@ -199,6 +222,7 @@ export interface LoadedDocxEditorSession {
199
222
  interface ImportedDocxState {
200
223
  sourceBytes: Uint8Array;
201
224
  sourcePackage: OpcPackage;
225
+ sourceDocumentXml: string;
202
226
  sourceDocumentPartPath: string;
203
227
  sourceDocumentRelationships: readonly OpcRelationship[];
204
228
  sourceDocumentAttributes: Record<string, string>;
@@ -265,6 +289,7 @@ export function loadDocxEditorSession(
265
289
  }),
266
290
  );
267
291
  }
292
+ const embeddedWorkflowMetadata = parseWorkflowPayloadFromPackage(sourcePackage);
268
293
 
269
294
  const mainDocumentPath = resolveMainDocumentPartPath(sourcePackage);
270
295
  const brokenRelationshipIssues = collectBrokenInternalRelationshipIssues(
@@ -403,6 +428,8 @@ export function loadDocxEditorSession(
403
428
  normalizedDocument.preservation.opaqueFragments,
404
429
  normalizedRevisions.revisions,
405
430
  );
431
+ const importedStoryRevisions: ReviewRevisionRecord[] = [];
432
+ const importedStoryRevisionDiagnostics: ParsedRevisionsResult["diagnostics"] = [];
406
433
  const subPartOpaqueState = createSubPartOpaqueImportState(
407
434
  normalizedDocument.preservation.opaqueFragments,
408
435
  normalizedDocument.diagnostics.warnings,
@@ -437,6 +464,7 @@ export function loadDocxEditorSession(
437
464
 
438
465
  const xml = decodeUtf8(partBytes);
439
466
  if (ref.kind === "header") {
467
+ const parsedHeaderRevisions = parseRevisionsFromStoryXml(xml);
440
468
  const parsed = parseHeaderXml(xml);
441
469
  parsedHeaders.push({
442
470
  variant: ref.variant,
@@ -451,8 +479,24 @@ export function loadDocxEditorSession(
451
479
  subPartOpaqueState,
452
480
  ),
453
481
  });
482
+ importedStoryRevisions.push(
483
+ ...parsedHeaderRevisions.revisions.map((revision): ReviewRevisionRecord => ({
484
+ ...revision,
485
+ metadata: {
486
+ ...revision.metadata,
487
+ storyTarget: {
488
+ kind: "header" as const,
489
+ relationshipId: ref.relationshipId,
490
+ variant: ref.variant,
491
+ ...(ref.sectionIndex !== undefined ? { sectionIndex: ref.sectionIndex } : {}),
492
+ },
493
+ },
494
+ })),
495
+ );
496
+ importedStoryRevisionDiagnostics.push(...parsedHeaderRevisions.diagnostics);
454
497
  sourceHeaderPaths.push({ partPath, relationshipId: ref.relationshipId });
455
498
  } else {
499
+ const parsedFooterRevisions = parseRevisionsFromStoryXml(xml);
456
500
  const parsed = parseFooterXml(xml);
457
501
  parsedFooters.push({
458
502
  variant: ref.variant,
@@ -467,6 +511,21 @@ export function loadDocxEditorSession(
467
511
  subPartOpaqueState,
468
512
  ),
469
513
  });
514
+ importedStoryRevisions.push(
515
+ ...parsedFooterRevisions.revisions.map((revision): ReviewRevisionRecord => ({
516
+ ...revision,
517
+ metadata: {
518
+ ...revision.metadata,
519
+ storyTarget: {
520
+ kind: "footer" as const,
521
+ relationshipId: ref.relationshipId,
522
+ variant: ref.variant,
523
+ ...(ref.sectionIndex !== undefined ? { sectionIndex: ref.sectionIndex } : {}),
524
+ },
525
+ },
526
+ })),
527
+ );
528
+ importedStoryRevisionDiagnostics.push(...parsedFooterRevisions.diagnostics);
470
529
  sourceFooterPaths.push({ partPath, relationshipId: ref.relationshipId });
471
530
  }
472
531
  }
@@ -624,6 +683,12 @@ export function loadDocxEditorSession(
624
683
  source: "review" as const,
625
684
  message: diagnostic.message,
626
685
  })),
686
+ ...importedStoryRevisionDiagnostics.map((diagnostic, index) => ({
687
+ diagnosticId: `diagnostic:story-revision-import-${index + 1}`,
688
+ warningId: `warning:story-revision-import-${diagnostic.revisionId}`,
689
+ source: "review" as const,
690
+ message: diagnostic.message,
691
+ })),
627
692
  ...normalizedComments.diagnostics.map((diagnostic, index) => ({
628
693
  diagnosticId: `diagnostic:comment-import-${index + 1}`,
629
694
  warningId: `warning:comment-import-${diagnostic.commentId}`,
@@ -635,7 +700,10 @@ export function loadDocxEditorSession(
635
700
  },
636
701
  review: {
637
702
  comments: toRuntimeCommentRecords(normalizedComments.threads),
638
- revisions: toRuntimeRevisionRecords(normalizedRevisions.revisions),
703
+ revisions: toRuntimeRevisionRecords([
704
+ ...normalizedRevisions.revisions,
705
+ ...importedStoryRevisions,
706
+ ]),
639
707
  },
640
708
  });
641
709
  const compatibility = buildCompatibilityReport({
@@ -650,6 +718,7 @@ export function loadDocxEditorSession(
650
718
  compatibility: toPublicCompatibilityReport(compatibility),
651
719
  protectionSnapshot: importedProtectionSnapshot,
652
720
  sourcePackage: createPersistedSourcePackage(sourceBytes, options.sourceLabel),
721
+ workflowMetadata: embeddedWorkflowMetadata,
653
722
  });
654
723
  const snapshotIssues = validatePersistedEditorSnapshot(snapshot);
655
724
  if (snapshotIssues.length > 0) {
@@ -670,6 +739,7 @@ export function loadDocxEditorSession(
670
739
  const importedState: ImportedDocxState = {
671
740
  sourceBytes: new Uint8Array(sourceBytes),
672
741
  sourcePackage,
742
+ sourceDocumentXml,
673
743
  sourceDocumentPartPath: mainDocumentPath,
674
744
  sourceDocumentRelationships: documentPart.relationships,
675
745
  sourceDocumentAttributes: extractDocumentRootAttributes(sourceDocumentXml),
@@ -757,9 +827,23 @@ function exportDocxEditorSession(
757
827
  const signatureMatch = serializeCanonicalDocumentForExport(currentDocument) ===
758
828
  state.initialCanonicalSignature;
759
829
  const canReuse = canReuseSourceBytesForCurrentDocument(state, currentDocument);
830
+ const durableWorkflowMetadata = getDocumentBackedWorkflowMetadata(sessionState.workflowMetadata);
760
831
  const commentCount = Object.keys(currentDocument.review?.comments ?? {}).length;
832
+ const currentRevisions = toReviewRevisionRecords(currentDocument.review.revisions);
833
+ const hasActiveImportedPreserveOnlyRevisions = currentRevisions.some(
834
+ (revision) =>
835
+ revision.status === "active" &&
836
+ revision.metadata.source === "import" &&
837
+ typeof revision.metadata.preserveOnlyReason === "string" &&
838
+ revision.metadata.preserveOnlyReason.length > 0,
839
+ );
761
840
 
762
- if (signatureMatch && canReuse) {
841
+ if (
842
+ signatureMatch &&
843
+ canReuse &&
844
+ durableWorkflowMetadata.definitions.length === 0 &&
845
+ durableWorkflowMetadata.entries.length === 0
846
+ ) {
763
847
  return {
764
848
  bytes: new Uint8Array(state.sourceBytes),
765
849
  mimeType: DOCX_MIME_TYPE,
@@ -781,29 +865,30 @@ function exportDocxEditorSession(
781
865
  `DOCX export is blocked because ${blockingCommentCount} preserve-only comment anchors cannot be safely remapped after runtime edits.`,
782
866
  );
783
867
  }
784
- const currentRevisions = toReviewRevisionRecords(currentDocument.review.revisions);
785
868
  const actionableRevisions = currentRevisions.filter(
786
869
  (revision) => getRevisionActionability(revision) === "actionable",
787
870
  );
788
- const secondaryStoryActionableRevisions = actionableRevisions.filter(
789
- (revision) => revision.metadata.storyTarget?.kind && revision.metadata.storyTarget.kind !== "main",
871
+ const mainStoryActionableRevisions = actionableRevisions.filter((revision) =>
872
+ !revision.metadata.storyTarget?.kind || revision.metadata.storyTarget.kind === "main"
790
873
  );
791
- if (secondaryStoryActionableRevisions.length > 0) {
792
- throw new Error(
793
- `DOCX export is blocked because ${secondaryStoryActionableRevisions.length} secondary-story tracked changes cannot yet be serialized safely.`,
794
- );
795
- }
796
874
  const commentThreads = Object.values(
797
875
  createCommentStoreFromRuntimeComments(currentDocument.review.comments).threads,
798
876
  );
799
877
  const ownedCommentThreads = commentThreads.filter(
800
878
  (thread) => !preservedCommentIds.has(thread.commentId),
801
879
  );
880
+ const revisionReadyMainContent = {
881
+ ...currentDocument.content,
882
+ children: splitStoryBlocksForRuntimeRevisions(
883
+ currentDocument.content.children,
884
+ mainStoryActionableRevisions,
885
+ ),
886
+ };
802
887
  const serialized = serializeMainDocument(
803
888
  splitDocumentAtReviewBoundaries(
804
- currentDocument.content as never,
889
+ revisionReadyMainContent as never,
805
890
  ownedCommentThreads,
806
- actionableRevisions,
891
+ mainStoryActionableRevisions,
807
892
  ) as never,
808
893
  currentDocument.preservation as never,
809
894
  state.sourceDocumentRelationships,
@@ -815,8 +900,7 @@ function exportDocxEditorSession(
815
900
  );
816
901
  const revisionDocument = serializeRuntimeRevisionsIntoDocumentXml(
817
902
  serialized.documentXml,
818
- actionableRevisions,
819
- serialized.paragraphBoundaries,
903
+ mainStoryActionableRevisions,
820
904
  );
821
905
  if (revisionDocument.skippedRevisionIds.length > 0) {
822
906
  throw new Error(
@@ -949,6 +1033,9 @@ function exportDocxEditorSession(
949
1033
  state.sourceDocumentPartPath,
950
1034
  APP_PROPERTIES_PART_PATH,
951
1035
  CORE_PROPERTIES_PART_PATH,
1036
+ WORKFLOW_PAYLOAD_PART_PATH,
1037
+ WORKFLOW_PAYLOAD_ITEM_PROPS_PART_PATH,
1038
+ WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
952
1039
  numberingPartPath,
953
1040
  commentsPartPath,
954
1041
  commentsExtendedPartPath,
@@ -957,9 +1044,18 @@ function exportDocxEditorSession(
957
1044
  ...subPartOwnedPaths,
958
1045
  ]);
959
1046
 
1047
+ const mainDocumentXmlForExport =
1048
+ signatureMatch &&
1049
+ durableWorkflowMetadata.definitions.length === 0 &&
1050
+ durableWorkflowMetadata.entries.length === 0 &&
1051
+ commentCount === 0 &&
1052
+ hasActiveImportedPreserveOnlyRevisions
1053
+ ? state.sourceDocumentXml
1054
+ : protectedDocumentXml;
1055
+
960
1056
  exportSession.replaceOwnedPart({
961
1057
  path: state.sourceDocumentPartPath,
962
- bytes: new TextEncoder().encode(protectedDocumentXml),
1058
+ bytes: new TextEncoder().encode(mainDocumentXmlForExport),
963
1059
  contentType: MAIN_DOCUMENT_CONTENT_TYPE,
964
1060
  relationships: nextRelationships,
965
1061
  });
@@ -1023,36 +1119,128 @@ function exportDocxEditorSession(
1023
1119
  }
1024
1120
 
1025
1121
  if (exportedSubParts) {
1122
+ const headersByPartPath = new Map<
1123
+ string,
1124
+ { header: (typeof exportedSubParts.headers)[number]; revisions: ReviewRevisionRecord[] }
1125
+ >();
1026
1126
  for (const header of exportedSubParts.headers) {
1127
+ const entry = headersByPartPath.get(header.partPath) ?? { header, revisions: [] };
1128
+ const matchingRevisions = actionableRevisions.filter((revision) =>
1129
+ storyTargetsEqual(revision.metadata.storyTarget ?? { kind: "main" }, {
1130
+ kind: "header",
1131
+ relationshipId: header.relationshipId,
1132
+ variant: header.variant,
1133
+ ...(header.sectionIndex !== undefined ? { sectionIndex: header.sectionIndex } : {}),
1134
+ })
1135
+ );
1136
+ entry.revisions.push(...matchingRevisions);
1137
+ if (matchingRevisions.length > 0) {
1138
+ entry.header = header;
1139
+ }
1140
+ headersByPartPath.set(header.partPath, entry);
1141
+ }
1142
+ for (const { header, revisions } of headersByPartPath.values()) {
1143
+ const serializedHeaderXml = serializeSecondaryStoryWithRuntimeRevisions(
1144
+ serializeHeaderXmlWithRevisions(header, revisions),
1145
+ revisions,
1146
+ `header ${header.partPath}`,
1147
+ );
1027
1148
  exportSession.replaceOwnedPart({
1028
1149
  path: header.partPath,
1029
- bytes: new TextEncoder().encode(serializeHeaderXml(header)),
1150
+ bytes: new TextEncoder().encode(serializedHeaderXml),
1030
1151
  contentType:
1031
1152
  state.sourcePackage.parts.get(header.partPath)?.contentType ?? WORD_HEADER_CONTENT_TYPE,
1032
1153
  });
1033
1154
  }
1155
+ const footersByPartPath = new Map<
1156
+ string,
1157
+ { footer: (typeof exportedSubParts.footers)[number]; revisions: ReviewRevisionRecord[] }
1158
+ >();
1034
1159
  for (const footer of exportedSubParts.footers) {
1160
+ const entry = footersByPartPath.get(footer.partPath) ?? { footer, revisions: [] };
1161
+ const matchingRevisions = actionableRevisions.filter((revision) =>
1162
+ storyTargetsEqual(revision.metadata.storyTarget ?? { kind: "main" }, {
1163
+ kind: "footer",
1164
+ relationshipId: footer.relationshipId,
1165
+ variant: footer.variant,
1166
+ ...(footer.sectionIndex !== undefined ? { sectionIndex: footer.sectionIndex } : {}),
1167
+ })
1168
+ );
1169
+ entry.revisions.push(...matchingRevisions);
1170
+ if (matchingRevisions.length > 0) {
1171
+ entry.footer = footer;
1172
+ }
1173
+ footersByPartPath.set(footer.partPath, entry);
1174
+ }
1175
+ for (const { footer, revisions } of footersByPartPath.values()) {
1176
+ const serializedFooterXml = serializeSecondaryStoryWithRuntimeRevisions(
1177
+ serializeFooterXmlWithRevisions(footer, revisions),
1178
+ revisions,
1179
+ `footer ${footer.partPath}`,
1180
+ );
1035
1181
  exportSession.replaceOwnedPart({
1036
1182
  path: footer.partPath,
1037
- bytes: new TextEncoder().encode(serializeFooterXml(footer)),
1183
+ bytes: new TextEncoder().encode(serializedFooterXml),
1038
1184
  contentType:
1039
1185
  state.sourcePackage.parts.get(footer.partPath)?.contentType ?? WORD_FOOTER_CONTENT_TYPE,
1040
1186
  });
1041
1187
  }
1042
1188
  if (exportedSubParts.footnoteCollection) {
1043
1189
  if (state.sourceSubPartPaths.footnotesPartPath) {
1190
+ const serializedFootnotesXml = serializeFootnotesXml(
1191
+ exportedSubParts.footnoteCollection,
1192
+ Object.fromEntries(
1193
+ actionableRevisions
1194
+ .filter((revision) => revision.metadata.storyTarget?.kind === "footnote")
1195
+ .map((revision) =>
1196
+ revision.metadata.storyTarget?.kind === "footnote"
1197
+ ? [revision.metadata.storyTarget.noteId, [] as ReviewRevisionRecord[]] as const
1198
+ : null,
1199
+ )
1200
+ .filter((entry): entry is readonly [string, ReviewRevisionRecord[]] => entry !== null)
1201
+ .map(([noteId]) => [
1202
+ noteId,
1203
+ actionableRevisions.filter(
1204
+ (revision) =>
1205
+ revision.metadata.storyTarget?.kind === "footnote" &&
1206
+ revision.metadata.storyTarget.noteId === noteId,
1207
+ ),
1208
+ ]),
1209
+ ),
1210
+ );
1044
1211
  exportSession.replaceOwnedPart({
1045
1212
  path: state.sourceSubPartPaths.footnotesPartPath,
1046
- bytes: new TextEncoder().encode(serializeFootnotesXml(exportedSubParts.footnoteCollection)),
1213
+ bytes: new TextEncoder().encode(serializedFootnotesXml),
1047
1214
  contentType:
1048
1215
  state.sourcePackage.parts.get(state.sourceSubPartPaths.footnotesPartPath)?.contentType ??
1049
1216
  WORD_FOOTNOTES_CONTENT_TYPE,
1050
1217
  });
1051
1218
  }
1052
1219
  if (state.sourceSubPartPaths.endnotesPartPath) {
1220
+ const serializedEndnotesXml = serializeEndnotesXml(
1221
+ exportedSubParts.footnoteCollection,
1222
+ Object.fromEntries(
1223
+ actionableRevisions
1224
+ .filter((revision) => revision.metadata.storyTarget?.kind === "endnote")
1225
+ .map((revision) =>
1226
+ revision.metadata.storyTarget?.kind === "endnote"
1227
+ ? [revision.metadata.storyTarget.noteId, [] as ReviewRevisionRecord[]] as const
1228
+ : null,
1229
+ )
1230
+ .filter((entry): entry is readonly [string, ReviewRevisionRecord[]] => entry !== null)
1231
+ .map(([noteId]) => [
1232
+ noteId,
1233
+ actionableRevisions.filter(
1234
+ (revision) =>
1235
+ revision.metadata.storyTarget?.kind === "endnote" &&
1236
+ revision.metadata.storyTarget.noteId === noteId,
1237
+ ),
1238
+ ]),
1239
+ ),
1240
+ );
1053
1241
  exportSession.replaceOwnedPart({
1054
1242
  path: state.sourceSubPartPaths.endnotesPartPath,
1055
- bytes: new TextEncoder().encode(serializeEndnotesXml(exportedSubParts.footnoteCollection)),
1243
+ bytes: new TextEncoder().encode(serializedEndnotesXml),
1056
1244
  contentType:
1057
1245
  state.sourcePackage.parts.get(state.sourceSubPartPaths.endnotesPartPath)?.contentType ??
1058
1246
  WORD_ENDNOTES_CONTENT_TYPE,
@@ -1076,6 +1264,7 @@ function exportDocxEditorSession(
1076
1264
  }
1077
1265
 
1078
1266
  ensureHostMetadataParts(exportSession, state.sourcePackage, currentDocument);
1267
+ ensureWorkflowPayloadParts(exportSession, sessionState, currentDocument, state.sourcePackage);
1079
1268
 
1080
1269
  return {
1081
1270
  bytes: exportSession.serialize(),
@@ -1455,6 +1644,7 @@ function createImportedSnapshot(input: {
1455
1644
  compatibility: PersistedEditorSnapshot["compatibility"];
1456
1645
  protectionSnapshot: ProtectionSnapshot;
1457
1646
  sourcePackage?: PersistedEditorSnapshot["sourcePackage"];
1647
+ workflowMetadata?: PersistedEditorSnapshot["workflowMetadata"];
1458
1648
  }): PersistedEditorSnapshot {
1459
1649
  return {
1460
1650
  snapshotVersion: "persisted-editor-snapshot/2",
@@ -1470,6 +1660,7 @@ function createImportedSnapshot(input: {
1470
1660
  warningLog: input.compatibility.warnings,
1471
1661
  protectionSnapshot: input.protectionSnapshot,
1472
1662
  sourcePackage: input.sourcePackage,
1663
+ workflowMetadata: input.workflowMetadata,
1473
1664
  };
1474
1665
  }
1475
1666
 
@@ -1685,6 +1876,12 @@ function normalizeImportedCommentThreads(
1685
1876
  ...thread,
1686
1877
  anchor: createDetachedAnchor(anchor.range, "importAmbiguity"),
1687
1878
  status: "detached",
1879
+ metadata: {
1880
+ ...thread.metadata,
1881
+ detachedReason: "opaque-region",
1882
+ actionabilityNote:
1883
+ "The comment body is preserved. The anchor overlaps opaque content that the editor cannot safely modify.",
1884
+ },
1688
1885
  };
1689
1886
  }
1690
1887
 
@@ -1706,6 +1903,12 @@ function normalizeImportedCommentThreads(
1706
1903
  ...thread,
1707
1904
  anchor: createDetachedAnchor(anchor.range, "importAmbiguity"),
1708
1905
  status: "detached",
1906
+ metadata: {
1907
+ ...thread.metadata,
1908
+ detachedReason: "revision-overlap",
1909
+ actionabilityNote:
1910
+ "The comment body is preserved. The anchor overlaps preserve-only revision markup that the editor cannot safely modify.",
1911
+ },
1709
1912
  };
1710
1913
  }
1711
1914
 
@@ -1824,14 +2027,14 @@ function collectCanonicalParagraphRanges(
1824
2027
  function measureCanonicalParagraph(paragraph: CanonicalDocumentEnvelope["content"]["children"][number] & { type: "paragraph" }): number {
1825
2028
  return paragraph.children.reduce<number>((size, child) => {
1826
2029
  if (child.type === "text") {
1827
- return size + child.text.length;
2030
+ return size + Array.from(child.text).length;
1828
2031
  }
1829
2032
  if (child.type === "hyperlink") {
1830
2033
  return (
1831
2034
  size +
1832
2035
  child.children.reduce<number>((childSize, entry) => {
1833
2036
  if (entry.type === "text") {
1834
- return childSize + entry.text.length;
2037
+ return childSize + Array.from(entry.text).length;
1835
2038
  }
1836
2039
  return childSize + 1;
1837
2040
  }, 0)
@@ -1964,9 +2167,15 @@ function toRuntimeRevisionRecords(
1964
2167
  source: revision.metadata.source,
1965
2168
  storyTarget: revision.metadata.storyTarget,
1966
2169
  preserveOnlyReason: revision.metadata.preserveOnlyReason,
2170
+ suggestionId: revision.metadata.suggestionId,
2171
+ semanticKind: revision.metadata.semanticKind,
2172
+ linkedRevisionIds: revision.metadata.linkedRevisionIds,
2173
+ predecessorSuggestionId: revision.metadata.predecessorSuggestionId,
1967
2174
  importedRevisionForm: revision.metadata.importedRevisionForm,
1968
2175
  originalRevisionType: revision.metadata.originalRevisionType,
1969
2176
  ooxmlRevisionId: revision.metadata.ooxmlRevisionId,
2177
+ propertyChangeData: revision.metadata.propertyChangeData,
2178
+ moveData: revision.metadata.moveData,
1970
2179
  },
1971
2180
  status: revision.status === "active" ? "open" : revision.status,
1972
2181
  } satisfies RuntimeRevisionRecord,
@@ -1988,14 +2197,38 @@ function toReviewRevisionRecords(
1988
2197
  source: revision.metadata?.source ?? "runtime",
1989
2198
  storyTarget: revision.metadata?.storyTarget,
1990
2199
  preserveOnlyReason: revision.metadata?.preserveOnlyReason,
2200
+ suggestionId: revision.metadata?.suggestionId,
2201
+ semanticKind: revision.metadata?.semanticKind,
2202
+ linkedRevisionIds: revision.metadata?.linkedRevisionIds,
2203
+ predecessorSuggestionId: revision.metadata?.predecessorSuggestionId,
1991
2204
  importedRevisionForm: revision.metadata?.importedRevisionForm,
1992
2205
  originalRevisionType: revision.metadata?.originalRevisionType,
1993
2206
  ooxmlRevisionId: revision.metadata?.ooxmlRevisionId,
2207
+ propertyChangeData: revision.metadata?.propertyChangeData,
2208
+ moveData: revision.metadata?.moveData,
1994
2209
  },
1995
2210
  status: revision.status === "open" ? "active" : revision.status,
1996
2211
  }));
1997
2212
  }
1998
2213
 
2214
+ function serializeSecondaryStoryWithRuntimeRevisions(
2215
+ xml: string,
2216
+ revisions: readonly ReviewRevisionRecord[],
2217
+ label: string,
2218
+ ): string {
2219
+ if (revisions.length === 0) {
2220
+ return xml;
2221
+ }
2222
+
2223
+ const serialized = serializeRuntimeRevisionsIntoStoryXml(xml, revisions);
2224
+ if (serialized.skippedRevisionIds.length > 0) {
2225
+ throw new Error(
2226
+ `DOCX export is blocked because ${serialized.skippedRevisionIds.length} active revisions overlap unsupported serialization boundaries in ${label}.`,
2227
+ );
2228
+ }
2229
+ return serialized.documentXml;
2230
+ }
2231
+
1999
2232
  export function stripCommentMarkup(
2000
2233
  documentXml: string,
2001
2234
  ownedCommentIds: readonly string[],
@@ -2399,7 +2632,12 @@ function toUint8Array(bytes: Uint8Array | ArrayBuffer): Uint8Array {
2399
2632
  }
2400
2633
 
2401
2634
  function serializeCanonicalDocumentForExport(document: CanonicalDocumentEnvelope): string {
2402
- return createCanonicalDocumentSignature(document);
2635
+ return createCanonicalDocumentSignature({
2636
+ ...document,
2637
+ docId: "__export__",
2638
+ createdAt: "__export__",
2639
+ updatedAt: "__export__",
2640
+ });
2403
2641
  }
2404
2642
 
2405
2643
  function canReuseSourceBytesForCurrentDocument(
@@ -2412,6 +2650,12 @@ function canReuseSourceBytesForCurrentDocument(
2412
2650
 
2413
2651
  const commentThreads = Object.values(document.review.comments);
2414
2652
  const hasLiveComments = commentThreads.some((thread) => thread.anchor.kind !== "detached");
2653
+ const hasRuntimeAuthoredActiveRevisions = Object.values(document.review.revisions).some((revision) =>
2654
+ revision.status === "open" && revision.metadata?.source === "runtime"
2655
+ );
2656
+ if (hasRuntimeAuthoredActiveRevisions) {
2657
+ return false;
2658
+ }
2415
2659
  if (!hasLiveComments) {
2416
2660
  return true;
2417
2661
  }
@@ -2471,6 +2715,55 @@ function ensureHostMetadataParts(
2471
2715
  });
2472
2716
  }
2473
2717
 
2718
+ function ensureWorkflowPayloadParts(
2719
+ exportSession: ReturnType<typeof createExportSession>,
2720
+ sessionState: EditorSessionState,
2721
+ document: CanonicalDocumentEnvelope,
2722
+ sourcePackage: OpcPackage,
2723
+ ): void {
2724
+ const payloadParts = buildWorkflowPayloadParts({
2725
+ sourcePackage,
2726
+ workflowMetadata: sessionState.workflowMetadata,
2727
+ documentId: sessionState.documentId,
2728
+ createdAt: document.createdAt,
2729
+ updatedAt: document.updatedAt,
2730
+ producerVersion: sessionState.editorBuild,
2731
+ });
2732
+ if (!payloadParts) {
2733
+ return;
2734
+ }
2735
+
2736
+ const payloadPart = sourcePackage.parts.get(payloadParts.payloadPartPath);
2737
+ const itemPropsPart = sourcePackage.parts.get(payloadParts.itemPropsPartPath);
2738
+ const customPropsPart = sourcePackage.parts.get(WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH);
2739
+
2740
+ exportSession.replaceOwnedPart({
2741
+ path: payloadParts.payloadPartPath,
2742
+ bytes: new TextEncoder().encode(payloadParts.payloadPartXml),
2743
+ contentType: payloadPart?.contentType ?? WORKFLOW_PAYLOAD_CONTENT_TYPE,
2744
+ relationships: payloadParts.payloadRelationships,
2745
+ compression: payloadPart?.compression,
2746
+ });
2747
+ exportSession.replaceOwnedPart({
2748
+ path: payloadParts.itemPropsPartPath,
2749
+ bytes: new TextEncoder().encode(payloadParts.itemPropsXml),
2750
+ contentType: itemPropsPart?.contentType ?? WORKFLOW_PAYLOAD_ITEM_PROPS_CONTENT_TYPE,
2751
+ compression: itemPropsPart?.compression,
2752
+ });
2753
+ exportSession.replaceOwnedPart({
2754
+ path: WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
2755
+ bytes: new TextEncoder().encode(payloadParts.customPropertiesXml),
2756
+ contentType: customPropsPart?.contentType ?? WORKFLOW_PAYLOAD_CUSTOM_PROPS_CONTENT_TYPE,
2757
+ compression: customPropsPart?.compression,
2758
+ });
2759
+
2760
+ exportSession.ensurePackageRelationship({
2761
+ type: WORKFLOW_PAYLOAD_CUSTOM_PROPS_RELATIONSHIP_TYPE,
2762
+ target: WORKFLOW_PAYLOAD_CUSTOM_PROPS_PART_PATH,
2763
+ preferredId: "rIdBwWorkflowCustomProps",
2764
+ });
2765
+ }
2766
+
2474
2767
  function hasHostSafeMetadataPackageStructure(sourcePackage: OpcPackage): boolean {
2475
2768
  const corePropertiesPart = sourcePackage.parts.get(CORE_PROPERTIES_PART_PATH);
2476
2769
  const appPropertiesPart = sourcePackage.parts.get(APP_PROPERTIES_PART_PATH);
@@ -2819,7 +3112,7 @@ function collectProtectionRangesFromInlines(
2819
3112
  function measureParsedInlineNode(node: ParsedInlineNode): number {
2820
3113
  switch (node.type) {
2821
3114
  case "text":
2822
- return node.text.length;
3115
+ return Array.from(node.text).length;
2823
3116
  case "tab":
2824
3117
  case "hard_break":
2825
3118
  case "column_break":