@beyondwork/docx-react-component 1.0.52 → 1.0.53

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 (29) hide show
  1. package/package.json +31 -40
  2. package/src/api/public-types.ts +32 -0
  3. package/src/io/chart-preview-resolver.ts +41 -0
  4. package/src/io/docx-session.ts +187 -17
  5. package/src/runtime/collab/runtime-collab-sync.ts +87 -6
  6. package/src/runtime/document-runtime.ts +159 -0
  7. package/src/runtime/layout/layout-engine-version.ts +40 -2
  8. package/src/runtime/layout/public-facet.ts +43 -1
  9. package/src/runtime/prerender/cache-envelope.ts +30 -0
  10. package/src/runtime/prerender/customxml-cache.ts +17 -3
  11. package/src/runtime/prerender/prerender-document.ts +17 -1
  12. package/src/runtime/render/render-kernel.ts +67 -19
  13. package/src/runtime/surface-projection.ts +28 -0
  14. package/src/runtime/table-schema.ts +27 -0
  15. package/src/runtime/table-style-resolver.ts +51 -0
  16. package/src/ui/editor-runtime-boundary.ts +39 -2
  17. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  18. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
  19. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
  20. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
  21. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +224 -0
  22. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +2 -2
  23. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +2 -2
  24. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +11 -147
  25. package/src/ui-tailwind/theme/chart-palette-adapter.ts +57 -0
  26. package/src/ui-tailwind/theme/editor-theme.css +26 -24
  27. package/src/ui-tailwind/theme/tokens.css +345 -0
  28. package/src/ui-tailwind/theme/tokens.ts +313 -0
  29. package/src/ui-tailwind/theme/use-density.ts +60 -0
@@ -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
  }
@@ -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,
@@ -6,6 +6,7 @@ export type PerfProbeKind =
6
6
  | "selection"
7
7
  | "runtime.create"
8
8
  | "loadSession.laycacheProbe"
9
+ | "loadSession.compatReportCached"
9
10
  | "snapshot.surface"
10
11
  | "snapshot.compatibility"
11
12
  | "snapshot.navigation"
@@ -519,6 +519,8 @@ function buildTable(
519
519
  borderRight: cell.borderRight ?? null,
520
520
  borderBottom: cell.borderBottom ?? null,
521
521
  borderLeft: cell.borderLeft ?? null,
522
+ // R3.a Phase 2 — per-cell text-flow direction.
523
+ textDirection: cell.textDirection ?? null,
522
524
  bandClasses: cell.bandClasses ?? null,
523
525
  },
524
526
  Fragment.from(cellContent),
@@ -536,6 +538,20 @@ function buildTable(
536
538
  Fragment.from(cells),
537
539
  ));
538
540
  }
541
+ // R3.a Phase 2 — convert each typed BorderSpec on `tableResolved.borders`
542
+ // into a CSS shorthand string ("1px solid #000000") so the schema attrs
543
+ // carry render-ready values. The four outer sides are applied as inline
544
+ // styles by the node-view; insideH / insideV ride along for round-trip
545
+ // and future use but do not change CSS at the table level (the per-cell
546
+ // border resolver already paints them on each cell).
547
+ const tr = block.tableResolved;
548
+ const tableBorderTop = tr?.borders?.top ? borderSpecToCss(tr.borders.top) : null;
549
+ const tableBorderRight = tr?.borders?.right ? borderSpecToCss(tr.borders.right) : null;
550
+ const tableBorderBottom = tr?.borders?.bottom ? borderSpecToCss(tr.borders.bottom) : null;
551
+ const tableBorderLeft = tr?.borders?.left ? borderSpecToCss(tr.borders.left) : null;
552
+ const tableBorderInsideH = tr?.borders?.insideH ? borderSpecToCss(tr.borders.insideH) : null;
553
+ const tableBorderInsideV = tr?.borders?.insideV ? borderSpecToCss(tr.borders.insideV) : null;
554
+
539
555
  return editorSchema.nodes.table.create(
540
556
  {
541
557
  styleId: block.styleId ?? null,
@@ -548,11 +564,49 @@ function buildTable(
548
564
  tblLookNoHBand: block.tblLook?.noHBand ?? false,
549
565
  tblLookNoVBand: block.tblLook?.noVBand ?? false,
550
566
  tblLookVal: block.tblLook?.val ?? null,
567
+ // R3.a Phase 2 — table-level resolved properties.
568
+ tableWidth: tr?.width ?? null,
569
+ tableWidthType: tr?.widthType ?? null,
570
+ tableLayoutMode: tr?.layoutMode ?? null,
571
+ tableCellSpacing: tr?.cellSpacing ?? null,
572
+ tableCellSpacingType: tr?.cellSpacingType ?? null,
573
+ tableBorderTop,
574
+ tableBorderRight,
575
+ tableBorderBottom,
576
+ tableBorderLeft,
577
+ tableBorderInsideH,
578
+ tableBorderInsideV,
551
579
  },
552
580
  Fragment.from(rows),
553
581
  );
554
582
  }
