@beyondwork/docx-react-component 1.0.41 → 1.0.43

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 (118) hide show
  1. package/package.json +38 -37
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -16,6 +16,8 @@ import * as React from "react";
16
16
  import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
17
17
  import type { ScopeRailSegment } from "../../runtime/layout";
18
18
  import type {
19
+ EditorRole,
20
+ EditorStoryTarget,
19
21
  ScopeIssueAction,
20
22
  TableStructureContextSnapshot,
21
23
  WordReviewEditorLayoutFacet,
@@ -23,6 +25,8 @@ import type {
23
25
  } from "../../api/public-types";
24
26
  import { TwScopeRailLayer } from "./tw-scope-rail-layer";
25
27
  import { TwScopeCardLayer } from "./tw-scope-card-layer";
28
+ import { TwPageStackOverlayLayer } from "./tw-page-stack-overlay-layer";
29
+ import { TwPageStackChromeLayer, type PmPortalView } from "../page-stack/tw-page-stack-chrome-layer";
26
30
  import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
27
31
 
28
32
  export interface TwChromeOverlayProps {
@@ -64,6 +68,26 @@ export interface TwChromeOverlayProps {
64
68
  issueId: string,
65
69
  action: ScopeIssueAction,
66
70
  ) => void;
71
+ /**
72
+ * R3 — scope card suggestion-group accept fired. The owner relays
73
+ * to `ref.acceptSuggestionGroup(groupId)`.
74
+ */
75
+ onScopeCardAcceptSuggestionGroup?: (scopeId: string, groupId: string) => void;
76
+ /** R3 — suggestion-group reject. */
77
+ onScopeCardRejectSuggestionGroup?: (scopeId: string, groupId: string) => void;
78
+ /**
79
+ * K2 — scope card "Ask review agent" button fired. The owner
80
+ * emits `agent-on-selection-requested` via WordReviewEditorEvent.
81
+ * Button is hidden when this handler is not supplied.
82
+ */
83
+ onScopeCardAskAgent?: (scopeId: string) => void;
84
+ /** P3 — active editor role; drives card visibility matrix. */
85
+ editorRole?: EditorRole;
86
+ /**
87
+ * P3 — scope-tag editor slot (workflow role only). Renders between
88
+ * the card's mode row and issue row when present.
89
+ */
90
+ scopeCardScopeTagEditor?: React.ReactNode;
67
91
  /** Test id applied to the overlay root. */
68
92
  "data-testid"?: string;
69
93
  /** Optional extra children (e.g., future comment balloon layer). */
@@ -80,6 +104,54 @@ export interface TwChromeOverlayProps {
80
104
  twips: number,
81
105
  rule: "auto" | "atLeast" | "exact",
82
106
  ) => void;
107
+
108
+ // Page-stack overlay (P3.b) -------------------------------------------
109
+ /**
110
+ * Scroll root that hosts the PM surface + page-break widgets. The page-
111
+ * stack overlay measures widget positions from this element to derive
112
+ * per-page Y-ranges. When omitted, the page-stack overlay is skipped —
113
+ * consumers in canvas workspace mode or tests without a DOM can pass
114
+ * `null` to disable the overlay cleanly.
115
+ */
116
+ pageStackScrollRoot?: HTMLElement | null;
117
+ /**
118
+ * Render-frame revision tick — incremented by the workspace on
119
+ * `zoom_changed` / `render_frame_ready` / `incremental_relayout` events.
120
+ * Drives re-measurement of the page-stack overlay without triggering a
121
+ * full workspace re-render.
122
+ */
123
+ renderFrameRevision?: number;
124
+
125
+ // Page-stack chrome layer (P8.8) --------------------------------------
126
+ /**
127
+ * Current active story target — the page-stack chrome layer uses this
128
+ * to decide which per-page band (if any) should render in active-slot
129
+ * mode. When omitted the chrome layer treats `{ kind: "main" }` as
130
+ * the active story, so no band is promoted.
131
+ */
132
+ activeStory?: EditorStoryTarget;
133
+ /**
134
+ * Fired when the user clicks a per-page header / footer band to
135
+ * promote it into the active editing surface. Task 10 will route PM
136
+ * into the matching band via React portals; today the handler is a
137
+ * pass-through to `runtime.openStory`.
138
+ */
139
+ onOpenStory?: (target: EditorStoryTarget) => void;
140
+ /**
141
+ * P8.11 — PM surface DOM element (`.ProseMirror` div). The chrome
142
+ * layer's portal mechanism reparents this element across the per-page
143
+ * band portal slots as `activeStory` changes. When omitted the
144
+ * reparent step is skipped; the chrome layer still renders read-only
145
+ * bands but the active-slot promotion is inert.
146
+ */
147
+ pmSurfaceElement?: HTMLElement | null;
148
+ /**
149
+ * P8.11 — optional PM view handle with `hasFocus()` + `focus()`. When
150
+ * supplied, the chrome layer re-focuses PM after a portal swap so
151
+ * mid-edit band clicks don't silently drop the caret. Omitting the
152
+ * handle leaves focus-restore as a no-op — DOM reparent still runs.
153
+ */
154
+ pmView?: PmPortalView | null;
83
155
  }
84
156
 
85
157
  /**
@@ -100,11 +172,22 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
100
172
  onScopeCardClose,
101
173
  onScopeCardModeChange,
102
174
  onScopeCardIssueAction,
175
+ onScopeCardAcceptSuggestionGroup,
176
+ onScopeCardRejectSuggestionGroup,
177
+ onScopeCardAskAgent,
178
+ editorRole,
179
+ scopeCardScopeTagEditor,
103
180
  "data-testid": testId,
104
181
  children,
105
182
  tableContext,
106
183
  onSetColumnWidth,
107
184
  onSetRowHeight,
185
+ pageStackScrollRoot,
186
+ renderFrameRevision,
187
+ activeStory,
188
+ onOpenStory,
189
+ pmSurfaceElement,
190
+ pmView,
108
191
  }) => {
109
192
  return (
110
193
  <div
@@ -112,6 +195,24 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
112
195
  data-testid={testId ?? "chrome-overlay"}
113
196
  role="presentation"
114
197
  >
198
+ {pageStackScrollRoot !== undefined ? (
199
+ <TwPageStackOverlayLayer
200
+ facet={facet}
201
+ scrollRoot={pageStackScrollRoot}
202
+ renderFrameRevision={renderFrameRevision ?? 0}
203
+ />
204
+ ) : null}
205
+ {pageStackScrollRoot !== undefined ? (
206
+ <TwPageStackChromeLayer
207
+ facet={facet}
208
+ scrollRoot={pageStackScrollRoot}
209
+ renderFrameRevision={renderFrameRevision ?? 0}
210
+ activeStory={activeStory ?? { kind: "main" }}
211
+ onOpenStory={onOpenStory}
212
+ pmSurfaceElement={pmSurfaceElement}
213
+ pmView={pmView}
214
+ />
215
+ ) : null}
115
216
  <TwScopeRailLayer
116
217
  facet={facet}
117
218
  space={space}
@@ -125,6 +226,11 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
125
226
  onClose={onScopeCardClose ?? noop}
126
227
  onModeChange={onScopeCardModeChange ?? noopModeChange}
127
228
  onIssueAction={onScopeCardIssueAction ?? noopIssueAction}
229
+ onAcceptSuggestionGroup={onScopeCardAcceptSuggestionGroup}
230
+ onRejectSuggestionGroup={onScopeCardRejectSuggestionGroup}
231
+ onAskAgent={onScopeCardAskAgent}
232
+ editorRole={editorRole}
233
+ scopeTagEditor={scopeCardScopeTagEditor}
128
234
  space={space}
129
235
  />
130
236
  <TwTableGripLayer
@@ -0,0 +1,527 @@
1
+ /**
2
+ * TwPageStackOverlayLayer — per-page paper-frame overlays for pages 2..N.
3
+ *
4
+ * Before this layer, a multi-page document rendered as ONE long page-shaped
5
+ * container with the `wre-page-chrome` shadow + border painted on the outer
6
+ * workspace `<div>`. The inter-page widgets from
7
+ * `pm-page-break-decorations.ts` produced footer + canvas gap + header
8
+ * visuals between pages, but the outer shadow spanned the whole stack — so
9
+ * the user perceived one long paper, not N distinct papers.
10
+ *
11
+ * This layer composes over the PM surface inside the chrome overlay and
12
+ * paints an additional rounded-rectangle overlay behind each page's body
13
+ * region. Page 1 keeps its shadow from the outer wrapper (the `wre-page-
14
+ * chrome` class); page 2..N get their own paper-frame overlay via this
15
+ * layer. The overlays are purely decorative — `pointer-events: none`,
16
+ * `aria-hidden="true"`, z-index underneath the PM text — so they never
17
+ * interfere with selection, caret, or chrome interaction.
18
+ *
19
+ * Positioning strategy
20
+ * --------------------
21
+ *
22
+ * PM renders the body as a single continuous flow and the P3.a
23
+ * `pm-page-break-decorations` widgets mark every page boundary with
24
+ * `data-page-frame-end={prevPageId}` / `data-page-frame-start={nextPageId}`
25
+ * attributes. This layer queries those markers from the overlay's owning
26
+ * scroll root, computes each page's Y-range in DOM coordinate space, and
27
+ * emits one `<div>` per page at that range.
28
+ *
29
+ * - Page 1 Y-range: from the scroll root top to the first
30
+ * `data-page-frame-end="page-0"` widget's offsetTop.
31
+ * - Page K (2..N-1) Y-range: from the bottom of the previous boundary
32
+ * widget (offsetTop + offsetHeight) to the top of the next boundary
33
+ * widget (offsetTop).
34
+ * - Page N Y-range: from the last boundary widget's bottom to the scroll
35
+ * root's bottom.
36
+ *
37
+ * Measurement is refreshed on:
38
+ * - mount (initial layout pass via `useLayoutEffect`)
39
+ * - `renderFrameRevision` tick (emitted by the layout facet after
40
+ * incremental relayout, zoom changes, or render-frame diffs)
41
+ * - scroll root resize (via `ResizeObserver`)
42
+ * - PM body DOM mutations (via `MutationObserver` on the scroll root)
43
+ *
44
+ * The overlay is a runtime-owned projection — it never consults canonical
45
+ * state or the render kernel's emit beyond the page count; it reads the
46
+ * DOM once per render-frame revision and otherwise stays idle.
47
+ */
48
+
49
+ import * as React from "react";
50
+ import type { WordReviewEditorLayoutFacet } from "../../api/public-types";
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Measurement
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export interface PageOverlayRect {
57
+ /** Canonical page id from `RuntimePageGraph.pageId`. */
58
+ pageId: string;
59
+ /** 0-based page index matching `facet.getPage(pageIndex)`. */
60
+ pageIndex: number;
61
+ /** Top Y in DOM coords relative to the scroll root. */
62
+ topPx: number;
63
+ /** Bottom Y in DOM coords relative to the scroll root. */
64
+ bottomPx: number;
65
+ /** Rendered height = bottomPx - topPx. */
66
+ heightPx: number;
67
+ }
68
+
69
+ /**
70
+ * Raw boundary measurement — one entry per `[data-page-frame-end]`
71
+ * widget. Exported so DOM measurement helpers and tests can share a
72
+ * single shape.
73
+ */
74
+ export interface PageBoundaryMeasurement {
75
+ prevPageId: string;
76
+ nextPageId: string;
77
+ /** Widget top edge = bottom of the previous page. */
78
+ topPx: number;
79
+ /** Widget bottom edge = top of the next page. */
80
+ bottomPx: number;
81
+ }
82
+
83
+ /**
84
+ * Pure helper: turn pre-measured page-boundary widget positions into
85
+ * one `PageOverlayRect` per page. No DOM access — the caller supplies
86
+ * `widgets` and `scrollHeight` already expressed in whatever coordinate
87
+ * space the overlay consumer renders in.
88
+ *
89
+ * `pageCount` is the authoritative source of how many pages exist.
90
+ * Partial renders (PM hasn't committed every boundary widget yet) still
91
+ * produce a `pageCount`-long result: any tail page without its own
92
+ * `boundaryBefore` falls back to `topPx = 0` and any tail page without
93
+ * `boundaryAfter` falls back to `bottomPx = scrollHeight`. Synthesized
94
+ * `page-N` ids keep React keys stable across partial→full renders.
95
+ *
96
+ * Exported for tests — unit tests drive this with inline widget data,
97
+ * no DOM fixture required.
98
+ */
99
+ export function resolvePageOverlayRects(
100
+ input:
101
+ | {
102
+ /** Pre-measured widgets — origin-relative `topPx` / `bottomPx`. */
103
+ widgets: readonly PageBoundaryMeasurement[];
104
+ pageCount: number;
105
+ /** Total scroll-root height in the overlay's coordinate space. */
106
+ scrollHeight: number;
107
+ }
108
+ // Legacy two-arg path preserved for backward compat. Walks
109
+ // offsetTop chain inside the scroll-root; used by harness code
110
+ // paths that never integrated a proper overlay ref.
111
+ | [
112
+ scrollRoot: Pick<HTMLElement, "clientHeight" | "querySelectorAll"> | null,
113
+ pageCount: number,
114
+ ],
115
+ legacyPageCount?: number,
116
+ ): readonly PageOverlayRect[] {
117
+ // Normalize arguments. The two supported shapes:
118
+ // 1. Object: `{widgets, pageCount, scrollHeight}` — purely pure.
119
+ // 2. Positional: `(scrollRoot, pageCount)` — backward-compat; runs
120
+ // `measureWidgetsViaOffsetChain` internally. Kept so existing
121
+ // tests pass without churn.
122
+ let widgets: readonly PageBoundaryMeasurement[];
123
+ let pageCount: number;
124
+ let scrollHeight: number;
125
+
126
+ if (Array.isArray(input)) {
127
+ const [scrollRoot, count] = input;
128
+ if (!scrollRoot || count <= 0) return [];
129
+ widgets = measureWidgetsViaOffsetChain(scrollRoot);
130
+ pageCount = count;
131
+ scrollHeight = scrollRoot.clientHeight;
132
+ } else if (
133
+ input !== null &&
134
+ typeof input === "object" &&
135
+ "widgets" in input
136
+ ) {
137
+ widgets = input.widgets;
138
+ pageCount = input.pageCount;
139
+ scrollHeight = input.scrollHeight;
140
+ } else if (input && legacyPageCount !== undefined) {
141
+ // (scrollRoot, pageCount) positional call. `input` is the scroll
142
+ // root, `legacyPageCount` is the count.
143
+ const scrollRoot = input as unknown as Pick<
144
+ HTMLElement,
145
+ "clientHeight" | "querySelectorAll"
146
+ >;
147
+ if (legacyPageCount <= 0) return [];
148
+ widgets = measureWidgetsViaOffsetChain(scrollRoot);
149
+ pageCount = legacyPageCount;
150
+ scrollHeight = scrollRoot.clientHeight;
151
+ } else {
152
+ return [];
153
+ }
154
+
155
+ if (pageCount <= 0) return [];
156
+
157
+ // Sort boundaries by topPx so page order is stable even if the DOM
158
+ // emits widgets out-of-order (it doesn't today, but the cost is
159
+ // negligible).
160
+ const boundaries = [...widgets].sort((a, b) => a.topPx - b.topPx);
161
+
162
+ const rects: PageOverlayRect[] = [];
163
+ for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
164
+ const boundaryBefore = pageIndex === 0 ? null : boundaries[pageIndex - 1];
165
+ const boundaryAfter =
166
+ pageIndex === pageCount - 1 ? null : boundaries[pageIndex];
167
+
168
+ let pageId: string | null = null;
169
+ if (boundaryBefore) pageId = boundaryBefore.nextPageId;
170
+ else if (boundaryAfter) pageId = boundaryAfter.prevPageId;
171
+ if (!pageId) pageId = `page-${pageIndex}`;
172
+
173
+ const topPx = boundaryBefore ? boundaryBefore.bottomPx : 0;
174
+ const bottomPx = boundaryAfter ? boundaryAfter.topPx : scrollHeight;
175
+
176
+ if (bottomPx <= topPx) continue;
177
+
178
+ rects.push({
179
+ pageId,
180
+ pageIndex,
181
+ topPx,
182
+ bottomPx,
183
+ heightPx: bottomPx - topPx,
184
+ });
185
+ }
186
+
187
+ return rects;
188
+ }
189
+
190
+ /**
191
+ * DOM measurement via `getBoundingClientRect()`.
192
+ *
193
+ * `originElement` defines the coordinate space the returned
194
+ * `topPx` / `bottomPx` values live in. When the overlay consumer is a
195
+ * React component, `originElement` is typically the component's own
196
+ * root `<div>` (ref via `useRef`). That keeps widget coordinates
197
+ * perfectly aligned with the overlay's own coordinate space regardless
198
+ * of how many positioned ancestors sit between the scroll root and
199
+ * the overlay — the problem P3.b's offset-chain walk silently
200
+ * produced wrong coordinates for.
201
+ *
202
+ * `queryRoot` is the DOM subtree to search for `[data-page-frame-end]`
203
+ * widgets. It can be the same element as `originElement` or a broader
204
+ * ancestor.
205
+ */
206
+ export function measureWidgetsViaBoundingRect(
207
+ queryRoot: Pick<HTMLElement, "querySelectorAll"> | null,
208
+ originElement: HTMLElement | null,
209
+ ): PageBoundaryMeasurement[] {
210
+ if (!queryRoot || !originElement) return [];
211
+ const originRect = originElement.getBoundingClientRect();
212
+ const widgets = Array.from(
213
+ queryRoot.querySelectorAll<HTMLElement>("[data-page-frame-end]"),
214
+ );
215
+ const out: PageBoundaryMeasurement[] = [];
216
+ for (const widget of widgets) {
217
+ const prevPageId = widget.getAttribute("data-page-frame-end");
218
+ const nextPageId = widget.getAttribute("data-page-frame-start");
219
+ if (!prevPageId || !nextPageId) continue;
220
+ const rect = widget.getBoundingClientRect();
221
+ out.push({
222
+ prevPageId,
223
+ nextPageId,
224
+ topPx: rect.top - originRect.top,
225
+ bottomPx: rect.bottom - originRect.top,
226
+ });
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * Legacy DOM measurement via the `offsetTop` / `offsetParent` chain.
233
+ * Returns offsets in scroll-root-relative coordinates. Retained for
234
+ * the positional `resolvePageOverlayRects(scrollRoot, pageCount)` call
235
+ * signature that predates the `getBoundingClientRect()` path; new code
236
+ * should prefer `measureWidgetsViaBoundingRect(queryRoot, origin)` so
237
+ * widget coordinates align with the overlay's own rendering origin.
238
+ *
239
+ * The walk stops at `scrollRoot` (never-null parent is an escape
240
+ * hatch). Nested positioned ancestors between the widget and the
241
+ * scroll root are summed correctly; the deprecation here is about
242
+ * "scroll root is often not the right origin", not about arithmetic.
243
+ */
244
+ export function measureWidgetsViaOffsetChain(
245
+ scrollRoot: Pick<HTMLElement, "clientHeight" | "querySelectorAll">,
246
+ ): PageBoundaryMeasurement[] {
247
+ const widgets = Array.from(
248
+ scrollRoot.querySelectorAll<HTMLElement>("[data-page-frame-end]"),
249
+ );
250
+ const out: PageBoundaryMeasurement[] = [];
251
+ for (const widget of widgets) {
252
+ const prevPageId = widget.getAttribute("data-page-frame-end");
253
+ const nextPageId = widget.getAttribute("data-page-frame-start");
254
+ if (!prevPageId || !nextPageId) continue;
255
+ const topPx = resolveOffsetTop(widget, scrollRoot);
256
+ const bottomPx = topPx + resolveOffsetHeight(widget);
257
+ out.push({ prevPageId, nextPageId, topPx, bottomPx });
258
+ }
259
+ return out;
260
+ }
261
+
262
+ function resolveOffsetTop(
263
+ widget: HTMLElement,
264
+ scrollRoot: Pick<HTMLElement, "clientHeight">,
265
+ ): number {
266
+ // jsdom doesn't populate `offsetTop` from layout, but the property is
267
+ // still defined and defaults to 0. Browsers set it relative to the
268
+ // offsetParent. We walk up the offset chain until we reach the scroll
269
+ // root (or exit the document) so the result is scroll-root-relative.
270
+ let node: HTMLElement | null = widget;
271
+ let top = 0;
272
+ while (node) {
273
+ top += node.offsetTop ?? 0;
274
+ const parent = node.offsetParent as HTMLElement | null;
275
+ if (parent === scrollRoot || parent === null) break;
276
+ node = parent;
277
+ }
278
+ return top;
279
+ }
280
+
281
+ function resolveOffsetHeight(widget: HTMLElement): number {
282
+ return widget.offsetHeight ?? 0;
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Component
287
+ // ---------------------------------------------------------------------------
288
+
289
+ export interface TwPageStackOverlayLayerProps {
290
+ /** Layout facet — source of the page count. */
291
+ facet: WordReviewEditorLayoutFacet;
292
+ /** Scroll root element whose page-boundary widgets drive measurement. */
293
+ scrollRoot: HTMLElement | null;
294
+ /**
295
+ * Revision tick incremented on `layout_recomputed`, `incremental_relayout`,
296
+ * `render_frame_ready`, or `zoom_changed`. The layer re-measures every
297
+ * time this changes so the overlays stay aligned with content.
298
+ */
299
+ renderFrameRevision: number;
300
+ /** Optional test id applied to the overlay root. */
301
+ "data-testid"?: string;
302
+ }
303
+
304
+ /**
305
+ * The layer itself. Renders one decorative `<div>` per page with the
306
+ * same card-shadow + rounded border treatment that `.wre-page-chrome`
307
+ * applies to the outer frame today. Page 1's overlay sits flush with
308
+ * the outer frame (effectively redundant — the outer card is already
309
+ * visible); pages 2..N gain their own paper-frame treatment that the
310
+ * outer wrapper cannot draw on its own.
311
+ */
312
+ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = ({
313
+ facet,
314
+ scrollRoot,
315
+ renderFrameRevision,
316
+ "data-testid": testId,
317
+ }) => {
318
+ const [rects, setRects] = React.useState<readonly PageOverlayRect[]>([]);
319
+ // P3.d fix: the overlay root acts as the **measurement origin** so
320
+ // widget `topPx` / `bottomPx` are expressed in the exact coordinate
321
+ // space the overlay paints in. Using `scrollRoot` as origin (P3.b
322
+ // default) silently produced wrong coordinates when the overlay
323
+ // rendered inside nested positioned ancestors (pm-body-wrapper inside
324
+ // frame-div inside content-inset inside scroll root).
325
+ const overlayRootRef = React.useRef<HTMLDivElement | null>(null);
326
+
327
+ // P14 hardening: coalesce refresh calls via requestAnimationFrame.
328
+ // Pre-P14 every ResizeObserver / MutationObserver / renderFrameRevision
329
+ // tick fired `refreshRects` synchronously — which does O(N)
330
+ // getBoundingClientRect calls (one per page-break widget plus the
331
+ // overlay-root rect), and every `getBoundingClientRect()` forces the
332
+ // browser to flush pending style + layout calculations. For a
333
+ // 38-page CCEP doc that's 38 forced layouts per keystroke, multiplied
334
+ // by 2–3 refresh triggers per edit (layout_recomputed +
335
+ // incremental_relayout + MutationObserver + maybe page_count_changed)
336
+ // = ~150 forced layouts per keystroke. The editor appeared to lock
337
+ // up during typing on large docs.
338
+ //
339
+ // rAF coalescing flips this so the worst case is **one measurement
340
+ // pass per frame** regardless of how many triggers fire. The
341
+ // `rafHandleRef` protects against double-scheduling; clearing it on
342
+ // unmount prevents the callback from firing after the component
343
+ // tears down.
344
+ const rafHandleRef = React.useRef<number | null>(null);
345
+
346
+ const refreshRectsNow = React.useCallback(() => {
347
+ if (!scrollRoot) {
348
+ setRects([]);
349
+ return;
350
+ }
351
+ const origin = overlayRootRef.current;
352
+ const pageCount = facet.getPageCount();
353
+ if (origin) {
354
+ const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin);
355
+ const originRect = origin.getBoundingClientRect();
356
+ setRects(
357
+ resolvePageOverlayRects({
358
+ widgets,
359
+ pageCount,
360
+ scrollHeight:
361
+ origin.clientHeight > 0 ? origin.clientHeight : originRect.height,
362
+ }),
363
+ );
364
+ } else {
365
+ setRects(resolvePageOverlayRects([scrollRoot, pageCount]));
366
+ }
367
+ }, [facet, scrollRoot]);
368
+
369
+ const refreshRects = React.useCallback(() => {
370
+ if (!scrollRoot) {
371
+ setRects([]);
372
+ return;
373
+ }
374
+ const runtime = scrollRoot.ownerDocument?.defaultView as
375
+ | (Window & {
376
+ requestAnimationFrame?: (cb: () => void) => number;
377
+ cancelAnimationFrame?: (handle: number) => void;
378
+ })
379
+ | null;
380
+ const raf = runtime?.requestAnimationFrame;
381
+ // SSR + older jsdom without rAF: fall back to immediate refresh.
382
+ if (!raf) {
383
+ refreshRectsNow();
384
+ return;
385
+ }
386
+ // Drop duplicate schedules inside the same frame — each subsequent
387
+ // refresh-request short-circuits while one is already pending.
388
+ if (rafHandleRef.current !== null) return;
389
+ rafHandleRef.current = raf(() => {
390
+ rafHandleRef.current = null;
391
+ refreshRectsNow();
392
+ });
393
+ }, [scrollRoot, refreshRectsNow]);
394
+
395
+ // P14: re-measure via the debounced scheduler so multiple
396
+ // `renderFrameRevision` bumps per edit (layout_recomputed +
397
+ // incremental_relayout + page_count_changed + page_field_dirtied)
398
+ // coalesce to one measurement pass per animation frame. `useEffect`
399
+ // instead of `useLayoutEffect` — we don't need to block paint for
400
+ // this decorative overlay; a one-frame delay before page borders
401
+ // reposition is imperceptible.
402
+ React.useEffect(() => {
403
+ refreshRects();
404
+ return () => {
405
+ const runtime = scrollRoot?.ownerDocument?.defaultView as
406
+ | (Window & { cancelAnimationFrame?: (h: number) => void })
407
+ | null;
408
+ if (rafHandleRef.current !== null && runtime?.cancelAnimationFrame) {
409
+ runtime.cancelAnimationFrame(rafHandleRef.current);
410
+ rafHandleRef.current = null;
411
+ }
412
+ };
413
+ }, [refreshRects, renderFrameRevision, scrollRoot]);
414
+
415
+ // Observe scroll-root size changes so zoom, viewport resize, or font
416
+ // loading re-trigger measurement without a full app re-render.
417
+ React.useEffect(() => {
418
+ if (!scrollRoot) return;
419
+ const runtime = scrollRoot.ownerDocument?.defaultView as
420
+ | (Window & { ResizeObserver?: typeof ResizeObserver })
421
+ | null;
422
+ if (!runtime?.ResizeObserver) return;
423
+ const observer = new runtime.ResizeObserver(() => refreshRects());
424
+ observer.observe(scrollRoot);
425
+ return () => observer.disconnect();
426
+ }, [scrollRoot, refreshRects]);
427
+
428
+ // Observe DOM mutations so PM re-renders (page-break widgets added /
429
+ // removed on relayout) re-trigger measurement. We filter to
430
+ // childList + subtree changes to avoid needless recomputation on
431
+ // style-only attribute churn.
432
+ //
433
+ // Hardening: ignore mutations whose targets live INSIDE the overlay
434
+ // root itself. Without this filter, the `setRects` → React
435
+ // reconciliation → new/removed `<div>` children → MutationObserver
436
+ // cycle can fire recursively. The filter breaks the cycle cleanly
437
+ // by ignoring every mutation that only touches overlay descendants.
438
+ //
439
+ // P14: the observer callback now routes through the debounced
440
+ // `refreshRects`, so a keystroke burst that fires the observer many
441
+ // times within one animation frame coalesces into one measurement
442
+ // pass instead of a per-mutation re-render storm.
443
+ React.useEffect(() => {
444
+ if (!scrollRoot) return;
445
+ const runtime = scrollRoot.ownerDocument?.defaultView as
446
+ | (Window & { MutationObserver?: typeof MutationObserver })
447
+ | null;
448
+ if (!runtime?.MutationObserver) return;
449
+ const observer = new runtime.MutationObserver((records) => {
450
+ const overlay = overlayRootRef.current;
451
+ if (overlay) {
452
+ const allSelf = records.every(
453
+ (r) => r.target instanceof Node && overlay.contains(r.target),
454
+ );
455
+ if (allSelf) return;
456
+ }
457
+ refreshRects();
458
+ });
459
+ // P14.e: subtree:false eliminates the per-keystroke characterData
460
+ // schedule. Page boundaries only shift on childList changes — the
461
+ // page-frame widget `<div>` is added or removed when pagination
462
+ // emits a new page boundary. characterData mutations (every
463
+ // keystroke!) used to fire the callback even though they never
464
+ // change widget positions; narrowing the scope removes that storm
465
+ // entirely without losing the page-add / page-remove signal that
466
+ // actually matters for the overlay's rect math.
467
+ observer.observe(scrollRoot, { childList: true, subtree: false });
468
+ return () => observer.disconnect();
469
+ }, [scrollRoot, refreshRects]);
470
+
471
+ // Always render the overlay root so the ref resolves on first paint.
472
+ // Without the root element we never get a `getBoundingClientRect()`
473
+ // call, which means the first measurement pass falls back to the
474
+ // offset-chain (wrong coords) and the user sees mis-aligned page
475
+ // frames until the next `renderFrameRevision` tick. Keeping the
476
+ // root element present + empty is cheap (one `<div>`) and makes the
477
+ // ref resolve during the first layout-effect pass.
478
+ if (rects.length === 0) {
479
+ return (
480
+ <div
481
+ ref={overlayRootRef}
482
+ className="wre-page-stack-overlay pointer-events-none absolute inset-0 z-0"
483
+ aria-hidden="true"
484
+ data-testid={testId ?? "page-stack-overlay"}
485
+ data-page-count="0"
486
+ />
487
+ );
488
+ }
489
+
490
+ return (
491
+ <div
492
+ ref={overlayRootRef}
493
+ className="wre-page-stack-overlay pointer-events-none absolute inset-0 z-0"
494
+ aria-hidden="true"
495
+ data-testid={testId ?? "page-stack-overlay"}
496
+ data-page-count={rects.length}
497
+ >
498
+ {rects.map((rect) => (
499
+ <div
500
+ key={rect.pageId}
501
+ className="wre-page-stack-overlay-frame absolute"
502
+ data-page-index={rect.pageIndex}
503
+ data-page-id={rect.pageId}
504
+ style={{
505
+ top: `${rect.topPx}px`,
506
+ height: `${rect.heightPx}px`,
507
+ left: 0,
508
+ right: 0,
509
+ // The overlay paints decorative paper-edge treatment (border
510
+ // + soft shadow) around each page's vertical slice. It sits
511
+ // ABOVE the PM surface in stacking order so we deliberately
512
+ // leave `backgroundColor` unset — a filled overlay would
513
+ // occlude PM text. Border + box-shadow alone give the
514
+ // 'distinct paper' perception without covering content.
515
+ backgroundColor: "transparent",
516
+ border: "1px solid var(--color-page-border, rgba(148,163,184,0.2))",
517
+ borderRadius: "var(--radius-page, 4px)",
518
+ boxShadow:
519
+ "0 8px 24px -20px var(--color-page-shadow, rgba(15,23,42,0.38))",
520
+ }}
521
+ />
522
+ ))}
523
+ </div>
524
+ );
525
+ };
526
+
527
+ export default TwPageStackOverlayLayer;