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
@@ -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}