555
583
 
584
+ /**
585
+ * R3.a Phase 2 — convert a parsed OOXML `BorderSpec` shape (size in eighths of
586
+ * a point, color hex without `#`, value like "single"/"double"/"dashed") to a
587
+ * CSS shorthand string. Mirrors the cell-side helper at
588
+ * `src/runtime/surface-projection.ts:506` so cell + table borders look the
589
+ * same. Returns `null` for absent / "none" / "nil" specs so callers can stamp
590
+ * `null` cleanly into PM attrs.
591
+ */
592
+ function borderSpecToCss(
593
+ spec: { value?: string; size?: number; color?: string } | null | undefined,
594
+ ): string | null {
595
+ if (!spec) return null;
596
+ if (spec.value === "none" || spec.value === "nil") return null;
597
+ const width = spec.size ? `${Math.max(1, Math.round(spec.size / 8))}px` : "1px";
598
+ const style =
599
+ spec.value === "double"
600
+ ? "double"
601
+ : spec.value === "dashed" || spec.value === "dashSmallGap"
602
+ ? "dashed"
603
+ : spec.value === "dotted"
604
+ ? "dotted"
605
+ : "solid";
606
+ const color = spec.color && spec.color !== "auto" ? `#${spec.color}` : "currentColor";
607
+ return `${width} ${style} ${color}`;
608
+ }
609
+
556
610
  function buildSdtBlock(
557
611
  block: Extract<SurfaceBlockSnapshot, { kind: "sdt_block" }>,
558
612
  mediaPreviews: Record<string, MediaPreviewDescriptor>,
@@ -282,11 +282,33 @@ function requestTableLayoutSync(start: HTMLElement): void {
282
282
  table?.dispatchEvent(new Event(TABLE_LAYOUT_SYNC_EVENT));
283
283
  }
284
284
 
285
+ // R3.a Phase 2 — single source of truth for the inline style properties
286
+ // `applyTableAttrs` writes back. Adding a new managed property requires only
287
+ // adding it here (and the matching conditional assignment below); the reset
288
+ // loop will pick it up automatically so stale values can never persist
289
+ // across PM snapshot updates.
290
+ const R3A_MANAGED_TABLE_STYLES = [
291
+ "marginLeft",
292
+ "marginRight",
293
+ "width",
294
+ "tableLayout",
295
+ "borderCollapse",
296
+ "borderSpacing",
297
+ "borderTop",
298
+ "borderRight",
299
+ "borderBottom",
300
+ "borderLeft",
301
+ ] as const;
302
+
285
303
  function applyTableAttrs(table: HTMLTableElement, node: PMNode): void {
286
- table.className = "border-collapse w-full my-2 text-sm";
304
+ // Reset every R3.a-managed inline style first so re-application doesn't
305
+ // accumulate stale values across PM updates (e.g. width changes, layout
306
+ // mode flips). The base Tailwind className is rebuilt below to match.
287
307
  table.setAttribute("data-pm-table-root", "true");
288
- table.style.marginLeft = "";
289
- table.style.marginRight = "";
308
+ for (const prop of R3A_MANAGED_TABLE_STYLES) {
309
+ table.style[prop] = "";
310
+ }
311
+
290
312
  const alignment = node.attrs.alignment as string | null | undefined;
291
313
  if (alignment === "center") {
292
314
  table.style.marginLeft = "auto";
@@ -294,6 +316,67 @@ function applyTableAttrs(table: HTMLTableElement, node: PMNode): void {
294
316
  } else if (alignment === "right") {
295
317
  table.style.marginLeft = "auto";
296
318
  }
319
+
320
+ // R3.a Phase 2 — table-level inline styles from resolved properties.
321
+ // Width: pct → "%", dxa → "pt" (twips/20), auto → "auto", null → unset.
322
+ // When an explicit width is present we drop Tailwind's `w-full` so the
323
+ // explicit value wins; otherwise keep the responsive default.
324
+ const tableWidth = node.attrs.tableWidth as number | null | undefined;
325
+ const tableWidthType = node.attrs.tableWidthType as string | null | undefined;
326
+ let baseClasses = "border-collapse w-full my-2 text-sm";
327
+ if (tableWidthType === "pct" && typeof tableWidth === "number") {
328
+ // OOXML pct widths are fiftieths of a percent (5000 = 100%).
329
+ table.style.width = `${tableWidth / 50}%`;
330
+ baseClasses = "border-collapse my-2 text-sm";
331
+ } else if (tableWidthType === "dxa" && typeof tableWidth === "number") {
332
+ table.style.width = `${tableWidth / 20}pt`;
333
+ baseClasses = "border-collapse my-2 text-sm";
334
+ } else if (tableWidthType === "auto") {
335
+ table.style.width = "auto";
336
+ baseClasses = "border-collapse my-2 text-sm";
337
+ }
338
+
339
+ // Layout mode: w:tblLayout/@w:type. "fixed" → CSS `table-layout: fixed`
340
+ // (column widths from <colgroup> rule); "autofit" falls through to the
341
+ // browser default ("auto").
342
+ const layoutMode = node.attrs.tableLayoutMode as string | null | undefined;
343
+ if (layoutMode === "fixed") {
344
+ table.style.tableLayout = "fixed";
345
+ }
346
+
347
+ // Cell spacing: w:tblCellSpacing. Non-zero spacing requires
348
+ // `border-collapse: separate` in CSS (border-collapse: collapse ignores
349
+ // border-spacing).
350
+ // ECMA-376 §17.4.44: w:tblCellSpacing is total spacing between cells in
351
+ // twentieths of a point; direct CSS border-spacing equivalent.
352
+ const cellSpacing = node.attrs.tableCellSpacing as number | null | undefined;
353
+ const cellSpacingType = node.attrs.tableCellSpacingType as string | null | undefined;
354
+ if (typeof cellSpacing === "number" && cellSpacing > 0 && cellSpacingType === "dxa") {
355
+ table.style.borderCollapse = "separate";
356
+ table.style.borderSpacing = `${cellSpacing / 20}pt`;
357
+ // `border-collapse` overrides Tailwind's `border-collapse` utility, so
358
+ // strip it from the base class set to avoid the conflicting cascade
359
+ // result that some browsers cache.
360
+ baseClasses = baseClasses.replace("border-collapse", "border-separate");
361
+ }
362
+
363
+ // Outer borders (top / right / bottom / left) — apply directly as inline
364
+ // styles. insideH / insideV are intentionally NOT applied at the table
365
+ // level because CSS has no native "table-inside-border" property; their
366
+ // visual effect is realized through per-cell borders projected on the
367
+ // cell snapshot via resolveCellBorderStyles in surface-projection.
368
+ // (See Lane 3b R3.a Phase 2 close-out plan: "option 1 — cell-level
369
+ // fallback".)
370
+ const tBorderTop = node.attrs.tableBorderTop as string | null | undefined;
371
+ const tBorderRight = node.attrs.tableBorderRight as string | null | undefined;
372
+ const tBorderBottom = node.attrs.tableBorderBottom as string | null | undefined;
373
+ const tBorderLeft = node.attrs.tableBorderLeft as string | null | undefined;
374
+ if (tBorderTop) table.style.borderTop = tBorderTop;
375
+ if (tBorderRight) table.style.borderRight = tBorderRight;
376
+ if (tBorderBottom) table.style.borderBottom = tBorderBottom;
377
+ if (tBorderLeft) table.style.borderLeft = tBorderLeft;
378
+
379
+ table.className = baseClasses;
297
380
  syncColgroup(table, node);
298
381
  }
299
382
 
@@ -423,6 +506,27 @@ function applyCellAttrs(cell: HTMLTableCellElement, node: PMNode, isHeader: bool
423
506
  cell.style.borderRight = borderRight ?? "";
424
507
  cell.style.borderBottom = borderBottom ?? "";
425
508
  cell.style.borderLeft = borderLeft ?? "";
509
+
510
+ // R3.a Phase 2 — vertical text direction. OOXML: tbRl = top→bottom, right→left
511
+ // (most common for vertical headers, reads when tilting the head right);
512
+ // btLr = bottom→top, left→right (rare, reads tilting head left); lrTb is the
513
+ // default horizontal flow. CSS `writing-mode` is the direct equivalent.
514
+ const textDirection = node.attrs.textDirection as
515
+ | "lrTb"
516
+ | "tbRl"
517
+ | "btLr"
518
+ | null
519
+ | undefined;
520
+ cell.style.writingMode = "";
521
+ if (textDirection === "tbRl") {
522
+ cell.style.writingMode = "vertical-rl";
523
+ cell.setAttribute("data-text-direction", "tbRl");
524
+ } else if (textDirection === "btLr") {
525
+ cell.style.writingMode = "vertical-lr";
526
+ cell.setAttribute("data-text-direction", "btLr");
527
+ } else {
528
+ cell.removeAttribute("data-text-direction");
529
+ }
426
530
  }
427
531
 
428
532
  /**
@@ -32,7 +32,7 @@ export interface TwFootnoteAreaProps {
32
32
  "data-testid"?: string;
33
33
  }
34
34
 
35
- export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = ({
35
+ export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = React.memo(({
36
36
  pageIndex,
37
37
  blocks,
38
38
  topPx,
@@ -66,6 +66,6 @@ export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = ({
66
66
  <TwRegionBlockRenderer blocks={blocks} />
67
67
  </div>
68
68
  );
69
- };
69
+ });
70
70
 
71
71
  export default TwFootnoteArea;
@@ -0,0 +1,224 @@
1
+ /**
2
+ * TwPageChromeEntry — memo-wrapped per-page chrome subtree.
3
+ *
4
+ * Extracted from `TwPageStackChromeLayer`'s per-page `.map()` so that
5
+ * `React.memo` can skip re-renders for pages whose `page` reference is
6
+ * stable (guaranteed post-D1+B2 for pages past the splice-convergence
7
+ * point). Rect values are compared structurally (topPx / bottomPx /
8
+ * pageId) because `resolvePageOverlayRects` allocates new objects each
9
+ * measurement pass even when positions haven't changed.
10
+ *
11
+ * Block arrays and click-handler callbacks are memoized on
12
+ * `(page, renderFrameRevision)` so they don't create new references
13
+ * when the page is unchanged.
14
+ */
15
+
16
+ import React from "react";
17
+ import type {
18
+ EditorStoryTarget,
19
+ PublicPageNode,
20
+ WordReviewEditorLayoutFacet,
21
+ } from "../../api/public-types.ts";
22
+ import type { PageOverlayRect } from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
23
+ import { FRAME_PX_PER_TWIP_AT_96DPI } from "../tw-review-workspace.tsx";
24
+ import { TwPageHeaderBand } from "./tw-page-header-band.tsx";
25
+ import { TwPageFooterBand } from "./tw-page-footer-band.tsx";
26
+ import { TwFootnoteArea } from "./tw-footnote-area.tsx";
27
+
28
+ export interface TwPageChromeEntryProps {
29
+ rect: PageOverlayRect;
30
+ pageIndex: number;
31
+ page: PublicPageNode;
32
+ facet: WordReviewEditorLayoutFacet;
33
+ activeStory: EditorStoryTarget;
34
+ onOpenStory?: (target: EditorStoryTarget) => void;
35
+ visiblePageIndexRange?: { start: number; end: number } | null;
36
+ renderFrameRevision: number;
37
+ }
38
+
39
+ function TwPageChromeEntryInner({
40
+ rect,
41
+ pageIndex,
42
+ page,
43
+ facet,
44
+ activeStory,
45
+ onOpenStory,
46
+ visiblePageIndexRange,
47
+ renderFrameRevision,
48
+ }: TwPageChromeEntryProps): React.ReactElement {
49
+ const layout = page.layout;
50
+ const headerStory = page.stories.header;
51
+ const footerStory = page.stories.footer;
52
+ const headerRegion = page.regions.header;
53
+ const footerRegion = page.regions.footer;
54
+ const footnoteRegion = page.regions.footnotes?.[0];
55
+
56
+ // All hooks must be called unconditionally before any early return.
57
+ const headerBlocks = React.useMemo(
58
+ () =>
59
+ headerStory
60
+ ? facet.getStoryBlocksForRegion(pageIndex, "header").map((b) => b.blockSnapshot)
61
+ : [],
62
+ // `page` as dependency captures per-page reference stability (D1+B2):
63
+ // when page ref is stable, the memo doesn't re-run even if revision ticks.
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ [facet, pageIndex, page, renderFrameRevision],
66
+ );
67
+
68
+ const footerBlocks = React.useMemo(
69
+ () =>
70
+ footerStory
71
+ ? facet.getStoryBlocksForRegion(pageIndex, "footer").map((b) => b.blockSnapshot)
72
+ : [],
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ [facet, pageIndex, page, renderFrameRevision],
75
+ );
76
+
77
+ const footnoteBlocks = React.useMemo(
78
+ () =>
79
+ footnoteRegion
80
+ ? facet
81
+ .getStoryBlocksForRegion(pageIndex, "footnote-area")
82
+ .map((b) => b.blockSnapshot)
83
+ : [],
84
+ // eslint-disable-next-line react-hooks/exhaustive-deps
85
+ [facet, pageIndex, page, renderFrameRevision],
86
+ );
87
+
88
+ const handleHeaderClick = React.useCallback(
89
+ () => headerStory && onOpenStory?.(headerStory),
90
+ [onOpenStory, headerStory],
91
+ );
92
+
93
+ const handleFooterClick = React.useCallback(
94
+ () => footerStory && onOpenStory?.(footerStory),
95
+ [onOpenStory, footerStory],
96
+ );
97
+
98
+ const frameHeightPx = rect.bottomPx - rect.topPx;
99
+
100
+ // Viewport cull — lightweight placeholder outside the visible range.
101
+ const isCulled =
102
+ visiblePageIndexRange !== null &&
103
+ visiblePageIndexRange !== undefined &&
104
+ (pageIndex < visiblePageIndexRange.start || pageIndex >= visiblePageIndexRange.end);
105
+
106
+ if (isCulled) {
107
+ return (
108
+ <div
109
+ data-page-chrome-frame=""
110
+ data-page-index={pageIndex}
111
+ data-page-chrome-culled=""
112
+ style={{
113
+ position: "absolute",
114
+ top: `${rect.topPx}px`,
115
+ left: 0,
116
+ width: "100%",
117
+ height: `${frameHeightPx}px`,
118
+ pointerEvents: "none",
119
+ }}
120
+ />
121
+ );
122
+ }
123
+
124
+ const px = (twips: number): number => twips * FRAME_PX_PER_TWIP_AT_96DPI;
125
+ const bandWidthPx = px(layout.pageWidth - layout.marginLeft - layout.marginRight);
126
+ const bandLeftPx = px(layout.marginLeft);
127
+
128
+ return (
129
+ <div
130
+ data-page-chrome-frame=""
131
+ data-page-index={pageIndex}
132
+ style={{
133
+ position: "absolute",
134
+ top: `${rect.topPx}px`,
135
+ left: 0,
136
+ width: "100%",
137
+ height: `${frameHeightPx}px`,
138
+ pointerEvents: "none",
139
+ }}
140
+ >
141
+ {headerRegion && headerStory ? (
142
+ <TwPageHeaderBand
143
+ pageIndex={pageIndex}
144
+ blocks={headerBlocks}
145
+ topPx={px(layout.headerMargin ?? 720)}
146
+ leftPx={bandLeftPx}
147
+ widthPx={bandWidthPx}
148
+ bandHeightPx={px(headerRegion.heightTwips)}
149
+ isActiveSlot={isActiveStoryMatch(activeStory, headerStory)}
150
+ onClick={handleHeaderClick}
151
+ />
152
+ ) : null}
153
+ {footerRegion && footerStory ? (
154
+ <TwPageFooterBand
155
+ pageIndex={pageIndex}
156
+ blocks={footerBlocks}
157
+ bottomPx={px(layout.footerMargin ?? 720)}
158
+ leftPx={bandLeftPx}
159
+ widthPx={bandWidthPx}
160
+ bandHeightPx={px(footerRegion.heightTwips)}
161
+ isActiveSlot={isActiveStoryMatch(activeStory, footerStory)}
162
+ onClick={handleFooterClick}
163
+ />
164
+ ) : null}
165
+ {footnoteRegion ? (
166
+ <TwFootnoteArea
167
+ pageIndex={pageIndex}
168
+ blocks={footnoteBlocks}
169
+ topPx={px(footnoteRegion.originTwips - layout.marginTop)}
170
+ leftPx={bandLeftPx}
171
+ widthPx={px(footnoteRegion.widthTwips)}
172
+ heightPx={px(footnoteRegion.heightTwips)}
173
+ />
174
+ ) : null}
175
+ </div>
176
+ );
177
+ }
178
+
179
+ function propsAreEqual(
180
+ prev: TwPageChromeEntryProps,
181
+ next: TwPageChromeEntryProps,
182
+ ): boolean {
183
+ return (
184
+ prev.pageIndex === next.pageIndex &&
185
+ prev.page === next.page &&
186
+ prev.facet === next.facet &&
187
+ prev.activeStory === next.activeStory &&
188
+ prev.onOpenStory === next.onOpenStory &&
189
+ prev.visiblePageIndexRange === next.visiblePageIndexRange &&
190
+ prev.renderFrameRevision === next.renderFrameRevision &&
191
+ prev.rect.topPx === next.rect.topPx &&
192
+ prev.rect.bottomPx === next.rect.bottomPx &&
193
+ prev.rect.pageId === next.rect.pageId
194
+ );
195
+ }
196
+
197
+ export const TwPageChromeEntry = React.memo(TwPageChromeEntryInner, propsAreEqual);
198
+
199
+ function isActiveStoryMatch(
200
+ active: EditorStoryTarget,
201
+ candidate: EditorStoryTarget,
202
+ ): boolean {
203
+ if (active.kind !== candidate.kind) return false;
204
+ if (active.kind === "main" || candidate.kind === "main") {
205
+ return active.kind === candidate.kind;
206
+ }
207
+ if (active.kind === "footnote" && candidate.kind === "footnote") {
208
+ return active.noteId === candidate.noteId;
209
+ }
210
+ if (active.kind === "endnote" && candidate.kind === "endnote") {
211
+ return active.noteId === candidate.noteId;
212
+ }
213
+ if (
214
+ (active.kind === "header" && candidate.kind === "header") ||
215
+ (active.kind === "footer" && candidate.kind === "footer")
216
+ ) {
217
+ return (
218
+ active.relationshipId === candidate.relationshipId &&
219
+ active.variant === candidate.variant &&
220
+ active.sectionIndex === candidate.sectionIndex
221
+ );
222
+ }
223
+ return false;
224
+ }
@@ -31,7 +31,7 @@ export interface TwPageFooterBandProps {
31
31
  "data-testid"?: string;
32
32
  }
33
33
 
34
- export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = ({
34
+ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
35
35
  pageIndex,
36
36
  blocks,
37
37
  bandHeightPx,
@@ -68,6 +68,6 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = ({
68
68
  )}
69
69
  </div>
70
70
  );
71
- };
71
+ });
72
72
 
73
73
  export default TwPageFooterBand;
@@ -32,7 +32,7 @@ export interface TwPageHeaderBandProps {
32
32
  "data-testid"?: string;
33
33
  }
34
34
 
35
- export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = ({
35
+ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
36
36
  pageIndex,
37
37
  blocks,
38
38
  bandHeightPx,
@@ -69,6 +69,6 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = ({
69
69
  )}
70
70
  </div>
71
71
  );
72
- };
72
+ });
73
73
 
74
74
  export default TwPageHeaderBand;