feat(studio): dragging a clip over another overwrites it, alt inserts, one undo

This commit is contained in:
Miguel Angel Simon Sierra
2026-09-19 07:03:50 -04:00
parent f7475ec7c4
commit 118aeea3f3
19 changed files with 956 additions and 45 deletions
+1
View File
@@ -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,
+23 -2
View File
@@ -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,
};
}
+27 -15
View File
@@ -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);