mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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:
@@ -504,6 +504,8 @@ export function StudioApp() {
|
||||
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
||||
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
||||
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
||||
handleRazorSplit={timelineEditing.handleRazorSplit}
|
||||
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
|
||||
setCompIdToSrc={setCompIdToSrc}
|
||||
setCompositionLoading={setCompositionLoading}
|
||||
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
|
||||
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
||||
import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery";
|
||||
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
||||
import { canSplitElement } from "../utils/timelineElementSplit";
|
||||
import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
|
||||
/** Safely resolves contentWindow for a potentially cross-origin iframe. */
|
||||
function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
|
||||
@@ -327,7 +329,7 @@ export function useAppHotkeys({
|
||||
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
|
||||
if (
|
||||
element &&
|
||||
["video", "audio", "img"].includes(element.tag) &&
|
||||
canSplitElement(element) &&
|
||||
currentTime > element.start &&
|
||||
currentTime < element.start + element.duration
|
||||
) {
|
||||
@@ -338,6 +340,51 @@ export function useAppHotkeys({
|
||||
}
|
||||
}
|
||||
|
||||
// B — toggle razor tool
|
||||
if (
|
||||
STUDIO_RAZOR_TOOL_ENABLED &&
|
||||
event.key.toLowerCase() === "b" &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
event.preventDefault();
|
||||
const { activeTool, setActiveTool } = usePlayerStore.getState();
|
||||
setActiveTool(activeTool === "razor" ? "select" : "razor");
|
||||
return;
|
||||
}
|
||||
|
||||
// V — return to selection tool
|
||||
if (
|
||||
event.key.toLowerCase() === "v" &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
event.preventDefault();
|
||||
usePlayerStore.getState().setActiveTool("select");
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape — exit razor mode (only when no selection to deselect first)
|
||||
if (event.key === "Escape" && !isEditableTarget(event.target)) {
|
||||
const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
|
||||
usePlayerStore.getState();
|
||||
if (activeTool === "razor") {
|
||||
if (selectedElementId) {
|
||||
setSelectedElementId(null);
|
||||
} else {
|
||||
setActiveTool("select");
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete / Backspace — remove selected keyframes > reset keyframes > remove element
|
||||
if (
|
||||
(event.key === "Delete" || event.key === "Backspace") &&
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers";
|
||||
import {
|
||||
canSplitElement,
|
||||
buildPatchTarget,
|
||||
readFileContent,
|
||||
SPLIT_BOUNDARY_EPSILON_S,
|
||||
} from "../utils/timelineElementSplit";
|
||||
import type { RecordEditInput } from "./useTimelineEditing";
|
||||
|
||||
interface UseRazorSplitOptions {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
isRecordingRef?: React.RefObject<boolean>;
|
||||
}
|
||||
|
||||
function generateSplitId(existingIds: string[], baseId: string): string {
|
||||
let newId = `${baseId}-split`;
|
||||
let suffix = 2;
|
||||
while (existingIds.includes(newId)) {
|
||||
newId = `${baseId}-split-${suffix++}`;
|
||||
}
|
||||
return newId;
|
||||
}
|
||||
|
||||
async function splitHtmlElement(
|
||||
projectId: string,
|
||||
targetPath: string,
|
||||
patchTarget: NonNullable<ReturnType<typeof buildPatchTarget>>,
|
||||
splitTime: number,
|
||||
newId: string,
|
||||
): Promise<{ ok: boolean; changed?: boolean; content?: string }> {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target: patchTarget, splitTime, newId }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error("Split request failed");
|
||||
return (await response.json()) as { ok: boolean; changed?: boolean; content?: string };
|
||||
}
|
||||
|
||||
async function splitGsapAnimations(
|
||||
projectId: string,
|
||||
targetPath: string,
|
||||
originalId: string,
|
||||
newId: string,
|
||||
splitTime: number,
|
||||
elementStart: number,
|
||||
elementDuration: number,
|
||||
): Promise<{ content: string | null; skippedSelectors?: string[] }> {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "split-animations",
|
||||
originalId,
|
||||
newId,
|
||||
splitTime,
|
||||
elementStart,
|
||||
elementDuration,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const errorBody = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
if (errorBody?.error === "no GSAP script found in file") {
|
||||
return { content: null };
|
||||
}
|
||||
throw new Error(errorBody?.error ?? `GSAP animation split failed (${response.status})`);
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
after?: string;
|
||||
skippedSelectors?: string[];
|
||||
};
|
||||
return {
|
||||
content: data.ok && data.after ? data.after : null,
|
||||
skippedSelectors: data.skippedSelectors,
|
||||
};
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function executeSplit(
|
||||
pid: string,
|
||||
element: TimelineElement,
|
||||
splitTime: number,
|
||||
activeCompPath: string | null,
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>,
|
||||
): Promise<{
|
||||
targetPath: string;
|
||||
originalContent: string;
|
||||
patchedContent: string;
|
||||
changed: boolean;
|
||||
skippedSelectors?: string[];
|
||||
}> {
|
||||
const patchTarget = buildPatchTarget(element);
|
||||
if (!patchTarget) throw new Error("Clip is missing a patchable target.");
|
||||
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
|
||||
|
||||
const splitResult = await splitHtmlElement(pid, targetPath, patchTarget, splitTime, newId);
|
||||
if (!splitResult.ok) throw new Error("Failed to split clip.");
|
||||
if (!splitResult.changed) {
|
||||
return { targetPath, originalContent, patchedContent: originalContent, changed: false };
|
||||
}
|
||||
|
||||
let patchedContent =
|
||||
typeof splitResult.content === "string" ? splitResult.content : originalContent;
|
||||
|
||||
let skippedSelectors: string[] | undefined;
|
||||
if (element.domId) {
|
||||
try {
|
||||
const gsapResult = await splitGsapAnimations(
|
||||
pid,
|
||||
targetPath,
|
||||
element.domId,
|
||||
newId,
|
||||
splitTime,
|
||||
element.start,
|
||||
element.duration,
|
||||
);
|
||||
if (gsapResult.content) patchedContent = gsapResult.content;
|
||||
if (gsapResult.skippedSelectors?.length) skippedSelectors = gsapResult.skippedSelectors;
|
||||
} catch (gsapError) {
|
||||
// GSAP mutation failed — the HTML split already wrote to disk.
|
||||
// Restore the original content to avoid a corrupt half-split state.
|
||||
await writeProjectFile(targetPath, originalContent);
|
||||
throw gsapError;
|
||||
}
|
||||
}
|
||||
|
||||
return { targetPath, originalContent, patchedContent, changed: true, skippedSelectors };
|
||||
}
|
||||
|
||||
export function useRazorSplit({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
}: UseRazorSplitOptions) {
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
|
||||
const handleRazorSplit = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (element: TimelineElement, splitTime: number) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid || !canSplitElement(element)) return;
|
||||
|
||||
const clipStart = element.start;
|
||||
const clipEnd = element.start + element.duration;
|
||||
if (
|
||||
splitTime <= clipStart + SPLIT_BOUNDARY_EPSILON_S ||
|
||||
splitTime >= clipEnd - SPLIT_BOUNDARY_EPSILON_S
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
|
||||
await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile);
|
||||
|
||||
if (!changed) {
|
||||
showToast("Failed to split clip — playhead may be outside the clip", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Split timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
reloadPreview();
|
||||
showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
|
||||
if (skippedSelectors?.length) {
|
||||
showToast(
|
||||
`Some animations use non-ID selectors (${skippedSelectors.join(", ")}) and were not retargeted`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to split timeline clip";
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
],
|
||||
);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleRazorSplitAll = useCallback(
|
||||
async (splitTime: number) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
const { elements } = usePlayerStore.getState();
|
||||
const splittable = elements.filter(
|
||||
(el) => canSplitElement(el) && splitTime > el.start && splitTime < el.start + el.duration,
|
||||
);
|
||||
if (splittable.length === 0) return;
|
||||
|
||||
try {
|
||||
const originals = new Map<string, string>();
|
||||
for (const el of splittable) {
|
||||
const path = el.sourceFile || activeCompPath || "index.html";
|
||||
if (!originals.has(path)) {
|
||||
originals.set(path, await readFileContent(pid, path));
|
||||
}
|
||||
}
|
||||
|
||||
let splitCount = 0;
|
||||
const finalContent = new Map<string, string>();
|
||||
|
||||
for (const element of splittable) {
|
||||
const result = await executeSplit(
|
||||
pid,
|
||||
element,
|
||||
splitTime,
|
||||
activeCompPath,
|
||||
writeProjectFile,
|
||||
);
|
||||
if (result.changed) {
|
||||
finalContent.set(result.targetPath, result.patchedContent);
|
||||
await writeProjectFile(result.targetPath, result.patchedContent);
|
||||
splitCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (splitCount === 0) return;
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await recordEdit({
|
||||
label: `Split ${splitCount} clips at ${splitTime.toFixed(2)}s`,
|
||||
kind: "timeline",
|
||||
files: Object.fromEntries(
|
||||
[...finalContent].map(([path, after]) => [
|
||||
path,
|
||||
{ before: originals.get(path) ?? "", after },
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
reloadPreview();
|
||||
showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to split clips";
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
],
|
||||
);
|
||||
|
||||
return { handleRazorSplit, handleRazorSplitAll };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { useRazorSplit } from "./useRazorSplit";
|
||||
import {
|
||||
buildTimelineAssetId,
|
||||
buildTimelineAssetInsertHtml,
|
||||
@@ -30,7 +31,7 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface RecordEditInput {
|
||||
export interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
@@ -386,108 +387,24 @@ export function useTimelineEditing({
|
||||
[showToast],
|
||||
);
|
||||
|
||||
const handleTimelineElementSplit = useCallback(
|
||||
async (element: TimelineElement, splitTime: number) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
|
||||
const splittableTags = new Set(["video", "audio", "img"]);
|
||||
if (
|
||||
element.timelineLocked ||
|
||||
element.timingSource === "implicit" ||
|
||||
element.compositionSrc ||
|
||||
!splittableTags.has(element.tag) ||
|
||||
!element.duration ||
|
||||
!Number.isFinite(element.duration)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (splitTime <= element.start || splitTime >= element.start + element.duration) {
|
||||
showToast("Playhead must be inside the clip to split.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const patchTarget = buildPatchTarget(element);
|
||||
if (!patchTarget) {
|
||||
showToast("Clip is missing a patchable target.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
try {
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
const existingIds = collectHtmlIds(originalContent);
|
||||
const baseId = element.domId || "clip";
|
||||
let newId = `${baseId}-split`;
|
||||
let suffix = 2;
|
||||
while (existingIds.includes(newId)) {
|
||||
newId = `${baseId}-split-${suffix++}`;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/projects/${pid}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target: patchTarget, splitTime, newId }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Split request failed");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
changed?: boolean;
|
||||
content?: string;
|
||||
};
|
||||
if (!data.ok || !data.changed) {
|
||||
showToast("Failed to split clip — playhead may be outside the clip.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const patchedContent = typeof data.content === "string" ? data.content : originalContent;
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Split timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
reloadPreview();
|
||||
const label = getTimelineElementLabel(element);
|
||||
showToast(`Split ${label} at ${splitTime.toFixed(2)}s`, "info");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to split timeline clip";
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
[
|
||||
const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
handleTimelineElementDelete,
|
||||
handleTimelineElementSplit,
|
||||
handleTimelineElementSplit: handleRazorSplit,
|
||||
handleRazorSplit,
|
||||
handleRazorSplitAll,
|
||||
handleTimelineAssetDrop,
|
||||
handleTimelineFileDrop,
|
||||
handleBlockedTimelineEdit,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface TimelineElement {
|
||||
}
|
||||
|
||||
export type ZoomMode = "fit" | "manual";
|
||||
type TimelineTool = "select" | "razor";
|
||||
|
||||
interface PlayerState {
|
||||
isPlaying: boolean;
|
||||
@@ -65,6 +66,9 @@ interface PlayerState {
|
||||
/** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
|
||||
outPoint: number | null;
|
||||
|
||||
activeTool: TimelineTool;
|
||||
setActiveTool: (tool: TimelineTool) => void;
|
||||
|
||||
/** Set of selected keyframe keys in format `${elementId}:${percentage}`. */
|
||||
selectedKeyframes: Set<string>;
|
||||
toggleSelectedKeyframe: (key: string) => void;
|
||||
@@ -153,6 +157,9 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
inPoint: null,
|
||||
outPoint: null,
|
||||
|
||||
activeTool: "select",
|
||||
setActiveTool: (tool) => set({ activeTool: tool }),
|
||||
|
||||
selectedKeyframes: new Set(),
|
||||
toggleSelectedKeyframe: (key) =>
|
||||
set((s) => {
|
||||
@@ -262,6 +269,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
selectedElementId: null,
|
||||
inPoint: null,
|
||||
outPoint: null,
|
||||
activeTool: "select",
|
||||
selectedKeyframes: new Set(),
|
||||
selectedElementIds: new Set(),
|
||||
expandedTimelineElements: new Set(),
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { TimelineElement } from "../player/store/playerStore";
|
||||
|
||||
export { buildPatchTarget, readFileContent } from "../hooks/timelineEditingHelpers";
|
||||
|
||||
/** Minimum distance (seconds) from clip boundaries to allow a split. */
|
||||
export const SPLIT_BOUNDARY_EPSILON_S = 0.03;
|
||||
|
||||
export function canSplitElement(el: TimelineElement): boolean {
|
||||
return (
|
||||
!el.timelineLocked &&
|
||||
|
||||
Reference in New Issue
Block a user