@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
@@ -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,
@@ -88,6 +89,10 @@ import { TwReviewRail, type ReviewRailTab } from "./review/tw-review-rail";
88
89
  import { TwStatusBar } from "./status/tw-status-bar";
89
90
  import { type ToolbarInteractionPolicy } from "./toolbar/tw-toolbar";
90
91
  import { TwChromeOverlay } from "./chrome-overlay";
92
+ import {
93
+ cycleScopeIndex,
94
+ shouldHandleScopeNavKey,
95
+ } from "./chrome-overlay/scope-keyboard-cycle";
91
96
 
92
97
  export type ReviewWorkspaceChromeVisibility = WordReviewEditorChromeVisibility;
93
98
 
@@ -153,6 +158,16 @@ export interface TwReviewWorkspaceProps {
153
158
  interactionGuardSnapshot?: InteractionGuardSnapshot;
154
159
  chromePreset?: WordReviewEditorChromePreset;
155
160
  chromeOptions?: Partial<WordReviewEditorChromeOptions>;
161
+ /** P9g — live collab session for the `"collab"` chrome preset's top nav. */
162
+ collabSession?: import("../runtime/collab-session.ts").CollabSession;
163
+ collabTransportStatus?: import("../api/awareness-identity-types.ts").TransportStatus;
164
+ collabActorId?: string;
165
+ collabSendBaseline?: {
166
+ originDocumentId: string;
167
+ originPayloadId: string;
168
+ originContentHash: string;
169
+ payloadXml: string;
170
+ };
156
171
  reviewQueue?: ReviewQueueSnapshot;
157
172
  documentContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
158
173
  selectionContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
@@ -274,7 +289,18 @@ export interface TwReviewWorkspaceProps {
274
289
  onAcceptAllChanges?: () => void;
275
290
  onRejectAllChanges?: () => void;
276
291
  onCloseStory?: () => void;
292
+ /**
293
+ * @deprecated P8.11 — the workspace no longer renders a workspace-level
294
+ * header band with an "Edit header" button; per-page header bands route
295
+ * clicks via `onOpenStory` / `runtime.openStory` directly. The prop
296
+ * remains optional for one release so existing hosts continue to
297
+ * compile; supplying it emits a `console.warn` on mount.
298
+ */
277
299
  onOpenHeaderStory?: () => void;
300
+ /**
301
+ * @deprecated P8.11 — see `onOpenHeaderStory`. Footer variant of the
302
+ * same deprecation.
303
+ */
278
304
  onOpenFooterStory?: () => void;
279
305
  /**
280
306
  * Open a header/footer story for a specific page. Called when the user
@@ -284,6 +310,13 @@ export interface TwReviewWorkspaceProps {
284
310
  */
285
311
  onOpenHeaderStoryForPage?: (pageIndex: number) => void;
286
312
  onOpenFooterStoryForPage?: (pageIndex: number) => void;
313
+ /**
314
+ * P8.11 — fired when a per-page chrome band (header / footer) is
315
+ * clicked to promote it into the active editing surface. Wire to
316
+ * `runtime.openStory(target)`; the chrome layer's portal mechanism
317
+ * then reparents the PM surface into the matching band's active slot.
318
+ */
319
+ onOpenStory?: (target: EditorStoryTarget) => void;
287
320
  onSetParagraphIndentation?: (indentation: {
288
321
  left?: number;
289
322
  right?: number;
@@ -343,6 +376,34 @@ export interface TwReviewWorkspaceProps {
343
376
  issueId: string;
344
377
  action: import("../api/public-types.ts").ScopeIssueAction;
345
378
  }) => void;
379
+ /**
380
+ * R3 — scope card suggestion-group accept button fired. WordReview-
381
+ * Editor relays to `ref.acceptSuggestionGroup(groupId)` which fans
382
+ * out to individual `acceptChange` calls across the group members.
383
+ */
384
+ onScopeAcceptSuggestionGroup?: (payload: {
385
+ scopeId: string;
386
+ groupId: string;
387
+ }) => void;
388
+ /** R3 — scope card suggestion-group reject. */
389
+ onScopeRejectSuggestionGroup?: (payload: {
390
+ scopeId: string;
391
+ groupId: string;
392
+ }) => void;
393
+ /**
394
+ * K2 — scope card "Ask review agent" fired. WordReviewEditor emits
395
+ * `agent-on-selection-requested` via WordReviewEditorEvent.
396
+ */
397
+ onScopeAskAgent?: (payload: {
398
+ scopeId: string;
399
+ }) => void;
400
+ /**
401
+ * P3 — optional scope-tag editor slot rendered inside the scope
402
+ * card when `editorRole === "workflow"`. Hosts pass a chip picker,
403
+ * free-text input, or whatever authoring surface they want. Unset
404
+ * in editor/review roles.
405
+ */
406
+ scopeCardScopeTagEditor?: ReactNode;
346
407
  }
347
408
 
348
409
  export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
@@ -352,6 +413,17 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
352
413
  } as TwReviewWorkspaceProps & EditorCommandBag;
353
414
  const { snapshot, viewState } = props;
354
415
  const selectionToolbarRootRef = useRef<HTMLDivElement>(null);
416
+ // P8.11 — body slot wrapping `{props.document}` (the PM surface) + scroll
417
+ // root ref. The chrome layer's `TwPageStackChromeLayer` needs both to
418
+ // measure per-page rects and to reparent PM's DOM node across band
419
+ // portals when `activeStory` changes. See comment near the body slot
420
+ // in the render tree below.
421
+ const bodySlotRef = useRef<HTMLDivElement | null>(null);
422
+ const scrollRootRef = useRef<HTMLDivElement | null>(null);
423
+ const [pmSurfaceElement, setPmSurfaceElement] =
424
+ useState<HTMLElement | null>(null);
425
+ const [pageStackScrollRoot, setPageStackScrollRoot] =
426
+ useState<HTMLElement | null>(null);
355
427
  const caps = props.capabilities;
356
428
  const isPageWorkspace = props.workspaceMode === "page";
357
429
  const markupDisplay = props.markupDisplay;
@@ -373,6 +445,37 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
373
445
  const handleScopeCardClose = useCallback(() => {
374
446
  setActiveScopeId(null);
375
447
  }, []);
448
+
449
+ // P3d: keyboard scope navigation. J / K cycle the active scope in
450
+ // document order; Enter opens the first scope when none is active.
451
+ // `shouldHandleScopeNavKey` + `cycleScopeIndex` are extracted pure
452
+ // helpers so the logic is unit-testable without a workspace mount.
453
+ useEffect(() => {
454
+ const layoutFacet = props.layoutFacet;
455
+ if (!layoutFacet || typeof layoutFacet.getAllScopeCardModels !== "function") {
456
+ return undefined;
457
+ }
458
+ const onKey = (event: KeyboardEvent) => {
459
+ if (!shouldHandleScopeNavKey(event)) return;
460
+ const models = layoutFacet.getAllScopeCardModels();
461
+ if (models.length === 0) return;
462
+ const ids = models.map((model) => model.scopeId);
463
+ const key = event.key.toLowerCase();
464
+ if (key === "enter") {
465
+ if (!activeScopeId) {
466
+ setActiveScopeId(ids[0] ?? null);
467
+ event.preventDefault();
468
+ }
469
+ return;
470
+ }
471
+ const direction: 1 | -1 = key === "j" ? 1 : -1;
472
+ const next = cycleScopeIndex(activeScopeId, ids, direction);
473
+ setActiveScopeId(next);
474
+ event.preventDefault();
475
+ };
476
+ window.addEventListener("keydown", onKey);
477
+ return () => window.removeEventListener("keydown", onKey);
478
+ }, [props.layoutFacet, activeScopeId]);
376
479
  const onScopeModeChangeRequested = props.onScopeModeChangeRequested;
377
480
  const handleScopeCardModeChange = useCallback(
378
481
  (scopeId: string, mode: import("../api/public-types.ts").WorkflowScopeMode) => {
@@ -391,10 +494,32 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
391
494
  },
392
495
  [onScopeIssueActionRequested],
393
496
  );
497
+ const onScopeAcceptSuggestionGroup = props.onScopeAcceptSuggestionGroup;
498
+ const handleScopeCardAcceptSuggestionGroup = useCallback(
499
+ (scopeId: string, groupId: string) => {
500
+ onScopeAcceptSuggestionGroup?.({ scopeId, groupId });
501
+ },
502
+ [onScopeAcceptSuggestionGroup],
503
+ );
504
+ const onScopeRejectSuggestionGroup = props.onScopeRejectSuggestionGroup;
505
+ const handleScopeCardRejectSuggestionGroup = useCallback(
506
+ (scopeId: string, groupId: string) => {
507
+ onScopeRejectSuggestionGroup?.({ scopeId, groupId });
508
+ },
509
+ [onScopeRejectSuggestionGroup],
510
+ );
511
+ const onScopeAskAgent = props.onScopeAskAgent;
512
+ const handleScopeCardAskAgent = useCallback(
513
+ (scopeId: string) => {
514
+ onScopeAskAgent?.({ scopeId });
515
+ },
516
+ [onScopeAskAgent],
517
+ );
394
518
  const zoomLevel = props.zoomLevel ?? 100;
395
- const zoomScale = typeof zoomLevel === "number" ? zoomLevel / 100 : 1;
396
- const pageZoomBucket =
397
- !isPageWorkspace ? undefined : zoomScale < 1 ? "low" : zoomScale > 1 ? "high" : "base";
519
+ // Numeric zooms resolve immediately; "pageWidth" / "onePage" need the
520
+ // page-frame dimensions to fit against — they're resolved below once
521
+ // `pageShellMetrics` has been computed (P2.c).
522
+ const numericZoomScale = typeof zoomLevel === "number" ? zoomLevel / 100 : 1;
398
523
  const chromePreset = resolveChromePreset(props.chromePreset, props.reviewMode);
399
524
  const chromeOptions = resolveChromePresetOptions(chromePreset, props.chromeOptions);
400
525
  const preserveOnlyCount = caps?.preserveOnlyCount ??
@@ -412,6 +537,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
412
537
  });
