@beyondwork/docx-react-component 1.0.19 → 1.0.21

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 (71) hide show
  1. package/package.json +44 -25
  2. package/src/api/public-types.ts +336 -0
  3. package/src/api/session-state.ts +2 -0
  4. package/src/core/commands/formatting-commands.ts +1 -1
  5. package/src/core/commands/index.ts +14 -2
  6. package/src/core/search/search-text.ts +28 -0
  7. package/src/core/state/editor-state.ts +3 -0
  8. package/src/index.ts +21 -0
  9. package/src/io/docx-session.ts +363 -17
  10. package/src/io/export/serialize-comments.ts +104 -34
  11. package/src/io/export/serialize-footnotes.ts +198 -1
  12. package/src/io/export/serialize-headers-footers.ts +203 -10
  13. package/src/io/export/serialize-main-document.ts +83 -3
  14. package/src/io/export/split-review-boundaries.ts +181 -19
  15. package/src/io/normalize/normalize-text.ts +82 -8
  16. package/src/io/ooxml/highlight-colors.ts +39 -0
  17. package/src/io/ooxml/parse-comments.ts +85 -19
  18. package/src/io/ooxml/parse-fields.ts +396 -0
  19. package/src/io/ooxml/parse-footnotes.ts +240 -2
  20. package/src/io/ooxml/parse-headers-footers.ts +431 -7
  21. package/src/io/ooxml/parse-inline-media.ts +15 -1
  22. package/src/io/ooxml/parse-main-document.ts +396 -14
  23. package/src/io/ooxml/parse-revisions.ts +317 -38
  24. package/src/legal/bookmarks.ts +44 -0
  25. package/src/legal/cross-references.ts +59 -1
  26. package/src/model/canonical-document.ts +117 -1
  27. package/src/model/snapshot.ts +85 -1
  28. package/src/review/store/revision-store.ts +6 -0
  29. package/src/review/store/revision-types.ts +1 -0
  30. package/src/runtime/document-navigation.ts +52 -13
  31. package/src/runtime/document-runtime.ts +1521 -75
  32. package/src/runtime/read-only-diagnostics-runtime.ts +8 -0
  33. package/src/runtime/session-capabilities.ts +33 -3
  34. package/src/runtime/surface-projection.ts +86 -25
  35. package/src/runtime/table-schema.ts +2 -2
  36. package/src/runtime/view-state.ts +24 -6
  37. package/src/runtime/workflow-markup.ts +349 -0
  38. package/src/ui/WordReviewEditor.tsx +915 -1314
  39. package/src/ui/editor-command-bag.ts +120 -0
  40. package/src/ui/editor-runtime-boundary.ts +1448 -0
  41. package/src/ui/editor-shell-view.tsx +134 -0
  42. package/src/ui/editor-surface-controller.tsx +55 -0
  43. package/src/ui/headless/revision-decoration-model.ts +4 -4
  44. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  45. package/src/ui/workflow-surface-blocked-rails.ts +94 -0
  46. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  47. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  48. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  49. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  50. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +27 -2
  51. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  52. package/src/ui-tailwind/editor-surface/perf-probe.ts +86 -14
  53. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +2 -2
  54. package/src/ui-tailwind/editor-surface/pm-decorations.ts +237 -0
  55. package/src/ui-tailwind/editor-surface/pm-position-map.ts +1 -1
  56. package/src/ui-tailwind/editor-surface/pm-schema.ts +139 -8
  57. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +98 -48
  58. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +55 -0
  59. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  60. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +190 -48
  61. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  62. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +7 -7
  63. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  64. package/src/ui-tailwind/review/tw-review-rail.tsx +3 -3
  65. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  66. package/src/ui-tailwind/theme/editor-theme.css +130 -0
  67. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +543 -5
  68. package/src/ui-tailwind/tw-review-workspace.tsx +316 -19
  69. package/src/validation/compatibility-engine.ts +27 -4
  70. package/src/validation/compatibility-report.ts +1 -0
  71. package/src/validation/docx-comment-proof.ts +220 -0
@@ -28,7 +28,7 @@ const CANONICAL_DOCUMENT_TOP_LEVEL_KEYS = [
28
28
  "diagnostics",
29
29
  ] as const;
30
30
 
31
- const CANONICAL_DOCUMENT_OPTIONAL_KEYS = ["subParts"] as const;
31
+ const CANONICAL_DOCUMENT_OPTIONAL_KEYS = ["subParts", "fieldRegistry"] as const;
32
32
 
