@beyondwork/docx-react-component 1.0.52 → 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 (103) hide show
  1. package/package.json +31 -40
  2. package/src/api/public-types.ts +67 -7
  3. package/src/io/chart-preview-resolver.ts +41 -0
  4. package/src/io/docx-session.ts +217 -23
  5. package/src/runtime/collab/checkpoint-store.ts +1 -1
  6. package/src/runtime/collab/event-types.ts +4 -0
  7. package/src/runtime/collab/runtime-collab-sync.ts +88 -8
  8. package/src/runtime/document-runtime.ts +182 -9
  9. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  10. package/src/runtime/layout/layout-engine-version.ts +97 -2
  11. package/src/runtime/layout/layout-invalidation.ts +150 -30
  12. package/src/runtime/layout/page-graph.ts +19 -0
  13. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  14. package/src/runtime/layout/project-block-fragments.ts +27 -0
  15. package/src/runtime/layout/public-facet.ts +70 -1
  16. package/src/runtime/prerender/cache-envelope.ts +30 -0
  17. package/src/runtime/prerender/customxml-cache.ts +17 -3
  18. package/src/runtime/prerender/prerender-document.ts +17 -1
  19. package/src/runtime/render/render-frame-diff.ts +38 -2
  20. package/src/runtime/render/render-kernel.ts +67 -19
  21. package/src/runtime/surface-projection.ts +28 -0
  22. package/src/runtime/table-schema.ts +27 -0
  23. package/src/runtime/table-style-resolver.ts +51 -0
  24. package/src/ui/WordReviewEditor.tsx +6 -3
  25. package/src/ui/editor-runtime-boundary.ts +39 -2
  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/perf-probe.ts +1 -0
  76. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  77. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
  78. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  79. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
  80. package/src/ui-tailwind/index.ts +11 -0
  81. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
  82. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +274 -0
  83. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +15 -2
  84. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +15 -2
  85. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +19 -147
  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/chart-palette-adapter.ts +57 -0
  94. package/src/ui-tailwind/theme/editor-theme.css +275 -46
  95. package/src/ui-tailwind/theme/tokens.css +345 -0
  96. package/src/ui-tailwind/theme/tokens.ts +313 -0
  97. package/src/ui-tailwind/theme/use-density.ts +60 -0
  98. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  99. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  100. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  101. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  102. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  103. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
@@ -10,7 +10,11 @@ import {
10
10
  } from "../layout/layout-engine-version.ts";
11
11
  import { createLayoutEngine } from "../layout/layout-engine-instance.ts";
12
12
  import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
13
- import type { CacheEnvelope } from "./cache-envelope.ts";
13
+ import { buildCompatibilityReport } from "../../validation/compatibility-engine.ts";
14
+ import {
15
+ CACHE_NORMALIZED_GENERATED_AT,
16
+ type CacheEnvelope,
17
+ } from "./cache-envelope.ts";
14
18
  import {
15
19
  computeStructuralHash,
16
20
  deriveCacheKey,
@@ -167,6 +171,17 @@ export async function prerenderDocument(
167
171
  canonicalDocumentHash,
168
172
  });
169
173
 
