@@ -103,6 +103,7 @@ export const NLEPreview = memo(function NLEPreview({
103103 const retiringTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
104104
105105 const zoomRef = useRef < PreviewZoomState > ( loadInitialZoom ( ) ) ;
106+ const [ settledZoom , setSettledZoom ] = useState < PreviewZoomState > ( ( ) => zoomRef . current ) ;
106107 const hudRef = useRef < HTMLDivElement > ( null ) ;
107108 const hudTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
108109 const settleTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
@@ -138,25 +139,28 @@ export const NLEPreview = memo(function NLEPreview({
138139 return ( ) => observer . disconnect ( ) ;
139140 } , [ portrait ] ) ;
140141
142+ const stageSizeRef = useRef ( stageSize ) ;
143+ stageSizeRef . current = stageSize ;
144+
141145 const writeTransform = useCallback ( ( state : PreviewZoomState ) => {
142146 const stage = stageRef . current ;
143147 if ( ! stage ) return ;
144148 const s = toDomPrecision ( state . zoomPercent / 100 ) ;
145149 const px = toDomPrecision ( state . panX ) ;
146150 const py = toDomPrecision ( state . panY ) ;
147- stage . style . transform = `translate (${ px } px, ${ py } px) scale(${ s } )` ;
151+ stage . style . transform = `translate3d (${ px } px, ${ py } px, 0 ) scale(${ s } )` ;
148152 } , [ ] ) ;
149153
150- const applyZoom = useCallback (
151- ( next : PreviewZoomState ) => {
154+ const applyTransform = useCallback (
155+ ( next : PreviewZoomState , showHud : boolean ) => {
152156 const clamped : PreviewZoomState = {
153157 zoomPercent : clampPreviewZoomPercent ( next . zoomPercent ) ,
154158 panX : Number . isFinite ( next . panX ) ? next . panX : 0 ,
155159 panY : Number . isFinite ( next . panY ) ? next . panY : 0 ,
156160 } ;
157161 zoomRef . current = clamped ;
158162
159- if ( ! zoomingRef . current ) {
163+ if ( showHud && ! zoomingRef . current ) {
160164 zoomingRef . current = true ;
161165 const hud = hudRef . current ;
162166 if ( hud ) hud . style . opacity = "1" ;
@@ -169,19 +173,38 @@ export const NLEPreview = memo(function NLEPreview({
169173 zoomingRef . current = false ;
170174 const final = zoomRef . current ;
171175 writeStudioUiPreferences ( { previewZoom : final } ) ;
172- const hud = hudRef . current ;
173- if ( hud ) {
174- hud . textContent = isPreviewAtFit ( final ) ? "Fit" : `${ Math . round ( final . zoomPercent ) } %` ;
175- if ( hudTimerRef . current ) clearTimeout ( hudTimerRef . current ) ;
176- hudTimerRef . current = setTimeout ( ( ) => {
177- if ( hudRef . current ) hudRef . current . style . opacity = "0" ;
178- } , ZOOM_HUD_TIMEOUT_MS ) ;
176+ setSettledZoom ( ( prev ) =>
177+ prev . zoomPercent === final . zoomPercent &&
178+ prev . panX === final . panX &&
179+ prev . panY === final . panY
180+ ? prev
181+ : final ,
182+ ) ;
183+ if ( showHud ) {
184+ const hud = hudRef . current ;
185+ if ( hud ) {
186+ hud . textContent = isPreviewAtFit ( final ) ? "Fit" : `${ Math . round ( final . zoomPercent ) } %` ;
187+ if ( hudTimerRef . current ) clearTimeout ( hudTimerRef . current ) ;
188+ hudTimerRef . current = setTimeout ( ( ) => {
189+ if ( hudRef . current ) hudRef . current . style . opacity = "0" ;
190+ } , ZOOM_HUD_TIMEOUT_MS ) ;
191+ }
179192 }
180193 } , ZOOM_SETTLE_MS ) ;
181194 } ,
182195 [ writeTransform ] ,
183196 ) ;
184197
198+ const applyZoom = useCallback (
199+ ( next : PreviewZoomState ) => applyTransform ( next , true ) ,
200+ [ applyTransform ] ,
201+ ) ;
202+
203+ const applyPan = useCallback (
204+ ( next : PreviewZoomState ) => applyTransform ( next , false ) ,
205+ [ applyTransform ] ,
206+ ) ;
207+
185208 if ( refreshKey !== prevRefreshKeyRef . current ) {
186209 const oldKey = `${ baseKey } :${ prevRefreshKeyRef . current ?? 0 } ` ;
187210 prevRefreshKeyRef . current = refreshKey ;
@@ -228,13 +251,18 @@ export const NLEPreview = memo(function NLEPreview({
228251 event . preventDefault ( ) ;
229252 event . stopPropagation ( ) ;
230253
254+ const sz = stageSizeRef . current ;
255+ const cursorX = event . clientX - ( rect . left + rect . width / 2 ) ;
256+ const cursorY = event . clientY - ( rect . top + rect . height / 2 ) ;
231257 const next = resolvePreviewWheelZoom ( {
232258 state : zoomRef . current ,
233259 deltaY : event . deltaY ,
234260 viewportWidth : rect . width ,
235261 viewportHeight : rect . height ,
236- contentWidth : stageSize . width ,
237- contentHeight : stageSize . height ,
262+ contentWidth : sz . width ,
263+ contentHeight : sz . height ,
264+ cursorX,
265+ cursorY,
238266 } ) ;
239267 applyZoom ( next ) ;
240268 return ;
@@ -245,21 +273,22 @@ export const NLEPreview = memo(function NLEPreview({
245273 event . preventDefault ( ) ;
246274 event . stopPropagation ( ) ;
247275
276+ const sz = stageSizeRef . current ;
248277 const next = resolvePreviewWheelPan ( {
249278 state : zoomRef . current ,
250279 deltaX : event . deltaX ,
251280 deltaY : event . deltaY ,
252281 viewportWidth : rect . width ,
253282 viewportHeight : rect . height ,
254- contentWidth : stageSize . width ,
255- contentHeight : stageSize . height ,
283+ contentWidth : sz . width ,
284+ contentHeight : sz . height ,
256285 } ) ;
257- applyZoom ( next ) ;
286+ applyPan ( next ) ;
258287 } ;
259288
260289 document . addEventListener ( "wheel" , handleWheel , { passive : false , capture : true } ) ;
261290 return ( ) => document . removeEventListener ( "wheel" , handleWheel , { capture : true } ) ;
262- } , [ applyZoom , stageSize . height , stageSize . width ] ) ;
291+ } , [ applyZoom , applyPan ] ) ;
263292
264293 useEffect ( ( ) => {
265294 const viewport = viewportRef . current ;
@@ -320,16 +349,17 @@ export const NLEPreview = memo(function NLEPreview({
320349 if ( ! drag || ! viewport || drag . pointerId !== event . pointerId ) return ;
321350 event . preventDefault ( ) ;
322351 const rect = viewport . getBoundingClientRect ( ) ;
352+ const sz = stageSizeRef . current ;
323353 const pan = clampPreviewPan ( {
324354 panX : drag . originX + event . clientX - drag . startX ,
325355 panY : drag . originY + event . clientY - drag . startY ,
326356 zoomPercent : zoomRef . current . zoomPercent ,
327357 viewportWidth : rect . width ,
328358 viewportHeight : rect . height ,
329- contentWidth : stageSize . width ,
330- contentHeight : stageSize . height ,
359+ contentWidth : sz . width ,
360+ contentHeight : sz . height ,
331361 } ) ;
332- applyZoom ( { ...zoomRef . current , ...pan } ) ;
362+ applyPan ( { ...zoomRef . current , ...pan } ) ;
333363 } ;
334364
335365 const finishDrag = ( event : PointerEvent ) => {
@@ -357,7 +387,7 @@ export const NLEPreview = memo(function NLEPreview({
357387 document . removeEventListener ( "pointercancel" , finishDrag , { capture : true } ) ;
358388 document . removeEventListener ( "auxclick" , handleAuxClick , { capture : true } ) ;
359389 } ;
360- } , [ applyZoom , stageSize . height , stageSize . width ] ) ;
390+ } , [ applyPan ] ) ;
361391
362392 const initial = zoomRef . current ;
363393
@@ -376,7 +406,8 @@ export const NLEPreview = memo(function NLEPreview({
376406 style = { {
377407 width : `${ stageSize . width } px` ,
378408 height : `${ stageSize . height } px` ,
379- transform : `translate(${ toDomPrecision ( initial . panX ) } px, ${ toDomPrecision ( initial . panY ) } px) scale(${ toDomPrecision ( initial . zoomPercent / 100 ) } )` ,
409+ transform : `translate3d(${ toDomPrecision ( initial . panX ) } px, ${ toDomPrecision ( initial . panY ) } px, 0) scale(${ toDomPrecision ( initial . zoomPercent / 100 ) } )` ,
410+ // resolvePreviewWheelZoom cursor math assumes center-center pivot
380411 transformOrigin : "center center" ,
381412 } }
382413 data-testid = "preview-zoom-stage"
@@ -417,6 +448,17 @@ export const NLEPreview = memo(function NLEPreview({
417448 style = { { opacity : 0 , transition : "opacity 300ms ease-out" } }
418449 aria-live = "polite"
419450 />
451+ { ! isPreviewAtFit ( settledZoom ) && (
452+ < button
453+ type = "button"
454+ className = "absolute bottom-3 right-3 z-50 rounded-md px-2.5 py-1 text-xs font-medium text-white/80 bg-black/50 backdrop-blur-sm hover:bg-black/70 hover:text-white transition-colors"
455+ onClick = { ( ) => applyZoom ( DEFAULT_PREVIEW_ZOOM ) }
456+ aria-label = "Reset zoom to fit"
457+ data-testid = "preview-reset-zoom"
458+ >
459+ { Math . round ( settledZoom . zoomPercent ) } % — Reset
460+ </ button >
461+ ) }
420462 </ div >
421463 </ div >
422464 ) ;
0 commit comments