feat(studio): move selected timeline clips together

Fan selected body drags through a group move session that snapshots all selected members.

The grabbed clip snap result is applied first, then one shared delta is clamped.

Live preview patches every moved member and release persists once through batch timing.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-09 16:54:34 -04:00
parent 45d09047b7
commit 5d27af30b2
11 changed files with 511 additions and 25 deletions
+1
View File
@@ -524,6 +524,7 @@ export function StudioApp() {
handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
handleTimelineGroupMove={timelineEditing.handleTimelineGroupMove}
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
handleToggleElementHidden={timelineEditing.handleToggleElementHidden}
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
@@ -26,6 +26,11 @@ import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
import type { GestureRecordingState } from "./editor/GestureRecordControl";
import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
import type { TimelineGroupMoveChange } from "../hooks/useTimelineGroupEditing";
import {
formatTimelineAttributeNumber,
patchIframeDomTiming,
} from "../hooks/timelineEditingHelpers";
export interface StudioPreviewAreaProps {
timelineToolbar: ReactNode;
@@ -59,6 +64,7 @@ export interface StudioPreviewAreaProps {
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
handleTimelineGroupMove: (changes: TimelineGroupMoveChange[]) => Promise<void> | void;
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
handleToggleElementHidden: (elementKey: string, hidden: boolean) => Promise<void> | void;
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
@@ -86,6 +92,7 @@ export function StudioPreviewArea({
handleTimelineFileDrop,
handleTimelineElementMove,
handleTimelineElementResize,
handleTimelineGroupMove,
handleToggleTrackHidden,
handleToggleElementHidden,
handleBlockedTimelineEdit,
@@ -194,11 +201,24 @@ export function StudioPreviewArea({
[domEditSelection?.id, selectedGsapAnimations],
);
const handleTimelineGroupMovePreview = useCallback(
(changes: TimelineGroupMoveChange[]) => {
for (const change of changes) {
patchIframeDomTiming(previewIframeRef.current, change.element, [
["data-start", formatTimelineAttributeNumber(change.start)],
]);
}
},
[previewIframeRef],
);
// fallow-ignore-next-line complexity
const timelineEditCallbacks = useMemo(
() => ({
onMoveElement: handleTimelineElementMove,
onResizeElement: handleTimelineElementResize,
onMoveElements: handleTimelineGroupMove,
onPreviewMoveElements: handleTimelineGroupMovePreview,
onToggleTrackHidden: handleToggleTrackHidden,
onToggleElementHidden: handleToggleElementHidden,
onBlockedEditAttempt: handleBlockedTimelineEdit,
@@ -207,7 +227,7 @@ export function StudioPreviewArea({
onRazorSplitAll: handleRazorSplitAll,
onDeleteAllKeyframes: () => {
// Hold the element where it is (collapse keyframes to a static set) rather
// than deleting the whole animation deleting strands a stale GSAP base
// than deleting the whole animation, deleting strands a stale GSAP base
// that the next drag adds to, flinging the element off-screen.
const anim = selectedGsapAnimations.find((a) => a.keyframes);
if (!anim) return;
@@ -227,7 +247,7 @@ export function StudioPreviewArea({
// absolute time (via the clip's timing basis) and let resolveKeyframeRetime
// decide: a drop inside the tween window is a plain move (re-key tween-%); a
// drop past the boundary (last keyframe past the end, first before the start)
// resizes the tween position/duration grow so the dragged keyframe lands at
// resizes the tween, position/duration grow so the dragged keyframe lands at
// the drop while every other keyframe keeps its absolute time (value+ease too).
// fallow-ignore-next-line complexity
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
@@ -300,6 +320,8 @@ export function StudioPreviewArea({
[
handleTimelineElementMove,
handleTimelineElementResize,
handleTimelineGroupMove,
handleTimelineGroupMovePreview,
handleToggleTrackHidden,
handleToggleElementHidden,
handleBlockedTimelineEdit,
@@ -343,7 +365,7 @@ export function StudioPreviewArea({
onCompositionLoadingChange={setCompositionLoading}
onCompositionChange={(compPath) => {
// Sync activeCompPath when user drills down via timeline double-click
// or navigates back via breadcrumb keeps sidebar + thumbnails in sync.
// or navigates back via breadcrumb, keeps sidebar + thumbnails in sync.
// Guard against no-op updates to prevent circular refresh cascades
// between activeCompPath → compositionStack → onCompositionChange.
if (compPath !== activeCompPath) {
@@ -10,7 +10,7 @@ export function useTimelineEditContext(): TimelineEditCallbacks {
}
/**
* Optional access returns an empty object when outside a provider.
* Optional access, returns an empty object when outside a provider.
* Useful in components that can render both inside and outside the NLE.
*/
export function useTimelineEditContextOptional(): TimelineEditCallbacks {
@@ -26,7 +26,7 @@ export function TimelineEditProvider({
}) {
const memoized = useMemo(
() => value,
// Each callback is a stable reference from the parent memoize the bag
// Each callback is a stable reference from the parent, memoize the bag
// so consumers don't re-render when unrelated parent state changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -34,6 +34,7 @@ export function TimelineEditProvider({
value.onResizeElement,
value.onMoveElements,
value.onResizeElements,
value.onPreviewMoveElements,
value.onToggleTrackHidden,
value.onToggleElementHidden,
value.onBlockedEditAttempt,
@@ -71,6 +71,8 @@ export const Timeline = memo(function Timeline({
const {
onMoveElement,
onResizeElement,
onMoveElements,
onPreviewMoveElements,
onBlockedEditAttempt,
onSplitElement,
onRazorSplitAll,
@@ -104,7 +106,6 @@ export const Timeline = memo(function Timeline({
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
const playheadRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const activeTool = usePlayerStore((s) => s.activeTool);
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
@@ -153,10 +154,6 @@ export const Timeline = memo(function Timeline({
});
}, [syncShortcutHintVisibility]);
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
containerRef.current = el;
}, []);
const setScrollRef = useCallback(
(el: HTMLDivElement | null) => {
if (roRef.current) {
@@ -225,6 +222,8 @@ export const Timeline = memo(function Timeline({
timelineElementsRef: expandedElementsRef,
onMoveElement,
onResizeElement,
onMoveElements,
onPreviewMoveElements,
onBlockedEditAttempt,
setShowPopover,
setRangeSelectionRef,
@@ -434,7 +433,6 @@ export const Timeline = memo(function Timeline({
return (
<div
ref={setContainerRef}
aria-label="Timeline"
className={`relative border-t select-none h-full overflow-hidden ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
onMouseMove={(e) => {
@@ -47,6 +47,7 @@ export interface TimelineEditCallbacks {
changes: TimelineGroupResizeChange[],
options?: TimelineGroupCommitOptions,
) => Promise<void> | void;
onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void;
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
@@ -10,6 +10,7 @@ import {
resolveTimelineAutoScroll,
resolveTimelineMove,
resolveTimelineResize,
resolveTimelineGroupMove,
snapKeyframePctToBeat,
type TimelinePromptElement,
} from "./timelineEditing";
@@ -220,6 +221,45 @@ describe("resolveTimelineMove", () => {
});
});
describe("resolveTimelineGroupMove", () => {
it("applies an unclamped delta uniformly", () => {
const result = resolveTimelineGroupMove(
[
{ start: 1, duration: 2 },
{ start: 4, duration: 3 },
],
1.25,
);
expect(result).toEqual({
delta: 1.25,
members: [
{ start: 2.25, duration: 2 },
{ start: 5.25, duration: 3 },
],
});
});
it("clamps the whole group when the earliest start reaches zero", () => {
const result = resolveTimelineGroupMove(
[
{ start: 1, duration: 2 },
{ start: 5, duration: 3 },
],
-3,
);
expect(result).toEqual({
delta: -1,
members: [
{ start: 0, duration: 2 },
{ start: 4, duration: 3 },
],
});
expect(result.members[1]!.start - result.members[0]!.start).toBe(4);
});
});
describe("hasPatchableTimelineTarget", () => {
it("returns true when the clip has a DOM id", () => {
expect(hasPatchableTimelineTarget({ domId: "hero-card" })).toBe(true);
@@ -537,7 +577,7 @@ describe("buildTimelineAgentPrompt", () => {
prompt: "Move the title later and lower the music",
});
expect(text).toContain("Time range: 0:01 0:04");
expect(text).toContain("Time range: 0:01 - 0:04");
expect(text).toContain("#title (div)");
expect(text).toContain("#music (audio)");
expect(text).toContain("Move the title later and lower the music");
@@ -4,6 +4,8 @@ import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrde
import { resolveTimelineLayerStackingMove } from "./timelineLayerDrag";
import type { TimelineStackingElement, TimelineStackingReorderIntent } from "./timelineStacking";
export { resolveTimelineGroupMove, type TimelineGroupTimingMember } from "./timelineGroupEditing";
export {
type TimelineStackingElement,
type TimelineStackingReorderIntent,
@@ -109,7 +111,7 @@ export function resolveTimelineMove(
// Stacking mode: the two axes never fight. Horizontal movement writes time
// (nextStart); vertical movement writes z-index. Lane/overlap resolution
// uses the clip's authored time span, NOT the dragged start otherwise a
// uses the clip's authored time span, NOT the dragged start, otherwise a
// diagonal drag that drifts the clip out of overlap silently flips the
// placement from "restack" to "join lane" and cancels the reorder.
if (input.stackingElement) {
@@ -458,13 +460,13 @@ export function buildTimelineAgentPrompt({
const elementLines = elements
.map(
(el) =>
`- #${el.id} (${el.tag}) ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
`- #${el.id} (${el.tag}) - ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
)
.join("\n");
return `Edit the following HyperFrames composition:
Time range: ${formatTime(start)} ${formatTime(end)}
Time range: ${formatTime(start)} - ${formatTime(end)}
Elements in range:
${elementLines || "(none)"}
@@ -0,0 +1,38 @@
import { roundToCenti } from "../../utils/rounding";
function roundTimelineTime(value: number): number {
return roundToCenti(value);
}
export interface TimelineGroupTimingMember {
start: number;
duration: number;
}
export interface TimelineGroupMoveResult {
delta: number;
members: Array<Pick<TimelineGroupTimingMember, "start" | "duration">>;
}
function clampTimelineGroupMoveDelta(
rawDelta: number,
members: readonly TimelineGroupTimingMember[],
): number {
if (members.length === 0) return 0;
const minDelta = Math.max(...members.map((member) => -member.start));
return roundTimelineTime(Math.max(rawDelta, minDelta));
}
export function resolveTimelineGroupMove(
members: readonly TimelineGroupTimingMember[],
rawDelta: number,
): TimelineGroupMoveResult {
const delta = clampTimelineGroupMoveDelta(rawDelta, members);
return {
delta,
members: members.map((member) => ({
start: roundTimelineTime(member.start + delta),
duration: member.duration,
})),
};
}
@@ -14,19 +14,24 @@ import { useTimelineClipDrag } from "./useTimelineClipDrag";
function timelineElement(input: {
id: string;
tag?: string;
track: number;
zIndex: number;
start?: number;
duration?: number;
sourceDuration?: number;
playbackStart?: number;
playbackRate?: number;
}): TimelineElement {
return {
id: input.id,
domId: input.id,
tag: "div",
tag: input.tag ?? "div",
start: input.start ?? 0,
duration: input.duration ?? 2,
sourceDuration: input.sourceDuration,
playbackStart: input.playbackStart,
playbackRate: input.playbackRate,
track: input.track,
zIndex: input.zIndex,
stackingContextId: "root",
@@ -43,11 +48,14 @@ afterEach(() => {
});
function renderDragHarness(elements: TimelineElement[]) {
usePlayerStore.getState().setElements(elements);
const layers = buildStackingTimelineLayers(elements).rows;
const scroll = document.createElement("div");
document.body.append(scroll);
const onMoveElement = vi.fn();
const onResizeElement = vi.fn();
const onMoveElements = vi.fn();
const onPreviewMoveElements = vi.fn();
let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null;
let setResizingClip: ((state: ResizingClipState | null) => void) | null = null;
@@ -60,6 +68,8 @@ function renderDragHarness(elements: TimelineElement[]) {
timelineElementsRef: { current: elements },
onMoveElement,
onResizeElement,
onMoveElements,
onPreviewMoveElements,
onBlockedEditAttempt: vi.fn(),
setShowPopover: vi.fn(),
setRangeSelectionRef: { current: vi.fn() },
@@ -84,7 +94,18 @@ function renderDragHarness(elements: TimelineElement[]) {
layers,
onMoveElement,
onResizeElement,
onMoveElements,
onPreviewMoveElements,
storeElements() {
return usePlayerStore.getState().elements;
},
startDrag(element: TimelineElement, layerIndex: number) {
const layer =
layers[layerIndex] ??
layers.find((candidate) =>
candidate.elements.some((candidateElement) => candidateElement.id === element.id),
) ??
layers[0]!;
act(() => {
applyDraggedClip({
element,
@@ -98,7 +119,7 @@ function renderDragHarness(elements: TimelineElement[]) {
pointerOffsetY: 0,
previewStart: element.start,
previewTrack: element.track,
previewLayerId: layers[layerIndex]!.id,
previewLayerId: layer.id,
previewLayerIndex: layerIndex,
previewStackingReorder: null,
snapBeatTime: null,
@@ -162,6 +183,82 @@ describe("useTimelineClipDrag", () => {
harness.unmount();
});
it("moves every selected clip body by the same delta", async () => {
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 2 });
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 4, duration: 2 });
const harness = renderDragHarness([first, second]);
act(() => {
usePlayerStore.getState().setSelection(["first", "second"], "first");
});
harness.startDrag(first, 0);
harness.movePointer(200, 0);
expect(harness.storeElements().map((element) => [element.id, element.start])).toEqual([
["first", 3],
["second", 6],
]);
expect(harness.onPreviewMoveElements).toHaveBeenLastCalledWith([
{ element: first, start: 3 },
{ element: second, start: 6 },
]);
await harness.dropPointer();
expect(harness.onMoveElement).not.toHaveBeenCalled();
expect(harness.onMoveElements).toHaveBeenCalledTimes(1);
expect(harness.onMoveElements).toHaveBeenCalledWith([
{ element: first, start: 3 },
{ element: second, start: 6 },
]);
harness.unmount();
});
it("clamps a selected group move when the earliest member reaches zero", async () => {
const early = timelineElement({ id: "early", track: 0, zIndex: 1, start: 1, duration: 2 });
const grabbed = timelineElement({ id: "grabbed", track: 1, zIndex: 1, start: 4, duration: 2 });
const harness = renderDragHarness([early, grabbed]);
act(() => {
usePlayerStore.getState().setSelection(["early", "grabbed"], "grabbed");
});
harness.startDrag(grabbed, 1);
harness.movePointer(-300, 0);
await harness.dropPointer();
expect(harness.onMoveElements).toHaveBeenCalledWith([
{ element: early, start: 0 },
{ element: grabbed, start: 3 },
]);
expect(harness.onMoveElement).not.toHaveBeenCalled();
harness.unmount();
});
it("keeps body drag single-clip when the grabbed clip is not in the multi-selection", async () => {
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 2 });
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 4, duration: 2 });
const outside = timelineElement({ id: "outside", track: 2, zIndex: 1, start: 7, duration: 2 });
const harness = renderDragHarness([first, second, outside]);
act(() => {
usePlayerStore.getState().setSelection(["first", "second"], "first");
});
harness.startDrag(outside, 2);
harness.movePointer(200, 0);
await harness.dropPointer();
expect(harness.onMoveElements).not.toHaveBeenCalled();
expect(harness.onMoveElement).toHaveBeenCalledTimes(1);
expect(harness.onMoveElement).toHaveBeenCalledWith(
outside,
expect.objectContaining({ start: 9 }),
);
harness.unmount();
});
it("allows right-edge resize past the current composition duration", async () => {
const clip = timelineElement({ id: "clip", track: 0, zIndex: 1, start: 6, duration: 2 });
const harness = renderDragHarness([clip]);
@@ -178,6 +275,28 @@ describe("useTimelineClipDrag", () => {
harness.unmount();
});
it("keeps handle resize single-clip when the grabbed clip is not in the multi-selection", async () => {
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 2 });
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 4, duration: 2 });
const outside = timelineElement({ id: "outside", track: 2, zIndex: 1, start: 7, duration: 2 });
const harness = renderDragHarness([first, second, outside]);
act(() => {
usePlayerStore.getState().setSelection(["first", "second"], "first");
});
harness.startResize(outside, "end");
harness.movePointer(100, 0);
await harness.dropPointer();
expect(harness.onResizeElement).toHaveBeenCalledTimes(1);
expect(harness.onResizeElement).toHaveBeenCalledWith(
outside,
expect.objectContaining({ start: 7, duration: 3 }),
);
harness.unmount();
});
it("passes a new-lane stacking intent when a vertical drag targets an overlapping lane", async () => {
const front = timelineElement({ id: "front", track: 0, zIndex: 3 });
const middle = timelineElement({ id: "middle", track: 1, zIndex: 2 });
@@ -13,12 +13,14 @@ import { TRACK_H } from "./timelineLayout";
import { isMusicTrack } from "../../utils/timelineInspector";
import { mergeUserBeats } from "../../utils/beatEditing";
import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
import type { TimelineGroupMoveChange } from "../../hooks/useTimelineGroupEditing";
import {
buildTimelineSnapTargets,
snapEdgesToTargets,
snapResizeEdgeToTargets,
type TimelineSnapKind,
} from "./timelineSnapTargets";
import { useTimelineClipGroupDrag } from "./useTimelineClipGroupDrag";
const EMPTY_BEAT_TIMES: number[] = [];
@@ -83,12 +85,58 @@ interface UseTimelineClipDragInput {
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
onMoveElements?: (changes: TimelineGroupMoveChange[]) => Promise<void> | void;
onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
setShowPopover: (show: boolean) => void;
/** Stable ref to the range selection setter wired after mount to break circular dependency. */
/** Stable ref to the range selection setter, wired after mount to break circular dependency. */
setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>;
}
interface TimelineMovePreview {
start: number;
track: number;
previewLayerId?: TimelineLayerId;
previewLayerIndex?: number;
stackingReorder?: TimelineStackingReorderIntent | null;
}
interface TimelineGroupMovePreview {
active: boolean;
previewStart: number;
}
function resolveDragPreviewPlacement(
drag: DraggedClipState,
nextMove: TimelineMovePreview,
groupMove: TimelineGroupMovePreview,
): Pick<
DraggedClipState,
| "previewStart"
| "previewTrack"
| "previewLayerId"
| "previewLayerIndex"
| "previewStackingReorder"
> {
if (groupMove.active) {
return {
previewStart: groupMove.previewStart,
previewTrack: drag.element.track,
previewLayerId: drag.previewLayerId,
previewLayerIndex: drag.previewLayerIndex,
previewStackingReorder: null,
};
}
return {
previewStart: groupMove.previewStart,
previewTrack: nextMove.track,
previewLayerId: nextMove.previewLayerId ?? drag.previewLayerId,
previewLayerIndex: nextMove.previewLayerIndex ?? drag.previewLayerIndex,
previewStackingReorder: nextMove.stackingReorder ?? null,
};
}
export function useTimelineClipDrag({
scrollRef,
ppsRef,
@@ -97,6 +145,8 @@ export function useTimelineClipDrag({
timelineElementsRef,
onMoveElement,
onResizeElement,
onMoveElements,
onPreviewMoveElements,
onBlockedEditAttempt,
setShowPopover,
setRangeSelectionRef,
@@ -166,10 +216,23 @@ export function useTimelineClipDrag({
onMoveElementRef.current = onMoveElement;
const onResizeElementRef = useRef(onResizeElement);
onResizeElementRef.current = onResizeElement;
const onMoveElementsRef = useRef(onMoveElements);
onMoveElementsRef.current = onMoveElements;
const onPreviewMoveElementsRef = useRef(onPreviewMoveElements);
onPreviewMoveElementsRef.current = onPreviewMoveElements;
const selectedElementIdsRef = useRef(usePlayerStore.getState().selectedElementIds);
selectedElementIdsRef.current = usePlayerStore((s) => s.selectedElementIds);
const clipDragScrollRaf = useRef(0);
const clipDragPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
const { previewGroupMove, commitGroupMove, clearGroupDragSessions } = useTimelineClipGroupDrag({
timelineElementsRef,
updateElement,
onMoveElementsRef,
onPreviewMoveElementsRef,
});
// fallow-ignore-next-line complexity
const updateDraggedClipPreview = useCallback(
(drag: DraggedClipState, clientX: number, clientY: number): DraggedClipState => {
const scroll = scrollRef.current;
@@ -203,22 +266,28 @@ export function useTimelineClipDrag({
ppsRef.current,
{ maxStart: Number.POSITIVE_INFINITY },
);
const groupMove = previewGroupMove(drag.element, selectedElementIdsRef.current, snap.start);
const placement = resolveDragPreviewPlacement(drag, nextMove, groupMove);
return {
...drag,
started: true,
pointerClientX: clientX,
pointerClientY: clientY,
previewStart: snap.start,
previewTrack: nextMove.track,
previewLayerId: nextMove.previewLayerId ?? drag.previewLayerId,
previewLayerIndex: nextMove.previewLayerIndex ?? drag.previewLayerIndex,
previewStackingReorder: nextMove.stackingReorder ?? null,
...placement,
snapBeatTime: snap.snapKind === "beat" ? snap.snapTime : null,
snapGuideTime: snap.snapTime,
snapGuideKind: snap.snapKind,
};
},
[scrollRef, ppsRef, trackOrderRef, timelineLayersRef, timelineElementsRef, buildSnapTargets],
[
scrollRef,
ppsRef,
trackOrderRef,
timelineLayersRef,
timelineElementsRef,
buildSnapTargets,
previewGroupMove,
],
);
const stopClipDragAutoScroll = useCallback(() => {
@@ -277,6 +346,10 @@ export function useTimelineClipDrag({
const updateDraggedClipPreviewRef = useRef(updateDraggedClipPreview);
updateDraggedClipPreviewRef.current = updateDraggedClipPreview;
const commitGroupMoveRef = useRef(commitGroupMove);
commitGroupMoveRef.current = commitGroupMove;
const clearGroupDragSessionsRef = useRef(clearGroupDragSessions);
clearGroupDragSessionsRef.current = clearGroupDragSessions;
const syncClipDragAutoScrollRef = useRef(syncClipDragAutoScroll);
syncClipDragAutoScrollRef.current = syncClipDragAutoScroll;
const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
@@ -360,7 +433,6 @@ export function useTimelineClipDrag({
};
}
}
setResizingClip((prev) =>
prev
? {
@@ -467,6 +539,8 @@ export function useTimelineClipDrag({
suppressClickRef.current = true;
clearSuppressedClick();
if (commitGroupMoveRef.current(drag.element)) return;
const hasStackingReorder =
drag.previewStackingReorder != null && drag.previewStackingReorder.zIndexChanges.length > 0;
const hasChanged = drag.previewStart !== drag.element.start || hasStackingReorder;
@@ -494,6 +568,7 @@ export function useTimelineClipDrag({
window.addEventListener("pointerup", handleWindowPointerUp);
window.addEventListener("pointercancel", handleWindowPointerUp);
return () => {
clearGroupDragSessionsRef.current();
stopClipDragAutoScrollRef.current();
window.removeEventListener("pointermove", handleWindowPointerMove);
window.removeEventListener("pointerup", handleWindowPointerUp);
@@ -0,0 +1,189 @@
import { useCallback, useRef, type RefObject } from "react";
import type { TimelineGroupMoveChange } from "../../hooks/useTimelineGroupEditing";
import type { TimelineElement } from "../store/playerStore";
import { resolveTimelineGroupMove, type TimelineGroupTimingMember } from "./timelineEditing";
type UpdateTimelineElement = (
elementId: string,
updates: Partial<Pick<TimelineElement, "start">>,
) => void;
interface GroupTimingMember extends TimelineGroupTimingMember {
element: TimelineElement;
key: string;
}
interface MoveSession {
grabbedKey: string;
members: GroupTimingMember[];
changes: TimelineGroupMoveChange[];
hasChanged: boolean;
}
interface UseTimelineClipGroupDragInput {
timelineElementsRef: RefObject<TimelineElement[]>;
updateElement: UpdateTimelineElement;
onMoveElementsRef: RefObject<
((changes: TimelineGroupMoveChange[]) => Promise<void> | void) | undefined
>;
onPreviewMoveElementsRef: RefObject<((changes: TimelineGroupMoveChange[]) => void) | undefined>;
}
interface PreviewGroupMoveResult {
active: boolean;
previewStart: number;
}
function elementKey(element: TimelineElement): string {
return element.key ?? element.id;
}
function selectedMembers(
grabbedElement: TimelineElement,
selectedElementIdsInput: Set<string>,
timelineElements: readonly TimelineElement[],
): GroupTimingMember[] | null {
const selectedElementIds =
selectedElementIdsInput instanceof Set ? selectedElementIdsInput : new Set<string>();
const grabbedKey = elementKey(grabbedElement);
if (selectedElementIds.size <= 1 || !selectedElementIds.has(grabbedKey)) return null;
const members = timelineElements
.filter((element) => selectedElementIds.has(elementKey(element)))
.map((element) => ({
element,
key: elementKey(element),
start: element.start,
duration: element.duration,
}));
return members.length > 1 ? members : null;
}
function sameGesture(sessionKey: string, element: TimelineElement): boolean {
return sessionKey === elementKey(element);
}
function createMoveSession(
element: TimelineElement,
selectedElementIds: Set<string>,
timelineElements: readonly TimelineElement[],
): MoveSession | null {
const members = selectedMembers(element, selectedElementIds, timelineElements);
if (!members) return null;
return {
grabbedKey: elementKey(element),
members,
changes: [],
hasChanged: false,
};
}
function resolveMoveChanges(
session: MoveSession,
previewStart: number,
): TimelineGroupMoveChange[] | null {
const grabbed = session.members.find((member) => member.key === session.grabbedKey);
if (!grabbed) return null;
const result = resolveTimelineGroupMove(session.members, previewStart - grabbed.start);
return result.members.map((member, index) => ({
element: session.members[index]!.element,
start: member.start,
}));
}
function moveSessionHasChanged(
session: MoveSession,
changes: readonly TimelineGroupMoveChange[],
): boolean {
return changes.some((change, index) => change.start !== session.members[index]!.start);
}
function previewStartForGrabbed(
session: MoveSession,
changes: readonly TimelineGroupMoveChange[],
fallback: number,
): number {
const change = changes.find((candidate) => elementKey(candidate.element) === session.grabbedKey);
return change?.start ?? fallback;
}
export function useTimelineClipGroupDrag({
timelineElementsRef,
updateElement,
onMoveElementsRef,
onPreviewMoveElementsRef,
}: UseTimelineClipGroupDragInput) {
const moveSessionRef = useRef<MoveSession | null>(null);
const rollbackMove = useCallback(
(session: MoveSession) => {
const changes = session.members.map((member) => ({
element: member.element,
start: member.start,
}));
for (const change of changes) {
updateElement(elementKey(change.element), { start: change.start });
}
onPreviewMoveElementsRef.current?.(changes);
},
[onPreviewMoveElementsRef, updateElement],
);
const previewGroupMove = useCallback(
(
element: TimelineElement,
selectedElementIds: Set<string>,
previewStart: number,
): PreviewGroupMoveResult => {
let session = moveSessionRef.current;
if (!session || !sameGesture(session.grabbedKey, element)) {
if (!onMoveElementsRef.current) return { active: false, previewStart };
session = createMoveSession(element, selectedElementIds, timelineElementsRef.current);
if (!session) return { active: false, previewStart };
moveSessionRef.current = session;
}
const changes = resolveMoveChanges(session, previewStart);
if (!changes) return { active: false, previewStart };
session.changes = changes;
session.hasChanged = moveSessionHasChanged(session, changes);
for (const change of changes) {
updateElement(elementKey(change.element), { start: change.start });
}
onPreviewMoveElementsRef.current?.(changes);
return {
active: true,
previewStart: previewStartForGrabbed(session, changes, previewStart),
};
},
[onMoveElementsRef, onPreviewMoveElementsRef, timelineElementsRef, updateElement],
);
const commitGroupMove = useCallback(
(element: TimelineElement): boolean => {
const session = moveSessionRef.current;
if (!session || !sameGesture(session.grabbedKey, element)) return false;
moveSessionRef.current = null;
if (!session.hasChanged) return true;
Promise.resolve(onMoveElementsRef.current?.(session.changes)).catch((error) => {
rollbackMove(session);
console.error("[Timeline] Failed to persist group clip move", error);
});
return true;
},
[onMoveElementsRef, rollbackMove],
);
const clearGroupDragSessions = useCallback(() => {
moveSessionRef.current = null;
}, []);
return {
previewGroupMove,
commitGroupMove,
clearGroupDragSessions,
};
}