@beyondwork/docx-react-component 1.0.15 → 1.0.16

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/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.15",
4
+ "version": "1.0.16",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
+ "packageManager": "pnpm@10.30.3",
6
7
  "type": "module",
7
8
  "sideEffects": [
8
9
  "**/*.css"
@@ -53,6 +54,29 @@
53
54
  "./package.json": "./package.json"
54
55
  },
55
56
  "types": "./src/index.ts",
57
+ "scripts": {
58
+ "build": "tsup",
59
+ "test": "bash scripts/run-workspace-tests.sh",
60
+ "test:repo": "node scripts/run-repo-tests.mjs core",
61
+ "test:repo:all": "node scripts/run-repo-tests.mjs all",
62
+ "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
63
+ "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
64
+ "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
65
+ "lint": "pnpm run lint:no-authored-js && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
66
+ "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
67
+ "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
68
+ "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
69
+ "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
70
+ "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
71
+ "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
72
+ "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
73
+ "wave:launch:managed": "bash scripts/wave-launch.sh",
74
+ "wave:status": "bash scripts/wave-status.sh",
75
+ "wave:watch": "bash scripts/wave-watch.sh --follow",
76
+ "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
77
+ "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
78
+ "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
79
+ },
56
80
  "keywords": [
57
81
  "docx",
58
82
  "word",
@@ -115,27 +139,14 @@
115
139
  "tsup": "^8.3.0",
116
140
  "tsx": "^4.21.0"
117
141
  },
118
- "scripts": {
119
- "build": "tsup",
120
- "test": "bash scripts/run-workspace-tests.sh",
121
- "test:repo": "node scripts/run-repo-tests.mjs core",
122
- "test:repo:all": "node scripts/run-repo-tests.mjs all",
123
- "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
124
- "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
125
- "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
126
- "lint": "pnpm run lint:no-authored-js && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
127
- "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
128
- "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
129
- "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
130
- "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
131
- "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
132
- "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
133
- "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
134
- "wave:launch:managed": "bash scripts/wave-launch.sh",
135
- "wave:status": "bash scripts/wave-status.sh",
136
- "wave:watch": "bash scripts/wave-watch.sh --follow",
137
- "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
138
- "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
139
- "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
142
+ "pnpm": {
143
+ "onlyBuiltDependencies": [
144
+ "esbuild",
145
+ "sharp"
146
+ ],
147
+ "overrides": {
148
+ "react": "19.2.4",
149
+ "react-dom": "19.2.4"
150
+ }
140
151
  }
141
152
  }
