@beyondwork/docx-react-component 1.0.53 → 1.0.55

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 (99) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +125 -7
  3. package/src/index.ts +5 -0
  4. package/src/io/docx-session.ts +27 -3
  5. package/src/io/normalize/normalize-text.ts +1 -0
  6. package/src/io/ooxml/parse-field-switches.ts +134 -0
  7. package/src/io/ooxml/parse-fields.ts +28 -2
  8. package/src/model/canonical-document.ts +13 -2
  9. package/src/runtime/chart/chart-model-store.ts +88 -0
  10. package/src/runtime/chart/chart-snapshot.ts +239 -0
  11. package/src/runtime/collab/checkpoint-store.ts +1 -1
  12. package/src/runtime/collab/event-types.ts +4 -0
  13. package/src/runtime/collab/runtime-collab-sync.ts +1 -2
  14. package/src/runtime/document-runtime.ts +115 -13
  15. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  16. package/src/runtime/layout/layout-engine-version.ts +58 -1
  17. package/src/runtime/layout/layout-invalidation.ts +150 -30
  18. package/src/runtime/layout/page-graph.ts +19 -0
  19. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  20. package/src/runtime/layout/project-block-fragments.ts +27 -0
  21. package/src/runtime/layout/public-facet.ts +27 -0
  22. package/src/runtime/page-number-format.ts +207 -0
  23. package/src/runtime/render/render-frame-diff.ts +38 -2
  24. package/src/runtime/surface-projection.ts +32 -3
  25. package/src/ui/WordReviewEditor.tsx +57 -3
  26. package/src/ui/headless/comment-decoration-model.ts +60 -5
  27. package/src/ui/headless/revision-decoration-model.ts +94 -6
  28. package/src/ui/shared/revision-filters.ts +16 -6
  29. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  30. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  31. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  32. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  33. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  34. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  35. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  36. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  37. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  38. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  39. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  40. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  41. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  42. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  43. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  44. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  45. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  46. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  47. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  48. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  49. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  50. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  51. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  52. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  53. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  54. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  55. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  56. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  57. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  58. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  59. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  60. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  61. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  62. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  63. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  64. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  65. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  66. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  67. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  68. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  69. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  70. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  71. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  72. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  73. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  74. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  75. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +90 -0
  76. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  77. package/src/ui-tailwind/editor-surface/pm-schema.ts +4 -0
  78. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +14 -0
  79. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  80. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
  81. package/src/ui-tailwind/index.ts +11 -0
  82. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
  83. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
  84. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
  85. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  86. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  87. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  88. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  89. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  90. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  91. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  92. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  93. package/src/ui-tailwind/theme/editor-theme.css +249 -22
  94. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  95. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  96. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  97. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  98. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  99. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
