diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index cd696541c1..5152f9ac08 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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} diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx index f7b2b9295b..69eab0ac8c 100644 --- a/packages/studio/src/components/EditorShell.tsx +++ b/packages/studio/src/components/EditorShell.tsx @@ -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 ( diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx index ed1dbf4e8d..d03af819c1 100644 --- a/packages/studio/src/components/nle/TimelinePane.tsx +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -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, ) { 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)) { diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts index 788b56282b..7f68419901 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts @@ -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; handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise | void; handleRazorSplitAll: (splitTime: number) => Promise | 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, diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index 4bdedcfbbc..d22bfc0d88 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -37,6 +37,7 @@ export function TimelineEditProvider({ value.onMoveElement, value.onMoveElements, value.onResizeElement, + value.onPlacementOps, value.onToggleTrackHidden, value.onSetAudioGroupAttributeLive, value.onSetAudioGroupAttributeQuiet, diff --git a/packages/studio/src/hooks/useRazorSplit.ts b/packages/studio/src/hooks/useRazorSplit.ts index 91bc44b2cf..9bd1fe902f 100644 --- a/packages/studio/src/hooks/useRazorSplit.ts +++ b/packages/studio/src/hooks/useRazorSplit.ts @@ -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 }; } diff --git a/packages/studio/src/hooks/useTimelineDeleteOps.ts b/packages/studio/src/hooks/useTimelineDeleteOps.ts index 5c0a297dd7..a1d2178bab 100644 --- a/packages/studio/src/hooks/useTimelineDeleteOps.ts +++ b/packages/studio/src/hooks/useTimelineDeleteOps.ts @@ -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 => { 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, + }; } diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index ab6b37df18..cba4f45eb6 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -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( + () => ({ + split: handlePlacementSplit, + remove: deleteTimelineElements, + toast: (message) => showToast(message, "error"), + }), + [handlePlacementSplit, deleteTimelineElements, showToast], + ); + return { + placementOps, handleTimelineElementMove, handleTimelineElementResize, handleToggleTrackHidden, diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts index ad206205e6..a4e29ff3a1 100644 --- a/packages/studio/src/player/components/timelineCallbacks.ts +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -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; + /** Split and remove writes a clip drop needs when it cuts a neighbour (one shared undo step). */ + onPlacementOps?: PlacementOps; onResizeElement?: ( element: TimelineElement, updates: Pick, @@ -65,7 +68,7 @@ export interface TimelineEditCallbacks { duration: number; playbackStart?: number; }>, - options?: { coalesceKey?: string }, + options?: { coalesceKey?: string; coalesceMs?: number }, ) => Promise | void; /** * `displayNumber` is the row the CLICKED control announced. It travels with diff --git a/packages/studio/src/player/components/timelineClipDragCommit.ts b/packages/studio/src/player/components/timelineClipDragCommit.ts index e8bcc409f3..9321df432b 100644 --- a/packages/studio/src/player/components/timelineClipDragCommit.ts +++ b/packages/studio/src/player/components/timelineClipDragCommit.ts @@ -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; export interface TimelineMoveEdit { @@ -76,6 +78,12 @@ export interface DragCommitDeps { onStackingPatches?: (patches: StackingPatch[], coalesceKey?: string) => Promise | 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; diff --git a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts index 674b1f360a..2fd737ed1b 100644 --- a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts +++ b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts @@ -74,6 +74,7 @@ interface TimelineClipDragGestureLifecycleInput { ((patches: StackingPatch[]) => Promise | void) | undefined >; refreshAfterLaneMoveRef: RefObject<(() => void) | undefined>; + onPlacementOpsRef: RefObject; } 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; diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts index a53f7e8764..51362a745b 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.ts @@ -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, }); } diff --git a/packages/studio/src/player/components/timelineCollision.test.ts b/packages/studio/src/player/components/timelineCollision.test.ts index 328c875708..4ae48d5e99 100644 --- a/packages/studio/src/player/components/timelineCollision.test.ts +++ b/packages/studio/src/player/components/timelineCollision.test.ts @@ -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(); + }); +}); diff --git a/packages/studio/src/player/components/timelineCollision.ts b/packages/studio/src/player/components/timelineCollision.ts index ab24667610..23a6e4b99c 100644 --- a/packages/studio/src/player/components/timelineCollision.ts +++ b/packages/studio/src/player/components/timelineCollision.ts @@ -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; @@ -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, diff --git a/packages/studio/src/player/components/timelineGroupEditing.ts b/packages/studio/src/player/components/timelineGroupEditing.ts index e5ec36d7ca..18e8f073be 100644 --- a/packages/studio/src/player/components/timelineGroupEditing.ts +++ b/packages/studio/src/player/components/timelineGroupEditing.ts @@ -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"; } diff --git a/packages/studio/src/player/components/timelinePlacementCommit.test.ts b/packages/studio/src/player/components/timelinePlacementCommit.test.ts new file mode 100644 index 0000000000..2b1f4e6823 --- /dev/null +++ b/packages/studio/src/player/components/timelinePlacementCommit.test.ts @@ -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 = {}) { + 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 => ({ + 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) { + 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"); + }); +}); diff --git a/packages/studio/src/player/components/timelinePlacementCommit.ts b/packages/studio/src/player/components/timelinePlacementCommit.ts new file mode 100644 index 0000000000..dc0e6c1d91 --- /dev/null +++ b/packages/studio/src/player/components/timelinePlacementCommit.ts @@ -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; + remove: (elements: TimelineElement[], fold: PlacementFold) => Promise; + 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; + move: (edits: TimelineMoveEdit[], fold: PlacementFold) => Promise; +} + +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 { + 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 | 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, + }); +} diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 7c49eda117..3b84594762 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -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(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, diff --git a/packages/studio/src/utils/razorSplitTransaction.ts b/packages/studio/src/utils/razorSplitTransaction.ts index 77054df21c..e1e04a4555 100644 --- a/packages/studio/src/utils/razorSplitTransaction.ts +++ b/packages/studio/src/utils/razorSplitTransaction.ts @@ -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; observeProjectFileVersion?: (path: string, version: string | null) => void; @@ -181,7 +184,13 @@ export function runAtomicCutTransaction(input: RunAtomicCutInput): Promise [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);