Skip to content

Commit d8b74c9

Browse files
committed
refactor(editor): extract zoom geometry into applyViewportZoom
Move the 136-line #applyZoom method's CSS transform math into a pure function. Handles semantic flow, horizontal, and vertical layout modes with per-page dimension calculation and margin compensation. PresentationEditor's #applyZoom is now a 15-line delegation that assembles the parameters from its internal state. Reduces PresentationEditor from 5379 to 5260 lines (-119).
1 parent 9040cf0 commit d8b74c9

2 files changed

Lines changed: 139 additions & 135 deletions

File tree

packages/super-editor/src/core/presentation-editor/PresentationEditor.ts

Lines changed: 17 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { DecorationBridge } from './dom/DecorationBridge.js';
55
import { SdtSelectionStyleManager } from './selection/SdtSelectionStyleManager.js';
66
import { SemanticFlowController } from './layout/SemanticFlowController.js';
77
import { LayoutErrorBanner } from './ui/LayoutErrorBanner.js';
8+
import { applyViewportZoom } from './layout/applyViewportZoom.js';
89
import type { EditorState, Transaction } from 'prosemirror-state';
910
import type { Node as ProseMirrorNode, Mark } from 'prosemirror-model';
1011
import type { Mapping } from 'prosemirror-transform';
@@ -4821,141 +4822,22 @@ export class PresentationEditor extends EventEmitter {
48214822
* - Horizontal: Uses totalWidth for viewport width, maxHeight for scroll height
48224823
*/
48234824
#applyZoom() {
4824-
if (this.#isSemanticFlowMode()) {
4825-
// Semantic mode: fill the container with fluid widths, no zoom scaling.
4826-
this.#viewportHost.style.width = '100%';
4827-
this.#viewportHost.style.minWidth = '';
4828-
this.#viewportHost.style.minHeight = '';
4829-
this.#viewportHost.style.transform = '';
4830-
4831-
this.#painterHost.style.width = '100%';
4832-
this.#painterHost.style.minHeight = '';
4833-
this.#painterHost.style.transformOrigin = '';
4834-
this.#painterHost.style.transform = '';
4835-
4836-
this.#selectionOverlay.style.width = '100%';
4837-
this.#selectionOverlay.style.height = '100%';
4838-
this.#selectionOverlay.style.transformOrigin = '';
4839-
this.#selectionOverlay.style.transform = '';
4840-
return;
4841-
}
4842-
4843-
// Apply zoom by scaling the children (#painterHost and #selectionOverlay) and
4844-
// setting the viewport dimensions to the scaled size.
4845-
//
4846-
// CSS transform: scale() only affects visual rendering, NOT layout box dimensions.
4847-
// Previously, transform was applied to #viewportHost which caused the parent scroll
4848-
// container to not see the scaled size, resulting in clipping at high zoom levels.
4849-
//
4850-
// The new approach:
4851-
// 1. Apply transform: scale(zoom) to #painterHost and #selectionOverlay (visual scaling)
4852-
// 2. Set #viewportHost width/height to scaled dimensions (layout box scaling)
4853-
// This ensures both visual rendering AND scroll container dimensions are correct.
4854-
const zoom = this.#layoutOptions.zoom ?? 1;
4855-
4856-
const layoutMode = this.#layoutOptions.layoutMode ?? 'vertical';
4857-
4858-
// Calculate actual document dimensions from per-page sizes.
4859-
// Multi-section documents can have pages with different sizes (e.g., landscape pages).
4860-
const pages = this.#layoutState.layout?.pages;
4861-
// Always use current layout mode's gap - layout.pageGap may be stale if layoutMode changed
4862-
const pageGap = this.#getEffectivePageGap();
4863-
const defaultWidth = this.#layoutOptions.pageSize?.w ?? DEFAULT_PAGE_SIZE.w;
4864-
const defaultHeight = this.#layoutOptions.pageSize?.h ?? DEFAULT_PAGE_SIZE.h;
4865-
4866-
let maxWidth = defaultWidth;
4867-
let maxHeight = defaultHeight;
4868-
let totalWidth = 0;
4869-
let totalHeight = 0;
4870-
4871-
if (Array.isArray(pages) && pages.length > 0) {
4872-
pages.forEach((page, index) => {
4873-
const pageWidth = page.size && typeof page.size.w === 'number' && page.size.w > 0 ? page.size.w : defaultWidth;
4874-
const pageHeight =
4875-
page.size && typeof page.size.h === 'number' && page.size.h > 0 ? page.size.h : defaultHeight;
4876-
maxWidth = Math.max(maxWidth, pageWidth);
4877-
maxHeight = Math.max(maxHeight, pageHeight);
4878-
totalWidth += pageWidth;
4879-
totalHeight += pageHeight;
4880-
if (index < pages.length - 1) {
4881-
totalWidth += pageGap;
4882-
totalHeight += pageGap;
4883-
}
4884-
});
4885-
} else {
4886-
totalWidth = defaultWidth;
4887-
totalHeight = defaultHeight;
4888-
}
4889-
4890-
// Horizontal layout stacks pages in a single row, so width grows with pageCount
4891-
if (layoutMode === 'horizontal') {
4892-
// For horizontal: sum widths, use max height
4893-
const scaledWidth = totalWidth * zoom;
4894-
const scaledHeight = maxHeight * zoom;
4895-
4896-
this.#viewportHost.style.width = `${scaledWidth}px`;
4897-
this.#viewportHost.style.minWidth = `${scaledWidth}px`;
4898-
this.#viewportHost.style.minHeight = `${scaledHeight}px`;
4899-
this.#viewportHost.style.height = '';
4900-
this.#viewportHost.style.overflow = '';
4901-
this.#viewportHost.style.transform = '';
4902-
4903-
this.#painterHost.style.width = `${totalWidth}px`;
4904-
this.#painterHost.style.minHeight = `${maxHeight}px`;
4905-
// Negative margin compensates for the CSS box overflow from transform: scale().
4906-
// At zoom < 1 the unscaled CSS box is larger than the visual; this pulls the
4907-
// bottom edge up to match, without clipping overlays (e.g., cursor labels).
4908-
this.#painterHost.style.marginBottom = zoom !== 1 ? `${maxHeight * zoom - maxHeight}px` : '';
4909-
this.#painterHost.style.transformOrigin = 'top left';
4910-
this.#painterHost.style.transform = zoom === 1 ? '' : `scale(${zoom})`;
4911-
4912-
this.#selectionOverlay.style.width = `${totalWidth}px`;
4913-
this.#selectionOverlay.style.height = `${maxHeight}px`;
4914-
this.#selectionOverlay.style.transformOrigin = 'top left';
4915-
this.#selectionOverlay.style.transform = zoom === 1 ? '' : `scale(${zoom})`;
4916-
return;
4917-
}
4918-
4919-
// Vertical layout: use max width, sum heights
4920-
// Zoom implementation:
4921-
// 1. #viewportHost has SCALED dimensions (maxWidth * zoom) for proper scroll container sizing
4922-
// 2. #painterHost has UNSCALED dimensions with transform: scale(zoom) applied
4923-
// 3. When scaled, #painterHost visually fills #viewportHost exactly
4924-
//
4925-
// This ensures the scroll container sees the correct scaled content size while
4926-
// the transform provides visual scaling.
4927-
//
4928-
// CSS transform: scale() does NOT change the element's CSS box dimensions.
4929-
// At zoom < 1, painterHost's CSS box stays at the full unscaled height while its
4930-
// visual size is smaller. A negative margin-bottom on painterHost compensates for
4931-
// the difference, so the scroll container sees the correct scaled size without
4932-
// clipping overlays (e.g., collaboration cursor labels that extend above their caret).
4933-
const scaledWidth = maxWidth * zoom;
4934-
const scaledHeight = totalHeight * zoom;
4935-
4936-
this.#viewportHost.style.width = `${scaledWidth}px`;
4937-
this.#viewportHost.style.minWidth = `${scaledWidth}px`;
4938-
this.#viewportHost.style.minHeight = `${scaledHeight}px`;
4939-
this.#viewportHost.style.height = '';
4940-
this.#viewportHost.style.overflow = '';
4941-
this.#viewportHost.style.transform = '';
4942-
4943-
// Set painterHost to UNSCALED dimensions and apply transform.
4944-
// Negative margin compensates for the CSS box overflow from transform: scale().
4945-
// At zoom < 1: totalHeight=74304 with scale(0.75) → visual 55728px but CSS box stays 74304px.
4946-
// marginBottom = totalHeight * zoom - totalHeight = 74304 * 0.75 - 74304 = -18576px
4947-
// This shrinks the layout contribution to match the visual size.
4948-
this.#painterHost.style.width = `${maxWidth}px`;
4949-
this.#painterHost.style.minHeight = `${totalHeight}px`;
4950-
this.#painterHost.style.marginBottom = zoom !== 1 ? `${totalHeight * zoom - totalHeight}px` : '';
4951-
this.#painterHost.style.transformOrigin = 'top left';
4952-
this.#painterHost.style.transform = zoom === 1 ? '' : `scale(${zoom})`;
4953-
4954-
// Selection overlay also scales - set to unscaled dimensions
4955-
this.#selectionOverlay.style.width = `${maxWidth}px`;
4956-
this.#selectionOverlay.style.height = `${totalHeight}px`;
4957-
this.#selectionOverlay.style.transformOrigin = 'top left';
4958-
this.#selectionOverlay.style.transform = zoom === 1 ? '' : `scale(${zoom})`;
4825+
applyViewportZoom(
4826+
{
4827+
viewportHost: this.#viewportHost,
4828+
painterHost: this.#painterHost,
4829+
selectionOverlay: this.#selectionOverlay,
4830+
},
4831+
{
4832+
zoom: this.#layoutOptions.zoom ?? 1,
4833+
layoutMode: (this.#layoutOptions.layoutMode ?? 'vertical') as 'vertical' | 'horizontal' | 'book',
4834+
isSemanticFlow: this.#isSemanticFlowMode(),
4835+
pages: this.#layoutState.layout?.pages,
4836+
pageGap: this.#getEffectivePageGap(),
4837+
defaultWidth: this.#layoutOptions.pageSize?.w ?? DEFAULT_PAGE_SIZE.w,
4838+
defaultHeight: this.#layoutOptions.pageSize?.h ?? DEFAULT_PAGE_SIZE.h,
4839+
},
4840+
);
49594841
}
49604842

49614843
/**
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
interface Page {
2+
size?: { w?: number; h?: number };
3+
}
4+
5+
interface ViewportElements {
6+
viewportHost: HTMLElement;
7+
painterHost: HTMLElement;
8+
selectionOverlay: HTMLElement;
9+
}
10+
11+
interface ZoomParams {
12+
zoom: number;
13+
layoutMode: 'vertical' | 'horizontal' | 'book';
14+
isSemanticFlow: boolean;
15+
pages: Page[] | undefined;
16+
pageGap: number;
17+
defaultWidth: number;
18+
defaultHeight: number;
19+
}
20+
21+
/**
22+
* Applies CSS transform zoom to viewport elements.
23+
*
24+
* Computes document dimensions from per-page sizes and applies:
25+
* 1. Scaled dimensions on viewportHost (for scroll container sizing)
26+
* 2. Unscaled dimensions + transform:scale on painterHost and selectionOverlay
27+
*
28+
* This ensures both visual rendering AND scroll container dimensions are correct.
29+
* CSS transform:scale() only affects visual rendering, not layout box dimensions,
30+
* so negative marginBottom compensates for the difference at zoom < 1.
31+
*/
32+
export function applyViewportZoom(elements: ViewportElements, params: ZoomParams): void {
33+
const { viewportHost, painterHost, selectionOverlay } = elements;
34+
const { zoom, layoutMode, isSemanticFlow, pages, pageGap, defaultWidth, defaultHeight } = params;
35+
36+
if (isSemanticFlow) {
37+
viewportHost.style.width = '100%';
38+
viewportHost.style.minWidth = '';
39+
viewportHost.style.minHeight = '';
40+
viewportHost.style.transform = '';
41+
42+
painterHost.style.width = '100%';
43+
painterHost.style.minHeight = '';
44+
painterHost.style.transformOrigin = '';
45+
painterHost.style.transform = '';
46+
47+
selectionOverlay.style.width = '100%';
48+
selectionOverlay.style.height = '100%';
49+
selectionOverlay.style.transformOrigin = '';
50+
selectionOverlay.style.transform = '';
51+
return;
52+
}
53+
54+
let maxWidth = defaultWidth;
55+
let maxHeight = defaultHeight;
56+
let totalWidth = 0;
57+
let totalHeight = 0;
58+
59+
if (Array.isArray(pages) && pages.length > 0) {
60+
pages.forEach((page, index) => {
61+
const pageWidth = page.size && typeof page.size.w === 'number' && page.size.w > 0 ? page.size.w : defaultWidth;
62+
const pageHeight = page.size && typeof page.size.h === 'number' && page.size.h > 0 ? page.size.h : defaultHeight;
63+
maxWidth = Math.max(maxWidth, pageWidth);
64+
maxHeight = Math.max(maxHeight, pageHeight);
65+
totalWidth += pageWidth;
66+
totalHeight += pageHeight;
67+
if (index < pages.length - 1) {
68+
totalWidth += pageGap;
69+
totalHeight += pageGap;
70+
}
71+
});
72+
} else {
73+
totalWidth = defaultWidth;
74+
totalHeight = defaultHeight;
75+
}
76+
77+
if (layoutMode === 'horizontal') {
78+
const scaledWidth = totalWidth * zoom;
79+
const scaledHeight = maxHeight * zoom;
80+
81+
viewportHost.style.width = `${scaledWidth}px`;
82+
viewportHost.style.minWidth = `${scaledWidth}px`;
83+
viewportHost.style.minHeight = `${scaledHeight}px`;
84+
viewportHost.style.height = '';
85+
viewportHost.style.overflow = '';
86+
viewportHost.style.transform = '';
87+
88+
painterHost.style.width = `${totalWidth}px`;
89+
painterHost.style.minHeight = `${maxHeight}px`;
90+
painterHost.style.marginBottom = zoom !== 1 ? `${maxHeight * zoom - maxHeight}px` : '';
91+
painterHost.style.transformOrigin = 'top left';
92+
painterHost.style.transform = zoom === 1 ? '' : `scale(${zoom})`;
93+
94+
selectionOverlay.style.width = `${totalWidth}px`;
95+
selectionOverlay.style.height = `${maxHeight}px`;
96+
selectionOverlay.style.transformOrigin = 'top left';
97+
selectionOverlay.style.transform = zoom === 1 ? '' : `scale(${zoom})`;
98+
return;
99+
}
100+
101+
// Vertical layout
102+
const scaledWidth = maxWidth * zoom;
103+
const scaledHeight = totalHeight * zoom;
104+
105+
viewportHost.style.width = `${scaledWidth}px`;
106+
viewportHost.style.minWidth = `${scaledWidth}px`;
107+
viewportHost.style.minHeight = `${scaledHeight}px`;
108+
viewportHost.style.height = '';
109+
viewportHost.style.overflow = '';
110+
viewportHost.style.transform = '';
111+
112+
painterHost.style.width = `${maxWidth}px`;
113+
painterHost.style.minHeight = `${totalHeight}px`;
114+
painterHost.style.marginBottom = zoom !== 1 ? `${totalHeight * zoom - totalHeight}px` : '';
115+
painterHost.style.transformOrigin = 'top left';
116+
painterHost.style.transform = zoom === 1 ? '' : `scale(${zoom})`;
117+
118+
selectionOverlay.style.width = `${maxWidth}px`;
119+
selectionOverlay.style.height = `${totalHeight}px`;
120+
selectionOverlay.style.transformOrigin = 'top left';
121+
selectionOverlay.style.transform = zoom === 1 ? '' : `scale(${zoom})`;
122+
}

0 commit comments

Comments
 (0)