174
+ // Phase 2 Finale C3: pre-compute `compatibilityReport` so the warm Plan B
175
+ // short-circuit in `loadDocxEditorSessionAsync` can skip the live
176
+ // `buildCompatibilityReport` call (~60–100 ms on extra-large). The report
177
+ // is a pure function of `canonicalDocument` + `generatedAt`; pinning
178
+ // `generatedAt` to `CACHE_NORMALIZED_GENERATED_AT` keeps the envelope
179
+ // byte-identical across two sequential prerenders on identical input.
180
+ const compatibilityReport = buildCompatibilityReport({
181
+ document: envelope,
182
+ generatedAt: CACHE_NORMALIZED_GENERATED_AT,
183
+ });
184
+
170
185
  const cacheBlob: CacheEnvelope = {
171
186
  schemaVersion: LAYCACHE_SCHEMA_VERSION,
172
187
  engineVersion: LAYOUT_ENGINE_VERSION,
@@ -176,6 +191,7 @@ export async function prerenderDocument(
176
191
  graph,
177
192
  surface,
178
193
  canonicalDocument: envelope,
194
+ compatibilityReport,
179
195
  };
180
196
 
181
197
  // Plan B B.5 — persistToCustomXml: inject the envelope into the docx's
@@ -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
  // ---------------------------------------------------------------------------
@@ -23,12 +23,8 @@ import type {
23
23
  PublicRegionBlock,
24
24
  WordReviewEditorLayoutFacet,
25
25
  } from "../layout/public-facet.ts";
26
- import type {
27
- CommentDecorationModel,
28
- } from "../../ui/headless/comment-decoration-model.ts";
29
- import type {
30
- RevisionDecorationModel,
31
- } from "../../ui/headless/revision-decoration-model.ts";
26
+ import type { CommentDecorationModel } from "../../ui/headless/comment-decoration-model.ts";
27
+ import type { RevisionDecorationModel } from "../../ui/headless/revision-decoration-model.ts";
32
28
  import type { ScopeRailSegment } from "../workflow-rail-segments.ts";
33
29
  import {
34
30
  resolveDecorationIndex,
@@ -148,6 +144,24 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
148
144
  // is an optimizer for repeat reads at the same revision.
149
145
  let lastEmittedFrame: RenderFrame | null = null;
150
146
 
147
+ // P14.c — single-slot DecorationIndex cache. `resolveDecorationIndex`
148
+ // is O(n × m) in decorations × offsets; it is safe to reuse the prior
149
+ // result when the layout revision, active story, zoom, and every source
150
+ // reference are all unchanged from the previous build. The frame-level
151
+ // `cache` already prevents rebuilds for repeat reads; this slot covers
152
+ // the post-invalidate rebuild where layout changed but decorations did not.
153
+ let diCacheKey: {
154
+ revision: number;
155
+ activeStoryKind: string;
156
+ zoomPxPerTwip: number;
157
+ workflowSegments: readonly ScopeRailSegment[] | undefined;
158
+ comments: CommentDecorationModel | null | undefined;
159
+ revisions: RevisionDecorationModel | null | undefined;
160
+ searchMatches: readonly SearchMatchRange[] | undefined;
161
+ lockedRanges: readonly LockedRangeInput[] | undefined;
162
+ } | null = null;
163
+ let diCacheValue: DecorationIndex | null = null;
164
+
151
165
  const listeners = new Set<(event: RenderKernelEvent) => void>();
152
166
  const unsubscribeFacet = facet.subscribe((event) => {
153
167
  // Any layout-changing event invalidates the cached frame. We rely on
@@ -208,6 +222,14 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
208
222
  pendingDeltas,
209
223
  zoom.pxPerTwip,
210
224
  );
225
+ // Revision: keyed off the engine's current page graph so repeated reads
226
+ // at the same revision return the same cached frame. We derive it
227
+ // from the first page since the engine stamps every page with the
228
+ // graph's revision indirectly via pageId; fall back to 0 if empty.
229
+ const revision = filteredPages[0]
230
+ ? Number(extractRevisionFromPageId(filteredPages[0].pageId))
231
+ : 0;
232
+
211
233
  const includeDecorations = options?.includeDecorations ?? true;
212
234
  const sources = input.getDecorationSources?.();
213
235
  const hasSources =
@@ -217,11 +239,45 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
217
239
  (sources.revisions?.revisions?.length ?? 0) > 0 ||
218
240
  (sources.searchMatches && sources.searchMatches.length > 0) ||
219
241
  (sources.lockedRanges && sources.lockedRanges.length > 0));
220
- const decorationIndex: DecorationIndex = !includeDecorations
221
- ? EMPTY_DECORATION_INDEX
222
- : hasSources
223
- ? resolveDecorationIndex({ anchorIndex: baseAnchorIndex, ...sources })
224
- : buildDecorationIndex(renderPages);
242
+ // P14.c DecorationIndex single-slot cache. Check reference equality
243
+ // on every source before calling the O(n × m) resolver.
244
+ const newDIKey = {
245
+ revision: Number.isFinite(revision) ? revision : 0,
246
+ activeStoryKind: activeStory.kind,
247
+ zoomPxPerTwip: zoom.pxPerTwip,
248
+ workflowSegments: sources?.workflowSegments,
249
+ comments: sources?.comments,
250
+ revisions: sources?.revisions,
251
+ searchMatches: sources?.searchMatches,
252
+ lockedRanges: sources?.lockedRanges,
253
+ };
254
+ const diCacheHit =
255
+ hasSources &&
256
+ diCacheKey !== null &&
257
+ diCacheValue !== null &&
258
+ diCacheKey.revision === newDIKey.revision &&
259
+ diCacheKey.activeStoryKind === newDIKey.activeStoryKind &&
260
+ diCacheKey.zoomPxPerTwip === newDIKey.zoomPxPerTwip &&
261
+ diCacheKey.workflowSegments === newDIKey.workflowSegments &&
262
+ diCacheKey.comments === newDIKey.comments &&
263
+ diCacheKey.revisions === newDIKey.revisions &&
264
+ diCacheKey.searchMatches === newDIKey.searchMatches &&
265
+ diCacheKey.lockedRanges === newDIKey.lockedRanges;
266
+
267
+ let decorationIndex: DecorationIndex;
268
+ if (!includeDecorations) {
269
+ decorationIndex = EMPTY_DECORATION_INDEX;
270
+ } else if (hasSources) {
271
+ if (diCacheHit) {
272
+ decorationIndex = diCacheValue!;
273
+ } else {
274
+ decorationIndex = resolveDecorationIndex({ anchorIndex: baseAnchorIndex, ...sources });
275
+ diCacheKey = newDIKey;
276
+ diCacheValue = decorationIndex;
277
+ }
278
+ } else {
279
+ decorationIndex = buildDecorationIndex(renderPages);
280
+ }
225
281
  const anchorIndex = buildAnchorIndex(
226
282
  renderPages,
227
283
  pendingDeltas,
@@ -229,14 +285,6 @@ export function createRenderKernel(input: CreateRenderKernelInput): RenderKernel
229
285
  decorationIndex,
230
286
  );
231
287
 
232
- // Revision: keyed off the engine's current page graph so repeated reads
233
- // at the same revision return the same cached frame. We derive it
234
- // from the first page since the engine stamps every page with the
235
- // graph's revision indirectly via pageId; fall back to 0 if empty.
236
- const revision = filteredPages[0]
237
- ? Number(extractRevisionFromPageId(filteredPages[0].pageId))
238
- : 0;
239
-
240
288
  const frame: RenderFrame = {
241
289
  revision: Number.isFinite(revision) ? revision : 0,
242
290
  measurementFidelity,
@@ -419,6 +419,9 @@ function createTableBlock(
419
419
  ...(cellBorders.borderRight ? { borderRight: cellBorders.borderRight } : {}),
420
420
  ...(cellBorders.borderBottom ? { borderBottom: cellBorders.borderBottom } : {}),
421
421
  ...(cellBorders.borderLeft ? { borderLeft: cellBorders.borderLeft } : {}),
422
+ // R3.a Phase 2: per-cell text-flow direction copied from canonical
423
+ // TableCellNode. Node-view renders `tbRl` / `btLr` as CSS writing-mode.
424
+ ...(cell.textDirection ? { textDirection: cell.textDirection } : {}),
422
425
  ...(bandClasses ? { bandClasses } : {}),
423
426
  content: cellContent,
424
427
  });
@@ -437,6 +440,30 @@ function createTableBlock(
437
440
  });
438
441
  }
