feat(studio): razor/blade tool UI for timeline clip splitting (#1331)

Wire the razor tool into Studio's timeline UI:

- B enters razor mode (crosshair cursor + red vertical guide line)
- Click any clip to split at the click position
- Shift+click splits all clips across every track at that time
- V or Escape exits razor mode
- Toolbar shows selection arrow / scissors toggle

Add useRazorSplit hook for split orchestration (HTML + GSAP mutation).
Add activeTool state to playerStore. Add preview reload after timeline
move/resize operations so the composition re-renders with updated timing.
This commit is contained in:
Miguel Ángel
2026-06-10 23:56:09 -04:00
committed by GitHub
parent 45d4a71ed0
commit ef18613975
12 changed files with 516 additions and 188 deletions
+48 -1
View File
@@ -6,6 +6,8 @@ import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
import { canSplitElement } from "../utils/timelineElementSplit";
import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
/** Safely resolves contentWindow for a potentially cross-origin iframe. */
function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
@@ -327,7 +329,7 @@ export function useAppHotkeys({
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
if (
element &&
["video", "audio", "img"].includes(element.tag) &&
canSplitElement(element) &&
currentTime > element.start &&
currentTime < element.start + element.duration
) {
@@ -338,6 +340,51 @@ export function useAppHotkeys({
}
}
// B — toggle razor tool
if (
STUDIO_RAZOR_TOOL_ENABLED &&
event.key.toLowerCase() === "b" &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
!isEditableTarget(event.target)
) {
event.preventDefault();
const { activeTool, setActiveTool } = usePlayerStore.getState();
setActiveTool(activeTool === "razor" ? "select" : "razor");
return;
}
// V — return to selection tool
if (
event.key.toLowerCase() === "v" &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
!isEditableTarget(event.target)
) {
event.preventDefault();
usePlayerStore.getState().setActiveTool("select");
return;
}
// Escape — exit razor mode (only when no selection to deselect first)
if (event.key === "Escape" && !isEditableTarget(event.target)) {
const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
usePlayerStore.getState();
if (activeTool === "razor") {
if (selectedElementId) {
setSelectedElementId(null);
} else {
setActiveTool("select");
}
event.preventDefault();
return;
}
}
// Delete / Backspace — remove selected keyframes > reset keyframes > remove element
if (
(event.key === "Delete" || event.key === "Backspace") &&
+303
View File
@@ -0,0 +1,303 @@
import { useCallback, useRef } from "react";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers";
import {
canSplitElement,
buildPatchTarget,
readFileContent,
SPLIT_BOUNDARY_EPSILON_S,
} from "../utils/timelineElementSplit";
import type { RecordEditInput } from "./useTimelineEditing";
interface UseRazorSplitOptions {
projectId: string | null;
activeCompPath: string | null;
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
reloadPreview: () => void;
isRecordingRef?: React.RefObject<boolean>;
}
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<ReturnType<typeof buildPatchTarget>>,
splitTime: number,
newId: string,
): 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 }),
},
);
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<void>,
): 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";
const originalContent = await readFileContent(pid, targetPath);
const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
const splitResult = await splitHtmlElement(pid, targetPath, patchTarget, splitTime, newId);
if (!splitResult.ok) throw new Error("Failed to split clip.");
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 || !canSplitElement(element)) return;
const clipStart = element.start;
const clipEnd = element.start + element.duration;
if (
splitTime <= clipStart + SPLIT_BOUNDARY_EPSILON_S ||
splitTime >= clipEnd - SPLIT_BOUNDARY_EPSILON_S
) {
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();
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 = elements.filter(
(el) => canSplitElement(el) && splitTime > el.start && splitTime < el.start + el.duration,
);
if (splittable.length === 0) return;
try {
const originals = new Map<string, string>();
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<string, string>();
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();
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 };
}
+15 -98
View File
@@ -1,6 +1,7 @@
import { useCallback, useRef } from "react";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import { useRazorSplit } from "./useRazorSplit";
import {
buildTimelineAssetId,
buildTimelineAssetInsertHtml,
@@ -30,7 +31,7 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
// ── Types ──
interface RecordEditInput {
export interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
@@ -386,108 +387,24 @@ export function useTimelineEditing({
[showToast],
);
const handleTimelineElementSplit = useCallback(
async (element: TimelineElement, splitTime: number) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) return;
const splittableTags = new Set(["video", "audio", "img"]);
if (
element.timelineLocked ||
element.timingSource === "implicit" ||
element.compositionSrc ||
!splittableTags.has(element.tag) ||
!element.duration ||
!Number.isFinite(element.duration)
) {
return;
}
if (splitTime <= element.start || splitTime >= element.start + element.duration) {
showToast("Playhead must be inside the clip to split.", "error");
return;
}
const patchTarget = buildPatchTarget(element);
if (!patchTarget) {
showToast("Clip is missing a patchable target.", "error");
return;
}
const targetPath = element.sourceFile || activeCompPath || "index.html";
try {
const originalContent = await readFileContent(pid, targetPath);
const existingIds = collectHtmlIds(originalContent);
const baseId = element.domId || "clip";
let newId = `${baseId}-split`;
let suffix = 2;
while (existingIds.includes(newId)) {
newId = `${baseId}-split-${suffix++}`;
}
const response = await fetch(
`/api/projects/${pid}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: patchTarget, splitTime, newId }),
},
);
if (!response.ok) {
throw new Error("Split request failed");
}
const data = (await response.json()) as {
ok?: boolean;
changed?: boolean;
content?: string;
};
if (!data.ok || !data.changed) {
showToast("Failed to split clip — playhead may be outside the clip.", "error");
return;
}
const patchedContent = typeof data.content === "string" ? data.content : originalContent;
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: "Split timeline clip",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
reloadPreview();
const label = getTimelineElementLabel(element);
showToast(`Split ${label} at ${splitTime.toFixed(2)}s`, "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,
],
);
const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
projectId,
activeCompPath,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
reloadPreview,
isRecordingRef,
});
return {
handleTimelineElementMove,
handleTimelineElementResize,
handleTimelineElementDelete,
handleTimelineElementSplit,
handleTimelineElementSplit: handleRazorSplit,
handleRazorSplit,
handleRazorSplitAll,
handleTimelineAssetDrop,
handleTimelineFileDrop,
handleBlockedTimelineEdit,