33
33
  const ID_PATTERNS = {
34
34
  styleId: /^[A-Za-z_][A-Za-z0-9._-]{0,127}$/,
@@ -62,6 +62,8 @@ export interface CanonicalDocument {
62
62
  preservation: PreservationStore;
63
63
  diagnostics: DiagnosticStore;
64
64
  subParts?: SubPartsCatalog;
65
+ /** Package-backed field registry for supported field families. */
66
+ fieldRegistry?: FieldRegistry;
65
67
  }
66
68
 
67
69
  export interface DocumentMetadata {
@@ -329,6 +331,7 @@ export interface ParagraphNode {
329
331
  };
330
332
  alignment?: "left" | "center" | "right" | "both" | "distribute";
331
333
  spacing?: ParagraphSpacing;
334
+ contextualSpacing?: boolean;
332
335
  indentation?: ParagraphIndentation;
333
336
  tabStops?: TabStop[];
334
337
  keepNext?: boolean;
@@ -477,11 +480,119 @@ export interface AltChunkNode {
477
480
  relationshipId: string;
478
481
  }
479
482
 
483
+ /**
484
+ * Supported field families that receive first-class canonical treatment.
485
+ * These families have stable registry IDs, dependency metadata, and
486
+ * runtime-owned refresh behavior.
487
+ */
488
+ export type SupportedFieldFamily = "REF" | "PAGEREF" | "NOTEREF" | "TOC";
489
+
490
+ /**
491
+ * Unsupported field families that remain preserve-only.
492
+ * They survive round-trip but do not participate in runtime refresh.
493
+ */
494
+ export type PreserveOnlyFieldFamily =
495
+ | "PAGE"
496
+ | "NUMPAGES"
497
+ | "DATE"
498
+ | "TIME"
499
+ | "AUTHOR"
500
+ | "FILENAME"
501
+ | "MERGEFIELD"
502
+ | "IF"
503
+ | "SEQ"
504
+ | "INDEX"
505
+ | "TC"
506
+ | "STYLEREF"
507
+ | "FORMULA"
508
+ | "UNKNOWN";
509
+
510
+ export type FieldFamily = SupportedFieldFamily | PreserveOnlyFieldFamily;
511
+
512
+ /** Runtime refresh status for a field instance. */
513
+ export type FieldRefreshStatus =
514
+ | "current"
515
+ | "stale"
516
+ | "unresolvable"
517
+ | "preserve-only";
518
+
480
519
  export interface FieldNode {
481
520
  type: "field";
482
521
  fieldType: "simple" | "complex";
483
522
  instruction: string;
484
523
  children: InlineNode[];
524
+ /** Classified field family. Undefined for legacy snapshots. */
525
+ fieldFamily?: FieldFamily;
526
+ /** Target bookmark name for REF/PAGEREF/NOTEREF fields. */
527
+ fieldTarget?: string;
528
+ /** Runtime refresh status. Undefined for legacy or preserve-only fields. */
529
+ refreshStatus?: FieldRefreshStatus;
530
+ }
531
+
532
+ // ─── Field registry ─────────────────────────────────────────────────────────
533
+
534
+ /**
535
+ * Package-backed field registry that catalogs every field instance in the
536
+ * document, grouped by supported vs preserve-only families.
537
+ *
538
+ * Supported field entries carry dependency metadata (bookmark targets) and
539
+ * participate in deterministic refresh. Preserve-only entries survive
540
+ * round-trip but are not refreshable.
541
+ */
542
+ export interface FieldRegistry {
543
+ /** Supported field instances that participate in refresh. */
544
+ supported: FieldRegistryEntry[];
545
+ /** Preserve-only field instances cataloged for round-trip safety. */
546
+ preserveOnly: FieldRegistryEntry[];
547
+ /** Generated TOC structure extracted from heading-driven TOC fields. */
548
+ tocStructure?: TocStructure;
549
+ }
550
+
551
+ export interface FieldRegistryEntry {
552
+ /** Stable document-order index of this field (0-based). */
553
+ fieldIndex: number;
554
+ /** Classified field family. */
555
+ fieldFamily: FieldFamily;
556
+ /** Whether the field is in the supported refresh slice. */
557
+ supported: boolean;
558
+ /** Field instruction text. */
559
+ instruction: string;
560
+ /** Target bookmark name for REF/PAGEREF/NOTEREF fields. */
561
+ fieldTarget?: string;
562
+ /** Current display text extracted from field content. */
563
+ displayText: string;
564
+ /** Paragraph index in document order where this field appears. */
565
+ paragraphIndex: number;
566
+ /** Runtime refresh status. */
567
+ refreshStatus: FieldRefreshStatus;
568
+ }
569
+
570
+ /**
571
+ * Generated table-of-contents structure extracted from TOC fields and
572
+ * heading-styled paragraphs in the document.
573
+ */
574
+ export interface TocStructure {
575
+ /** The raw TOC field instruction (e.g. "TOC \\o \"1-3\" \\h"). */
576
+ instruction: string;
577
+ /** Heading level range the TOC covers. */
578
+ levelRange: { from: number; to: number };
579
+ /** Ordered TOC entries derived from heading paragraphs. */
580
+ entries: TocEntry[];
581
+ /** Whether the TOC content is current with the heading structure. */
582
+ status: "current" | "stale";
583
+ }
584
+
585
+ export interface TocEntry {
586
+ /** Heading text. */
587
+ text: string;
588
+ /** Heading outline level (1-9). */
589
+ level: number;
590
+ /** Paragraph index of the heading in document order. */
591
+ paragraphIndex: number;
592
+ /** Style ID of the heading paragraph, if available. */
593
+ styleId?: string;
594
+ /** Bookmark name anchoring this heading, if present. */
595
+ bookmarkName?: string;
485
596
  }
486
597
 
487
598
  export interface BookmarkStartNode {
@@ -612,6 +723,7 @@ export type TextMark =
612
723
  | { type: "doubleStrikethrough" }
613
724
  | { type: "vanish" }
614
725
  | { type: "lang"; val: string }
726
+ | { type: "highlight"; color: string; val: string }
615
727
  | { type: "backgroundColor"; color: string }
616
728
  | { type: "charSpacing"; val: number }
617
729
  | { type: "kerning"; val: number }
@@ -743,6 +855,7 @@ export interface OpaqueBlockNode {
743
855
  type: "opaque_block";
744
856
  fragmentId: string;
745
857
  warningId: string;
858
+ rawXml?: string;
746
859
  }
747
860
 
748
861
  export interface DocRange {
@@ -1272,6 +1385,9 @@ function validateDocumentNode(
1272
1385
  case "opaque_block":
1273
1386
  expectDomainString(record.fragmentId, "fragmentId", `${path}.fragmentId`, issues);
1274
1387
  expectDomainString(record.warningId, "warningId", `${path}.warningId`, issues);
1388
+ if (record.rawXml !== undefined) {
1389
+ expectString(record.rawXml, `${path}.rawXml`, issues);
1390
+ }
1275
1391
  return;
1276
1392
  case "table":
1277
1393
  if (!Array.isArray(record.gridColumns)) {
@@ -110,6 +110,7 @@ export interface PersistedEditorSnapshot {
110
110
  canonicalDocument: CanonicalDocument;
111
111
  compatibility: CompatibilityReport;
112
112
  warningLog: EditorWarning[];
113
+ protectionSnapshot?: ProtectionSnapshotRecord;
113
114
  sourcePackage?: PersistedSourcePackage;
114
115
  }
115
116
 
@@ -127,7 +128,26 @@ const SNAPSHOT_REQUIRED_TOP_LEVEL_KEYS = [
127
128
  "warningLog",
128
129
  ] as const;
129
130
 
130
- const SNAPSHOT_OPTIONAL_TOP_LEVEL_KEYS = ["sourcePackage"] as const;
131
+ const SNAPSHOT_OPTIONAL_TOP_LEVEL_KEYS = ["sourcePackage", "protectionSnapshot"] as const;
132
+
133
+ export interface ProtectionRangeRecord {
134
+ rangeId: string;
135
+ start?: number;
136
+ end?: number;
137
+ editorGroup?: string;
138
+ editor?: string;
139
+ enforced: boolean;
140
+ enforcementReason: string;
141
+ }
142
+
143
+ export interface ProtectionSnapshotRecord {
144
+ hasDocumentProtection: boolean;
145
+ editType?: string;
146
+ enforcementActive: boolean;
147
+ ranges: ProtectionRangeRecord[];
148
+ enforcedRangeCount: number;
149
+ preservedRangeCount: number;
150
+ }
131
151
 
132
152
  const PERSISTED_EDITOR_SNAPSHOT_VERSIONS = new Set<PersistedEditorSnapshotVersion>([
133
153
  LEGACY_PERSISTED_EDITOR_SNAPSHOT_VERSION,
@@ -260,6 +280,9 @@ export function validatePersistedEditorSnapshot(
260
280
  if (record.sourcePackage !== undefined) {
261
281
  validatePersistedSourcePackage(record.sourcePackage, "$.sourcePackage", issues);
262
282
  }
283
+ if (record.protectionSnapshot !== undefined) {
284
+ validateProtectionSnapshot(record.protectionSnapshot, "$.protectionSnapshot", issues);
285
+ }
263
286
 
264
287
  return issues;
265
288
  }
@@ -537,6 +560,7 @@ export function createPersistedEditorSnapshot(params: {
537
560
  canonicalDocument: CanonicalDocument;
538
561
  compatibility: CompatibilityReport;
539
562
  warningLog?: EditorWarning[];
563
+ protectionSnapshot?: ProtectionSnapshotRecord;
540
564
  sourcePackage?: PersistedSourcePackage;
541
565
  }): PersistedEditorSnapshot {
542
566
  assertCanonicalDocument(params.canonicalDocument);
@@ -554,10 +578,70 @@ export function createPersistedEditorSnapshot(params: {
554
578
  canonicalDocument: params.canonicalDocument,
555
579
  compatibility: params.compatibility,
556
580
  warningLog: params.warningLog ?? params.compatibility.warnings,
581
+ protectionSnapshot: params.protectionSnapshot,
557
582
  sourcePackage: params.sourcePackage,
558
583
  };
559
584
  }
560
585
 
586
+ function validateProtectionSnapshot(
587
+ value: unknown,
588
+ path: string,
589
+ issues: ModelValidationIssue[],
590
+ ): void {
591
+ const record = asPlainObject(value, path, issues);
592
+ if (!record) {
593
+ return;
594
+ }
595
+ if (typeof record.hasDocumentProtection !== "boolean") {
596
+ issues.push({ path: `${path}.hasDocumentProtection`, message: "hasDocumentProtection must be a boolean." });
597
+ }
598
+ if (record.editType !== undefined) {
599
+ expectString(record.editType, `${path}.editType`, issues);
600
+ }
601
+ if (typeof record.enforcementActive !== "boolean") {
602
+ issues.push({ path: `${path}.enforcementActive`, message: "enforcementActive must be a boolean." });
603
+ }
604
+ if (!Array.isArray(record.ranges)) {
605
+ issues.push({ path: `${path}.ranges`, message: "ranges must be an array." });
606
+ } else {
607
+ record.ranges.forEach((range, index) => validateProtectionRange(range, `${path}.ranges[${index}]`, issues));
608
+ }
609
+ if (!Number.isInteger(record.enforcedRangeCount)) {
610
+ issues.push({ path: `${path}.enforcedRangeCount`, message: "enforcedRangeCount must be an integer." });
611
+ }
612
+ if (!Number.isInteger(record.preservedRangeCount)) {
613
+ issues.push({ path: `${path}.preservedRangeCount`, message: "preservedRangeCount must be an integer." });
614
+ }
615
+ }
616
+
617
+ function validateProtectionRange(
618
+ value: unknown,
619
+ path: string,
620
+ issues: ModelValidationIssue[],
621
+ ): void {
622
+ const record = asPlainObject(value, path, issues);
623
+ if (!record) {
624
+ return;
625
+ }
626
+ expectString(record.rangeId, `${path}.rangeId`, issues);
627
+ if (record.start !== undefined && !Number.isInteger(record.start)) {
628
+ issues.push({ path: `${path}.start`, message: "start must be an integer." });
629
+ }
630
+ if (record.end !== undefined && !Number.isInteger(record.end)) {
631
+ issues.push({ path: `${path}.end`, message: "end must be an integer." });
632
+ }
633
+ if (record.editorGroup !== undefined) {
634
+ expectString(record.editorGroup, `${path}.editorGroup`, issues);
635
+ }
636
+ if (record.editor !== undefined) {
637
+ expectString(record.editor, `${path}.editor`, issues);
638
+ }
639
+ if (typeof record.enforced !== "boolean") {
640
+ issues.push({ path: `${path}.enforced`, message: "enforced must be a boolean." });
641
+ }
642
+ expectString(record.enforcementReason, `${path}.enforcementReason`, issues);
643
+ }
644
+
561
645
  export function projectAnchorToSnapshot(
562
646
  anchor: Record<string, unknown>,
563
647
  ): Record<string, unknown> {
@@ -44,6 +44,8 @@ export interface RevisionSidebarEntry {
44
44
  canAccept: boolean;
45
45
  canReject: boolean;
46
46
  preserveOnlyReason?: string;
47
+ linkedMoveRevisionId?: string;
48
+ moveDirection?: "from" | "to";
47
49
  }
48
50
 
49
51
  export interface RevisionSidebarProjection {
@@ -212,6 +214,8 @@ function toSidebarEntry(revision: RevisionRecord): RevisionSidebarEntry {
212
214
  const anchorSummary = summarizeRevisionAnchor(revision.anchor);
213
215
  const actionability = getRevisionActionability(revision);
214
216
 
217
+ const moveData = revision.metadata.moveData;
218
+
215
219
  return {
216
220
  revisionId: revision.revisionId,
217
221
  kind: revision.kind,
@@ -228,6 +232,8 @@ function toSidebarEntry(revision: RevisionRecord): RevisionSidebarEntry {
228
232
  canAccept: isRevisionActionable(revision),
229
233
  canReject: isRevisionActionable(revision),
230
234
  preserveOnlyReason: revision.metadata.preserveOnlyReason,
235
+ linkedMoveRevisionId: moveData?.linkedRevisionId,
236
+ moveDirection: moveData?.direction,
231
237
  };
232
238
  }
233
239
 
@@ -28,6 +28,7 @@ export interface PropertyChangeData {
28
28
  export interface MoveData {
29
29
  moveId: string;
30
30
  direction: "from" | "to";
31
+ linkedRevisionId?: string;
31
32
  }
32
33
 
33
34
  export interface RevisionMetadataEnvelope {
@@ -45,6 +45,21 @@ import { findNoteReferencePosition } from "./view-state.ts";
45
45
  const MIN_BLOCK_HEIGHT_TWIPS = 240;
46
46
  const FOOTNOTE_REFERENCE_RESERVATION_TWIPS = 180;
47
47
 
48
+ interface NavigationBaseSnapshot {
49
+ mainSurface: EditorSurfaceSnapshot;
50
+ sections: ResolvedDocumentSection[];
51
+ pages: DocumentPageSnapshot[];
52
+ headings: DocumentHeadingSnapshot[];
53
+ }
54
+
55
+ const navigationBaseCache = new WeakMap<
56
+ CanonicalDocumentEnvelope["content"],
57
+ NavigationBaseSnapshot & {
58
+ styles: CanonicalDocumentEnvelope["styles"];
59
+ subParts: CanonicalDocumentEnvelope["subParts"];
60
+ }
61
+ >();
62
+
48
63
  // ---------------------------------------------------------------------------
49
64
  // Public API
50
65
  // ---------------------------------------------------------------------------
@@ -61,31 +76,55 @@ export function createDocumentNavigationSnapshot(
61
76
  selectionHead: number,
62
77
  activeStory: EditorStoryTarget,
63
78
  ): DocumentNavigationSnapshot {
79
+ const base = getNavigationBaseSnapshot(document);
80
+ const navigationContext = resolveActiveNavigationContext(
81
+ document,
82
+ base.pages,
83
+ base.sections,
84
+ base.mainSurface,
85
+ selectionHead,
86
+ activeStory,
87
+ );
88
+
89
+ return {
90
+ pageCount: base.pages.length,
91
+ pages: base.pages,
92
+ headings: base.headings,
93
+ activePageIndex: navigationContext.activePageIndex,
94
+ activeSectionIndex: navigationContext.activeSectionIndex,
95
+ };
96
+ }
97
+
98
+ function getNavigationBaseSnapshot(
99
+ document: CanonicalDocumentEnvelope,
100
+ ): NavigationBaseSnapshot {
101
+ const cached = navigationBaseCache.get(document.content);
102
+ if (
103
+ cached &&
104
+ cached.styles === document.styles &&
105
+ cached.subParts === document.subParts
106
+ ) {
107
+ return cached;
108
+ }
109
+
64
110
  const mainSurface = createEditorSurfaceSnapshot(
65
111
  document,
66
112
  createSelectionSnapshot(0, 0),
67
113
  MAIN_STORY_TARGET,
68
114
  );
69
-
70
115
  const sections = buildResolvedSections(document);
71
116
  const pages = buildPageStack(document, sections, mainSurface);
72
117
  const headings = buildHeadingOutline(document, mainSurface, sections, pages);
73
- const navigationContext = resolveActiveNavigationContext(
74
- document,
75
- pages,
76
- sections,
118
+ const next = {
77
119
  mainSurface,
78
- selectionHead,
79
- activeStory,
80
- );
81
-
82
- return {
83
- pageCount: pages.length,
120
+ sections,
84
121
  pages,
85
122
  headings,
86
- activePageIndex: navigationContext.activePageIndex,
87
- activeSectionIndex: navigationContext.activeSectionIndex,
123
+ styles: document.styles,
124
+ subParts: document.subParts,
88
125
  };
126
+ navigationBaseCache.set(document.content, next);
127
+ return next;
89
128
  }
90
129
 
91
130
  /**