@@ -0,0 +1,207 @@
1
+ /**
2
+ * OOXML page number format codes (ECMA-376 §17.18.59).
3
+ *
4
+ * This module is intentionally dependency-free so it can be unit-tested
5
+ * without any runtime or model imports.
6
+ */
7
+
8
+ const ROMAN_PAIRS: [number, string][] = [
9
+ [1000, "M"],
10
+ [900, "CM"],
11
+ [500, "D"],
12
+ [400, "CD"],
13
+ [100, "C"],
14
+ [90, "XC"],
15
+ [50, "L"],
16
+ [40, "XL"],
17
+ [10, "X"],
18
+ [9, "IX"],
19
+ [5, "V"],
20
+ [4, "IV"],
21
+ [1, "I"],
22
+ ];
23
+
24
+ function toRoman(n: number): string {
25
+ if (n <= 0 || n >= 4000) {
26
+ return String(n);
27
+ }
28
+ let result = "";
29
+ let remainder = n;
30
+ for (const [value, numeral] of ROMAN_PAIRS) {
31
+ while (remainder >= value) {
32
+ result += numeral;
33
+ remainder -= value;
34
+ }
35
+ }
36
+ return result;
37
+ }
38
+
39
+ function toAlphabetic(n: number): string {
40
+ if (n <= 0) {
41
+ return String(n);
42
+ }
43
+ let result = "";
44
+ let remainder = n;
45
+ while (remainder > 0) {
46
+ remainder -= 1;
47
+ result = String.fromCharCode(65 + (remainder % 26)) + result;
48
+ remainder = Math.floor(remainder / 26);
49
+ }
50
+ return result;
51
+ }
52
+
53
+ function toOrdinal(n: number): string {
54
+ const abs = Math.abs(n);
55
+ const mod100 = abs % 100;
56
+ const mod10 = abs % 10;
57
+ if (mod100 >= 11 && mod100 <= 13) {
58
+ return `${n}th`;
59
+ }
60
+ if (mod10 === 1) return `${n}st`;
61
+ if (mod10 === 2) return `${n}nd`;
62
+ if (mod10 === 3) return `${n}rd`;
63
+ return `${n}th`;
64
+ }
65
+
66
+ const ONES = [
67
+ "",
68
+ "one",
69
+ "two",
70
+ "three",
71
+ "four",
72
+ "five",
73
+ "six",
74
+ "seven",
75
+ "eight",
76
+ "nine",
77
+ "ten",
78
+ "eleven",
79
+ "twelve",
80
+ "thirteen",
81
+ "fourteen",
82
+ "fifteen",
83
+ "sixteen",
84
+ "seventeen",
85
+ "eighteen",
86
+ "nineteen",
87
+ ];
88
+
89
+ const TENS = [
90
+ "",
91
+ "",
92
+ "twenty",
93
+ "thirty",
94
+ "forty",
95
+ "fifty",
96
+ "sixty",
97
+ "seventy",
98
+ "eighty",
99
+ "ninety",
100
+ ];
101
+
102
+ function toCardinalText(n: number): string {
103
+ if (n <= 0 || n >= 100) {
104
+ return String(n);
105
+ }
106
+ if (n < 20) {
107
+ return ONES[n]!;
108
+ }
109
+ const tensWord = TENS[Math.floor(n / 10)]!;
110
+ const onesDigit = n % 10;
111
+ if (onesDigit === 0) {
112
+ return tensWord;
113
+ }
114
+ return `${tensWord}-${ONES[onesDigit]}`;
115
+ }
116
+
117
+ const ORDINAL_IRREGULARS: Record<string, string> = {
118
+ one: "first",
119
+ two: "second",
120
+ three: "third",
121
+ four: "fourth",
122
+ five: "fifth",
123
+ six: "sixth",
124
+ seven: "seventh",
125
+ eight: "eighth",
126
+ nine: "ninth",
127
+ ten: "tenth",
128
+ eleven: "eleventh",
129
+ twelve: "twelfth",
130
+ thirteen: "thirteenth",
131
+ fifteen: "fifteenth",
132
+ twenty: "twentieth",
133
+ thirty: "thirtieth",
134
+ forty: "fortieth",
135
+ fifty: "fiftieth",
136
+ sixty: "sixtieth",
137
+ seventy: "seventieth",
138
+ eighty: "eightieth",
139
+ ninety: "ninetieth",
140
+ };
141
+
142
+ function toOrdinalText(n: number): string {
143
+ if (n <= 0 || n >= 100) {
144
+ return `${n}th`;
145
+ }
146
+ const cardinal = toCardinalText(n);
147
+ // Check if it's a hyphenated compound like "twenty-one"
148
+ const hyphenIdx = cardinal.lastIndexOf("-");
149
+ if (hyphenIdx !== -1) {
150
+ const prefix = cardinal.slice(0, hyphenIdx + 1);
151
+ const suffix = cardinal.slice(hyphenIdx + 1);
152
+ const ordinalSuffix = ORDINAL_IRREGULARS[suffix] ?? `${suffix}th`;
153
+ return `${prefix}${ordinalSuffix}`;
154
+ }
155
+ return ORDINAL_IRREGULARS[cardinal] ?? `${cardinal}th`;
156
+ }
157
+
158
+ const CHICAGO_SYMBOLS = ["*", "†", "‡", "§", "‖", "¶"];
159
+
160
+ function toChicago(n: number): string {
161
+ if (n <= 0) {
162
+ return String(n);
163
+ }
164
+ const symbolCount = CHICAGO_SYMBOLS.length;
165
+ // Which cycle: 1-6 → repeat 1×, 7-12 → repeat 2×, etc.
166
+ const cycleIndex = Math.ceil(n / symbolCount) - 1; // 0-based repeat count
167
+ const symbolIndex = ((n - 1) % symbolCount);
168
+ const symbol = CHICAGO_SYMBOLS[symbolIndex]!;
169
+ return symbol.repeat(cycleIndex + 1);
170
+ }
171
+
172
+ /**
173
+ * Format a page number integer using an OOXML ST_NumberFormat code.
174
+ *
175
+ * @param n - The raw page number (1-based within section).
176
+ * @param format - The OOXML format code from `<w:pgNumType w:fmt="…"/>`.
177
+ * @returns Formatted string for display.
178
+ */
179
+ export function formatPageNumber(n: number, format: string | undefined): string {
180
+ switch (format) {
181
+ case undefined:
182
+ case "decimal":
183
+ return String(n);
184
+ case "upperRoman":
185
+ return toRoman(n);
186
+ case "lowerRoman":
187
+ return toRoman(n).toLowerCase();
188
+ case "upperLetter":
189
+ return toAlphabetic(n);
190
+ case "lowerLetter":
191
+ return toAlphabetic(n).toLowerCase();
192
+ case "none":
193
+ return "";
194
+ case "ordinal":
195
+ return toOrdinal(n);
196
+ case "cardinalText":
197
+ return toCardinalText(n);
198
+ case "ordinalText":
199
+ return toOrdinalText(n);
200
+ case "hex":
201
+ return n.toString(16).toUpperCase();
202
+ case "chicago":
203
+ return toChicago(n);
204
+ default:
205
+ return String(n);
206
+ }
207
+ }
@@ -22,6 +22,7 @@ import type {
22
22
  RenderPage,
23
23
  RenderStoryRegion,
24
24
  DecorationIndex,
25
+ PageChromeReservations,
25
26
  } from "./render-frame-types.ts";
