@beyondwork/docx-react-component 1.0.5 → 1.0.8

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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/bwllaming/React-OOXML-Office/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bwllaming/React-OOXML-Office/actions/workflows/ci.yml)
4
4
 
5
- `@beyondwork/docx-react-component` is now configured as an npm-ready package for the shipped product in this repository: `WordReviewEditor`, a fidelity-first React editor for legal-review-safe `docx` workflows.
5
+ `@beyondwork/docx-react-component` is the shipped product in this repository: `WordReviewEditor`, a fidelity-first React editor for legal-review-safe `docx` workflows. Wave 21 lands the package-facing docs, npm metadata, publish workflow, and `pnpm pack` proof for this surface.
6
6
 
7
7
  The broader repository is still evolving toward a layered `react-ooxml-office` platform, but the source reality is unchanged:
8
8
 
@@ -22,6 +22,7 @@ Current packaging truth:
22
22
 
23
23
  - the package is ESM-only
24
24
  - exports point at shipped TypeScript source entry points
25
+ - `types` and subpath `types` entries resolve to the shipped source-backed TypeScript contracts
25
26
  - consumers need a bundler or runtime that can resolve `.ts` and `.tsx` ESM imports
26
27
  - consumers should import `@beyondwork/docx-react-component/ui-tailwind/theme/editor-theme.css` once and provide a Tailwind v4 CSS pipeline for it
27
28
  - package and source identifiers remain docx-first until a deliberate rename lands
@@ -104,7 +105,7 @@ Shared platform and planned xlsx docs:
104
105
 
105
106
  - `.github/workflows/publish.yml` publishes on `v*` tags after verifying the tag matches `package.json`
106
107
  - `pnpm pack --dry-run` is the baseline package proof for this wave
107
- - npm provenance is enabled in `publishConfig`
108
+ - npm provenance is enabled in `publishConfig` and in the publish workflow invocation
108
109
  - the published package currently ships source ESM entry points plus TypeScript source-backed `types` exports
109
110
  - the Microsoft Open XML SDK remains CI/internal-service only, never part of the shipped browser runtime
110
111
 
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.5",
4
+ "version": "1.0.8",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
+ "sideEffects": [
8
+ "**/*.css"
9
+ ],
7
10
  "files": [
8
11
  "README.md",
9
12
  "LICENSE.md",
@@ -26,9 +29,6 @@
26
29
  "types": "./src/ui-tailwind/index.ts",
27
30
  "import": "./src/ui-tailwind/index.ts"
28
31
  },
29
- "./io/docx-session": "./src/io/docx-session.ts",
30
- "./runtime/document-runtime": "./src/runtime/document-runtime.ts",
31
- "./api/public-types": "./src/api/public-types.ts",
32
32
  "./ui-tailwind/theme/editor-theme.css": "./src/ui-tailwind/theme/editor-theme.css",
33
33
  "./package.json": "./package.json"
34
34
  },
@@ -45,6 +45,14 @@
45
45
  "prosemirror"
46
46
  ],
47
47
  "author": "",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/bwllaming/React-OOXML-Office.git"
51
+ },
52
+ "homepage": "https://github.com/bwllaming/React-OOXML-Office#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/bwllaming/React-OOXML-Office/issues"
55
+ },
48
56
  "license": "SEE LICENSE IN LICENSE.md",
49
57
  "dependencies": {
50
58
  "@radix-ui/react-popover": "^1.1.15",
@@ -73,6 +81,7 @@
73
81
  "@chllming/wave-orchestration": "^0.9.8",
74
82
  "@types/react": "19.2.14",
75
83
  "@types/react-dom": "19.2.3",
84
+ "@typescript/native-preview": "7.0.0-dev.20260409.1",
76
85
  "jsdom": "^29.0.1",
77
86
  "prosemirror-commands": "^1.7.1",
78
87
  "prosemirror-keymap": "^1.2.3",
@@ -91,7 +100,10 @@
91
100
  "test": "bash scripts/run-workspace-tests.sh",
92
101
  "test:repo": "pnpm exec tsx --test $(find test -type f \\( -name '*.test.ts' -o -name '*.test.tsx' \\) | sort)",
93
102
  "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
103
+ "lint": "pnpm run lint:no-authored-js && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
94
104
  "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
105
+ "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
106
+ "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
95
107
  "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
96
108
  "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
97
109
  "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
@@ -384,6 +384,8 @@ export type WordReviewEditorEvent =
384
384
  source: "docx" | "snapshot" | "datastore" | "canonical";
385
385
  stats: DocumentStats;
386
386
  compatibility: CompatibilityReport;
387
+ comments: CommentSidebarSnapshot;
388
+ trackedChanges: TrackedChangesSnapshot;
387
389
  }