@@ -125,6 +125,13 @@ export interface TrackedChangesSnapshot {
125
125
 
126
126
  export type FormattingAlignment = "left" | "center" | "right" | "justify";
127
127
 
128
+ export interface FormattingBreadcrumbItem {
129
+ kind: "table" | "table_row" | "table_cell" | "paragraph" | "sdt_block" | "opaque_block";
130
+ label: string;
131
+ from: number;
132
+ to: number;
133
+ }
134
+
128
135
  export interface FormattingStateSnapshot {
129
136
  bold: boolean;
130
137
  italic: boolean;
@@ -137,6 +144,7 @@ export interface FormattingStateSnapshot {
137
144
  textColor?: string;
138
145
  highlightColor?: string | null;
139
146
  alignment?: FormattingAlignment;
147
+ breadcrumb: FormattingBreadcrumbItem[];
140
148
  }
141
149
 
142
150
  export interface SearchOptions {
@@ -587,6 +595,7 @@ export interface WordReviewEditorRef {
587
595
  getTrackedChanges(): TrackedChangesSnapshot;
588
596
  isDirty(): boolean;
589
597
  getFormattingState(): FormattingStateSnapshot;
598
+ replaceText(text: string, target?: EditorAnchorProjection): void;
590
599
  toggleBold(): void;
591
600
  toggleItalic(): void;
592
601
  toggleUnderline(): void;
@@ -21,12 +21,14 @@ import type { Command } from "prosemirror-state";
21
21
  import type { MarkType, Schema } from "prosemirror-model";
22
22
 
23
23
  import type {
24
+ FormattingBreadcrumbItem,
24
25
  FormattingAlignment,
25
26
  FormattingStateSnapshot,
26
27
  PersistedEditorSnapshot,
27
28
  RuntimeRenderSnapshot,
28
29
  SurfaceBlockSnapshot,
29
30
  SurfaceInlineSegment,
31
+ SurfaceTableCellSnapshot,
30
32
  } from "../../api/public-types";
31
33
  import type {
32
34
  BlockNode,
@@ -209,6 +211,7 @@ const DEFAULT_FORMATTING_STATE: FormattingStateSnapshot = {
209
211
  strikethrough: false,
210
212
  superscript: false,
211
213
  subscript: false,
214
+ breadcrumb: [],
212
215
  };
213
216
 
214
217
  const INDENT_STEP_TWIPS = 720;
@@ -268,9 +271,231 @@ export function getFormattingStateFromRenderSnapshot(
268
271
  alignment: getConsistentValue(paragraphs, (paragraph) =>
269
272
  toPublicAlignment(paragraph.alignment),
270
273
  ),
274
+ breadcrumb: getSelectionBreadcrumb(surface.blocks, snapshot.selection),
271
275
  };
272
276
  }
273
277
 
278
+ function getSelectionBreadcrumb(
279
+ blocks: SurfaceBlockSnapshot[],
280
+ selection: RuntimeRenderSnapshot["selection"],
281
+ ): FormattingBreadcrumbItem[] {
282
+ const paths = collectSelectionBreadcrumbPaths(blocks, selection);
283
+ if (paths.length === 0) {
284
+ return [];
285
+ }
286
+
287
+ let commonLength = paths[0]?.length ?? 0;
288
+ for (let pathIndex = 1; pathIndex < paths.length; pathIndex += 1) {
289
+ const currentPath = paths[pathIndex] ?? [];
290
+ commonLength = Math.min(commonLength, currentPath.length);
291
+
292
+ let compareIndex = 0;
293
+ while (
294
+ compareIndex < commonLength &&
295
+ breadcrumbItemsEqual(paths[0]![compareIndex]!, currentPath[compareIndex]!)
296
+ ) {
297
+ compareIndex += 1;
298
+ }
299
+ commonLength = compareIndex;
300
+ }
301
+
302
+ return paths[0]!.slice(0, commonLength);
303
+ }
304
+
305
+ function collectSelectionBreadcrumbPaths(
306
+ blocks: SurfaceBlockSnapshot[],
307
+ selection: RuntimeRenderSnapshot["selection"],
308
+ path: FormattingBreadcrumbItem[] = [],
309
+ output: FormattingBreadcrumbItem[][] = [],
310
+ ): FormattingBreadcrumbItem[][] {
311
+ for (let blockIndex = 0; blockIndex < blocks.length; blockIndex += 1) {
312
+ const block = blocks[blockIndex];
313
+ if (!block) {
314
+ continue;
315
+ }
316
+
317
+ if (
318
+ !selectionTouchesRange(
319
+ selection.anchor,
320
+ selection.head,
321
+ block.from,
322
+ block.to,
323
+ )
324
+ ) {
325
+ continue;
326
+ }
327
+
328
+ if (block.kind === "paragraph") {
329
+ output.push([
330
+ ...path,
331
+ {
332
+ kind: "paragraph" as const,
333
+ label: block.styleId ? `Paragraph (${block.styleId})` : "Paragraph",
334
+ from: block.from,
335
+ to: block.to,
336
+ },
337
+ ]);
338
+ continue;
339
+ }
340
+
341
+ if (block.kind === "opaque_block") {
342
+ output.push([
343
+ ...path,
344
+ {
345
+ kind: "opaque_block" as const,
346
+ label: block.label,
347
+ from: block.from,
348
+ to: block.to,
349
+ },
350
+ ]);
351
+ continue;
352
+ }
353
+
354
+ if (block.kind === "sdt_block") {
355
+ collectSelectionBreadcrumbPaths(
356
+ block.children,
357
+ selection,
358
+ [
359
+ ...path,
360
+ {
361
+ kind: "sdt_block" as const,
362
+ label: block.alias ?? block.tag ?? block.sdtType ?? "Content control",
363
+ from: block.from,
364
+ to: block.to,
365
+ },
366
+ ],
367
+ output,
368
+ );
369
+ continue;
370
+ }
371
+
372
+ if (block.kind === "table") {
373
+ const tablePath = [
374
+ ...path,
375
+ {
376
+ kind: "table" as const,
377
+ label: block.styleId ? `Table (${block.styleId})` : "Table",
378
+ from: block.from,
379
+ to: block.to,
380
+ },
381
+ ];
382
+
383
+ for (let rowIndex = 0; rowIndex < block.rows.length; rowIndex += 1) {
384
+ const row = block.rows[rowIndex];
385
+ if (!row) {
386
+ continue;
387
+ }
388
+ const rowRange = getTableRowRange(block, row);
389
+ if (
390
+ !selectionTouchesRange(
391
+ selection.anchor,
392
+ selection.head,
393
+ rowRange.from,
394
+ rowRange.to,
395
+ )
396
+ ) {
397
+ continue;
398
+ }
399
+
400
+ const rowPath = [
401
+ ...tablePath,
402
+ {
403
+ kind: "table_row" as const,
404
+ label: `Row ${rowIndex + 1}`,
405
+ from: rowRange.from,
406
+ to: rowRange.to,
407
+ },
408
+ ];
409
+
410
+ for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) {
411
+ const cell = row.cells[cellIndex];
412
+ if (!cell) {
413
+ continue;
414
+ }
415
+ const cellRange = getTableCellRange(block, cell);
416
+ if (
417
+ !selectionTouchesRange(
418
+ selection.anchor,
419
+ selection.head,
420
+ cellRange.from,
421
+ cellRange.to,
422
+ )
423
+ ) {
424
+ continue;
425
+ }
426
+
427
+ collectSelectionBreadcrumbPaths(
428
+ cell.content,
429
+ selection,
430
+ [
431
+ ...rowPath,
432
+ {
433
+ kind: "table_cell",
434
+ label: `Cell ${cellIndex + 1}`,
435
+ from: cellRange.from,
436
+ to: cellRange.to,
437
+ },
438
+ ],
439
+ output,
440
+ );
441
+ }
442
+ }
443
+ }
444
+ }
445
+
446
+ return output;
447
+ }
448
+
449
+ function getTableRowRange(
450
+ table: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
451
+ row: (typeof table.rows)[number],
452
+ ): { from: number; to: number } {
453
+ let from = Number.POSITIVE_INFINITY;
454
+ let to = Number.NEGATIVE_INFINITY;
455
+
456
+ for (const cell of row.cells) {
457
+ const range = getTableCellRange(table, cell);
458
+ from = Math.min(from, range.from);
459
+ to = Math.max(to, range.to);
460
+ }
461
+
462
+ return Number.isFinite(from) && Number.isFinite(to)
463
+ ? { from, to }
464
+ : { from: table.from, to: table.to };
465
+ }
466
+
467
+ function getTableCellRange(
468
+ table: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
469
+ cell: SurfaceTableCellSnapshot,
470
+ ): { from: number; to: number } {
471
+ if (cell.content.length === 0) {
472
+ return { from: table.from, to: table.to };
473
+ }
474
+
475
+ let from = Number.POSITIVE_INFINITY;
476
+ let to = Number.NEGATIVE_INFINITY;
477
+ for (const child of cell.content) {
478
+ from = Math.min(from, child.from);
479
+ to = Math.max(to, child.to);
480
+ }
481
+
482
+ return Number.isFinite(from) && Number.isFinite(to)
483
+ ? { from, to }
484
+ : { from: table.from, to: table.to };
485
+ }
486
+
487
+ function breadcrumbItemsEqual(
488
+ left: FormattingBreadcrumbItem,
489
+ right: FormattingBreadcrumbItem,
490
+ ): boolean {
491
+ return (
492
+ left.kind === right.kind &&
493
+ left.label === right.label &&
494
+ left.from === right.from &&
495
+ left.to === right.to
496
+ );
497
+ }
498
+
274
499
  export function applyFormattingOperationToDocument(
275
500
  document: CanonicalDocumentEnvelope,
276
501
  snapshot: RuntimeRenderSnapshot,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  createEditorState,
3
+ createSelectionSnapshot,
3
4
  createPersistedEditorSnapshot,
4
5
  deriveDocumentStats,
5
6
  type CanonicalDocumentEnvelope,
@@ -35,6 +36,7 @@ import {
35
36
  type EditorCommand,
36
37
  type EditorTransaction,
37
38
  } from "../core/commands/index.ts";
39
+ import { insertText } from "../core/commands/text-commands.ts";
38
40
  import {
39
41
  createDetachedAnchor,
40
42
  createNodeAnchor,
@@ -51,6 +53,7 @@ import {
51
53
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
52
54
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
53
55
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
56
+ import { getFormattingStateFromRenderSnapshot } from "../core/commands/formatting-commands.ts";
54
57
 
55
58
  export type Unsubscribe = () => void;
56
59
 
@@ -58,11 +61,13 @@ export interface DocumentRuntime {
58
61
  subscribe(listener: () => void): Unsubscribe;
59
62
  subscribeToEvents(listener: (event: WordReviewEditorEvent) => void): Unsubscribe;
60
63
  getRenderSnapshot(): RuntimeRenderSnapshot;
64
+ getFormattingState(): import("../api/public-types").FormattingStateSnapshot;
61
65
  dispatch(command: EditorCommand): void;
62
66
  undo(): void;
63
67
  redo(): void;
64
68
  focus(): void;
65
69
  blur(): void;
70
+ replaceText(text: string, target?: EditorAnchorProjection): void;
66
71
  addComment(params: AddCommentParams): string;
67
72
  openComment(commentId: string): void;
68
73
  resolveComment(commentId: string): void;
@@ -162,6 +167,9 @@ export function createDocumentRuntime(
162
167
  getRenderSnapshot() {
163
168
  return cachedRenderSnapshot;
164
169
  },
170
+ getFormattingState() {
171
+ return getFormattingStateFromRenderSnapshot(cachedRenderSnapshot);
172
+ },
165
173
  dispatch(command) {
166
174
  if (command.type === "history.undo") {
167
175
  applyHistory("undo");
@@ -208,6 +216,25 @@ export function createDocumentRuntime(
208
216
  origin: createOrigin("api", clock()),
209
217
  });
210
218
  },
219
+ replaceText(text, target) {
220
+ try {
221
+ const timestamp = clock();
222
+ const selection = target
223
+ ? createSelectionFromPublicAnchor(target)
224
+ : state.selection;
225
+ const result = insertText(state.document, selection, text, { timestamp });
226
+
227
+ this.dispatch({
228
+ type: "document.replace",
229
+ document: result.document,
230
+ mapping: result.mapping,
231
+ selection: result.selection,
232
+ origin: createOrigin("api", timestamp),
233
+ });
234
+ } catch (error) {
235
+ emitError(toRuntimeError(error));
236
+ }
237
+ },
211
238
  addComment(params) {
212
239
  const commentId = createEntityId("comment", state.document.review.comments, clock());
213
240
  const anchor = params.anchor
@@ -694,6 +721,22 @@ function toInternalAnchorProjection(
694
721
  }
695
722
  }
696
723
 
724
+ function createSelectionFromPublicAnchor(
725
+ anchor: EditorAnchorProjection,
726
+ ): import("../core/state/editor-state.ts").SelectionSnapshot {
727
+ switch (anchor.kind) {
728
+ case "range":
729
+ return createSelectionSnapshot(anchor.from, anchor.to);
730
+ case "node":
731
+ return createSelectionSnapshot(anchor.at, anchor.at);
732
+ case "detached":
733
+ return createSelectionSnapshot(
734
+ anchor.lastKnownRange.from,
735
+ anchor.lastKnownRange.to,
736
+ );
737
+ }
738
+ }
739
+
697
740
  function toPublicCompatibilityReport(
698
741
  report: InternalCompatibilityReport,
699
742
  ): CompatibilityReport {
@@ -680,7 +680,11 @@ function createPlainText(
680
680
  }
681
681
 
682
682
  if (block.kind === "table") {
683
- text.push("\uFFFA"); // placeholder for table in plain text
683
+ for (const row of block.rows) {
684
+ for (const cell of row.cells) {
685
+ text.push(createPlainText(cell.content));
686
+ }
687
+ }
684
688
  continue;
685
689
  }
686
690
 
@@ -165,6 +165,7 @@ export function __createWordReviewEditorRefBridge(
165
165
  clonePublicValue(runtime.getRenderSnapshot().trackedChanges),
166
166
  isDirty: () => runtime.getRenderSnapshot().isDirty,
167
167
  getFormattingState: () => getFormattingStateFromRenderSnapshot(runtime.getRenderSnapshot()),
168
+ replaceText: (text, target) => runtime.replaceText(text, target),
168
169
  toggleBold: () => {
169
170
  applyRuntimeFormattingOperation(runtime, { type: "toggle", mark: "bold" });
170
171
  },
@@ -729,6 +730,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
729
730
  isDirty: () => activeRuntime.getRenderSnapshot().isDirty,
730
731
  getFormattingState: () =>
731
732
  getFormattingStateFromRenderSnapshot(activeRuntime.getRenderSnapshot()),
733
+ replaceText: (text, target) => activeRuntime.replaceText(text, target),
732
734
  toggleBold: () => {
733
735
  applyRuntimeFormattingOperation(activeRuntime, {
734
736
  type: "toggle",