@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
@@ -0,0 +1,274 @@
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
+ SurfaceTableRowSnapshot,
21
+ WordReviewEditorLayoutFacet,
22
+ } from "../../api/public-types.ts";
23
+ import type { PageOverlayRect } from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
24
+ import { TwTableContinuationHeader } from "../chrome-overlay/tw-table-continuation-header.tsx";
25
+ import { FRAME_PX_PER_TWIP_AT_96DPI } from "../tw-review-workspace.tsx";
26
+ import { TwPageHeaderBand } from "./tw-page-header-band.tsx";
27
+ import { TwPageFooterBand } from "./tw-page-footer-band.tsx";
28
+ import { TwFootnoteArea } from "./tw-footnote-area.tsx";
29
+
30
+ export interface TwPageChromeEntryProps {
31
+ rect: PageOverlayRect;
32
+ pageIndex: number;
33
+ page: PublicPageNode;
34
+ facet: WordReviewEditorLayoutFacet;
35
+ activeStory: EditorStoryTarget;
36
+ onOpenStory?: (target: EditorStoryTarget) => void;
37
+ visiblePageIndexRange?: { start: number; end: number } | null;
38
+ renderFrameRevision: number;
39
+ }
40
+
41
+ function TwPageChromeEntryInner({
42
+ rect,
43
+ pageIndex,
44
+ page,
45
+ facet,
46
+ activeStory,
47
+ onOpenStory,
48
+ visiblePageIndexRange,
49
+ renderFrameRevision,
50
+ }: TwPageChromeEntryProps): React.ReactElement {
51
+ const layout = page.layout;
52
+ const headerStory = page.stories.header;
53
+ const footerStory = page.stories.footer;
54
+ const headerRegion = page.regions.header;
55
+ const footerRegion = page.regions.footer;
56
+ const footnoteRegion = page.regions.footnotes?.[0];
57
+
58
+ // All hooks must be called unconditionally before any early return.
59
+ const headerBlocks = React.useMemo(
60
+ () =>
61
+ headerStory
62
+ ? facet.getStoryBlocksForRegion(pageIndex, "header").map((b) => b.blockSnapshot)
63
+ : [],
64
+ // `page` as dependency captures per-page reference stability (D1+B2):
65
+ // when page ref is stable, the memo doesn't re-run even if revision ticks.
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
+ [facet, pageIndex, page, renderFrameRevision],
68
+ );
69
+
70
+ const footerBlocks = React.useMemo(
71
+ () =>
72
+ footerStory
73
+ ? facet.getStoryBlocksForRegion(pageIndex, "footer").map((b) => b.blockSnapshot)
74
+ : [],
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ [facet, pageIndex, page, renderFrameRevision],
77
+ );
78
+
79
+ const footnoteBlocks = React.useMemo(
80
+ () =>
81
+ footnoteRegion
82
+ ? facet
83
+ .getStoryBlocksForRegion(pageIndex, "footnote-area")
84
+ .map((b) => b.blockSnapshot)
85
+ : [],
86
+ // eslint-disable-next-line react-hooks/exhaustive-deps
87
+ [facet, pageIndex, page, renderFrameRevision],
88
+ );
89
+
90
+ // Continuation table header entries: only non-empty for continuation pages
91
+ // of multi-page tables (isContinuationPage && repeatedHeaderRows.length > 0).
92
+ const continuationTableEntries = React.useMemo((): Array<{
93
+ blockId: string;
94
+ headerRows: readonly SurfaceTableRowSnapshot[];
95
+ }> => {
96
+ const bodyBlocks = facet.getStoryBlocksForRegion(pageIndex, "body");
97
+ const entries: Array<{
98
+ blockId: string;
99
+ headerRows: readonly SurfaceTableRowSnapshot[];
100
+ }> = [];
101
+ for (const { blockSnapshot } of bodyBlocks) {
102
+ if (blockSnapshot.kind !== "table") continue;
103
+ const plan = facet.getTableRenderPlan(blockSnapshot.blockId, pageIndex);
104
+ if (!plan || plan.repeatedHeaderRows.length === 0) continue;
105
+ const headerRows = plan.repeatedHeaderRows
106
+ .map((ref) => blockSnapshot.rows[ref.sourceRowIndex])
107
+ .filter((r): r is SurfaceTableRowSnapshot => r !== undefined);
108
+ if (headerRows.length > 0) {
109
+ entries.push({ blockId: blockSnapshot.blockId, headerRows });
110
+ }
111
+ }
112
+ return entries;
113
+ // eslint-disable-next-line react-hooks/exhaustive-deps
114
+ }, [facet, pageIndex, page, renderFrameRevision]);
115
+
116
+ const handleHeaderClick = React.useCallback(
117
+ () => headerStory && onOpenStory?.(headerStory),
118
+ [onOpenStory, headerStory],
119
+ );
120
+
121
+ const handleFooterClick = React.useCallback(
122
+ () => footerStory && onOpenStory?.(footerStory),
123
+ [onOpenStory, footerStory],
124
+ );
125
+
126
+ const frameHeightPx = rect.bottomPx - rect.topPx;
127
+
128
+ // Viewport cull — lightweight placeholder outside the visible range.
129
+ const isCulled =
130
+ visiblePageIndexRange !== null &&
131
+ visiblePageIndexRange !== undefined &&
132
+ (pageIndex < visiblePageIndexRange.start || pageIndex >= visiblePageIndexRange.end);
133
+
134
+ if (isCulled) {
135
+ return (
136
+ <div
137
+ data-page-chrome-frame=""
138
+ data-page-index={pageIndex}
139
+ data-page-chrome-culled=""
140
+ style={{
141
+ position: "absolute",
142
+ top: `${rect.topPx}px`,
143
+ left: 0,
144
+ width: "100%",
145
+ height: `${frameHeightPx}px`,
146
+ pointerEvents: "none",
147
+ }}
148
+ />
149
+ );
150
+ }
151
+
152
+ const px = (twips: number): number => twips * FRAME_PX_PER_TWIP_AT_96DPI;
153
+ const bandWidthPx = px(layout.pageWidth - layout.marginLeft - layout.marginRight);
154
+ const bandLeftPx = px(layout.marginLeft);
155
+
156
+ // L6d.U1 — 1-based section label for the active-band ribbon. The
157
+ // `sectionIndex` on the page is 0-based; reviewers expect "Section 1"
158
+ // not "Section 0", matching Word's section-break dialog numbering.
159
+ const sectionNumber = (page.sectionIndex ?? 0) + 1;
160
+ const headerSectionLabel = `Header — Section ${sectionNumber}`;
161
+ const footerSectionLabel = `Footer — Section ${sectionNumber}`;
162
+ const headerActive = headerStory && isActiveStoryMatch(activeStory, headerStory);
163
+ const footerActive = footerStory && isActiveStoryMatch(activeStory, footerStory);
164
+
165
+ return (
166
+ <div
167
+ data-page-chrome-frame=""
168
+ data-page-index={pageIndex}
169
+ style={{
170
+ position: "absolute",
171
+ top: `${rect.topPx}px`,
172
+ left: 0,
173
+ width: "100%",
174
+ height: `${frameHeightPx}px`,
175
+ pointerEvents: "none",
176
+ }}
177
+ >
178
+ {headerRegion && headerStory ? (
179
+ <TwPageHeaderBand
180
+ pageIndex={pageIndex}
181
+ blocks={headerBlocks}
182
+ topPx={px(layout.headerMargin ?? 720)}
183
+ leftPx={bandLeftPx}
184
+ widthPx={bandWidthPx}
185
+ bandHeightPx={px(headerRegion.heightTwips)}
186
+ isActiveSlot={Boolean(headerActive)}
187
+ sectionLabel={headerActive ? headerSectionLabel : undefined}
188
+ onClick={handleHeaderClick}
189
+ />
190
+ ) : null}
191
+ {footerRegion && footerStory ? (
192
+ <TwPageFooterBand
193
+ pageIndex={pageIndex}
194
+ blocks={footerBlocks}
195
+ bottomPx={px(layout.footerMargin ?? 720)}
196
+ leftPx={bandLeftPx}
197
+ widthPx={bandWidthPx}
198
+ bandHeightPx={px(footerRegion.heightTwips)}
199
+ isActiveSlot={Boolean(footerActive)}
200
+ sectionLabel={footerActive ? footerSectionLabel : undefined}
201
+ onClick={handleFooterClick}
202
+ />
203
+ ) : null}
204
+ {footnoteRegion ? (
205
+ <TwFootnoteArea
206
+ pageIndex={pageIndex}
207
+ blocks={footnoteBlocks}
208
+ topPx={px(footnoteRegion.originTwips - layout.marginTop)}
209
+ leftPx={bandLeftPx}
210
+ widthPx={px(footnoteRegion.widthTwips)}
211
+ heightPx={px(footnoteRegion.heightTwips)}
212
+ />
213
+ ) : null}
214
+ {continuationTableEntries.map(({ blockId, headerRows }) => (
215
+ <TwTableContinuationHeader
216
+ key={`table-cont-${blockId}`}
217
+ blockId={blockId}
218
+ pageIndex={pageIndex}
219
+ facet={facet}
220
+ headerRows={headerRows}
221
+ pxPerTwip={FRAME_PX_PER_TWIP_AT_96DPI}
222
+ bodyOriginTopPx={px(layout.marginTop)}
223
+ />
224
+ ))}
225
+ </div>
226
+ );
227
+ }
228
+
229
+ function propsAreEqual(
230
+ prev: TwPageChromeEntryProps,
231
+ next: TwPageChromeEntryProps,
232
+ ): boolean {
233
+ return (
234
+ prev.pageIndex === next.pageIndex &&
235
+ prev.page === next.page &&
236
+ prev.facet === next.facet &&
237
+ prev.activeStory === next.activeStory &&
238
+ prev.onOpenStory === next.onOpenStory &&
239
+ prev.visiblePageIndexRange === next.visiblePageIndexRange &&
240
+ prev.renderFrameRevision === next.renderFrameRevision &&
241
+ prev.rect.topPx === next.rect.topPx &&
242
+ prev.rect.bottomPx === next.rect.bottomPx &&
243
+ prev.rect.pageId === next.rect.pageId
244
+ );
245
+ }
246
+
247
+ export const TwPageChromeEntry = React.memo(TwPageChromeEntryInner, propsAreEqual);
248
+
249
+ function isActiveStoryMatch(
250
+ active: EditorStoryTarget,
251
+ candidate: EditorStoryTarget,
252
+ ): boolean {
253
+ if (active.kind !== candidate.kind) return false;
254
+ if (active.kind === "main" || candidate.kind === "main") {
255
+ return active.kind === candidate.kind;
256
+ }
257
+ if (active.kind === "footnote" && candidate.kind === "footnote") {
258
+ return active.noteId === candidate.noteId;
259
+ }
260
+ if (active.kind === "endnote" && candidate.kind === "endnote") {
261
+ return active.noteId === candidate.noteId;
262
+ }
263
+ if (
264
+ (active.kind === "header" && candidate.kind === "header") ||
265
+ (active.kind === "footer" && candidate.kind === "footer")
266
+ ) {
267
+ return (
268
+ active.relationshipId === candidate.relationshipId &&
269
+ active.variant === candidate.variant &&
270
+ active.sectionIndex === candidate.sectionIndex
271
+ );
272
+ }
273
+ return false;
274
+ }
@@ -27,11 +27,16 @@ export interface TwPageFooterBandProps {
27
27
  widthPx: number;
28
28
  /** True when this band is the active-story slot. Renders portal target instead of read-only DOM. */
29
29
  isActiveSlot: boolean;
30
+ /**
31
+ * Lane 6d.U1 — section label for the active-band ribbon (e.g. "Footer — Section 1").
32
+ * Only rendered when `isActiveSlot` is true.
33
+ */
34
+ sectionLabel?: string;
30
35
  onClick: () => void;
31
36
  "data-testid"?: string;
32
37
  }
