@beyondwork/docx-react-component 1.0.42 → 1.0.45

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 (82) hide show
  1. package/README.md +17 -0
  2. package/package.json +5 -4
  3. package/src/api/editor-state-types.ts +110 -0
  4. package/src/api/public-types.ts +333 -4
  5. package/src/core/commands/formatting-commands.ts +7 -1
  6. package/src/core/commands/index.ts +60 -10
  7. package/src/core/commands/text-commands.ts +59 -0
  8. package/src/core/search/search-text.ts +15 -2
  9. package/src/core/selection/review-anchors.ts +131 -21
  10. package/src/index.ts +29 -1
  11. package/src/io/chart-preview-resolver.ts +281 -0
  12. package/src/io/docx-session.ts +692 -2
  13. package/src/io/export/build-app-properties-xml.ts +1 -1
  14. package/src/io/export/serialize-comments.ts +38 -9
  15. package/src/io/export/twip.ts +1 -1
  16. package/src/io/load-scheduler.ts +230 -0
  17. package/src/io/normalize/normalize-text.ts +116 -0
  18. package/src/io/ooxml/parse-comments.ts +0 -33
  19. package/src/io/ooxml/parse-complex-content.ts +14 -0
  20. package/src/io/ooxml/parse-main-document.ts +4 -0
  21. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  22. package/src/io/ooxml/workflow-payload.ts +172 -1
  23. package/src/preservation/opaque-region.ts +5 -0
  24. package/src/review/store/comment-remapping.ts +2 -2
  25. package/src/runtime/collab-session.ts +1 -1
  26. package/src/runtime/document-runtime.ts +661 -42
  27. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  28. package/src/runtime/edit-dispatch/index.ts +2 -0
  29. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  30. package/src/runtime/editor-state-channel.ts +544 -0
  31. package/src/runtime/editor-state-integration.ts +217 -0
  32. package/src/runtime/editor-surface/capabilities.ts +411 -0
  33. package/src/runtime/layout/index.ts +2 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +63 -2
  36. package/src/runtime/layout/layout-engine-version.ts +41 -0
  37. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  38. package/src/runtime/layout/public-facet.ts +430 -1
  39. package/src/runtime/perf-counters.ts +28 -0
  40. package/src/runtime/prerender/cache-envelope.ts +29 -0
  41. package/src/runtime/prerender/cache-key.ts +66 -0
  42. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  43. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  44. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  45. package/src/runtime/prerender/prerender-document.ts +145 -0
  46. package/src/runtime/render/block-fragment-projection.ts +2 -0
  47. package/src/runtime/render/render-frame-types.ts +17 -0
  48. package/src/runtime/render/render-kernel.ts +172 -29
  49. package/src/runtime/selection/post-edit-validator.ts +77 -0
  50. package/src/runtime/surface-projection.ts +45 -7
  51. package/src/runtime/workflow-markup.ts +71 -16
  52. package/src/ui/WordReviewEditor.tsx +142 -237
  53. package/src/ui/editor-command-bag.ts +14 -0
  54. package/src/ui/editor-runtime-boundary.ts +115 -12
  55. package/src/ui/editor-shell-view.tsx +10 -0
  56. package/src/ui/editor-surface-controller.tsx +5 -0
  57. package/src/ui/headless/selection-helpers.ts +10 -0
  58. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  59. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  60. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  62. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  63. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  64. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  65. package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
  66. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
  67. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  68. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  69. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  70. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
  71. package/src/ui-tailwind/index.ts +5 -1
  72. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  73. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  74. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  75. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  76. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  77. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  78. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  79. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  80. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  81. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  82. package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
