@beyondwork/docx-react-component 1.0.53 → 1.0.54

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 (86) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +35 -7
  3. package/src/io/docx-session.ts +30 -6
  4. package/src/runtime/collab/checkpoint-store.ts +1 -1
  5. package/src/runtime/collab/event-types.ts +4 -0
  6. package/src/runtime/collab/runtime-collab-sync.ts +1 -2
  7. package/src/runtime/document-runtime.ts +23 -9
  8. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  9. package/src/runtime/layout/layout-engine-version.ts +58 -1
  10. package/src/runtime/layout/layout-invalidation.ts +150 -30
  11. package/src/runtime/layout/page-graph.ts +19 -0
  12. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  13. package/src/runtime/layout/project-block-fragments.ts +27 -0
  14. package/src/runtime/layout/public-facet.ts +27 -0
  15. package/src/runtime/render/render-frame-diff.ts +38 -2
  16. package/src/ui/WordReviewEditor.tsx +6 -3
  17. package/src/ui/headless/comment-decoration-model.ts +60 -5
  18. package/src/ui/headless/revision-decoration-model.ts +94 -6
  19. package/src/ui/shared/revision-filters.ts +16 -6
  20. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  21. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  22. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  23. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  24. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  25. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  26. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  27. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  28. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  29. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  30. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  31. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  32. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  33. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  34. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  35. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  36. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  37. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  38. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  39. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  40. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  41. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  42. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  43. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  44. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  45. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  46. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  47. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  48. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  49. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  50. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  51. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  52. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  53. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  54. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  55. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  56. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  57. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  58. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  59. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  60. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  61. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  62. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  63. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  64. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  65. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  66. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  67. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  68. package/src/ui-tailwind/index.ts +11 -0
  69. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
  70. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
  71. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
  72. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  73. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  74. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  75. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  76. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  77. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  78. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  79. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  80. package/src/ui-tailwind/theme/editor-theme.css +249 -22
  81. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  82. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  83. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  84. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  85. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  86. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