26
27
 
27
28
  // ---------------------------------------------------------------------------
@@ -46,6 +47,14 @@ export interface ChangedPageEntry {
46
47
  */
47
48
  changedBlockIds: readonly string[];
48
49
  }[];
50
+ /**
51
+ * R1: Set when the page's physical frame rect changed but no individual
52
+ * block regions changed. Consumers that iterate `regions` for targeted
53
+ * re-render targets must also honour this flag to avoid silently skipping
54
+ * pages whose geometry shifted without block-level changes (e.g. zoom
55
+ * change or page margin edit that shifts every page frame uniformly).
56
+ */
57
+ pageFrameChanged?: boolean;
49
58
  }
50
59
 
51
60
  export interface RenderFrameDiff {
@@ -110,10 +119,21 @@ export function diffRenderFrames(
110
119
  continue;
111
120
  }
112
121
  const regions = diffPage(prevPage, nextPage, prev.decorationIndex, next.decorationIndex);
113
- if (regions.length === 0 && rectEquals(prevPage.frame, nextPage.frame)) {
122
+ // R1: track page-frame geometry changes independently of block regions.
123
+ const frameChanged = !rectEquals(prevPage.frame, nextPage.frame);
124
+ // R2: track chrome-reservation changes so chrome re-projection isn't skipped.
125
+ const reservationsChanged = !reservationsEqual(
126
+ prevPage.chromeReservations,
127
+ nextPage.chromeReservations,
128
+ );
129
+ if (regions.length === 0 && !frameChanged && !reservationsChanged) {
114
130
  unchangedPages.push(pageIndex);
115
131
  } else {
116
- changedPages.push({ pageIndex, regions });
132
+ changedPages.push({
133
+ pageIndex,
134
+ regions,
135
+ ...(frameChanged ? { pageFrameChanged: true } : {}),
136
+ });
117
137
  }
118
138
  }
119
139
 
@@ -268,6 +288,22 @@ function decorationHashForBlock(
268
288
  return tokens.join("|");
269
289
  }
270
290
 
291
+ // ---------------------------------------------------------------------------
292
+ // Chrome reservations compare
293
+ // ---------------------------------------------------------------------------
294
+
295
+ // R2: Compare all five reservation fields so changes to rail/balloon lanes
296
+ // or footnote area trigger changedPages, not unchangedPages.
297
+ function reservationsEqual(a: PageChromeReservations, b: PageChromeReservations): boolean {
298
+ return (
299
+ a.railLaneTwips === b.railLaneTwips &&
300
+ a.balloonLaneTwips === b.balloonLaneTwips &&
301
+ a.footnoteAreaTwips === b.footnoteAreaTwips &&
302
+ a.pageFrameWidthPx === b.pageFrameWidthPx &&
303
+ a.pageFrameHeightPx === b.pageFrameHeightPx
304
+ );
305
+ }
306
+
271
307
  // ---------------------------------------------------------------------------
272
308
  // Geometry compare
273
309
  // ---------------------------------------------------------------------------
@@ -8,6 +8,11 @@ import type {
8
8
  SurfaceTableRowSnapshot,
9
9
  SurfaceTextMark,
10
10
  } from "../api/public-types";
11
+ import {
12
+ chartModelStore,
13
+ extractChartDimensions,
14
+ stableChartId,
15
+ } from "./chart/chart-model-store.ts";
11
16
  import type {
12
17
  CanonicalDocumentEnvelope,
13
18
  SelectionSnapshot,
@@ -1011,10 +1016,25 @@ function appendInlineSegments(
1011
1016
  });
1012
1017
  return { nextCursor: start + 1, lockedFragmentIds: [node.fragmentId] };
1013
1018
  }
