1- import { memo , useCallback , useEffect , useRef , type Ref } from "react" ;
1+ import { memo , useCallback , useEffect , useRef , useState , type Ref } from "react" ;
22import { Player } from "../../player" ;
33import {
44 DEFAULT_PREVIEW_ZOOM ,
5+ canStartPreviewPan ,
56 clampPreviewPan ,
67 clampPreviewZoomPercent ,
8+ ownsPreviewPanTarget ,
79 resolvePreviewWheelZoom ,
810 toDomPrecision ,
911 type PreviewZoomState ,
@@ -34,6 +36,15 @@ export function getPreviewPlayerKey({
3436
3537const ZOOM_HUD_TIMEOUT_MS = 1200 ;
3638const ZOOM_SETTLE_MS = 200 ;
39+ const PREVIEW_STAGE_INSET_PX = 16 ;
40+
41+ function isPreviewAtFit ( state : PreviewZoomState ) : boolean {
42+ return (
43+ Math . abs ( state . zoomPercent - 100 ) < 0.5 &&
44+ Math . abs ( state . panX ) < 0.1 &&
45+ Math . abs ( state . panY ) < 0.1
46+ ) ;
47+ }
3748
3849function loadInitialZoom ( ) : PreviewZoomState {
3950 const stored = readStudioUiPreferences ( ) . previewZoom ;
@@ -46,21 +57,49 @@ function loadInitialZoom(): PreviewZoomState {
4657 : DEFAULT_PREVIEW_ZOOM ;
4758}
4859
60+ function resolvePreviewStageSize (
61+ viewportWidth : number ,
62+ viewportHeight : number ,
63+ portrait : boolean | undefined ,
64+ ) : { width : number ; height : number } {
65+ const availableWidth = Math . max ( 0 , viewportWidth - PREVIEW_STAGE_INSET_PX ) ;
66+ const availableHeight = Math . max ( 0 , viewportHeight - PREVIEW_STAGE_INSET_PX ) ;
67+ const aspectRatio = portrait ? 9 / 16 : 16 / 9 ;
68+
69+ if ( availableWidth === 0 || availableHeight === 0 ) {
70+ return { width : 0 , height : 0 } ;
71+ }
72+
73+ let width = availableWidth ;
74+ let height = width / aspectRatio ;
75+ if ( height > availableHeight ) {
76+ height = availableHeight ;
77+ width = height * aspectRatio ;
78+ }
79+
80+ return {
81+ width : toDomPrecision ( width ) ,
82+ height : toDomPrecision ( height ) ,
83+ } ;
84+ }
85+
4986export const NLEPreview = memo ( function NLEPreview ( {
5087 projectId,
5188 iframeRef,
5289 onIframeLoad,
5390 onCompositionLoadingChange,
5491 portrait,
5592 directUrl,
93+ refreshKey,
5694 suppressLoadingOverlay,
5795} : NLEPreviewProps ) {
58- // Player key only changes for structural changes (project switch, composition
59- // drill-down), NOT for content refreshes. Content refreshes use the lighter
60- // iframe.src reload path handled by NLELayout → refreshPlayer().
61- const activeKey = getPreviewPlayerKey ( { projectId, directUrl } ) ;
96+ const baseKey = getPreviewPlayerKey ( { projectId, directUrl, refreshKey } ) ;
97+ const prevRefreshKeyRef = useRef ( refreshKey ) ;
6298 const viewportRef = useRef < HTMLDivElement > ( null ) ;
6399 const stageRef = useRef < HTMLDivElement > ( null ) ;
100+ const [ retiringKey , setRetiringKey ] = useState < string | null > ( null ) ;
101+ const [ stageSize , setStageSize ] = useState ( ( ) => resolvePreviewStageSize ( 0 , 0 , portrait ) ) ;
102+ const retiringTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
64103
65104 const zoomRef = useRef < PreviewZoomState > ( loadInitialZoom ( ) ) ;
66105 const hudRef = useRef < HTMLDivElement > ( null ) ;
@@ -79,17 +118,32 @@ export const NLEPreview = memo(function NLEPreview({
79118 return ( ) => {
80119 if ( settleTimerRef . current ) clearTimeout ( settleTimerRef . current ) ;
81120 if ( hudTimerRef . current ) clearTimeout ( hudTimerRef . current ) ;
121+ if ( retiringTimerRef . current ) clearTimeout ( retiringTimerRef . current ) ;
82122 } ;
83123 } , [ ] ) ;
84124
125+ useEffect ( ( ) => {
126+ const viewport = viewportRef . current ;
127+ if ( ! viewport ) return ;
128+
129+ const updateStageSize = ( ) => {
130+ const rect = viewport . getBoundingClientRect ( ) ;
131+ setStageSize ( resolvePreviewStageSize ( rect . width , rect . height , portrait ) ) ;
132+ } ;
133+
134+ updateStageSize ( ) ;
135+ const observer = new ResizeObserver ( updateStageSize ) ;
136+ observer . observe ( viewport ) ;
137+ return ( ) => observer . disconnect ( ) ;
138+ } , [ portrait ] ) ;
139+
85140 const writeTransform = useCallback ( ( state : PreviewZoomState ) => {
86141 const stage = stageRef . current ;
87142 if ( ! stage ) return ;
88143 const s = toDomPrecision ( state . zoomPercent / 100 ) ;
89144 const px = toDomPrecision ( state . panX ) ;
90145 const py = toDomPrecision ( state . panY ) ;
91- stage . style . zoom = String ( s ) ;
92- stage . style . transform = `translate(${ px } px, ${ py } px)` ;
146+ stage . style . transform = `translate(${ px } px, ${ py } px) scale(${ s } )` ;
93147 } , [ ] ) ;
94148
95149 const applyZoom = useCallback (
@@ -116,8 +170,7 @@ export const NLEPreview = memo(function NLEPreview({
116170 writeStudioUiPreferences ( { previewZoom : final } ) ;
117171 const hud = hudRef . current ;
118172 if ( hud ) {
119- const zoomed = Math . abs ( final . zoomPercent - 100 ) > 0.5 ;
120- hud . textContent = zoomed ? `${ Math . round ( final . zoomPercent ) } %` : "Fit" ;
173+ hud . textContent = isPreviewAtFit ( final ) ? "Fit" : `${ Math . round ( final . zoomPercent ) } %` ;
121174 if ( hudTimerRef . current ) clearTimeout ( hudTimerRef . current ) ;
122175 hudTimerRef . current = setTimeout ( ( ) => {
123176 if ( hudRef . current ) hudRef . current . style . opacity = "0" ;
@@ -128,13 +181,31 @@ export const NLEPreview = memo(function NLEPreview({
128181 [ writeTransform ] ,
129182 ) ;
130183
184+ if ( refreshKey !== prevRefreshKeyRef . current ) {
185+ const oldKey = `${ baseKey } :${ prevRefreshKeyRef . current ?? 0 } ` ;
186+ prevRefreshKeyRef . current = refreshKey ;
187+ setRetiringKey ( oldKey ) ;
188+ }
189+
190+ const activeKey = `${ baseKey } :${ refreshKey ?? 0 } ` ;
191+
131192 const applyInitialZoom = useCallback ( ( ) => {
132193 const z = zoomRef . current ;
133194 if ( Math . abs ( z . zoomPercent - 100 ) > 0.5 || Math . abs ( z . panX ) > 0.1 || Math . abs ( z . panY ) > 0.1 ) {
134195 writeTransform ( z ) ;
135196 }
136197 } , [ writeTransform ] ) ;
137198
199+ const handleNewPlayerLoad = ( ) => {
200+ onIframeLoad ( ) ;
201+ applyInitialZoom ( ) ;
202+ if ( retiringTimerRef . current ) clearTimeout ( retiringTimerRef . current ) ;
203+ retiringTimerRef . current = setTimeout ( ( ) => {
204+ setRetiringKey ( null ) ;
205+ retiringTimerRef . current = null ;
206+ } , 160 ) ;
207+ } ;
208+
138209 useEffect ( ( ) => {
139210 const viewport = viewportRef . current ;
140211 if ( ! viewport ) return ;
@@ -164,6 +235,8 @@ export const NLEPreview = memo(function NLEPreview({
164235 deltaY : event . deltaY ,
165236 viewportWidth : rect . width ,
166237 viewportHeight : rect . height ,
238+ contentWidth : stageSize . width ,
239+ contentHeight : stageSize . height ,
167240 } ) ;
168241 applyZoom ( next ) ;
169242 return ;
@@ -177,14 +250,14 @@ export const NLEPreview = memo(function NLEPreview({
177250
178251 document . addEventListener ( "wheel" , handleWheel , { passive : false , capture : true } ) ;
179252 return ( ) => document . removeEventListener ( "wheel" , handleWheel , { capture : true } ) ;
180- } , [ applyZoom ] ) ;
253+ } , [ applyZoom , stageSize . height , stageSize . width ] ) ;
181254
182255 useEffect ( ( ) => {
183256 const viewport = viewportRef . current ;
184257 if ( ! viewport ) return ;
185258
186259 const handleDblClick = ( event : MouseEvent ) => {
187- if ( Math . abs ( zoomRef . current . zoomPercent - 100 ) < 0.5 ) return ;
260+ if ( isPreviewAtFit ( zoomRef . current ) ) return ;
188261 const rect = viewport . getBoundingClientRect ( ) ;
189262 if (
190263 event . clientX < rect . left ||
@@ -201,20 +274,38 @@ export const NLEPreview = memo(function NLEPreview({
201274 return ( ) => document . removeEventListener ( "dblclick" , handleDblClick , { capture : true } ) ;
202275 } , [ applyZoom ] ) ;
203276
204- const handlePointerDown = useCallback ( ( event : React . PointerEvent < HTMLDivElement > ) => {
205- if ( zoomRef . current . zoomPercent <= 100 || event . button !== 0 ) return ;
206- event . currentTarget . setPointerCapture ( event . pointerId ) ;
207- dragRef . current = {
208- pointerId : event . pointerId ,
209- startX : event . clientX ,
210- startY : event . clientY ,
211- originX : zoomRef . current . panX ,
212- originY : zoomRef . current . panY ,
277+ useEffect ( ( ) => {
278+ const isInsideViewport = ( clientX : number , clientY : number ) : DOMRect | null => {
279+ const viewport = viewportRef . current ;
280+ if ( ! viewport ) return null ;
281+ const rect = viewport . getBoundingClientRect ( ) ;
282+ if (
283+ clientX < rect . left ||
284+ clientX > rect . right ||
285+ clientY < rect . top ||
286+ clientY > rect . bottom
287+ ) {
288+ return null ;
289+ }
290+ return rect ;
291+ } ;
292+
293+ const handlePointerDown = ( event : PointerEvent ) => {
294+ const rect = isInsideViewport ( event . clientX , event . clientY ) ;
295+ if ( ! rect ) return ;
296+ if ( ! ownsPreviewPanTarget ( event . target , stageRef . current ) ) return ;
297+ if ( ! canStartPreviewPan ( event . button ) ) return ;
298+ event . preventDefault ( ) ;
299+ dragRef . current = {
300+ pointerId : event . pointerId ,
301+ startX : event . clientX ,
302+ startY : event . clientY ,
303+ originX : zoomRef . current . panX ,
304+ originY : zoomRef . current . panY ,
305+ } ;
213306 } ;
214- } , [ ] ) ;
215307
216- const handlePointerMove = useCallback (
217- ( event : React . PointerEvent < HTMLDivElement > ) => {
308+ const handlePointerMove = ( event : PointerEvent ) => {
218309 const drag = dragRef . current ;
219310 const viewport = viewportRef . current ;
220311 if ( ! drag || ! viewport || drag . pointerId !== event . pointerId ) return ;
@@ -226,17 +317,38 @@ export const NLEPreview = memo(function NLEPreview({
226317 zoomPercent : zoomRef . current . zoomPercent ,
227318 viewportWidth : rect . width ,
228319 viewportHeight : rect . height ,
320+ contentWidth : stageSize . width ,
321+ contentHeight : stageSize . height ,
229322 } ) ;
230323 applyZoom ( { ...zoomRef . current , ...pan } ) ;
231- } ,
232- [ applyZoom ] ,
233- ) ;
324+ } ;
234325
235- const finishDrag = useCallback ( ( event : React . PointerEvent < HTMLDivElement > ) => {
236- if ( dragRef . current ?. pointerId === event . pointerId ) {
237- dragRef . current = null ;
238- }
239- } , [ ] ) ;
326+ const finishDrag = ( event : PointerEvent ) => {
327+ if ( dragRef . current ?. pointerId === event . pointerId ) {
328+ dragRef . current = null ;
329+ }
330+ } ;
331+
332+ const handleAuxClick = ( event : MouseEvent ) => {
333+ if ( event . button !== 1 ) return ;
334+ if ( ! isInsideViewport ( event . clientX , event . clientY ) ) return ;
335+ if ( ! ownsPreviewPanTarget ( event . target , stageRef . current ) ) return ;
336+ event . preventDefault ( ) ;
337+ } ;
338+
339+ document . addEventListener ( "pointerdown" , handlePointerDown , { capture : true } ) ;
340+ document . addEventListener ( "pointermove" , handlePointerMove , { capture : true } ) ;
341+ document . addEventListener ( "pointerup" , finishDrag , { capture : true } ) ;
342+ document . addEventListener ( "pointercancel" , finishDrag , { capture : true } ) ;
343+ document . addEventListener ( "auxclick" , handleAuxClick , { capture : true } ) ;
344+ return ( ) => {
345+ document . removeEventListener ( "pointerdown" , handlePointerDown , { capture : true } ) ;
346+ document . removeEventListener ( "pointermove" , handlePointerMove , { capture : true } ) ;
347+ document . removeEventListener ( "pointerup" , finishDrag , { capture : true } ) ;
348+ document . removeEventListener ( "pointercancel" , finishDrag , { capture : true } ) ;
349+ document . removeEventListener ( "auxclick" , handleAuxClick , { capture : true } ) ;
350+ } ;
351+ } , [ applyZoom , stageSize . height , stageSize . width ] ) ;
240352
241353 const initial = zoomRef . current ;
242354
@@ -247,34 +359,48 @@ export const NLEPreview = memo(function NLEPreview({
247359 className = "relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40 bg-neutral-700"
248360 tabIndex = { 0 }
249361 aria-label = "Composition preview"
250- onPointerDown = { handlePointerDown }
251- onPointerMove = { handlePointerMove }
252- onPointerUp = { finishDrag }
253- onPointerCancel = { finishDrag }
254362 >
255- < div
256- ref = { stageRef }
257- className = "absolute inset-2"
258- style = { {
259- zoom : toDomPrecision ( initial . zoomPercent / 100 ) ,
260- transform : `translate(${ toDomPrecision ( initial . panX ) } px, ${ toDomPrecision ( initial . panY ) } px)` ,
261- transformOrigin : "0 0" ,
262- } }
263- data-testid = "preview-zoom-stage"
264- >
265- < Player
266- key = { activeKey }
267- ref = { iframeRef }
268- projectId = { directUrl ? undefined : projectId }
269- directUrl = { directUrl }
270- onLoad = { ( ) => {
271- onIframeLoad ( ) ;
272- applyInitialZoom ( ) ;
363+ < div className = "absolute inset-2 flex items-center justify-center pointer-events-none" >
364+ < div
365+ ref = { stageRef }
366+ className = "relative shrink-0 pointer-events-auto"
367+ style = { {
368+ width : `${ stageSize . width } px` ,
369+ height : `${ stageSize . height } px` ,
370+ transform : `translate(${ toDomPrecision ( initial . panX ) } px, ${ toDomPrecision ( initial . panY ) } px) scale(${ toDomPrecision ( initial . zoomPercent / 100 ) } )` ,
371+ transformOrigin : "center center" ,
273372 } }
274- onCompositionLoadingChange = { onCompositionLoadingChange }
275- portrait = { portrait }
276- suppressLoadingOverlay = { suppressLoadingOverlay }
277- />
373+ data-testid = "preview-zoom-stage"
374+ >
375+ { retiringKey && (
376+ < Player
377+ key = { retiringKey }
378+ projectId = { directUrl ? undefined : projectId }
379+ directUrl = { directUrl }
380+ onLoad = { ( ) => { } }
381+ portrait = { portrait }
382+ style = { { position : "absolute" , inset : 0 , zIndex : 0 , opacity : 1 } }
383+ />
384+ ) }
385+ < Player
386+ key = { activeKey }
387+ ref = { iframeRef }
388+ projectId = { directUrl ? undefined : projectId }
389+ directUrl = { directUrl }
390+ onLoad = {
391+ retiringKey
392+ ? handleNewPlayerLoad
393+ : ( ) => {
394+ onIframeLoad ( ) ;
395+ applyInitialZoom ( ) ;
396+ }
397+ }
398+ onCompositionLoadingChange = { onCompositionLoadingChange }
399+ portrait = { portrait }
400+ style = { retiringKey ? { position : "absolute" , inset : 0 , zIndex : 1 } : undefined }
401+ suppressLoadingOverlay = { suppressLoadingOverlay }
402+ />
403+ </ div >
278404 </ div >
279405 < div
280406 ref = { hudRef }
0 commit comments