388
390
  | {
389
391
  type: "dirty_changed";
@@ -504,6 +506,8 @@ export interface WordReviewEditorRef {
504
506
  getSnapshot(): PersistedEditorSnapshot;
505
507
  getCompatibilityReport(): CompatibilityReport;
506
508
  getWarnings(): EditorWarning[];
509
+ getComments(): CommentSidebarSnapshot;
510
+ getTrackedChanges(): TrackedChangesSnapshot;
507
511
  }
508
512
 
509
513
  export interface WordReviewEditorProps {
@@ -6,6 +6,7 @@ import {
6
6
  type HyperlinkNode,
7
7
  type InlineNode,
8
8
  type ParagraphNode,
9
+ type RevisionRecord,
9
10
  type TextNode,
10
11
  } from "../model/canonical-document.ts";
11
12
  import type { DocumentVersionSnapshot } from "./snapshot.ts";
@@ -319,39 +320,35 @@ function createParagraphRevisionRecords(
319
320
  previousWasParagraph = false;
320
321
  }
321
322
 
322
- return Object.fromEntries(
323
- pendingRevisions
324
- .map((revision, index) => {
325
- const position = positionByParagraphIndex.get(revision.paragraphIndex);
326
- if (position === undefined) {
327
- return undefined;
328
- }
323
+ const entries: Array<[string, RevisionRecord]> = [];
324
+ pendingRevisions.forEach((revision, index) => {
325
+ const position = positionByParagraphIndex.get(revision.paragraphIndex);
326
+ if (position === undefined) {
327
+ return;
328
+ }
329
329
 
330
- const changeId = `change-${index + 1}`;
331
- return [
332
- changeId,
333
- {
334
- changeId,
335
- kind: revision.kind,
336
- anchor: {
337
- kind: "range" as const,
338
- range: { from: position, to: position },
339
- assoc: { start: -1 as const, end: 1 as const },
340
- },
341
- authorId,
342
- createdAt,
343
- status: "open" as const,
344
- warningIds: [],
345
- metadata: {
346
- source: "runtime" as const,
347
- importedRevisionForm:
348
- revision.kind === "insertion" ? "paragraph-insertion" : "paragraph-deletion",
349
- },
350
- },
351
- ] as const;
352
- })
353
- .filter((entry): entry is readonly [string, CanonicalDocument["review"]["revisions"][string]] => Boolean(entry)),
354
- );
330
+ const changeId = `change-${index + 1}`;
331
+ const record: RevisionRecord = {
332
+ changeId,
333
+ kind: revision.kind,
334
+ anchor: {
335
+ kind: "range",
336
+ range: { from: position, to: position },
337
+ assoc: { start: -1, end: 1 },
338
+ },
339
+ authorId,
340
+ createdAt,
341
+ status: "open",
342
+ warningIds: [],
343
+ metadata: {
344
+ source: "runtime",
345
+ importedRevisionForm:
346
+ revision.kind === "insertion" ? "paragraph-insertion" : "paragraph-deletion",
347
+ },
348
+ };
349
+ entries.push([changeId, record]);
350
+ });
351
+ return Object.fromEntries(entries);
355
352
  }
356
353
 
357
354
  function getComparableBlockKey(block: BlockNode): string {
@@ -434,11 +431,17 @@ function getInlineLength(node: InlineNode): number {
434
431
  case "tab":
435
432
  case "hard_break":
436
433
  case "column_break":
434
+ case "symbol":
437
435
  case "image":
438
436
  case "opaque_inline":
439
437
  case "footnote_ref":
440
438
  case "bookmark_start":
441
439
  case "bookmark_end":
440
+ case "chart_preview":
441
+ case "smartart_preview":
442
+ case "shape":
443
+ case "wordart":
444
+ case "vml_shape":
442
445
  return 1;
443
446
  }
444
447
  }
@@ -471,6 +474,8 @@ function getInlineDisplayText(node: InlineNode): string {
471
474
  case "hard_break":
472
475
  case "column_break":
473
476
  return "\n";
477
+ case "symbol":
478
+ return node.char;
474
479
  case "hyperlink":
475
480
  return node.children.map(getInlineDisplayText).join("");
476
481
  case "field":
@@ -484,6 +489,16 @@ function getInlineDisplayText(node: InlineNode): string {
484
489
  case "bookmark_start":
485
490
  case "bookmark_end":
486
491
  return "";
492
+ case "chart_preview":
493
+ return "[Chart]";
494
+ case "smartart_preview":
495
+ return "[SmartArt]";
496
+ case "shape":
497
+ return node.text ?? "[Shape]";
498
+ case "wordart":
499
+ return node.text;
500
+ case "vml_shape":
501
+ return node.text ?? "[VML Shape]";
487
502
  }
488
503
  }
489
504
 
@@ -517,7 +532,7 @@ function mergePreservationStores(
517
532
  };
518
533
  }
519
534
 
