@beyondwork/docx-react-component 1.0.24 → 1.0.26-rc

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.24",
4
+ "version": "1.0.26rc",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -1398,6 +1398,7 @@ export interface WordReviewEditorProps {
1398
1398
  loadSourcePolicy?: LoadSourcePolicy;
1399
1399
  readOnly?: boolean;
1400
1400
  reviewMode?: "editing" | "review";
1401
+ suggestionsEnabled?: boolean;
1401
1402
  markupDisplay?: "clean" | "simple" | "all";
1402
1403
  showReviewPanel?: boolean;
1403
1404
  chromeVisibility?: Partial<WordReviewEditorChromeVisibility>;
@@ -1309,7 +1309,7 @@ function applySuggestingInsert(
1309
1309
  }
1310
1310
 
1311
1311
  if (isCollapsed) {
1312
- // Pure insertion at cursor: apply normally, then create insertion revision
1312
+ // Pure insertion at cursor: apply normally, then create or extend an insertion revision.
1313
1313
  const result = insertText(state.document, selection, text, { timestamp: context.timestamp });
1314
1314
  const insertedFrom = from;
1315
1315
  const insertedTo = from + Array.from(text).length;
@@ -1321,25 +1321,53 @@ function applySuggestingInsert(
1321
1321
  result.mapping,
1322
1322
  );
1323
1323
 
1324
- // Create the revision with pre-mapping positions it refers to content
1325
- // that was just inserted, so its anchors are already correct in the new document
1326
- const revision = createAuthoredRevision(
1324
+ // If there is an authored insertion revision that ends exactly where this
1325
+ // text was inserted (i.e. the cursor was at the end of a prior insertion),
1326
+ // extend that revision to cover the new text rather than creating a new one.
1327
+ // This groups consecutive keystrokes into a single tracked change.
1328
+ const adjacentInsertion = findAdjacentAuthoredInsertion(
1327
1329
  reviewState.document.review.revisions,
1328
- "insertion",
1329
1330
  insertedFrom,
1330
- insertedTo,
1331
- authorId,
1332
- context.timestamp,
1333
1331
  );
1334
1332
 
1333
+ let finalRevisionId: string;
1334
+ let finalRevisions: Record<string, CanonicalRevisionRecord>;
1335
+
1336
+ if (adjacentInsertion && adjacentInsertion.anchor.kind === "range") {
1337
+ const extended: CanonicalRevisionRecord = {
1338
+ ...adjacentInsertion,
1339
+ anchor: {
1340
+ kind: "range",
1341
+ range: { from: adjacentInsertion.anchor.range.from, to: insertedTo },
1342
+ assoc: { start: 1, end: -1 },
1343
+ },
1344
+ };
1345
+ finalRevisionId = extended.changeId;
1346
+ finalRevisions = {
1347
+ ...reviewState.document.review.revisions,
1348
+ [extended.changeId]: extended,
1349
+ };
1350
+ } else {
1351
+ const revision = createAuthoredRevision(
1352
+ reviewState.document.review.revisions,
1353
+ "insertion",
1354
+ insertedFrom,
1355
+ insertedTo,
1356
+ authorId,
1357
+ context.timestamp,
1358
+ );
1359
+ finalRevisionId = revision.changeId;
1360
+ finalRevisions = {
1361
+ ...reviewState.document.review.revisions,
1362
+ [revision.changeId]: revision,
1363
+ };
1364
+ }
1365
+
1335
1366
  const finalDocument: CanonicalDocumentEnvelope = {
1336
1367
  ...reviewState.document,
1337
1368
  review: {
1338
1369
  ...reviewState.document.review,
1339
- revisions: {
1340
- ...reviewState.document.review.revisions,
1341
- [revision.changeId]: revision,
1342
- },
1370
+ revisions: finalRevisions,
1343
1371
  },
1344
1372
  };
1345
1373
 
@@ -1360,7 +1388,7 @@ function applySuggestingInsert(
1360
1388
  mapping: result.mapping,
1361
1389
  effects: {
1362
1390
  ...reviewState.effects,
1363
- revisionAuthored: { changeId: revision.changeId, kind: "insertion" },
1391
+ revisionAuthored: { changeId: finalRevisionId, kind: "insertion" },
1364
1392
  },
1365
1393
  },
1366
1394
  );
@@ -1704,3 +1732,26 @@ function findOverlappingAuthoredDeletion(
1704
1732
  }
1705
1733
  return undefined;
1706
1734
  }
1735
+
1736
+ /**
1737
+ * Find an open authored insertion revision whose end position equals `cursorAt`.
1738
+ * Used to extend an existing insertion when the user keeps typing at the same
1739
+ * position rather than creating a new revision for every character.
1740
+ */
1741
+ function findAdjacentAuthoredInsertion(
1742
+ revisions: Record<string, CanonicalRevisionRecord>,
1743
+ cursorAt: number,
1744
+ ): CanonicalRevisionRecord | undefined {
1745
+ for (const revision of Object.values(revisions)) {
1746
+ if (
1747
+ revision.kind === "insertion" &&
1748
+ revision.status === "open" &&
1749
+ revision.metadata?.source === "runtime" &&
1750
+ revision.anchor.kind === "range" &&
1751
+ revision.anchor.range.to === cursorAt
1752
+ ) {
1753
+ return revision;
1754
+ }
1755
+ }
1756
+ return undefined;
1757
+ }
@@ -104,7 +104,6 @@ export function rangeStaysWithinSingleParagraph(
104
104
  }
105
105
 
106
106
  export function canCreateDocxCommentAnchor(
107
- content: unknown,
108
107
  anchor: ReviewAnchor,
109
108
  ): boolean {
110
109
  if (anchor.kind !== "range") {
@@ -112,11 +111,7 @@ export function canCreateDocxCommentAnchor(
112
111
  }
113
112
 
114
113
  const normalized = normalizeRange(anchor.range);
115
- if (normalized.from === normalized.to) {
116
- return false;
117
- }
118
-
119
- return rangeStaysWithinSingleParagraph(content, normalized);
114
+ return normalized.from !== normalized.to;
120
115
  }
121
116
 
122
117
  function readSurfaceBlocks(
@@ -261,19 +261,25 @@ export function serializeCommentAnchorsIntoDocumentXml(
261
261
  continue;
262
262
  }
263
263
 
264
- const paragraph = paragraphs.find(
264
+ const startParagraph = paragraphs.find(
265
265
  (candidate) =>
266
266
  anchor.range.from >= candidate.start &&
267
+ anchor.range.from <= candidate.end,
268
+ );
269
+
270
+ const endParagraph = paragraphs.find(
271
+ (candidate) =>
272
+ anchor.range.to >= candidate.start &&
267
273
  anchor.range.to <= candidate.end,
268
274
  );
269
275
 
270
- if (!paragraph) {
276
+ if (!startParagraph || !endParagraph) {
271
277
  skippedCommentIds.push(thread.commentId);
272
278
  continue;
273
279
  }
274
280
 
275
- const startIndex = paragraph.boundaries.get(anchor.range.from);
276
- const endIndex = paragraph.boundaries.get(anchor.range.to);
281
+ const startIndex = startParagraph.boundaries.get(anchor.range.from);
282
+ const endIndex = endParagraph.boundaries.get(anchor.range.to);
277
283
 
278
284
  if (startIndex === undefined || endIndex === undefined) {
279
285
  skippedCommentIds.push(thread.commentId);
@@ -1160,9 +1160,9 @@ export function createDocumentRuntime(
1160
1160
  const selection = params.anchor
1161
1161
  ? createSelectionFromPublicAnchor(params.anchor)
1162
1162
  : state.selection;
1163
- if (!canCreateDocxCommentAnchor(state.document.content, anchor)) {
1163
+ if (!canCreateDocxCommentAnchor(anchor)) {
1164
1164
  const message =
1165
- "DOCX comments must use a non-empty range that stays within a single paragraph.";
1165
+ "DOCX comments must use a non-empty range.";
1166
1166
  emitError({
1167
1167
  errorId: createSessionId("comment-anchor", clock()),
1168
1168
  code: "validation_failed",
@@ -110,7 +110,7 @@ export function deriveCapabilities(
110
110
  activeStory.kind === "main" &&
111
111
  !snapshot.selection.isCollapsed &&
112
112
  Boolean(snapshot.surface) &&
113
- canCreateDocxCommentAnchor(snapshot.surface, toRuntimeAnchor(snapshot.selection.activeRange));
113
+ canCreateDocxCommentAnchor(toRuntimeAnchor(snapshot.selection.activeRange));
114
114
  const canExport = isReady && !exportBlocked && !hasFatalError;
115
115
 
116
116
  // Revision capabilities
@@ -520,6 +520,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
520
520
  onWarning,
521
521
  readOnly = false,
522
522
  reviewMode = "review",
523
+ suggestionsEnabled = false,
523
524
  showReviewPanel = true,
524
525
  chromeVisibility,
525
526
  } = props;
@@ -693,6 +694,10 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
693
694
  activeRuntime.setViewMode(effectiveViewMode);
694
695
  }, [activeRuntime, effectiveViewMode]);
695
696
 
697
+ useEffect(() => {
698
+ activeRuntime.setDocumentMode(suggestionsEnabled ? "suggesting" : "editing");
699
+ }, [activeRuntime, suggestionsEnabled]);
700
+
696
701
  useEffect(() => {
697
702
  runtimeViewStateSeedRef.current = {
698
703
  workspaceMode: viewState.workspaceMode,
@@ -1707,6 +1712,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1707
1712
  markupDisplay={liveMarkupDisplay}
1708
1713
  activeRevisionId={activeRevisionId}
1709
1714
  showTrackedChanges={showTrackedChanges}
1715
+ suggestionsEnabled={suggestionsEnabled}
1710
1716
  mediaPreviews={mediaPreviews}
1711
1717
  isPageWorkspace={isPageWorkspace}
1712
1718
  workflowScopes={workflowScopeSnapshot?.scopes}
@@ -27,6 +27,7 @@ export interface EditorSurfaceControllerProps {
27
27
  markupDisplay: MarkupDisplay;
28
28
  activeRevisionId?: string;
29
29
  showTrackedChanges?: boolean;
30
+ suggestionsEnabled?: boolean;
30
31
  mediaPreviews?: Record<string, MediaPreviewDescriptor>;
31
32
  isPageWorkspace?: boolean;
32
33
  onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
@@ -211,10 +211,13 @@ export function buildDecorations(
211
211
  workflowBlockedReasons?: readonly WorkflowBlockedCommandReason[],
212
212
  activeWorkflowWorkItemId?: string | null,
213
213
  activeWorkflowScopeIds?: readonly string[],
214
+ suggestionsEnabled = false,
214
215
  ): DecorationSet {
215
216
  const decorations: Decoration[] = [];
216
217
  const railRangeCache = new Map<string, Array<{ from: number; to: number }>>();
217
218
  const activeScopeIds = new Set(activeWorkflowScopeIds ?? []);
219
+ // In suggestions mode, tracked changes are always shown regardless of the toggle.
220
+ const effectiveShowTracked = suggestionsEnabled ? true : showTrackedChanges;
218
221
 
219
222
  // Walk comment threads and create inline decorations
220
223
  if (commentModel) {
@@ -248,7 +251,7 @@ export function buildDecorations(
248
251
  // Always hide deletions in clean mode (final-text semantics).
249
252
  // This is the critical behavior: "hide tracked changes" must show
250
253
  // the document as if accepted, not show deleted text as kept text.
251
- if (markupDisplay === "clean" && rev.kind === "deletion") {
254
+ if (markupDisplay === "clean" && rev.kind === "deletion" && !suggestionsEnabled) {
252
255
  const pmFrom = positionMap.runtimeToPm(rev.from);
253
256
  const pmTo = positionMap.runtimeToPm(rev.to);
254
257
  if (pmFrom < pmTo) {
@@ -263,7 +266,48 @@ export function buildDecorations(
263
266
  }
264
267
 
265
268
  // Skip visual styling when tracked changes display is off
266
- if (!showTrackedChanges) continue;
269
+ if (!effectiveShowTracked) continue;
270
+
271
+ const pmFrom = positionMap.runtimeToPm(rev.from);
272
+ const pmTo = positionMap.runtimeToPm(rev.to);
273
+ if (pmFrom >= pmTo) continue;
274
+
275
+ if (suggestionsEnabled) {
276
+ if (rev.kind === "insertion") {
277
+ decorations.push(
278
+ Decoration.inline(pmFrom, pmTo, {
279
+ class: "text-insert",
280
+ "data-revision-id": rev.revisionId,
281
+ }),
282
+ );
283
+ decorations.push(
284
+ Decoration.widget(pmFrom, () => {
285
+ const el = document.createElement("span");
286
+ el.textContent = "[";
287
+ el.className = "text-insert";
288
+ el.setAttribute("contenteditable", "false");
289
+ return el;
290
+ }, { side: -1, key: `${rev.revisionId}-open` }),
291
+ );
292
+ decorations.push(
293
+ Decoration.widget(pmTo, () => {
294
+ const el = document.createElement("span");
295
+ el.textContent = "]";
296
+ el.className = "text-insert";
297
+ el.setAttribute("contenteditable", "false");
298
+ return el;
299
+ }, { side: 1, key: `${rev.revisionId}-close` }),
300
+ );
301
+ } else if (rev.kind === "deletion") {
302
+ decorations.push(
303
+ Decoration.inline(pmFrom, pmTo, {
304
+ class: "text-danger line-through decoration-danger/80 decoration-1",
305
+ "data-revision-id": rev.revisionId,
306
+ }),
307
+ );
308
+ }
309
+ continue;
310
+ }
267
311
 
268
312
  const cls = getRevisionHighlightClass(
269
313
  revisionModel,
@@ -273,16 +317,12 @@ export function buildDecorations(
273
317
  );
274
318
  if (!cls) continue;
275
319
 
276
- const pmFrom = positionMap.runtimeToPm(rev.from);
277
- const pmTo = positionMap.runtimeToPm(rev.to);
278
- if (pmFrom < pmTo) {
279
- decorations.push(
280
- Decoration.inline(pmFrom, pmTo, {
281
- class: cls,
282
- "data-revision-id": rev.revisionId,
283
- }),
284
- );
285
- }
320
+ decorations.push(
321
+ Decoration.inline(pmFrom, pmTo, {
322
+ class: cls,
323
+ "data-revision-id": rev.revisionId,
324
+ }),
325
+ );
286
326
  }
287
327
  }
288
328
 
@@ -43,6 +43,7 @@ export function createSurfaceDecorationKey(input: {
43
43
  workflowBlockedSignature?: string;
44
44
  activeWorkflowWorkItemId?: string | null;
45
45
  activeWorkflowScopeIds?: readonly string[];
46
+ suggestionsEnabled?: boolean;
46
47
  }): string {
47
48
  return JSON.stringify({
48
49
  markupDisplay: input.markupDisplay,
@@ -55,5 +56,6 @@ export function createSurfaceDecorationKey(input: {
55
56
  workflowBlockedSignature: input.workflowBlockedSignature ?? null,
56
57
  activeWorkflowWorkItemId: input.activeWorkflowWorkItemId ?? null,
57
58
  activeWorkflowScopeIds: input.activeWorkflowScopeIds ?? [],
59
+ suggestionsEnabled: input.suggestionsEnabled ?? false,
58
60
  });
59
61
  }
@@ -75,6 +75,7 @@ export interface TwProseMirrorSurfaceProps {
75
75
  markupDisplay: MarkupDisplay;
76
76
  activeRevisionId?: string;
77
77
  showTrackedChanges?: boolean;
78
+ suggestionsEnabled?: boolean;
78
79
  /** When true, the surface renders inside the page workspace (vs canvas). */
79
80
  isPageWorkspace?: boolean;
80
81
  onFocus: FocusEventHandler<HTMLDivElement>;
@@ -204,6 +205,7 @@ export const TwProseMirrorSurface = forwardRef<
204
205
  }),
205
206
  [mediaPreviewKey, snapshot.activeStory, surface],
206
207
  );
208
+ const suggestionsEnabled = props.suggestionsEnabled === true;
207
209
  const decorationBuildKey = useMemo(
208
210
  () =>
209
211
  createSurfaceDecorationKey({
@@ -217,6 +219,7 @@ export const TwProseMirrorSurface = forwardRef<
217
219
  workflowBlockedSignature: createWorkflowBlockedSignature(props.workflowBlockedReasons),
218
220
  activeWorkflowWorkItemId: props.activeWorkflowWorkItemId ?? null,
219
221
  activeWorkflowScopeIds: props.activeWorkflowScopeIds ?? [],
222
+ suggestionsEnabled,
220
223
  }),
221
224
  [
222
225
  canEdit,
@@ -229,6 +232,7 @@ export const TwProseMirrorSurface = forwardRef<
229
232
  props.workflowScopes,
230
233
  showTrackedChanges,
231
234
  snapshot.comments.activeCommentId,
235
+ suggestionsEnabled,
232
236
  ],
233
237
  );
234
238
 
@@ -274,6 +278,7 @@ export const TwProseMirrorSurface = forwardRef<
274
278
  props.workflowBlockedReasons,
275
279
  props.activeWorkflowWorkItemId,
276
280
  props.activeWorkflowScopeIds,
281
+ suggestionsEnabled,
277
282
  );
278
283
  view.setProps({
279
284
  editable: () => canEdit,