@@ -17,6 +17,7 @@ import type {
17
17
  ActiveListContext,
18
18
  CommentSidebarThreadSnapshot,
19
19
  DocumentNavigationSnapshot,
20
+ EditorStoryTarget,
20
21
  EditorViewStateSnapshot,
21
22
  FormattingStateSnapshot,
22
23
  FormattingAlignment,
@@ -51,6 +52,7 @@ import {
51
52
  incrementInvalidationCounter,
52
53
  recordPerfSample,
53
54
  } from "./editor-surface/perf-probe.ts";
55
+ import { useVisibleBlockRange } from "./page-stack/use-visible-block-range.ts";
54
56
  import { sliceBlocksForPage } from "./editor-surface/page-slice-util.ts";
55
57
  import { TwPageBlockView } from "./editor-surface/tw-page-block-view.tsx";
56
58
  import { computeLineMarkersIfEnabled } from "./page-chrome-model.ts";
@@ -157,6 +159,16 @@ export interface TwReviewWorkspaceProps {
157
159
  interactionGuardSnapshot?: InteractionGuardSnapshot;
158
160
  chromePreset?: WordReviewEditorChromePreset;
159
161
  chromeOptions?: Partial<WordReviewEditorChromeOptions>;
162
+ /** P9g — live collab session for the `"collab"` chrome preset's top nav. */
163
+ collabSession?: import("../runtime/collab-session.ts").CollabSession;
164
+ collabTransportStatus?: import("../api/awareness-identity-types.ts").TransportStatus;
165
+ collabActorId?: string;
166
+ collabSendBaseline?: {
167
+ originDocumentId: string;
168
+ originPayloadId: string;
169
+ originContentHash: string;
170
+ payloadXml: string;
171
+ };
160
172
  reviewQueue?: ReviewQueueSnapshot;
161
173
  documentContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
162
174
  selectionContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
@@ -278,7 +290,18 @@ export interface TwReviewWorkspaceProps {
278
290
  onAcceptAllChanges?: () => void;
279
291
  onRejectAllChanges?: () => void;
280
292
  onCloseStory?: () => void;
293
+ /**
294
+ * @deprecated P8.11 — the workspace no longer renders a workspace-level
295
+ * header band with an "Edit header" button; per-page header bands route
296
+ * clicks via `onOpenStory` / `runtime.openStory` directly. The prop
297
+ * remains optional for one release so existing hosts continue to
298
+ * compile; supplying it emits a `console.warn` on mount.
299
+ */
281
300
  onOpenHeaderStory?: () => void;
301
+ /**
302
+ * @deprecated P8.11 — see `onOpenHeaderStory`. Footer variant of the
303
+ * same deprecation.
304
+ */
282
305
  onOpenFooterStory?: () => void;
283
306
  /**
284
307
  * Open a header/footer story for a specific page. Called when the user
@@ -288,6 +311,13 @@ export interface TwReviewWorkspaceProps {
288
311
  */
289
312
  onOpenHeaderStoryForPage?: (pageIndex: number) => void;
290
313
  onOpenFooterStoryForPage?: (pageIndex: number) => void;
314
+ /**
315
+ * P8.11 — fired when a per-page chrome band (header / footer) is
316
+ * clicked to promote it into the active editing surface. Wire to
317
+ * `runtime.openStory(target)`; the chrome layer's portal mechanism
318
+ * then reparents the PM surface into the matching band's active slot.
319
+ */
320
+ onOpenStory?: (target: EditorStoryTarget) => void;
291
321
  onSetParagraphIndentation?: (indentation: {
292
322
  left?: number;
293
323
  right?: number;
@@ -384,6 +414,17 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
384
414
  } as TwReviewWorkspaceProps & EditorCommandBag;
385
415
  const { snapshot, viewState } = props;
386
416
  const selectionToolbarRootRef = useRef<HTMLDivElement>(null);
417
+ // P8.11 — body slot wrapping `{props.document}` (the PM surface) + scroll
418
+ // root ref. The chrome layer's `TwPageStackChromeLayer` needs both to
419
+ // measure per-page rects and to reparent PM's DOM node across band
420
+ // portals when `activeStory` changes. See comment near the body slot
421
+ // in the render tree below.
422
+ const bodySlotRef = useRef<HTMLDivElement | null>(null);
423
+ const scrollRootRef = useRef<HTMLDivElement | null>(null);
424
+ const [pmSurfaceElement, setPmSurfaceElement] =
425
+ useState<HTMLElement | null>(null);
426
+ const [pageStackScrollRoot, setPageStackScrollRoot] =
427
+ useState<HTMLElement | null>(null);
387
428
  const caps = props.capabilities;
388
429
  const isPageWorkspace = props.workspaceMode === "page";
389
430
  const markupDisplay = props.markupDisplay;
@@ -640,8 +681,10 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
640
681
  };
641
682
  // eslint-disable-next-line react-hooks/exhaustive-deps
642
683
  }, [props.layoutFacet, selectionPosition, snapshot.activeStory, renderFrameRevision]);
643
- const headerBandLabel = resolvePageBandLabel("header", snapshot.activeStory);
644
- const footerBandLabel = resolvePageBandLabel("footer", snapshot.activeStory);
684
+ // P8.11 — `headerBandLabel` / `footerBandLabel` retired along with the
685
+ // workspace-level bands. Per-page bands in `TwPageStackChromeLayer`
686
+ // render the actual header / footer story blocks via
687
+ // `TwRegionBlockRenderer`, so a label row is no longer meaningful here.
645
688
  const hidePageBorderForActiveEditing =
646
689
  isPageWorkspace &&
647
690
  snapshot.activeStory.kind === "main" &&
@@ -708,6 +751,88 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
708
751
  }
