@@ -18,14 +18,15 @@ import {
1818 canStartPreviewPan ,
1919 clampPreviewPan ,
2020 clampPreviewZoomPercent ,
21+ isPreviewAtFit ,
2122 ownsPreviewPanTarget ,
22- resolvePreviewVisibleRegion ,
2323 resolvePreviewWheelPan ,
2424 resolvePreviewWheelZoom ,
2525 toDomPrecision ,
2626 type PreviewZoomState ,
2727} from "./previewZoom" ;
2828import { RULER_GUTTER_PX , usePreviewGuidesStore } from "../editor/previewGuidesStore" ;
29+ import { PreviewZoomOverlay , usePreviewNavigator } from "./PreviewZoomOverlay" ;
2930import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry" ;
3031interface NLEPreviewProps {
3132 projectId : string ;
@@ -61,7 +62,6 @@ export function getPreviewPlayerKey({
6162const ZOOM_HUD_TIMEOUT_MS = 1200 ;
6263const ZOOM_SETTLE_MS = 200 ;
6364const PREVIEW_STAGE_INSET_PX = 8 ;
64- const NAVIGATOR_PX = 112 ;
6565
6666// clip-path as well as visibility: the player's loading overlay sets its own
6767// visibility:visible and would otherwise paint over the live frame.
@@ -73,23 +73,6 @@ const SHADOW_IFRAME_STYLE: React.CSSProperties = {
7373 pointerEvents : "none" ,
7474} ;
7575
76- const isFitZoom = ( zoomPercent : number ) => Math . abs ( zoomPercent - 100 ) < 0.5 ;
77-
78- function isPreviewAtFit ( state : PreviewZoomState ) : boolean {
79- return isFitZoom ( state . zoomPercent ) && Math . abs ( state . panX ) < 0.1 && Math . abs ( state . panY ) < 0.1 ;
80- }
81-
82- function zoomChipLabel ( zoomPercent : number ) : string {
83- return isFitZoom ( zoomPercent ) ? "Panned" : `Zoomed ${ Math . round ( zoomPercent ) } %` ;
84- }
85-
86- function navigatorFrameSize ( stage : { width : number ; height : number } ) {
87- const ratio = stage . width > 0 && stage . height > 0 ? stage . width / stage . height : 16 / 9 ;
88- return ratio >= 1
89- ? { width : NAVIGATOR_PX , height : toDomPrecision ( NAVIGATOR_PX / ratio ) }
90- : { width : toDomPrecision ( NAVIGATOR_PX * ratio ) , height : NAVIGATOR_PX } ;
91- }
92-
9376export function resolvePreviewStageSize (
9477 viewportWidth : number ,
9578 viewportHeight : number ,
@@ -237,32 +220,11 @@ export const NLEPreview = memo(function NLEPreview({
237220 const stageSizeRef = useRef ( stageSize ) ;
238221 stageSizeRef . current = stageSize ;
239222
240- const navigatorRegionRef = useRef < HTMLDivElement | null > ( null ) ;
241- const drawNavigator = useCallback ( ( state : PreviewZoomState ) => {
242- const region = navigatorRegionRef . current ;
243- const rect = viewportRef . current ?. getBoundingClientRect ( ) ;
244- if ( ! region || ! rect ) return ;
245- const visible = resolvePreviewVisibleRegion ( {
246- state,
247- viewportWidth : rect . width ,
248- viewportHeight : rect . height ,
249- contentWidth : stageSizeRef . current . width ,
250- contentHeight : stageSizeRef . current . height ,
251- } ) ;
252- region . style . left = `${ visible . left * 100 } %` ;
253- region . style . top = `${ visible . top * 100 } %` ;
254- region . style . width = `${ visible . width * 100 } %` ;
255- region . style . height = `${ visible . height * 100 } %` ;
256- } , [ ] ) ;
257- const setNavigatorRegion = useCallback (
258- ( node : HTMLDivElement | null ) => {
259- navigatorRegionRef . current = node ;
260- drawNavigator ( zoomRef . current ) ;
261- } ,
262- [ drawNavigator ] ,
223+ const { draw : drawNavigator , setRegion : setNavigatorRegion } = usePreviewNavigator (
224+ viewportRef ,
225+ stageSize ,
226+ zoomRef ,
263227 ) ;
264- useEffect ( ( ) => drawNavigator ( zoomRef . current ) , [ stageSize , drawNavigator ] ) ;
265-
266228 const writeTransform = useCallback (
267229 ( state : PreviewZoomState ) => {
268230 const stage = stageRef . current ;
@@ -600,49 +562,15 @@ export const NLEPreview = memo(function NLEPreview({
600562 style = { { opacity : 0 , transition : "opacity 200ms ease-in" } }
601563 aria-live = "polite"
602564 />
603- { ! isPreviewAtFit ( settledZoom ) && (
604- < >
605- < div
606- className = "absolute top-3 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1.5 rounded-md py-1 pl-2.5 pr-1 text-xs text-white/80 bg-black/60 backdrop-blur-xs"
607- data-testid = "preview-zoom-chip"
608- // The pane clears the timeline selection on a pointerdown outside the frame.
609- onPointerDown = { ( event ) => event . stopPropagation ( ) }
610- >
611- < span className = "tabular-nums" > { zoomChipLabel ( settledZoom . zoomPercent ) } </ span >
612- < span aria-hidden = "true" className = "text-white/30" >
613- ·
614- </ span >
615- < button
616- type = "button"
617- className = "rounded px-1.5 py-0.5 font-medium text-studio-accent hover:bg-white/10 transition-colors"
618- onClick = { ( ) => {
619- applyZoom ( DEFAULT_PREVIEW_ZOOM ) ;
620- viewportRef . current ?. focus ( ) ;
621- } }
622- aria-label = "Fit the whole frame in view"
623- data-testid = "preview-zoom-fit"
624- >
625- Fit
626- </ button >
627- </ div >
628- < div
629- aria-hidden = "true"
630- className = "pointer-events-none absolute bottom-3 right-3 z-50 rounded-md p-1.5 bg-black/60 backdrop-blur-xs"
631- data-testid = "preview-zoom-navigator"
632- >
633- < div
634- className = "relative overflow-hidden bg-white/10"
635- style = { navigatorFrameSize ( stageSize ) }
636- >
637- < div
638- ref = { setNavigatorRegion }
639- className = "absolute rounded-[1px] border border-studio-accent bg-studio-accent/15"
640- data-testid = "preview-zoom-navigator-region"
641- />
642- </ div >
643- </ div >
644- </ >
645- ) }
565+ < PreviewZoomOverlay
566+ zoom = { settledZoom }
567+ stageSize = { stageSize }
568+ onFit = { ( ) => {
569+ applyZoom ( DEFAULT_PREVIEW_ZOOM ) ;
570+ viewportRef . current ?. focus ( ) ;
571+ } }
572+ navigatorRegionRef = { setNavigatorRegion }
573+ />
646574 </ div >
647575 </ div >
648576 ) ;
0 commit comments