413
538
  const reviewRailAvailable = chromeVisibility.reviewRail && (caps?.reviewRailVisible ?? true);
414
539
  const [viewportWidth, setViewportWidth] = useState<number | undefined>(() => readViewportWidth());
540
+ const [viewportHeight, setViewportHeight] = useState<number | undefined>(() => readViewportHeight());
415
541
  const [reviewRailOpen, setReviewRailOpen] = useState(() =>
416
542
  getInitialReviewRailOpen({
417
543
  viewportWidth: readViewportWidth(),
@@ -461,6 +587,33 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
461
587
  }
462
588
  return props.activeSelectionTool;
463
589
  }, [props.activeSelectionTool, chromeVisibility.contextToolbars]);
590
+ const pageShellMetrics = useMemo(
591
+ () => buildPageShellMetrics(snapshot.pageLayout),
592
+ [snapshot.pageLayout],
593
+ );
594
+ // P2.c — resolve "pageWidth" / "onePage" against the active section's
595
+ // real paper dimensions. Numeric zooms pass through. Falls back to
596
+ // `numericZoomScale` (1.0 for symbolic zooms when paper dims are
597
+ // unavailable, e.g., during initial load).
598
+ const zoomScale = useMemo(() => {
599
+ if (typeof zoomLevel === "number") return numericZoomScale;
600
+ return resolveZoomMultiplier(
601
+ zoomLevel,
602
+ pageShellMetrics.frameWidthPx ?? 0,
603
+ pageShellMetrics.frameHeightPx ?? 0,
604
+ viewportWidth,
605
+ viewportHeight,
606
+ );
607
+ }, [
608
+ zoomLevel,
609
+ numericZoomScale,
610
+ pageShellMetrics.frameWidthPx,
611
+ pageShellMetrics.frameHeightPx,
612
+ viewportWidth,
613
+ viewportHeight,
614
+ ]);
615
+ const pageZoomBucket =
616
+ !isPageWorkspace ? undefined : zoomScale < 1 ? "low" : zoomScale > 1 ? "high" : "base";
464
617
  const selectionToolbarPlacement = useMemo(() => {
465
618
  // Prefer render-frame anchors when the layout facet is available — this
466
619
  // keeps the tool glued to kernel coordinates across zoom, scroll, and
@@ -496,12 +649,41 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
496
649
  renderFrameRevision,
497
650
  ]);
