import { memo, useCallback, useEffect, useRef, useState, type RefObject } from "react"; import { Player } from "../../player"; import { DEFAULT_PREVIEW_ZOOM, canStartPreviewPan, clampPreviewPan, clampPreviewZoomPercent, ownsPreviewPanTarget, resolvePreviewWheelPan, resolvePreviewWheelZoom, toDomPrecision, type PreviewZoomState, } from "./previewZoom"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; interface NLEPreviewProps { projectId: string; iframeRef: RefObject; onIframeLoad: () => void; onCompositionLoadingChange?: (loading: boolean) => void; portrait?: boolean; directUrl?: string; suppressLoadingOverlay?: boolean; onStageRef?: (ref: React.RefObject) => void; } export function getPreviewPlayerKey({ projectId, directUrl, }: { projectId: string; directUrl?: string; }): string { return directUrl ?? projectId; } const ZOOM_HUD_TIMEOUT_MS = 1200; const ZOOM_SETTLE_MS = 200; const PREVIEW_STAGE_INSET_PX = 16; interface PreviewCompositionSize { width: number; height: number; } function isPreviewAtFit(state: PreviewZoomState): boolean { return ( Math.abs(state.zoomPercent - 100) < 0.5 && Math.abs(state.panX) < 0.1 && Math.abs(state.panY) < 0.1 ); } function loadInitialZoom(): PreviewZoomState { const stored = readStudioUiPreferences().previewZoom; return stored ? { zoomPercent: clampPreviewZoomPercent(stored.zoomPercent), panX: stored.panX, panY: stored.panY, } : DEFAULT_PREVIEW_ZOOM; } // fallow-ignore-next-line complexity function readPreviewCompositionSize( iframe: HTMLIFrameElement | null, ): PreviewCompositionSize | null { try { const doc = iframe?.contentDocument; const root = doc?.querySelector("[data-composition-id][data-width][data-height]") ?? doc?.querySelector("[data-width][data-height]"); if (!root) return null; const width = Number.parseInt(root.getAttribute("data-width") ?? "", 10); const height = Number.parseInt(root.getAttribute("data-height") ?? "", 10); if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) { return null; } return { width, height }; } catch { return null; } } export function resolvePreviewStageSize( viewportWidth: number, viewportHeight: number, compositionSize: PreviewCompositionSize | null, portrait: boolean | undefined, ): { width: number; height: number } { const availableWidth = Math.max(0, viewportWidth - PREVIEW_STAGE_INSET_PX); const availableHeight = Math.max(0, viewportHeight - PREVIEW_STAGE_INSET_PX); const aspectRatio = compositionSize && compositionSize.width > 0 && compositionSize.height > 0 ? compositionSize.width / compositionSize.height : portrait ? 9 / 16 : 16 / 9; if (availableWidth === 0 || availableHeight === 0) { return { width: 0, height: 0 }; } let width = availableWidth; let height = width / aspectRatio; if (height > availableHeight) { height = availableHeight; width = height * aspectRatio; } return { width: toDomPrecision(width), height: toDomPrecision(height), }; } export const NLEPreview = memo(function NLEPreview({ projectId, iframeRef, onIframeLoad, onCompositionLoadingChange, portrait, directUrl, suppressLoadingOverlay, onStageRef, }: NLEPreviewProps) { const activeKey = getPreviewPlayerKey({ projectId, directUrl }); const viewportRef = useRef(null); const stageRef = useRef(null); const previewIframeRef = useRef(null); useEffect(() => { onStageRef?.(stageRef); }, [onStageRef]); const [compositionSize, setCompositionSize] = useState(null); const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait)); const zoomRef = useRef(loadInitialZoom()); const [settledZoom, setSettledZoom] = useState(() => zoomRef.current); const hudRef = useRef(null); const hudTimerRef = useRef | null>(null); const settleTimerRef = useRef | null>(null); const zoomingRef = useRef(false); const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number; } | null>(null); useEffect(() => { return () => { if (settleTimerRef.current) clearTimeout(settleTimerRef.current); if (hudTimerRef.current) clearTimeout(hudTimerRef.current); }; }, []); useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; const updateStageSize = () => { const rect = viewport.getBoundingClientRect(); setStageSize(resolvePreviewStageSize(rect.width, rect.height, compositionSize, portrait)); }; updateStageSize(); const observer = new ResizeObserver(updateStageSize); observer.observe(viewport); return () => observer.disconnect(); }, [compositionSize, portrait]); const updateCompositionSizeFromPreview = useCallback(() => { const next = readPreviewCompositionSize(previewIframeRef.current); setCompositionSize((prev) => prev?.width === next?.width && prev?.height === next?.height ? prev : next, ); }, []); const setPreviewIframeRef = useCallback( (node: HTMLIFrameElement | null) => { previewIframeRef.current = node; iframeRef.current = node; }, [iframeRef], ); const stageSizeRef = useRef(stageSize); stageSizeRef.current = stageSize; const writeTransform = useCallback((state: PreviewZoomState) => { const stage = stageRef.current; if (!stage) return; const s = toDomPrecision(state.zoomPercent / 100); const px = toDomPrecision(state.panX); const py = toDomPrecision(state.panY); stage.style.transform = `translate3d(${px}px, ${py}px, 0) scale(${s})`; }, []); const applyTransform = useCallback( (next: PreviewZoomState, showHud: boolean) => { const clamped: PreviewZoomState = { zoomPercent: clampPreviewZoomPercent(next.zoomPercent), panX: Number.isFinite(next.panX) ? next.panX : 0, panY: Number.isFinite(next.panY) ? next.panY : 0, }; zoomRef.current = clamped; if (showHud && !zoomingRef.current) { zoomingRef.current = true; const hud = hudRef.current; if (hud) hud.style.opacity = "1"; } writeTransform(clamped); if (settleTimerRef.current) clearTimeout(settleTimerRef.current); settleTimerRef.current = setTimeout(() => { zoomingRef.current = false; const final = zoomRef.current; writeStudioUiPreferences({ previewZoom: final }); setSettledZoom((prev) => prev.zoomPercent === final.zoomPercent && prev.panX === final.panX && prev.panY === final.panY ? prev : final, ); if (showHud) { const hud = hudRef.current; if (hud) { hud.textContent = isPreviewAtFit(final) ? "Fit" : `${Math.round(final.zoomPercent)}%`; if (hudTimerRef.current) clearTimeout(hudTimerRef.current); hudTimerRef.current = setTimeout(() => { if (hudRef.current) hudRef.current.style.opacity = "0"; }, ZOOM_HUD_TIMEOUT_MS); } } }, ZOOM_SETTLE_MS); }, [writeTransform], ); const applyZoom = useCallback( (next: PreviewZoomState) => applyTransform(next, true), [applyTransform], ); const applyPan = useCallback( (next: PreviewZoomState) => applyTransform(next, false), [applyTransform], ); const applyInitialZoom = useCallback(() => { const z = zoomRef.current; if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) { writeTransform(z); } }, [writeTransform]); useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; const handleWheel = (event: WheelEvent) => { const rect = viewport.getBoundingClientRect(); if ( event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom ) { return; } const isZoomGesture = event.ctrlKey || event.metaKey; if (isZoomGesture) { event.preventDefault(); event.stopPropagation(); const sz = stageSizeRef.current; const cursorX = event.clientX - (rect.left + rect.width / 2); const cursorY = event.clientY - (rect.top + rect.height / 2); const next = resolvePreviewWheelZoom({ state: zoomRef.current, deltaY: event.deltaY, viewportWidth: rect.width, viewportHeight: rect.height, contentWidth: sz.width, contentHeight: sz.height, cursorX, cursorY, }); applyZoom(next); return; } if (!ownsPreviewPanTarget(event.target, stageRef.current)) return; event.preventDefault(); event.stopPropagation(); const sz = stageSizeRef.current; const next = resolvePreviewWheelPan({ state: zoomRef.current, deltaX: event.deltaX, deltaY: event.deltaY, viewportWidth: rect.width, viewportHeight: rect.height, contentWidth: sz.width, contentHeight: sz.height, }); applyPan(next); }; document.addEventListener("wheel", handleWheel, { passive: false, capture: true }); return () => document.removeEventListener("wheel", handleWheel, { capture: true }); }, [applyZoom, applyPan]); useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; const handleDblClick = (event: MouseEvent) => { if (isPreviewAtFit(zoomRef.current)) return; const rect = viewport.getBoundingClientRect(); if ( event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom ) { return; } applyZoom(DEFAULT_PREVIEW_ZOOM); }; document.addEventListener("dblclick", handleDblClick, { capture: true }); return () => document.removeEventListener("dblclick", handleDblClick, { capture: true }); }, [applyZoom]); useEffect(() => { const isInsideViewport = (clientX: number, clientY: number): DOMRect | null => { const viewport = viewportRef.current; if (!viewport) return null; const rect = viewport.getBoundingClientRect(); if ( clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom ) { return null; } return rect; }; const handlePointerDown = (event: PointerEvent) => { const rect = isInsideViewport(event.clientX, event.clientY); if (!rect) return; if (!ownsPreviewPanTarget(event.target, stageRef.current)) return; if (!canStartPreviewPan(event.button)) return; event.preventDefault(); dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originX: zoomRef.current.panX, originY: zoomRef.current.panY, }; }; const handlePointerMove = (event: PointerEvent) => { const drag = dragRef.current; const viewport = viewportRef.current; if (!drag || !viewport || drag.pointerId !== event.pointerId) return; event.preventDefault(); const rect = viewport.getBoundingClientRect(); const sz = stageSizeRef.current; const pan = clampPreviewPan({ panX: drag.originX + event.clientX - drag.startX, panY: drag.originY + event.clientY - drag.startY, zoomPercent: zoomRef.current.zoomPercent, viewportWidth: rect.width, viewportHeight: rect.height, contentWidth: sz.width, contentHeight: sz.height, }); applyPan({ ...zoomRef.current, ...pan }); }; const finishDrag = (event: PointerEvent) => { if (dragRef.current?.pointerId === event.pointerId) { dragRef.current = null; } }; const handleAuxClick = (event: MouseEvent) => { if (event.button !== 1) return; if (!isInsideViewport(event.clientX, event.clientY)) return; if (!ownsPreviewPanTarget(event.target, stageRef.current)) return; event.preventDefault(); }; document.addEventListener("pointerdown", handlePointerDown, { capture: true }); document.addEventListener("pointermove", handlePointerMove, { capture: true }); document.addEventListener("pointerup", finishDrag, { capture: true }); document.addEventListener("pointercancel", finishDrag, { capture: true }); document.addEventListener("auxclick", handleAuxClick, { capture: true }); return () => { document.removeEventListener("pointerdown", handlePointerDown, { capture: true }); document.removeEventListener("pointermove", handlePointerMove, { capture: true }); document.removeEventListener("pointerup", finishDrag, { capture: true }); document.removeEventListener("pointercancel", finishDrag, { capture: true }); document.removeEventListener("auxclick", handleAuxClick, { capture: true }); }; }, [applyPan]); const initial = zoomRef.current; return (
{directUrl?.includes("/components/") && ( {}} portrait={portrait} suppressLoadingOverlay style={{ position: "absolute", inset: 0, zIndex: 0 }} /> )} { updateCompositionSizeFromPreview(); onIframeLoad(); applyInitialZoom(); }} onCompositionLoadingChange={onCompositionLoadingChange} portrait={portrait} suppressLoadingOverlay={suppressLoadingOverlay} style={ directUrl?.includes("/components/") ? { position: "absolute", inset: 0, zIndex: 1 } : undefined } />
{!isPreviewAtFit(settledZoom) && ( )}
); });