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
+2
View File
@@ -504,6 +504,8 @@ export function StudioApp() {
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
handleRazorSplit={timelineEditing.handleRazorSplit}
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
setCompIdToSrc={setCompIdToSrc}
setCompositionLoading={setCompositionLoading}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
@@ -52,6 +52,8 @@ export interface StudioPreviewAreaProps {
) => Promise<void> | void;
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
setCompIdToSrc: (map: Map<string, string>) => void;
setCompositionLoading: (loading: boolean) => void;
shouldShowSelectedDomBounds: boolean;
@@ -73,6 +75,8 @@ export function StudioPreviewArea({
handleTimelineElementResize,
handleBlockedTimelineEdit,
handleTimelineElementSplit,
handleRazorSplit,
handleRazorSplitAll,
setCompIdToSrc,
setCompositionLoading,
shouldShowSelectedDomBounds,
@@ -146,6 +150,8 @@ export function StudioPreviewArea({
onResizeElement={handleTimelineElementResize}
onBlockedEditAttempt={handleBlockedTimelineEdit}
onSplitElement={handleTimelineElementSplit}
onRazorSplit={handleRazorSplit}
onRazorSplitAll={handleRazorSplitAll}
onSelectTimelineElement={handleTimelineElementSelect}
onDeleteAllKeyframes={(_elId) => {
const anim =
@@ -4,13 +4,18 @@ import {
getNextTimelineZoomPercent,
getTimelineZoomPercent,
} from "../player/components/timelineZoom";
import { useTimelineZoom } from "../player/components/useTimelineZoom";
import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
import { usePlayerStore, type TimelineElement } from "../player";
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
import {
STUDIO_KEYFRAMES_ENABLED,
STUDIO_RAZOR_TOOL_ENABLED,
} from "./editor/manualEditingAvailability";
import { Tooltip } from "./ui";
import { Scissors } from "../icons/SystemIcons";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";
import { canSplitElement } from "../utils/timelineElementSplit";
function AutoKeyframeToggle() {
const enabled = usePlayerStore((s) => s.autoKeyframeEnabled);
@@ -58,14 +63,17 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
const anims = session.selectedGsapAnimations;
const kfAnim = anims.find((a) => a.keyframes);
const computePct = (time: number) => {
const elStart = Number.parseFloat(sel?.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(sel?.dataAttributes?.duration ?? "1") || 1;
return elDuration > 0
? Math.max(0, Math.min(100, Math.round(((time - elStart) / elDuration) * 1000) / 10))
: 0;
};
let state: "active" | "inactive" | "none" = "none";
if (kfAnim?.keyframes && sel) {
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
const pct =
elDuration > 0
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
: 0;
const pct = computePct(currentTime);
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
? "active"
: "inactive";
@@ -74,15 +82,15 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
return { state, onToggle: sel ? onToggle : undefined };
}
// fallow-ignore-next-line complexity
export function TimelineToolbar({
toggleTimelineVisibility,
domEditSession,
onSplitElement,
}: TimelineToolbarProps) {
const zoomMode = usePlayerStore((s) => s.zoomMode);
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
const activeTool = usePlayerStore((s) => s.activeTool);
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
@@ -93,6 +101,38 @@ export function TimelineToolbar({
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
Timeline
</div>
{STUDIO_RAZOR_TOOL_ENABLED && (
<div className="flex items-center border border-neutral-800 rounded overflow-hidden">
<Tooltip label="Selection tool (V)">
<button
type="button"
onClick={() => setActiveTool("select")}
className={`flex h-6 w-6 items-center justify-center transition-colors ${
activeTool === "select"
? "bg-neutral-700 text-neutral-200"
: "text-neutral-500 hover:text-neutral-300"
}`}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
</svg>
</button>
</Tooltip>
<Tooltip label="Razor tool (B)">
<button
type="button"
onClick={() => setActiveTool("razor")}
className={`flex h-6 w-6 items-center justify-center transition-colors ${
activeTool === "razor"
? "bg-neutral-700 text-neutral-200"
: "text-neutral-500 hover:text-neutral-300"
}`}
>
<Scissors size={11} />
</button>
</Tooltip>
</div>
)}
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
<>
<Tooltip
@@ -138,9 +178,7 @@ export function TimelineToolbar({
const el = selectedElementId
? elements.find((e) => (e.key ?? e.id) === selectedElementId)
: null;
const splittable =
el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag);
if (!splittable) return null;
if (!el || !canSplitElement(el)) return null;
const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
return (
<Tooltip label="Split clip at playhead (S)">
@@ -77,6 +77,12 @@ export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
true,
);
export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_ENABLE_RAZOR_TOOL", "VITE_STUDIO_RAZOR_TOOL_ENABLED"],
false,
);
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
@@ -10,7 +10,7 @@ import {
import { useMountEffect } from "../../hooks/useMountEffect";
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
import type { TimelineElement } from "../../player";
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
import { NLEPreview } from "./NLEPreview";
import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
@@ -20,7 +20,7 @@ import {
getTimelineToggleTitle,
} from "../../utils/timelineDiscovery";
interface NLELayoutProps {
interface NLELayoutProps extends TimelineEditCallbacks {
projectId: string;
portrait?: boolean;
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
@@ -59,23 +59,7 @@ interface NLELayoutProps {
blockName: string,
position: { left: number; top: number },
) => Promise<void> | void;
/** Persist timeline move actions back into source HTML */
onMoveElement?: (
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "track">,
) => Promise<void> | void;
onResizeElement?: (
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onSelectTimelineElement?: (element: TimelineElement | null) => void;
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
onDeleteAllKeyframes?: (elementId: string) => void;
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
onCompIdToSrcChange?: (map: Map<string, string>) => void;
/** Whether the timeline panel is visible (default: true) */
@@ -124,6 +108,8 @@ export const NLELayout = memo(function NLELayout({
onResizeElement,
onBlockedEditAttempt,
onSplitElement,
onRazorSplit,
onRazorSplitAll,
onSelectTimelineElement,
onDeleteKeyframe,
onDeleteAllKeyframes,
@@ -460,6 +446,8 @@ export const NLELayout = memo(function NLELayout({
onResizeElement={onResizeElement}
onBlockedEditAttempt={onBlockedEditAttempt}
onSplitElement={onSplitElement}
onRazorSplit={onRazorSplit}
onRazorSplitAll={onRazorSplitAll}
onSelectElement={onSelectTimelineElement}
onDeleteKeyframe={onDeleteKeyframe}
onDeleteAllKeyframes={onDeleteAllKeyframes}
+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,
@@ -2,12 +2,12 @@ import { useRef, useMemo, useCallback, useState, useEffect, memo, type ReactNode
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { useMountEffect } from "../../hooks/useMountEffect";
import { EditPopover } from "./EditModal";
import { type BlockedTimelineEditIntent } from "./timelineEditing";
import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
import { useTimelinePlayhead } from "./useTimelinePlayhead";
import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
import { getTimelinePixelsPerSecond } from "./timelineZoom";
import { useTimelineZoom } from "./useTimelineZoom";
import { useTimelineAssetDrop } from "./timelineDragDrop";
import { TimelineEmptyState } from "./TimelineEmptyState";
import { TimelineCanvas } from "./TimelineCanvas";
@@ -23,6 +23,7 @@ import {
getTimelineCanvasHeight,
shouldShowTimelineShortcutHint,
} from "./timelineLayout";
import type { TimelineEditCallbacks, TimelineDropCallbacks } from "./timelineCallbacks";
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
export {
@@ -39,7 +40,7 @@ export {
getDefaultDroppedTrack,
} from "./timelineLayout";
interface TimelineProps {
interface TimelineProps extends TimelineEditCallbacks, TimelineDropCallbacks {
onSeek?: (time: number) => void;
onDrillDown?: (element: TimelineElement) => void;
renderClipContent?: (
@@ -47,35 +48,8 @@ interface TimelineProps {
style: { clip: string; label: string },
) => ReactNode;
renderClipOverlay?: (element: TimelineElement) => ReactNode;
onFileDrop?: (
files: File[],
placement?: { start: number; track: number },
) => Promise<void> | void;
onAssetDrop?: (
assetPath: string,
placement: { start: number; track: number },
) => Promise<void> | void;
onBlockDrop?: (
blockName: string,
placement: { start: number; track: number },
) => Promise<void> | void;
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
onMoveElement?: (
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "track">,
) => Promise<void> | void;
onResizeElement?: (
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onSelectElement?: (element: TimelineElement | null) => void;
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
onDeleteAllKeyframes?: (elementId: string) => void;
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
theme?: Partial<TimelineTheme>;
}
@@ -92,6 +66,8 @@ export const Timeline = memo(function Timeline({
onResizeElement,
onBlockedEditAttempt,
onSplitElement,
onRazorSplit,
onRazorSplitAll,
onSelectElement,
onDeleteKeyframe,
onDeleteAllKeyframes,
@@ -107,17 +83,16 @@ export const Timeline = memo(function Timeline({
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
const currentTime = usePlayerStore((s) => s.currentTime);
const zoomMode = usePlayerStore((s) => s.zoomMode);
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
const playheadRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const activeTool = usePlayerStore((s) => s.activeTool);
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
const isDragging = useRef(false);
const [shiftHeld, setShiftHeld] = useState(false);
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
useMountEffect(() => {
const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
@@ -388,7 +363,14 @@ export const Timeline = memo(function Timeline({
<div
ref={setContainerRef}
aria-label="Timeline"
className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
className={`relative border-t select-none h-full overflow-hidden ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
onMouseMove={(e) => {
if (activeTool === "razor" && scrollRef.current) {
const rect = scrollRef.current.getBoundingClientRect();
setRazorGuideX(e.clientX - rect.left + scrollRef.current.scrollLeft);
}
}}
onMouseLeave={() => setRazorGuideX(null)}
style={{
touchAction: "pan-x pan-y",
background: theme.shellBackground,
@@ -402,7 +384,16 @@ export const Timeline = memo(function Timeline({
onDragOver={handleAssetDragOver}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleAssetDrop}
onPointerDown={handlePointerDown}
onPointerDown={(e) => {
if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) {
const rect = scrollRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER;
const splitTime = Math.max(0, x / pps);
onRazorSplitAll?.(splitTime);
return;
}
handlePointerDown(e);
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onLostPointerCapture={handlePointerUp}
@@ -488,7 +479,19 @@ export const Timeline = memo(function Timeline({
onSelectElement?.(el);
setClipContextMenu({ x: e.clientX, y: e.clientY, element: el });
}}
onRazorSplit={onRazorSplit}
onRazorSplitAll={onRazorSplitAll}
/>
{activeTool === "razor" && razorGuideX !== null && (
<div
className="absolute top-0 bottom-0 pointer-events-none z-10"
style={{
left: razorGuideX,
width: 1,
background: "rgba(239,68,68,0.7)",
}}
/>
)}
</div>
{showShortcutHint && !showPopover && !rangeSelection && (
@@ -2,6 +2,7 @@ import { memo, type ReactNode } from "react";
import { TimelineClip } from "./TimelineClip";
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
import { TimelineRuler } from "./TimelineRuler";
import { PlayheadIndicator } from "./PlayheadIndicator";
import {
getTimelineEditCapabilities,
resolveBlockedTimelineEditIntent,
@@ -17,6 +18,7 @@ import {
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
import type { TrackVisualStyle } from "./timelineIcons";
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
function ClipLabel({ element, color }: { element: TimelineElement; color: string }) {
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
@@ -91,6 +93,8 @@ interface TimelineCanvasProps {
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
onRazorSplit?: (element: TimelineElement, splitTime: number) => void;
onRazorSplitAll?: (splitTime: number) => void;
}
export const TimelineCanvas = memo(function TimelineCanvas({
@@ -141,6 +145,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({
onContextMenuKeyframe,
onContextMenuClip,
onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead,
onRazorSplit,
onRazorSplitAll,
}: TimelineCanvasProps) {
const draggedElement = draggedClip?.element ?? null;
const activeDraggedElement =
@@ -305,6 +311,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
if (usePlayerStore.getState().activeTool === "razor") return;
if (e.shiftKey) {
shiftClickClipRef.current = {
element: el,
@@ -358,6 +365,27 @@ export const TimelineCanvas = memo(function TimelineCanvas({
onClick={(e) => {
e.stopPropagation();
if (suppressClickRef.current) return;
const { activeTool } = usePlayerStore.getState();
if (activeTool === "razor" && onRazorSplit) {
const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const clickOffsetX = e.clientX - clipRect.left;
const splitTime = previewElement.start + clickOffsetX / pps;
const clampedTime = Math.max(
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
Math.min(
previewElement.start +
previewElement.duration -
SPLIT_BOUNDARY_EPSILON_S,
splitTime,
),
);
if (e.shiftKey && onRazorSplitAll) {
onRazorSplitAll(clampedTime);
} else {
onRazorSplit(el, clampedTime);
}
return;
}
const nextElement = isSelected ? null : el;
setSelectedElementId(nextElement ? elementKey : null);
onSelectElement?.(nextElement);
@@ -457,28 +485,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
className="absolute top-0 bottom-0 pointer-events-none"
style={{ left: `${GUTTER}px`, zIndex: 100 }}
>
<div
className="absolute top-0 bottom-0"
style={{
left: "50%",
width: 2,
marginLeft: -1,
background: "var(--hf-accent, #3CE6AC)",
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
}}
/>
<div className="absolute" style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}>
<div
style={{
width: 0,
height: 0,
borderLeft: "6px solid transparent",
borderRight: "6px solid transparent",
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
}}
/>
</div>
<PlayheadIndicator />
</div>
</div>
);
@@ -45,6 +45,7 @@ export interface TimelineElement {
}
export type ZoomMode = "fit" | "manual";
type TimelineTool = "select" | "razor";
interface PlayerState {
isPlaying: boolean;
@@ -65,6 +66,9 @@ interface PlayerState {
/** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
outPoint: number | null;
activeTool: TimelineTool;
setActiveTool: (tool: TimelineTool) => void;
/** Set of selected keyframe keys in format `${elementId}:${percentage}`. */
selectedKeyframes: Set<string>;
toggleSelectedKeyframe: (key: string) => void;
@@ -153,6 +157,9 @@ export const usePlayerStore = create<PlayerState>((set) => ({
inPoint: null,
outPoint: null,
activeTool: "select",
setActiveTool: (tool) => set({ activeTool: tool }),
selectedKeyframes: new Set(),
toggleSelectedKeyframe: (key) =>
set((s) => {
@@ -262,6 +269,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
selectedElementId: null,
inPoint: null,
outPoint: null,
activeTool: "select",
selectedKeyframes: new Set(),
selectedElementIds: new Set(),
expandedTimelineElements: new Set(),
@@ -2,6 +2,9 @@ import type { TimelineElement } from "../player/store/playerStore";
export { buildPatchTarget, readFileContent } from "../hooks/timelineEditingHelpers";
/** Minimum distance (seconds) from clip boundaries to allow a split. */
export const SPLIT_BOUNDARY_EPSILON_S = 0.03;
export function canSplitElement(el: TimelineElement): boolean {
return (
!el.timelineLocked &&