498
651
  const activePage = props.documentNavigation?.pages[props.documentNavigation.activePageIndex] ?? null;
499
- const pageShellMetrics = useMemo(
500
- () => buildPageShellMetrics(snapshot.pageLayout),
501
- [snapshot.pageLayout],
502
- );
503
- const headerBandLabel = resolvePageBandLabel("header", snapshot.activeStory);
504
- const footerBandLabel = resolvePageBandLabel("footer", snapshot.activeStory);
652
+ // P5b status-bar facts derived from the layout facet so the
653
+ // Page-N-of-M display + measurement-fidelity badge ("E" / "C" / "C+F")
654
+ // refresh on every layout-affecting facet event. The subscription
655
+ // above bumps `renderFrameRevision` on the same kinds; including it
656
+ // in the dependency list re-runs this memo without a separate
657
+ // subscription.
658
+ const statusBarPageFacts = useMemo(() => {
659
+ const facet = props.layoutFacet;
660
+ if (!facet) {
661
+ return {
662
+ displayPageNumber: null as number | null,
663
+ pageCount: null as number | null,
664
+ measurementFidelity: undefined as
665
+ | import("../api/public-types.ts").PublicMeasurementFidelity
666
+ | undefined,
667
+ };
668
+ }
669
+ const head = selectionPosition;
670
+ const pageRef = facet.getPageForOffset(head, snapshot.activeStory);
671
+ const displayPageNumber =
672
+ pageRef !== null && typeof pageRef.pageIndex === "number"
673
+ ? facet.getDisplayPageNumber(pageRef.pageIndex) ?? pageRef.pageIndex + 1
674
+ : null;
675
+ const pageCount = facet.getPageCount();
676
+ return {
677
+ displayPageNumber,
678
+ pageCount,
679
+ measurementFidelity: facet.getMeasurementFidelity(),
680
+ };
681
+ // eslint-disable-next-line react-hooks/exhaustive-deps
682
+ }, [props.layoutFacet, selectionPosition, snapshot.activeStory, renderFrameRevision]);
683
+ // P8.11 — `headerBandLabel` / `footerBandLabel` retired along with the
684
+ // workspace-level bands. Per-page bands in `TwPageStackChromeLayer`
685
+ // render the actual header / footer story blocks via
686
+ // `TwRegionBlockRenderer`, so a label row is no longer meaningful here.
505
687
  const hidePageBorderForActiveEditing =
506
688
  isPageWorkspace &&