709
752
  }, [isPageWorkspace, snapshot.activeStory.kind]);
710
753
 
754
+ // P8.11 — capture the scroll-root DOM element on mount so the chrome
755
+ // overlay's `TwPageStackChromeLayer` can measure per-page rects and
756
+ // observe DOM mutations. `scrollRootRef` is attached to the existing
757
+ // `[data-wre-scroll-root]` container; rely on a mount effect rather
758
+ // than a ref callback so render-time state stays cheap.
759
+ useEffect(() => {
760
+ if (scrollRootRef.current !== pageStackScrollRoot) {
761
+ setPageStackScrollRoot(scrollRootRef.current);
762
+ }
763
+ // A `useEffect` re-runs after every render; the comparison guard
764
+ // keeps `setPageStackScrollRoot` from firing every commit. The
765
+ // scroll-root identity only changes when the component re-mounts.
766
+ });
767
+
768
+ // P8.11 — capture the PM surface DOM element. The ProseMirror surface
769
+ // mounts inside `bodySlotRef` on its own schedule (the PM constructor
770
+ // runs inside the `TwProseMirrorSurface` child component). A
771
+ // `MutationObserver` scoped to the body slot's `childList` picks up
772
+ // the PM root on first commit; once captured, the chrome layer owns
773
+ // reparent state (including portal-slot promotion), so we skip
774
+ // further updates unless PM is actually disconnected from the
775
+ // document (e.g. session/document swap tearing PM down).
776
+ useEffect(() => {
777
+ const slot = bodySlotRef.current;
778
+ if (!slot) return undefined;
779
+
780
+ // If we already hold a live reference, the chrome layer may have
781
+ // portaled PM into a per-page band — PM has left `bodySlotRef` but
782
+ // is still connected to the document. We keep the reference until
783
+ // the node is fully disconnected.
784
+ if (pmSurfaceElement && pmSurfaceElement.isConnected) {
785
+ return undefined;
786
+ }
787
+
788
+ const readPm = (): HTMLElement | null =>
789
+ slot.querySelector<HTMLElement>(".ProseMirror");
790
+
791
+ const current = readPm();
792
+ if (current !== pmSurfaceElement) {
793
+ setPmSurfaceElement(current);
794
+ }
795
+ const runtime = slot.ownerDocument?.defaultView as
796
+ | (Window & { MutationObserver?: typeof MutationObserver })
797
+ | null;
798
+ if (!runtime?.MutationObserver) return undefined;
799
+ const observer = new runtime.MutationObserver(() => {
800
+ const next = readPm();
801
+ if (next !== null && next !== pmSurfaceElement) {
802
+ setPmSurfaceElement(next);
803
+ }
804
+ });
805
+ // `childList: true, subtree: false` — we only care when children of
806
+ // the body slot change (e.g. PM is added for the first time).
807
+ // Subtree mutations (PM's own edits) are not our concern and would
808
+ // fire on every keystroke.
809
+ observer.observe(slot, { childList: true, subtree: false });
810
+ return () => observer.disconnect();
811
+ }, [pmSurfaceElement]);
812
+
813
+ // P8.11 — deprecation shim for the legacy `onOpenHeaderStory` /
814
+ // `onOpenFooterStory` props. Per-page chrome bands route clicks via
815
+ // `onOpenStory` + `runtime.openStory` directly; the workspace-level
816
+ // bands that consumed these callbacks are gone. Kept optional for one
817
+ // release so existing hosts compile; a mount-time `console.warn` nudges
818
+ // them toward `onOpenStory`.
819
+ useEffect(() => {
820
+ if (props.onOpenHeaderStory) {
821
+ // eslint-disable-next-line no-console
822
+ console.warn(
823
+ "[docx-react-component] `onOpenHeaderStory` is deprecated. Per-page header bands route clicks via runtime.openStory directly. (P8)",
824
+ );
825
+ }
826
+ if (props.onOpenFooterStory) {
827
+ // eslint-disable-next-line no-console
828
+ console.warn(
829
+ "[docx-react-component] `onOpenFooterStory` is deprecated. Per-page footer bands route clicks via runtime.openStory directly. (P8)",
830
+ );
831
+ }
832
+ // Mount-once: we only want to nudge hosts at startup, not per render.
833
+ // eslint-disable-next-line react-hooks/exhaustive-deps
834
+ }, []);
835
+
711
836
  useEffect(() => {
712
837
  if (typeof window === "undefined") {
713
838
  return;
@@ -771,6 +896,101 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
771
896
  );
772
897
  }, [reviewRailAvailable, viewportWidth]);
