mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +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:
@@ -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)">
|
||||
|
||||
Reference in New Issue
Block a user