mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-12 20:47:30 +00:00
feat(studio): dragging a clip over another overwrites it, alt inserts, one undo
This commit is contained in:
@@ -525,6 +525,7 @@ export function StudioApp() {
|
||||
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
||||
handleRazorSplit={timelineEditing.handleRazorSplit}
|
||||
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
|
||||
placementOps={timelineEditing.placementOps}
|
||||
onCopyClip={handleCopy}
|
||||
onPasteClip={handlePaste}
|
||||
onDuplicateClip={handleDuplicate}
|
||||
|
||||
@@ -94,6 +94,7 @@ export function EditorShell({
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
handleRazorSplitAll,
|
||||
placementOps,
|
||||
onCopyClip,
|
||||
onPasteClip,
|
||||
onDuplicateClip,
|
||||
@@ -151,6 +152,7 @@ export function EditorShell({
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
handleRazorSplitAll,
|
||||
placementOps,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -40,6 +40,8 @@ export function forwardRebasedTimelineMoveElements(
|
||||
);
|
||||
}
|
||||
|
||||
type TimelineResizeCommitOptions = { coalesceKey?: string; coalesceMs?: number };
|
||||
|
||||
type TimelineResizeChange = {
|
||||
element: TimelineElement;
|
||||
start: number;
|
||||
@@ -49,10 +51,10 @@ type TimelineResizeChange = {
|
||||
|
||||
export function forwardRebasedTimelineResizeElements(
|
||||
changes: TimelineResizeChange[],
|
||||
options: { coalesceKey?: string } | undefined,
|
||||
options: TimelineResizeCommitOptions | undefined,
|
||||
onResizeElements: (
|
||||
changes: TimelineResizeChange[],
|
||||
options?: { coalesceKey?: string },
|
||||
options?: TimelineResizeCommitOptions,
|
||||
) => Promise<void> | void,
|
||||
) {
|
||||
return onResizeElements(
|
||||
@@ -222,7 +224,7 @@ export function TimelinePane({
|
||||
duration: number;
|
||||
playbackStart?: number;
|
||||
}>,
|
||||
options?: { coalesceKey?: string },
|
||||
options?: TimelineResizeCommitOptions,
|
||||
) => {
|
||||
if (!onResizeElements) return;
|
||||
if (changes.some(({ element }) => element.expandedParentStart !== undefined)) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
splitTimelineElementKey,
|
||||
} from "../../player/lib/timelineElementHelpers";
|
||||
import type { TimelineKeyframeTarget } from "../../player/components/timelineKeyframeIdentity";
|
||||
import type { PlacementOps } from "../../player/components/timelinePlacementCommit";
|
||||
|
||||
export interface TimelineEditCallbackDeps {
|
||||
handleTimelineElementMove: (
|
||||
@@ -46,6 +47,8 @@ export interface TimelineEditCallbackDeps {
|
||||
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
|
||||
/** Split and remove writes for a clip drop that overwrites a neighbour. */
|
||||
placementOps?: PlacementOps;
|
||||
/** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
|
||||
handleGroupClips?: (
|
||||
clipIds: readonly string[],
|
||||
@@ -124,6 +127,7 @@ export function useTimelineEditCallbacks({
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
handleRazorSplitAll,
|
||||
placementOps,
|
||||
handleGroupClips,
|
||||
setElementFxAttribute,
|
||||
}: TimelineEditCallbackDeps): TimelineEditCallbacks {
|
||||
@@ -207,6 +211,7 @@ export function useTimelineEditCallbacks({
|
||||
onMoveElements: handleTimelineElementsMove,
|
||||
onResizeElement: handleTimelineElementResize,
|
||||
onResizeElements: handleTimelineGroupResize,
|
||||
onPlacementOps: placementOps,
|
||||
onToggleTrackHidden: handleToggleTrackHidden,
|
||||
onSetAudioGroupAttributeLive: setAudioGroupAttribute.setLive,
|
||||
onSetAudioGroupAttributeQuiet: setAudioGroupAttribute.setQuiet,
|
||||
@@ -405,6 +410,7 @@ export function useTimelineEditCallbacks({
|
||||
handleTimelineElementsMove,
|
||||
handleTimelineElementResize,
|
||||
handleTimelineGroupResize,
|
||||
placementOps,
|
||||
handleToggleTrackHidden,
|
||||
setAudioGroupAttribute,
|
||||
handleGroupClips,
|
||||
|
||||
@@ -37,6 +37,7 @@ export function TimelineEditProvider({
|
||||
value.onMoveElement,
|
||||
value.onMoveElements,
|
||||
value.onResizeElement,
|
||||
value.onPlacementOps,
|
||||
value.onToggleTrackHidden,
|
||||
value.onSetAudioGroupAttributeLive,
|
||||
value.onSetAudioGroupAttributeQuiet,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { trackStudioRazorSplit } from "../telemetry/events";
|
||||
import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
|
||||
import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
|
||||
import type { RecordEditInput } from "./timelineEditingHelpers";
|
||||
import type { PlacementFold } from "../player/components/timelinePlacementCommit";
|
||||
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
||||
|
||||
interface UseRazorSplitOptions {
|
||||
projectId: string | null;
|
||||
@@ -50,7 +52,12 @@ export function useRazorSplit({
|
||||
}, [forceReloadSdkSession, reloadPreview]);
|
||||
|
||||
const runCut = useCallback(
|
||||
async (elements: readonly TimelineElement[], splitTime: number, mode: "single" | "all") => {
|
||||
async (
|
||||
elements: readonly TimelineElement[],
|
||||
splitTime: number,
|
||||
mode: "single" | "all",
|
||||
fold?: PlacementFold,
|
||||
) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid || elements.length === 0) return;
|
||||
const intents = buildAtomicCutIntents(elements, splitTime, activeCompPath);
|
||||
@@ -64,6 +71,7 @@ export function useRazorSplit({
|
||||
projectId: pid,
|
||||
intents,
|
||||
label,
|
||||
...fold,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
observeProjectFileVersion,
|
||||
@@ -114,6 +122,19 @@ export function useRazorSplit({
|
||||
[isRecordingRef, runCut, showToast],
|
||||
);
|
||||
|
||||
/** One cut inside a clip drop: recorded under the drop's fold key, true once it landed. */
|
||||
const handlePlacementSplit = useCallback(
|
||||
async (element: TimelineElement, splitTime: number, fold: PlacementFold) => {
|
||||
try {
|
||||
return (await runCut([element], splitTime, "single", fold)) !== undefined;
|
||||
} catch (error) {
|
||||
showToast(getStudioSaveErrorMessage(error), "error");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[runCut, showToast],
|
||||
);
|
||||
|
||||
const handleRazorSplitAll = useCallback(
|
||||
async (splitTime: number) => {
|
||||
if (isRecordingRef?.current) {
|
||||
@@ -135,5 +156,5 @@ export function useRazorSplit({
|
||||
[isRecordingRef, runCut, showToast],
|
||||
);
|
||||
|
||||
return { handleRazorSplit, handleRazorSplitAll };
|
||||
return { handleRazorSplit, handleRazorSplitAll, handlePlacementSplit };
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
resolveMainTrackDeleteRippleShifts,
|
||||
resolveShiftedElements,
|
||||
} from "../player/components/timelineGapCommit";
|
||||
import type { PlacementFold } from "../player/components/timelinePlacementCommit";
|
||||
import type {
|
||||
TimelineGroupCommitOptions,
|
||||
TimelineGroupMoveChange,
|
||||
@@ -78,17 +79,17 @@ export function useTimelineDeleteOps({
|
||||
// named in words instead.
|
||||
const rippleNoticeShownRef = useRef(false);
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineElementsDelete = useCallback(
|
||||
const deleteTimelineElements = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (selection: TimelineElement[]) => {
|
||||
async (selection: TimelineElement[], overwrite?: PlacementFold): Promise<boolean> => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
const [element] = selection;
|
||||
if (!element) return;
|
||||
if (!element) return false;
|
||||
const label =
|
||||
selection.length === 1 ? getTimelineElementLabel(element) : `${selection.length} clips`;
|
||||
|
||||
@@ -151,7 +152,8 @@ export function useTimelineDeleteOps({
|
||||
// Shared with the ripple move below so a folded ripple is one undo
|
||||
// step with the delete, not two (editHistory.ts coalesces by key +
|
||||
// window across separate recordEdit calls, not by label).
|
||||
const coalesceKey = `main-track-ripple-delete:${deleteGestureSeq++}`;
|
||||
const coalesceKey =
|
||||
overwrite?.coalesceKey ?? `main-track-ripple-delete:${deleteGestureSeq++}`;
|
||||
const deleteHistoryLabel = "Delete timeline clip";
|
||||
try {
|
||||
await saveProjectFilesWithHistory({
|
||||
@@ -159,6 +161,7 @@ export function useTimelineDeleteOps({
|
||||
label: deleteHistoryLabel,
|
||||
kind: "timeline",
|
||||
coalesceKey,
|
||||
coalesceMs: overwrite?.coalesceMs,
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
// remove-element already wrote the removal, so disk holds THAT — not the
|
||||
@@ -179,11 +182,14 @@ export function useTimelineDeleteOps({
|
||||
// first (resolveMainTrackDeleteRippleShifts — off, no main-track clip
|
||||
// deleted, already gapless, or a locked survivor all resolve to null),
|
||||
// then the one write, folded into the delete's undo entry above.
|
||||
const rippleShifts = resolveMainTrackDeleteRippleShifts(
|
||||
survivors,
|
||||
sameFile,
|
||||
usePlayerStore.getState().rippleEditEnabled,
|
||||
);
|
||||
// An overwrite never ripples: the dropped clip fills the span it removed.
|
||||
const rippleShifts = overwrite
|
||||
? null
|
||||
: resolveMainTrackDeleteRippleShifts(
|
||||
survivors,
|
||||
sameFile,
|
||||
usePlayerStore.getState().rippleEditEnabled,
|
||||
);
|
||||
let rippleApplied: TimelineGroupMoveChange[] | null = null;
|
||||
let rippleFailed = false;
|
||||
if (rippleShifts) {
|
||||
@@ -212,13 +218,15 @@ export function useTimelineDeleteOps({
|
||||
}
|
||||
|
||||
usePlayerStore.getState().setElements(applyRippleShifts(survivors, rippleApplied));
|
||||
usePlayerStore.getState().setSelectedElementId(null);
|
||||
usePlayerStore.getState().setSelectedElementIds(new Set());
|
||||
if (!overwrite) {
|
||||
usePlayerStore.getState().setSelectedElementId(null);
|
||||
usePlayerStore.getState().setSelectedElementIds(new Set());
|
||||
}
|
||||
forceReloadSdkSession?.();
|
||||
reloadPreview();
|
||||
// A failed ripple already showed its own toast above; the user did one
|
||||
// thing (delete), so they get one message, not this generic follow-up too.
|
||||
if (!rippleFailed) {
|
||||
if (!rippleFailed && !overwrite) {
|
||||
showToast(
|
||||
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
||||
"info",
|
||||
@@ -232,9 +240,11 @@ export function useTimelineDeleteOps({
|
||||
"info",
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
||||
showToast(message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -252,13 +262,25 @@ export function useTimelineDeleteOps({
|
||||
],
|
||||
);
|
||||
|
||||
const handleTimelineElementsDelete = useCallback(
|
||||
async (selection: TimelineElement[]) => {
|
||||
await deleteTimelineElements(selection);
|
||||
},
|
||||
[deleteTimelineElements],
|
||||
);
|
||||
|
||||
/** Single-clip delete — the context menu and clip chrome path. */
|
||||
const handleTimelineElementDelete = useCallback(
|
||||
async (element: TimelineElement) => {
|
||||
await handleTimelineElementsDelete([element]);
|
||||
await deleteTimelineElements([element]);
|
||||
},
|
||||
[handleTimelineElementsDelete],
|
||||
[deleteTimelineElements],
|
||||
);
|
||||
|
||||
return { handleTimelineElementsDelete, handleTimelineElementDelete };
|
||||
return {
|
||||
handleTimelineElementsDelete,
|
||||
handleTimelineElementDelete,
|
||||
/** The delete a clip drop uses to remove an overwritten clip: no ripple, folded into its undo step. */
|
||||
deleteTimelineElements,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// fallow-ignore-file complexity
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { useRazorSplit } from "./useRazorSplit";
|
||||
import type { PlacementOps } from "../player/components/timelinePlacementCommit";
|
||||
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
||||
import {
|
||||
applyTimelineStackingReorder,
|
||||
@@ -402,19 +403,20 @@ export function useTimelineEditing({
|
||||
isRecordingRef,
|
||||
});
|
||||
|
||||
const { handleTimelineElementsDelete, handleTimelineElementDelete } = useTimelineDeleteOps({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
previewIframeRef,
|
||||
handleTimelineGroupMove: groupEditing.handleTimelineGroupMove,
|
||||
});
|
||||
const { handleTimelineElementsDelete, handleTimelineElementDelete, deleteTimelineElements } =
|
||||
useTimelineDeleteOps({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
previewIframeRef,
|
||||
handleTimelineGroupMove: groupEditing.handleTimelineGroupMove,
|
||||
});
|
||||
|
||||
const { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop } =
|
||||
useTimelineAssetDropOps({
|
||||
@@ -433,7 +435,7 @@ export function useTimelineEditing({
|
||||
|
||||
const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
|
||||
|
||||
const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
|
||||
const { handleRazorSplit, handleRazorSplitAll, handlePlacementSplit } = useRazorSplit({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
@@ -445,7 +447,17 @@ export function useTimelineEditing({
|
||||
forceReloadSdkSession,
|
||||
});
|
||||
|
||||
const placementOps = useMemo<PlacementOps>(
|
||||
() => ({
|
||||
split: handlePlacementSplit,
|
||||
remove: deleteTimelineElements,
|
||||
toast: (message) => showToast(message, "error"),
|
||||
}),
|
||||
[handlePlacementSplit, deleteTimelineElements, showToast],
|
||||
);
|
||||
|
||||
return {
|
||||
placementOps,
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
handleToggleTrackHidden,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
||||
import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
||||
import type { PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
import type { PlacementOps } from "./timelinePlacementCommit";
|
||||
|
||||
export interface TimelinePropertyGroupKeyframeToggle {
|
||||
animationId: string;
|
||||
@@ -54,6 +55,8 @@ export interface TimelineEditCallbacks {
|
||||
operation?: TimelineMoveOperation,
|
||||
coalesceMs?: number,
|
||||
) => Promise<void> | void;
|
||||
/** Split and remove writes a clip drop needs when it cuts a neighbour (one shared undo step). */
|
||||
onPlacementOps?: PlacementOps;
|
||||
onResizeElement?: (
|
||||
element: TimelineElement,
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
@@ -65,7 +68,7 @@ export interface TimelineEditCallbacks {
|
||||
duration: number;
|
||||
playbackStart?: number;
|
||||
}>,
|
||||
options?: { coalesceKey?: string },
|
||||
options?: { coalesceKey?: string; coalesceMs?: number },
|
||||
) => Promise<void> | void;
|
||||
/**
|
||||
* `displayNumber` is the row the CLICKED control announced. It travels with
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import { runLaneZGesture } from "../../components/nle/zLaneGesture";
|
||||
import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
|
||||
import { authoredTrackForLane } from "./timelineAuthoredTrack";
|
||||
import { commitPlacementDrop, type PlacementOps } from "./timelinePlacementCommit";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
|
||||
type StartTrack = Pick<TimelineElement, "start" | "track">;
|
||||
export interface TimelineMoveEdit {
|
||||
@@ -76,6 +78,12 @@ export interface DragCommitDeps {
|
||||
onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => Promise<unknown> | void;
|
||||
/** Converge the preview manifest after the complete lane + z transaction. */
|
||||
refreshAfterLaneMove?: () => void;
|
||||
/** Trims for a drop that cuts a neighbour. */
|
||||
onResizeElements?: TimelineEditCallbacks["onResizeElements"];
|
||||
/** Split and remove writes for a drop that cuts a neighbour. */
|
||||
placementOps?: PlacementOps;
|
||||
/** Alt or Cmd held at pointer-up: push what follows instead of overwriting. */
|
||||
insertMode?: boolean;
|
||||
}
|
||||
|
||||
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
||||
@@ -240,6 +248,22 @@ export function commitDraggedClipMove(rawDrag: DraggedClipState, deps: DragCommi
|
||||
const isVertical = isInsert || aimTrack !== drag.element.track;
|
||||
const multi = resolveMultiSelection(drag, deps);
|
||||
|
||||
if (!isInsert && !multi) {
|
||||
const mode = deps.insertMode ? "insert" : "overwrite";
|
||||
const move = (edits: TimelineMoveEdit[], fold: { coalesceKey: string; coalesceMs: number }) =>
|
||||
refreshAfterDurableLaneMove(
|
||||
persistMoveEdits(
|
||||
edits,
|
||||
deps,
|
||||
fold.coalesceKey,
|
||||
edits.some((e) => e.updates.track !== e.element.track) ? "lane-reorder" : "timing",
|
||||
fold.coalesceMs,
|
||||
),
|
||||
deps,
|
||||
);
|
||||
if (commitPlacementDrop(drag, deps, mode, move)) return;
|
||||
}
|
||||
|
||||
// ── Pure time-move (dragged clip keeps its lane, no insert) ─────────────────
|
||||
if (!isInsert && !laneChanged) {
|
||||
const delta = drag.previewStart - drag.element.start;
|
||||
|
||||
@@ -74,6 +74,7 @@ interface TimelineClipDragGestureLifecycleInput {
|
||||
((patches: StackingPatch[]) => Promise<unknown> | void) | undefined
|
||||
>;
|
||||
refreshAfterLaneMoveRef: RefObject<(() => void) | undefined>;
|
||||
onPlacementOpsRef: RefObject<TimelineEditCallbacks["onPlacementOps"]>;
|
||||
}
|
||||
|
||||
export function mountTimelineClipDragGestureLifecycle({
|
||||
@@ -109,6 +110,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
||||
readZIndexRef,
|
||||
onStackingPatchesRef,
|
||||
refreshAfterLaneMoveRef,
|
||||
onPlacementOpsRef,
|
||||
}: TimelineClipDragGestureLifecycleInput): () => void {
|
||||
const clearSuppressedClick = () => {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -277,7 +279,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
||||
if (blocked.started) clearSuppressedClick();
|
||||
};
|
||||
|
||||
const commitDragPointerUp = (drag: DraggedClipState) => {
|
||||
const commitDragPointerUp = (drag: DraggedClipState, insertMode: boolean) => {
|
||||
if (!drag.started) return;
|
||||
suppressClickRef.current = true;
|
||||
clearSuppressedClick();
|
||||
@@ -291,6 +293,9 @@ export function mountTimelineClipDragGestureLifecycle({
|
||||
readZIndex: readZIndexRef.current,
|
||||
onStackingPatches: onStackingPatchesRef.current,
|
||||
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
|
||||
onResizeElements: onResizeElementsRef.current,
|
||||
placementOps: onPlacementOpsRef.current,
|
||||
insertMode,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -333,12 +338,12 @@ export function mountTimelineClipDragGestureLifecycle({
|
||||
return claimed;
|
||||
};
|
||||
|
||||
const commitClaimedGesture = (gesture: TimelineGestureCommit) => {
|
||||
const commitClaimedGesture = (gesture: TimelineGestureCommit, insertMode: boolean) => {
|
||||
try {
|
||||
if (gesture.kind === "resize" && gesture.resize) {
|
||||
commitResizePointerUp(gesture.resize, gesture.groupResize);
|
||||
} else if (gesture.kind === "drag" && gesture.drag) {
|
||||
commitDragPointerUp(gesture.drag);
|
||||
commitDragPointerUp(gesture.drag, insertMode);
|
||||
}
|
||||
} finally {
|
||||
const lifecycle = lifecycleRef.current;
|
||||
@@ -352,7 +357,7 @@ export function mountTimelineClipDragGestureLifecycle({
|
||||
const claimed = claimActiveGesture(event);
|
||||
if (claimed === "ignored") return;
|
||||
if (claimed) {
|
||||
commitClaimedGesture(claimed);
|
||||
commitClaimedGesture(claimed, event.altKey || event.metaKey);
|
||||
return;
|
||||
}
|
||||
const blocked = blockedClipRef.current;
|
||||
|
||||
@@ -104,7 +104,9 @@ function resolveDropPlacement(
|
||||
desiredTrack: number,
|
||||
ctx: DragPreviewContext,
|
||||
): { track: number; insertRow: number | null } {
|
||||
const { scroll, trackOrder, rowHeights, elements } = ctx;
|
||||
const { scroll, trackOrder, rowHeights, elements, selectedKeys } = ctx;
|
||||
const dragKey = drag.element.key ?? drag.element.id;
|
||||
const isGroupDrag = selectedKeys.size > 1 && selectedKeys.has(dragKey);
|
||||
// rowFloat = the pointer's position in track-heights from the top lane; a
|
||||
// near-boundary hover requests a deliberate new-track insert. Uses the
|
||||
// shared row→y inverse so the top breathing pad is subtracted consistently.
|
||||
@@ -134,9 +136,10 @@ function resolveDropPlacement(
|
||||
deliberateInsertRow: rawInsertRow,
|
||||
start: previewStart,
|
||||
duration: drag.element.duration,
|
||||
dragKey: drag.element.key ?? drag.element.id,
|
||||
dragKey,
|
||||
isAudio: isAudioTimelineElement(drag.element),
|
||||
preferInsertAbove,
|
||||
relocateOnOverlap: isGroupDrag,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +285,7 @@ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overl
|
||||
duration: 2,
|
||||
dragKey: "x",
|
||||
isAudio: false,
|
||||
relocateOnOverlap: true,
|
||||
};
|
||||
|
||||
it("lands on the aimed track when it is free at that time", () => {
|
||||
@@ -516,3 +517,45 @@ describe("resolveMainTrackDropStart (magnetic first clip on an empty main track)
|
||||
expect(resolveMainTrackDropStart([], 1, 0, true, 7)).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveZoneDropPlacement overwrite (a single clip does not relocate)", () => {
|
||||
const base = {
|
||||
order: [0, 1, 2, 3],
|
||||
audioTracks: new Set([3]),
|
||||
deliberateInsertRow: null as number | null,
|
||||
start: 2,
|
||||
duration: 2,
|
||||
dragKey: "x",
|
||||
isAudio: false,
|
||||
};
|
||||
|
||||
it("stays on the aimed lane over an overlapping clip", () => {
|
||||
expect(
|
||||
resolveZoneDropPlacement({ ...base, elements: [el("a", 1, 0, 5)], desiredTrack: 1 }),
|
||||
).toEqual({ track: 1, insertRow: null });
|
||||
});
|
||||
|
||||
it("stays on the aimed lane when every lane is occupied", () => {
|
||||
const full = [el("a", 0, 0, 9), el("b", 1, 0, 9), el("c", 2, 0, 9)];
|
||||
expect(resolveZoneDropPlacement({ ...base, elements: full, desiredTrack: 1 })).toEqual({
|
||||
track: 1,
|
||||
insertRow: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("still honours a deliberate new-track insert", () => {
|
||||
expect(
|
||||
resolveZoneDropPlacement({
|
||||
...base,
|
||||
elements: [el("a", 1, 0, 5)],
|
||||
desiredTrack: 1,
|
||||
deliberateInsertRow: 1,
|
||||
}),
|
||||
).toEqual({ track: 1, insertRow: 1 });
|
||||
});
|
||||
|
||||
it("still creates a track for an aim that is not a real lane", () => {
|
||||
const result = resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: -1 });
|
||||
expect(result.insertRow).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +84,7 @@ function outOfRangeZoneInsertRow(
|
||||
return desired < Math.min(...zoneTracks) ? zoneTop : zoneBottom;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function resolveZoneDropPlacement(input: {
|
||||
order: number[];
|
||||
audioTracks: ReadonlySet<number>;
|
||||
@@ -95,6 +96,8 @@ export function resolveZoneDropPlacement(input: {
|
||||
dragKey: string;
|
||||
isAudio: boolean;
|
||||
preferInsertAbove?: boolean;
|
||||
/** Group drags keep the old bump-to-a-free-lane rule; a single clip overwrites in place. */
|
||||
relocateOnOverlap?: boolean;
|
||||
}): { track: number; insertRow: number | null } {
|
||||
const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input;
|
||||
const { start, duration, dragKey, isAudio, preferInsertAbove } = input;
|
||||
@@ -108,6 +111,9 @@ export function resolveZoneDropPlacement(input: {
|
||||
}
|
||||
|
||||
const desired = clampTrackToZone(desiredTrack, order, audioRow, isAudio);
|
||||
if (!input.relocateOnOverlap && order.includes(desired)) {
|
||||
return { track: desired, insertRow: null };
|
||||
}
|
||||
const zoneTracks = order.filter((t) => audioTracks.has(t) === isAudio);
|
||||
const placement = resolvePlacement({
|
||||
elements,
|
||||
|
||||
@@ -181,7 +181,7 @@ function elementKey(element: TimelineElement): string {
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
|
||||
function hasSourcePlaybackOffset(element: TimelineElement): boolean {
|
||||
export function hasSourcePlaybackOffset(element: TimelineElement): boolean {
|
||||
const tag = element.tag.toLowerCase();
|
||||
return element.kind === "composition" || tag === "audio" || tag === "video";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { DragCommitDeps, TimelineMoveEdit } from "./timelineClipDragCommit";
|
||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
import type { PlacementResult } from "./timelinePlacement";
|
||||
import {
|
||||
buildPlacementSteps,
|
||||
commitPlacementDrop,
|
||||
placementRefusal,
|
||||
runPlacementSteps,
|
||||
type PlacementFold,
|
||||
type PlacementOps,
|
||||
type PlacementResizeChange,
|
||||
} from "./timelinePlacementCommit";
|
||||
import {
|
||||
buildEditHistoryEntry,
|
||||
createEmptyEditHistory,
|
||||
hashEditHistoryContent,
|
||||
pushEditHistoryEntry,
|
||||
undoEditHistory,
|
||||
type EditHistoryState,
|
||||
} from "../../utils/editHistory";
|
||||
|
||||
function clip(id: string, start: number, duration: number, extra: Partial<TimelineElement> = {}) {
|
||||
return { id, domId: id, tag: "video", start, duration, track: 1, ...extra } as TimelineElement;
|
||||
}
|
||||
|
||||
const dragged = clip("d", 20, 2);
|
||||
const draggedEdit = (start: number): TimelineMoveEdit => ({
|
||||
element: dragged,
|
||||
updates: { start, track: 1 },
|
||||
});
|
||||
const result = (r: Partial<PlacementResult>): PlacementResult => ({
|
||||
track: 1,
|
||||
start: 0,
|
||||
cuts: [],
|
||||
shifts: [],
|
||||
...r,
|
||||
});
|
||||
|
||||
describe("buildPlacementSteps", () => {
|
||||
const a = clip("a", 0, 4);
|
||||
const b = clip("b", 4, 4, { playbackStart: 1, playbackRate: 2 });
|
||||
|
||||
it("removes a covered clip and then moves the dragged clip", () => {
|
||||
const steps = buildPlacementSteps({
|
||||
result: result({ start: 0, cuts: [{ kind: "remove", key: "a" }] }),
|
||||
mode: "overwrite",
|
||||
laneClips: [a],
|
||||
draggedEdit: draggedEdit(0),
|
||||
});
|
||||
expect(steps).toEqual([
|
||||
{ kind: "remove", elements: [a] },
|
||||
{ kind: "move", edits: [draggedEdit(0)] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("trims a tail without touching its start", () => {
|
||||
const steps = buildPlacementSteps({
|
||||
result: result({ start: 2, cuts: [{ kind: "trim-tail", key: "a", duration: 2 }] }),
|
||||
mode: "overwrite",
|
||||
laneClips: [a],
|
||||
draggedEdit: draggedEdit(2),
|
||||
});
|
||||
expect(steps[0]).toEqual({
|
||||
kind: "resize",
|
||||
changes: [{ element: a, start: 0, duration: 2 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("trims a head and moves its source in-point by the cut at the clip's own rate", () => {
|
||||
const steps = buildPlacementSteps({
|
||||
result: result({
|
||||
start: 2,
|
||||
cuts: [{ kind: "trim-head", key: "b", start: 6, duration: 2, sourceShift: 2 }],
|
||||
}),
|
||||
mode: "overwrite",
|
||||
laneClips: [b],
|
||||
draggedEdit: draggedEdit(4),
|
||||
});
|
||||
// 1s in-point + 2s of timeline cut at 2x rate = 5s into the source.
|
||||
expect(steps[0]).toEqual({
|
||||
kind: "resize",
|
||||
changes: [{ element: b, start: 6, duration: 2, playbackStart: 5 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the playback start of a clip with no source alone", () => {
|
||||
const text = clip("t", 4, 4, { tag: "div" });
|
||||
const steps = buildPlacementSteps({
|
||||
result: result({
|
||||
cuts: [{ kind: "trim-head", key: "t", start: 6, duration: 2, sourceShift: 2 }],
|
||||
}),
|
||||
mode: "overwrite",
|
||||
laneClips: [text],
|
||||
draggedEdit: draggedEdit(4),
|
||||
});
|
||||
expect(steps[0]).toMatchObject({
|
||||
changes: [{ start: 6, duration: 2, playbackStart: undefined }],
|
||||
});
|
||||
});
|
||||
|
||||
it("splits at the end of the drop range, then trims the original with its post-split length", () => {
|
||||
const steps = buildPlacementSteps({
|
||||
result: result({
|
||||
start: 1,
|
||||
cuts: [
|
||||
{
|
||||
kind: "split",
|
||||
key: "a",
|
||||
headDuration: 1,
|
||||
tail: { start: 3, duration: 1, sourceShift: 3 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
mode: "overwrite",
|
||||
laneClips: [a],
|
||||
draggedEdit: draggedEdit(1),
|
||||
});
|
||||
expect(steps).toEqual([
|
||||
{ kind: "split", element: a, at: 3 },
|
||||
{
|
||||
kind: "resize",
|
||||
changes: [{ element: { ...a, duration: 3 }, start: 0, duration: 1 }],
|
||||
},
|
||||
{ kind: "move", edits: [draggedEdit(1)] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("insert pushes later clips before the dragged clip lands", () => {
|
||||
const steps = buildPlacementSteps({
|
||||
result: result({ start: 4, shifts: [{ key: "b", start: 6 }] }),
|
||||
mode: "insert",
|
||||
laneClips: [b],
|
||||
draggedEdit: draggedEdit(4),
|
||||
});
|
||||
expect(steps).toEqual([
|
||||
{ kind: "move", edits: [{ element: b, updates: { start: 6, track: 1 } }] },
|
||||
{ kind: "move", edits: [draggedEdit(4)] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("insert into a straddled clip pushes it, splits at the pushed drop end, moves the head back", () => {
|
||||
const steps = buildPlacementSteps({
|
||||
result: result({
|
||||
start: 6,
|
||||
cuts: [
|
||||
{
|
||||
kind: "split",
|
||||
key: "b",
|
||||
headDuration: 2,
|
||||
tail: { start: 8, duration: 2, sourceShift: 2 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
mode: "insert",
|
||||
laneClips: [b],
|
||||
draggedEdit: draggedEdit(6),
|
||||
});
|
||||
const pushed = { ...b, start: 6 };
|
||||
expect(steps).toEqual([
|
||||
{ kind: "move", edits: [{ element: b, updates: { start: 6, track: 1 } }] },
|
||||
{ kind: "split", element: pushed, at: 8 },
|
||||
{
|
||||
kind: "move",
|
||||
edits: [
|
||||
{ element: { ...pushed, duration: 2 }, updates: { start: 4, track: 1 } },
|
||||
draggedEdit(6),
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("placementRefusal", () => {
|
||||
const cutA = result({ start: 0, cuts: [{ kind: "remove", key: "a" }] });
|
||||
|
||||
it("allows a clean overwrite", () => {
|
||||
expect(placementRefusal(cutA, "overwrite", [clip("a", 0, 4)])).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses when a clip that would be cut is locked", () => {
|
||||
expect(
|
||||
placementRefusal(cutA, "overwrite", [clip("a", 0, 4, { timelineLocked: true })]),
|
||||
).toMatch(/locked or expanded/);
|
||||
});
|
||||
|
||||
it("refuses when a clip that would be cut is an expanded child", () => {
|
||||
expect(
|
||||
placementRefusal(cutA, "overwrite", [clip("a", 0, 4, { expandedParentStart: 2 })]),
|
||||
).toMatch(/locked or expanded/);
|
||||
});
|
||||
|
||||
it("refuses when a clip that would be pushed is locked", () => {
|
||||
const push = result({ start: 0, shifts: [{ key: "a", start: 2 }] });
|
||||
expect(placementRefusal(push, "insert", [clip("a", 0, 4, { timelineLocked: true })])).toMatch(
|
||||
/locked or expanded/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a split closer to a clip edge than the split epsilon", () => {
|
||||
const split = result({
|
||||
start: 0,
|
||||
cuts: [
|
||||
{
|
||||
kind: "split",
|
||||
key: "a",
|
||||
headDuration: 3.98,
|
||||
tail: { start: 3.99, duration: 0.01, sourceShift: 3.99 },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(placementRefusal(split, "overwrite", [clip("a", 0, 4)])).toMatch(/split/);
|
||||
});
|
||||
});
|
||||
|
||||
interface Doc {
|
||||
[id: string]: { start: number; duration: number; playbackStart?: number };
|
||||
}
|
||||
|
||||
/** A tiny stand-in for the project file plus the history recorder, driven by the real reducer. */
|
||||
function createFakeProject(initial: Doc) {
|
||||
let doc: Doc = structuredClone(initial);
|
||||
let history: EditHistoryState = createEmptyEditHistory();
|
||||
let clock = 0;
|
||||
const serialize = (d: Doc) =>
|
||||
JSON.stringify(Object.entries(d).sort(([x], [y]) => (x < y ? -1 : 1)));
|
||||
const record = (mutate: () => void, label: string, fold: PlacementFold) => {
|
||||
const before = serialize(doc);
|
||||
mutate();
|
||||
history = pushEditHistoryEntry(
|
||||
history,
|
||||
buildEditHistoryEntry({
|
||||
id: `e${clock}`,
|
||||
projectId: "p",
|
||||
label,
|
||||
kind: "timeline",
|
||||
coalesceKey: fold.coalesceKey,
|
||||
coalesceMs: fold.coalesceMs,
|
||||
now: (clock += 1000),
|
||||
files: { "index.html": { before, after: serialize(doc) } },
|
||||
}),
|
||||
);
|
||||
};
|
||||
const current = (element: TimelineElement) => {
|
||||
const found = doc[element.id];
|
||||
if (!found) throw new Error(`${element.id} is not in the document`);
|
||||
// The element a step hands over must describe what the document holds right now.
|
||||
expect({ start: element.start, duration: element.duration }).toEqual({
|
||||
start: found.start,
|
||||
duration: found.duration,
|
||||
});
|
||||
return found;
|
||||
};
|
||||
const ops: PlacementOps = {
|
||||
split: async (element, at, fold) => {
|
||||
record(
|
||||
() => {
|
||||
const target = current(element);
|
||||
const end = target.start + target.duration;
|
||||
doc[`${element.id}-split`] = {
|
||||
start: at,
|
||||
duration: end - at,
|
||||
playbackStart: (target.playbackStart ?? 0) + (at - target.start),
|
||||
};
|
||||
target.duration = at - target.start;
|
||||
},
|
||||
"split",
|
||||
fold,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
remove: async (elements, fold) => {
|
||||
record(
|
||||
() => {
|
||||
for (const element of elements) {
|
||||
current(element);
|
||||
delete doc[element.id];
|
||||
}
|
||||
},
|
||||
"delete",
|
||||
fold,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
toast: vi.fn(),
|
||||
};
|
||||
const resize = async (changes: PlacementResizeChange[], fold: PlacementFold) => {
|
||||
record(
|
||||
() => {
|
||||
for (const change of changes) {
|
||||
const target = current(change.element);
|
||||
target.start = change.start;
|
||||
target.duration = change.duration;
|
||||
if (change.playbackStart != null) target.playbackStart = change.playbackStart;
|
||||
}
|
||||
},
|
||||
"resize",
|
||||
fold,
|
||||
);
|
||||
};
|
||||
const move = async (edits: TimelineMoveEdit[], fold: PlacementFold) => {
|
||||
record(
|
||||
() => {
|
||||
for (const edit of edits) {
|
||||
const found = doc[edit.element.id];
|
||||
if (found) found.start = edit.updates.start;
|
||||
else
|
||||
doc[edit.element.id] = { start: edit.updates.start, duration: edit.element.duration };
|
||||
}
|
||||
},
|
||||
"move",
|
||||
fold,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
return {
|
||||
ops,
|
||||
resize,
|
||||
move,
|
||||
doc: () => doc,
|
||||
history: () => history,
|
||||
undo: () => {
|
||||
const undone = undoEditHistory(
|
||||
history,
|
||||
{ "index.html": hashEditHistoryContent(serialize(doc)) },
|
||||
0,
|
||||
);
|
||||
if (!undone.ok) throw new Error(`undo refused: ${undone.reason}`);
|
||||
return undone.filesToWrite["index.html"];
|
||||
},
|
||||
serializeInitial: () => serialize(initial),
|
||||
};
|
||||
}
|
||||
|
||||
function dragOf(element: TimelineElement, previewStart: number, previewTrack = 1) {
|
||||
return {
|
||||
element,
|
||||
previewStart,
|
||||
previewTrack,
|
||||
insertRow: null,
|
||||
} as unknown as DraggedClipState;
|
||||
}
|
||||
|
||||
function depsOf(elements: TimelineElement[], project: ReturnType<typeof createFakeProject>) {
|
||||
return {
|
||||
elements,
|
||||
trackOrder: [1],
|
||||
updateElement: vi.fn(),
|
||||
placementOps: project.ops,
|
||||
onResizeElements: project.resize,
|
||||
} as unknown as DragCommitDeps;
|
||||
}
|
||||
|
||||
describe("commitPlacementDrop: one undo step for the move and every cut", () => {
|
||||
// Lane 1: a [0,4) and b [4,8) video (b reads its source from 1s at rate 1), dragged d [20,22).
|
||||
const a = clip("a", 0, 4);
|
||||
const b = clip("b", 4, 4, { playbackStart: 1 });
|
||||
const d = clip("d", 20, 2);
|
||||
const start: Doc = {
|
||||
a: { start: 0, duration: 4 },
|
||||
b: { start: 4, duration: 4, playbackStart: 1 },
|
||||
d: { start: 20, duration: 2 },
|
||||
};
|
||||
|
||||
async function drop(at: number, mode: "overwrite" | "insert" = "overwrite", lane = [a, b]) {
|
||||
const project = createFakeProject(start);
|
||||
const placed = commitPlacementDrop(
|
||||
dragOf(d, at),
|
||||
depsOf([...lane, d], project),
|
||||
mode,
|
||||
project.move,
|
||||
);
|
||||
expect(placed).not.toBeNull();
|
||||
await placed;
|
||||
return project;
|
||||
}
|
||||
|
||||
it("trimmed head: the clip underneath starts later and reads its source later", async () => {
|
||||
const project = await drop(4);
|
||||
// d [4,6) leaves a alone and trims b to [6,8), reading the source from 3s.
|
||||
expect(project.doc()).toEqual({
|
||||
a: { start: 0, duration: 4 },
|
||||
b: { start: 6, duration: 2, playbackStart: 3 },
|
||||
d: { start: 4, duration: 2 },
|
||||
});
|
||||
expect(project.history().undo).toHaveLength(1);
|
||||
expect(project.undo()).toBe(project.serializeInitial());
|
||||
});
|
||||
|
||||
it("removed: a clip the drop fully covers is deleted and the drop lands", async () => {
|
||||
const project = createFakeProject({ ...start, d: { start: 20, duration: 6 } });
|
||||
const long = clip("d", 20, 6);
|
||||
await commitPlacementDrop(
|
||||
dragOf(long, 4),
|
||||
depsOf([a, b, long], project),
|
||||
"overwrite",
|
||||
project.move,
|
||||
);
|
||||
expect(project.doc()).toEqual({
|
||||
a: { start: 0, duration: 4 },
|
||||
d: { start: 4, duration: 6 },
|
||||
});
|
||||
expect(project.history().undo).toHaveLength(1);
|
||||
expect(project.undo()).toBe(project.serializeInitial());
|
||||
});
|
||||
|
||||
it("trimmed tail: the drop over a tail shortens the clip underneath", async () => {
|
||||
const project = await drop(2);
|
||||
// d [2,4): a loses [2,4) so it ends at 2.
|
||||
expect(project.doc().a).toEqual({ start: 0, duration: 2 });
|
||||
expect(project.doc().d).toEqual({ start: 2, duration: 2 });
|
||||
expect(project.history().undo).toHaveLength(1);
|
||||
expect(project.undo()).toBe(project.serializeInitial());
|
||||
});
|
||||
|
||||
it("split: a drop inside a clip leaves a head and a tail, one undo removes the tail too", async () => {
|
||||
const project = await drop(1, "overwrite", [a]);
|
||||
// d [1,3) inside a [0,4): head [0,1), tail [3,4) reading the source from 3s.
|
||||
expect(project.doc()).toEqual({
|
||||
a: { start: 0, duration: 1 },
|
||||
"a-split": { start: 3, duration: 1, playbackStart: 3 },
|
||||
d: { start: 1, duration: 2 },
|
||||
});
|
||||
expect(project.history().undo).toHaveLength(1);
|
||||
expect(project.undo()).toBe(project.serializeInitial());
|
||||
});
|
||||
|
||||
it("insert: what follows is pushed right and the straddled clip is split around the drop", async () => {
|
||||
const project = await drop(6, "insert", [b]);
|
||||
// d [6,8) into b [4,8): head [4,6), tail [8,10) reading the source from 3s.
|
||||
expect(project.doc()).toEqual({
|
||||
b: { start: 4, duration: 2, playbackStart: 1 },
|
||||
"b-split": { start: 8, duration: 2, playbackStart: 3 },
|
||||
d: { start: 6, duration: 2 },
|
||||
});
|
||||
expect(project.history().undo).toHaveLength(1);
|
||||
expect(project.undo()).toBe(project.serializeInitial());
|
||||
});
|
||||
|
||||
it("refuses the whole drop when a clip it would cut is locked, writing nothing", async () => {
|
||||
const project = createFakeProject(start);
|
||||
const locked = clip("b", 4, 4, { timelineLocked: true });
|
||||
await commitPlacementDrop(
|
||||
dragOf(d, 4),
|
||||
depsOf([a, locked, d], project),
|
||||
"overwrite",
|
||||
project.move,
|
||||
);
|
||||
expect(project.history().undo).toHaveLength(0);
|
||||
expect(project.doc()).toEqual(start);
|
||||
expect(project.ops.toast).toHaveBeenCalledWith(expect.stringMatching(/locked or expanded/));
|
||||
});
|
||||
|
||||
it("leaves a drop that touches nothing to the plain move paths", () => {
|
||||
const project = createFakeProject(start);
|
||||
expect(
|
||||
commitPlacementDrop(dragOf(d, 10), depsOf([a, b, d], project), "overwrite", project.move),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a drop that did not move to the plain move paths", () => {
|
||||
const project = createFakeProject(start);
|
||||
expect(
|
||||
commitPlacementDrop(dragOf(d, 20), depsOf([a, b, d], project), "overwrite", project.move),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runPlacementSteps failures", () => {
|
||||
const steps = [
|
||||
{ kind: "split", element: clip("a", 0, 4), at: 2 },
|
||||
{ kind: "move", edits: [] },
|
||||
] as const;
|
||||
|
||||
it("names a partial apply and stops when a later step fails", async () => {
|
||||
const toast = vi.fn();
|
||||
await runPlacementSteps(steps, {
|
||||
ops: { split: async () => true, remove: async () => true, toast },
|
||||
resize: vi.fn(),
|
||||
move: async () => false,
|
||||
});
|
||||
expect(toast).toHaveBeenCalledWith("Overwrite partly applied, Undo restores it");
|
||||
});
|
||||
|
||||
it("says nothing extra when the first step fails, since nothing was applied", async () => {
|
||||
const toast = vi.fn();
|
||||
const move = vi.fn(async () => true);
|
||||
await runPlacementSteps(steps, {
|
||||
ops: { split: async () => false, remove: async () => true, toast },
|
||||
resize: vi.fn(),
|
||||
move,
|
||||
});
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
expect(move).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a rejected step as a failure", async () => {
|
||||
const toast = vi.fn();
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
await runPlacementSteps(steps, {
|
||||
ops: { split: async () => true, remove: async () => true, toast },
|
||||
resize: vi.fn(),
|
||||
move: async () => {
|
||||
throw new Error("write failed");
|
||||
},
|
||||
});
|
||||
expect(toast).toHaveBeenCalledWith("Overwrite partly applied, Undo restores it");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
import type { DragCommitDeps, TimelineMoveEdit } from "./timelineClipDragCommit";
|
||||
import { canMoveTimelineElement } from "./timelineAuthoredMoveTarget";
|
||||
import { authoredTrackForLane } from "./timelineAuthoredTrack";
|
||||
import { round3 } from "./timelineGaps";
|
||||
import { hasSourcePlaybackOffset } from "./timelineGroupEditing";
|
||||
import {
|
||||
placeClip,
|
||||
type PlacementMode,
|
||||
type PlacementResult,
|
||||
type PlacementShift,
|
||||
} from "./timelinePlacement";
|
||||
import { canSplitElementAt } from "../../utils/timelineElementSplit";
|
||||
|
||||
/** One shared history key and an unbounded window: every write of a drop is one undo step. */
|
||||
export interface PlacementFold {
|
||||
coalesceKey: string;
|
||||
coalesceMs: number;
|
||||
}
|
||||
|
||||
export interface PlacementResizeChange {
|
||||
element: TimelineElement;
|
||||
start: number;
|
||||
duration: number;
|
||||
playbackStart?: number;
|
||||
}
|
||||
|
||||
/** The two writes a drop needs beyond move and resize; both must record with the fold key. */
|
||||
export interface PlacementOps {
|
||||
split: (element: TimelineElement, at: number, fold: PlacementFold) => Promise<boolean>;
|
||||
remove: (elements: TimelineElement[], fold: PlacementFold) => Promise<boolean>;
|
||||
toast: (message: string) => void;
|
||||
}
|
||||
|
||||
export type PlacementStep =
|
||||
| { kind: "split"; element: TimelineElement; at: number }
|
||||
| { kind: "remove"; elements: TimelineElement[] }
|
||||
| { kind: "resize"; changes: PlacementResizeChange[] }
|
||||
| { kind: "move"; edits: TimelineMoveEdit[] };
|
||||
|
||||
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
||||
|
||||
const moveEdit = (element: TimelineElement, start: number): TimelineMoveEdit => ({
|
||||
element,
|
||||
updates: { start, track: element.track },
|
||||
});
|
||||
|
||||
/** Where a trimmed-in clip starts reading its source: later by the cut time, at its own rate. */
|
||||
function trimmedPlaybackStart(el: TimelineElement, sourceShift: number): number | undefined {
|
||||
if (!hasSourcePlaybackOffset(el)) return el.playbackStart;
|
||||
return round3((el.playbackStart ?? 0) + sourceShift * (el.playbackRate ?? 1));
|
||||
}
|
||||
|
||||
interface PlacementPlanInput {
|
||||
result: PlacementResult;
|
||||
mode: PlacementMode;
|
||||
laneClips: readonly TimelineElement[];
|
||||
draggedEdit: TimelineMoveEdit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placement to ordered writes, needing no new clip id: an overwrite splits at the drop end then
|
||||
* trims the original; an insert pushes the straddled clip, splits it, then moves the head back.
|
||||
*/
|
||||
export function buildPlacementSteps({
|
||||
result,
|
||||
mode,
|
||||
laneClips,
|
||||
draggedEdit,
|
||||
}: PlacementPlanInput): PlacementStep[] {
|
||||
const byKey = new Map(laneClips.map((e) => [keyOf(e), e]));
|
||||
const clip = (key: string): TimelineElement => {
|
||||
const found = byKey.get(key);
|
||||
if (!found) throw new Error(`Placement referenced ${key}, which is not on the target lane`);
|
||||
return found;
|
||||
};
|
||||
const shiftEdits = result.shifts.map((s: PlacementShift) =>
|
||||
moveEdit(clip(s.key), round3(s.start)),
|
||||
);
|
||||
const splits: PlacementStep[] = [];
|
||||
const removes: TimelineElement[] = [];
|
||||
const resizes: PlacementResizeChange[] = [];
|
||||
const settle: TimelineMoveEdit[] = [];
|
||||
|
||||
for (const cut of result.cuts) {
|
||||
const el = clip(cut.key);
|
||||
switch (cut.kind) {
|
||||
case "remove":
|
||||
removes.push(el);
|
||||
break;
|
||||
case "trim-tail":
|
||||
resizes.push({ element: el, start: el.start, duration: round3(cut.duration) });
|
||||
break;
|
||||
case "trim-head":
|
||||
resizes.push({
|
||||
element: el,
|
||||
start: round3(cut.start),
|
||||
duration: round3(cut.duration),
|
||||
playbackStart: trimmedPlaybackStart(el, cut.sourceShift),
|
||||
});
|
||||
break;
|
||||
case "split": {
|
||||
const at = round3(cut.tail.start);
|
||||
if (mode === "overwrite") {
|
||||
splits.push({ kind: "split", element: el, at });
|
||||
resizes.push({
|
||||
element: { ...el, duration: round3(at - el.start) },
|
||||
start: el.start,
|
||||
duration: round3(cut.headDuration),
|
||||
});
|
||||
break;
|
||||
}
|
||||
const pushed = { ...el, start: round3(el.start + (at - result.start)) };
|
||||
shiftEdits.push(moveEdit(el, pushed.start));
|
||||
splits.push({ kind: "split", element: pushed, at });
|
||||
settle.push(moveEdit({ ...pushed, duration: round3(cut.headDuration) }, el.start));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const steps: PlacementStep[] = [];
|
||||
if (shiftEdits.length > 0) steps.push({ kind: "move", edits: shiftEdits });
|
||||
steps.push(...splits);
|
||||
if (removes.length > 0) steps.push({ kind: "remove", elements: removes });
|
||||
if (resizes.length > 0) steps.push({ kind: "resize", changes: resizes });
|
||||
steps.push({ kind: "move", edits: [...settle, draggedEdit] });
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** Reason the whole drop must be refused, or null. Nothing is written when this is set. */
|
||||
export function placementRefusal(
|
||||
result: PlacementResult,
|
||||
mode: PlacementMode,
|
||||
laneClips: readonly TimelineElement[],
|
||||
): string | null {
|
||||
const byKey = new Map(laneClips.map((e) => [keyOf(e), e]));
|
||||
const touched = [...result.cuts, ...result.shifts].map((c) => byKey.get(c.key));
|
||||
if (touched.some((el) => !el || !canMoveTimelineElement(el) || el.expandedParentStart != null)) {
|
||||
return "Cannot overwrite a locked or expanded clip";
|
||||
}
|
||||
const unsplittable = result.cuts.some((cut) => {
|
||||
if (cut.kind !== "split") return false;
|
||||
const at = mode === "overwrite" ? cut.tail.start : result.start;
|
||||
const el = byKey.get(cut.key);
|
||||
return !el || !canSplitElementAt(el, at);
|
||||
});
|
||||
return unsplittable ? "Cannot split a clip at the drop point" : null;
|
||||
}
|
||||
|
||||
let placementGestureSeq = 0;
|
||||
|
||||
interface PlacementRunner {
|
||||
ops: PlacementOps;
|
||||
resize: (changes: PlacementResizeChange[], fold: PlacementFold) => Promise<void> | void;
|
||||
move: (edits: TimelineMoveEdit[], fold: PlacementFold) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function runStep(step: PlacementStep, fold: PlacementFold, run: PlacementRunner) {
|
||||
switch (step.kind) {
|
||||
case "split":
|
||||
return run.ops.split(step.element, step.at, fold);
|
||||
case "remove":
|
||||
return run.ops.remove(step.elements, fold);
|
||||
case "resize":
|
||||
return run.resize(step.changes, fold);
|
||||
case "move":
|
||||
return run.move(step.edits, fold);
|
||||
}
|
||||
}
|
||||
|
||||
/** Each step awaits the previous: the history fold needs every write to start from the last one's output. */
|
||||
export async function runPlacementSteps(
|
||||
steps: readonly PlacementStep[],
|
||||
run: PlacementRunner,
|
||||
): Promise<void> {
|
||||
const fold: PlacementFold = {
|
||||
coalesceKey: `clip-overwrite:${placementGestureSeq++}`,
|
||||
coalesceMs: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
for (const [index, step] of steps.entries()) {
|
||||
let applied = false;
|
||||
try {
|
||||
applied = (await runStep(step, fold, run)) !== false;
|
||||
} catch (error) {
|
||||
console.error("[Timeline] Overwrite step failed", error);
|
||||
}
|
||||
if (applied) continue;
|
||||
if (index > 0) run.ops.toast("Overwrite partly applied, Undo restores it");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Commit a single-clip drop onto its lane's clips; null when it touches none, so plain moves apply. */
|
||||
export function commitPlacementDrop(
|
||||
drag: DraggedClipState,
|
||||
deps: DragCommitDeps,
|
||||
mode: PlacementMode,
|
||||
move: PlacementRunner["move"],
|
||||
): Promise<void> | null {
|
||||
const { placementOps, onResizeElements, elements } = deps;
|
||||
const dragKey = keyOf(drag.element);
|
||||
const laneChanged = drag.previewTrack !== drag.element.track;
|
||||
if (!placementOps || !onResizeElements) return null;
|
||||
if (!laneChanged && drag.previewStart === drag.element.start) return null;
|
||||
|
||||
const laneClips = elements.filter((e) => e.track === drag.previewTrack && keyOf(e) !== dragKey);
|
||||
const result = placeClip({
|
||||
clips: laneClips.map((e) => ({ key: keyOf(e), start: e.start, duration: e.duration })),
|
||||
track: drag.previewTrack,
|
||||
start: drag.previewStart,
|
||||
duration: drag.element.duration,
|
||||
mode,
|
||||
});
|
||||
if (result.cuts.length === 0 && result.shifts.length === 0) return null;
|
||||
|
||||
const refusal = placementRefusal(result, mode, laneClips);
|
||||
if (refusal) {
|
||||
placementOps.toast(refusal);
|
||||
return Promise.resolve();
|
||||
}
|
||||
const draggedEdit: TimelineMoveEdit = {
|
||||
element: drag.element,
|
||||
updates: { start: result.start, track: drag.previewTrack },
|
||||
...(laneChanged
|
||||
? { persistTrack: authoredTrackForLane(drag.previewTrack, elements, drag.element) }
|
||||
: {}),
|
||||
};
|
||||
const steps = buildPlacementSteps({ result, mode, laneClips, draggedEdit });
|
||||
return runPlacementSteps(steps, {
|
||||
ops: placementOps,
|
||||
resize: (changes, fold) => onResizeElements(changes, fold),
|
||||
move,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
||||
import {
|
||||
applyTimelineAutoScrollStep,
|
||||
resolveTimelineAutoScrollLoopAction,
|
||||
@@ -243,6 +244,9 @@ export function useTimelineClipDrag({
|
||||
// It owns a projection only; canonical store timing changes at commit.
|
||||
const groupResizeRef = useRef<TimelineGroupResizeSession | null>(null);
|
||||
|
||||
const { onPlacementOps } = useTimelineEditContextOptional();
|
||||
const onPlacementOpsRef = useRef(onPlacementOps);
|
||||
onPlacementOpsRef.current = onPlacementOps;
|
||||
const onMoveElementRef = useRef(onMoveElement);
|
||||
onMoveElementRef.current = onMoveElement;
|
||||
const onMoveElementsRef = useRef(onMoveElements);
|
||||
@@ -418,6 +422,7 @@ export function useTimelineClipDrag({
|
||||
useMountEffect(() =>
|
||||
mountTimelineClipDragGestureLifecycle({
|
||||
onStackingPatchesRef,
|
||||
onPlacementOpsRef,
|
||||
refreshAfterLaneMoveRef,
|
||||
readZIndexRef,
|
||||
onBlockedEditAttemptRef,
|
||||
|
||||
@@ -166,6 +166,9 @@ interface RunAtomicCutInput {
|
||||
projectId: string;
|
||||
intents: CutFileIntent[];
|
||||
label: string;
|
||||
/** Folds this cut into the undo entry of the writes that share the key. */
|
||||
coalesceKey?: string;
|
||||
coalesceMs?: number;
|
||||
writeProjectFile: ProjectFileWriter;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
||||
@@ -181,7 +184,13 @@ export function runAtomicCutTransaction(input: RunAtomicCutInput): Promise<Atomi
|
||||
result.files.map((file) => [file.path, { before: file.before, after: file.after }]),
|
||||
);
|
||||
try {
|
||||
await input.recordEdit({ label: input.label, kind: "timeline", files: snapshots });
|
||||
await input.recordEdit({
|
||||
label: input.label,
|
||||
kind: "timeline",
|
||||
coalesceKey: input.coalesceKey,
|
||||
coalesceMs: input.coalesceMs,
|
||||
files: snapshots,
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await rollbackUnrecordedCut(result.files, input.writeProjectFile);
|
||||
|
||||
Reference in New Issue
Block a user