773
898
 
899
+ // L7 Phase 2 Task 2.2.4a — viewport-scroll wiring.
900
+ // Collect DOM elements with `[data-page-frame]` from the PM surface so the
901
+ // IntersectionObserver inside `useVisibleBlockRange` can determine which
902
+ // pages are currently visible. The MutationObserver refreshes the set when
903
+ // the PM surface re-renders (e.g. a document load changes the page count).
904
+ const [pageMarkers, setPageMarkers] = useState<readonly HTMLElement[]>([]);
905
+
906
+ useEffect(() => {
907
+ const root = pageStackScrollRoot;
908
+ if (!root) {
909
+ setPageMarkers([]);
910
+ return undefined;
911
+ }
912
+ const refresh = () => {
913
+ const found = Array.from(root.querySelectorAll<HTMLElement>("[data-page-frame]"));
914
+ // The boundary widgets between pages N and N+1 carry `data-page-frame`
915
+ // for the *next* page (pages 1 … N). Page 0 has no widget before it,
916
+ // so we synthesize an in-memory element that carries page-0's attributes.
917
+ // This element is NOT in the DOM — the IntersectionObserver won't fire
918
+ // on it — but the hook's useMemo uses it to look up block indices when
919
+ // the overscan expansion includes page 0 (which happens whenever any of
920
+ // pages 1-2 are visible with overscan ≥ 1).
921
+ const hasPage0 = found.some(
922
+ (el) => el.getAttribute("data-page-frame") === "0",
923
+ );
924
+ if (!hasPage0 && found.length > 0) {
925
+ // Derive page-0 block range from the snapshot surface: page 0 starts
926
+ // at block 0 and ends just before the first block that belongs to page 1.
927
+ // The boundary widget's `data-page-first-block-index` for page 1 tells
928
+ // us where page 0 ends.
929
+ const page1Marker = found.find(
930
+ (el) => el.getAttribute("data-page-frame") === "1",
931
+ );
932
+ const page1First = page1Marker
933
+ ? Number(page1Marker.getAttribute("data-page-first-block-index") ?? "")
934
+ : NaN;
935
+ const page0Last = Number.isFinite(page1First) && page1First > 0
936
+ ? page1First - 1
937
+ : -1; // page 0 empty or unknown; synthetic marker contributes nothing.
938
+ const ownerDoc = found[0]!.ownerDocument;
939
+ const synth = ownerDoc.createElement("span");
940
+ synth.setAttribute("data-page-frame", "0");
941
+ synth.setAttribute("data-page-first-block-index", "0");
942
+ synth.setAttribute("data-page-last-block-index", String(Math.max(0, page0Last)));
943
+ setPageMarkers([synth, ...found]);
944
+ } else {
945
+ setPageMarkers(found);
946
+ }
947
+ };
948
+ refresh();
949
+ // Observe PM surface mutations for page-count changes (new doc load, etc).
950
+ const view = root.ownerDocument?.defaultView;
951
+ if (!view?.MutationObserver) return undefined;
952
+ const mo = new view.MutationObserver(refresh);
953
+ mo.observe(root, { childList: true, subtree: true });
954
+ return () => mo.disconnect();
955
+ // Re-run when the scroll root changes OR when a new snapshot lands
956
+ // (which may have a different page count and new widgets in the PM DOM).
957
+ // NOTE: snapshot.surface is intentionally excluded — its reference changes on
958
+ // every requestViewportRefresh(), which would add two extra render passes per
959
+ // scroll event. The page-0 fallback uses -1 when page1First is unknown,
960
+ // which is correct (no page-0 blocks → synthetic marker contributes nothing).
961
+ }, [pageStackScrollRoot, snapshot.revisionToken]);
962
+
963
+ // Derive the surface block index for the current selection head so the
964
+ // hook can extend the visible range to always include the selection.
965
+ const selectionBlockIndex = useMemo(() => {
966
+ const sel = snapshot.selection;
967
+ const blocks = snapshot.surface?.blocks;
968
+ if (!sel || !blocks) return null;
969
+ for (let i = 0; i < blocks.length; i++) {
970
+ const block = blocks[i]!; // from/to are required on all SurfaceBlockSnapshot variants
971
+ const blockFrom = block.from;
972
+ const blockTo = block.to;
973
+ if (sel.head >= blockFrom && sel.head <= blockTo) return i;
974
+ }
975
+ return null;
976
+ }, [snapshot.selection, snapshot.surface]);
977
+
978
+ const visibleBlockRange = useVisibleBlockRange({
979
+ pageMarkers,
980
+ overscanPages: 2,
981
+ selectionBlockIndex,
982
+ totalBlockCount: snapshot.surface?.blocks.length ?? 0,
983
+ });
984
+
985
+ // Push the visible range into the layout facet (which delegates to the
986
+ // runtime's viewport-culling machinery). Depend on `[start, end]` values
987
+ // (not the range object) so identity-preserving updates are a no-op.
988
+ useEffect(() => {
989
+ if (!props.layoutFacet) return;
990
+ props.layoutFacet.setVisibleBlockRange(visibleBlockRange);
991
+ props.layoutFacet.requestViewportRefresh();
992
+ }, [props.layoutFacet, visibleBlockRange.start, visibleBlockRange.end]);
993
+
774
994
  const dismissSelectionToolbar = useCallback(() => {
775
995
  props.onDismissSelectionToolbar?.();
776
996
  }, [props.onDismissSelectionToolbar]);
