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
@@ -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)">