507
689
  snapshot.activeStory.kind === "main" &&
@@ -568,29 +750,131 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
568
750
  }
569
751
  }, [isPageWorkspace, snapshot.activeStory.kind]);
570
752
 
753
+ // P8.11 — capture the scroll-root DOM element on mount so the chrome
754
+ // overlay's `TwPageStackChromeLayer` can measure per-page rects and
755
+ // observe DOM mutations. `scrollRootRef` is attached to the existing
756
+ // `[data-wre-scroll-root]` container; rely on a mount effect rather
757
+ // than a ref callback so render-time state stays cheap.
758
+ useEffect(() => {
759
+ if (scrollRootRef.current !== pageStackScrollRoot) {
760
+ setPageStackScrollRoot(scrollRootRef.current);
761
+ }
762
+ // A `useEffect` re-runs after every render; the comparison guard
763
+ // keeps `setPageStackScrollRoot` from firing every commit. The
764
+ // scroll-root identity only changes when the component re-mounts.
765
+ });
766
+
767
+ // P8.11 — capture the PM surface DOM element. The ProseMirror surface
768
+ // mounts inside `bodySlotRef` on its own schedule (the PM constructor
769
+ // runs inside the `TwProseMirrorSurface` child component). A
770
+ // `MutationObserver` scoped to the body slot's `childList` picks up
771
+ // the PM root on first commit; once captured, the chrome layer owns
772
+ // reparent state (including portal-slot promotion), so we skip
773
+ // further updates unless PM is actually disconnected from the
774
+ // document (e.g. session/document swap tearing PM down).
775
+ useEffect(() => {
776
+ const slot = bodySlotRef.current;
777
+ if (!slot) return undefined;
778
+
779
+ // If we already hold a live reference, the chrome layer may have
780
+ // portaled PM into a per-page band — PM has left `bodySlotRef` but
781
+ // is still connected to the document. We keep the reference until
782
+ // the node is fully disconnected.
783
+ if (pmSurfaceElement && pmSurfaceElement.isConnected) {
784
+ return undefined;
785
+ }
786
+
787
+ const readPm = (): HTMLElement | null =>
788
+ slot.querySelector<HTMLElement>(".ProseMirror");
789
+
790
+ const current = readPm();
791
+ if (current !== pmSurfaceElement) {
792
+ setPmSurfaceElement(current);
793
+ }
794
+ const runtime = slot.ownerDocument?.defaultView as
795
+ | (Window & { MutationObserver?: typeof MutationObserver })
796
+ | null;
797
+ if (!runtime?.MutationObserver) return undefined;
798
+ const observer = new runtime.MutationObserver(() => {
799
+ const next = readPm();
800
+ if (next !== null && next !== pmSurfaceElement) {
801
+ setPmSurfaceElement(next);
802
+ }
803
+ });
804
+ // `childList: true, subtree: false` — we only care when children of
805
+ // the body slot change (e.g. PM is added for the first time).
806
+ // Subtree mutations (PM's own edits) are not our concern and would
807
+ // fire on every keystroke.
808
+ observer.observe(slot, { childList: true, subtree: false });
809
+ return () => observer.disconnect();
810
+ }, [pmSurfaceElement]);
811
+
812
+ // P8.11 — deprecation shim for the legacy `onOpenHeaderStory` /
813
+ // `onOpenFooterStory` props. Per-page chrome bands route clicks via
814
+ // `onOpenStory` + `runtime.openStory` directly; the workspace-level
815
+ // bands that consumed these callbacks are gone. Kept optional for one
816
+ // release so existing hosts compile; a mount-time `console.warn` nudges
817
+ // them toward `onOpenStory`.
818
+ useEffect(() => {
819
+ if (props.onOpenHeaderStory) {
820
+ // eslint-disable-next-line no-console
821
+ console.warn(
822
+ "[docx-react-component] `onOpenHeaderStory` is deprecated. Per-page header bands route clicks via runtime.openStory directly. (P8)",
823
+ );
824
+ }
825
+ if (props.onOpenFooterStory) {
826
+ // eslint-disable-next-line no-console
827
+ console.warn(
828
+ "[docx-react-component] `onOpenFooterStory` is deprecated. Per-page footer bands route clicks via runtime.openStory directly. (P8)",
829
+ );
830
+ }
831
+ // Mount-once: we only want to nudge hosts at startup, not per render.
832
+ // eslint-disable-next-line react-hooks/exhaustive-deps
833
+ }, []);
834
+
571
835
  useEffect(() => {
572
836
  if (typeof window === "undefined") {
573
837
  return;
574
838
  }
575
839
 
576
- const updateViewportWidth = () => {
840
+ const updateViewport = () => {
577
841
  setViewportWidth(readViewportWidth());
842
+ setViewportHeight(readViewportHeight());
578
843
  };
579
844
 
580
- updateViewportWidth();
581
- window.addEventListener("resize", updateViewportWidth);
845
+ updateViewport();
846
+ window.addEventListener("resize", updateViewport);
582
847
  return () => {
583
- window.removeEventListener("resize", updateViewportWidth);
848
+ window.removeEventListener("resize", updateViewport);
584
849
  };
585
850
  }, []);
586
851
 
587
- // Subscribe to layout facet events so chrome re-projects on zoom changes
588
- // and after incremental relayout (R4).
852
+ // Subscribe to layout facet events so chrome re-projects whenever the
853
+ // engine produces new pagination state, fields dirty, or measurement
854
+ // fidelity changes. P5b broadened this beyond the original P3.b set
855
+ // ("zoom_changed" / "render_frame_ready") so the status-bar Page-N-of-M
856
+ // and fidelity badge transition in real time; the hardening commit
857
+ // added "measurement_backend_ready" so the canvas backend swap also
858
+ // refreshes the badge. P14.b adds "layout_committed" — a single
859
+ // coalesced event per applyPatch — so consumers that only care
860
+ // about "the engine just finished a build" can react once instead
861
+ // of N times.
589
862
  useEffect(() => {
590
863
  if (!props.layoutFacet) return;
591
864
  const unsub = props.layoutFacet.subscribe((event) => {
592
- if (event.kind === "zoom_changed" || event.kind === "render_frame_ready") {
593
- setRenderFrameRevision((n) => n + 1);
865
+ switch (event.kind) {
866
+ case "zoom_changed":
867
+ case "render_frame_ready":
868
+ case "layout_recomputed":
869
+ case "incremental_relayout":
870
+ case "page_count_changed":
871
+ case "page_field_dirtied":
872
+ case "measurement_backend_ready":
873
+ case "layout_committed":
874
+ setRenderFrameRevision((n) => n + 1);
875
+ break;
876
+ default:
877
+ break;
594
878
  }
595
879
  });
596
880
  return unsub;
@@ -658,6 +942,20 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
658
942
  <div className="px-3 pt-3">
659
943
  <ChromePresetToolbar
660
944
  chromePreset={chromePreset}
945
+ {...(props.collabSession ? { collabSession: props.collabSession } : {})}
946
+ {...(props.collabTransportStatus
947
+ ? { collabTransportStatus: props.collabTransportStatus }
948
+ : {})}
949
+ {...(props.activeCommentId !== undefined
950
+ ? { activeCommentId: props.activeCommentId }
951
+ : {})}
952
+ {...(props.collabActorId !== undefined
953
+ ? { collabActorId: props.collabActorId }
954
+ : {})}
955
+ {...(props.collabSendBaseline
956
+ ? { collabSendBaseline: props.collabSendBaseline }
957
+ : {})}
958
+ chromeOptionsResolved={chromeOptions}
661
959
  capabilities={caps}
662
960
  compatibility={snapshot.compatibility}
663
961
  warnings={snapshot.warnings}
@@ -916,6 +1214,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
916
1214
  {/* Document column */}
917
1215
  <div className="flex flex-1 flex-col min-w-0">
918
1216
  <div
1217
+ ref={scrollRootRef}
919
1218
  className="flex-1 overflow-y-auto bg-surface"
920
1219
  data-wre-scroll-root="true"
921
1220
  >
@@ -923,11 +1222,32 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
923
1222
  ref={selectionToolbarRootRef}
924
1223
  className={`mx-auto min-h-full w-full ${
925
1224
  isPageWorkspace
926
- ? "wre-page-chrome wre-page-surface relative max-w-[840px] my-8 overflow-hidden"
1225
+ ? "wre-page-chrome wre-page-surface relative my-8 overflow-hidden"
927
1226
  : "wre-canvas-surface relative my-8 overflow-hidden"
928
1227
  }`}
929
1228
  data-zoom-bucket={pageZoomBucket}
930
- style={isPageWorkspace && zoomScale !== 1 ? { transform: `scale(${zoomScale})`, transformOrigin: "top center" } : undefined}
1229
+ data-zoom-scale={isPageWorkspace ? zoomScale : undefined}
1230
+ style={
1231
+ isPageWorkspace
1232
+ ? {
1233
+ // P2.a — real-dim page frame: width/height from
1234
+ // `pageLayout.pageWidth/pageHeight × FRAME_PX_PER_TWIP_AT_96DPI`
1235
+ // so every paper size renders at its
1236
+ // Word-matching CSS px. `max-w-[840px]` retired.
1237
+ ...(pageShellMetrics.frameWidthPx
1238
+ ? { width: `${pageShellMetrics.frameWidthPx}px` }
1239
+ : {}),
1240
+ ...(pageShellMetrics.frameHeightPx
1241
+ ? { minHeight: `${pageShellMetrics.frameHeightPx}px` }
1242
+ : {}),
1243
+ // P2.b — browser-native CSS `zoom` rescales layout
1244
+ // so `getBoundingClientRect()` and hit-test offsets
1245
+ // stay truthful at any zoom — no inverse-projection
1246
+ // math needed downstream.
1247
+ ...(zoomScale !== 1 ? { zoom: zoomScale } : {}),
1248
+ }
1249
+ : undefined
1250
+ }
931
1251
  >
932
1252
  {isPageWorkspace && chromeVisibility.pageChrome && snapshot.pageLayout ? (
933
1253
  <div className="border-b border-border/70 bg-surface/65 px-5 py-3" data-testid="page-context-summary">
@@ -1164,25 +1484,6 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1164
1484
  }
1165
1485
  : pageChromeModel.documentGridStyle}
1166
1486
  >
1167
- {isPageWorkspace && chromeVisibility.pageChrome ? (
1168
- <div
1169
- data-testid="page-header-band"
1170
- 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]"
1171
- style={pageShellMetrics.headerBandStyle}
1172
- >
1173
- <span className="uppercase tracking-[0.12em] text-tertiary">{headerBandLabel}</span>
1174
- {snapshot.pageLayout?.headerVariants[0] ? (
1175
- <button
1176
- type="button"
1177
- aria-label="Open header story"
1178
- onClick={props.onOpenHeaderStory}
1179
- className="rounded-md px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface"
1180
- >
1181
- Edit header
1182
- </button>
1183
- ) : null}
1184
- </div>
1185
- ) : null}
1186
1487
  {isPageWorkspace && chromeVisibility.pageChrome && pageChromeModel.showPageBorder && !hidePageBorderForActiveEditing ? (
1187
1488
  <div
1188
1489
  aria-hidden="true"
@@ -1192,15 +1493,23 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1192
1493
  />
1193
1494
  ) : null}
1194
1495
  <div className={isPageWorkspace ? "relative z-10" : "relative"}>
1195
- {/* Page chrome (frame borders, header/footer bands,
1196
- page-number labels, inter-page separators) is
1197
- rendered as in-flow widget decorations inside
1198
- the PM surface itself see
1199
- `pm-page-break-decorations.ts`. That keeps the
1200
- chrome perfectly aligned with PM content without
1201
- any absolute-positioned overlay that would drift
1202
- relative to the browser's line layout. */}
1203
- {props.document}
1496
+ {/* P8.11 workspace-level header / footer bands
1497
+ retired. The PM surface now mounts inside
1498
+ `data-pm-body-slot`; per-page header, footer,
1499
+ footnote, and endnote chrome is owned by
1500
+ `TwPageStackChromeLayer` inside `TwChromeOverlay`
1501
+ (see below). When the user clicks a per-page
1502
+ band, the chrome layer reparents PM's DOM node
1503
+ into the active band's `data-pm-portal-slot`;
1504
+ when the user returns to the body, PM slides
1505
+ back into this wrapper. */}
1506
+ <div
1507
+ data-pm-body-slot=""
1508
+ ref={bodySlotRef}
1509
+ style={{ width: "100%" }}
1510
+ >
1511
+ {props.document}
1512
+ </div>
1204
1513
  {props.layoutFacet ? (
1205
1514
  <TwChromeOverlay
1206
1515
  facet={props.layoutFacet}
@@ -1208,32 +1517,39 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1208
1517
  onSetColumnWidth={props.onSetColumnWidth}
1209
1518
  onSetRowHeight={props.onSetRowHeight}
1210
1519
  activeScopeId={activeScopeId}
1520
+ editorRole={viewState.editorRole}
1521
+ scopeCardScopeTagEditor={props.scopeCardScopeTagEditor}
1211
1522
  onScopeStripeClick={handleScopeStripeClick}
1212
1523
  onScopeCardClose={handleScopeCardClose}
1213
1524
  onScopeCardModeChange={handleScopeCardModeChange}
1214
1525
  onScopeCardIssueAction={handleScopeCardIssueAction}
1526
+ onScopeCardAcceptSuggestionGroup={
1527
+ onScopeAcceptSuggestionGroup
1528
+ ? handleScopeCardAcceptSuggestionGroup
1529
+ : undefined
1530
+ }
1531
+ onScopeCardRejectSuggestionGroup={
1532
+ onScopeRejectSuggestionGroup
1533
+ ? handleScopeCardRejectSuggestionGroup
1534
+ : undefined
1535
+ }
1536
+ onScopeCardAskAgent={
1537
+ onScopeAskAgent
1538
+ ? handleScopeCardAskAgent
1539
+ : undefined
1540
+ }
1541
+ pageStackScrollRoot={
1542
+ isPageWorkspace && chromeVisibility.pageChrome
1543
+ ? pageStackScrollRoot
1544
+ : undefined
1545
+ }
1546
+ renderFrameRevision={renderFrameRevision}
1547
+ activeStory={snapshot.activeStory}
1548
+ onOpenStory={props.onOpenStory}
1549
+ pmSurfaceElement={pmSurfaceElement}
1215
1550
  />
1216
1551
  ) : null}
1217
1552
  </div>
1218
- {isPageWorkspace && chromeVisibility.pageChrome ? (
1219
- <div
1220
- data-testid="page-footer-band"
1221
- 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]"
1222
- style={pageShellMetrics.footerBandStyle}
1223
- >
1224
- <span className="uppercase tracking-[0.12em] text-tertiary">{footerBandLabel}</span>
1225
- {snapshot.pageLayout?.footerVariants[0] ? (
1226
- <button
1227
- type="button"
1228
- aria-label="Open footer story"
1229
- onClick={props.onOpenFooterStory}
1230
- className="rounded-md px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-surface"
1231
- >
1232
- Edit footer
1233
- </button>
1234
- ) : null}
1235
- </div>
1236
- ) : null}
1237
1553
  </div>
1238
1554
  </div>
1239
1555
  </div>
@@ -1257,6 +1573,9 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1257
1573
  ? props.documentContextAnalytics
1258
1574
  : null
1259
1575
  }
1576
+ displayPageNumber={statusBarPageFacts.displayPageNumber}
1577
+ pageCount={statusBarPageFacts.pageCount}
1578
+ measurementFidelity={statusBarPageFacts.measurementFidelity}
1260
1579
  />
1261
1580
  ) : null}