@@ -818,6 +1038,20 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
818
1038
  <div className="px-3 pt-3">
819
1039
  <ChromePresetToolbar
820
1040
  chromePreset={chromePreset}
1041
+ {...(props.collabSession ? { collabSession: props.collabSession } : {})}
1042
+ {...(props.collabTransportStatus
1043
+ ? { collabTransportStatus: props.collabTransportStatus }
1044
+ : {})}
1045
+ {...(props.activeCommentId !== undefined
1046
+ ? { activeCommentId: props.activeCommentId }
1047
+ : {})}
1048
+ {...(props.collabActorId !== undefined
1049
+ ? { collabActorId: props.collabActorId }
1050
+ : {})}
1051
+ {...(props.collabSendBaseline
1052
+ ? { collabSendBaseline: props.collabSendBaseline }
1053
+ : {})}
1054
+ chromeOptionsResolved={chromeOptions}
821
1055
  capabilities={caps}
822
1056
  compatibility={snapshot.compatibility}
823
1057
  warnings={snapshot.warnings}
@@ -1076,6 +1310,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1076
1310
  {/* Document column */}
1077
1311
  <div className="flex flex-1 flex-col min-w-0">
1078
1312
  <div
1313
+ ref={scrollRootRef}
1079
1314
  className="flex-1 overflow-y-auto bg-surface"
1080
1315
  data-wre-scroll-root="true"
1081
1316
  >
@@ -1083,32 +1318,13 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1083
1318
  ref={selectionToolbarRootRef}
1084
1319
  className={`mx-auto min-h-full w-full ${
1085
1320
  isPageWorkspace
1086
- ? "wre-page-chrome wre-page-surface relative my-8 overflow-hidden"
1321
+ ? "wre-page-chrome relative overflow-hidden"
1087
1322
  : "wre-canvas-surface relative my-8 overflow-hidden"
1088
1323
  }`}
1089
1324
  data-zoom-bucket={pageZoomBucket}
