// Asset-drop handlers for the timeline: drop an existing project asset at a // placement, or upload dragged-in OS files and place them sequentially. // Extracted verbatim from useTimelineEditing.ts to keep it under the studio // 600-line cap. import { useCallback, type MutableRefObject, type RefObject } from "react"; import type { TimelineElement } from "../player"; import { buildTimelineAssetId, buildTimelineAssetInsertHtml, buildTimelineFileDropPlacements, fitTimelineAssetGeometry, getTimelineAssetKind, insertTimelineAssetIntoSource, resolveTimelineAssetCompositionSize, resolveTimelineAssetSrc, } from "../utils/timelineAssetDrop"; import { generateId } from "../utils/generateId"; import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory"; import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelpers"; import { formatTimelineAttributeNumber } from "./timelineEditingHelpers"; import { readFileContent } from "./timelineTimingSync"; interface UseTimelineAssetDropOpsOptions { projectIdRef: MutableRefObject; activeCompPath: string | null; timelineElements: TimelineElement[]; showToast: (message: string, tone?: "error" | "info") => void; writeProjectFile: (path: string, content: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; domEditSaveTimestampRef: MutableRefObject; reloadPreview: () => void; uploadProjectFiles: (files: Iterable, dir?: string) => Promise; isRecordingRef?: RefObject; forceReloadSdkSession?: () => void; } export function useTimelineAssetDropOps({ projectIdRef, activeCompPath, timelineElements, showToast, writeProjectFile, recordEdit, domEditSaveTimestampRef, reloadPreview, uploadProjectFiles, isRecordingRef, forceReloadSdkSession, }: UseTimelineAssetDropOpsOptions) { // fallow-ignore-next-line complexity const handleTimelineAssetDrop = useCallback( // fallow-ignore-next-line complexity async ( assetPath: string, placement: Pick, durationOverride?: number, ) => { if (isRecordingRef?.current) { showToast("Cannot edit timeline while recording", "error"); return; } const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); const kind = getTimelineAssetKind(assetPath); if (!kind) { showToast("Only image, video, and audio assets can be dropped onto the timeline."); return; } const targetPath = activeCompPath || "index.html"; try { const originalContent = await readFileContent(pid, targetPath); const normalizedStart = Number(formatTimelineAttributeNumber(placement.start)); const duration = Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0 ? durationOverride : await resolveDroppedAssetDuration(pid, assetPath, kind); const normalizedDuration = Number(formatTimelineAttributeNumber(duration)); const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent)); const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath); const resolvedTargetPath = targetPath || "index.html"; const relevantElements = timelineElements.filter( (te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath, ); const newElementZIndex = Math.max(1, relevantElements.length + 1); const patchedContent = insertTimelineAssetIntoSource( originalContent, buildTimelineAssetInsertHtml({ id: newId, hfId: `hf-${generateId()}`, assetPath: resolvedAssetSrc, kind, start: normalizedStart, duration: normalizedDuration, track: placement.track, zIndex: newElementZIndex, geometry: fitTimelineAssetGeometry( null, resolveTimelineAssetCompositionSize(originalContent), ), }), ); domEditSaveTimestampRef.current = Date.now(); await saveProjectFilesWithHistory({ projectId: pid, label: "Add timeline asset", kind: "timeline", files: { [targetPath]: patchedContent }, readFile: async () => originalContent, writeFile: writeProjectFile, recordEdit, }); forceReloadSdkSession?.(); reloadPreview(); } catch (error) { const message = error instanceof Error ? error.message : "Failed to drop asset onto timeline"; showToast(message); } }, [ projectIdRef, activeCompPath, recordEdit, showToast, timelineElements, writeProjectFile, domEditSaveTimestampRef, reloadPreview, isRecordingRef, forceReloadSdkSession, ], ); // fallow-ignore-next-line complexity const handleTimelineFileDrop = useCallback( // fallow-ignore-next-line complexity async (files: File[], placement?: Pick) => { if (isRecordingRef?.current) { showToast("Cannot edit timeline while recording", "error"); return; } const pid = projectIdRef.current; if (!pid) return; const uploaded = await uploadProjectFiles(files); if (uploaded.length === 0) return; const durations: number[] = []; for (const assetPath of uploaded) { const kind = getTimelineAssetKind(assetPath); const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0; durations.push(Number(formatTimelineAttributeNumber(duration))); } const placements = buildTimelineFileDropPlacements( placement ?? { start: 0, track: 0 }, durations, ); for (const [index, assetPath] of uploaded.entries()) { await handleTimelineAssetDrop( assetPath, placements[index] ?? placements[0], durations[index], ); } }, [handleTimelineAssetDrop, projectIdRef, uploadProjectFiles, isRecordingRef, showToast], ); return { handleTimelineAssetDrop, handleTimelineFileDrop }; }