1262
1581
  </div>
@@ -1364,6 +1683,10 @@ function readViewportWidth(): number | undefined {
1364
1683
  return typeof window === "undefined" ? undefined : window.innerWidth;
1365
1684
  }
1366
1685
 
1686
+ function readViewportHeight(): number | undefined {
1687
+ return typeof window === "undefined" ? undefined : window.innerHeight;
1688
+ }
1689
+
1367
1690
  function shouldHidePageBorderForSelection(
1368
1691
  selection: EditorViewStateSnapshot["selection"],
1369
1692
  ): boolean {
@@ -1496,11 +1819,22 @@ const EMPTY_PAGE_CHROME_MODEL: PageChromeModel = {
1496
1819
 
1497
1820
  const DOCUMENT_CONTENT_TOP_PADDING_PX = 40;
1498
1821
 
1499
- interface PageShellMetrics {
1822
+ // P2.a — real-dimension page frame. Page frame width/height are
1823
+ // `pageLayout.pageWidth/pageHeight × FRAME_PX_PER_TWIP_AT_96DPI` so
1824
+ // every paper size renders at its Word-matching CSS px (Letter
1825
+ // 816×1056, A4 794×1123, Legal 816×1344, …). Constants are exported
1826
+ // so tests + harness panels can derive the same values.
1827
+ export const FRAME_PX_PER_TWIP_AT_96DPI = 96 / 1440;
1828
+ /** Floor on header/footer band heights so empty bands stay clickable. */
1829
+ export const MIN_BAND_HEIGHT_PX = 20;
1830
+
1831
+ export interface PageShellMetrics {
1832
+ /** P2.a — page frame CSS px width = `pageWidth × FRAME_PX_PER_TWIP_AT_96DPI`. */
1833
+ frameWidthPx?: number;
1834
+ /** P2.a — page frame CSS px height = `pageHeight × FRAME_PX_PER_TWIP_AT_96DPI`. */
1835
+ frameHeightPx?: number;
1500
1836
  contentInsetStyle: CSSProperties;
1501
1837
  pageFrameStyle: CSSProperties;
1502
- headerBandStyle: CSSProperties;
1503
- footerBandStyle: CSSProperties;
1504
1838
  }
1505
1839
 
1506
1840
  function buildPageChromeModel(
@@ -1539,41 +1873,36 @@ function buildPageChromeModel(
1539
1873
  };
1540
1874
  }
1541
1875
 
1542
- function buildPageShellMetrics(
1876
+ export function buildPageShellMetrics(
1543
1877
  pageLayout: RuntimeRenderSnapshot["pageLayout"] | undefined,
1544
1878
  ): PageShellMetrics {
1545
1879
  if (!pageLayout) {
1546
1880
  return {
1547
1881
  contentInsetStyle: {},
1548
1882
  pageFrameStyle: {},
1549
- headerBandStyle: {},
1550
- footerBandStyle: {},
1883
+ frameWidthPx: 0,
1884
+ frameHeightPx: 0,
1551
1885
  };
1552
1886
  }
1553
1887
 
1554
- const horizontalInsetPx = Math.max(
1555
- 24,
1556
- Math.min(120, Math.round(pageLayout.marginLeft * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP)),
1557
- );
1558
- const verticalInsetPx = Math.max(
1559
- 24,
1560
- Math.min(140, Math.round(pageLayout.marginTop * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP)),
1561
- );
1562
- const headerBandHeightPx = Math.max(
1563
- 40,
1564
- Math.min(96, Math.round(pageLayout.headerMargin * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP + 16)),
1565
- );
1566
- const footerBandHeightPx = Math.max(
1567
- 40,
1568
- Math.min(96, Math.round(pageLayout.footerMargin * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP + 16)),
1569
- );
1888
+ // P2.a frame dimensions follow the section's real twip width/height
1889
+ // so every paper size in the catalog renders at its Word-matching CSS
1890
+ // px (Letter 816×1056, A4 794×1123, Legal 816×1344, …).
1891
+ const pxPerTwip = FRAME_PX_PER_TWIP_AT_96DPI;
1892
+ const frameWidthPx = Math.round(pageLayout.pageWidth * pxPerTwip);
1893
+ const frameHeightPx = Math.round(pageLayout.pageHeight * pxPerTwip);
1894
+ const horizontalInsetPx = Math.round(pageLayout.marginLeft * pxPerTwip);
1895
+ const horizontalInsetRightPx = Math.round(pageLayout.marginRight * pxPerTwip);
1896
+
1897
+ // P8.11 — `headerBandStyle` / `footerBandStyle` removed. The
1898
+ // workspace-level band divs that consumed them are gone; per-page
1899
+ // bands (rendered by `TwPageStackChromeLayer`) compute their own
1900
+ // heights from the runtime's `PageRegionsSnapshot`.
1570
1901
 
1571
1902
  return {
1572
1903
  contentInsetStyle: {
1573
1904
  paddingLeft: `${horizontalInsetPx}px`,
1574
- paddingRight: `${horizontalInsetPx}px`,
1575
- paddingTop: `${Math.max(20, verticalInsetPx - 12)}px`,
1576
- paddingBottom: `${Math.max(20, Math.round(pageLayout.marginBottom * DEFAULT_PAGE_ESTIMATE_PX_PER_TWIP) - 12)}px`,
1905
+ paddingRight: `${horizontalInsetRightPx}px`,
1577
1906
  },
1578
1907
  pageFrameStyle: {
1579
1908
  backgroundColor: "var(--color-page-bg)",
@@ -1581,37 +1910,45 @@ function buildPageShellMetrics(
1581
1910
  boxShadow: "0 24px 48px -32px rgba(15, 23, 42, 0.38), 0 8px 20px -18px rgba(15, 23, 42, 0.22)",
1582
1911
  border: "1px solid rgba(148, 163, 184, 0.2)",
1583
1912
  },
1584
- headerBandStyle: {
1585
- minHeight: `${headerBandHeightPx}px`,
1586
- },
1587
- footerBandStyle: {
1588
- minHeight: `${footerBandHeightPx}px`,
1589
- },
1913
+ frameWidthPx,
1914
+ frameHeightPx,
1590
1915
  };
1591
1916
  }
1592
1917
 
1593
- function resolvePageBandLabel(
1594
- region: "header" | "footer",
1595
- activeStory: RuntimeRenderSnapshot["activeStory"],
1596
- ): string {
1597
- const regionLabel = region === "header" ? "header" : "footer";
1598
- let label: string;
1599
- if (activeStory.kind !== region) {
1600
- label = region === "header" ? "Header" : "Footer";
1601
- } else {
1602
- switch (activeStory.variant) {
1603
- case "first":
1604
- label = `First page ${regionLabel}`;
1605
- break;
1606
- case "even":
1607
- label = `Even page ${regionLabel}`;
1608
- break;
1609
- default:
1610
- label = `Default ${regionLabel}`;
1611
- break;
1612
- }
1918
+ // P2.c — fit-to-width / fit-to-page resolves against the active section's
1919
+ // real paper size (not a global constant), so Letter and A4 produce the
1920
+ // expected 1.029:1 fit-width ratio at the same viewport. Clamped so
1921
+ // extreme viewports don't pin the editor at unreadable zooms.
1922
+ const FIT_WIDTH_CHROME_RESERVATION_PX = 96;
1923
+ const FIT_HEIGHT_CHROME_RESERVATION_PX = 180;
1924
+ const MIN_FIT_ZOOM = 0.5;
1925
+ const MAX_FIT_ZOOM = 2.0;
1926
+
1927
+ export function resolveZoomMultiplier(
1928
+ zoomLevel: number | "pageWidth" | "onePage",
1929
+ frameWidthPx: number,
1930
+ frameHeightPx: number,
1931
+ viewportWidth: number | undefined,
1932
+ viewportHeight: number | undefined,
1933
+ ): number {
1934
+ if (typeof zoomLevel === "number") {
1935
+ return zoomLevel / 100;
1613
1936
  }
1614
- return label;
1937
+ if (!viewportWidth || frameWidthPx <= 0) return 1;
1938
+ const widthFit =
1939
+ (viewportWidth - FIT_WIDTH_CHROME_RESERVATION_PX) / frameWidthPx;
1940
+ if (zoomLevel === "pageWidth") {
1941
+ return Math.max(MIN_FIT_ZOOM, Math.min(MAX_FIT_ZOOM, widthFit));
1942
+ }
1943
+ if (!viewportHeight || frameHeightPx <= 0) {
1944
+ return Math.max(MIN_FIT_ZOOM, Math.min(MAX_FIT_ZOOM, widthFit));
1945
+ }
1946
+ const heightFit =
1947
+ (viewportHeight - FIT_HEIGHT_CHROME_RESERVATION_PX) / frameHeightPx;
1948
+ return Math.max(
1949
+ MIN_FIT_ZOOM,
1950
+ Math.min(MAX_FIT_ZOOM, Math.min(widthFit, heightFit)),
1951
+ );
1615
1952
  }
1616
1953
 
1617
1954
  function buildLineNumberMarkers(