import { useState, useMemo, type ReactNode } from "react"; import { NLELayout } from "./nle/NLELayout"; import { CaptionOverlay } from "../captions/components/CaptionOverlay"; import { CaptionTimeline } from "../captions/components/CaptionTimeline"; import { DomEditOverlay } from "./editor/DomEditOverlay"; import { MotionPathOverlay } from "./editor/MotionPathOverlay"; import { useCompositionDimensions } from "../hooks/useCompositionDimensions"; import { SnapToolbar } from "./editor/SnapToolbar"; import { StudioFeedbackBar } from "./StudioFeedbackBar"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player/store/playerStore"; import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing"; import { STUDIO_INSPECTOR_PANELS_ENABLED, STUDIO_KEYFRAMES_ENABLED, STUDIO_PREVIEW_MANUAL_EDITING_ENABLED, STUDIO_PREVIEW_SELECTION_ENABLED, } from "./editor/manualEditingAvailability"; import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext"; import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext"; import { TimelineEditProvider } from "../contexts/TimelineEditContext"; import type { BlockPreviewInfo } from "./sidebar/BlocksTab"; import { readStudioUiPreferences } from "../utils/studioUiPreferences"; import { fetchParsedAnimations } from "../hooks/useGsapTweenCache"; import { pickKeyframeTween, computeKeyframeMovePlan } from "./editor/keyframeMove"; import type { GestureRecordingState } from "./editor/GestureRecordControl"; export interface StudioPreviewAreaProps { timelineToolbar: ReactNode; renderClipContent: ( element: TimelineElement, style: { clip: string; label: string }, ) => ReactNode; // Timeline editing handleTimelineElementDelete: (element: TimelineElement) => Promise | void; handleTimelineAssetDrop: ( assetPath: string, placement: Pick, ) => Promise | void; handleTimelineBlockDrop?: ( blockName: string, placement: Pick, ) => Promise | void; handlePreviewBlockDrop?: ( blockName: string, position: { left: number; top: number }, ) => Promise | void; handleTimelineFileDrop: ( files: File[], placement?: Pick, ) => Promise | void; handleTimelineElementMove: ( element: TimelineElement, updates: Pick, ) => Promise | void; handleTimelineElementResize: ( element: TimelineElement, updates: Pick, ) => Promise | void; handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise | void; handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise | void; handleRazorSplitAll: (splitTime: number) => Promise | void; setCompIdToSrc: (map: Map) => void; setCompositionLoading: (loading: boolean) => void; shouldShowSelectedDomBounds: boolean; blockPreview?: BlockPreviewInfo | null; isGestureRecording?: boolean; recordingState?: GestureRecordingState; onToggleRecording?: () => void; gestureOverlay?: ReactNode; } // fallow-ignore-next-line complexity export function StudioPreviewArea({ timelineToolbar, renderClipContent, handleTimelineElementDelete, handleTimelineAssetDrop, handleTimelineBlockDrop, handlePreviewBlockDrop, handleTimelineFileDrop, handleTimelineElementMove, handleTimelineElementResize, handleBlockedTimelineEdit, handleTimelineElementSplit, handleRazorSplit, handleRazorSplitAll, setCompIdToSrc, setCompositionLoading, shouldShowSelectedDomBounds, isGestureRecording, recordingState, onToggleRecording, blockPreview, gestureOverlay, }: StudioPreviewAreaProps) { const { projectId, activeCompPath, setActiveCompPath, previewIframeRef, handlePreviewIframeRef, timelineVisible, toggleTimelineVisibility, } = useStudioShellContext(); const { refreshKey, captionEditMode, compositionLoading, isPlaying, refreshPreviewDocumentVersion, } = useStudioPlaybackContext(); const compositionDimensions = useCompositionDimensions(); const { domEditHoverSelection, domEditSelection, domEditGroupSelections, selectedGsapAnimations, } = useDomEditSelectionContext(); const { handleTimelineElementSelect, handlePreviewCanvasMouseDown, handlePreviewCanvasPointerMove, handlePreviewCanvasPointerLeave, applyDomSelection, handleBlockedDomMove, handleDomManualDragStart, handleDomPathOffsetCommit, handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, handleGsapRemoveKeyframe, handleGsapUpdateMeta, handleGsapAddKeyframe, handleGsapConvertToKeyframes, handleGsapDeleteAllForElement, buildDomSelectionForTimelineElement, applyMarqueeSelection, } = useDomEditActionsContext(); // fallow-ignore-next-line complexity const [snapPrefs, setSnapPrefs] = useState(() => { const p = readStudioUiPreferences(); return { snapEnabled: p.snapEnabled ?? true, gridVisible: p.gridVisible ?? false, gridSpacing: p.gridSpacing ?? 50, snapToGrid: p.snapToGrid ?? false, }; }); // fallow-ignore-next-line complexity const timelineEditCallbacks = useMemo( () => ({ onMoveElement: handleTimelineElementMove, onResizeElement: handleTimelineElementResize, onBlockedEditAttempt: handleBlockedTimelineEdit, onSplitElement: handleTimelineElementSplit, onRazorSplit: handleRazorSplit, onRazorSplitAll: handleRazorSplitAll, onDeleteAllKeyframes: (elId: string) => { const rawId = elId.includes("#") ? (elId.split("#").pop() ?? elId) : elId; handleGsapDeleteAllForElement(`#${rawId}`); }, // fallow-ignore-next-line complexity onDeleteKeyframe: (_elId: string, pct: number) => { const cacheKey = domEditSelection?.id ?? ""; const cached = usePlayerStore.getState().keyframeCache.get(cacheKey); const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2); const group = kf?.propertyGroup; const anim = (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ?? selectedGsapAnimations.find((a) => a.keyframes); if (!anim) return; handleGsapRemoveKeyframe(anim.id, kf?.tweenPercentage ?? pct); }, onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => { for (const anim of selectedGsapAnimations) { if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease }); } }, // fallow-ignore-next-line complexity onMoveKeyframe: async (_el: TimelineElement, oldPct: number, newPct: number) => { // Resolve the dragged element's selection + parsed animations on demand // (both awaited and cached) rather than relying on the async DOM-edit // session being loaded for this element — that coupling made the commit // intermittently no-op (revert) when dragging before the session caught up. if (!projectId) return; const sourceFile = _el.sourceFile || activeCompPath || "index.html"; const [selection, parsed] = await Promise.all([ buildDomSelectionForTimelineElement(_el), fetchParsedAnimations(projectId, sourceFile), ]); if (!selection || !parsed) return; const cached = usePlayerStore.getState().keyframeCache.get(_el.key ?? _el.id); const cachedKf = cached?.keyframes.find((k) => Math.abs(k.percentage - oldPct) < 0.2); const origAbsTime = _el.start + (oldPct / 100) * _el.duration; const anim = pickKeyframeTween( parsed.animations, _el, origAbsTime, cachedKf?.propertyGroup, ); if (!anim) return; const plan = computeKeyframeMovePlan( anim, cachedKf?.tweenPercentage ?? oldPct, _el, newPct, ); if (plan.meta) handleGsapUpdateMeta(anim.id, plan.meta, selection); for (const pct of plan.removes) handleGsapRemoveKeyframe(anim.id, pct, selection); for (const add of plan.adds) { for (const [prop, val] of Object.entries(add.properties)) { handleGsapAddKeyframe(anim.id, add.pct, prop, val, selection); } } }, // fallow-ignore-next-line complexity onToggleKeyframeAtPlayhead: (el: TimelineElement) => { const currentTime = usePlayerStore.getState().currentTime; const pct = el.duration > 0 ? Math.max(0, Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100))) : 0; const anim = selectedGsapAnimations.find((a) => a.keyframes); if (anim?.keyframes) { const existing = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1); if (existing) { handleGsapRemoveKeyframe(anim.id, existing.percentage); } else { handleGsapAddKeyframe(anim.id, pct, "x", 0); } } else { const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes); if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id); } }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ handleTimelineElementMove, handleTimelineElementResize, handleBlockedTimelineEdit, handleTimelineElementSplit, handleRazorSplit, handleRazorSplitAll, handleGsapDeleteAllForElement, domEditSelection?.id, selectedGsapAnimations, handleGsapRemoveKeyframe, handleGsapUpdateMeta, handleGsapAddKeyframe, handleGsapConvertToKeyframes, buildDomSelectionForTimelineElement, projectId, activeCompPath, ], ); return (
{ // Sync activeCompPath when user drills down via timeline double-click // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync. // Guard against no-op updates to prevent circular refresh cascades // between activeCompPath → compositionStack → onCompositionChange. if (compPath !== activeCompPath) { setActiveCompPath(compPath); refreshPreviewDocumentVersion(); } }} onIframeRef={handlePreviewIframeRef} previewOverlay={ blockPreview ? (
{blockPreview.videoUrl ? (
) : captionEditMode ? ( ) : STUDIO_INSPECTOR_PANELS_ENABLED ? ( <> {STUDIO_KEYFRAMES_ENABLED && ( )} {gestureOverlay} ) : null } timelineFooter={ captionEditMode ? (
Captions
) : undefined } timelineVisible={timelineVisible} onToggleTimeline={toggleTimelineVisibility} />
); }