@@ -35,19 +35,15 @@ export function setupPinchZoom({
3535
3636 const viewportScope = viewportProvides . forDocument ( documentId ) ;
3737 const zoomScope = zoomProvides . forDocument ( documentId ) ;
38-
3938 const getState = ( ) => zoomScope . getState ( ) ;
4039
41- // Shared state for gestures
40+ // Shared state
4241 let initialZoom = 0 ;
43- let lastCenter = { x : 0 , y : 0 } ;
4442 let currentScale = 1 ;
45-
46- // Pinch-specific state
4743 let isPinching = false ;
4844 let initialDistance = 0 ;
4945
50- // Wheel zoom state
46+ // Wheel state
5147 let wheelZoomTimeout : ReturnType < typeof setTimeout > | null = null ;
5248 let accumulatedWheelScale = 1 ;
5349
@@ -56,15 +52,21 @@ export function setupPinchZoom({
5652 let initialElementHeight = 0 ;
5753 let initialElementLeft = 0 ;
5854 let initialElementTop = 0 ;
59- let containerWidth = 0 ;
60- let containerHeight = 0 ;
55+
56+ // Container Dimensions (Bounding Box)
57+ let containerRectWidth = 0 ;
58+ let containerRectHeight = 0 ;
59+
60+ // Layout Dimensions (Client Box from Metrics)
61+ // This is the actual space the CSS uses for centering.
62+ let layoutWidth = 0 ;
63+ let layoutCenterX = 0 ; // Relative to the container Rect origin
64+
6165 let pointerLocalY = 0 ;
6266 let pointerContainerX = 0 ;
6367 let pointerContainerY = 0 ;
6468
65- // NEW: Simple number for the gap
6669 let currentGap = 0 ;
67-
6870 let pivotLocalX = 0 ;
6971
7072 const clamp = ( val : number , min : number , max : number ) => Math . min ( Math . max ( val , min ) , max ) ;
@@ -73,54 +75,49 @@ export function setupPinchZoom({
7375 const finalWidth = initialElementWidth * scale ;
7476 const finalHeight = initialElementHeight * scale ;
7577
76- // --- 1. Unconstrained Transforms ---
7778 let ty = pointerLocalY * ( 1 - scale ) ;
7879
79- const txCenter = ( containerWidth - finalWidth ) / 2 - initialElementLeft ;
80+ // --- 1. Center-based Transform (The "Structural" Center) ---
81+ // Instead of using containerRectWidth, we use the layoutCenterX derived from Metrics.
82+ // layoutCenterX is the specific pixel where the content center should align.
83+
84+ // Target X position relative to Container Rect Left:
85+ const targetX = layoutCenterX - finalWidth / 2 ;
86+
87+ // Convert to translation (tx) relative to initial position:
88+ const txCenter = targetX - initialElementLeft ;
89+
90+ // --- 2. Mouse-based Transform ---
8091 const txMouse = pointerContainerX - pivotLocalX * scale - initialElementLeft ;
8192
82- const overflow = Math . max ( 0 , finalWidth - containerWidth ) ;
83- const blendRange = containerWidth * 0.3 ;
93+ // --- 3. Blending ---
94+ // Compare finalWidth against layoutWidth (actual available space).
95+ const overflow = Math . max ( 0 , finalWidth - layoutWidth ) ;
96+ const blendRange = layoutWidth * 0.3 ;
8497 const blend = Math . min ( 1 , overflow / blendRange ) ;
8598
8699 let tx = txCenter + ( txMouse - txCenter ) * blend ;
87100
88- // --- 2. Gap-Aware Clamping ---
89- // If the content is larger than the "Safe Area" (Container - 2 * Gap),
90- // we must clamp it so it doesn't detach from the edges.
91-
92- // Vertical Clamp
93- // Safe height is container minus top gap AND bottom gap
94- const safeHeight = containerHeight - currentGap * 2 ;
95-
101+ // --- 4. Gap-Aware Clamping ---
102+ const safeHeight = containerRectHeight - currentGap * 2 ;
96103 if ( finalHeight > safeHeight ) {
97104 const currentTop = initialElementTop + ty ;
98-
99- // The content top cannot be lower than the gap (toolbar)
100105 const maxTop = currentGap ;
101-
102- // The content bottom cannot be higher than the container bottom (minus gap)
103- // So: Top position cannot be less than (ContainerBottom - ContentHeight)
104- const minTop = containerHeight - currentGap - finalHeight ;
105-
106+ const minTop = containerRectHeight - currentGap - finalHeight ;
106107 const constrainedTop = clamp ( currentTop , minTop , maxTop ) ;
107108 ty = constrainedTop - initialElementTop ;
108109 }
109110
110- // Horizontal Clamp
111- const safeWidth = containerWidth - currentGap * 2 ;
112-
111+ const safeWidth = containerRectWidth - currentGap * 2 ;
113112 if ( finalWidth > safeWidth ) {
114113 const currentLeft = initialElementLeft + tx ;
115-
116114 const maxLeft = currentGap ;
117- const minLeft = containerWidth - currentGap - finalWidth ;
118-
115+ const minLeft = containerRectWidth - currentGap - finalWidth ;
119116 const constrainedLeft = clamp ( currentLeft , minLeft , maxLeft ) ;
120117 tx = constrainedLeft - initialElementLeft ;
121118 }
122119
123- return { tx, ty, blend } ;
120+ return { tx, ty, blend, finalWidth } ;
124121 } ;
125122
126123 const updateTransform = ( scale : number ) => {
@@ -137,19 +134,25 @@ export function setupPinchZoom({
137134 } ;
138135
139136 const commitZoom = ( ) => {
140- const { tx, ty } = calculateTransform ( currentScale ) ;
137+ const { tx, ty, finalWidth } = calculateTransform ( currentScale ) ;
138+ const delta = ( currentScale - 1 ) * initialZoom ;
141139
142- const scaleDiff = 1 - currentScale ;
143- const anchorX =
144- Math . abs ( scaleDiff ) > 0.001 ? initialElementLeft + tx / scaleDiff : containerWidth / 2 ;
140+ let anchorX : number ;
141+ let anchorY : number = pointerContainerY ;
145142
146- lastCenter = {
147- x : anchorX ,
148- y : pointerContainerY ,
149- } ;
143+ // --- CRITICAL FIX ---
144+ // If the content fits within the LAYOUT width (not just rect width),
145+ // we force the anchor to be the Layout Center.
146+ if ( finalWidth <= layoutWidth ) {
147+ // anchorX is relative to the Container Rect Origin (which zoomScope uses)
148+ anchorX = layoutCenterX ;
149+ } else {
150+ const scaleDiff = 1 - currentScale ;
151+ anchorX =
152+ Math . abs ( scaleDiff ) > 0.001 ? initialElementLeft + tx / scaleDiff : pointerContainerX ;
153+ }
150154
151- const delta = ( currentScale - 1 ) * initialZoom ;
152- zoomScope . requestZoomBy ( delta , { vx : lastCenter . x , vy : lastCenter . y } ) ;
155+ zoomScope . requestZoomBy ( delta , { vx : anchorX , vy : anchorY } ) ;
153156 resetTransform ( ) ;
154157 initialZoom = 0 ;
155158 } ;
@@ -158,23 +161,35 @@ export function setupPinchZoom({
158161 const contRect = viewportScope . getBoundingRect ( ) ;
159162 const innerRect = element . getBoundingClientRect ( ) ;
160163
161- // NEW: Fetch the simple gap number
162- currentGap = viewportProvides . getViewportGap ( ) || 0 ;
164+ // FETCH METRICS (Single Source of Truth)
165+ const metrics = viewportScope . getMetrics ( ) ;
163166
167+ currentGap = viewportProvides . getViewportGap ( ) || 0 ;
164168 initialElementWidth = innerRect . width ;
165169 initialElementHeight = innerRect . height ;
166170 initialElementLeft = innerRect . left - contRect . origin . x ;
167171 initialElementTop = innerRect . top - contRect . origin . y ;
168- containerWidth = contRect . size . width ;
169- containerHeight = contRect . size . height ;
172+
173+ containerRectWidth = contRect . size . width ;
174+ containerRectHeight = contRect . size . height ;
175+
176+ // --- CLEAN LAYOUT CALCULATION ---
177+ // We use the viewport metrics to determine the layout geometry.
178+ // clientWidth: The width available for content (excludes scrollbars/borders)
179+ // clientLeft: The width of the left border (offset from Rect origin to Content origin)
180+ const clientLeft = metrics . clientLeft ;
181+
182+ layoutWidth = metrics . clientWidth ;
183+ layoutCenterX = clientLeft + layoutWidth / 2 ;
170184
171185 const rawPointerLocalX = clientX - innerRect . left ;
172186 pointerLocalY = clientY - innerRect . top ;
173187 pointerContainerX = clientX - contRect . origin . x ;
174188 pointerContainerY = clientY - contRect . origin . y ;
175189
176- if ( initialElementWidth < containerWidth ) {
177- pivotLocalX = ( pointerContainerX * initialElementWidth ) / containerWidth ;
190+ // Pivot Calculation based on Layout Width
191+ if ( initialElementWidth < layoutWidth ) {
192+ pivotLocalX = ( pointerContainerX * initialElementWidth ) / layoutWidth ;
178193 } else {
179194 pivotLocalX = rawPointerLocalX ;
180195 }
@@ -183,38 +198,31 @@ export function setupPinchZoom({
183198 // --- Handlers ---
184199 const handleTouchStart = ( e : TouchEvent ) => {
185200 if ( e . touches . length !== 2 ) return ;
186-
187201 isPinching = true ;
188202 initialZoom = getState ( ) . currentZoomLevel ;
189203 initialDistance = getTouchDistance ( e . touches ) ;
190-
191204 const center = getTouchCenter ( e . touches ) ;
192205 initializeGestureState ( center . x , center . y ) ;
193-
194206 e . preventDefault ( ) ;
195207 } ;
196208
197209 const handleTouchMove = ( e : TouchEvent ) => {
198210 if ( ! isPinching || e . touches . length !== 2 ) return ;
199-
200211 const currentDistance = getTouchDistance ( e . touches ) ;
201212 const scale = currentDistance / initialDistance ;
202-
203213 updateTransform ( scale ) ;
204214 e . preventDefault ( ) ;
205215 } ;
206216
207217 const handleTouchEnd = ( e : TouchEvent ) => {
208218 if ( ! isPinching ) return ;
209219 if ( e . touches . length >= 2 ) return ;
210-
211220 isPinching = false ;
212221 commitZoom ( ) ;
213222 } ;
214223
215224 const handleWheel = ( e : WheelEvent ) => {
216225 if ( ! e . ctrlKey && ! e . metaKey ) return ;
217-
218226 e . preventDefault ( ) ;
219227
220228 if ( wheelZoomTimeout === null ) {
@@ -228,7 +236,6 @@ export function setupPinchZoom({
228236 const zoomFactor = 1 - e . deltaY * 0.01 ;
229237 accumulatedWheelScale *= zoomFactor ;
230238 accumulatedWheelScale = Math . max ( 0.1 , Math . min ( 10 , accumulatedWheelScale ) ) ;
231-
232239 updateTransform ( accumulatedWheelScale ) ;
233240
234241 wheelZoomTimeout = setTimeout ( ( ) => {
@@ -250,11 +257,9 @@ export function setupPinchZoom({
250257 element . removeEventListener ( 'touchend' , handleTouchEnd ) ;
251258 element . removeEventListener ( 'touchcancel' , handleTouchEnd ) ;
252259 element . removeEventListener ( 'wheel' , handleWheel ) ;
253-
254260 if ( wheelZoomTimeout ) {
255261 clearTimeout ( wheelZoomTimeout ) ;
256262 }
257-
258263 resetTransform ( ) ;
259264 } ;
260265}
0 commit comments