1090
1325
  data-zoom-scale={isPageWorkspace ? zoomScale : undefined}
1091
- style={
1092
- isPageWorkspace
1093
- ? {
1094
- // P2.a — real-dim page frame: width/height from
1095
- // `pageLayout.pageWidth/pageHeight × FRAME_PX_PER_TWIP_AT_96DPI`
1096
- // so every paper size renders at its
1097
- // Word-matching CSS px. `max-w-[840px]` retired.
1098
- ...(pageShellMetrics.frameWidthPx
1099
- ? { width: `${pageShellMetrics.frameWidthPx}px` }
1100
- : {}),
1101
- ...(pageShellMetrics.frameHeightPx
1102
- ? { minHeight: `${pageShellMetrics.frameHeightPx}px` }
1103
- : {}),
1104
- // P2.b — browser-native CSS `zoom` rescales layout
1105
- // so `getBoundingClientRect()` and hit-test offsets
1106
- // stay truthful at any zoom — no inverse-projection
1107
- // math needed downstream.
1108
- ...(zoomScale !== 1 ? { zoom: zoomScale } : {}),
1109
- }
1110
- : undefined
1111
- }
1326
+ data-workspace-canvas={isPageWorkspace ? "true" : undefined}
1327
+ data-workspace-mode={isPageWorkspace ? "page" : "canvas"}
1112
1328
  >
1113
1329
  {isPageWorkspace && chromeVisibility.pageChrome && snapshot.pageLayout ? (
1114
1330
  <div className="border-b border-border/70 bg-surface/65 px-5 py-3" data-testid="page-context-summary">
@@ -1309,8 +1525,38 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1309
1525
  />
1310
1526
  ) : null}
1311
1527
  <div
1312
- className={isPageWorkspace ? "relative" : undefined}
1528
+ className={
1529
+ isPageWorkspace
1530
+ ? "wre-page-surface relative mx-auto my-8"
1531
+ : "relative"
1532
+ }
1313
1533
  data-line-numbering={pageChromeModel.lineNumberingEnabled ? "enabled" : "disabled"}
1534
+ data-paper-frame={isPageWorkspace ? "true" : undefined}
1535
+ data-debug-page-layout={
1536
+ isPageWorkspace && snapshot.pageLayout
1537
+ ? `${snapshot.pageLayout.pageWidth}:${snapshot.pageLayout.pageHeight}:${snapshot.pageLayout.orientation}:${snapshot.pageLayout.sectionIndex}`
1538
+ : undefined
1539
+ }
1540
+ style={
1541
+ isPageWorkspace
1542
+ ? {
1543
+ // Phase A (L8 page-native layout): the paper frame
1544
+ // owns paper width/height + browser-native CSS
1545
+ // `zoom` so layout measurement stays truthful
1546
+ // inside the card. `pageShellMetrics.pageFrameStyle`
1547
+ // carries the paper chrome (bg + shadow + rounded +
1548
+ // border) — painted exactly once.
1549
+ ...(pageShellMetrics.frameWidthPx
1550
+ ? { width: `${pageShellMetrics.frameWidthPx}px` }
1551
+ : {}),
1552
+ ...(pageShellMetrics.frameHeightPx
1553
+ ? { minHeight: `${pageShellMetrics.frameHeightPx}px` }
1554
+ : {}),
1555
+ ...(zoomScale !== 1 ? { zoom: zoomScale } : {}),
1556
+ ...pageShellMetrics.pageFrameStyle,
1557
+ }
1558
+ : undefined
1559
+ }
1314
1560
  >
