@@ -5,6 +5,7 @@ import { DecorationBridge } from './dom/DecorationBridge.js';
55import { SdtSelectionStyleManager } from './selection/SdtSelectionStyleManager.js' ;
66import { SemanticFlowController } from './layout/SemanticFlowController.js' ;
77import { LayoutErrorBanner } from './ui/LayoutErrorBanner.js' ;
8+ import { applyViewportZoom } from './layout/applyViewportZoom.js' ;
89import type { EditorState , Transaction } from 'prosemirror-state' ;
910import type { Node as ProseMirrorNode , Mark } from 'prosemirror-model' ;
1011import 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 /**
0 commit comments