@beyondwork/docx-react-component 1.0.83 → 1.0.85

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.
Files changed (55) hide show
  1. package/package.json +1 -1
  2. package/src/api/internal/build-ref-projections.ts +3 -0
  3. package/src/api/public-types.ts +86 -4
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/runtime/content.ts +148 -1
  6. package/src/api/v3/runtime/formatting.ts +41 -0
  7. package/src/api/v3/runtime/review.ts +98 -0
  8. package/src/api/v3/runtime/workflow.ts +154 -6
  9. package/src/core/commands/index.ts +81 -25
  10. package/src/core/state/editor-state.ts +15 -0
  11. package/src/io/export/serialize-main-document.ts +72 -6
  12. package/src/io/ooxml/header-footer-reference.ts +38 -0
  13. package/src/io/ooxml/parse-headers-footers.ts +11 -23
  14. package/src/io/ooxml/parse-main-document.ts +7 -10
  15. package/src/io/ooxml/workflow-payload-validator.ts +24 -0
  16. package/src/io/ooxml/workflow-payload.ts +12 -0
  17. package/src/model/canonical-document.ts +9 -0
  18. package/src/model/review/comment-types.ts +2 -0
  19. package/src/runtime/document-runtime.ts +718 -68
  20. package/src/runtime/formatting/field/resolver.ts +73 -8
  21. package/src/runtime/layout/layout-engine-version.ts +31 -12
  22. package/src/runtime/layout/paginated-layout-engine.ts +18 -11
  23. package/src/runtime/layout/public-facet.ts +119 -16
  24. package/src/runtime/layout/resolve-page-fields.ts +68 -6
  25. package/src/runtime/layout/resolve-page-previews.ts +1 -1
  26. package/src/runtime/scopes/_scope-dependencies.ts +1 -0
  27. package/src/runtime/scopes/action-validation.ts +54 -45
  28. package/src/runtime/scopes/workflow-overlap.ts +41 -9
  29. package/src/runtime/suggestions-snapshot.ts +24 -0
  30. package/src/runtime/surface-projection.ts +59 -2
  31. package/src/runtime/workflow/coordinator.ts +66 -14
  32. package/src/runtime/workflow/scope-writer.ts +83 -5
  33. package/src/shell/ref-commands.ts +3 -354
  34. package/src/shell/session-bootstrap.ts +10 -0
  35. package/src/ui/WordReviewEditor.tsx +99 -9
  36. package/src/ui/editor-command-bag.ts +3 -1
  37. package/src/ui/headless/revision-decoration-model.ts +13 -0
  38. package/src/ui/headless/selection-tool-types.ts +2 -0
  39. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +7 -3
  40. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +175 -25
  41. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -1
  42. package/src/ui-tailwind/editor-surface/pm-decorations.ts +12 -0
  43. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +18 -30
  44. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +1 -1
  45. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +1 -1
  46. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +20 -11
  47. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +9 -4
  48. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +12 -7
  49. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +29 -10
  50. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +1 -1
  51. package/src/ui-tailwind/review-workspace/types.ts +3 -2
  52. package/src/ui-tailwind/review-workspace/use-page-markers.ts +11 -1
  53. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +2 -1
  54. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +2 -1
  55. package/src/ui-tailwind/tw-review-workspace.tsx +18 -2