1315
1561
  {isPageWorkspace && chromeVisibility.pageChrome && pageChromeModel.lineNumberingEnabled ? (
1316
1562
  <div
@@ -1338,32 +1584,8 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1338
1584
  className={isPageWorkspace ? "relative" : undefined}
1339
1585
  data-document-grid={pageChromeModel.documentGridType}
1340
1586
  data-page-border-display={pageChromeModel.pageBorderDisplay}
1341
- style={isPageWorkspace
1342
- ? {
1343
- ...pageChromeModel.documentGridStyle,
1344
- ...pageShellMetrics.pageFrameStyle,
1345
- }
1346
- : pageChromeModel.documentGridStyle}
1587
+ style={pageChromeModel.documentGridStyle}
1347
1588
  >
1348
- {isPageWorkspace && chromeVisibility.pageChrome ? (
1349
- <div
1350
- data-testid="page-header-band"
1351
- className="relative z-10 flex items-center justify-between border-b border-border/50 bg-surface/45 px-4 text-[11px] text-secondary backdrop-blur-[1px]"
1352
- style={pageShellMetrics.headerBandStyle}
1353
- >
1354
- <span className="uppercase tracking-[0.12em] text-tertiary">{headerBandLabel}</span>
1355
- {snapshot.pageLayout?.headerVariants[0] ? (
1356
- <button
1357
- type="button"
1358
- aria-label="Open header story"
1359
- onClick={props.onOpenHeaderStory}
1360
- className="rounded-md px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface"
1361
- >
1362
- Edit header
1363
- </button>
1364
- ) : null}
1365
- </div>
1366
- ) : null}
1367
1589
  {isPageWorkspace && chromeVisibility.pageChrome && pageChromeModel.showPageBorder && !hidePageBorderForActiveEditing ? (
1368
1590
  <div
1369
1591
  aria-hidden="true"
@@ -1373,15 +1595,23 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1373
1595
  />
1374
1596
  ) : null}
1375
1597
  <div className={isPageWorkspace ? "relative z-10" : "relative"}>
1376
- {/* Page chrome (frame borders, header/footer bands,
1377
- page-number labels, inter-page separators) is
1378
- rendered as in-flow widget decorations inside
1379
- the PM surface itself see
1380
- `pm-page-break-decorations.ts`. That keeps the
1381
- chrome perfectly aligned with PM content without
1382
- any absolute-positioned overlay that would drift
1383
- relative to the browser's line layout. */}
1384
- {props.document}
1598
+ {/* P8.11 workspace-level header / footer bands
1599
+ retired. The PM surface now mounts inside
1600
+ `data-pm-body-slot`; per-page header, footer,
1601
+ footnote, and endnote chrome is owned by
1602
+ `TwPageStackChromeLayer` inside `TwChromeOverlay`
1603
+ (see below). When the user clicks a per-page
1604
+ band, the chrome layer reparents PM's DOM node
1605
+ into the active band's `data-pm-portal-slot`;
1606
+ when the user returns to the body, PM slides
1607
+ back into this wrapper. */}
1608
+ <div
1609
+ data-pm-body-slot=""
1610
+ ref={bodySlotRef}
1611
+ style={{ width: "100%" }}
1612
+ >
1613
+ {props.document}
1614
+ </div>
1385
1615
  {props.layoutFacet ? (
1386
1616
  <TwChromeOverlay
1387
1617
  facet={props.layoutFacet}
@@ -1410,28 +1640,18 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1410
1640
  ? handleScopeCardAskAgent
1411
1641
  : undefined
1412
1642
  }
1643
+ pageStackScrollRoot={
1644
+ isPageWorkspace && chromeVisibility.pageChrome
1645
+ ? pageStackScrollRoot
1646
+ : undefined
1647
+ }
1648
+ renderFrameRevision={renderFrameRevision}
1649
+ activeStory={snapshot.activeStory}
1650
+ onOpenStory={props.onOpenStory}
1651
+ pmSurfaceElement={pmSurfaceElement}
1413
1652
  />
1414
1653
  ) : null}
1415
1654
  </div>
1416
- {isPageWorkspace && chromeVisibility.pageChrome ? (
1417
- <div
1418
- data-testid="page-footer-band"
1419
- className="relative z-10 flex items-center justify-between border-t border-border/50 bg-surface/45 px-4 text-[11px] text-secondary backdrop-blur-[1px]"
1420
- style={pageShellMetrics.footerBandStyle}
1421
- >
1422
- <span className="uppercase tracking-[0.12em] text-tertiary">{footerBandLabel}</span>
1423
- {snapshot.pageLayout?.footerVariants[0] ? (
1424
- <button
1425
- type="button"
1426
- aria-label="Open footer story"
1427
- onClick={props.onOpenFooterStory}
1428
- className="rounded-md px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface"
1429
- >
1430
- Edit footer
1431
- </button>
1432
- ) : null}
1433
- </div>
1434
- ) : null}
1435
1655
  </div>
1436
1656
  </div>
1437
1657
  </div>
@@ -1717,8 +1937,6 @@ export interface PageShellMetrics {
1717
1937
  frameHeightPx?: number;
1718
1938
  contentInsetStyle: CSSProperties;
1719
1939
  pageFrameStyle: CSSProperties;
1720
- headerBandStyle: CSSProperties;
1721
- footerBandStyle: CSSProperties;
1722
1940
  }
1723
1941
 
1724
1942
  function buildPageChromeModel(
@@ -1764,8 +1982,6 @@ export function buildPageShellMetrics(
1764
1982
  return {
1765
1983
  contentInsetStyle: {},
1766
1984
  pageFrameStyle: {},
1767
- headerBandStyle: {},
1768
- footerBandStyle: {},
1769
1985
  frameWidthPx: 0,
1770
1986
  frameHeightPx: 0,
1771
1987
  };
@@ -1779,17 +1995,11 @@ export function buildPageShellMetrics(
1779
1995
  const frameHeightPx = Math.round(pageLayout.pageHeight * pxPerTwip);
1780
1996
  const horizontalInsetPx = Math.round(pageLayout.marginLeft * pxPerTwip);
1781
1997
  const horizontalInsetRightPx = Math.round(pageLayout.marginRight * pxPerTwip);
1782
- // Header BAND height = margin space NOT consumed by header content.
1783
- // When marginTop == headerMargin, the band has no headroom — floor to
1784
- // MIN_BAND_HEIGHT_PX so empty bands stay clickable.
1785
- const headerBandHeightPx = Math.max(
1786
- MIN_BAND_HEIGHT_PX,
1787
- Math.round(Math.max(0, pageLayout.marginTop - pageLayout.headerMargin) * pxPerTwip),
1788
- );
1789
- const footerBandHeightPx = Math.max(
1790
- MIN_BAND_HEIGHT_PX,
1791
- Math.round(Math.max(0, pageLayout.marginBottom - pageLayout.footerMargin) * pxPerTwip),
1792
- );
1998
+
1999
+ // P8.11 `headerBandStyle` / `footerBandStyle` removed. The
2000
+ // workspace-level band divs that consumed them are gone; per-page
2001
+ // bands (rendered by `TwPageStackChromeLayer`) compute their own
2002
+ // heights from the runtime's `PageRegionsSnapshot`.
1793
2003
 
1794
2004
  return {
1795
2005
  contentInsetStyle: {
@@ -1802,12 +2012,6 @@ export function buildPageShellMetrics(
1802
2012
  boxShadow: "0 24px 48px -32px rgba(15, 23, 42, 0.38), 0 8px 20px -18px rgba(15, 23, 42, 0.22)",
1803
2013
  border: "1px solid rgba(148, 163, 184, 0.2)",
1804
2014
  },
1805
- headerBandStyle: {
1806
- minHeight: `${headerBandHeightPx}px`,
1807
- },
1808
- footerBandStyle: {
1809
- minHeight: `${footerBandHeightPx}px`,
1810
- },
1811
2015
  frameWidthPx,
1812
2016
  frameHeightPx,
1813
2017
  };
@@ -1849,30 +2053,6 @@ export function resolveZoomMultiplier(
1849
2053
  );
1850
2054
  }
1851
2055
 
1852
- function resolvePageBandLabel(
1853
- region: "header" | "footer",
1854
- activeStory: RuntimeRenderSnapshot["activeStory"],
1855
- ): string {
1856
- const regionLabel = region === "header" ? "header" : "footer";
1857
- let label: string;
1858
- if (activeStory.kind !== region) {
1859
- label = region === "header" ? "Header" : "Footer";
1860
- } else {
1861
- switch (activeStory.variant) {
1862
- case "first":
1863
- label = `First page ${regionLabel}`;
1864
- break;
1865
- case "even":
1866
- label = `Even page ${regionLabel}`;
1867
- break;
1868
- default:
1869
- label = `Default ${regionLabel}`;
1870
- break;
1871
- }
1872
- }
1873
- return label;
1874
- }
1875
-
1876
2056
  function buildLineNumberMarkers(
1877
2057
  blocks: readonly SurfaceBlockSnapshot[],
1878
2058
  pages: ReadonlyArray<DocumentNavigationSnapshot["pages"][number]>,