import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react"; import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar"; import { useRenderQueue } from "./components/renders/useRenderQueue"; import { usePlayerStore } from "./player"; import { StudioOverlays } from "./components/StudioOverlays"; import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner"; import { useCaptionStore } from "./captions/store"; import { useCaptionSync } from "./captions/hooks/useCaptionSync"; import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory"; import { usePanelLayout } from "./hooks/usePanelLayout"; import { useFileManager } from "./hooks/useFileManager"; import { usePreviewPersistence } from "./hooks/usePreviewPersistence"; import { useTimelineEditing } from "./hooks/useTimelineEditing"; import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab"; import { useDomEditSession } from "./hooks/useDomEditSession"; import { useSdkSession } from "./hooks/useSdkSession"; import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync"; import { useBlockHandlers } from "./hooks/useBlockHandlers"; import { useAppHotkeys } from "./hooks/useAppHotkeys"; import { useClipboard } from "./hooks/useClipboard"; import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences"; import { selectedKeyframePercentagesForElement } from "./utils/keyframeSelection"; import { useCaptionDetection } from "./hooks/useCaptionDetection"; import { useRenderClipContent } from "./hooks/useRenderClipContent"; import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture"; import { useFrameCapture } from "./hooks/useFrameCapture"; import { useLintModal } from "./hooks/useLintModal"; import { useCompositionDimensions } from "./hooks/useCompositionDimensions"; import { useToast } from "./hooks/useToast"; import { useStudioUrlState } from "./hooks/useStudioUrlState"; import { buildStudioContextValue, useDragOverlay, useInspectorState, } from "./hooks/useStudioContextValue"; import type { DomEditSelection } from "./components/editor/domEditing"; import { StudioHeader } from "./components/StudioHeader"; import { useGestureCommit } from "./hooks/useGestureCommit"; import { STUDIO_KEYFRAMES_ENABLED, STUDIO_STORYBOARD_ENABLED, } from "./components/editor/manualEditingAvailability"; import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay"; import { StudioLeftSidebar } from "./components/StudioLeftSidebar"; import { StudioPreviewArea } from "./components/StudioPreviewArea"; import { StudioRightPanel } from "./components/StudioRightPanel"; import { TimelineToolbar } from "./components/TimelineToolbar"; import { StudioPlaybackProvider, StudioShellProvider } from "./contexts/StudioContext"; import { PanelLayoutProvider } from "./contexts/PanelLayoutContext"; import { ViewModeProvider, useViewModeState } from "./contexts/ViewModeContext"; import { StoryboardView } from "./components/storyboard/StoryboardView"; import { FileManagerProvider } from "./contexts/FileManagerContext"; import { DomEditProvider } from "./contexts/DomEditContext"; import { StudioSplash } from "./components/StudioSplash"; import { useServerConnection } from "./hooks/useServerConnection"; import { normalizeStudioCompositionPath, readStudioUrlStateFromWindow, } from "./utils/studioUrlState"; import { trackStudioSessionStart } from "./telemetry/events"; import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config"; type CanvasRect = { left: number; top: number; width: number; height: number }; // fallow-ignore-next-line complexity export function StudioApp() { const { projectId, resolving, waitingForServer } = useServerConnection(); const initialUrlStateRef = useRef(readStudioUrlStateFromWindow()); const viewModeValue = useViewModeState(STUDIO_STORYBOARD_ENABLED); // sessionStorage-backed: fires once per tab, survives HMR remounts useEffect(() => { if (resolving || waitingForServer) return; if (hasFiredSessionStart()) return; markSessionStartFired(); trackStudioSessionStart({ has_project: projectId != null }); }, [projectId, resolving, waitingForServer]); const [activeCompPath, setActiveCompPath] = useState(null); const [activeCompPathHydrated, setActiveCompPathHydrated] = useState( () => initialUrlStateRef.current.activeCompPath == null, ); const [compIdToSrc, setCompIdToSrc] = useState>(new Map()); const [previewIframe, setPreviewIframe] = useState(null); const [compositionLoading, setCompositionLoading] = useState(true); const [refreshKey, setRefreshKey] = useState(0); const [, setPreviewDocumentVersion] = useState(0); const [blockPreview, setBlockPreview] = useState(null); const previewIframeRef = useRef(null); const activeCompPathRef = useRef(activeCompPath); activeCompPathRef.current = activeCompPath; const leftSidebarRef = useRef(null); const renderQueue = useRenderQueue(projectId); const captionEditMode = useCaptionStore((s) => s.isEditMode); const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0); const captionSync = useCaptionSync(projectId); const timelineElements = usePlayerStore((s) => s.elements); const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId); const timelineDuration = usePlayerStore((s) => s.duration); const isPlaying = usePlayerStore((s) => s.isPlaying); const isMasterView = !activeCompPath || activeCompPath === "index.html"; const activePreviewUrl = activeCompPath ? `/api/projects/${projectId}/preview/comp/${activeCompPath}` : null; const effectiveTimelineDuration = useMemo(() => { const maxEnd = timelineElements.length > 0 ? Math.max(...timelineElements.map((el) => el.start + el.duration)) : 0; return Math.max(timelineDuration, maxEnd); }, [timelineDuration, timelineElements]); const refreshTimersRef = useRef([]); const refreshPreviewDocumentVersion = useCallback(() => { for (const id of refreshTimersRef.current) clearTimeout(id); refreshTimersRef.current = []; setPreviewDocumentVersion((v) => v + 1); refreshTimersRef.current.push( window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80), window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300), ); }, []); useEffect( () => () => { for (const id of refreshTimersRef.current) clearTimeout(id); }, [], ); const [timelineVisible, setTimelineVisible] = useState( () => initialUrlStateRef.current.timelineVisible ?? readStudioUiPreferences().timelineVisible ?? true, ); const toggleTimelineVisibility = useCallback(() => { setTimelineVisible((v) => { writeStudioUiPreferences({ timelineVisible: !v }); return !v; }); }, []); const { appToast, showToast, dismissToast } = useToast(); const panelLayout = usePanelLayout({ rightCollapsed: initialUrlStateRef.current.rightCollapsed, rightPanelTab: initialUrlStateRef.current.rightPanelTab, }); const editHistory = usePersistentEditHistory({ projectId }); const domEditSaveTimestampRef = useRef(0); const pendingTimelineEditPathRef = useRef(new Set()); const isGestureRecordingRef = useRef(false); const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []); const fileManager = useFileManager({ projectId, showToast, recordEdit: editHistory.recordEdit, domEditSaveTimestampRef, setRefreshKey, }); const sdkHandle = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef); useEffect(() => { if (activeCompPathHydrated) return; if (!fileManager.fileTreeLoaded) return; const nextCompPath = normalizeStudioCompositionPath( initialUrlStateRef.current.activeCompPath, fileManager.fileTree, ); setActiveCompPath((current) => (current === nextCompPath ? current : nextCompPath)); setActiveCompPathHydrated(true); }, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]); const previewPersistence = usePreviewPersistence({ projectId, showToast, readOptionalProjectFile: fileManager.readOptionalProjectFile, writeProjectFile: fileManager.writeProjectFile, recordEdit: editHistory.recordEdit, previewIframeRef, activeCompPathRef, domEditSaveTimestampRef, reloadPreview: () => setRefreshKey((k) => k + 1), pendingTimelineEditPathRef, }); const timelineEditing = useTimelineEditing({ projectId, activeCompPath, timelineElements, showToast, writeProjectFile: fileManager.writeProjectFile, recordEdit: editHistory.recordEdit, domEditSaveTimestampRef, reloadPreview, previewIframeRef, pendingTimelineEditPathRef, uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, sdkSession: sdkHandle.session, forceReloadSdkSession: sdkHandle.forceReload, }); const { activeBlockParams, setActiveBlockParams, handleAddBlock, handleTimelineBlockDrop, handlePreviewBlockDrop, } = useBlockHandlers({ projectId, blockCtxDeps: { activeCompPath, timelineElements, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, recordEdit: editHistory.recordEdit, refreshFileTree: fileManager.refreshFileTree, reloadPreview, showToast, }, previewIframeRef, setRightCollapsed: panelLayout.setRightCollapsed, setRightPanelTab: panelLayout.setRightPanelTab, }); const clearDomSelectionRef = useRef<() => void>(() => {}); const domEditSelectionBridgeRef = useRef(null); const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise>( async () => {}, ); const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s); const resetKeyframesRef = useRef<() => boolean>(() => false); const deleteSelectedKeyframesRef = useRef<() => void>(() => {}); const invalidateGsapCacheRef = useRef<() => void>(() => {}); const { handleCopy, handlePaste, handleCut } = useClipboard({ projectId, activeCompPath, domEditSelectionRef: domEditSelectionBridgeRef, showToast, writeProjectFile: fileManager.writeProjectFile, recordEdit: editHistory.recordEdit, domEditSaveTimestampRef, reloadPreview, handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete, handleDomEditElementDelete: domEditDeleteBridge, previewIframeRef, }); const appHotkeys = useAppHotkeys({ toggleTimelineVisibility, handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete, handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit, handleDomEditElementDelete: domEditDeleteBridge, domEditSelectionRef: domEditSelectionBridgeRef, clearDomSelectionRef, editHistory, readOptionalProjectFile: fileManager.readOptionalProjectFile, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, domEditSaveTimestampRef, showToast, syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply, waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves, leftSidebarRef, handleCopy, handlePaste, handleCut, onResetKeyframes: () => resetKeyframesRef.current(), onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(), onAfterUndoRedo: () => invalidateGsapCacheRef.current(), activeCompPath, forceReloadSdkSession: sdkHandle.forceReload, onToggleRecording: STUDIO_KEYFRAMES_ENABLED ? () => handleToggleRecordingRef.current() : undefined, }); const sidebarTabRef = useRef({ select: (t: SidebarTab) => leftSidebarRef.current?.selectTab(t), get: () => leftSidebarRef.current?.getTab() ?? "compositions", }); const domEditSession = useDomEditSession({ projectId, activeCompPath, isMasterView, compIdToSrc, captionEditMode, compositionLoading, previewIframeRef, timelineElements, setSelectedTimelineElementId, setRightCollapsed: panelLayout.setRightCollapsed, setRightPanelTab: panelLayout.setRightPanelTab, showToast, refreshPreviewDocumentVersion, queueDomEditSave: previewPersistence.queueDomEditSave, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, updateEditingFileContent: fileManager.updateEditingFileContent, domEditSaveTimestampRef, editHistory: { recordEdit: editHistory.recordEdit }, fileTree: fileManager.fileTree, importedFontAssetsRef: fileManager.importedFontAssetsRef, projectDir: fileManager.projectDir, projectIdRef: fileManager.projectIdRef, previewIframe, refreshKey, rightPanelTab: panelLayout.rightPanelTab, applyStudioManualEditsToPreviewRef: previewPersistence.applyStudioManualEditsToPreviewRef, syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey, reloadPreview, setRefreshKey, openSourceForSelection: fileManager.openSourceForSelection, selectSidebarTab: sidebarTabRef.current.select, getSidebarTab: sidebarTabRef.current.get, sdkSession: sdkHandle.session, forceReloadSdkSession: sdkHandle.forceReload, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete; resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes; invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache; deleteSelectedKeyframesRef.current = () => { const { selectedKeyframes, selectedElementId } = usePlayerStore.getState(); const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes); if (!a) return; // Only the active element's keyframes; a stale cross-element selection must not delete here. for (const p of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) { domEditSession.handleGsapRemoveKeyframe(a.id, p); } }; useSdkSelectionSync( sdkHandle.session, domEditSession.domEditSelection, domEditSession.domEditGroupSelections, ); useCaptionDetection({ projectId, activeCompPath, compIdToSrc, captionEditMode, captionHasSelection, previewIframeRef, captionSync, setRightCollapsed: panelLayout.setRightCollapsed, }); const renderClipContent = useRenderClipContent({ projectIdRef: fileManager.projectIdRef, compIdToSrc, activePreviewUrl, effectiveTimelineDuration, }); const compositionDimensions = useCompositionDimensions(); const { lintModal, linting, handleLint, closeLintModal, findingsByFile } = useLintModal( projectId, refreshKey, ); const frameCapture = useFrameCapture({ projectId, activeCompPath, showToast, waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves, }); const { consoleErrors, setConsoleErrors, resetErrors: resetConsoleErrors, } = useConsoleErrorCapture(previewIframe); const dragOverlay = useDragOverlay(fileManager.handleImportFiles); // Gesture recording const handleToggleRecordingRef = useRef<() => void>(() => {}); const domEditSessionRef = useRef(domEditSession); domEditSessionRef.current = domEditSession; const { gestureState, gestureRecording, handleToggleRecording } = useGestureCommit({ domEditSessionRef, previewIframeRef, showToast, isGestureRecordingRef, }); handleToggleRecordingRef.current = handleToggleRecording; const canvasRectRef = useRef(null); useLayoutEffect(() => { if (gestureState !== "recording" || !previewIframe) { canvasRectRef.current = null; return; } const r = previewIframe.getBoundingClientRect(); canvasRectRef.current = { left: r.left, top: r.top, width: r.width, height: r.height }; }, [gestureState, previewIframe]); const handlePreviewIframeRef = useCallback( (iframe: HTMLIFrameElement | null) => { previewIframeRef.current = iframe; setPreviewIframe(iframe); appHotkeys.syncPreviewTimelineHotkey(iframe); appHotkeys.syncPreviewHistoryHotkey(iframe); resetConsoleErrors(); refreshPreviewDocumentVersion(); }, [appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion], ); const handleSelectComposition = useCallback( (comp: string) => { setActiveCompPath(comp.endsWith(".html") ? comp : null); fileManager.setEditingFile({ path: comp, content: null }); fetch(`/api/projects/${projectId}/files/${comp}`) .then((r) => r.json()) .then((data) => fileManager.setEditingFile({ path: comp, content: data.content })) .catch(() => {}); }, [projectId, fileManager], ); const { designPanelActive, inspectorPanelActive, inspectorButtonActive, shouldShowSelectedDomBounds, } = useInspectorState( panelLayout.rightPanelTab, panelLayout.rightInspectorPanes, panelLayout.rightCollapsed, isPlaying, gestureState === "recording", ); useStudioUrlState({ projectId, activeCompPath, duration: effectiveTimelineDuration, isPlaying, compositionLoading, refreshKey, previewIframeRef, rightPanelTab: panelLayout.rightPanelTab, rightCollapsed: panelLayout.rightCollapsed, timelineVisible, activeCompPathHydrated, domEditSelection: domEditSession.domEditSelection, buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget, applyDomSelection: domEditSession.applyDomSelection, setRightPanelTab: panelLayout.setRightPanelTab, initialState: initialUrlStateRef.current, }); const studioCtxValue = buildStudioContextValue({ projectId: projectId!, activeCompPath, setActiveCompPath, showToast, previewIframeRef, captionEditMode, compositionLoading, refreshKey, setRefreshKey, timelineElements, isPlaying, editHistory, handleUndo: appHotkeys.handleUndo, handleRedo: appHotkeys.handleRedo, renderQueue, compositionDimensions, waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves, handlePreviewIframeRef, refreshPreviewDocumentVersion, timelineVisible, toggleTimelineVisibility, }); const timelineToolbar = useMemo( () => ( ), [toggleTimelineVisibility, domEditSession, timelineEditing.handleTimelineElementSplit], ); if (resolving || waitingForServer || !projectId) return ; return (
void renderQueue.startRender(undefined)} /> {previewPersistence.domEditSaveQueuePaused && ( )} {viewModeValue.viewMode === "storyboard" && ( )} {/* Timeline stage stays mounted (just hidden) in storyboard mode, so preview/player/gesture/render state survives the toggle. */}
) : undefined } /> {!panelLayout.rightCollapsed && ( { setActiveBlockParams(null); panelLayout.setRightPanelTab("design"); }} recordingState={gestureState} recordingDuration={gestureRecording.recordingDuration} onToggleRecording={ STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined } sdkSession={sdkHandle.session} reloadPreview={reloadPreview} domEditSaveTimestampRef={domEditSaveTimestampRef} recordEdit={editHistory.recordEdit} /> )}
setConsoleErrors(null)} domEditSession={domEditSession} activeCompPath={activeCompPath} dragOverlayActive={dragOverlay.active} appToast={appToast} dismissToast={dismissToast} />
); }