import { useState, useCallback, useRef, useEffect, useSyncExternalStore, memo, type ReactNode, } from "react"; import { useMountEffect } from "../../hooks/useMountEffect"; import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player"; import type { TimelineElement } from "../../player"; import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; import { NLEPreview } from "./NLEPreview"; import { CompositionBreadcrumb } from "./CompositionBreadcrumb"; import { usePreviewBlockDrop } from "./usePreviewBlockDrop"; import { useCompositionStack } from "./useCompositionStack"; import { useTimelineEditContext } from "../../contexts/TimelineEditContext"; import { setCompositionSourceMap } from "../editor/domEditingDom"; import { trackStudioExpandedClipEdit } from "../../telemetry/events"; import { TIMELINE_TOGGLE_SHORTCUT_LABEL, getTimelineToggleTitle, } from "../../utils/timelineDiscovery"; import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload"; interface NLELayoutProps { projectId: string; portrait?: boolean; /** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */ previewOverlay?: ReactNode; /** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */ timelineToolbar?: ReactNode; /** Slot rendered below the timeline tracks */ timelineFooter?: ReactNode; /** Increment to force the preview to reload (e.g., after file writes) */ refreshKey?: number; /** Navigate to a specific composition path (e.g., "compositions/intro.html") */ activeCompositionPath?: string | null; /** Callback to expose the iframe ref (for element picker, etc.) */ onIframeRef?: (iframe: HTMLIFrameElement | null) => void; /** Callback when the viewed composition changes (drill-down/back) */ onCompositionChange?: (compositionPath: string | null) => void; /** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */ renderClipContent?: ( element: TimelineElement, style: { clip: string; label: string }, ) => ReactNode; onFileDrop?: ( files: File[], placement?: Pick, ) => Promise | void; onDeleteElement?: (element: TimelineElement) => Promise | void; onAssetDrop?: ( assetPath: string, placement: Pick, ) => Promise | void; onBlockDrop?: ( blockName: string, placement: Pick, ) => Promise | void; onPreviewBlockDrop?: ( blockName: string, position: { left: number; top: number }, ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; onSelectTimelineElement?: (element: TimelineElement | null) => void; /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ onCompIdToSrcChange?: (map: Map) => void; /** Whether the timeline panel is visible (default: true) */ timelineVisible?: boolean; /** Callback to toggle timeline visibility */ onToggleTimeline?: () => void; /** Notifies parent when composition loading state changes */ onCompositionLoadingChange?: (loading: boolean) => void; } const MIN_TIMELINE_H = 100; const DEFAULT_TIMELINE_H = 220; const MIN_PREVIEW_H = 120; function subscribeFullscreen(cb: () => void) { document.addEventListener("fullscreenchange", cb); return () => document.removeEventListener("fullscreenchange", cb); } function getFullscreenElement() { return document.fullscreenElement; } export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean { return compositionLoading; } // fallow-ignore-next-line complexity export const NLELayout = memo(function NLELayout({ projectId, portrait, previewOverlay, timelineToolbar, timelineFooter, refreshKey, activeCompositionPath, onIframeRef, onCompositionChange, renderClipContent, onFileDrop, onDeleteElement, onAssetDrop, onBlockDrop, onPreviewBlockDrop, onBlockedEditAttempt, onSelectTimelineElement, onCompIdToSrcChange, timelineVisible, onToggleTimeline, onCompositionLoadingChange: onCompositionLoadingChangeParent, }: NLELayoutProps) { const { iframeRef, togglePlay, seek, onIframeLoad: baseOnIframeLoad, refreshPlayer, } = useTimelinePlayer(); // Reset timeline state when the project changes. Done in an effect, not during // render: reset() updates the player store, and updating another store/component // mid-render triggers React's "Cannot update a component while rendering a // different component" warning. The effect runs right after commit, so the new // project's first frame may briefly show prior timeline state before it clears. useEffect(() => { usePlayerStore.getState().reset(); }, [projectId]); const stageRefForDrop = useRef(null); const handleStageRef = useCallback((ref: React.RefObject) => { stageRefForDrop.current = ref.current; }, []); const { isDragOver: previewDragOver, handleDragOver: handlePreviewDragOver, handleDragLeave: handlePreviewDragLeave, handleDrop: handlePreviewDrop, } = usePreviewBlockDrop({ portrait, stageRef: stageRefForDrop as React.RefObject, onBlockDrop: onPreviewBlockDrop, }); // Lightweight reload: change iframe src instead of destroying the Player. // refreshPlayer() saves the seek position and appends a cache-busting _t // param — the Player instance stays alive so the adapter is available for // saveSeekPosition() to read the current time before the reload. const prevRefreshKeyRef = useRef(refreshKey); useEffect(() => { if (refreshKey === prevRefreshKeyRef.current) return; prevRefreshKeyRef.current = refreshKey; refreshPlayer(); }, [refreshKey, refreshPlayer]); const onIframeLoad = useCallback(() => { baseOnIframeLoad(); // Pre-load + register MotionPathPlugin once so adding a motion path in the // studio doesn't take the async plugin-load flash path on the first soft // reload (the comp may not ship the plugin until it actually uses one). ensureMotionPathPluginLoaded(iframeRef.current); onIframeRef?.(iframeRef.current); }, [baseOnIframeLoad, iframeRef, onIframeRef]); const { compositionStack, updateCompositionStack, handleNavigateComposition, handleDrillDown: drillDown, masterSeekRef, compIdToSrc, setCompIdToSrc, } = useCompositionStack({ projectId, activeCompositionPath, onCompositionChange, }); // Wrap handleDrillDown to also scan the iframe DOM for data-composition-src const iframeRef_ = iframeRef; const handleDrillDown = useCallback( (element: TimelineElement) => { if (!element.compositionSrc) return; usePlayerStore.getState().setSelectedElementId(null); // Check compIdToSrc map first; then scan iframe DOM; then fall through to drillDown const compId = element.id; let resolvedPath = compIdToSrc.get(compId); if (!resolvedPath) { try { const doc = iframeRef_.current?.contentDocument; if (doc) { const host = doc.querySelector( `[data-composition-id="${CSS.escape(compId)}"][data-composition-src]`, ); if (host) { resolvedPath = host.getAttribute("data-composition-src") || undefined; } } } catch { /* cross-origin */ } } // Delegate with the resolved compositionSrc (may be same as original) drillDown({ id: compId, compositionSrc: resolvedPath ?? element.compositionSrc, }); }, [compIdToSrc, drillDown, iframeRef_], ); // Move/resize/split come from the timeline edit context, not props — the // wrappers below intercept expanded clips and must call the *real* handlers. // (Delete is a direct prop; it stays that way.) const { onMoveElement, onResizeElement, onSplitElement } = useTimelineEditContext(); // An expanded sub-comp child reaches the normal edit handlers in its own // local coordinates: addressed by its real DOM id, with timeline time rebased // onto the sub-comp it lives in. The handlers then save + reloadPreview exactly // as they do for top-level clips — no separate live-DOM path. const toLocalElement = useCallback( (element: TimelineElement, basis: number): TimelineElement => ({ ...element, id: element.domId ?? element.id, start: element.start - basis, }), [], ); const handleMoveElement = useCallback( (element: TimelineElement, updates: Pick) => { const basis = element.expandedParentStart; if (basis === undefined) return onMoveElement?.(element, updates); trackStudioExpandedClipEdit({ action: "move" }); onMoveElement?.(toLocalElement(element, basis), { ...updates, start: Math.max(0, updates.start - basis), }); }, [onMoveElement, toLocalElement], ); const handleResizeElement = useCallback( ( element: TimelineElement, updates: Pick, ) => { const basis = element.expandedParentStart; if (basis === undefined) return onResizeElement?.(element, updates); trackStudioExpandedClipEdit({ action: "resize" }); onResizeElement?.(toLocalElement(element, basis), { ...updates, start: Math.max(0, updates.start - basis), }); }, [onResizeElement, toLocalElement], ); const handleDeleteElement = useCallback( (element: TimelineElement) => { const basis = element.expandedParentStart; if (basis === undefined) return onDeleteElement?.(element); trackStudioExpandedClipEdit({ action: "delete" }); return onDeleteElement?.(toLocalElement(element, basis)); }, [onDeleteElement, toLocalElement], ); const handleSplitElement = useCallback( (element: TimelineElement, splitTime: number) => { const basis = element.expandedParentStart; if (basis === undefined) return onSplitElement?.(element, splitTime); trackStudioExpandedClipEdit({ action: "split" }); return onSplitElement?.(toLocalElement(element, basis), Math.max(0, splitTime - basis)); }, [onSplitElement, toLocalElement], ); // Composition ID → file path map from raw index.html const compIdToSrcRef = useRef(compIdToSrc); compIdToSrcRef.current = compIdToSrc; useMountEffect(() => { fetch(`/api/projects/${projectId}/files/index.html`) .then((r) => r.json()) .then((data: { content?: string }) => { const html = data.content || ""; const map = new Map(); const re = /data-composition-id=["']([^"']+)["'][^>]*data-composition-src=["']([^"']+)["']|data-composition-src=["']([^"']+)["'][^>]*data-composition-id=["']([^"']+)["']/g; let match; while ((match = re.exec(html)) !== null) { const id = match[1] || match[4]; const src = match[2] || match[3]; if (id && src) map.set(id, src); } setCompIdToSrc(map); // Let DOM source-resolution recover a subcomposition element's source file // (the runtime drops the linkage when inlining — see getSourceFileForElement). setCompositionSourceMap(map); onCompIdToSrcChange?.(map); }) .catch(() => {}); }); // Patch elements with compositionSrc whenever elements or compIdToSrc change. // eslint-disable-next-line no-restricted-syntax useEffect(() => { if (compIdToSrc.size === 0) return; const patchElements = (elements: TimelineElement[]): TimelineElement[] | null => { const map = compIdToSrcRef.current; if (map.size === 0) return null; let patched = false; const updated = elements.map((el) => { if (el.compositionSrc) return el; const src = map.get(el.id) ?? map.get(el.id.replace(/-(host|comp|layer)$/, "")); if (src) { patched = true; return { ...el, compositionSrc: src }; } return el; }); return patched ? updated : null; }; const patched = patchElements(usePlayerStore.getState().elements); if (patched) usePlayerStore.getState().setElements(patched); let patching = false; return usePlayerStore.subscribe((state, prev) => { if (patching) return; if (state.elements === prev.elements || state.elements.length === 0) return; if (state.elements.every((el) => el.compositionSrc)) return; patching = true; const result = patchElements(state.elements); if (result) state.setElements(result); patching = false; }); }, [compIdToSrc]); // Resizable timeline height const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H); const hasLoadedOnceRef = useRef(false); const [compositionLoading, setCompositionLoadingRaw] = useState(true); const setCompositionLoading = useCallback((loading: boolean) => { if (!loading) hasLoadedOnceRef.current = true; if (loading && hasLoadedOnceRef.current) return; setCompositionLoadingRaw(loading); }, []); const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading); useEffect(() => { onCompositionLoadingChangeParent?.(compositionLoading); }, [compositionLoading, onCompositionLoadingChangeParent]); const fullscreenElement = useSyncExternalStore(subscribeFullscreen, getFullscreenElement); const isTimelineVisible = timelineVisible ?? true; const isDragging = useRef(false); const containerRef = useRef(null); const isFullscreen = fullscreenElement === containerRef.current && fullscreenElement != null; const toggleFullscreen = useCallback(() => { if (!containerRef.current) return; if (document.fullscreenElement) { void document.exitFullscreen(); } else { void containerRef.current.requestFullscreen(); } }, []); const currentLevel = compositionStack[compositionStack.length - 1]; const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined; const onIframeRefStable = useRef(onIframeRef); onIframeRefStable.current = onIframeRef; useEffect(() => { onIframeRefStable.current?.(iframeRef.current); }, [compositionStack.length, refreshKey, iframeRef]); // Resize divider handlers const handleDividerPointerDown = useCallback( (e: React.PointerEvent) => { if (timelineDisabled) return; e.preventDefault(); isDragging.current = true; (e.target as HTMLElement).setPointerCapture(e.pointerId); }, [timelineDisabled], ); const handleDividerPointerMove = useCallback( (e: React.PointerEvent) => { if (timelineDisabled) return; if (!isDragging.current || !containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const mouseY = e.clientY - rect.top; const containerH = rect.height; const newTimelineH = Math.max( MIN_TIMELINE_H, Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY), ); setTimelineH(newTimelineH); }, [timelineDisabled], ); const handleDividerPointerUp = useCallback(() => { isDragging.current = false; }, []); // Keyboard: Escape to pop composition level const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape" && compositionStack.length > 1) { updateCompositionStack((prev) => prev.slice(0, -1)); } }, // eslint-disable-next-line react-hooks/exhaustive-deps [compositionStack.length], ); // Suppress TS unused-var warning for masterSeekRef (used inside useCompositionStack) void masterSeekRef; return (
{/* Preview + player controls */}
{ const el = iframeRef.current?.parentElement ?? iframeRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; if (!inside) onSelectTimelineElement?.(null); }} onDragOver={handlePreviewDragOver} onDragLeave={handlePreviewDragLeave} onDrop={handlePreviewDrop} >
{previewDragOver && (
)}
{!isFullscreen && previewOverlay}
{!isFullscreen && compositionStack.length > 1 && ( )}
{!isFullscreen && isTimelineVisible ? ( <> {/* Resize divider */}
{/* Timeline section */}
{ if ((e.target as HTMLElement).closest("[data-clip]")) return; if (timelineDisabled) return; if (compositionStack.length > 1) { updateCompositionStack((prev) => prev.slice(0, -1)); } }} >
{timelineToolbar}
{timelineFooter &&
{timelineFooter}
} {timelineDisabled && ( ) : !isFullscreen && onToggleTimeline ? (
Timeline
) : null}
); });