mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user