@beyondwork/docx-react-component 1.0.12 → 1.0.14

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.
@@ -14,9 +14,14 @@ import type {
14
14
  CompatibilityReport,
15
15
  EditorError,
16
16
  EditorWarning,
17
+ FormattingAlignment,
17
18
  ExportDocxOptions,
19
+ InsertImageOptions,
20
+ InsertTableOptions,
18
21
  PersistedEditorSnapshot,
19
22
  RuntimeRenderSnapshot,
23
+ SearchOptions,
24
+ SearchResultSnapshot,
20
25
  SelectionSnapshot as PublicSelectionSnapshot,
21
26
  ExportResult,
22
27
  WordReviewEditorEvent,
@@ -27,8 +32,24 @@ import {
27
32
  createDetachedAnchor,
28
33
  createNodeAnchor,
29
34
  createRangeAnchor,
35
+ type TransactionMapping,
30
36
  } from "../core/selection/mapping.ts";
31
- import { createCanonicalDocumentId } from "../core/state/editor-state.ts";
37
+ import {
38
+ applyFormattingOperationToDocument,
39
+ getFormattingStateFromRenderSnapshot,
40
+ } from "../core/commands/formatting-commands.ts";
41
+ import { insertImage as insertImageInDocument } from "../core/commands/image-commands.ts";
42
+ import {
43
+ applyTableStructureOperation,
44
+ } from "../core/commands/table-structure-commands.ts";
45
+ import {
46
+ insertPageBreak as insertPageBreakInDocument,
47
+ insertTable as insertTableInDocument,
48
+ } from "../core/commands/text-commands.ts";
49
+ import {
50
+ createCanonicalDocumentId,
51
+ type SelectionSnapshot as InternalSelectionSnapshot,
52
+ } from "../core/state/editor-state.ts";
32
53
  import {
33
54
  createDocumentRuntime,
34
55
  type DocumentRuntime,
@@ -39,7 +60,14 @@ import {
39
60
  hasValidPersistedSourcePackageDigest,
40
61
  } from "../io/source-package-provenance.ts";
41
62
  import { deriveCapabilities } from "../runtime/session-capabilities";
42
- import { TwProseMirrorSurface } from "../ui-tailwind/editor-surface/tw-prosemirror-surface";
63
+ import {
64
+ createSearchExcerpt,
65
+ findSearchMatches,
66
+ } from "../ui-tailwind/editor-surface/search-plugin";
67
+ import {
68
+ TwProseMirrorSurface,
69
+ type TwProseMirrorSurfaceRef,
70
+ } from "../ui-tailwind/editor-surface/tw-prosemirror-surface";
43
71
  import { TwReviewWorkspace } from "../ui-tailwind/tw-review-workspace";
44
72
  import type { ReviewRailTab } from "../ui-tailwind/review/tw-review-rail";
45
73
  import type { ViewMode } from "../ui-tailwind/toolbar/tw-toolbar";
@@ -103,6 +131,7 @@ type AccessibleRegionId = (typeof ACCESSIBLE_REGION_ORDER)[number];
103
131
 
104
132
  export function __createWordReviewEditorRefBridge(
105
133
  runtime: WordReviewEditorRuntime,
134
+ mountedSurface?: TwProseMirrorSurfaceRef | null,
106
135
  ): WordReviewEditorRef {
107
136
  return {
108
137
  focus: () => runtime.focus(),
@@ -125,6 +154,127 @@ export function __createWordReviewEditorRefBridge(
125
154
  getWarnings: () => runtime.getWarnings(),
126
155
  getComments: () => runtime.getRenderSnapshot().comments,
127
156
  getTrackedChanges: () => runtime.getRenderSnapshot().trackedChanges,
157
+ getFormattingState: () => getFormattingStateFromRenderSnapshot(runtime.getRenderSnapshot()),
158
+ toggleBold: () => {
159
+ applyRuntimeFormattingOperation(runtime, { type: "toggle", mark: "bold" });
160
+ },
161
+ toggleItalic: () => {
162
+ applyRuntimeFormattingOperation(runtime, { type: "toggle", mark: "italic" });
163
+ },
164
+ toggleUnderline: () => {
165
+ applyRuntimeFormattingOperation(runtime, { type: "toggle", mark: "underline" });
166
+ },
167
+ toggleStrikethrough: () => {
168
+ applyRuntimeFormattingOperation(runtime, {
169
+ type: "toggle",
170
+ mark: "strikethrough",
171
+ });
172
+ },
173
+ toggleSuperscript: () => {
174
+ applyRuntimeFormattingOperation(runtime, {
175
+ type: "toggle",
176
+ mark: "superscript",
177
+ });
178
+ },
179
+ toggleSubscript: () => {
180
+ applyRuntimeFormattingOperation(runtime, { type: "toggle", mark: "subscript" });
181
+ },
182
+ setFontFamily: (fontFamily) => {
183
+ applyRuntimeFormattingOperation(runtime, {
184
+ type: "set-font-family",
185
+ fontFamily,
186
+ });
187
+ },
188
+ setFontSize: (size) => {
189
+ applyRuntimeFormattingOperation(runtime, { type: "set-font-size", size });
190
+ },
191
+ setTextColor: (color) => {
192
+ applyRuntimeFormattingOperation(runtime, { type: "set-text-color", color });
193
+ },
194
+ setHighlightColor: (color) => {
195
+ applyRuntimeFormattingOperation(runtime, {
196
+ type: "set-highlight-color",
197
+ color,
198
+ });
199
+ },
200
+ setAlignment: (alignment) => {
201
+ applyRuntimeFormattingOperation(runtime, {
202
+ type: "set-alignment",
203
+ alignment,
204
+ });
205
+ },
206
+ indent: () => {
207
+ applyRuntimeFormattingOperation(runtime, { type: "indent" });
208
+ },
209
+ outdent: () => {
210
+ applyRuntimeFormattingOperation(runtime, { type: "outdent" });
211
+ },
212
+ insertPageBreak: () => {
213
+ applyRuntimeInsertPageBreak(runtime);
214
+ },
215
+ insertTable: (options) => {
216
+ applyRuntimeInsertTable(runtime, options);
217
+ },
218
+ insertImage: (options) => {
219
+ applyRuntimeInsertImage(runtime, options);
220
+ },
221
+ addRowBefore: () => {
222
+ applyRuntimeTableStructureOperation(runtime, mountedSurface ?? null, {
223
+ type: "add-row-before",
224
+ });
225
+ },
226
+ addRowAfter: () => {
227
+ applyRuntimeTableStructureOperation(runtime, mountedSurface ?? null, {
228
+ type: "add-row-after",
229
+ });
230
+ },
231
+ addColumnBefore: () => {
232
+ applyRuntimeTableStructureOperation(runtime, mountedSurface ?? null, {
233
+ type: "add-column-before",
234
+ });
235
+ },
236
+ addColumnAfter: () => {
237
+ applyRuntimeTableStructureOperation(runtime, mountedSurface ?? null, {
238
+ type: "add-column-after",
239
+ });
240
+ },
241
+ deleteRow: () => {
242
+ applyRuntimeTableStructureOperation(runtime, mountedSurface ?? null, {
243
+ type: "delete-row",
244
+ });
245
+ },
246
+ deleteColumn: () => {
247
+ applyRuntimeTableStructureOperation(runtime, mountedSurface ?? null, {
248
+ type: "delete-column",
249
+ });
250
+ },
251
+ deleteTable: () => {
252
+ applyRuntimeTableStructureOperation(runtime, mountedSurface ?? null, {
253
+ type: "delete-table",
254
+ });
255
+ },
256
+ mergeCells: () => {
257
+ applyRuntimeTableStructureOperation(runtime, mountedSurface ?? null, {
258
+ type: "merge-cells",
259
+ });
260
+ },
261
+ splitCell: () => {
262
+ applyRuntimeTableStructureOperation(runtime, mountedSurface ?? null, {
263
+ type: "split-cell",
264
+ });
265
+ },
266
+ setCellBackground: (color) => {
267
+ applyRuntimeTableStructureOperation(runtime, mountedSurface ?? null, {
268
+ type: "set-cell-background",
269
+ color,
270
+ });
271
+ },
272
+ search: (query, options) =>
273
+ mountedSurface?.search(query, options) ??
274
+ searchSnapshotSurface(runtime.getRenderSnapshot(), query, options),
275
+ clearSearch: () => {
276
+ mountedSurface?.clearSearch();
277
+ },
128
278
  scrollToRevision: (revisionId: string) => {
129
279
  const revision = runtime.getRenderSnapshot().trackedChanges.revisions.find(
130
280
  (r) => r.revisionId === revisionId,
@@ -312,6 +462,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
312
462
  const [showTrackedChanges, setShowTrackedChanges] = useState(false);
313
463
  const [activeRevisionId, setActiveRevisionId] = useState<string | undefined>();
314
464
  const runtimeRef = useRef<WordReviewEditorRuntime | null>(null);
465
+ const surfaceRef = useRef<TwProseMirrorSurfaceRef | null>(null);
315
466
  const shellRef = useRef<HTMLDivElement | null>(null);
316
467
  const lastAnnouncedErrorIdRef = useRef<string | null>(null);
317
468
  const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -547,6 +698,146 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
547
698
  getWarnings: () => activeRuntime.getWarnings(),
548
699
  getComments: () => activeRuntime.getRenderSnapshot().comments,
549
700
  getTrackedChanges: () => activeRuntime.getRenderSnapshot().trackedChanges,
701
+ getFormattingState: () =>
702
+ getFormattingStateFromRenderSnapshot(activeRuntime.getRenderSnapshot()),
703
+ toggleBold: () => {
704
+ applyRuntimeFormattingOperation(activeRuntime, {
705
+ type: "toggle",
706
+ mark: "bold",
707
+ });
708
+ },
709
+ toggleItalic: () => {
710
+ applyRuntimeFormattingOperation(activeRuntime, {
711
+ type: "toggle",
712
+ mark: "italic",
713
+ });
714
+ },
715
+ toggleUnderline: () => {
716
+ applyRuntimeFormattingOperation(activeRuntime, {
717
+ type: "toggle",
718
+ mark: "underline",
719
+ });
720
+ },
721
+ toggleStrikethrough: () => {
722
+ applyRuntimeFormattingOperation(activeRuntime, {
723
+ type: "toggle",
724
+ mark: "strikethrough",
725
+ });
726
+ },
727
+ toggleSuperscript: () => {
728
+ applyRuntimeFormattingOperation(activeRuntime, {
729
+ type: "toggle",
730
+ mark: "superscript",
731
+ });
732
+ },
733
+ toggleSubscript: () => {
734
+ applyRuntimeFormattingOperation(activeRuntime, {
735
+ type: "toggle",
736
+ mark: "subscript",
737
+ });
738
+ },
739
+ setFontFamily: (fontFamily) => {
740
+ applyRuntimeFormattingOperation(activeRuntime, {
741
+ type: "set-font-family",
742
+ fontFamily,
743
+ });
744
+ },
745
+ setFontSize: (size) => {
746
+ applyRuntimeFormattingOperation(activeRuntime, {
747
+ type: "set-font-size",
748
+ size,
749
+ });
750
+ },
751
+ setTextColor: (color) => {
752
+ applyRuntimeFormattingOperation(activeRuntime, {
753
+ type: "set-text-color",
754
+ color,
755
+ });
756
+ },
757
+ setHighlightColor: (color) => {
758
+ applyRuntimeFormattingOperation(activeRuntime, {
759
+ type: "set-highlight-color",
760
+ color,
761
+ });
762
+ },
763
+ setAlignment: (alignment) => {
764
+ applyRuntimeFormattingOperation(activeRuntime, {
765
+ type: "set-alignment",
766
+ alignment,
767
+ });
768
+ },
769
+ indent: () => {
770
+ applyRuntimeFormattingOperation(activeRuntime, { type: "indent" });
771
+ },
772
+ outdent: () => {
773
+ applyRuntimeFormattingOperation(activeRuntime, { type: "outdent" });
774
+ },
775
+ insertPageBreak: () => {
776
+ applyRuntimeInsertPageBreak(activeRuntime);
777
+ },
778
+ insertTable: (options) => {
779
+ applyRuntimeInsertTable(activeRuntime, options);
780
+ },
781
+ insertImage: (options) => {
782
+ applyRuntimeInsertImage(activeRuntime, options);
783
+ },
784
+ addRowBefore: () => {
785
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current, {
786
+ type: "add-row-before",
787
+ });
788
+ },
789
+ addRowAfter: () => {
790
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current, {
791
+ type: "add-row-after",
792
+ });
793
+ },
794
+ addColumnBefore: () => {
795
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current, {
796
+ type: "add-column-before",
797
+ });
798
+ },
799
+ addColumnAfter: () => {
800
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current, {
801
+ type: "add-column-after",
802
+ });
803
+ },
804
+ deleteRow: () => {
805
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current, {
806
+ type: "delete-row",
807
+ });
808
+ },
809
+ deleteColumn: () => {
810
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current, {
811
+ type: "delete-column",
812
+ });
813
+ },
814
+ deleteTable: () => {
815
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current, {
816
+ type: "delete-table",
817
+ });
818
+ },
819
+ mergeCells: () => {
820
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current, {
821
+ type: "merge-cells",
822
+ });
823
+ },
824
+ splitCell: () => {
825
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current, {
826
+ type: "split-cell",
827
+ });
828
+ },
829
+ setCellBackground: (color) => {
830
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current, {
831
+ type: "set-cell-background",
832
+ color,
833
+ });
834
+ },
835
+ search: (query, options) =>
836
+ surfaceRef.current?.search(query, options) ??
837
+ searchSnapshotSurface(activeRuntime.getRenderSnapshot(), query, options),
838
+ clearSearch: () => {
839
+ surfaceRef.current?.clearSearch();
840
+ },
550
841
  scrollToRevision: (revisionId: string) => {
551
842
  const revision = activeRuntime.getRenderSnapshot().trackedChanges.revisions.find(
552
843
  (r) => r.revisionId === revisionId,
@@ -667,12 +958,16 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
667
958
  }
668
959
 
669
960
  function addReviewComment(): void {
670
- activeRuntime.addComment({
671
- anchor: snapshot.selection.activeRange,
672
- body: "New review comment",
673
- authorId: currentUser.userId,
674
- });
675
- setActiveRailTab("comments");
961
+ try {
962
+ activeRuntime.addComment({
963
+ anchor: snapshot.selection.activeRange,
964
+ body: "New review comment",
965
+ authorId: currentUser.userId,
966
+ });
967
+ setActiveRailTab("comments");
968
+ } catch {
969
+ // Runtime already emitted a concrete export-safety error for invalid anchors.
970
+ }
676
971
  }
677
972
 
678
973
  function exportCurrentDocument(): void {
@@ -700,6 +995,10 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
700
995
  ? derivedCapabilities
701
996
  : { ...derivedCapabilities, reviewRailVisible: false };
702
997
  const diagnosticsModeMessage = getDiagnosticsModeMessage(loadError ?? snapshot.fatalError);
998
+ const addCommentDisabledReason =
999
+ !capabilities.canAddComment && !snapshot.selection.isCollapsed
1000
+ ? "Select text within one paragraph to add a DOCX comment."
1001
+ : undefined;
703
1002
  const accessibilityInstructionsId = `${documentId}-accessibility-instructions`;
704
1003
  const accessibilityStatusId = `${documentId}-accessibility-status`;
705
1004
  const accessibilityAlertId = `${documentId}-accessibility-alert`;
@@ -856,12 +1155,14 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
856
1155
  activeRevisionId={activeRevisionId}
857
1156
  showTrackedChanges={showTrackedChanges}
858
1157
  selectionPreview={selectionPreview}
1158
+ addCommentDisabledReason={addCommentDisabledReason}
859
1159
  onViewModeChange={setViewMode}
860
1160
  onActiveRailTabChange={setActiveRailTab}
861
1161
  onShowTrackedChangesChange={setShowTrackedChanges}
862
1162
  {...reviewCallbacks}
863
1163
  document={
864
1164
  <TwProseMirrorSurface
1165
+ ref={surfaceRef}
865
1166
  currentUser={currentUser}
866
1167
  snapshot={snapshot}
867
1168
  reviewMode={reviewMode}
@@ -885,6 +1186,231 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
885
1186
  },
886
1187
  );
887
1188
 
1189
+ function applyRuntimeFormattingOperation(
1190
+ runtime: WordReviewEditorRuntime,
1191
+ operation:
1192
+ | { type: "toggle"; mark: "bold" | "italic" | "underline" | "strikethrough" | "superscript" | "subscript" }
1193
+ | { type: "set-font-family"; fontFamily: string | null }
1194
+ | { type: "set-font-size"; size: number | null }
1195
+ | { type: "set-text-color"; color: string | null }
1196
+ | { type: "set-highlight-color"; color: string | null }
1197
+ | { type: "set-alignment"; alignment: FormattingAlignment }
1198
+ | { type: "indent" }
1199
+ | { type: "outdent" },
1200
+ ): void {
1201
+ const snapshot = runtime.getRenderSnapshot();
1202
+ if (!snapshot.isReady || snapshot.readOnly || snapshot.fatalError) {
1203
+ return;
1204
+ }
1205
+
1206
+ const result = applyFormattingOperationToDocument(
1207
+ runtime.getPersistedSnapshot().canonicalDocument,
1208
+ snapshot,
1209
+ operation,
1210
+ );
1211
+ if (!result.changed) {
1212
+ return;
1213
+ }
1214
+
1215
+ runtime.dispatch({
1216
+ type: "document.replace",
1217
+ document: result.document,
1218
+ selection: toRuntimeSelectionSnapshot(result.selection),
1219
+ origin: {
1220
+ source: "api",
1221
+ timestamp: new Date().toISOString(),
1222
+ },
1223
+ });
1224
+ }
1225
+
1226
+ function applyRuntimeInsertPageBreak(runtime: WordReviewEditorRuntime): void {
1227
+ const snapshot = runtime.getRenderSnapshot();
1228
+ if (!canApplyRuntimeMutation(snapshot)) {
1229
+ return;
1230
+ }
1231
+
1232
+ const timestamp = new Date().toISOString();
1233
+ const result = insertPageBreakInDocument(
1234
+ runtime.getPersistedSnapshot().canonicalDocument,
1235
+ toRuntimeSelectionSnapshot(snapshot.selection),
1236
+ { timestamp },
1237
+ );
1238
+ dispatchRuntimeDocumentMutation(runtime, result, timestamp);
1239
+ }
1240
+
1241
+ function applyRuntimeInsertTable(
1242
+ runtime: WordReviewEditorRuntime,
1243
+ options: InsertTableOptions,
1244
+ ): void {
1245
+ const snapshot = runtime.getRenderSnapshot();
1246
+ if (!canApplyRuntimeMutation(snapshot)) {
1247
+ return;
1248
+ }
1249
+
1250
+ const timestamp = new Date().toISOString();
1251
+ const result = insertTableInDocument(
1252
+ runtime.getPersistedSnapshot().canonicalDocument,
1253
+ toRuntimeSelectionSnapshot(snapshot.selection),
1254
+ options,
1255
+ { timestamp },
1256
+ );
1257
+ dispatchRuntimeDocumentMutation(runtime, result, timestamp);
1258
+ }
1259
+
1260
+ function applyRuntimeInsertImage(
1261
+ runtime: WordReviewEditorRuntime,
1262
+ options: InsertImageOptions,
1263
+ ): void {
1264
+ const snapshot = runtime.getRenderSnapshot();
1265
+ if (!canApplyRuntimeMutation(snapshot)) {
1266
+ return;
1267
+ }
1268
+
1269
+ const timestamp = new Date().toISOString();
1270
+ try {
1271
+ const result = insertImageInDocument(
1272
+ runtime.getPersistedSnapshot().canonicalDocument,
1273
+ toRuntimeSelectionSnapshot(snapshot.selection),
1274
+ options.data,
1275
+ options.mimeType,
1276
+ options.width,
1277
+ options.height,
1278
+ {
1279
+ timestamp,
1280
+ altText: options.altText,
1281
+ },
1282
+ );
1283
+ dispatchRuntimeDocumentMutation(runtime, {
1284
+ changed: true,
1285
+ document: result.document,
1286
+ selection: result.selection,
1287
+ mapping: result.mapping,
1288
+ }, timestamp);
1289
+ } catch {
1290
+ return;
1291
+ }
1292
+ }
1293
+
1294
+ function applyRuntimeTableStructureOperation(
1295
+ runtime: WordReviewEditorRuntime,
1296
+ mountedSurface: TwProseMirrorSurfaceRef | null | undefined,
1297
+ operation:
1298
+ | { type: "add-row-before" }
1299
+ | { type: "add-row-after" }
1300
+ | { type: "add-column-before" }
1301
+ | { type: "add-column-after" }
1302
+ | { type: "delete-row" }
1303
+ | { type: "delete-column" }
1304
+ | { type: "delete-table" }
1305
+ | { type: "merge-cells" }
1306
+ | { type: "split-cell" }
1307
+ | { type: "set-cell-background"; color: string },
1308
+ ): void {
1309
+ const snapshot = runtime.getRenderSnapshot();
1310
+ if (!canApplyRuntimeMutation(snapshot)) {
1311
+ return;
1312
+ }
1313
+
1314
+ const timestamp = new Date().toISOString();
1315
+ const result = applyTableStructureOperation(
1316
+ runtime.getPersistedSnapshot().canonicalDocument,
1317
+ snapshot,
1318
+ mountedSurface?.getTableSelection() ?? null,
1319
+ operation,
1320
+ );
1321
+ dispatchRuntimeDocumentMutation(runtime, result, timestamp);
1322
+ }
1323
+
1324
+ function canApplyRuntimeMutation(snapshot: RuntimeRenderSnapshot): boolean {
1325
+ return snapshot.isReady && !snapshot.readOnly && !snapshot.fatalError;
1326
+ }
1327
+
1328
+ function dispatchRuntimeDocumentMutation(
1329
+ runtime: WordReviewEditorRuntime,
1330
+ result: {
1331
+ changed: boolean;
1332
+ document: PersistedEditorSnapshot["canonicalDocument"];
1333
+ selection: InternalSelectionSnapshot;
1334
+ mapping?: TransactionMapping;
1335
+ },
1336
+ timestamp: string,
1337
+ ): void {
1338
+ if (!result.changed) {
1339
+ return;
1340
+ }
1341
+
1342
+ runtime.dispatch({
1343
+ type: "document.replace",
1344
+ document: {
1345
+ ...result.document,
1346
+ updatedAt: timestamp,
1347
+ },
1348
+ mapping: result.mapping,
1349
+ selection: result.selection,
1350
+ origin: {
1351
+ source: "api",
1352
+ timestamp,
1353
+ },
1354
+ });
1355
+ }
1356
+
1357
+ function searchSnapshotSurface(
1358
+ snapshot: RuntimeRenderSnapshot,
1359
+ query: string,
1360
+ options: SearchOptions = {},
1361
+ ): SearchResultSnapshot[] {
1362
+ const normalizedQuery = query.trim();
1363
+ if (!normalizedQuery || !snapshot.surface) {
1364
+ return [];
1365
+ }
1366
+
1367
+ const rawResults = findSearchMatches(
1368
+ snapshot.surface.plainText,
1369
+ normalizedQuery,
1370
+ options,
1371
+ ).slice(0, options.limit ?? Number.POSITIVE_INFINITY);
1372
+ const activeResultIndex = getActiveSearchResultIndex(rawResults, snapshot.selection);
1373
+
1374
+ return rawResults.map((result, index) => ({
1375
+ resultId: `search-result-${index}`,
1376
+ anchor: {
1377
+ kind: "range",
1378
+ from: result.from,
1379
+ to: result.to,
1380
+ assoc: {
1381
+ start: -1,
1382
+ end: 1,
1383
+ },
1384
+ },
1385
+ excerpt: createSearchExcerpt(
1386
+ snapshot.surface?.plainText ?? "",
1387
+ result.from,
1388
+ result.to,
1389
+ ),
1390
+ isActive: index === activeResultIndex,
1391
+ }));
1392
+ }
1393
+
1394
+ function getActiveSearchResultIndex(
1395
+ results: Array<{ from: number; to: number }>,
1396
+ selection: PublicSelectionSnapshot,
1397
+ ): number {
1398
+ if (results.length === 0) {
1399
+ return -1;
1400
+ }
1401
+
1402
+ const selectionFrom = Math.min(selection.anchor, selection.head);
1403
+ const selectionTo = Math.max(selection.anchor, selection.head);
1404
+ const activeIndex = results.findIndex((result) => {
1405
+ if (selectionFrom === selectionTo) {
1406
+ return selectionFrom >= result.from && selectionFrom <= result.to;
1407
+ }
1408
+ return selectionFrom < result.to && selectionTo > result.from;
1409
+ });
1410
+
1411
+ return activeIndex >= 0 ? activeIndex : 0;
1412
+ }
1413
+
888
1414
  function applyRegionAttributes(shell: HTMLElement): void {
889
1415
  const toolbar = shell.querySelector<HTMLElement>("header");
890
1416
  if (toolbar) {
@@ -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>
@@ -1,5 +1,5 @@
1
1
  import { Fragment, type Node as PMNode } from "prosemirror-model";
2
- import { EditorState, type Plugin, TextSelection } from "prosemirror-state";
2
+ import { EditorState, type Plugin, Selection, TextSelection } from "prosemirror-state";
3
3
 
4
4
  import type {
5
5
  EditorSurfaceSnapshot,
@@ -43,12 +43,13 @@ export function createPMStateFromSnapshot(
43
43
  positionMap.pmDocSize - 1,
44
44
  );
45
45
 
46
- let pmSelection: TextSelection;
46
+ let pmSelection: Selection;
47
47
  try {
48
- pmSelection = TextSelection.create(doc, pmAnchor, pmHead);
48
+ pmSelection = TextSelection.between(doc.resolve(pmAnchor), doc.resolve(pmHead));
49
49
  } catch {
50
- // If the position is invalid (e.g., inside an atom), fall back to start
51
- pmSelection = TextSelection.create(doc, 1);
50
+ // If the mapped runtime selection is invalid or lands in a non-text block,
51
+ // let ProseMirror choose the nearest valid starting selection.
52
+ pmSelection = Selection.atStart(doc);
52
53
  }
53
54
 
54
55
  const state = EditorState.create({
@@ -258,6 +259,7 @@ function buildTable(
258
259
  rowspan: cell.rowspan,
259
260
  gridSpan: cell.gridSpan,
260
261
  verticalMerge: cell.verticalMerge,
262
+ backgroundColor: cell.backgroundColor ?? null,
261
263
  },
262
264
  Fragment.from(cellContent),
263
265
  ),