520
- function mergeRecordCatalog<T extends Record<string, unknown>>(base: T, target: T): T {
535
+ function mergeRecordCatalog<T extends object>(base: T, target: T): T {
521
536
  return projectValue({ ...base, ...target });
522
537
  }
523
538
 
@@ -94,6 +94,10 @@ export function parseTextStory(content: unknown): TextStory {
94
94
  continue;
95
95
  }
96
96
 
97
+ if (block.type !== "opaque_block") {
98
+ continue;
99
+ }
100
+
97
101
  units.push({
98
102
  kind: "opaque_block",
99
103
  fragmentId: block.fragmentId,
@@ -484,7 +488,7 @@ function createEmptyParagraph(): ParagraphNode {
484
488
  const EMPTY_PARAGRAPH_PROPERTIES: ParagraphProperties = {};
485
489
 
486
490
  function cloneMarks(marks: TextMark[]): TextMark[] {
487
- return marks.map((mark) => ({ type: mark.type }));
491
+ return marks.map((mark) => ({ ...mark }));
488
492
  }
489
493
 
490
494
  function haveEqualMarks(left?: TextMark[], right?: TextMark[]): boolean {
@@ -101,6 +101,16 @@ export function normalizeRange(range: DocRange): DocRange {
101
101
  : { from: range.to, to: range.from };
102
102
  }
103
103
 
104
+ export function getEffectiveRange(anchor: EditorAnchorProjection): DocRange {
105
+ if (anchor.kind === "range") {
106
+ return anchor.range;
107
+ }
108
+ if (anchor.kind === "node") {
109
+ return { from: anchor.at, to: anchor.at };
110
+ }
111
+ return anchor.lastKnownRange;
112
+ }
113
+
104
114
  export function mapPosition(
105
115
  position: Position,
106
116
  assoc: Assoc,
@@ -301,6 +301,8 @@ function convertCachedFormulaValue(
301
301
  // Helpers
302
302
  // ---------------------------------------------------------------------------
303
303
 
304
+ const UTF8_DECODER = new TextDecoder("utf-8");
305
+
304
306
  function decodePartBytes(bytes: Uint8Array): string {
305
- return new TextDecoder("utf-8").decode(bytes);
307
+ return UTF8_DECODER.decode(bytes);
306
308
  }
@@ -8,6 +8,8 @@
8
8
  import type { CellAddress, CellKey, CellValue, ColIndex, RowIndex } from "./cell.ts";
9
9
  import { cellKey } from "./cell.ts";
10
10
 
11
+ export type { ColIndex, RowIndex } from "./cell.ts";
12
+
11
13
  // ---------------------------------------------------------------------------
12
14
  // Row and column metadata (sparse)
13
15
  // ---------------------------------------------------------------------------
@@ -853,7 +853,9 @@ function exportDocxEditorSession(
853
853
  exportSession.replaceOwnedPart({
854
854
  path: state.sourceSubPartPaths.themePartPath,
855
855
  bytes: sourceThemePart.bytes,
856
- contentType: sourceThemePart.contentType,
856
+ contentType:
857
+ sourceThemePart.contentType ??
858
+ "application/vnd.openxmlformats-officedocument.theme+xml",
857
859
  relationships: sourceThemePart.relationships,
858
860
  compression: sourceThemePart.compression,
859
861
  });
@@ -1061,13 +1063,14 @@ function normalizeImportedRevisionRecords(
1061
1063
  return {
1062
1064
  ...parsed,
1063
1065
  revisions: parsed.revisions.map((revision) => {
1064
- if (revision.anchor.kind !== "range" || revision.metadata.preserveOnlyReason) {
1066
+ const { anchor } = revision;
1067
+ if (anchor.kind !== "range" || revision.metadata.preserveOnlyReason) {
1065
1068
  return revision;
1066
1069
  }
1067
1070
 
1068
1071
  const preserveOnlyReason =
1069
1072
  getStructuralPreserveOnlyReason(revision, paragraphRanges) ??
1070
- (opaqueRanges.some((range) => rangesIntersect(range, revision.anchor.range))
1073
+ (opaqueRanges.some((range) => rangesIntersect(range, anchor.range))
1071
1074
  ? "Imported revision overlaps preserve-only OOXML and remains preserve-only."
1072
1075
  : undefined);
1073
1076
 
@@ -1092,23 +1095,27 @@ function normalizeImportedCommentThreads(
1092
1095
  revisions: readonly ReviewRevisionRecord[],
1093
1096
  ): NormalizedImportedCommentsResult {
1094
1097
  const opaqueRanges = Object.values(opaqueFragments).map((fragment) => fragment.lastKnownRange);
1095
- const preserveOnlyRevisionRanges = revisions
1096
- .filter(
1097
- (revision) =>
1098
- revision.anchor.kind === "range" &&
1099
- typeof revision.metadata.preserveOnlyReason === "string" &&
1100
- revision.metadata.preserveOnlyReason.length > 0,
1101
- )
1102
- .map((revision) => revision.anchor.range);
1098
+ const preserveOnlyRevisionRanges = revisions.flatMap((revision) => {
1099
+ const { anchor } = revision;
1100
+ if (
1101
+ anchor.kind !== "range" ||
1102
+ typeof revision.metadata.preserveOnlyReason !== "string" ||
1103
+ revision.metadata.preserveOnlyReason.length === 0
1104
+ ) {
1105
+ return [];
1106
+ }
1107
+ return [anchor.range];
1108
+ });
1103
1109
  const preserveOnlyCommentIds = new Set(parsed.diagnostics.map((diagnostic) => diagnostic.commentId));
1104
1110
  const additionalDiagnostics: CommentImportDiagnostic[] = [];
1105
- const normalizedThreads = parsed.threads.map((thread) => {
1106
- if (thread.anchor.kind !== "range") {
1111
+ const normalizedThreads: CommentThread[] = parsed.threads.map((thread) => {
1112
+ const { anchor } = thread;
1113
+ if (anchor.kind !== "range") {
1107
1114
  preserveOnlyCommentIds.add(thread.commentId);
1108
1115
  return thread;
1109
1116
  }
1110
1117
 
1111
- const opaqueOverlap = opaqueRanges.some((range) => rangesIntersect(range, thread.anchor.range));
1118
+ const opaqueOverlap = opaqueRanges.some((range) => rangesIntersect(range, anchor.range));
1112
1119
  if (opaqueOverlap) {
1113
1120
  preserveOnlyCommentIds.add(thread.commentId);
1114
1121
  additionalDiagnostics.push({
@@ -1120,13 +1127,13 @@ function normalizeImportedCommentThreads(
1120
1127
  });
1121
1128
  return {
1122
1129
  ...thread,
1123
- anchor: createDetachedAnchor(thread.anchor.range, "importAmbiguity"),
1130
+ anchor: createDetachedAnchor(anchor.range, "importAmbiguity"),
1124
1131
  status: "detached",
1125
1132
  };
1126
1133
  }
1127
1134
 
1128
1135
  const preserveOnlyRevisionOverlap = preserveOnlyRevisionRanges.some((range) =>
1129
- rangesIntersect(range, thread.anchor.range),
1136
+ rangesIntersect(range, anchor.range),
1130
1137
  );
1131
1138
  if (preserveOnlyRevisionOverlap) {
1132
1139
  preserveOnlyCommentIds.add(thread.commentId);
@@ -1139,7 +1146,7 @@ function normalizeImportedCommentThreads(
1139
1146
  });
1140
1147
  return {
1141
1148
  ...thread,
1142
- anchor: createDetachedAnchor(thread.anchor.range, "importAmbiguity"),
1149
+ anchor: createDetachedAnchor(anchor.range, "importAmbiguity"),
1143
1150
  status: "detached",
1144
1151
  };
1145
1152
  }
@@ -1196,13 +1203,14 @@ function getStructuralPreserveOnlyReason(
1196
1203
  paragraphRanges: ReadonlyArray<{ start: number; end: number }>,
1197
1204
  ): string | undefined {
1198
1205
  const form = revision.metadata.importedRevisionForm;
1199
- if (!form || revision.anchor.kind !== "range") {
1206
+ const { anchor } = revision;
1207
+ if (!form || anchor.kind !== "range") {
1200
1208
  return undefined;
1201
1209
  }
1202
1210
 
1203
1211
  if (
1204
1212
  (form === "run-insertion" || form === "run-deletion") &&
1205
- revision.anchor.range.from === revision.anchor.range.to
1213
+ anchor.range.from === anchor.range.to
1206
1214
  ) {
1207
1215
  return "Imported zero-width run revision remains preserve-only.";
1208
1216
  }
@@ -1210,9 +1218,9 @@ function getStructuralPreserveOnlyReason(
1210
1218
  if (form === "paragraph-insertion" || form === "paragraph-deletion") {
1211
1219
  const paragraphBoundary = paragraphRanges.find(
1212
1220
  (boundary) =>
1213
- boundary.end === revision.anchor.range.from ||
1214
- (revision.anchor.range.from >= boundary.start &&
1215
- revision.anchor.range.from <= boundary.end),
1221
+ boundary.end === anchor.range.from ||
1222
+ (anchor.range.from >= boundary.start &&
1223
+ anchor.range.from <= boundary.end),
1216
1224
  );
1217
1225
  return paragraphBoundary
1218
1226
  ? undefined
@@ -1221,8 +1229,8 @@ function getStructuralPreserveOnlyReason(
1221
1229
 
1222
1230
  const paragraphBoundary = paragraphRanges.find(
1223
1231
  (boundary) =>
1224
- revision.anchor.range.from >= boundary.start &&
1225
- revision.anchor.range.to <= boundary.end,
1232
+ anchor.range.from >= boundary.start &&
1233
+ anchor.range.to <= boundary.end,
1226
1234
  );
1227
1235
  return paragraphBoundary
1228
1236
  ? undefined
@@ -1256,29 +1264,22 @@ function collectCanonicalParagraphRanges(
1256
1264
  }
1257
1265
 
1258
1266
  function measureCanonicalParagraph(paragraph: CanonicalDocumentEnvelope["content"]["children"][number] & { type: "paragraph" }): number {
1259
- return paragraph.children.reduce((size, child) => {
1260
- switch (child.type) {
1261
- case "text":
1262
- return size + child.text.length;
1263
- case "tab":
1264
- case "hard_break":
1265
- case "image":
1266
- case "opaque_inline":
1267
- return size + 1;
1268
- case "hyperlink":
1269
- return (
1270
- size +
1271
- child.children.reduce((childSize, entry) => {
1272
- switch (entry.type) {
1273
- case "text":
1274
- return childSize + entry.text.length;
1275
- case "tab":
1276
- case "hard_break":
1277
- return childSize + 1;
1278
- }
1279
- }, 0)
1280
- );
1267
+ return paragraph.children.reduce<number>((size, child) => {
1268
+ if (child.type === "text") {
1269
+ return size + child.text.length;
1281
1270
  }
1271
+ if (child.type === "hyperlink") {
1272
+ return (
1273
+ size +
1274
+ child.children.reduce<number>((childSize, entry) => {
1275
+ if (entry.type === "text") {
1276
+ return childSize + entry.text.length;
1277
+ }
1278
+ return childSize + 1;
1279
+ }, 0)
1280
+ );
1281
+ }
1282
+ return size + 1;
1282
1283
  }, 0);
1283
1284
  }
1284
1285
 
@@ -1728,12 +1729,14 @@ function cloneRelationship(relationship: OpcRelationship): OpcRelationship {
1728
1729
  return { ...relationship };
1729
1730
  }
1730
1731
 
1732
+ const UTF8_DECODER = new TextDecoder("utf-8");
1733
+
1731
1734
  function decodeUtf8(bytes: Uint8Array | undefined): string {
1732
1735
  if (!bytes) {
1733
1736
  return "";
1734
1737
  }
1735
1738
 
1736
- return new TextDecoder("utf-8").decode(bytes);
1739
+ return UTF8_DECODER.decode(bytes);
1737
1740
  }
1738
1741
 
1739
1742
  function toUint8Array(bytes: Uint8Array | ArrayBuffer): Uint8Array {
@@ -255,15 +255,16 @@ export function serializeCommentAnchorsIntoDocumentXml(
255
255
  options.exportCommentIds ?? createCommentExportIdMap(threads);
256
256
 
257
257
  for (const thread of threads) {
258
- if (thread.anchor.kind !== "range") {
258
+ const { anchor } = thread;
259
+ if (anchor.kind !== "range") {
259
260
  skippedCommentIds.push(thread.commentId);
260
261
  continue;
261
262
  }
262
263
 
263
264
  const paragraph = paragraphs.find(
264
265
  (candidate) =>
265
- thread.anchor.range.from >= candidate.start &&
266
- thread.anchor.range.to <= candidate.end,
266
+ anchor.range.from >= candidate.start &&
267
+ anchor.range.to <= candidate.end,
267
268
  );
268
269
 
269
270
  if (!paragraph) {
@@ -271,8 +272,8 @@ export function serializeCommentAnchorsIntoDocumentXml(
271
272
  continue;
272
273
  }
273
274
 
274
- const startIndex = paragraph.boundaries.get(thread.anchor.range.from);
275
- const endIndex = paragraph.boundaries.get(thread.anchor.range.to);
275
+ const startIndex = paragraph.boundaries.get(anchor.range.from);
276
+ const endIndex = paragraph.boundaries.get(anchor.range.to);
276
277
 
277
278
  if (startIndex === undefined || endIndex === undefined) {
278
279
  skippedCommentIds.push(thread.commentId);
@@ -140,7 +140,12 @@ function serializeInlineNode(node: InlineNode): string {
140
140
  case "bookmark_end":
141
141
  case "column_break":
142
142
  case "symbol":
143
- // These node types are not parsed from headers/footers by parse-headers-footers.ts
143
+ case "chart_preview":
144
+ case "smartart_preview":
145
+ case "shape":
146
+ case "wordart":
147
+ case "vml_shape":
148
+ default:
144
149
  return "";
145
150
  }
146
151
  }
@@ -153,6 +153,15 @@ export function serializeMainDocument(
153
153
  continue;
154
154
  }
155
155
 
156
+ if (block.type === "section_break") {
157
+ if (block.propertiesXml) {
158
+ sectionPropertiesXml = block.propertiesXml;
159
+ }
160
+ cursor += 1;
161
+ previousWasParagraph = false;
162
+ continue;
163
+ }
164
+
156
165
  const blockXml = serializeOpaqueBlock(block, state);
157
166
  if (looksLikeSectionPropertiesXml(blockXml)) {
158
167
  sectionPropertiesXml = blockXml;
@@ -365,6 +374,12 @@ function serializeTableInlineNode(
365
374
  const childrenXml = node.children.map((child) => serializeTableInlineNode(child, state)).join("");
366
375
  return `${hyperlinkOpen}${childrenXml}</w:hyperlink>`;
367
376
  }
377
+ case "field":
378
+ case "bookmark_start":
379
+ case "bookmark_end":
380
+ case "footnote_ref":
381
+ default:
382
+ return "";
368
383
  }
369
384
  }
370
385
 
@@ -772,6 +787,15 @@ function serializeInlineNode(
772
787
  boundaries,
773
788
  };
774
789
  }
790
+ case "field":
791
+ case "bookmark_start":
792
+ case "bookmark_end":
793
+ case "footnote_ref":
794
+ default: {
795
+ const boundaries = new Map<number, number>();
796
+ boundaries.set(cursor, xmlOffset);
797
+ return { xml: "", cursor, boundaries };
798
+ }
775
799
  }
776
800
  }
777
801
 
@@ -94,13 +94,17 @@ function createRangeRevisionReplacement(
94
94
  boundaries: readonly RevisionParagraphBoundary[],
95
95
  revision: RevisionRecord,
96
96
  ): XmlReplacement | undefined {
97
- const paragraphBoundary = findParagraphBoundaryForRange(boundaries, revision.anchor.range.from, revision.anchor.range.to);
97
+ const { anchor } = revision;
98
+ if (anchor.kind !== "range") {
99
+ return undefined;
100
+ }
101
+ const paragraphBoundary = findParagraphBoundaryForRange(boundaries, anchor.range.from, anchor.range.to);
98
102
  if (!paragraphBoundary) {
99
103
  return undefined;
100
104
  }
101
105
 
102
- const startIndex = paragraphBoundary.boundaries.get(revision.anchor.range.from);
103
- const endIndex = paragraphBoundary.boundaries.get(revision.anchor.range.to);
106
+ const startIndex = paragraphBoundary.boundaries.get(anchor.range.from);
107
+ const endIndex = paragraphBoundary.boundaries.get(anchor.range.to);
104
108
  if (startIndex === undefined || endIndex === undefined || endIndex < startIndex) {
105
109
  return undefined;
106
110
  }
@@ -368,14 +368,11 @@ function normalizeHyperlink(node: ParsedHyperlinkNode): {
368
368
  }
369
369
 
370
370
  function measureHyperlink(node: ParsedHyperlinkNode): number {
371
- return node.children.reduce((size, child) => {
372
- switch (child.type) {
373
- case "text":
374
- return size + child.text.length;
375
- case "tab":
376
- case "hard_break":
377
- return size + 1;
371
+ return node.children.reduce<number>((size, child) => {
372
+ if (child.type === "text") {
373
+ return size + child.text.length;
378
374
  }
375
+ return size + 1;
379
376
  }, 0);
380
377
  }
381
378
 
@@ -124,14 +124,15 @@ export function parseCommentsFromOoxml(
124
124
  }));
125
125
  const createdBy = rootDefinition.authorId ?? entries[0]?.authorId ?? "unknown";
126
126
  const createdAt = rootDefinition.createdAt ?? entries[0]?.createdAt ?? "1970-01-01T00:00:00.000Z";
127
+ const lastEntry = entries.length > 0 ? entries[entries.length - 1] : undefined;
127
128
  const resolution =
128
129
  rootDefinition.isDone
129
130
  ? {
130
- resolvedAt: entries.at(-1)?.createdAt ?? createdAt,
131
- resolvedBy: entries.at(-1)?.authorId ?? createdBy,
131
+ resolvedAt: lastEntry?.createdAt ?? createdAt,
132
+ resolvedBy: lastEntry?.authorId ?? createdBy,
132
133
  }
133
134
  : undefined;
134
- const detachedRange = toDetachedRange(anchor);
135
+ const detachedRange = anchor ? toDetachedRange(anchor) : { from: 0, to: 0 };
135
136
 
136
137
  if (
137
138
  start === undefined ||
@@ -1249,7 +1249,7 @@ function parseHyperlink(
1249
1249
  };
1250
1250
  }
1251
1251
 
1252
- const children: Array<ParsedTextNode | ParsedBreakNode | ParsedTabNode> = [];
1252
+ const children: Array<ParsedTextNode | ParsedBreakNode | ParsedColumnBreakNode | ParsedTabNode | ParsedSymbolNode> = [];
1253
1253
 
1254
1254
  for (const child of node.children) {
1255
1255
  if (child.type !== "element") {
@@ -80,7 +80,7 @@ export function parseCrossReferencesFromDocumentXml(xml: string): CrossReference
80
80
  });
81
81
  }
82
82
 
83
- for (const pattern of detectCrossReferencePatterns(flattenParagraphText(block))) {
83
+ for (const pattern of detectCrossReferencePatterns(flattenParagraphText(block as unknown as ParagraphNode))) {
84
84
  results.push({
85
85
  source: "text-pattern",
86
86
  kind: pattern.kind,
@@ -35,7 +35,7 @@ export function collectDefinedTermsFromDocumentXml(xml: string): DefinedTerm[] {
35
35
  const parsed = parseMainDocumentXml(xml);
36
36
  const paragraphs = parsed.blocks
37
37
  .filter((block): block is typeof parsed.blocks[number] & { type: "paragraph" } => block.type === "paragraph")
38
- .map(flattenParagraphText);
38
+ .map((block) => flattenParagraphText(block as unknown as ParagraphNode));
39
39
 
40
40
  return buildDefinedTermCatalog(paragraphs);
41
41
  }
@@ -56,7 +56,7 @@ export interface CanonicalDocument {
56
56
  styles: StylesCatalog;
57
57
  numbering: NumberingCatalog;
58
58
  media: MediaCatalog;
59
- content: DocumentNode;
59
+ content: DocumentRootNode;
60
60
  review: ReviewStore;
61
61
  preservation: PreservationStore;
62
62
  diagnostics: DiagnosticStore;
@@ -70,6 +70,7 @@ export interface DocumentMetadata {
70
70
  language?: string;
71
71
  keywords?: string[];
72
72
  category?: string;
73
+ importMode?: string;
73
74
  customProperties: Record<string, string>;
74
75
  }
75
76
 
@@ -456,7 +457,9 @@ export interface SectionBreakNode {
456
457
  export type InlineNode =
457
458
  | TextNode
458
459
  | HardBreakNode
460
+ | ColumnBreakNode
459
461
  | TabNode
462
+ | SymbolNode
460
463
  | HyperlinkNode
461
464
  | ImageNode
462
465
  | FieldNode
@@ -765,6 +768,7 @@ export interface DiagnosticErrorEntry {
765
768
  diagnosticId: string;
766
769
  code:
767
770
  | "load_failed"
771
+ | "import_failed"
768
772
  | "export_failed"
769
773
  | "package_corrupt"
770
774
  | "validation_failed"
@@ -773,6 +777,7 @@ export interface DiagnosticErrorEntry {
773
777
  message: string;
774
778
  isFatal: boolean;
775
779
  source: "import" | "runtime" | "validation" | "datastore" | "export";
780
+ details?: unknown;
776
781
  }
777
782
 
778
783
  export function createCanonicalDocument(
@@ -13,6 +13,8 @@ import {
13
13
  type RevisionStatus,
14
14
  } from "./revision-types.ts";
15
15
 
16
+ export type { RevisionRecord, RevisionKind, RevisionStatus } from "./revision-types.ts";
17
+
16
18
  export interface RevisionStore {
17
19
  version: "revision-store/1";
18
20
  revisions: Record<string, RevisionRecord>;
@@ -133,6 +133,8 @@ export function createDocumentRuntime(
133
133
  source: options.sourceKind ?? (options.initialSnapshot ? "snapshot" : "canonical"),
134
134
  stats: toPublicDocumentStats(state),
135
135
  compatibility: toPublicCompatibilityReport(createDerivedCompatibility(state)),
136
+ comments: cachedRenderSnapshot.comments,
137
+ trackedChanges: cachedRenderSnapshot.trackedChanges,
136
138
  });
137
139
  if (options.fatalError) {
138
140
  emit({
@@ -42,7 +42,7 @@ export function applyRevisionRuntimeCommand(
42
42
  : listBatchRevisionIds(options.state.store);
43
43
 
44
44
  const outcomes: RevisionActionOutcome[] = [];
45
- const mappings: Array<{ revisionId: string; steps: number }> = [];
45
+ const mappings: Array<{ revisionId: string; mapping: TransactionMapping; steps: number }> = [];
46
46
  const appliedRevisionIds: string[] = [];
47
47
  const detachedRevisionIds = new Set<string>();
48
48
 
@@ -14,6 +14,7 @@ import type {
14
14
  ChartPreviewNode,
15
15
  DocumentRootNode,
16
16
  InlineNode,
17
+ MediaCatalog,
17
18
  ParagraphNode,
18
19
  SdtNode,
19
20
  ShapeNode,
@@ -165,6 +166,26 @@ function createSurfaceBlock(
165
166
  };
166
167
  }
167
168
 
169
+ if (block.type === "section_break") {
170
+ const blockId = `section-break-${counters.opaque}`;
171
+ counters.opaque += 1;
172
+ return {
173
+ block: {
174
+ blockId,
175
+ kind: "opaque_block",
176
+ from: cursor,
177
+ to: cursor + 1,
178
+ fragmentId: blockId,
179
+ warningId: blockId,
180
+ label: "Section break",
181
+ detail: "Section properties preserved as a read-only boundary.",
182
+ state: "locked-preserve-only",
183
+ },
184
+ lockedFragmentIds: [],
185
+ nextCursor: cursor + 1,
186
+ };
187
+ }
188
+
168
189
  const paragraphIndex = counters.paragraph;
169
190
  counters.paragraph += 1;
170
191
  return createParagraphBlock(paragraphIndex, block, document, cursor);
@@ -394,6 +415,14 @@ function appendInlineSegments(
394
415
  return appendComplexPreviewSegment(paragraph, node, start, "WordArt", createWordArtDetail(node));
395
416
  case "vml_shape":
396
417
  return appendComplexPreviewSegment(paragraph, node, start, "VML shape", createVmlDetail(node));
418
+ case "column_break":
419
+ case "symbol":
420
+ case "field":
421
+ case "bookmark_start":
422
+ case "bookmark_end":
423
+ case "footnote_ref":
424
+ default:
425
+ return { nextCursor: start + 1, lockedFragmentIds: [] };
397
426
  }
398
427
  }
399
428
 
@@ -499,7 +528,18 @@ function createPlainText(
499
528
  }
500
529
 
501
530
  function cloneMarks(marks: TextMark[]): Array<"bold" | "italic" | "underline" | "strikethrough"> {
502
- return marks.map((mark) => mark.type);
531
+ const supported: Array<"bold" | "italic" | "underline" | "strikethrough"> = [];
532
+ for (const mark of marks) {
533
+ if (
534
+ mark.type === "bold" ||
535
+ mark.type === "italic" ||
536
+ mark.type === "underline" ||
537
+ mark.type === "strikethrough"
538
+ ) {
539
+ supported.push(mark.type);
540
+ }
541
+ }
542
+ return supported;
503
543
  }
504
544
 
505
545
  function normalizeDocumentRoot(content: unknown): DocumentRootNode {
@@ -556,15 +596,6 @@ function createFloatingImageDetail(
556
596
  return parts.join(" ");
557
597
  }
558
598
 
559
- function hasMediaItem(media: Record<string, unknown>, mediaId: string): boolean {
560
- if (mediaId in media) {
561
- return true;
562
- }
563
-
564
- const items = media.items;
565
- if (!items || typeof items !== "object" || Array.isArray(items)) {
566
- return false;
567
- }
568
-
569
- return mediaId in items;
599
+ function hasMediaItem(media: MediaCatalog, mediaId: string): boolean {
600
+ return mediaId in media.items;
570
601
  }
@@ -60,22 +60,22 @@ function withTableGuard(
60
60
  };
61
61
  }
62
62
 
63
- export const addRowBefore: PMCommand = (state, dispatch, view) =>
64
- pmAddRowBefore(state, dispatch, view);
63
+ export const addRowBefore: PMCommand = (state, dispatch) =>
64
+ pmAddRowBefore(state, dispatch);
65
65
 
66
- export const addRowAfter: PMCommand = (state, dispatch, view) =>
67
- pmAddRowAfter(state, dispatch, view);
66
+ export const addRowAfter: PMCommand = (state, dispatch) =>
67
+ pmAddRowAfter(state, dispatch);
68
68
 
69
69
  export const deleteRow: PMCommand = withTableGuard((state) => {
70
70
  const table = tableAtSelection(state);
71
71
  return table !== null && table.childCount > 1;
72
72
  }, pmDeleteRow);
73
73
 
74
- export const addColumnBefore: PMCommand = (state, dispatch, view) =>
75
- pmAddColumnBefore(state, dispatch, view);
74
+ export const addColumnBefore: PMCommand = (state, dispatch) =>
75
+ pmAddColumnBefore(state, dispatch);
76
76
 
77
- export const addColumnAfter: PMCommand = (state, dispatch, view) =>
78
- pmAddColumnAfter(state, dispatch, view);
77
+ export const addColumnAfter: PMCommand = (state, dispatch) =>
78
+ pmAddColumnAfter(state, dispatch);
79
79
 
80
80
  export const deleteColumn: PMCommand = withTableGuard((state) => {
81
81
  const table = tableAtSelection(state);
@@ -112,6 +112,8 @@ export function __createWordReviewEditorRefBridge(
112
112
  getSnapshot: () => runtime.getPersistedSnapshot(),
113
113
  getCompatibilityReport: () => runtime.getCompatibilityReport(),
114
114
  getWarnings: () => runtime.getWarnings(),
115
+ getComments: () => runtime.getRenderSnapshot().comments,
116
+ getTrackedChanges: () => runtime.getRenderSnapshot().trackedChanges,
115
117
  };
116
118
  }
117
119
 
@@ -496,6 +498,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
496
498
  getSnapshot: () => activeRuntime.getPersistedSnapshot(),
497
499
  getCompatibilityReport: () => activeRuntime.getCompatibilityReport(),
498
500
  getWarnings: () => activeRuntime.getWarnings(),
501
+ getComments: () => activeRuntime.getRenderSnapshot().comments,
502
+ getTrackedChanges: () => activeRuntime.getRenderSnapshot().trackedChanges,
499
503
  }),
500
504
  [activeRuntime, currentUser.userId, documentId, runtime],
501
505
  );
@@ -1289,6 +1293,8 @@ function createReadyEvent(
1289
1293
  source,
1290
1294
  stats: snapshot.documentStats,
1291
1295
  compatibility: runtime.getCompatibilityReport(),
1296
+ comments: snapshot.comments,
1297
+ trackedChanges: snapshot.trackedChanges,
1292
1298
  };
1293
1299
  }
1294
1300
 
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { Node as PMNode } from "prosemirror-model";
12
+ import type { NodeViewConstructor, ViewMutationRecord } from "prosemirror-view";
12
13
 
13
14
  const TABLE_LAYOUT_SYNC_EVENT = "pm-table-layout-sync";
14
15
 
@@ -251,7 +252,7 @@ export class TableNodeView {
251
252
  this.dom.removeEventListener(TABLE_LAYOUT_SYNC_EVENT, this.onSyncRequest);
252
253
  }
253
254
 
254
- ignoreMutation(record: MutationRecord): boolean {
255
+ ignoreMutation(record: ViewMutationRecord): boolean {
255
256
  return record.type === "attributes" && record.target === this.dom;
256
257
  }
257
258
 
@@ -336,7 +337,7 @@ export class TableCellNodeView {
336
337
  return true;
337
338
  }
338
339
 
339
- ignoreMutation(record: MutationRecord): boolean {
340
+ ignoreMutation(record: ViewMutationRecord): boolean {
340
341
  return record.type === "attributes" && record.target === this.dom;
341
342
  }
342
343
  }
@@ -347,7 +348,7 @@ export class TableCellNodeView {
347
348
  * Pass this object directly to the EditorView constructor options:
348
349
  * new EditorView(mount, { nodeViews: tableNodeViews, ... })
349
350
  */
350
- export const tableNodeViews = {
351
+ export const tableNodeViews: { [node: string]: NodeViewConstructor } = {
351
352
  table: (node: PMNode) => new TableNodeView(node),
352
353
  table_row: (node: PMNode) => new TableRowNodeView(node),
353
354
  table_cell: (node: PMNode) => new TableCellNodeView(node),
@@ -200,6 +200,20 @@ function measureInlineNode(
200
200
  case "opaque_inline":
201
201
  flags.runs = true;
202
202
  return 1;
203
+ case "column_break":
204
+ case "symbol":
205
+ case "field":
206
+ case "bookmark_start":
207
+ case "bookmark_end":
208
+ case "footnote_ref":
209
+ case "chart_preview":
210
+ case "smartart_preview":
211
+ case "shape":
212
+ case "wordart":
213
+ case "vml_shape":
214
+ default:
215
+ flags.runs = true;
216
+ return 1;
203
217
  }
204
218
  }
205
219
 
@@ -147,7 +147,7 @@ export type {
147
147
  EditorWarning,
148
148
  } from "./diagnostics.ts";
149
149
 
150
- function flattenUnique<Value extends { [key: string]: unknown }>(
150
+ function flattenUnique<Value extends object>(
151
151
  items: readonly Value[],
152
152
  ): readonly Value[] {
153
153
  const deduped = new Map<string, Value>();