Skip to content

Commit ef18613

Browse files
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.
1 parent 45d4a71 commit ef18613

12 files changed

Lines changed: 516 additions & 188 deletions

File tree

packages/studio/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,8 @@ export function StudioApp() {
504504
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
505505
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
506506
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
507+
handleRazorSplit={timelineEditing.handleRazorSplit}
508+
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
507509
setCompIdToSrc={setCompIdToSrc}
508510
setCompositionLoading={setCompositionLoading}
509511
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}

packages/studio/src/components/StudioPreviewArea.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export interface StudioPreviewAreaProps {
5252
) => Promise<void> | void;
5353
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
5454
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
55+
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
56+
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
5557
setCompIdToSrc: (map: Map<string, string>) => void;
5658
setCompositionLoading: (loading: boolean) => void;
5759
shouldShowSelectedDomBounds: boolean;
@@ -73,6 +75,8 @@ export function StudioPreviewArea({
7375
handleTimelineElementResize,
7476
handleBlockedTimelineEdit,
7577
handleTimelineElementSplit,
78+
handleRazorSplit,
79+
handleRazorSplitAll,
7680
setCompIdToSrc,
7781
setCompositionLoading,
7882
shouldShowSelectedDomBounds,
@@ -146,6 +150,8 @@ export function StudioPreviewArea({
146150
onResizeElement={handleTimelineElementResize}
147151
onBlockedEditAttempt={handleBlockedTimelineEdit}
148152
onSplitElement={handleTimelineElementSplit}
153+
onRazorSplit={handleRazorSplit}
154+
onRazorSplitAll={handleRazorSplitAll}
149155
onSelectTimelineElement={handleTimelineElementSelect}
150156
onDeleteAllKeyframes={(_elId) => {
151157
const anim =

packages/studio/src/components/TimelineToolbar.tsx

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@ import {
44
getNextTimelineZoomPercent,
55
getTimelineZoomPercent,
66
} from "../player/components/timelineZoom";
7+
import { useTimelineZoom } from "../player/components/useTimelineZoom";
78
import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
89
import { usePlayerStore, type TimelineElement } from "../player";
9-
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
10+
import {
11+
STUDIO_KEYFRAMES_ENABLED,
12+
STUDIO_RAZOR_TOOL_ENABLED,
13+
} from "./editor/manualEditingAvailability";
1014
import { Tooltip } from "./ui";
1115
import { Scissors } from "../icons/SystemIcons";
1216
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
1317
import type { DomEditSelection } from "./editor/domEditingTypes";
18+
import { canSplitElement } from "../utils/timelineElementSplit";
1419

1520
function AutoKeyframeToggle() {
1621
const enabled = usePlayerStore((s) => s.autoKeyframeEnabled);
@@ -58,14 +63,17 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
5863
const anims = session.selectedGsapAnimations;
5964
const kfAnim = anims.find((a) => a.keyframes);
6065

66+
const computePct = (time: number) => {
67+
const elStart = Number.parseFloat(sel?.dataAttributes?.start ?? "0") || 0;
68+
const elDuration = Number.parseFloat(sel?.dataAttributes?.duration ?? "1") || 1;
69+
return elDuration > 0
70+
? Math.max(0, Math.min(100, Math.round(((time - elStart) / elDuration) * 1000) / 10))
71+
: 0;
72+
};
73+
6174
let state: "active" | "inactive" | "none" = "none";
6275
if (kfAnim?.keyframes && sel) {
63-
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
64-
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
65-
const pct =
66-
elDuration > 0
67-
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
68-
: 0;
76+
const pct = computePct(currentTime);
6977
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
7078
? "active"
7179
: "inactive";
@@ -74,15 +82,15 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
7482
return { state, onToggle: sel ? onToggle : undefined };
7583
}
7684

85+
// fallow-ignore-next-line complexity
7786
export function TimelineToolbar({
7887
toggleTimelineVisibility,
7988
domEditSession,
8089
onSplitElement,
8190
}: TimelineToolbarProps) {
82-
const zoomMode = usePlayerStore((s) => s.zoomMode);
83-
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
84-
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
85-
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
91+
const activeTool = usePlayerStore((s) => s.activeTool);
92+
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
93+
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
8694
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
8795
const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
8896

@@ -93,6 +101,38 @@ export function TimelineToolbar({
93101
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
94102
Timeline
95103
</div>
104+
{STUDIO_RAZOR_TOOL_ENABLED && (
105+
<div className="flex items-center border border-neutral-800 rounded overflow-hidden">
106+
<Tooltip label="Selection tool (V)">
107+
<button
108+
type="button"
109+
onClick={() => setActiveTool("select")}
110+
className={`flex h-6 w-6 items-center justify-center transition-colors ${
111+
activeTool === "select"
112+
? "bg-neutral-700 text-neutral-200"
113+
: "text-neutral-500 hover:text-neutral-300"
114+
}`}
115+
>
116+
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
117+
<path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
118+
</svg>
119+
</button>
120+
</Tooltip>
121+
<Tooltip label="Razor tool (B)">
122+
<button
123+
type="button"
124+
onClick={() => setActiveTool("razor")}
125+
className={`flex h-6 w-6 items-center justify-center transition-colors ${
126+
activeTool === "razor"
127+
? "bg-neutral-700 text-neutral-200"
128+
: "text-neutral-500 hover:text-neutral-300"
129+
}`}
130+
>
131+
<Scissors size={11} />
132+
</button>
133+
</Tooltip>
134+
</div>
135+
)}
96136
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
97137
<>
98138
<Tooltip
@@ -138,9 +178,7 @@ export function TimelineToolbar({
138178
const el = selectedElementId
139179
? elements.find((e) => (e.key ?? e.id) === selectedElementId)
140180
: null;
141-
const splittable =
142-
el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag);
143-
if (!splittable) return null;
181+
if (!el || !canSplitElement(el)) return null;
144182
const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
145183
return (
146184
<Tooltip label="Split clip at playhead (S)">

packages/studio/src/components/editor/manualEditingAvailability.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
7777
true,
7878
);
7979

80+
export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
81+
env,
82+
["VITE_STUDIO_ENABLE_RAZOR_TOOL", "VITE_STUDIO_RAZOR_TOOL_ENABLED"],
83+
false,
84+
);
85+
8086
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
8187

8288
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";

packages/studio/src/components/nle/NLELayout.tsx

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import { useMountEffect } from "../../hooks/useMountEffect";
1111
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
1212
import type { TimelineElement } from "../../player";
13-
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
13+
import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
1414
import { NLEPreview } from "./NLEPreview";
1515
import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
1616
import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
@@ -20,7 +20,7 @@ import {
2020
getTimelineToggleTitle,
2121
} from "../../utils/timelineDiscovery";
2222

23-
interface NLELayoutProps {
23+
interface NLELayoutProps extends TimelineEditCallbacks {
2424
projectId: string;
2525
portrait?: boolean;
2626
/** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
@@ -59,23 +59,7 @@ interface NLELayoutProps {
5959
blockName: string,
6060
position: { left: number; top: number },
6161
) => Promise<void> | void;
62-
/** Persist timeline move actions back into source HTML */
63-
onMoveElement?: (
64-
element: TimelineElement,
65-
updates: Pick<TimelineElement, "start" | "track">,
66-
) => Promise<void> | void;
67-
onResizeElement?: (
68-
element: TimelineElement,
69-
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
70-
) => Promise<void> | void;
71-
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
72-
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
7362
onSelectTimelineElement?: (element: TimelineElement | null) => void;
74-
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
75-
onDeleteAllKeyframes?: (elementId: string) => void;
76-
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
77-
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
78-
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
7963
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
8064
onCompIdToSrcChange?: (map: Map<string, string>) => void;
8165
/** Whether the timeline panel is visible (default: true) */
@@ -124,6 +108,8 @@ export const NLELayout = memo(function NLELayout({
124108
onResizeElement,
125109
onBlockedEditAttempt,
126110
onSplitElement,
111+
onRazorSplit,
112+
onRazorSplitAll,
127113
onSelectTimelineElement,
128114
onDeleteKeyframe,
129115
onDeleteAllKeyframes,
@@ -460,6 +446,8 @@ export const NLELayout = memo(function NLELayout({
460446
onResizeElement={onResizeElement}
461447
onBlockedEditAttempt={onBlockedEditAttempt}
462448
onSplitElement={onSplitElement}
449+
onRazorSplit={onRazorSplit}
450+
onRazorSplitAll={onRazorSplitAll}
463451
onSelectElement={onSelectTimelineElement}
464452
onDeleteKeyframe={onDeleteKeyframe}
465453
onDeleteAllKeyframes={onDeleteAllKeyframes}

packages/studio/src/hooks/useAppHotkeys.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
66
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
77
import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
88
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9+
import { canSplitElement } from "../utils/timelineElementSplit";
10+
import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
911

1012
/** Safely resolves contentWindow for a potentially cross-origin iframe. */
1113
function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
@@ -327,7 +329,7 @@ export function useAppHotkeys({
327329
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
328330
if (
329331
element &&
330-
["video", "audio", "img"].includes(element.tag) &&
332+
canSplitElement(element) &&
331333
currentTime > element.start &&
332334
currentTime < element.start + element.duration
333335
) {
@@ -338,6 +340,51 @@ export function useAppHotkeys({
338340
}
339341
}
340342

343+
// B — toggle razor tool
344+
if (
345+
STUDIO_RAZOR_TOOL_ENABLED &&
346+
event.key.toLowerCase() === "b" &&
347+
!event.metaKey &&
348+
!event.ctrlKey &&
349+
!event.altKey &&
350+
!event.shiftKey &&
351+
!isEditableTarget(event.target)
352+
) {
353+
event.preventDefault();
354+
const { activeTool, setActiveTool } = usePlayerStore.getState();
355+
setActiveTool(activeTool === "razor" ? "select" : "razor");
356+
return;
357+
}
358+
359+
// V — return to selection tool
360+
if (
361+
event.key.toLowerCase() === "v" &&
362+
!event.metaKey &&
363+
!event.ctrlKey &&
364+
!event.altKey &&
365+
!event.shiftKey &&
366+
!isEditableTarget(event.target)
367+
) {
368+
event.preventDefault();
369+
usePlayerStore.getState().setActiveTool("select");
370+
return;
371+
}
372+
373+
// Escape — exit razor mode (only when no selection to deselect first)
374+
if (event.key === "Escape" && !isEditableTarget(event.target)) {
375+
const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
376+
usePlayerStore.getState();
377+
if (activeTool === "razor") {
378+
if (selectedElementId) {
379+
setSelectedElementId(null);
380+
} else {
381+
setActiveTool("select");
382+
}
383+
event.preventDefault();
384+
return;
385+
}
386+
}
387+
341388
// Delete / Backspace — remove selected keyframes > reset keyframes > remove element
342389
if (
343390
(event.key === "Delete" || event.key === "Backspace") &&

0 commit comments

Comments
 (0)