@@ -81,7 +81,7 @@ import {
81
81
  export type LayoutInvalidationReason =
82
82
  | { kind: "content-edit"; from: number; to: number }
83
83
  | { kind: "section-change"; sectionIndex: number }
84
- | { kind: "styles-change" }
84
+ | { kind: "styles-change"; dirtyStyleIds?: readonly string[] }
85
85
  | { kind: "theme-change" }
86
86
  | { kind: "numbering-change"; numberingInstanceId?: string }
87
87
  | { kind: "field-refresh"; family?: string }
@@ -416,28 +416,137 @@ export function buildPageStackFromWithSplits(
416
416
  resumeAt: { startPageIndex: number; startOffset: number },
417
417
  measurementProvider?: LayoutMeasurementProvider,
418
418
  ): PageStackResultWithSplits {
419
- void resumeAt.startOffset;
420
- const full = buildPageStackWithSplits(
421
- document,
422
- sections as ResolvedDocumentSection[],
423
- mainSurface,
424
- measurementProvider,
419
+ const startOffset = resumeAt.startOffset;
420
+ const dirtyPageNumberOffset = Math.max(0, resumeAt.startPageIndex);
421
+
422
+ // Fast path: offset at document start → full paginate is identical.
423
+ if (startOffset <= 0) {
424
+ return buildPageStackWithSplits(
425
+ document,
426
+ sections as ResolvedDocumentSection[],
427
+ mainSurface,
428
+ measurementProvider,
429
+ );
430
+ }
431
+
432
+ // Find the first section that contains or starts after startOffset.
433
+ const firstDirtySectionIdx = (sections as ResolvedDocumentSection[]).findIndex(
434
+ (s) => s.end > startOffset,
425
435
  );
426
- const startIndex = Math.max(0, resumeAt.startPageIndex);
427
- const tailPages = full.pages.slice(startIndex);
428
- const tailSplits = new Map<string, ParagraphLineSlice[]>();
429
- for (const [blockId, slices] of full.splits.byBlockId) {
430
- const tail = slices.filter((s) => s.pageIndex >= startIndex);
431
- if (tail.length > 0) tailSplits.set(blockId, tail);
436
+ if (firstDirtySectionIdx < 0) {
437
+ // All sections end before the dirty offset — nothing to re-paginate.
438
+ return buildPageStackWithSplits(
439
+ document,
440
+ sections as ResolvedDocumentSection[],
441
+ mainSurface,
442
+ measurementProvider,
443
+ );
432
444
  }
433
- const tailTableSplits = new Map<string, TableRowSlice[]>();
434
- for (const [blockId, slices] of full.splits.tablesByBlockId) {
435
- const tail = slices.filter((s) => s.pageIndex >= startIndex);
436
- if (tail.length > 0) tailTableSplits.set(blockId, tail);
445
+
446
+ // Safety guard: if any block straddles the section boundary (to > startOffset
447
+ // but from < section.start), we cannot safely skip without reconstructing
448
+ // carry-over state fall back to full paginate to avoid silent correctness loss.
449
+ const dirtySectionStart = (sections as ResolvedDocumentSection[])[firstDirtySectionIdx]!.start;
450
+ const straddling = mainSurface.blocks.some(
451
+ (b) => b.from < dirtySectionStart && b.to > startOffset,
452
+ );
453
+ // When the dirty section is section 0, there is nothing to skip — fall
454
+ // back to the full-build + tail-slice path so page startOffsets are
455
+ // correct (the section-skip path shifts only pageIndex, not startOffset,
456
+ // which would produce phantom pages when spliceGraph prepends page 0).
457
+ if (straddling || firstDirtySectionIdx === 0) {
458
+ const full = buildPageStackWithSplits(
459
+ document,
460
+ sections as ResolvedDocumentSection[],
461
+ mainSurface,
462
+ measurementProvider,
463
+ );
464
+ const tailPages = full.pages.slice(dirtyPageNumberOffset);
465
+ const tailSplits = new Map<string, ParagraphLineSlice[]>();
466
+ for (const [blockId, slices] of full.splits.byBlockId) {
467
+ const tail = slices.filter((s) => s.pageIndex >= dirtyPageNumberOffset);
468
+ if (tail.length > 0) tailSplits.set(blockId, tail);
469
+ }
470
+ const tailTableSplits = new Map<string, TableRowSlice[]>();
471
+ for (const [blockId, slices] of full.splits.tablesByBlockId) {
472
+ const tail = slices.filter((s) => s.pageIndex >= dirtyPageNumberOffset);
473
+ if (tail.length > 0) tailTableSplits.set(blockId, tail);
474
+ }
475
+ return {
476
+ pages: tailPages,
477
+ splits: { byBlockId: tailSplits, tablesByBlockId: tailTableSplits },
478
+ };
437
479
  }
480
+
481
+ // Section-level skip: paginate only the dirty sections onward (only
482
+ // reached when firstDirtySectionIdx > 0).
483
+ const dirtySections = (sections as ResolvedDocumentSection[]).slice(firstDirtySectionIdx);
484
+ const dirtyBlockSet = new Set(
485
+ mainSurface.blocks
486
+ .filter((b) => b.from >= dirtySectionStart)
487
+ .map((b) => b.blockId),
488
+ );
489
+ const dirtySurface: EditorSurfaceSnapshot = {
490
+ ...mainSurface,
491
+ blocks: mainSurface.blocks.filter((b) => dirtyBlockSet.has(b.blockId)),
492
+ };
493
+
494
+ const tailResult = buildPageStackWithSplits(
495
+ document,
496
+ dirtySections,
497
+ dirtySurface,
498
+ measurementProvider,
499
+ );
500
+
501
+ // Shift global page indices on all returned pages and splits so they align
502
+ // with the global page graph (the caller's spliceGraph prepends the head).
503
+ const shiftedPages = tailResult.pages.map((p) => ({
504
+ ...p,
505
+ pageIndex: p.pageIndex + dirtyPageNumberOffset,
506
+ }));
507
+ const shiftedSplits: BlockSplits = {
508
+ byBlockId: new Map(
509
+ Array.from(tailResult.splits.byBlockId.entries()).map(([id, slices]) => [
510
+ id,
511
+ slices.map((s) => ({ ...s, pageIndex: s.pageIndex + dirtyPageNumberOffset })),
512
+ ]),
513
+ ),
514
+ tablesByBlockId: new Map(
515
+ Array.from(tailResult.splits.tablesByBlockId.entries()).map(([id, slices]) => [
516
+ id,
517
+ slices.map((s) => ({ ...s, pageIndex: s.pageIndex + dirtyPageNumberOffset })),
518
+ ]),
519
+ ),
520
+ };
521
+ // Shift note-allocation page indices if present.
522
+ const shiftedNoteAllocs =
523
+ tailResult.noteAllocationsByPageIndex && tailResult.noteAllocationsByPageIndex.size > 0
524
+ ? new Map(
525
+ Array.from(tailResult.noteAllocationsByPageIndex.entries()).map(([pi, allocs]) => [
526
+ pi + dirtyPageNumberOffset,
527
+ allocs,
528
+ ]),
529
+ )
530
+ : undefined;
531
+ const shiftedNoteFragments =
532
+ tailResult.noteFragmentsByPageIndex && tailResult.noteFragmentsByPageIndex.size > 0
533
+ ? new Map(
534
+ Array.from(tailResult.noteFragmentsByPageIndex.entries()).map(([pi, frags]) => [
535
+ pi + dirtyPageNumberOffset,
536
+ frags,
537
+ ]),
538
+ )
539
+ : undefined;
540
+
438
541
  return {
439
- pages: tailPages,
440
- splits: { byBlockId: tailSplits, tablesByBlockId: tailTableSplits },
542
+ pages: shiftedPages,
543
+ splits: shiftedSplits,
544
+ ...(shiftedNoteAllocs !== undefined
545
+ ? { noteAllocationsByPageIndex: shiftedNoteAllocs }
546
+ : {}),
547
+ ...(shiftedNoteFragments !== undefined
548
+ ? { noteFragmentsByPageIndex: shiftedNoteFragments }
549
+ : {}),
441
550
  };
442
551
  }
443
552
 
@@ -109,6 +109,7 @@ export function projectSurfaceBlocksToPageFragments(
109
109
  from: block.from,
110
110
  to: block.to,
111
111
  heightTwips: estimateBlockHeightFromSpan(block),
112
+ ...deriveStyleMetadata(block),
112
113
  kind: "whole",
113
114
  };
114
115
 
@@ -138,6 +139,7 @@ function emitSlicedParagraph(
138
139
  from: block.from,
139
140
  to: block.to,
140
141
  heightTwips: estimateSliceHeightFromLines(slice.lineRange),
142
+ ...deriveStyleMetadata(block),
141
143
  kind: "paragraph-slice",
142
144
  paragraphLineRange: slice.lineRange,
143
145
  };
@@ -178,6 +180,7 @@ function emitSlicedTable(
178
180
  from: block.from,
179
181
  to: block.to,
180
182
  heightTwips: estimateSliceHeightFromRows(slice.rowRange),
183
+ ...deriveStyleMetadata(block),
181
184
  kind: "table-slice",
182
185
  tableRowRange: slice.rowRange,
183
186
  };
@@ -223,3 +226,27 @@ function estimateBlockHeightFromSpan(block: SurfaceBlockSnapshot): number {
223
226
  const lines = Math.max(1, Math.ceil(span / 80));
224
227
  return lines * 240;
225
228
  }
229
+
230
+ /**
231
+ * Slice 5 — extract style metadata from a surface block for use in
232
+ * bounded invalidation. `resolvedStyleChainRef` is the block's styleId
233
+ * (paragraphs and tables only); `numberingInstanceId` comes from the
234
+ * block's list-instance (paragraphs only). Both fields are optional and
235
+ * absent when the block carries no style or numbering data.
236
+ */
237
+ function deriveStyleMetadata(
238
+ block: SurfaceBlockSnapshot,
239
+ ): Pick<FragmentWithoutPageId, "resolvedStyleChainRef" | "numberingInstanceId"> {
240
+ const styleId =
241
+ block.kind === "paragraph" || block.kind === "table"
242
+ ? (block.styleId ?? undefined)
243
+ : undefined;
244
+ const numberingInstanceId =
245
+ block.kind === "paragraph"
246
+ ? (block.numbering?.numberingInstanceId ?? undefined)
247
+ : undefined;
248
+ return {
249
+ ...(styleId !== undefined ? { resolvedStyleChainRef: styleId } : {}),
250
+ ...(numberingInstanceId !== undefined ? { numberingInstanceId } : {}),
251
+ };
252
+ }
@@ -560,6 +560,15 @@ export interface WordReviewEditorLayoutFacet {
560
560
  pageIndex: number,
561
561
  ): import("./table-render-plan.ts").TableRenderPlan | null;
562
562
 
563
+ /**
564
+ * Returns the Y offset (in twips from the top of the page body) where this
565
+ * table fragment starts on the given page. Computed by summing
566
+ * `heightTwips` of all body fragments with `orderInRegion` less than the
567
+ * table fragment's order. Returns `null` when the block is not found on
568
+ * the specified page.
569
+ */
570
+ getTableBodyYOffsetOnPage(blockId: string, pageIndex: number): number | null;
571
+
563
572
  // Fields ---------------------------------------------------------------
564
573
  getDirtyFieldFamilies(): readonly string[];
565
574
  getFieldDirtinessReport(): PublicFieldDirtinessReport;
@@ -1255,6 +1264,24 @@ export function createLayoutFacet(
1255
1264
  });
1256
1265
  },
1257
1266
 
1267
+ getTableBodyYOffsetOnPage(blockId, pageIndex) {
1268
+ const graph = currentGraph();
1269
+ const pageNode = graph.pages.find(
1270
+ (p) => p.pageIndex === pageIndex && !p.isBlankFiller,
1271
+ );
1272
+ if (!pageNode) return null;
1273
+ const bodyFragmentIdSet = new Set(pageNode.regions.body.fragmentIds);
1274
+ const bodyFragments = graph.fragments
1275
+ .filter((f) => bodyFragmentIdSet.has(f.fragmentId))
1276
+ .sort((a, b) => a.orderInRegion - b.orderInRegion);
1277
+ let yOffset = 0;
1278
+ for (const frag of bodyFragments) {
1279
+ if (frag.blockId === blockId) return yOffset;
1280
+ yOffset += frag.heightTwips;
1281
+ }
1282
+ return null;
1283
+ },
1284
+
1258
1285
  getDirtyFieldFamilies() {
1259
1286
  return engine.getDirtyFieldFamilies();
1260
1287
  },
@@ -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
  // ---------------------------------------------------------------------------
@@ -782,7 +782,9 @@ export function __createWordReviewEditorRefBridge(
782
782
  clearWorkflowOverlay: () => {
783
783
  runtime.clearWorkflowOverlay();
784
784
  },
785
- getWorkflowOverlay: () => clonePublicValue(runtime.getWorkflowOverlay()),
785
+ getWorkflowOverlay: () => {
786
+ return clonePublicValue(runtime.getWorkflowOverlay());
787
+ },
786
788
  setSharedWorkflowState: (state) => {
787
789
  runtime.setSharedWorkflowState(state === null ? null : clonePublicValue(state));
788
790
  },
@@ -1834,8 +1836,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1834
1836
  clearWorkflowOverlay: () => {
1835
1837
  activeRuntime.clearWorkflowOverlay();
1836
1838
  },
1837
- getWorkflowOverlay: () =>
1838
- clonePublicValue(activeRuntime.getWorkflowOverlay()),
1839
+ getWorkflowOverlay: () => {
1840
+ return clonePublicValue(activeRuntime.getWorkflowOverlay());
1841
+ },
1839
1842
  setSharedWorkflowState: (state) => {
1840
1843
  activeRuntime.setSharedWorkflowState(state === null ? null : clonePublicValue(state));
1841
1844
  },
@@ -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
+ }
@@ -1,20 +1,24 @@
1
1
  import type { RuntimeRenderSnapshot } from "../../api/public-types";
2
+ import {
3
+ normalizeMarkupDisplay,
4
+ type MarkupDisplay,
5
+ } from "../headless/comment-decoration-model";
2
6
 
3
7
  type Revision = RuntimeRenderSnapshot["trackedChanges"]["revisions"][number];
4
- type MarkupDisplay = "clean" | "simple" | "all";
5
8
 
6
9
  export function selectVisibleRevisions(
7
10
  revisions: readonly Revision[],
8
11
  markupDisplay: MarkupDisplay,
9
12
  ): Revision[] {
10
- switch (markupDisplay) {
11
- case "clean":
12
- case "simple":
13
+ switch (normalizeMarkupDisplay(markupDisplay)) {
14
+ case "no-markup":
15
+ case "simple-markup":
16
+ case "original":
13
17
  return revisions.filter(
14
18
  (revision) =>
15
19
  revision.status === "active" && revision.actionability === "actionable",
16
20
  );
17
- case "all":
21
+ case "all-markup":
18
22
  return [...revisions];
19
23
  }
20
24
  }
@@ -23,7 +27,13 @@ export function describeEmptyRevisionState(
23
27
  markupDisplay: MarkupDisplay,
24
28
  totalCount: number,
25
29
  ): string {
26
- if ((markupDisplay === "clean" || markupDisplay === "simple") && totalCount > 0) {
30
+ const canonical = normalizeMarkupDisplay(markupDisplay);
31
+ if (
32
+ (canonical === "no-markup" ||
33
+ canonical === "simple-markup" ||
34
+ canonical === "original") &&
35
+ totalCount > 0
36
+ ) {
27
37
  return "Simple markup keeps the rail focused on actionable live changes. Switch to All to inspect preserve-only or historical revision records.";
28
38
  }
29
39