import { useCallback, useRef } from "react"; import { editLog } from "../utils/editDebugLog"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers"; import { trackStudioRazorSplit } from "../telemetry/events"; import { canSplitElementAt, selectSplittableElements, buildPatchTarget, readFileContent, } from "../utils/timelineElementSplit"; import type { RecordEditInput } from "./timelineEditingHelpers"; interface UseRazorSplitOptions { projectId: string | null; activeCompPath: string | null; showToast: (message: string, tone?: "error" | "info") => void; writeProjectFile: (path: string, content: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; domEditSaveTimestampRef: React.MutableRefObject; reloadPreview: () => void; isRecordingRef?: React.RefObject; } function generateSplitId(existingIds: string[], baseId: string): string { let newId = `${baseId}-split`; let suffix = 2; while (existingIds.includes(newId)) { newId = `${baseId}-split-${suffix++}`; } return newId; } async function splitHtmlElement( projectId: string, targetPath: string, patchTarget: NonNullable>, splitTime: number, newId: string, elementStart: number, elementDuration: number, ): Promise<{ ok: boolean; changed?: boolean; content?: string }> { const response = await fetch( `/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ target: patchTarget, splitTime, newId, elementStart, elementDuration, }), }, ); if (!response.ok) throw new Error("Split request failed"); return (await response.json()) as { ok: boolean; changed?: boolean; content?: string }; } async function splitGsapAnimations( projectId: string, targetPath: string, originalId: string, newId: string, splitTime: number, elementStart: number, elementDuration: number, ): Promise<{ content: string | null; skippedSelectors?: string[] }> { const response = await fetch( `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "split-animations", originalId, newId, splitTime, elementStart, elementDuration, }), }, ); if (!response.ok) { const errorBody = (await response.json().catch(() => null)) as { error?: string } | null; if (errorBody?.error === "no GSAP script found in file") { return { content: null }; } throw new Error(errorBody?.error ?? `GSAP animation split failed (${response.status})`); } const data = (await response.json()) as { ok?: boolean; after?: string; skippedSelectors?: string[]; }; return { content: data.ok && data.after ? data.after : null, skippedSelectors: data.skippedSelectors, }; } // fallow-ignore-next-line complexity async function executeSplit( pid: string, element: TimelineElement, splitTime: number, activeCompPath: string | null, writeProjectFile: (path: string, content: string) => Promise, ): Promise<{ targetPath: string; originalContent: string; patchedContent: string; changed: boolean; skippedSelectors?: string[]; }> { const patchTarget = buildPatchTarget(element); if (!patchTarget) throw new Error("Clip is missing a patchable target."); const targetPath = element.sourceFile || activeCompPath || "index.html"; editLog("razor-split", { id: element.domId ?? element.id, splitTime, file: targetPath }); const originalContent = await readFileContent(pid, targetPath); const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip"); const splitResult = await splitHtmlElement( pid, targetPath, patchTarget, splitTime, newId, element.start, element.duration, ); if (!splitResult.ok) throw new Error("Failed to split clip."); editLog("razor-split:done", { changed: splitResult.changed, newId }); if (!splitResult.changed) { return { targetPath, originalContent, patchedContent: originalContent, changed: false }; } let patchedContent = typeof splitResult.content === "string" ? splitResult.content : originalContent; let skippedSelectors: string[] | undefined; if (element.domId) { try { const gsapResult = await splitGsapAnimations( pid, targetPath, element.domId, newId, splitTime, element.start, element.duration, ); if (gsapResult.content) patchedContent = gsapResult.content; if (gsapResult.skippedSelectors?.length) skippedSelectors = gsapResult.skippedSelectors; } catch (gsapError) { // GSAP mutation failed — the HTML split already wrote to disk. // Restore the original content to avoid a corrupt half-split state. await writeProjectFile(targetPath, originalContent); throw gsapError; } } return { targetPath, originalContent, patchedContent, changed: true, skippedSelectors }; } export function useRazorSplit({ projectId, activeCompPath, showToast, writeProjectFile, recordEdit, domEditSaveTimestampRef, reloadPreview, isRecordingRef, }: UseRazorSplitOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; const handleRazorSplit = useCallback( // fallow-ignore-next-line complexity async (element: TimelineElement, splitTime: number) => { if (isRecordingRef?.current) { showToast("Cannot edit timeline while recording", "error"); return; } const pid = projectIdRef.current; if (!pid || !canSplitElementAt(element, splitTime)) return; try { const { targetPath, originalContent, patchedContent, changed, skippedSelectors } = await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile); if (!changed) { showToast("Failed to split clip — playhead may be outside the clip", "error"); return; } domEditSaveTimestampRef.current = Date.now(); await saveProjectFilesWithHistory({ projectId: pid, label: "Split timeline clip", kind: "timeline", files: { [targetPath]: patchedContent }, readFile: async () => originalContent, writeFile: writeProjectFile, recordEdit, }); reloadPreview(); trackStudioRazorSplit({ mode: "single", count: 1 }); showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info"); if (skippedSelectors?.length) { showToast( `Some animations use non-ID selectors (${skippedSelectors.join(", ")}) and were not retargeted`, "info", ); } } catch (error) { const message = error instanceof Error ? error.message : "Failed to split timeline clip"; showToast(message, "error"); } }, [ activeCompPath, recordEdit, showToast, writeProjectFile, domEditSaveTimestampRef, reloadPreview, isRecordingRef, ], ); // fallow-ignore-next-line complexity const handleRazorSplitAll = useCallback( async (splitTime: number) => { if (isRecordingRef?.current) { showToast("Cannot edit timeline while recording", "error"); return; } const pid = projectIdRef.current; if (!pid) return; const { elements } = usePlayerStore.getState(); const splittable = selectSplittableElements(elements, splitTime); if (splittable.length === 0) return; try { const originals = new Map(); for (const el of splittable) { const path = el.sourceFile || activeCompPath || "index.html"; if (!originals.has(path)) { originals.set(path, await readFileContent(pid, path)); } } let splitCount = 0; const finalContent = new Map(); for (const element of splittable) { const result = await executeSplit( pid, element, splitTime, activeCompPath, writeProjectFile, ); if (result.changed) { finalContent.set(result.targetPath, result.patchedContent); await writeProjectFile(result.targetPath, result.patchedContent); splitCount++; } } if (splitCount === 0) return; domEditSaveTimestampRef.current = Date.now(); await recordEdit({ label: `Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, kind: "timeline", files: Object.fromEntries( [...finalContent].map(([path, after]) => [ path, { before: originals.get(path) ?? "", after }, ]), ), }); reloadPreview(); trackStudioRazorSplit({ mode: "all", count: splitCount }); showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info"); } catch (error) { const message = error instanceof Error ? error.message : "Failed to split clips"; showToast(message, "error"); } }, [ activeCompPath, recordEdit, showToast, writeProjectFile, domEditSaveTimestampRef, reloadPreview, isRecordingRef, ], ); return { handleRazorSplit, handleRazorSplitAll }; }