1014
- case "chart_preview":
1019
+ case "chart_preview": {
1020
+ let parsedChartId: string | undefined;
1021
+ if (node.parsedData) {
1022
+ parsedChartId = stableChartId(node.rawXml);
1023
+ if (!chartModelStore.has(parsedChartId)) {
1024
+ const { widthPx, heightPx } = extractChartDimensions(node.rawXml);
1025
+ chartModelStore.set(parsedChartId, {
1026
+ model: node.parsedData,
1027
+ widthPx,
1028
+ heightPx,
1029
+ theme: undefined,
1030
+ });
1031
+ }
1032
+ }
1015
1033
  return appendComplexPreviewSegment(paragraph, node, start, "Embedded chart", createChartDetail(node), {
1016
1034
  previewMediaId: node.previewMediaId,
1035
+ parsedChartId,
1017
1036
  });
1037
+ }
1018
1038
  case "smartart_preview":
1019
1039
  return appendComplexPreviewSegment(paragraph, node, start, "SmartArt diagram", createSmartArtDetail(node), {
1020
1040
  previewMediaId: node.previewMediaId,
@@ -1087,6 +1107,14 @@ function appendInlineSegments(
1087
1107
  node.fieldFamily === "PAGE" ||
1088
1108
  node.fieldFamily === "NUMPAGES";
1089
1109
  if (node.children && node.children.length > 0) {
1110
+ // For REF \h, pass the bookmark as a hyperlink href so child text gets hyperlink styling
1111
+ const refHyperlinkHref =
1112
+ node.fieldFamily === "REF" &&
1113
+ node.switches?.hyperlink === true &&
1114
+ node.fieldTarget
1115
+ ? `#${node.fieldTarget}`
1116
+ : undefined;
1117
+
1090
1118
  let cursor = start;
1091
1119
  const lockedIds: string[] = [];
1092
1120
  for (const child of node.children) {
@@ -1096,7 +1124,7 @@ function appendInlineSegments(
1096
1124
  document,
1097
1125
  cursor,
1098
1126
  promoteSecondaryStoryTextBoxes,
1099
- undefined,
1127
+ refHyperlinkHref ?? hyperlinkHref,
1100
1128
  cullBuild,
1101
1129
  );
1102
1130
  cursor = result.nextCursor;
@@ -1159,7 +1187,7 @@ function appendComplexPreviewSegment(
1159
1187
  start: number,
1160
1188
  label: string,
1161
1189
  detail: string,
1162
- extras: { previewMediaId?: string } = {},
1190
+ extras: { previewMediaId?: string; parsedChartId?: string } = {},
1163
1191
  ): { nextCursor: number; lockedFragmentIds: string[] } {
1164
1192
  paragraph.segments.push({
1165
1193
  segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
@@ -1171,6 +1199,7 @@ function appendComplexPreviewSegment(
1171
1199
  label,
1172
1200
  detail,
1173
1201
  ...(extras.previewMediaId ? { previewMediaId: extras.previewMediaId } : {}),
1202
+ ...(extras.parsedChartId ? { parsedChartId: extras.parsedChartId } : {}),
1174
1203
  state: "locked-preserve-only",
1175
1204
  });
1176
1205
  return { nextCursor: start + 1, lockedFragmentIds: [] };
@@ -220,6 +220,12 @@ import {
220
220
  getCursorColorForUser,
221
221
  setLocalCursorState,
222
222
  } from "../runtime/collab/remote-cursor-awareness.ts";
223
+ import {
224
+ stableChartId,
225
+ } from "../runtime/chart/chart-model-store.ts";
226
+ import {
227
+ projectChartSnapshot,
228
+ } from "../runtime/chart/chart-snapshot.ts";
223
229
 
224
230
  export {
225
231
  __createFallbackRuntime,
@@ -466,6 +472,40 @@ async function runConvertScopesToExternal(args: {
466
472
 
467
473
  // ---------------------------------------------------------------------------
468
474
 
475
+ type CanonicalDocType = ReturnType<WordReviewEditorRuntime["getCanonicalDocument"]>;
476
+
477
+ function collectChartSnapshots(doc: CanonicalDocType): import("../api/public-types").ChartSnapshot[] {
478
+ const results: import("../api/public-types").ChartSnapshot[] = [];
479
+ collectChartSnapshotsFromBlocks(doc.content.children, results);
480
+ return results;
481
+ }
482
+
483
+ function collectChartSnapshotsFromBlocks(
484
+ blocks: CanonicalDocType["content"]["children"],
485
+ results: import("../api/public-types").ChartSnapshot[],
486
+ ): void {
487
+ for (const block of blocks) {
488
+ if (block.type === "paragraph") {
489
+ for (const inline of block.children) {
490
+ if (inline.type === "chart_preview" && inline.parsedData) {
491
+ const chartId = stableChartId(inline.rawXml);
492
+ results.push(projectChartSnapshot(chartId, inline.parsedData));
493
+ }
494
+ }
495
+ } else if (block.type === "table") {
496
+ for (const row of block.rows) {
497
+ for (const cell of row.cells) {
498
+ collectChartSnapshotsFromBlocks(cell.children, results);
499
+ }
500
+ }
501
+ } else if (block.type === "sdt" || block.type === "custom_xml") {
502
+ collectChartSnapshotsFromBlocks(block.children, results);
503
+ }
504
+ }
505
+ }
506
+
507
+ // ---------------------------------------------------------------------------
508
+
469
509
  export function __createWordReviewEditorRefBridge(
470
510
  runtime: WordReviewEditorRuntime,
471
511
  mountedSurface?: TwProseMirrorSurfaceRef | null,
@@ -514,6 +554,12 @@ export function __createWordReviewEditorRefBridge(
514
554
  getRenderSnapshot: () => clonePublicValue(runtime.getRenderSnapshot()),
515
555
  getCompatibilityReport: () => runtime.getCompatibilityReport(),
516
556
  getWarnings: () => runtime.getWarnings(),
557
+ getChartSnapshot: (chartId) => {
558
+ return collectChartSnapshots(runtime.getCanonicalDocument()).find(
559
+ (s) => s.chartId === chartId,
560
+ ) ?? null;
561
+ },
562
+ getChartSnapshots: () => collectChartSnapshots(runtime.getCanonicalDocument()),
517
563
  getCommentSidebarSnapshot: () =>
518
564
  clonePublicValue(runtime.getRenderSnapshot().comments),
519
565
  getTrackedChangesSnapshot: () =>
@@ -782,7 +828,9 @@ export function __createWordReviewEditorRefBridge(
782
828
  clearWorkflowOverlay: () => {
783
829
  runtime.clearWorkflowOverlay();
784
830
  },
785
- getWorkflowOverlay: () => clonePublicValue(runtime.getWorkflowOverlay()),
831
+ getWorkflowOverlay: () => {
832
+ return clonePublicValue(runtime.getWorkflowOverlay());
833
+ },
786
834
  setSharedWorkflowState: (state) => {
787
835
  runtime.setSharedWorkflowState(state === null ? null : clonePublicValue(state));
788
836
  },
@@ -1535,6 +1583,11 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1535
1583
  getRenderSnapshot: () => clonePublicValue(activeRuntime.getRenderSnapshot()),
1536
1584
  getCompatibilityReport: () => activeRuntime.getCompatibilityReport(),
1537
1585
  getWarnings: () => activeRuntime.getWarnings(),
1586
+ getChartSnapshot: (chartId) =>
1587
+ collectChartSnapshots(activeRuntime.getCanonicalDocument()).find(
1588
+ (s) => s.chartId === chartId,
1589
+ ) ?? null,
1590
+ getChartSnapshots: () => collectChartSnapshots(activeRuntime.getCanonicalDocument()),
1538
1591
  getCommentSidebarSnapshot: () =>
1539
1592
  clonePublicValue(activeRuntime.getRenderSnapshot().comments),
1540
1593
  getTrackedChangesSnapshot: () =>
@@ -1834,8 +1887,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1834
1887
  clearWorkflowOverlay: () => {
1835
1888
  activeRuntime.clearWorkflowOverlay();
1836
1889
  },
1837
- getWorkflowOverlay: () =>
1838
- clonePublicValue(activeRuntime.getWorkflowOverlay()),
1890
+ getWorkflowOverlay: () => {
1891
+ return clonePublicValue(activeRuntime.getWorkflowOverlay());
1892
+ },
1839
1893
  setSharedWorkflowState: (state) => {
1840
1894
  activeRuntime.setSharedWorkflowState(state === null ? null : clonePublicValue(state));
1841
1895
  },
@@ -77,7 +77,61 @@ export function getCommentRangeState(
77
77
  };
78
78
  }
79
79
 
80
- export type MarkupDisplay = "clean" | "simple" | "all";
80
+ /**
81
+ * Markup display mode — controls how tracked changes and comments render.
82
+ *
83
+ * L6d.N2 widens the union to Word's 4-mode grammar (`"all-markup"`,
84
+ * `"simple-markup"`, `"no-markup"`, `"original"`) while keeping the
85
+ * legacy aliases `"clean" | "simple" | "all"` for backward compat with
86
+ * hosts that already pass the old values. Callers that switch on this
87
+ * value should either:
88
+ * - handle both sets of names (the decoration models do), or
89
+ * - normalize via `normalizeMarkupDisplay()` before matching.
90
+ *
91
+ * New in 6d.N2:
92
+ * - `"original"` — hide insertions entirely, render deletions as
93
+ * plain body text. Shows what the document looked like before the
94
+ * current batch of tracked changes.
95
+ */
96
+ export type MarkupDisplay =
97
+ | "all-markup"
98
+ | "simple-markup"
99
+ | "no-markup"
100
+ | "original"
101
+ // Legacy aliases — accepted on the public API; prefer the canonical names above.
102
+ | "clean"
103
+ | "simple"
104
+ | "all";
105
+
106
+ /**
107
+ * Collapse legacy `MarkupDisplay` aliases to their canonical Word-grammar
108
+ * name:
109
+ * - `"all"` → `"all-markup"`
110
+ * - `"simple"` → `"simple-markup"`
111
+ * - `"clean"` → `"no-markup"`
112
+ * - `"original"` → `"original"` (identity)
113
+ *
114
+ * The two sets remain interchangeable inside the decoration models, but
115
+ * external surfaces (selector labels, telemetry, host events) should
116
+ * normalize first so Word's grammar is the single source of truth.
117
+ */
118
+ export function normalizeMarkupDisplay(
119
+ value: MarkupDisplay,
120
+ ): "all-markup" | "simple-markup" | "no-markup" | "original" {
121
+ switch (value) {
122
+ case "all":
123
+ case "all-markup":
124
+ return "all-markup";
125
+ case "simple":
126
+ case "simple-markup":
127
+ return "simple-markup";
128
+ case "clean":
129
+ case "no-markup":
130
+ return "no-markup";
131
+ case "original":
132
+ return "original";
133
+ }
134
+ }
81
135
 
82
136
  export function getCommentHighlightClass(
83
137
  model: CommentDecorationModel | undefined,
@@ -90,10 +144,11 @@ export function getCommentHighlightClass(
90
144
  return "";
91
145
  }
92
146
 
93
- switch (markupDisplay) {
94
- case "clean":
147
+ switch (normalizeMarkupDisplay(markupDisplay)) {
148
+ case "no-markup":
149
+ case "original":
95
150
  return state.hasActive ? "bg-comment-soft" : "";
96
- case "simple":
151
+ case "simple-markup":
97
152
  if (state.hasActive) {
98
153
  return "underline decoration-comment decoration-2 underline-offset-4";
99
154
  }
@@ -101,7 +156,7 @@ export function getCommentHighlightClass(
101
156
  return "underline decoration-comment/60 decoration-1 underline-offset-4";
102
157
  }
103
158
  return "underline decoration-comment/40 decoration-1 underline-offset-4";
104
- case "all":
159
+ case "all-markup":
105
160
  if (state.hasActive) {
106
161
  return "bg-comment-strong";
107
162
  }
@@ -1,5 +1,9 @@
1
1
  import type { TrackedChangesSnapshot, TrackedChangeEntrySnapshot } from "../../api/public-types";
2
- import { rangesOverlap, type MarkupDisplay } from "./comment-decoration-model";
2
+ import {
3
+ normalizeMarkupDisplay,
4
+ rangesOverlap,
5
+ type MarkupDisplay,
6
+ } from "./comment-decoration-model";
3
7
 
4
8
  export interface RevisionDecorationModel {
5
9
  revisions: RevisionDecorationEntry[];
@@ -13,6 +17,65 @@ export interface RevisionDecorationEntry {
13
17
  status: TrackedChangeEntrySnapshot["status"];
14
18
  actionability: TrackedChangeEntrySnapshot["actionability"];
15
19
  isActive: boolean;
20
+ /** L6d.N3 — source author, used to pick the decoration color. */
21
+ authorId?: string;
22
+ /** L6d.N3 — palette slot for `authorId` (0..7, stable via FNV-1a hash). */
23
+ authorPaletteIndex?: number;
24
+ }
25
+
26
+ /**
27
+ * L6d.N3 — author-color assignment.
28
+ *
29
+ * Map `authorId` → one of the 8 `--color-chart-categorical-*` vars
30
+ * using FNV-1a 32-bit hashing. FNV-1a is deterministic, allocation-
31
+ * free, and has better avalanche on short ASCII keys than djb2, so
32
+ * typical author-id alphabets (emails, UUIDs, Slack IDs) distribute
33
+ * evenly across the 8 slots.
34
+ *
35
+ * The palette lives as CSS vars so theme swaps / reduced-contrast
36
+ * overrides in `tokens.css` cascade automatically — we emit `var(...)`
37
+ * strings, never hex literals.
38
+ */
39
+ export const AUTHOR_PALETTE: readonly string[] = [
40
+ "var(--color-chart-categorical-1)",
41
+ "var(--color-chart-categorical-2)",
42
+ "var(--color-chart-categorical-3)",
43
+ "var(--color-chart-categorical-4)",
44
+ "var(--color-chart-categorical-5)",
45
+ "var(--color-chart-categorical-6)",
46
+ "var(--color-chart-categorical-7)",
47
+ "var(--color-chart-categorical-8)",
48
+ ];
49
+
50
+ /**
51
+ * FNV-1a 32-bit hash. Constants from the Fowler–Noll–Vo reference:
52
+ * offset basis = 0x811c9dc5
53
+ * prime = 0x01000193
54
+ * The `>>> 0` keeps the value in unsigned 32-bit territory after each
55
+ * multiplication so two identical inputs always produce the same hash.
56
+ */
57
+ export function hashAuthorId(authorId: string): number {
58
+ let hash = 0x811c9dc5;
59
+ for (let i = 0; i < authorId.length; i += 1) {
60
+ hash ^= authorId.charCodeAt(i);
61
+ hash = Math.imul(hash, 0x01000193) >>> 0;
62
+ }
63
+ return hash;
64
+ }
65
+
66
+ /** Reduce a hash to a palette slot in `[0, AUTHOR_PALETTE.length)`. */
67
+ export function authorPaletteIndex(authorId: string): number {
68
+ return hashAuthorId(authorId) % AUTHOR_PALETTE.length;
69
+ }
70
+
71
+ /**
72
+ * Resolve a CSS color string for an author. Returns `undefined` when
73
+ * `authorId` is missing so callers can fall back to the default
74
+ * per-kind class (e.g. `bg-insert-soft`).
75
+ */
76
+ export function getAuthorColor(authorId: string | undefined): string | undefined {
77
+ if (!authorId) return undefined;
78
+ return AUTHOR_PALETTE[authorPaletteIndex(authorId)];
16
79
  }
17
80
 
18
81
  export interface RevisionRangeState {
@@ -47,6 +110,8 @@ export function createRevisionDecorationModel(
47
110
  status: rev.status,
48
111
  actionability: rev.actionability,
49
112
  isActive: rev.revisionId === activeRevisionId,
113
+ authorId: rev.authorId,
114
+ authorPaletteIndex: rev.authorId ? authorPaletteIndex(rev.authorId) : undefined,
50
115
  };
51
116
  }),
52
117
  };
@@ -94,12 +159,17 @@ export function getRevisionHighlightClass(
94
159
 
95
160
  const activeRing = state.hasActive ? " ring-1 ring-accent/30" : "";
96
161
 
97
- switch (markupDisplay) {
98
- case "clean":
99
- // In clean mode, deletions are hidden entirely (caller should not render).
162
+ switch (normalizeMarkupDisplay(markupDisplay)) {
163
+ case "no-markup":
164
+ // In no-markup mode, deletions are hidden entirely (caller should not render).
100
165
  // Insertions render as normal text with no decoration.
101
166
  return "";
102
- case "simple":
167
+ case "original":
168
+ // L6d.N2 — "original" shows what the doc looked like BEFORE the
169
+ // pending tracked changes: insertions are hidden (caller checks
170
+ // `shouldHideInOriginalMode`), deletions render as plain body text.
171
+ return "";
172
+ case "simple-markup":
103
173
  if (state.hasInsertions) {
104
174
  return `underline decoration-insert/60 decoration-1 underline-offset-2 text-primary${activeRing}`;
105
175
  }
@@ -107,7 +177,7 @@ export function getRevisionHighlightClass(
107
177
  return `text-secondary line-through decoration-danger/70 decoration-1${activeRing}`;
108
178
  }
109
179
  return activeRing;
110
- case "all":
180
+ case "all-markup":
111
181
  if (state.hasInsertions) {
112
182
  return `text-primary bg-insert-soft/80 ring-1 ring-insert/20${activeRing}`;
113
183
  }
@@ -118,6 +188,10 @@ export function getRevisionHighlightClass(
118
188
  }
119
189
  }
120
190
 
191
+ /**
192
+ * `no-markup` hides deletions (pretending the doc already accepted all
193
+ * pending changes). The caller skips rendering affected spans entirely.
194
+ */
121
195
  export function shouldHideInCleanMode(
122
196
  model: RevisionDecorationModel | undefined,
123
197
  from: number,
@@ -126,3 +200,17 @@ export function shouldHideInCleanMode(
126
200
  const state = getRevisionRangeState(model, from, to);
127
201
  return state.hasDeletions;
128
202
  }
203
+
204
+ /**
205
+ * L6d.N2 — `original` mode hides insertions (pretending the pending
206
+ * batch of tracked changes was rejected). Caller skips rendering
207
+ * affected spans entirely.
208
+ */
209
+ export function shouldHideInOriginalMode(
210
+ model: RevisionDecorationModel | undefined,
211
+ from: number,
212
+ to: number,
213
+ ): boolean {
214
+ const state = getRevisionRangeState(model, from, to);
215
+ return state.hasInsertions;
216
+ }