439
442
 
443
+ // R3.a Phase 2 — fold the resolver's table-level bundle onto the surface
444
+ // block when any field is populated. Borders carry the typed BorderSpec per
445
+ // side; the node-view converts the four outer sides to CSS shorthand at
446
+ // render time. insideH / insideV flow through for round-trip + future use,
447
+ // but their visual effect is realized via per-cell borders today.
448
+ const tr = resolvedTable.tableResolved;
449
+ const trHasContent =
450
+ tr.width !== undefined ||
451
+ tr.layoutMode !== undefined ||
452
+ tr.cellSpacing !== undefined ||
453
+ tr.borders !== undefined;
454
+ const tableResolvedAttr = trHasContent
455
+ ? {
456
+ ...(tr.width !== undefined ? { width: tr.width } : {}),
457
+ ...(tr.widthType !== undefined ? { widthType: tr.widthType } : {}),
458
+ ...(tr.layoutMode !== undefined ? { layoutMode: tr.layoutMode } : {}),
459
+ ...(tr.cellSpacing !== undefined ? { cellSpacing: tr.cellSpacing } : {}),
460
+ ...(tr.cellSpacingType !== undefined
461
+ ? { cellSpacingType: tr.cellSpacingType }
462
+ : {}),
463
+ ...(tr.borders ? { borders: tr.borders } : {}),
464
+ }
465
+ : undefined;
466
+
440
467
  return {
441
468
  block: {
442
469
  blockId: `table-${tableIndex}`,
@@ -447,6 +474,7 @@ function createTableBlock(
447
474
  gridColumns: table.gridColumns,
448
475
  ...(resolvedTable.table?.alignment ? { alignment: resolvedTable.table.alignment } : {}),
449
476
  tblLook: resolvedTable.effectiveTblLook,
477
+ ...(tableResolvedAttr ? { tableResolved: tableResolvedAttr } : {}),
450
478
  rows,
451
479
  },
452
480
  lockedFragmentIds,
@@ -42,6 +42,8 @@ type TableCellAttrs = {
42
42
  borderRight?: string | null;
43
43
  borderBottom?: string | null;
44
44
  borderLeft?: string | null;
45
+ /** R3.a Phase 2 — per-cell text-flow direction. */
46
+ textDirection?: "lrTb" | "tbRl" | "btLr" | null;
45
47
  bandClasses?: string | null;
46
48
  };
47
49
 
@@ -188,6 +190,13 @@ const tableCellSpecAttrs = {
188
190
  borderRight: { default: null },
189
191
  borderBottom: { default: null },
190
192
  borderLeft: { default: null },
193
+ /**
194
+ * R3.a Phase 2 — per-cell text-flow direction copied from
195
+ * `TableCellNode.textDirection` ("lrTb" | "tbRl" | "btLr"). Node-view maps
196
+ * `tbRl` → `writing-mode: vertical-rl`, `btLr` → `writing-mode: vertical-lr`,
197
+ * `lrTb` (or null) → unset.
198
+ */
199
+ textDirection: { default: null },
191
200
  /** R2b: space-joined band classes ("band-firstRow band-band1Horz") from the resolved style. */
192
201
  bandClasses: { default: null },
193
202
  } as const;
@@ -210,6 +219,24 @@ export const tableNodeSpec: NodeSpec = {
210
219
  tblLookNoVBand: { default: false },
211
220
  /** R2d: raw `w:tblLook/@w:val` hex preserved verbatim so vendor-extended bits survive round-trip. */
212
221
  tblLookVal: { default: null },
222
+ // R3.a Phase 2 — table-level resolved properties projected from
223
+ // ResolvedTableLevelProperties. Each is nullable so PM's null-vs-undefined
224
+ // preference is satisfied. Width is in twips (dxa) or fiftieths-of-percent
225
+ // (pct); spacing is twips (dxa). Borders are CSS shorthand strings (e.g.
226
+ // "1px solid #000000") for the four outer sides; insideH / insideV are
227
+ // realized through per-cell border attrs (CSS has no native
228
+ // table-inside-border property — see applyTableAttrs comment).
229
+ tableWidth: { default: null },
230
+ tableWidthType: { default: null },
231
+ tableLayoutMode: { default: null },
232
+ tableCellSpacing: { default: null },
233
+ tableCellSpacingType: { default: null },
234
+ tableBorderTop: { default: null },
235
+ tableBorderRight: { default: null },
236
+ tableBorderBottom: { default: null },
237
+ tableBorderLeft: { default: null },
238
+ tableBorderInsideH: { default: null },
239
+ tableBorderInsideV: { default: null },
213
240
  },
214
241
  parseDOM: [{ tag: "table" }],
215
242
  toDOM(node) {
@@ -27,10 +27,31 @@ export interface ResolvedTableRowStyle {
27
27
  activeConditionalRegions: TableStyleConditionalRegion[];
28
28
  }
29
29
 
30
+ /**
31
+ * R3.a Phase 2 — typed bundle of table-level resolved properties projected onto
32
+ * the surface snapshot so the PM schema + node-view can render them inline at
33
+ * commit time.
34
+ *
35
+ * Borders are kept as the typed `TableBorders` shape here (BorderSpec per side);
36
+ * the surface-projection step converts them to per-side CSS shorthand strings
37
+ * (e.g. "1px solid #000") to mirror the existing `resolveCellBorderStyles`
38
+ * pattern at `surface-projection.ts:506`.
39
+ */
40
+ export interface ResolvedTableLevelProperties {
41
+ width?: number;
42
+ widthType?: TableWidth["type"];
43
+ layoutMode?: "fixed" | "autofit";
44
+ cellSpacing?: number;
45
+ cellSpacingType?: TableWidth["type"];
46
+ borders?: TableBorders;
47
+ }
48
+
30
49
  export interface ResolvedTableStyleResolution {
31
50
  rawTblLook?: TableLook;
32
51
  effectiveTblLook: TableLook;
33
52
  table?: TableStyleFormatting["table"];
53
+ /** R3.a Phase 2 — table-level resolved properties (width / layout / spacing / borders). */
54
+ tableResolved: ResolvedTableLevelProperties;
34
55
  rows: Array<{
35
56
  style: ResolvedTableRowStyle;
36
57
  cells: ResolvedTableCellStyle[];
@@ -140,10 +161,40 @@ export function resolveTableStyleResolution(
140
161
  },
141
162
  );
142
163
 
164
+ // R3.a Phase 2 — collect the table-level resolved property bundle. Direct
165
+ // TableNode properties win over the resolved table-style formatting; both
166
+ // paths still populate the optional fields independently so a partial style
167
+ // (e.g. style provides borders, direct provides width) merges cleanly.
168
+ const resolvedTableLevel: ResolvedTableLevelProperties = {};
169
+ const styleTable = resolvedStyle.formatting?.table;
170
+ const directWidth = table.width;
171
+ const styleWidth = styleTable?.width;
172
+ if (directWidth) {
173
+ resolvedTableLevel.width = directWidth.value;
174
+ resolvedTableLevel.widthType = directWidth.type;
175
+ } else if (styleWidth) {
176
+ resolvedTableLevel.width = styleWidth.value;
177
+ resolvedTableLevel.widthType = styleWidth.type;
178
+ }
179
+ if (table.layoutMode) {
180
+ resolvedTableLevel.layoutMode = table.layoutMode;
181
+ }
182
+ if (table.cellSpacing) {
183
+ resolvedTableLevel.cellSpacing = table.cellSpacing.value;
184
+ resolvedTableLevel.cellSpacingType = table.cellSpacing.type;
185
+ }
186
+ // Borders: prefer direct TableNode.borders, fall back to merged style borders.
187
+ // Both shapes are TableBorders so we can hand the side-bag straight back.
188
+ const mergedBorders = mergeBorderMap<TableBorders>(styleTable?.borders, table.borders);
189
+ if (mergedBorders) {
190
+ resolvedTableLevel.borders = mergedBorders;
191
+ }
192
+
143
193
  return {
144
194
  ...(table.tblLook ? { rawTblLook: table.tblLook } : {}),
145
195
  effectiveTblLook,
146
196
  ...(tableFormatting ? { table: tableFormatting } : {}),
197
+ tableResolved: resolvedTableLevel,
147
198
  rows,
148
199
  };
149
200
  }
@@ -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
  },
@@ -8,6 +8,7 @@ import type {
8
8
  EditorError,
9
9
  EditorHostAdapter,
10
10
  EditorSessionState,
11
+ EditorSurfaceSnapshot,
11
12
  EditorViewStateSnapshot,
12
13
  EditorWarning,
13
14
  ExportDocxOptions,
@@ -134,6 +135,8 @@ export interface EditorRuntimeBoundaryState {
134
135
  runtime: WordReviewEditorRuntime | null;
135
136
  loadError: EditorError | null;
136
137
  activeRuntime: WordReviewEditorRuntime;
138
+ /** C2c: partial surface snapshot from body-normalize stage; null once full runtime is ready. */
139
+ progressiveSurface: EditorSurfaceSnapshot | null;
137
140
  commandAppliedBridge: RuntimeCommandAppliedBridge;
138
141
  fallbackSnapshot: RuntimeRenderSnapshot;
139
142
  loadingSessionState: EditorSessionState;
@@ -302,6 +305,12 @@ export function useEditorRuntimeBoundary(
302
305
 
303
306
  const [runtime, setRuntime] = useState<WordReviewEditorRuntime | null>(null);
304
307
  const [loadError, setLoadError] = useState<EditorError | null>(null);
308
+ // C2c: progressive initial mount — transient surface snapshot from the
309
+ // body-normalize stage. Cleared when the full runtime commits (setRuntime).
310
+ const [progressiveSurface, setProgressiveSurface] =
311
+ useState<EditorSurfaceSnapshot | null>(null);
312
+ const progressiveSurfaceRef = useRef<EditorSurfaceSnapshot | null>(null);
313
+ progressiveSurfaceRef.current = progressiveSurface;
305
314
  const runtimeRef = useRef<WordReviewEditorRuntime | null>(null);
306
315
  const pendingReadySourceRef = useRef<"docx" | "session" | "snapshot" | null>(null);
307
316
  const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -445,7 +454,20 @@ export function useEditorRuntimeBoundary(
445
454
  bytes: source.initialDocx,
446
455
  editorBuild: "dev",
447
456
  scheduler,
448
- ...(probeResult ? { laycacheEnvelope: probeResult.envelope } : {}),
457
+ ...(probeResult ? { laycacheEnvelope: probeResult.envelope } : {
458
+ // C2c: on the cold path (no cached envelope), emit the first
459
+ // viewport surface as soon as body-normalize completes so the
460
+ // UI can show the first page before the full session is ready.
461
+ // Wrapped in startTransition so React treats this as a
462
+ // low-priority update — the full setRuntime commit (urgent)
463
+ // will preempt it if it arrives before React flushes the
464
+ // transition.
465
+ onProgressiveSnapshot: (partial) => {
466
+ React.startTransition(() => {
467
+ setProgressiveSurface(partial.surface);
468
+ });
469
+ },
470
+ }),
449
471
  });
450
472
  if (cancelled) {
451
473
  scheduler.dispose();
@@ -478,6 +500,10 @@ export function useEditorRuntimeBoundary(
478
500
  recordPerfSample("runtime.create");
479
501
  runtimeRef.current = nextRuntime;
480
502
  pendingReadySourceRef.current = source.source;
503
+ // C2c: clear the transient progressive surface — the full runtime
504
+ // snapshot supersedes it. No need for startTransition here since
505
+ // this is the higher-priority commit that preempts the transition.
506
+ setProgressiveSurface(null);
481
507
  setRuntime(nextRuntime);
482
508
  } catch (error) {
483
509
  if (cancelled) {
@@ -627,6 +653,9 @@ export function useEditorRuntimeBoundary(
627
653
  sessionState: loadingSessionState,
628
654
  viewState: loadingViewState,
629
655
  navigation: loadingNavigation,
656
+ // C2c: ref so the bridge can serve the progressive surface in
657
+ // getRenderSnapshot().surface without recreating on each update.
658
+ progressiveSurfaceRef,
630
659
  }),
631
660
  [fallbackSnapshot, loadingNavigation, loadingSessionState, loadingViewState],
632
661
  );
@@ -635,6 +664,7 @@ export function useEditorRuntimeBoundary(
635
664
  runtime,
636
665
  loadError,
637
666
  activeRuntime: runtime ?? loadingRuntimeBridge,
667
+ progressiveSurface,
638
668
  commandAppliedBridge,
639
669
  fallbackSnapshot,
640
670
  loadingSessionState,
@@ -876,6 +906,8 @@ function createLoadingRuntimeBridge(input: {
876
906
  sessionState: EditorSessionState;
877
907
  viewState: EditorViewStateSnapshot;
878
908
  navigation: DocumentNavigationSnapshot;
909
+ /** C2c: when present, getRenderSnapshot() injects the progressive surface. */
910
+ progressiveSurfaceRef?: React.RefObject<EditorSurfaceSnapshot | null>;
879
911
  }): WordReviewEditorRuntime {
880
912
  const inertLayoutFacet = createInertLayoutFacet();
881
913
  const emptyFieldSnapshot: FieldSnapshot = {
@@ -908,7 +940,11 @@ function createLoadingRuntimeBridge(input: {
908
940
  subscribe: () => () => undefined,
909
941
  subscribeToEvents: () => () => undefined,
910
942
  emitBlockedCommand: () => undefined,
911
- getRenderSnapshot: () => input.snapshot,
943
+ getRenderSnapshot: () => {
944
+ const progressive = input.progressiveSurfaceRef?.current;
945
+ if (progressive == null) return input.snapshot;
946
+ return { ...input.snapshot, surface: progressive };
947
+ },
912
948
  getCanonicalDocument: () => input.sessionState.canonicalDocument,
913
949
  getSourcePackage: () => input.sessionState.sourcePackage,
914
950
  replaceText: () => undefined,
@@ -922,6 +958,7 @@ function createLoadingRuntimeBridge(input: {
922
958
  }),
923
959
  dispatch: () => undefined,
924
960
  applyRemoteCommand: () => undefined,
961
+ applyRemoteCommandBatch: () => undefined,
925
962
  undo: () => undefined,
926
963
  redo: () => undefined,
927
964
  focus: () => undefined,
@@ -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
  }