@beyondwork/docx-react-component 1.0.13 → 1.0.15

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.
@@ -1,4 +1,10 @@
1
1
  import type { RuntimeRenderSnapshot } from "../api/public-types";
2
+ import {
3
+ createDetachedAnchor,
4
+ createNodeAnchor,
5
+ createRangeAnchor,
6
+ } from "../core/selection/mapping.ts";
7
+ import { canCreateDocxCommentAnchor } from "../core/selection/review-anchors";
2
8
 
3
9
  /**
4
10
  * Session capabilities derived from the runtime snapshot.
@@ -80,7 +86,11 @@ export function deriveCapabilities(
80
86
  const canEdit = isReady && !isReadOnly && !hasFatalError;
81
87
  const canUndo = snapshot.commandState.canUndo && canEdit;
82
88
  const canRedo = snapshot.commandState.canRedo && canEdit;
83
- const canAddComment = canEdit && !snapshot.selection.isCollapsed;
89
+ const canAddComment =
90
+ canEdit &&
91
+ !snapshot.selection.isCollapsed &&
92
+ Boolean(snapshot.surface) &&
93
+ canCreateDocxCommentAnchor(snapshot.surface, toRuntimeAnchor(snapshot.selection.activeRange));
84
94
  const canExport = isReady && !exportBlocked && !hasFatalError;
85
95
 
86
96
  // Revision capabilities
@@ -136,3 +146,14 @@ export function deriveCapabilities(
136
146
  hasFatalError,
137
147
  };
138
148
  }
149
+
150
+ function toRuntimeAnchor(anchor: RuntimeRenderSnapshot["selection"]["activeRange"]) {
151
+ switch (anchor.kind) {
152
+ case "range":
153
+ return createRangeAnchor(anchor.from, anchor.to, anchor.assoc);
154
+ case "node":
155
+ return createNodeAnchor(anchor.at, anchor.assoc);
156
+ case "detached":
157
+ return createDetachedAnchor(anchor.lastKnownRange, anchor.reason);
158
+ }
159
+ }
@@ -29,6 +29,10 @@ import {
29
29
  describeOpaqueFragment,
30
30
  getOpaqueFragment,
31
31
  } from "../preservation/store.ts";
32
+ import {
33
+ createNumberingPrefixResolver,
34
+ type NumberingPrefixResolver,
35
+ } from "./numbering-prefix.ts";
32
36
 
33
37
  interface ParagraphAccumulator {
34
38
  blockId: string;
@@ -37,6 +41,7 @@ interface ParagraphAccumulator {
37
41
  to: number;
38
42
  styleId?: string;
39
43
  numbering?: ParagraphNode["numbering"];
44
+ numberingPrefix?: string;
40
45
  segments: SurfaceInlineSegment[];
41
46
  }
42
47
 
@@ -47,6 +52,7 @@ export function createEditorSurfaceSnapshot(
47
52
  const root = normalizeDocumentRoot(document.content);
48
53
  const blocks: SurfaceBlockSnapshot[] = [];
49
54
  const lockedFragmentIds: string[] = [];
55
+ const numberingPrefixResolver = createNumberingPrefixResolver(document.numbering);
50
56
  let cursor = 0;
51
57
  const counters = {
52
58
  paragraph: 0,
@@ -58,7 +64,13 @@ export function createEditorSurfaceSnapshot(
58
64
  };
59
65
 
60
66
  for (let index = 0; index < root.children.length; index += 1) {
61
- const surfaceBlock = createSurfaceBlock(root.children[index], document, cursor, counters);
67
+ const surfaceBlock = createSurfaceBlock(
68
+ root.children[index],
69
+ document,
70
+ cursor,
71
+ counters,
72
+ numberingPrefixResolver,
73
+ );
62
74
  blocks.push(surfaceBlock.block);
63
75
  lockedFragmentIds.push(...surfaceBlock.lockedFragmentIds);
64
76
  cursor = surfaceBlock.nextCursor;
@@ -67,6 +79,8 @@ export function createEditorSurfaceSnapshot(
67
79
  }
68
80
  }
69
81
 
82
+ blocks.push(...createSecondaryStoryPreviewBlocks(document, cursor));
83
+
70
84
  return {
71
85
  storySize: cursor,
72
86
  plainText: createPlainText(blocks),
@@ -87,6 +101,7 @@ function createSurfaceBlock(
87
101
  customXml: number;
88
102
  altChunk: number;
89
103
  },
104
+ numberingPrefixResolver: NumberingPrefixResolver,
90
105
  ): { block: SurfaceBlockSnapshot; lockedFragmentIds: string[]; nextCursor: number } {
91
106
  if (block.type === "opaque_block") {
92
107
  const fragment = getOpaqueFragment(document.preservation as never, block.fragmentId);
@@ -115,13 +130,27 @@ function createSurfaceBlock(
115
130
  if (block.type === "table") {
116
131
  const tableIndex = counters.table;
117
132
  counters.table += 1;
118
- return createTableBlock(tableIndex, block, document, cursor, counters);
133
+ return createTableBlock(
134
+ tableIndex,
135
+ block,
136
+ document,
137
+ cursor,
138
+ counters,
139
+ numberingPrefixResolver,
140
+ );
119
141
  }
120
142
 
121
143
  if (block.type === "sdt") {
122
144
  const sdtIndex = counters.sdt;
123
145
  counters.sdt += 1;
124
- return createSdtBlock(sdtIndex, block, document, cursor, counters);
146
+ return createSdtBlock(
147
+ sdtIndex,
148
+ block,
149
+ document,
150
+ cursor,
151
+ counters,
152
+ numberingPrefixResolver,
153
+ );
125
154
  }
126
155
 
127
156
  if (block.type === "custom_xml") {
@@ -189,7 +218,13 @@ function createSurfaceBlock(
189
218
 
190
219
  const paragraphIndex = counters.paragraph;
191
220
  counters.paragraph += 1;
192
- return createParagraphBlock(paragraphIndex, block, document, cursor);
221
+ return createParagraphBlock(
222
+ paragraphIndex,
223
+ block,
224
+ document,
225
+ cursor,
226
+ numberingPrefixResolver,
227
+ );
193
228
  }
194
229
 
195
230
  function createTableBlock(
@@ -205,6 +240,7 @@ function createTableBlock(
205
240
  customXml: number;
206
241
  altChunk: number;
207
242
  },
243
+ numberingPrefixResolver: NumberingPrefixResolver,
208
244
  ): { block: SurfaceBlockSnapshot; lockedFragmentIds: string[]; nextCursor: number } {
209
245
  const lockedFragmentIds: string[] = [];
210
246
  let innerCursor = cursor;
@@ -216,7 +252,13 @@ function createTableBlock(
216
252
  for (const [cellIndex, cell] of row.cells.entries()) {
217
253
  const cellContent: SurfaceBlockSnapshot[] = [];
218
254
  for (const child of cell.children) {
219
- const result = createSurfaceBlock(child, document, innerCursor, counters);
255
+ const result = createSurfaceBlock(
256
+ child,
257
+ document,
258
+ innerCursor,
259
+ counters,
260
+ numberingPrefixResolver,
261
+ );
220
262
  cellContent.push(result.block);
221
263
  lockedFragmentIds.push(...result.lockedFragmentIds);
222
264
  innerCursor = result.nextCursor;
@@ -310,13 +352,20 @@ function createSdtBlock(
310
352
  customXml: number;
311
353
  altChunk: number;
312
354
  },
355
+ numberingPrefixResolver: NumberingPrefixResolver,
313
356
  ): { block: SurfaceBlockSnapshot; lockedFragmentIds: string[]; nextCursor: number } {
314
357
  const children: SurfaceBlockSnapshot[] = [];
315
358
  const lockedFragmentIds: string[] = [];
316
359
  let innerCursor = cursor;
317
360
 
318
361
  for (const child of block.children) {
319
- const result = createSurfaceBlock(child, document, innerCursor, counters);
362
+ const result = createSurfaceBlock(
363
+ child,
364
+ document,
365
+ innerCursor,
366
+ counters,
367
+ numberingPrefixResolver,
368
+ );
320
369
  children.push(result.block);
321
370
  lockedFragmentIds.push(...result.lockedFragmentIds);
322
371
  innerCursor = result.nextCursor;
@@ -344,6 +393,7 @@ function createParagraphBlock(
344
393
  paragraph: ParagraphNode,
345
394
  document: CanonicalDocumentEnvelope,
346
395
  start: number,
396
+ numberingPrefixResolver: NumberingPrefixResolver,
347
397
  ): {
348
398
  block: SurfaceBlockSnapshot;
349
399
  nextCursor: number;
@@ -356,12 +406,20 @@ function createParagraphBlock(
356
406
  to: start,
357
407
  ...(paragraph.styleId ? { styleId: paragraph.styleId } : {}),
358
408
  ...(paragraph.numbering ? { numbering: paragraph.numbering } : {}),
409
+ ...(paragraph.numbering
410
+ ? {
411
+ numberingPrefix:
412
+ numberingPrefixResolver.resolve(paragraph.numbering) ?? undefined,
413
+ }
414
+ : {}),
359
415
  ...(paragraph.alignment ? { alignment: paragraph.alignment } : {}),
360
416
  ...(paragraph.spacing ? { spacing: paragraph.spacing } : {}),
361
417
  ...(paragraph.indentation ? { indentation: paragraph.indentation } : {}),
362
418
  ...(paragraph.borders ? { borders: paragraph.borders } : {}),
363
419
  ...(paragraph.shading ? { shading: paragraph.shading } : {}),
364
- ...(paragraph.tabStops && paragraph.tabStops.length > 0 ? { tabStops: paragraph.tabStops } : {}),
420
+ ...(paragraph.tabStops && paragraph.tabStops.length > 0
421
+ ? { tabStops: paragraph.tabStops.map((tabStop) => toSurfaceTabStop(tabStop)) }
422
+ : {}),
365
423
  ...(paragraph.keepNext ? { keepNext: true } : {}),
366
424
  ...(paragraph.keepLines ? { keepLines: true } : {}),
367
425
  ...(paragraph.pageBreakBefore ? { pageBreakBefore: true } : {}),
@@ -459,6 +517,7 @@ function appendInlineSegments(
459
517
  case "opaque_inline": {
460
518
  const fragment = getOpaqueFragment(document.preservation as never, node.fragmentId);
461
519
  const descriptor = fragment ? describeOpaqueFragment(fragment) : null;
520
+ const preview = fragment ? describePreservedInlinePreview(fragment.payloadReference) : null;
462
521
  paragraph.segments.push({
463
522
  segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
464
523
  kind: "opaque_inline",
@@ -466,8 +525,9 @@ function appendInlineSegments(
466
525
  to: start + 1,
467
526
  fragmentId: node.fragmentId,
468
527
  warningId: node.warningId,
469
- label: descriptor?.label ?? "Unsupported inline OOXML",
528
+ label: preview?.label ?? descriptor?.label ?? "Unsupported inline OOXML",
470
529
  detail:
530
+ preview?.detail ??
471
531
  descriptor?.detail ??
472
532
  "Locked whole-unit to keep unsupported inline OOXML intact through export.",
473
533
  state: "locked-preserve-only",
@@ -612,6 +672,9 @@ function createPlainText(
612
672
  const text: string[] = [];
613
673
  for (const block of blocks) {
614
674
  if (block.kind === "opaque_block") {
675
+ if (block.fragmentId.startsWith("preview:")) {
676
+ continue;
677
+ }
615
678
  text.push("\uFFFA");
616
679
  continue;
617
680
  }
@@ -650,6 +713,222 @@ function createPlainText(
650
713
  return text.join("");
651
714
  }
652
715
 
716
+ function createSecondaryStoryPreviewBlocks(
717
+ document: CanonicalDocumentEnvelope,
718
+ cursor: number,
719
+ ): SurfaceBlockSnapshot[] {
720
+ const previews: SurfaceBlockSnapshot[] = [];
721
+ const subParts = document.subParts;
722
+ if (!subParts) {
723
+ return previews;
724
+ }
725
+
726
+ for (const header of subParts.headers ?? []) {
727
+ previews.push(
728
+ createSecondaryStoryPreviewBlock(
729
+ `Header · ${header.variant}`,
730
+ `header:${header.relationshipId}`,
731
+ cursor,
732
+ createSecondaryStoryPreviewDetail(header.partPath, header.blocks),
733
+ ),
734
+ );
735
+ }
736
+
737
+ for (const footer of subParts.footers ?? []) {
738
+ previews.push(
739
+ createSecondaryStoryPreviewBlock(
740
+ `Footer · ${footer.variant}`,
741
+ `footer:${footer.relationshipId}`,
742
+ cursor,
743
+ createSecondaryStoryPreviewDetail(footer.partPath, footer.blocks),
744
+ ),
745
+ );
746
+ }
747
+
748
+ const footnotes = Object.values(subParts.footnoteCollection?.footnotes ?? {}).sort(compareNoteIds);
749
+ for (const note of footnotes) {
750
+ previews.push(
751
+ createSecondaryStoryPreviewBlock(
752
+ `Footnote ${note.noteId}`,
753
+ `footnote:${note.noteId}`,
754
+ cursor,
755
+ createSecondaryStoryPreviewDetail(`/word/footnotes.xml#${note.noteId}`, note.blocks),
756
+ ),
757
+ );
758
+ }
759
+
760
+ const endnotes = Object.values(subParts.footnoteCollection?.endnotes ?? {}).sort(compareNoteIds);
761
+ for (const note of endnotes) {
762
+ previews.push(
763
+ createSecondaryStoryPreviewBlock(
764
+ `Endnote ${note.noteId}`,
765
+ `endnote:${note.noteId}`,
766
+ cursor,
767
+ createSecondaryStoryPreviewDetail(`/word/endnotes.xml#${note.noteId}`, note.blocks),
768
+ ),
769
+ );
770
+ }
771
+
772
+ return previews;
773
+ }
774
+
775
+ function createSecondaryStoryPreviewBlock(
776
+ label: string,
777
+ previewId: string,
778
+ cursor: number,
779
+ detail: string,
780
+ ): Extract<SurfaceBlockSnapshot, { kind: "opaque_block" }> {
781
+ return {
782
+ blockId: `secondary-story-${previewId.replace(/[^a-z0-9._:-]+/gi, "-")}`,
783
+ kind: "opaque_block",
784
+ from: cursor,
785
+ to: cursor,
786
+ fragmentId: `preview:${previewId}`,
787
+ warningId: `preview:${previewId}`,
788
+ label,
789
+ detail,
790
+ state: "locked-preserve-only",
791
+ };
792
+ }
793
+
794
+ function createSecondaryStoryPreviewDetail(
795
+ sourceLabel: string,
796
+ blocks: readonly BlockNode[],
797
+ ): string {
798
+ const previewLines = blocks
799
+ .map((block) => summarizePreviewBlock(block))
800
+ .filter((line) => line.trim().length > 0);
801
+ const previewText = previewLines.length > 0 ? previewLines.join("\n\n") : "Empty story.";
802
+ return `Read-only preview from ${sourceLabel}.\n${previewText}`;
803
+ }
804
+
805
+ function summarizePreviewBlock(block: BlockNode): string {
806
+ switch (block.type) {
807
+ case "paragraph": {
808
+ const text = block.children.map((child) => summarizePreviewInline(child)).join("");
809
+ return text.trim().length > 0 ? text : "¶";
810
+ }
811
+ case "table":
812
+ return "[Table preserved in secondary story]";
813
+ case "opaque_block":
814
+ return "[Locked block preserved for export]";
815
+ case "sdt":
816
+ return "[Content control preview]";
817
+ case "custom_xml":
818
+ return "[Custom XML preview]";
819
+ case "alt_chunk":
820
+ return "[AltChunk preview]";
821
+ case "section_break":
822
+ return "[Section boundary]";
823
+ }
824
+ }
825
+
826
+ function summarizePreviewInline(node: InlineNode): string {
827
+ switch (node.type) {
828
+ case "text":
829
+ return node.text;
830
+ case "tab":
831
+ return "\t";
832
+ case "hard_break":
833
+ return "\n";
834
+ case "hyperlink":
835
+ return node.children.map((child) => summarizePreviewInline(child)).join("");
836
+ case "footnote_ref":
837
+ return node.noteKind === "footnote" ? `[fn ${node.noteId}]` : `[en ${node.noteId}]`;
838
+ case "field": {
839
+ const instruction = node.instruction.trim();
840
+ return instruction.length > 0 ? `[Field: ${instruction}]` : "[Field]";
841
+ }
842
+ case "bookmark_start":
843
+ return node.name ? `[Bookmark: ${node.name}]` : "[Bookmark]";
844
+ case "bookmark_end":
845
+ return "";
846
+ case "image":
847
+ return node.altText ? `[Image: ${node.altText}]` : "[Image]";
848
+ case "opaque_inline":
849
+ return "[Locked inline content]";
850
+ case "symbol":
851
+ return node.char ? String.fromCodePoint(parseInt(node.char, 16)) : "\uFFFD";
852
+ case "column_break":
853
+ return "[Column break]";
854
+ case "chart_preview":
855
+ return "[Chart]";
856
+ case "smartart_preview":
857
+ return "[SmartArt]";
858
+ case "shape":
859
+ return node.text ? `[Shape: ${node.text}]` : "[Shape]";
860
+ case "wordart":
861
+ return node.text ? `[WordArt: ${node.text}]` : "[WordArt]";
862
+ case "vml_shape":
863
+ return node.text ? `[VML: ${node.text}]` : "[VML shape]";
864
+ }
865
+ }
866
+
867
+ function compareNoteIds(
868
+ left: { noteId: string },
869
+ right: { noteId: string },
870
+ ): number {
871
+ return Number.parseInt(left.noteId, 10) - Number.parseInt(right.noteId, 10);
872
+ }
873
+
874
+ function toSurfaceTabStop(
875
+ tabStop: NonNullable<ParagraphNode["tabStops"]>[number],
876
+ ): { pos: number; val?: string; leader?: string } {
877
+ return {
878
+ pos: tabStop.position,
879
+ ...(tabStop.align ? { val: tabStop.align } : {}),
880
+ ...(tabStop.leader ? { leader: tabStop.leader } : {}),
881
+ };
882
+ }
883
+
884
+ function describePreservedInlinePreview(
885
+ payloadReference: string,
886
+ ): { label: string; detail: string } | null {
887
+ if (/\b(?:w:)?bookmarkStart\b/u.test(payloadReference)) {
888
+ const name = /\bw:name="([^"]+)"/u.exec(payloadReference)?.[1];
889
+ return {
890
+ label: name ? `Bookmark · ${name}` : "Bookmark",
891
+ detail: name
892
+ ? `Bookmark anchor \"${name}\" is preserved as a read-only inline token for export safety.`
893
+ : "Bookmark anchor is preserved as a read-only inline token for export safety.",
894
+ };
895
+ }
896
+
897
+ if (/\b(?:w:)?bookmarkEnd\b/u.test(payloadReference)) {
898
+ return {
899
+ label: "Bookmark end",
900
+ detail: "Bookmark end marker is preserved as a read-only inline token for export safety.",
901
+ };
902
+ }
903
+
904
+ if (/\b(?:w:)?fldSimple\b|\b(?:w:)?fldChar\b|\b(?:w:)?instrText\b/u.test(payloadReference)) {
905
+ const simpleInstruction = /\bw:instr="([^"]*)"/u.exec(payloadReference)?.[1];
906
+ const complexInstruction = [...payloadReference.matchAll(/<(?:\w+:)?instrText\b[^>]*>([\s\S]*?)<\/(?:\w+:)?instrText>/gu)]
907
+ .map((match) => decodeXmlEntities(match[1] ?? ""))
908
+ .join("")
909
+ .trim();
910
+ const instruction = (simpleInstruction ?? complexInstruction ?? "").trim();
911
+ return {
912
+ label: "Field",
913
+ detail:
914
+ instruction.length > 0
915
+ ? `Read-only field preserved for export safety. Instruction: ${instruction}.`
916
+ : "Read-only field preserved for export safety.",
917
+ };
918
+ }
919
+
920
+ return null;
921
+ }
922
+
923
+ function decodeXmlEntities(text: string): string {
924
+ return text
925
+ .replace(/&lt;/g, "<")
926
+ .replace(/&gt;/g, ">")
927
+ .replace(/&quot;/g, "\"")
928
+ .replace(/&apos;/g, "'")
929
+ .replace(/&amp;/g, "&");
930
+ }
931
+
653
932
  function cloneMarks(marks: TextMark[]): {
654
933
  marks: SurfaceTextMark[];
655
934
  markAttrs?: {
@@ -144,16 +144,26 @@ export function __createWordReviewEditorRefBridge(
144
144
  reopenComment: (commentId) => runtime.reopenComment(commentId),
145
145
  addCommentReply: (commentId, body) => runtime.addCommentReply(commentId, body),
146
146
  editCommentBody: (commentId, body) => runtime.editCommentBody(commentId, body),
147
+ deleteComment: (commentId) => {
148
+ applyRuntimeDeleteComment(runtime, commentId);
149
+ },
147
150
  acceptChange: (changeId) => runtime.acceptChange(changeId),
148
151
  rejectChange: (changeId) => runtime.rejectChange(changeId),
149
152
  acceptAllChanges: () => runtime.acceptAllChanges(),
150
153
  rejectAllChanges: () => runtime.rejectAllChanges(),
151
154
  exportDocx: (options) => runtime.exportDocx(options),
152
155
  getSnapshot: () => runtime.getPersistedSnapshot(),
156
+ getRenderSnapshot: () => clonePublicValue(runtime.getRenderSnapshot()),
153
157
  getCompatibilityReport: () => runtime.getCompatibilityReport(),
154
158
  getWarnings: () => runtime.getWarnings(),
155
- getComments: () => runtime.getRenderSnapshot().comments,
156
- getTrackedChanges: () => runtime.getRenderSnapshot().trackedChanges,
159
+ getCommentSidebarSnapshot: () =>
160
+ clonePublicValue(runtime.getRenderSnapshot().comments),
161
+ getTrackedChangesSnapshot: () =>
162
+ clonePublicValue(runtime.getRenderSnapshot().trackedChanges),
163
+ getComments: () => clonePublicValue(runtime.getRenderSnapshot().comments),
164
+ getTrackedChanges: () =>
165
+ clonePublicValue(runtime.getRenderSnapshot().trackedChanges),
166
+ isDirty: () => runtime.getRenderSnapshot().isDirty,
157
167
  getFormattingState: () => getFormattingStateFromRenderSnapshot(runtime.getRenderSnapshot()),
158
168
  toggleBold: () => {
159
169
  applyRuntimeFormattingOperation(runtime, { type: "toggle", mark: "bold" });
@@ -275,6 +285,14 @@ export function __createWordReviewEditorRefBridge(
275
285
  clearSearch: () => {
276
286
  mountedSurface?.clearSearch();
277
287
  },
288
+ setSelection: (selection) => {
289
+ runtime.dispatch({
290
+ type: "selection.set",
291
+ selection: toRuntimeSelectionSnapshot(
292
+ normalizeRequestedSelection(runtime.getRenderSnapshot(), selection),
293
+ ),
294
+ });
295
+ },
278
296
  scrollToRevision: (revisionId: string) => {
279
297
  const revision = runtime.getRenderSnapshot().trackedChanges.revisions.find(
280
298
  (r) => r.revisionId === revisionId,
@@ -671,6 +689,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
671
689
  activeRuntime.addCommentReply(commentId, body, currentUser.userId),
672
690
  editCommentBody: (commentId, body) =>
673
691
  activeRuntime.editCommentBody(commentId, body),
692
+ deleteComment: (commentId) => {
693
+ applyRuntimeDeleteComment(activeRuntime, commentId);
694
+ },
674
695
  acceptChange: (changeId) => activeRuntime.acceptChange(changeId),
675
696
  rejectChange: (changeId) => activeRuntime.rejectChange(changeId),
676
697
  acceptAllChanges: () => activeRuntime.acceptAllChanges(),
@@ -694,10 +715,18 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
694
715
  onEvent: onEventRef.current,
695
716
  }),
696
717
  getSnapshot: () => activeRuntime.getPersistedSnapshot(),
718
+ getRenderSnapshot: () => clonePublicValue(activeRuntime.getRenderSnapshot()),
697
719
  getCompatibilityReport: () => activeRuntime.getCompatibilityReport(),
698
720
  getWarnings: () => activeRuntime.getWarnings(),
699
- getComments: () => activeRuntime.getRenderSnapshot().comments,
700
- getTrackedChanges: () => activeRuntime.getRenderSnapshot().trackedChanges,
721
+ getCommentSidebarSnapshot: () =>
722
+ clonePublicValue(activeRuntime.getRenderSnapshot().comments),
723
+ getTrackedChangesSnapshot: () =>
724
+ clonePublicValue(activeRuntime.getRenderSnapshot().trackedChanges),
725
+ getComments: () =>
726
+ clonePublicValue(activeRuntime.getRenderSnapshot().comments),
727
+ getTrackedChanges: () =>
728
+ clonePublicValue(activeRuntime.getRenderSnapshot().trackedChanges),
729
+ isDirty: () => activeRuntime.getRenderSnapshot().isDirty,
701
730
  getFormattingState: () =>
702
731
  getFormattingStateFromRenderSnapshot(activeRuntime.getRenderSnapshot()),
703
732
  toggleBold: () => {
@@ -838,6 +867,14 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
838
867
  clearSearch: () => {
839
868
  surfaceRef.current?.clearSearch();
840
869
  },
870
+ setSelection: (selection) => {
871
+ activeRuntime.dispatch({
872
+ type: "selection.set",
873
+ selection: toRuntimeSelectionSnapshot(
874
+ normalizeRequestedSelection(activeRuntime.getRenderSnapshot(), selection),
875
+ ),
876
+ });
877
+ },
841
878
  scrollToRevision: (revisionId: string) => {
842
879
  const revision = activeRuntime.getRenderSnapshot().trackedChanges.revisions.find(
843
880
  (r) => r.revisionId === revisionId,
@@ -958,12 +995,16 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
958
995
  }
959
996
 
960
997
  function addReviewComment(): void {
961
- activeRuntime.addComment({
962
- anchor: snapshot.selection.activeRange,
963
- body: "New review comment",
964
- authorId: currentUser.userId,
965
- });
966
- setActiveRailTab("comments");
998
+ try {
999
+ activeRuntime.addComment({
1000
+ anchor: snapshot.selection.activeRange,
1001
+ body: "New review comment",
1002
+ authorId: currentUser.userId,
1003
+ });
1004
+ setActiveRailTab("comments");
1005
+ } catch {
1006
+ // Runtime already emitted a concrete export-safety error for invalid anchors.
1007
+ }
967
1008
  }
968
1009
 
969
1010
  function exportCurrentDocument(): void {
@@ -991,6 +1032,10 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
991
1032
  ? derivedCapabilities
992
1033
  : { ...derivedCapabilities, reviewRailVisible: false };
993
1034
  const diagnosticsModeMessage = getDiagnosticsModeMessage(loadError ?? snapshot.fatalError);
1035
+ const addCommentDisabledReason =
1036
+ !capabilities.canAddComment && !snapshot.selection.isCollapsed
1037
+ ? "Select text within one paragraph to add a DOCX comment."
1038
+ : undefined;
994
1039
  const accessibilityInstructionsId = `${documentId}-accessibility-instructions`;
995
1040
  const accessibilityStatusId = `${documentId}-accessibility-status`;
996
1041
  const accessibilityAlertId = `${documentId}-accessibility-alert`;
@@ -1147,6 +1192,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1147
1192
  activeRevisionId={activeRevisionId}
1148
1193
  showTrackedChanges={showTrackedChanges}
1149
1194
  selectionPreview={selectionPreview}
1195
+ addCommentDisabledReason={addCommentDisabledReason}
1150
1196
  onViewModeChange={setViewMode}
1151
1197
  onActiveRailTabChange={setActiveRailTab}
1152
1198
  onShowTrackedChangesChange={setShowTrackedChanges}
@@ -1345,6 +1391,70 @@ function dispatchRuntimeDocumentMutation(
1345
1391
  });
1346
1392
  }
1347
1393
 
1394
+ function applyRuntimeDeleteComment(
1395
+ runtime: WordReviewEditorRuntime,
1396
+ commentId: string,
1397
+ ): void {
1398
+ const snapshot = runtime.getRenderSnapshot();
1399
+ if (!snapshot.isReady || snapshot.readOnly || snapshot.fatalError) {
1400
+ return;
1401
+ }
1402
+
1403
+ const persistedSnapshot = runtime.getPersistedSnapshot();
1404
+ if (!persistedSnapshot.canonicalDocument.review.comments[commentId]) {
1405
+ return;
1406
+ }
1407
+
1408
+ const nextComments = {
1409
+ ...persistedSnapshot.canonicalDocument.review.comments,
1410
+ };
1411
+ delete nextComments[commentId];
1412
+
1413
+ runtime.dispatch({
1414
+ type: "document.replace",
1415
+ document: {
1416
+ ...persistedSnapshot.canonicalDocument,
1417
+ review: {
1418
+ ...persistedSnapshot.canonicalDocument.review,
1419
+ comments: nextComments,
1420
+ },
1421
+ },
1422
+ selection: toRuntimeSelectionSnapshot(snapshot.selection),
1423
+ origin: {
1424
+ source: "api",
1425
+ timestamp: new Date().toISOString(),
1426
+ },
1427
+ });
1428
+ }
1429
+
1430
+ function normalizeRequestedSelection(
1431
+ snapshot: RuntimeRenderSnapshot,
1432
+ selection: PublicSelectionSnapshot | null,
1433
+ ): PublicSelectionSnapshot {
1434
+ return selection ?? createCollapsedPublicSelection(snapshot.selection.head);
1435
+ }
1436
+
1437
+ function createCollapsedPublicSelection(position: number): PublicSelectionSnapshot {
1438
+ return {
1439
+ anchor: position,
1440
+ head: position,
1441
+ isCollapsed: true,
1442
+ activeRange: {
1443
+ kind: "range",
1444
+ from: position,
1445
+ to: position,
1446
+ assoc: {
1447
+ start: -1,
1448
+ end: 1,
1449
+ },
1450
+ },
1451
+ };
1452
+ }
1453
+
1454
+ function clonePublicValue<T>(value: T): T {
1455
+ return structuredClone(value);
1456
+ }
1457
+
1348
1458
  function searchSnapshotSurface(
1349
1459
  snapshot: RuntimeRenderSnapshot,
1350
1460
  query: string,
@@ -5,6 +5,8 @@ import { MessageSquare } from "lucide-react";
5
5
  export interface TwSelectionToolbarProps {
6
6
  selectionPreview: string;
7
7
  readOnly: boolean;
8
+ canAddComment?: boolean;
9
+ disabledReason?: string;
8
10
  onAddComment?: () => void;
9
11
  }
10
12
 
@@ -12,6 +14,10 @@ const focusRingClass =
12
14
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas";
13
15
 
14
16
  export function TwSelectionToolbar(props: TwSelectionToolbarProps) {
17
+ const addCommentDisabled = props.readOnly || props.canAddComment === false;
18
+ const tooltipLabel = addCommentDisabled
19
+ ? props.disabledReason ?? "Select text within one paragraph to add a DOCX comment"
20
+ : "Add comment";
15
21
  return (
16
22
  <div className="mb-6 inline-flex items-center gap-1 rounded-lg bg-canvas shadow-lg ring-1 ring-border p-1">
17
23
  <Tooltip.Root>
@@ -19,7 +25,7 @@ export function TwSelectionToolbar(props: TwSelectionToolbarProps) {
19
25
  <button
20
26
  type="button"
21
27
  aria-label="Comment"
22
- disabled={props.readOnly}
28
+ disabled={addCommentDisabled}
23
29
  onClick={props.onAddComment}
24
30
  className={`inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors text-accent hover:bg-accent-soft disabled:opacity-30 disabled:cursor-not-allowed ${focusRingClass}`}
25
31
  >
@@ -31,7 +37,7 @@ export function TwSelectionToolbar(props: TwSelectionToolbarProps) {
31
37
  className="rounded-md bg-primary px-2 py-1 text-xs text-white shadow-md z-50"
32
38
  sideOffset={6}
33
39
  >
34
- Add comment
40
+ {tooltipLabel}
35
41
  </Tooltip.Content>
36
42
  </Tooltip.Portal>
37
43
  </Tooltip.Root>