33
38
 
34
- export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = ({
39
+ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
35
40
  pageIndex,
36
41
  blocks,
37
42
  bandHeightPx,
@@ -39,13 +44,16 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = ({
39
44
  leftPx,
40
45
  widthPx,
41
46
  isActiveSlot,
47
+ sectionLabel,
42
48
  onClick,
43
49
  "data-testid": testId,
44
50
  }) => {
45
51
  return (
46
52
  <div
53
+ className="wre-page-band"
47
54
  data-page-band="footer"
48
55
  data-page-index={pageIndex}
56
+ data-active={isActiveSlot ? "true" : undefined}
49
57
  data-testid={testId}
50
58
  onClick={onClick}
51
59
  style={{
@@ -57,6 +65,11 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = ({
57
65
  cursor: "pointer",
58
66
  }}
59
67
  >
68
+ {isActiveSlot && sectionLabel ? (
69
+ <span className="wre-page-band__label" data-kind="page-band-label">
70
+ {sectionLabel}
71
+ </span>
72
+ ) : null}
60
73
  {isActiveSlot ? (
61
74
  <div
62
75
  data-pm-portal-slot
@@ -68,6 +81,6 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = ({
68
81
  )}
69
82
  </div>
70
83
  );
71
- };
84
+ });
72
85
 
73
86
  export default TwPageFooterBand;
@@ -28,11 +28,16 @@ export interface TwPageHeaderBandProps {
28
28
  widthPx: number;
29
29
  /** True when this band is the active-story slot. Renders portal target instead of read-only DOM. */
30
30
  isActiveSlot: boolean;
31
+ /**
32
+ * Lane 6d.U1 — section label for the active-band ribbon (e.g. "Header — Section 1").
33
+ * Only rendered when `isActiveSlot` is true.
34
+ */
35
+ sectionLabel?: string;
31
36
  onClick: () => void;
32
37
  "data-testid"?: string;
33
38
  }
34
39
 
35
- export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = ({
40
+ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
36
41
  pageIndex,
37
42
  blocks,
38
43
  bandHeightPx,
@@ -40,13 +45,16 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = ({
40
45
  leftPx,
41
46
  widthPx,
42
47
  isActiveSlot,
48
+ sectionLabel,
43
49
  onClick,
44
50
  "data-testid": testId,
45
51
  }) => {
46
52
  return (
47
53
  <div
54
+ className="wre-page-band"
48
55
  data-page-band="header"
49
56
  data-page-index={pageIndex}
57
+ data-active={isActiveSlot ? "true" : undefined}
50
58
  data-testid={testId}
51
59
  onClick={onClick}
52
60
  style={{
@@ -58,6 +66,11 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = ({
58
66
  cursor: "pointer",
59
67
  }}
60
68
  >
69
+ {isActiveSlot && sectionLabel ? (
70
+ <span className="wre-page-band__label" data-kind="page-band-label">
71
+ {sectionLabel}
72
+ </span>
73
+ ) : null}
61
74
  {isActiveSlot ? (
62
75
  <div
63
76
  data-pm-portal-slot
@@ -69,6 +82,6 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = ({
69
82
  )}
70
83
  </div>
71
84
  );
72
- };
85
+ });
73
86
 
74
87
  export default TwPageHeaderBand;
@@ -77,11 +77,8 @@ import {
77
77
  resolvePageOverlayRects,
78
78
  type PageOverlayRect,
79
79
  } from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
80
- import { TwPageHeaderBand } from "./tw-page-header-band.tsx";
81
- import { TwPageFooterBand } from "./tw-page-footer-band.tsx";
82
- import { TwFootnoteArea } from "./tw-footnote-area.tsx";
83
80
  import { TwEndnoteArea } from "./tw-endnote-area.tsx";
84
- import { FRAME_PX_PER_TWIP_AT_96DPI } from "../tw-review-workspace.tsx";
81
+ import { TwPageChromeEntry } from "./tw-page-chrome-entry.tsx";
85
82
 
86
83
  /**
87
84
  * Minimal structural type for the PM `EditorView` handle consumed by
@@ -364,120 +361,31 @@ export const TwPageStackChromeLayer: React.FC<TwPageStackChromeLayerProps> = ({
364
361
  ref={overlayRootRef}
365
362
  data-page-stack-chrome-layer=""
366
363
  data-testid={testId ?? "page-stack-chrome-layer"}
364
+ // L6d.U1 — expose the active-story kind so the CSS can dim the
365
+ // body when an H/F story is being edited and un-dim again when
366
+ // the active story is the main body.
367
+ data-story-active={
368
+ activeStory.kind === "header" || activeStory.kind === "footer"
369
+ ? activeStory.kind
370
+ : undefined
371
+ }
367
372
  style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
368
373
  >
369
374
  {rects.map((rect, pageIndex) => {
370
375
  const page = facet.getPage(pageIndex);
371
376
  if (!page) return null;
372
-
373
- // L7 Phase 2.8 — viewport cull. Pages outside the visible range
374
- // (plus overscan) render an empty frame wrapper: the
375
- // `data-page-chrome-frame` + `data-page-index` attributes stay
376
- // put so downstream DOM queries (portal-slot, test hooks) keep
377
- // working, but the four heavy React subtrees below do not mount.
378
- const frameHeightPxForCull = rect.bottomPx - rect.topPx;
379
- if (
380
- visiblePageIndexRange &&
381
- (pageIndex < visiblePageIndexRange.start ||
382
- pageIndex >= visiblePageIndexRange.end)
383
- ) {
384
- return (
385
- <div
386
- key={`page-chrome-${rect.pageId}`}
387
- data-page-chrome-frame=""
388
- data-page-index={pageIndex}
389
- data-page-chrome-culled=""
390
- style={{
391
- position: "absolute",
392
- top: `${rect.topPx}px`,
393
- left: 0,
394
- width: "100%",
395
- height: `${frameHeightPxForCull}px`,
396
- pointerEvents: "none",
397
- }}
398
- />
399
- );
400
- }
401
-
402
- const layout = page.layout;
403
- const headerStory = page.stories.header;
404
- const footerStory = page.stories.footer;
405
- const headerRegion = page.regions.header;
406
- const footerRegion = page.regions.footer;
407
- const footnoteRegion = page.regions.footnotes?.[0];
408
-
409
- const headerBlocks = headerStory
410
- ? facet
411
- .getStoryBlocksForRegion(pageIndex, "header")
412
- .map((b) => b.blockSnapshot)
413
- : [];
414
- const footerBlocks = footerStory
415
- ? facet
416
- .getStoryBlocksForRegion(pageIndex, "footer")
417
- .map((b) => b.blockSnapshot)
418
- : [];
419
- const footnoteBlocks = footnoteRegion
420
- ? facet
421
- .getStoryBlocksForRegion(pageIndex, "footnote-area")
422
- .map((b) => b.blockSnapshot)
423
- : [];
424
-
425
- const px = (twips: number): number => twips * FRAME_PX_PER_TWIP_AT_96DPI;
426
- const bandWidthPx = px(
427
- layout.pageWidth - layout.marginLeft - layout.marginRight,
428
- );
429
- const bandLeftPx = px(layout.marginLeft);
430
- const frameHeightPx = rect.bottomPx - rect.topPx;
431
-
432
377
  return (
433
- <div
378
+ <TwPageChromeEntry
434
379
  key={`page-chrome-${rect.pageId}`}
435
- data-page-chrome-frame=""
436
- data-page-index={pageIndex}
437
- style={{
438
- position: "absolute",
439
- top: `${rect.topPx}px`,
440
- left: 0,
441
- width: "100%",
442
- height: `${frameHeightPx}px`,
443
- pointerEvents: "none",
444
- }}
445
- >
446
- {headerRegion && headerStory ? (
447
- <TwPageHeaderBand
448
- pageIndex={pageIndex}
449
- blocks={headerBlocks}
450
- topPx={px(layout.headerMargin ?? 720)}
451
- leftPx={bandLeftPx}
452
- widthPx={bandWidthPx}
453
- bandHeightPx={px(headerRegion.heightTwips)}
454
- isActiveSlot={isActiveStoryMatch(activeStory, headerStory)}
455
- onClick={() => onOpenStory?.(headerStory)}
456
- />
457
- ) : null}
458
- {footerRegion && footerStory ? (
459
- <TwPageFooterBand
460
- pageIndex={pageIndex}
461
- blocks={footerBlocks}
462
- bottomPx={px(layout.footerMargin ?? 720)}
463
- leftPx={bandLeftPx}
464
- widthPx={bandWidthPx}
465
- bandHeightPx={px(footerRegion.heightTwips)}
466
- isActiveSlot={isActiveStoryMatch(activeStory, footerStory)}
467
- onClick={() => onOpenStory?.(footerStory)}
468
- />
469
- ) : null}
470
- {footnoteRegion ? (
471
- <TwFootnoteArea
472
- pageIndex={pageIndex}
473
- blocks={footnoteBlocks}
474
- topPx={px(footnoteRegion.originTwips - layout.marginTop)}
475
- leftPx={bandLeftPx}
476
- widthPx={px(footnoteRegion.widthTwips)}
477
- heightPx={px(footnoteRegion.heightTwips)}
478
- />
479
- ) : null}
480
- </div>
380
+ rect={rect}
381
+ pageIndex={pageIndex}
382
+ page={page}
383
+ facet={facet}
384
+ activeStory={activeStory}
385
+ onOpenStory={onOpenStory}
386
+ visiblePageIndexRange={visiblePageIndexRange}
387
+ renderFrameRevision={renderFrameRevision}
388
+ />
481
389
  );
482
390
  })}
483
391
  <TwEndnoteArea blocks={endnoteBlocks} />
@@ -485,40 +393,4 @@ export const TwPageStackChromeLayer: React.FC<TwPageStackChromeLayerProps> = ({
485
393
  );
486
394
  };
487
395
 
488
- /**
489
- * Strict equality for two `EditorStoryTarget` values — used to decide
490
- * whether a given band should render in active-slot mode.
491
- *
492
- * For `"main"` the kinds must match; for header/footer the triple
493
- * `(relationshipId, variant, sectionIndex)` must match; for footnote /
494
- * endnote the `noteId` must match. Any mismatch returns false.
495
- */
496
- function isActiveStoryMatch(
497
- active: EditorStoryTarget,
498
- candidate: EditorStoryTarget,
499
- ): boolean {
500
- if (active.kind !== candidate.kind) return false;
501
- if (active.kind === "main" || candidate.kind === "main") {
502
- return active.kind === candidate.kind;
503
- }
504
- if (active.kind === "footnote" && candidate.kind === "footnote") {
505
- return active.noteId === candidate.noteId;
506
- }
507
- if (active.kind === "endnote" && candidate.kind === "endnote") {
508
- return active.noteId === candidate.noteId;
509
- }
510
- // header / footer — triple match.
511
- if (
512
- (active.kind === "header" && candidate.kind === "header") ||
513
- (active.kind === "footer" && candidate.kind === "footer")
514
- ) {
515
- return (
516
- active.relationshipId === candidate.relationshipId &&
517
- active.variant === candidate.variant &&
518
- active.sectionIndex === candidate.sectionIndex
519
- );
520
- }
521
- return false;
522
- }
523
-
524
396
  export default TwPageStackChromeLayer;