@@ -30,7 +30,6 @@
30
30
  import type {
31
31
  EditorSessionState,
32
32
  EditorStoryTarget,
33
- FormattingAlignment,
34
33
  HeaderFooterLinkPatch,
35
34
  InsertImageOptions,
36
35
  InsertTableOptions,
@@ -41,7 +40,6 @@ import type {
41
40
  SelectionSnapshot as PublicSelectionSnapshot,
42
41
  StyleCatalogSnapshot,
43
42
  SurfaceBlockSnapshot,
44
- SurfaceInlineSegment,
45
43
  TableOp,
46
44
  } from "../api/public-types";
47
45
  import {
@@ -51,7 +49,7 @@ import {
51
49
  storyTargetsEqual,
52
50
  type TransactionMapping,
53
51
  } from "../core/selection/mapping.ts";
54
- import { applyFormattingOperationToDocument } from "../core/commands/formatting-commands.ts";
52
+ import type { FormattingOperation } from "../core/commands/formatting-commands.ts";
55
53
  import {
56
54
  applyParagraphStyleToDocument,
57
55
  applyTableStyleToDocument,
@@ -220,178 +218,15 @@ export function getRuntimeStyleCatalog(
220
218
  // Formatting (flat + suggesting-mode)
221
219
  // ---------------------------------------------------------------------------
222
220
 
223
- type FormattingOperation =
224
- | { type: "toggle"; mark: "bold" | "italic" | "underline" | "strikethrough" | "superscript" | "subscript" }
225
- | { type: "set-font-family"; fontFamily: string | null }
226
- | { type: "set-font-size"; size: number | null }
227
- | { type: "set-text-color"; color: string | null }
228
- | { type: "set-highlight-color"; color: string | null }
229
- | { type: "set-alignment"; alignment: FormattingAlignment }
230
- | { type: "indent" }
231
- | { type: "outdent" };
232
-
233
221
  export function applyRuntimeFormattingOperation(
234
222
  runtime: WordReviewEditorRuntime,
235
223
  operation: FormattingOperation,
236
224
  ): void {
237
- if (isSelectionSuggesting(runtime)) {
238
- if (applySuggestingFormattingOperation(runtime, operation)) {
239
- return;
240
- }
241
- }
242
- if (emitSuggestingUnsupportedMutation(runtime, getFormattingOperationCommandName(operation))) {
243
- return;
244
- }
245
- const context = getStoryMutationContext(runtime, getFormattingOperationCommandName(operation));
246
- if (!context) {
247
- return;
248
- }
249
-
250
- const result = applyFormattingOperationToDocument(
251
- context.localDocument,
252
- context.localSnapshot,
253
- operation,
254
- );
255
- dispatchStoryMutationResult(
256
- runtime,
257
- context,
258
- {
259
- ...result,
260
- selection: toRuntimeSelectionSnapshot(result.selection),
261
- },
262
- context.timestamp,
263
- );
264
- }
265
-
266
- function applySuggestingFormattingOperation(
267
- runtime: WordReviewEditorRuntime,
268
- operation: FormattingOperation,
269
- ): boolean {
270
- const commandName = getFormattingOperationCommandName(operation);
271
- const context = getStoryMutationContext(runtime, commandName);
272
- if (!context) {
273
- return true;
274
- }
275
- if (context.activeStory.kind !== "main") {
276
- runtime.emitBlockedCommand(commandName, [{
277
- code: "suggesting_unsupported",
278
- message: `"${commandName}" is not supported in suggesting mode for this story.`,
279
- }]);
280
- return true;
281
- }
282
-
283
- if (operation.type === "set-alignment" || operation.type === "indent" || operation.type === "outdent") {
284
- const paragraphContext = resolveActiveParagraphContext(context.localSnapshot);
285
- if (!paragraphContext) {
286
- return true;
287
- }
288
- const beforeXml = buildParagraphPropertyBeforeXml(paragraphContext.paragraph);
289
- const result = applyFormattingOperationToDocument(
290
- context.localDocument,
291
- context.localSnapshot,
292
- operation,
293
- );
294
- if (!result.changed) {
295
- return true;
296
- }
297
- const nextDocument = appendPropertyChangeSuggestion(
298
- result.document,
299
- {
300
- from: paragraphContext.paragraph.from,
301
- to: paragraphContext.paragraph.to,
302
- },
303
- {
304
- originalRevisionType: "pPrChange",
305
- xmlTag: "pPrChange",
306
- beforeXml,
307
- semanticKind: "paragraph-property-change",
308
- storyTarget: context.activeStory,
309
- authorId: runtime.getDefaultAuthorId?.(),
310
- },
311
- context.timestamp,
312
- );
313
- dispatchStoryMutationResult(
314
- runtime,
315
- context,
316
- {
317
- changed: true,
318
- document: nextDocument,
319
- selection: toRuntimeSelectionSnapshot(result.selection),
320
- },
321
- context.timestamp,
322
- );
323
- return true;
324
- }
325
-
326
- const segment = findSingleSelectedTextSegment(context.localSnapshot);
327
- if (!segment) {
328
- runtime.emitBlockedCommand(commandName, [{
329
- code: "suggesting_unsupported",
330
- message: `"${commandName}" requires one bounded text segment in suggesting mode.`,
331
- }]);
332
- return true;
333
- }
334
- const beforeXml = buildRunPropertyBeforeXml(segment);
335
- const result = applyFormattingOperationToDocument(
336
- context.localDocument,
337
- context.localSnapshot,
338
- operation,
339
- );
340
- if (!result.changed) {
341
- return true;
342
- }
343
- const nextDocument = appendPropertyChangeSuggestion(
344
- result.document,
345
- {
346
- from: segment.from,
347
- to: segment.to,
348
- },
349
- {
350
- originalRevisionType: "rPrChange",
351
- xmlTag: "rPrChange",
352
- beforeXml,
353
- semanticKind: "formatting-change",
354
- storyTarget: context.activeStory,
355
- authorId: runtime.getDefaultAuthorId?.(),
356
- },
357
- context.timestamp,
358
- );
359
- dispatchStoryMutationResult(
360
- runtime,
361
- context,
362
- {
363
- changed: true,
364
- document: nextDocument,
365
- selection: toRuntimeSelectionSnapshot(result.selection),
366
- },
367
- context.timestamp,
368
- );
369
- return true;
370
- }
371
-
372
- function getFormattingOperationCommandName(operation: FormattingOperation): string {
373
- switch (operation.type) {
374
- case "toggle":
375
- return `toggle${operation.mark.charAt(0).toUpperCase()}${operation.mark.slice(1)}`;
376
- case "set-font-family":
377
- return "setFontFamily";
378
- case "set-font-size":
379
- return "setFontSize";
380
- case "set-text-color":
381
- return "setTextColor";
382
- case "set-highlight-color":
383
- return "setHighlightColor";
384
- case "set-alignment":
385
- return "setAlignment";
386
- case "indent":
387
- return "indent";
388
- case "outdent":
389
- return "outdent";
390
- }
225
+ runtime.applyFormattingOperation(operation);
391
226
  }
392
227
 
393
228
  // ---------------------------------------------------------------------------
394
- // Suggesting-mode property-change plumbing
229
+ // Suggesting-mode guard helper for commands without bounded redline support
395
230
  // ---------------------------------------------------------------------------
396
231
 
397
232
  function emitSuggestingUnsupportedMutation(
@@ -409,192 +244,6 @@ function emitSuggestingUnsupportedMutation(
409
244
  return true;
410
245
  }
411
246
 
412
- function appendPropertyChangeSuggestion(
413
- document: EditorSessionState["canonicalDocument"],
414
- anchor: { from: number; to: number },
415
- input: {
416
- originalRevisionType: "rPrChange" | "pPrChange";
417
- xmlTag: "rPrChange" | "pPrChange";
418
- beforeXml: string;
419
- semanticKind: "formatting-change" | "paragraph-property-change";
420
- storyTarget: EditorStoryTarget;
421
- authorId?: string;
422
- },
423
- timestamp: string,
424
- ): EditorSessionState["canonicalDocument"] {
425
- const existing = document.review.revisions;
426
- const changeId = createRuntimeSuggestionChangeId(existing, timestamp);
427
- const resolvedAuthorId = input.authorId ?? "unknown";
428
- return {
429
- ...document,
430
- review: {
431
- ...document.review,
432
- revisions: {
433
- ...existing,
434
- [changeId]: {
435
- changeId,
436
- kind: "property-change",
437
- anchor: createRangeAnchor(anchor.from, anchor.to, { start: 1, end: -1 }),
438
- authorId: resolvedAuthorId,
439
- createdAt: timestamp,
440
- warningIds: [],
441
- metadata: {
442
- source: "runtime",
443
- storyTarget: input.storyTarget,
444
- suggestionId: changeId,
445
- semanticKind: input.semanticKind,
446
- originalRevisionType: input.originalRevisionType,
447
- propertyChangeData: {
448
- xmlTag: input.xmlTag,
449
- beforeXml: input.beforeXml,
450
- },
451
- },
452
- status: "open",
453
- },
454
- },
455
- },
456
- };
457
- }
458
-
459
- function createRuntimeSuggestionChangeId(
460
- existing: EditorSessionState["canonicalDocument"]["review"]["revisions"],
461
- timestamp: string,
462
- ): string {
463
- const base = `change-${timestamp.replace(/[^0-9]/gu, "")}`;
464
- let counter = Object.keys(existing).length + 1;
465
- let candidate = `${base}-p${counter}`;
466
- while (existing[candidate]) {
467
- counter += 1;
468
- candidate = `${base}-p${counter}`;
469
- }
470
- return candidate;
471
- }
472
-
473
- function findSingleSelectedTextSegment(
474
- snapshot: Pick<RuntimeRenderSnapshot, "surface" | "selection">,
475
- ): Extract<SurfaceInlineSegment, { kind: "text" }> | null {
476
- if (!snapshot.surface || snapshot.selection.activeRange.kind !== "range" || snapshot.selection.isCollapsed) {
477
- return null;
478
- }
479
- const selectionFrom = Math.min(snapshot.selection.anchor, snapshot.selection.head);
480
- const selectionTo = Math.max(snapshot.selection.anchor, snapshot.selection.head);
481
- const segments = collectSelectedTextSegments(snapshot.surface.blocks, selectionFrom, selectionTo);
482
- if (segments.length !== 1) {
483
- return null;
484
- }
485
- const [segment] = segments;
486
- if (!segment || segment.from !== selectionFrom || segment.to !== selectionTo) {
487
- return null;
488
- }
489
- return segment;
490
- }
491
-
492
- function collectSelectedTextSegments(
493
- blocks: readonly SurfaceBlockSnapshot[],
494
- selectionFrom: number,
495
- selectionTo: number,
496
- output: Array<Extract<SurfaceInlineSegment, { kind: "text" }>> = [],
497
- ): Array<Extract<SurfaceInlineSegment, { kind: "text" }>> {
498
- for (const block of blocks) {
499
- if (block.kind === "paragraph") {
500
- for (const segment of block.segments) {
501
- if (
502
- segment.kind === "text" &&
503
- rangesOverlap(selectionFrom, selectionTo, segment.from, segment.to)
504
- ) {
505
- output.push(segment);
506
- }
507
- }
508
- continue;
509
- }
510
- if (block.kind === "table") {
511
- for (const row of block.rows) {
512
- for (const cell of row.cells) {
513
- collectSelectedTextSegments(cell.content, selectionFrom, selectionTo, output);
514
- }
515
- }
516
- continue;
517
- }
518
- if (block.kind === "sdt_block") {
519
- collectSelectedTextSegments(block.children, selectionFrom, selectionTo, output);
520
- }
521
- }
522
- return output;
523
- }
524
-
525
- function rangesOverlap(
526
- leftFrom: number,
527
- leftTo: number,
528
- rightFrom: number,
529
- rightTo: number,
530
- ): boolean {
531
- return leftFrom < rightTo && rightFrom < leftTo;
532
- }
533
-
534
- function buildRunPropertyBeforeXml(
535
- segment: Extract<SurfaceInlineSegment, { kind: "text" }>,
536
- ): string {
537
- const parts: string[] = [];
538
- const marks = new Set(segment.marks ?? []);
539
- if (marks.has("bold")) parts.push("<w:b/>");
540
- if (marks.has("italic")) parts.push("<w:i/>");
541
- if (marks.has("underline")) parts.push("<w:u w:val=\"single\"/>");
542
- if (marks.has("strikethrough")) parts.push("<w:strike/>");
543
- if (marks.has("superscript")) parts.push("<w:vertAlign w:val=\"superscript\"/>");
544
- if (marks.has("subscript")) parts.push("<w:vertAlign w:val=\"subscript\"/>");
545
- if (segment.markAttrs?.fontFamily) {
546
- parts.push(`<w:rFonts w:ascii="${escapeAttributeXml(segment.markAttrs.fontFamily)}" w:hAnsi="${escapeAttributeXml(segment.markAttrs.fontFamily)}"/>`);
547
- }
548
- if (segment.markAttrs?.fontSize !== undefined) {
549
- parts.push(`<w:sz w:val="${segment.markAttrs.fontSize}"/>`);
550
- }
551
- if (segment.markAttrs?.textColor) {
552
- parts.push(`<w:color w:val="${escapeAttributeXml(segment.markAttrs.textColor)}"/>`);
553
- }
554
- if (segment.markAttrs?.backgroundColor) {
555
- parts.push(`<w:shd w:val="clear" w:color="auto" w:fill="${escapeAttributeXml(segment.markAttrs.backgroundColor)}"/>`);
556
- }
557
- return `<w:rPr>${parts.join("")}</w:rPr>`;
558
- }
559
-
560
- function buildParagraphPropertyBeforeXml(
561
- paragraph: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
562
- ): string {
563
- const parts: string[] = [];
564
- if (paragraph.styleId) {
565
- parts.push(`<w:pStyle w:val="${escapeAttributeXml(paragraph.styleId)}"/>`);
566
- }
567
- if (paragraph.numbering) {
568
- parts.push(
569
- `<w:numPr><w:ilvl w:val="${paragraph.numbering.level}"/><w:numId w:val="${escapeAttributeXml(
570
- paragraph.numbering.numberingInstanceId.replace(/^num:/u, ""),
571
- )}"/></w:numPr>`,
572
- );
573
- }
574
- if (paragraph.alignment) {
575
- parts.push(`<w:jc w:val="${escapeAttributeXml(paragraph.alignment)}"/>`);
576
- }
577
- if (paragraph.indentation) {
578
- const attrs: string[] = [];
579
- if (paragraph.indentation.left !== undefined) attrs.push(`w:left="${paragraph.indentation.left}"`);
580
- if (paragraph.indentation.right !== undefined) attrs.push(`w:right="${paragraph.indentation.right}"`);
581
- if (paragraph.indentation.firstLine !== undefined) attrs.push(`w:firstLine="${paragraph.indentation.firstLine}"`);
582
- if (paragraph.indentation.hanging !== undefined) attrs.push(`w:hanging="${paragraph.indentation.hanging}"`);
583
- if (attrs.length > 0) {
584
- parts.push(`<w:ind ${attrs.join(" ")}/>`);
585
- }
586
- }
587
- return `<w:pPr>${parts.join("")}</w:pPr>`;
588
- }
589
-
590
- function escapeAttributeXml(value: string): string {
591
- return value
592
- .replace(/&/g, "&amp;")
593
- .replace(/</g, "&lt;")
594
- .replace(/>/g, "&gt;")
595
- .replace(/"/g, "&quot;");
596
- }
597
-
598
247
  // ---------------------------------------------------------------------------
599
248
  // List toggle / paragraph style / table style
600
249
  // ---------------------------------------------------------------------------
@@ -1098,6 +1098,7 @@ function createLoadingRuntimeBridge(input: {
1098
1098
  getFontEntry: (name: string) =>
1099
1099
  input.sessionState.canonicalDocument.fontTable?.fonts[name],
1100
1100
  replaceText: () => undefined,
1101
+ applyFormattingOperation: () => undefined,
1101
1102
  applyScopeReplacement: () => undefined,
1102
1103
  insertFragment: () => undefined,
1103
1104
  copy: () => undefined,
@@ -1133,6 +1134,13 @@ function createLoadingRuntimeBridge(input: {
1133
1134
  addCommentReply: () => {
1134
1135
  throw createLoadingBoundaryError(input.snapshot.documentId, "comment");
1135
1136
  },
1137
+ getCommentThreadForChange: () => null,
1138
+ ensureCommentThreadForChange: () => {
1139
+ throw createLoadingBoundaryError(input.snapshot.documentId, "comment");
1140
+ },
1141
+ addReplyToChange: () => {
1142
+ throw createLoadingBoundaryError(input.snapshot.documentId, "comment");
1143
+ },
1136
1144
  editCommentBody: () => undefined,
1137
1145
  addScope: () => {
1138
1146
  throw createLoadingBoundaryError(input.snapshot.documentId, "scope");
@@ -1289,6 +1297,8 @@ function createLoadingRuntimeBridge(input: {
1289
1297
  addInvisibleScope: () => ({ scopeId: "", anchor: { kind: "range", from: 0, to: 0, assoc: { start: -1, end: 1 } } }),
1290
1298
  setScopeVisibility: () => undefined,
1291
1299
  getScopeVisibility: () => "visible" as const,
1300
+ setScopeGuardPolicy: () => undefined,
1301
+ getScopeGuardPolicy: () => "none" as const,
1292
1302
  setScopeChromeVisibility: () => undefined,
1293
1303
  getScopeChromeVisibility: () => ({ mode: "all" as const }),
1294
1304
  subscribeToScopeQuery: (_filter, _callback) => () => undefined,
@@ -66,6 +66,7 @@ import type {
66
66
  ViewMode as EditorViewMode,
67
67
  WorkflowBlockedCommandReason,
68
68
  WorkflowMarkupSnapshot,
69
+ WorkflowMarkupMode,
69
70
  WorkflowScopeSnapshot,
70
71
  WordReviewEditorChromeOptions,
71
72
  WordReviewEditorChromePreset,
@@ -250,6 +251,21 @@ function normalizeHostMarkupDisplay(
250
251
  return "markup";
251
252
  }
252
253
 
254
+ function toWorkflowMarkupMode(mode: MarkupDisplay): WorkflowMarkupMode {
255
+ switch (mode) {
256
+ case "all":
257
+ case "all-markup":
258
+ return "all";
259
+ case "simple":
260
+ case "simple-markup":
261
+ return "simple";
262
+ case "clean":
263
+ case "no-markup":
264
+ case "original":
265
+ return "clean";
266
+ }
267
+ }
268
+
253
269
  const VISUALLY_HIDDEN_STYLES: React.CSSProperties = {
254
270
  position: "absolute",
255
271
  width: "1px",
@@ -383,6 +399,11 @@ export function __createWordReviewEditorRefBridge(
383
399
  resolveComment: (commentId) => runtime.resolveComment(commentId),
384
400
  reopenComment: (commentId) => runtime.reopenComment(commentId),
385
401
  addCommentReply: (commentId, body) => runtime.addCommentReply(commentId, body),
402
+ getCommentThreadForChange: (changeId) =>
403
+ clonePublicValue(runtime.getCommentThreadForChange(changeId)),
404
+ ensureCommentThreadForChange: (changeId) =>
405
+ runtime.ensureCommentThreadForChange(changeId),
406
+ addReplyToChange: (changeId, body) => runtime.addReplyToChange(changeId, body),
386
407
  editCommentBody: (commentId, body) => runtime.editCommentBody(commentId, body),
387
408
  deleteComment: (commentId) => {
388
409
  applyRuntimeDeleteComment(runtime, commentId);
@@ -393,6 +414,8 @@ export function __createWordReviewEditorRefBridge(
393
414
  addInvisibleScope: (params) => runtime.addInvisibleScope(params),
394
415
  setScopeVisibility: (scopeId, visibility) => runtime.setScopeVisibility(scopeId, visibility),
395
416
  getScopeVisibility: (scopeId) => runtime.getScopeVisibility(scopeId),
417
+ setScopeGuardPolicy: (scopeId, guardPolicy) => runtime.setScopeGuardPolicy(scopeId, guardPolicy),
418
+ getScopeGuardPolicy: (scopeId) => runtime.getScopeGuardPolicy(scopeId),
396
419
  setScopeChromeVisibility: (state) => runtime.setScopeChromeVisibility(state),
397
420
  getScopeChromeVisibility: () => runtime.getScopeChromeVisibility(),
398
421
  subscribeToScopeQuery: (filter, callback) => runtime.subscribeToScopeQuery(filter, callback),
@@ -1039,7 +1062,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1039
1062
  } = props;
1040
1063
 
1041
1064
  const [activeRailTab, setActiveRailTab] = useState<ReviewRailTab>("comments");
1042
- const [showTrackedChanges, setShowTrackedChanges] = useState(false);
1065
+ const [showTrackedChanges, setShowTrackedChanges] = useState(() => suggestionsEnabled);
1066
+ const [localMarkupDisplay, setLocalMarkupDisplay] =
1067
+ useState<WorkflowMarkupMode | null>(() => suggestionsEnabled ? "all" : null);
1043
1068
  const [activeRevisionId, setActiveRevisionId] = useState<string | undefined>();
1044
1069
  const [suppressedSuggestionRevisionId, setSuppressedSuggestionRevisionId] = useState<string | null>(null);
1045
1070
  const [selectionToolbarAnchor, setSelectionToolbarAnchor] = useState<SelectionToolbarAnchor | null>(null);
@@ -1341,7 +1366,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1341
1366
  loadingViewState,
1342
1367
  );
1343
1368
  const isPageWorkspace = viewState.workspaceMode === "page";
1344
- const liveMarkupDisplay = __resolveLiveMarkupDisplay(markupDisplay, isPageWorkspace);
1369
+ const liveMarkupDisplay = localMarkupDisplay ??
1370
+ __resolveLiveMarkupDisplay(markupDisplay, isPageWorkspace);
1345
1371
  const documentNavigation = useRuntimeValue(
1346
1372
  runtime
1347
1373
  ? {
@@ -1481,9 +1507,29 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1481
1507
  activeRuntime.setViewMode(effectiveViewMode);
1482
1508
  }, [activeRuntime, effectiveViewMode]);
1483
1509
 
1510
+ const setReviewMarkupMode = useCallback((mode: MarkupDisplay) => {
1511
+ const workflowMode = toWorkflowMarkupMode(mode);
1512
+ setLocalMarkupDisplay(workflowMode);
1513
+ setShowTrackedChanges(workflowMode !== "clean");
1514
+ api.ui?.viewport.setLocalMarkupMode(workflowMode);
1515
+ }, [api]);
1516
+
1517
+ const setTrackedChangesAuthoring = useCallback((enabled: boolean) => {
1518
+ const workflowMode: WorkflowMarkupMode = enabled ? "all" : "clean";
1519
+ setShowTrackedChanges(enabled);
1520
+ setLocalMarkupDisplay(workflowMode);
1521
+ api.runtime.document.setMode(enabled ? "suggesting" : "editing");
1522
+ api.ui?.viewport.setLocalMarkupMode(workflowMode);
1523
+ }, [api]);
1524
+
1484
1525
  useEffect(() => {
1485
- activeRuntime.setDocumentMode(suggestionsEnabled ? "suggesting" : "editing");
1486
- }, [activeRuntime, suggestionsEnabled]);
1526
+ if (suggestionsEnabled) {
1527
+ setTrackedChangesAuthoring(true);
1528
+ return;
1529
+ }
1530
+ setShowTrackedChanges(false);
1531
+ api.runtime.document.setMode("editing");
1532
+ }, [api, setTrackedChangesAuthoring, suggestionsEnabled]);
1487
1533
 
1488
1534
  // design-close-chrome Phase 2 — density contract (designsystem §4.2).
1489
1535
  // When the `density` prop is supplied, drive the root `data-density`
@@ -1643,6 +1689,12 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1643
1689
  reopenComment: (commentId) => activeRuntime.reopenComment(commentId),
1644
1690
  addCommentReply: (commentId, body) =>
1645
1691
  activeRuntime.addCommentReply(commentId, body, currentUser.userId),
1692
+ getCommentThreadForChange: (changeId) =>
1693
+ clonePublicValue(activeRuntime.getCommentThreadForChange(changeId)),
1694
+ ensureCommentThreadForChange: (changeId) =>
1695
+ activeRuntime.ensureCommentThreadForChange(changeId, currentUser.userId),
1696
+ addReplyToChange: (changeId, body) =>
1697
+ activeRuntime.addReplyToChange(changeId, body, currentUser.userId),
1646
1698
  editCommentBody: (commentId, body) =>
1647
1699
  activeRuntime.editCommentBody(commentId, body),
1648
1700
  deleteComment: (commentId) => {
@@ -1654,6 +1706,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1654
1706
  addInvisibleScope: (params) => activeRuntime.addInvisibleScope(params),
1655
1707
  setScopeVisibility: (scopeId, visibility) => activeRuntime.setScopeVisibility(scopeId, visibility),
1656
1708
  getScopeVisibility: (scopeId) => activeRuntime.getScopeVisibility(scopeId),
1709
+ setScopeGuardPolicy: (scopeId, guardPolicy) => activeRuntime.setScopeGuardPolicy(scopeId, guardPolicy),
1710
+ getScopeGuardPolicy: (scopeId) => activeRuntime.getScopeGuardPolicy(scopeId),
1657
1711
  setScopeChromeVisibility: (state) => activeRuntime.setScopeChromeVisibility(state),
1658
1712
  getScopeChromeVisibility: () => activeRuntime.getScopeChromeVisibility(),
1659
1713
  subscribeToScopeQuery: (filter, callback) => activeRuntime.subscribeToScopeQuery(filter, callback),
@@ -2736,7 +2790,20 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2736
2790
  );
2737
2791
 
2738
2792
  const addSelectionToolbarComment = useCallback(() => {
2739
- const commentId = addReviewComment();
2793
+ let commentId: string | null = null;
2794
+ if (activeSelectionTool?.kind === "suggestion-review") {
2795
+ const primaryChangeId = activeSelectionTool.changeIds[0];
2796
+ const linkedThread = primaryChangeId
2797
+ ? activeRuntime.ensureCommentThreadForChange(primaryChangeId, currentUser.userId)
2798
+ : null;
2799
+ commentId = linkedThread?.commentId ?? null;
2800
+ if (commentId) {
2801
+ activeRuntime.openComment(commentId);
2802
+ setActiveRailTab("comments");
2803
+ }
2804
+ } else {
2805
+ commentId = addReviewComment();
2806
+ }
2740
2807
  if (!commentId) {
2741
2808
  return;
2742
2809
  }
@@ -2744,7 +2811,13 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2744
2811
  queueMicrotask(() => {
2745
2812
  focusDocumentSurface();
2746
2813
  });
2747
- }, [dismissSelectionToolbar, focusDocumentSurface]);
2814
+ }, [
2815
+ activeRuntime,
2816
+ activeSelectionTool,
2817
+ currentUser.userId,
2818
+ dismissSelectionToolbar,
2819
+ focusDocumentSurface,
2820
+ ]);
2748
2821
 
2749
2822
  const handleSelectionToolbarAnchorChange = useCallback(
2750
2823
  (nextAnchor: SelectionToolbarAnchor | null) => {
@@ -3162,7 +3235,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3162
3235
  onWorkspaceModeChange: (mode) => activeRuntime.setWorkspaceMode(mode),
3163
3236
  onZoomChange: (level) => activeRuntime.setZoom(level),
3164
3237
  onActiveRailTabChange: setActiveRailTab,
3165
- onShowTrackedChangesChange: setShowTrackedChanges,
3238
+ onShowTrackedChangesChange: setTrackedChangesAuthoring,
3239
+ onReviewMarkupModeChange: setReviewMarkupMode,
3166
3240
  onToggleBold: () =>
3167
3241
  applyRuntimeFormattingOperation(activeRuntime, { type: "toggle", mark: "bold" }),
3168
3242
  onToggleItalic: () =>
@@ -3259,10 +3333,20 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
3259
3333
  // with a mount-time `console.warn`; hosts that still pass them can
3260
3334
  // migrate to `onOpenStory` at their leisure.
3261
3335
  onOpenHeaderStoryForPage: (pageIndex: number) =>
3262
- openStoryForPage(activeRuntime, pageIndex, "header"),
3336
+ isPageWorkspace
3337
+ ? openStoryForPage(activeRuntime, pageIndex, "header")
3338
+ : undefined,
3263
3339
  onOpenFooterStoryForPage: (pageIndex: number) =>
3264
- openStoryForPage(activeRuntime, pageIndex, "footer"),
3340
+ isPageWorkspace
3341
+ ? openStoryForPage(activeRuntime, pageIndex, "footer")
3342
+ : undefined,
3265
3343
  onOpenStory: (target) => {
3344
+ if (
3345
+ !isPageWorkspace &&
3346
+ (target.kind === "header" || target.kind === "footer")
3347
+ ) {
3348
+ return;
3349
+ }
3266
3350
  activeRuntime.openStory(target);
3267
3351
  },
3268
3352
  onDeleteSectionBreak: (sectionIndex) =>
@@ -4736,6 +4820,12 @@ function buildSuggestionCardModel(args: {
4736
4820
  canReject: canReviewSuggestion && capabilities.canRejectChange && focusedSuggestion.canReject,
4737
4821
  canEditSuggestion: canReviewSuggestion && focusedSuggestion.editable,
4738
4822
  canAddComment,
4823
+ ...(focusedSuggestion.commentThreadIds
4824
+ ? { commentThreadIds: focusedSuggestion.commentThreadIds }
4825
+ : {}),
4826
+ ...(focusedSuggestion.replyCount !== undefined
4827
+ ? { replyCount: focusedSuggestion.replyCount }
4828
+ : {}),
4739
4829
  ...(disabledReason ? { disabledReason } : {}),
4740
4830
  };
4741
4831
  }
@@ -14,6 +14,7 @@ import type {
14
14
  WorkspaceMode,
15
15
  } from "../api/public-types.ts";
16
16
  import type { ReviewRailTab } from "../ui-tailwind/review/tw-review-rail.tsx";
17
+ import type { MarkupDisplay } from "./headless/comment-decoration-model.ts";
17
18
 
18
19
  type CommandHandler = (...args: any[]) => unknown;
19
20
 
@@ -22,6 +23,7 @@ export interface EditorCommandBag {
22
23
  onZoomChange?(level: ZoomLevel): void;
23
24
  onActiveRailTabChange(value: ReviewRailTab): void;
24
25
  onShowTrackedChangesChange(show: boolean): void;
26
+ onReviewMarkupModeChange?(mode: MarkupDisplay): void;
25
27
  onUndo(): void;
26
28
  onRedo(): void;
27
29
  onSetParagraphStyle?(styleId: string): void;
@@ -102,7 +104,7 @@ export interface EditorCommandBag {
102
104
  /** Open the footer story for a specific page (double-click on its band). */
103
105
  onOpenFooterStoryForPage?(pageIndex: number): void;
104
106
  /**
105
- * P8.11 — per-page header/footer band click handler. Receives the
107
+ * P8.11 — per-page header/footer band double-click handler. Receives the
106
108
  * exact `EditorStoryTarget` the band represents; the command bag wires
107
109
  * this to `runtime.openStory(target)`.
108
110
  */
@@ -225,6 +225,19 @@ export function buildClassFromRevisionDisplay(
225
225
  parts.push("text-secondary");
226
226
  }
227
227
 
228
+ // Formatting/property-change revisions carry their semantics through
229
+ // `kind` even when markup posture has no underline/strike flag. Give
230
+ // mounted suggestion authoring a visible, non-destructive cue instead
231
+ // of silently relying on the sidebar/card path.
232
+ if (
233
+ parts.length === 0 &&
234
+ (display.kind === "formatting" || display.kind === "property-change")
235
+ ) {
236
+ parts.push(
237
+ "underline decoration-accent/70 decoration-dotted decoration-1 underline-offset-2",
238
+ );
239
+ }
240
+
228
241
  // Surface the author palette color as a CSS variable the renderer
229
242
  // can pick up via `var(--wre-revision-author)`. Consumer stylesheet
230
243
  // composes this into ring/underline color when the palette slot is
@@ -81,6 +81,8 @@ export interface SuggestionReviewSelectionToolModel extends BaseSelectionToolMod
81
81
  canReject: boolean;
82
82
  canEditSuggestion: boolean;
83
83
  canAddComment: boolean;
84
+ commentThreadIds?: string[];
85
+ replyCount?: number;
84
86
  }
85
87
 
86
88
  export type StructureContextKind = "table" | "image" | "object" | "list";