mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user