mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): resize selected timeline clips together
Apply handle deltas through a group resize session that snapshots selected members. Clamp one shared delta against zero start and min duration before preview and commit. Playback-start changes are calculated per member with the existing trim formula.
This commit is contained in:
@@ -525,6 +525,7 @@ export function StudioApp() {
|
||||
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
|
||||
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
||||
handleTimelineGroupMove={timelineEditing.handleTimelineGroupMove}
|
||||
handleTimelineGroupResize={timelineEditing.handleTimelineGroupResize}
|
||||
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
|
||||
handleToggleElementHidden={timelineEditing.handleToggleElementHidden}
|
||||
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
||||
|
||||
@@ -26,7 +26,10 @@ 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 type {
|
||||
TimelineGroupMoveChange,
|
||||
TimelineGroupResizeChange,
|
||||
} from "../hooks/useTimelineGroupEditing";
|
||||
import {
|
||||
formatTimelineAttributeNumber,
|
||||
patchIframeDomTiming,
|
||||
@@ -65,6 +68,7 @@ export interface StudioPreviewAreaProps {
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
handleTimelineGroupMove: (changes: TimelineGroupMoveChange[]) => Promise<void> | void;
|
||||
handleTimelineGroupResize: (changes: TimelineGroupResizeChange[]) => 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;
|
||||
@@ -93,6 +97,7 @@ export function StudioPreviewArea({
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
handleTimelineGroupMove,
|
||||
handleTimelineGroupResize,
|
||||
handleToggleTrackHidden,
|
||||
handleToggleElementHidden,
|
||||
handleBlockedTimelineEdit,
|
||||
@@ -212,13 +217,36 @@ export function StudioPreviewArea({
|
||||
[previewIframeRef],
|
||||
);
|
||||
|
||||
const handleTimelineGroupResizePreview = useCallback(
|
||||
(changes: TimelineGroupResizeChange[]) => {
|
||||
for (const change of changes) {
|
||||
const attrs: Array<[string, string]> = [
|
||||
["data-start", formatTimelineAttributeNumber(change.start)],
|
||||
["data-duration", formatTimelineAttributeNumber(change.duration)],
|
||||
];
|
||||
if (change.playbackStart != null) {
|
||||
attrs.push([
|
||||
change.element.playbackStartAttr === "playback-start"
|
||||
? "data-playback-start"
|
||||
: "data-media-start",
|
||||
formatTimelineAttributeNumber(change.playbackStart),
|
||||
]);
|
||||
}
|
||||
patchIframeDomTiming(previewIframeRef.current, change.element, attrs);
|
||||
}
|
||||
},
|
||||
[previewIframeRef],
|
||||
);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const timelineEditCallbacks = useMemo(
|
||||
() => ({
|
||||
onMoveElement: handleTimelineElementMove,
|
||||
onResizeElement: handleTimelineElementResize,
|
||||
onMoveElements: handleTimelineGroupMove,
|
||||
onResizeElements: handleTimelineGroupResize,
|
||||
onPreviewMoveElements: handleTimelineGroupMovePreview,
|
||||
onPreviewResizeElements: handleTimelineGroupResizePreview,
|
||||
onToggleTrackHidden: handleToggleTrackHidden,
|
||||
onToggleElementHidden: handleToggleElementHidden,
|
||||
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
||||
@@ -322,6 +350,8 @@ export function StudioPreviewArea({
|
||||
handleTimelineElementResize,
|
||||
handleTimelineGroupMove,
|
||||
handleTimelineGroupMovePreview,
|
||||
handleTimelineGroupResize,
|
||||
handleTimelineGroupResizePreview,
|
||||
handleToggleTrackHidden,
|
||||
handleToggleElementHidden,
|
||||
handleBlockedTimelineEdit,
|
||||
|
||||
@@ -35,6 +35,7 @@ export function TimelineEditProvider({
|
||||
value.onMoveElements,
|
||||
value.onResizeElements,
|
||||
value.onPreviewMoveElements,
|
||||
value.onPreviewResizeElements,
|
||||
value.onToggleTrackHidden,
|
||||
value.onToggleElementHidden,
|
||||
value.onBlockedEditAttempt,
|
||||
|
||||
@@ -72,7 +72,9 @@ export const Timeline = memo(function Timeline({
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onMoveElements,
|
||||
onResizeElements,
|
||||
onPreviewMoveElements,
|
||||
onPreviewResizeElements,
|
||||
onBlockedEditAttempt,
|
||||
onSplitElement,
|
||||
onRazorSplitAll,
|
||||
@@ -112,7 +114,6 @@ export const Timeline = memo(function Timeline({
|
||||
const isDragging = useRef(false);
|
||||
const [shiftHeld, setShiftHeld] = useState(false);
|
||||
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
|
||||
|
||||
useMountEffect(() => {
|
||||
const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
|
||||
const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false);
|
||||
@@ -138,7 +139,6 @@ export const Timeline = memo(function Timeline({
|
||||
const [viewportWidth, setViewportWidth] = useState(0);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const shortcutHintRafRef = useRef(0);
|
||||
|
||||
const syncShortcutHintVisibility = useCallback(() => {
|
||||
const scroll = scrollRef.current;
|
||||
setShowShortcutHint(
|
||||
@@ -223,7 +223,9 @@ export const Timeline = memo(function Timeline({
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onMoveElements,
|
||||
onResizeElements,
|
||||
onPreviewMoveElements,
|
||||
onPreviewResizeElements,
|
||||
onBlockedEditAttempt,
|
||||
setShowPopover,
|
||||
setRangeSelectionRef,
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface TimelineEditCallbacks {
|
||||
options?: TimelineGroupCommitOptions,
|
||||
) => Promise<void> | void;
|
||||
onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void;
|
||||
onPreviewResizeElements?: (changes: TimelineGroupResizeChange[]) => void;
|
||||
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
|
||||
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
|
||||
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineStackingReorderIntent } from "./timelineEditing";
|
||||
import type { TimelineLayerId } from "./timelineTrackOrder";
|
||||
|
||||
interface DragPreviewState {
|
||||
element: Pick<TimelineElement, "track">;
|
||||
previewLayerId: TimelineLayerId;
|
||||
previewLayerIndex: number;
|
||||
}
|
||||
|
||||
export interface TimelineMovePreview {
|
||||
start: number;
|
||||
track: number;
|
||||
previewLayerId?: TimelineLayerId;
|
||||
previewLayerIndex?: number;
|
||||
stackingReorder?: TimelineStackingReorderIntent | null;
|
||||
}
|
||||
|
||||
export interface TimelineGroupMovePreview {
|
||||
active: boolean;
|
||||
previewStart: number;
|
||||
}
|
||||
|
||||
export function resolveDragPreviewPlacement(
|
||||
drag: DragPreviewState,
|
||||
nextMove: TimelineMovePreview,
|
||||
groupMove: TimelineGroupMovePreview,
|
||||
): {
|
||||
previewStart: number;
|
||||
previewTrack: number;
|
||||
previewLayerId: TimelineLayerId;
|
||||
previewLayerIndex: number;
|
||||
previewStackingReorder: TimelineStackingReorderIntent | null;
|
||||
} {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildPromptCopyText,
|
||||
buildTimelineElementAgentPrompt,
|
||||
buildTimelineAgentPrompt,
|
||||
clampTimelineGroupResizeDelta,
|
||||
getTimelineEditCapabilities,
|
||||
hasPatchableTimelineTarget,
|
||||
resolveBlockedTimelineEditIntent,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
resolveTimelineMove,
|
||||
resolveTimelineResize,
|
||||
resolveTimelineGroupMove,
|
||||
resolveTimelineGroupResize,
|
||||
snapKeyframePctToBeat,
|
||||
type TimelinePromptElement,
|
||||
} from "./timelineEditing";
|
||||
@@ -260,6 +262,120 @@ describe("resolveTimelineGroupMove", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineGroupResize", () => {
|
||||
it("returns the shared clamped delta without applying per-member starts", () => {
|
||||
expect(
|
||||
clampTimelineGroupResizeDelta(
|
||||
1,
|
||||
[
|
||||
{ start: 1, duration: 0.5 },
|
||||
{ start: 4, duration: 2 },
|
||||
],
|
||||
"start",
|
||||
),
|
||||
).toBe(0.4);
|
||||
});
|
||||
|
||||
it("applies an unclamped start-edge delta uniformly", () => {
|
||||
const result = resolveTimelineGroupResize(
|
||||
[
|
||||
{ start: 1, duration: 3 },
|
||||
{ start: 5, duration: 4 },
|
||||
],
|
||||
"start",
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
delta: 1,
|
||||
members: [
|
||||
{ start: 2, duration: 2, playbackStart: undefined },
|
||||
{ start: 6, duration: 3, playbackStart: undefined },
|
||||
],
|
||||
});
|
||||
expect(result.members[1]!.start - result.members[0]!.start).toBe(4);
|
||||
});
|
||||
|
||||
it("clamps a start-edge delta when the earliest member reaches zero", () => {
|
||||
const result = resolveTimelineGroupResize(
|
||||
[
|
||||
{ start: 0.5, duration: 3 },
|
||||
{ start: 4, duration: 4 },
|
||||
],
|
||||
"start",
|
||||
-2,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
delta: -0.5,
|
||||
members: [
|
||||
{ start: 0, duration: 3.5, playbackStart: undefined },
|
||||
{ start: 3.5, duration: 4.5, playbackStart: undefined },
|
||||
],
|
||||
});
|
||||
expect(result.members[1]!.start - result.members[0]!.start).toBe(3.5);
|
||||
});
|
||||
|
||||
it("clamps a start-edge delta when any member reaches minimum duration", () => {
|
||||
const result = resolveTimelineGroupResize(
|
||||
[
|
||||
{ start: 1, duration: 0.5 },
|
||||
{ start: 4, duration: 2 },
|
||||
],
|
||||
"start",
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
delta: 0.4,
|
||||
members: [
|
||||
{ start: 1.4, duration: 0.1, playbackStart: undefined },
|
||||
{ start: 4.4, duration: 1.6, playbackStart: undefined },
|
||||
],
|
||||
});
|
||||
expect(result.members[1]!.start - result.members[0]!.start).toBeCloseTo(3);
|
||||
});
|
||||
|
||||
it("clamps an end-edge delta when any member reaches minimum duration", () => {
|
||||
const result = resolveTimelineGroupResize(
|
||||
[
|
||||
{ start: 1, duration: 0.5 },
|
||||
{ start: 4, duration: 2 },
|
||||
],
|
||||
"end",
|
||||
-1,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
delta: -0.4,
|
||||
members: [
|
||||
{ start: 1, duration: 0.1, playbackStart: undefined },
|
||||
{ start: 4, duration: 1.6, playbackStart: undefined },
|
||||
],
|
||||
});
|
||||
expect(result.members[1]!.start - result.members[0]!.start).toBe(3);
|
||||
});
|
||||
|
||||
it("adjusts each start-edge playback start using the shared delta", () => {
|
||||
const result = resolveTimelineGroupResize(
|
||||
[
|
||||
{ start: 2, duration: 3, playbackStart: 1, playbackRate: 1 },
|
||||
{ start: 5, duration: 4, playbackStart: 2, playbackRate: 2 },
|
||||
],
|
||||
"start",
|
||||
0.5,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
delta: 0.5,
|
||||
members: [
|
||||
{ start: 2.5, duration: 2.5, playbackStart: 1.5 },
|
||||
{ start: 5.5, duration: 3.5, playbackStart: 3 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPatchableTimelineTarget", () => {
|
||||
it("returns true when the clip has a DOM id", () => {
|
||||
expect(hasPatchableTimelineTarget({ domId: "hero-card" })).toBe(true);
|
||||
|
||||
@@ -4,7 +4,15 @@ import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrde
|
||||
import { resolveTimelineLayerStackingMove } from "./timelineLayerDrag";
|
||||
import type { TimelineStackingElement, TimelineStackingReorderIntent } from "./timelineStacking";
|
||||
|
||||
export { resolveTimelineGroupMove, type TimelineGroupTimingMember } from "./timelineGroupEditing";
|
||||
import { resolveTimelineMinDuration } from "./timelineGroupEditing";
|
||||
|
||||
export {
|
||||
clampTimelineGroupResizeDelta,
|
||||
resolveTimelineGroupMove,
|
||||
resolveTimelineGroupResize,
|
||||
type TimelineGroupResizeEdge,
|
||||
type TimelineGroupTimingMember,
|
||||
} from "./timelineGroupEditing";
|
||||
|
||||
export {
|
||||
type TimelineStackingElement,
|
||||
@@ -196,7 +204,7 @@ export function resolveTimelineResize(
|
||||
edge: "start" | "end",
|
||||
clientX: number,
|
||||
): { start: number; duration: number; playbackStart?: number } {
|
||||
const minDuration = Math.max(0.05, input.minDuration ?? 0.1);
|
||||
const minDuration = resolveTimelineMinDuration(input.minDuration);
|
||||
const deltaTime = (clientX - input.originClientX) / Math.max(input.pixelsPerSecond, 1);
|
||||
|
||||
if (edge === "end") {
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
import { roundToCenti } from "../../utils/rounding";
|
||||
|
||||
const DEFAULT_TIMELINE_MIN_DURATION = 0.1;
|
||||
const ABSOLUTE_TIMELINE_MIN_DURATION = 0.05;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function roundTimelineTime(value: number): number {
|
||||
return roundToCenti(value);
|
||||
}
|
||||
|
||||
export function resolveTimelineMinDuration(minDuration?: number): number {
|
||||
return Math.max(ABSOLUTE_TIMELINE_MIN_DURATION, minDuration ?? DEFAULT_TIMELINE_MIN_DURATION);
|
||||
}
|
||||
|
||||
export interface TimelineGroupTimingMember {
|
||||
start: number;
|
||||
duration: number;
|
||||
playbackStart?: number;
|
||||
playbackRate?: number;
|
||||
}
|
||||
|
||||
export type TimelineGroupResizeEdge = "start" | "end";
|
||||
|
||||
export interface TimelineGroupMoveResult {
|
||||
delta: number;
|
||||
members: Array<Pick<TimelineGroupTimingMember, "start" | "duration">>;
|
||||
}
|
||||
|
||||
export interface TimelineGroupResizeResult {
|
||||
delta: number;
|
||||
members: Array<Pick<TimelineGroupTimingMember, "start" | "duration" | "playbackStart">>;
|
||||
}
|
||||
|
||||
function clampTimelineGroupMoveDelta(
|
||||
rawDelta: number,
|
||||
members: readonly TimelineGroupTimingMember[],
|
||||
@@ -36,3 +56,61 @@ export function resolveTimelineGroupMove(
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function clampTimelineGroupResizeDelta(
|
||||
rawDelta: number,
|
||||
members: readonly TimelineGroupTimingMember[],
|
||||
edge: TimelineGroupResizeEdge,
|
||||
minDuration = resolveTimelineMinDuration(),
|
||||
): number {
|
||||
if (members.length === 0) return 0;
|
||||
|
||||
if (edge === "end") {
|
||||
const minDelta = Math.max(...members.map((member) => minDuration - member.duration));
|
||||
return roundTimelineTime(Math.max(rawDelta, minDelta));
|
||||
}
|
||||
|
||||
const minDelta = Math.max(
|
||||
...members.map((member) => {
|
||||
const playbackRate = Math.max(0.1, member.playbackRate ?? 1);
|
||||
const maxLeftExtensionFromMedia =
|
||||
member.playbackStart != null
|
||||
? member.playbackStart / playbackRate
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return -Math.min(member.start, maxLeftExtensionFromMedia);
|
||||
}),
|
||||
);
|
||||
const maxDelta = Math.min(...members.map((member) => member.duration - minDuration));
|
||||
return roundTimelineTime(clamp(rawDelta, minDelta, maxDelta));
|
||||
}
|
||||
|
||||
export function resolveTimelineGroupResize(
|
||||
members: readonly TimelineGroupTimingMember[],
|
||||
edge: TimelineGroupResizeEdge,
|
||||
rawDelta: number,
|
||||
minDuration = resolveTimelineMinDuration(),
|
||||
): TimelineGroupResizeResult {
|
||||
const delta = clampTimelineGroupResizeDelta(rawDelta, members, edge, minDuration);
|
||||
return {
|
||||
delta,
|
||||
members: members.map((member) => {
|
||||
if (edge === "end") {
|
||||
return {
|
||||
start: member.start,
|
||||
duration: roundTimelineTime(member.duration + delta),
|
||||
playbackStart: member.playbackStart,
|
||||
};
|
||||
}
|
||||
|
||||
const playbackRate = Math.max(0.1, member.playbackRate ?? 1);
|
||||
return {
|
||||
start: roundTimelineTime(member.start + delta),
|
||||
duration: roundTimelineTime(member.duration - delta),
|
||||
playbackStart:
|
||||
member.playbackStart != null
|
||||
? roundTimelineTime(Math.max(0, member.playbackStart + delta * playbackRate))
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@ function renderDragHarness(elements: TimelineElement[]) {
|
||||
const onMoveElement = vi.fn();
|
||||
const onResizeElement = vi.fn();
|
||||
const onMoveElements = vi.fn();
|
||||
const onResizeElements = vi.fn();
|
||||
const onPreviewMoveElements = vi.fn();
|
||||
const onPreviewResizeElements = vi.fn();
|
||||
let setDraggedClip: ((state: DraggedClipState | null) => void) | null = null;
|
||||
let setResizingClip: ((state: ResizingClipState | null) => void) | null = null;
|
||||
|
||||
@@ -69,7 +71,9 @@ function renderDragHarness(elements: TimelineElement[]) {
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onMoveElements,
|
||||
onResizeElements,
|
||||
onPreviewMoveElements,
|
||||
onPreviewResizeElements,
|
||||
onBlockedEditAttempt: vi.fn(),
|
||||
setShowPopover: vi.fn(),
|
||||
setRangeSelectionRef: { current: vi.fn() },
|
||||
@@ -95,7 +99,9 @@ function renderDragHarness(elements: TimelineElement[]) {
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onMoveElements,
|
||||
onResizeElements,
|
||||
onPreviewMoveElements,
|
||||
onPreviewResizeElements,
|
||||
storeElements() {
|
||||
return usePlayerStore.getState().elements;
|
||||
},
|
||||
@@ -275,6 +281,120 @@ describe("useTimelineClipDrag", () => {
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("resizes every selected start edge by the same delta", async () => {
|
||||
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 4 });
|
||||
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 5, duration: 3 });
|
||||
const harness = renderDragHarness([first, second]);
|
||||
act(() => {
|
||||
usePlayerStore.getState().setSelection(["first", "second"], "first");
|
||||
});
|
||||
|
||||
harness.startResize(first, "start");
|
||||
harness.movePointer(100, 0);
|
||||
|
||||
expect(
|
||||
harness.storeElements().map((element) => [element.id, element.start, element.duration]),
|
||||
).toEqual([
|
||||
["first", 2, 3],
|
||||
["second", 6, 2],
|
||||
]);
|
||||
expect(harness.onPreviewResizeElements).toHaveBeenLastCalledWith([
|
||||
{ element: first, start: 2, duration: 3, playbackStart: undefined },
|
||||
{ element: second, start: 6, duration: 2, playbackStart: undefined },
|
||||
]);
|
||||
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onResizeElement).not.toHaveBeenCalled();
|
||||
expect(harness.onResizeElements).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onResizeElements).toHaveBeenCalledWith([
|
||||
{ element: first, start: 2, duration: 3, playbackStart: undefined },
|
||||
{ element: second, start: 6, duration: 2, playbackStart: undefined },
|
||||
]);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("resizes every selected end edge by the same delta", async () => {
|
||||
const first = timelineElement({ id: "first", track: 0, zIndex: 1, start: 1, duration: 4 });
|
||||
const second = timelineElement({ id: "second", track: 1, zIndex: 1, start: 5, duration: 3 });
|
||||
const harness = renderDragHarness([first, second]);
|
||||
act(() => {
|
||||
usePlayerStore.getState().setSelection(["first", "second"], "first");
|
||||
});
|
||||
|
||||
harness.startResize(first, "end");
|
||||
harness.movePointer(100, 0);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onResizeElement).not.toHaveBeenCalled();
|
||||
expect(harness.onResizeElements).toHaveBeenCalledWith([
|
||||
{ element: first, start: 1, duration: 5, playbackStart: undefined },
|
||||
{ element: second, start: 5, duration: 4, playbackStart: undefined },
|
||||
]);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("clamps selected start-edge resize at the most constrained duration", async () => {
|
||||
const short = timelineElement({ id: "short", track: 0, zIndex: 1, start: 1, duration: 0.5 });
|
||||
const long = timelineElement({ id: "long", track: 1, zIndex: 1, start: 4, duration: 2 });
|
||||
const harness = renderDragHarness([short, long]);
|
||||
act(() => {
|
||||
usePlayerStore.getState().setSelection(["short", "long"], "short");
|
||||
});
|
||||
|
||||
harness.startResize(short, "start");
|
||||
harness.movePointer(100, 0);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onResizeElements).toHaveBeenCalledWith([
|
||||
{ element: short, start: 1.4, duration: 0.1, playbackStart: undefined },
|
||||
{ element: long, start: 4.4, duration: 1.6, playbackStart: undefined },
|
||||
]);
|
||||
expect(harness.onResizeElement).not.toHaveBeenCalled();
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
it("adjusts every selected media playback start during start-edge resize", async () => {
|
||||
const audio = timelineElement({
|
||||
id: "audio",
|
||||
tag: "audio",
|
||||
track: 0,
|
||||
zIndex: 1,
|
||||
start: 2,
|
||||
duration: 3,
|
||||
playbackStart: 1,
|
||||
playbackRate: 1,
|
||||
});
|
||||
const video = timelineElement({
|
||||
id: "video",
|
||||
tag: "video",
|
||||
track: 1,
|
||||
zIndex: 1,
|
||||
start: 5,
|
||||
duration: 4,
|
||||
playbackStart: 2,
|
||||
playbackRate: 2,
|
||||
});
|
||||
const harness = renderDragHarness([audio, video]);
|
||||
act(() => {
|
||||
usePlayerStore.getState().setSelection(["audio", "video"], "audio");
|
||||
});
|
||||
|
||||
harness.startResize(audio, "start");
|
||||
harness.movePointer(50, 0);
|
||||
await harness.dropPointer();
|
||||
|
||||
expect(harness.onResizeElements).toHaveBeenCalledWith([
|
||||
{ element: audio, start: 2.5, duration: 2.5, playbackStart: 1.5 },
|
||||
{ element: video, start: 5.5, duration: 3.5, playbackStart: 3 },
|
||||
]);
|
||||
|
||||
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 });
|
||||
|
||||
@@ -13,13 +13,17 @@ 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 type {
|
||||
TimelineGroupMoveChange,
|
||||
TimelineGroupResizeChange,
|
||||
} from "../../hooks/useTimelineGroupEditing";
|
||||
import {
|
||||
buildTimelineSnapTargets,
|
||||
snapEdgesToTargets,
|
||||
snapResizeEdgeToTargets,
|
||||
type TimelineSnapKind,
|
||||
} from "./timelineSnapTargets";
|
||||
import { resolveDragPreviewPlacement } from "./timelineClipDragPreview";
|
||||
import { useTimelineClipGroupDrag } from "./useTimelineClipGroupDrag";
|
||||
|
||||
const EMPTY_BEAT_TIMES: number[] = [];
|
||||
@@ -86,57 +90,15 @@ interface UseTimelineClipDragInput {
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
onMoveElements?: (changes: TimelineGroupMoveChange[]) => Promise<void> | void;
|
||||
onResizeElements?: (changes: TimelineGroupResizeChange[]) => Promise<void> | void;
|
||||
onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void;
|
||||
onPreviewResizeElements?: (changes: TimelineGroupResizeChange[]) => 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. */
|
||||
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,
|
||||
@@ -146,7 +108,9 @@ export function useTimelineClipDrag({
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onMoveElements,
|
||||
onResizeElements,
|
||||
onPreviewMoveElements,
|
||||
onPreviewResizeElements,
|
||||
onBlockedEditAttempt,
|
||||
setShowPopover,
|
||||
setRangeSelectionRef,
|
||||
@@ -218,18 +182,30 @@ export function useTimelineClipDrag({
|
||||
onResizeElementRef.current = onResizeElement;
|
||||
const onMoveElementsRef = useRef(onMoveElements);
|
||||
onMoveElementsRef.current = onMoveElements;
|
||||
const onResizeElementsRef = useRef(onResizeElements);
|
||||
onResizeElementsRef.current = onResizeElements;
|
||||
const onPreviewMoveElementsRef = useRef(onPreviewMoveElements);
|
||||
onPreviewMoveElementsRef.current = onPreviewMoveElements;
|
||||
const onPreviewResizeElementsRef = useRef(onPreviewResizeElements);
|
||||
onPreviewResizeElementsRef.current = onPreviewResizeElements;
|
||||
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({
|
||||
const {
|
||||
previewGroupMove,
|
||||
previewGroupResize,
|
||||
commitGroupMove,
|
||||
commitGroupResize,
|
||||
clearGroupDragSessions,
|
||||
} = useTimelineClipGroupDrag({
|
||||
timelineElementsRef,
|
||||
updateElement,
|
||||
onMoveElementsRef,
|
||||
onResizeElementsRef,
|
||||
onPreviewMoveElementsRef,
|
||||
onPreviewResizeElementsRef,
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -348,6 +324,8 @@ export function useTimelineClipDrag({
|
||||
updateDraggedClipPreviewRef.current = updateDraggedClipPreview;
|
||||
const commitGroupMoveRef = useRef(commitGroupMove);
|
||||
commitGroupMoveRef.current = commitGroupMove;
|
||||
const commitGroupResizeRef = useRef(commitGroupResize);
|
||||
commitGroupResizeRef.current = commitGroupResize;
|
||||
const clearGroupDragSessionsRef = useRef(clearGroupDragSessions);
|
||||
clearGroupDragSessionsRef.current = clearGroupDragSessions;
|
||||
const syncClipDragAutoScrollRef = useRef(syncClipDragAutoScroll);
|
||||
@@ -433,6 +411,15 @@ export function useTimelineClipDrag({
|
||||
};
|
||||
}
|
||||
}
|
||||
const groupResize = previewGroupResize(
|
||||
resize.element,
|
||||
selectedElementIdsRef.current,
|
||||
resize.edge,
|
||||
nextResize,
|
||||
);
|
||||
if (groupResize.active) {
|
||||
nextResize = groupResize.updates;
|
||||
}
|
||||
setResizingClip((prev) =>
|
||||
prev
|
||||
? {
|
||||
@@ -493,6 +480,8 @@ export function useTimelineClipDrag({
|
||||
suppressClickRef.current = true;
|
||||
clearSuppressedClick();
|
||||
|
||||
if (commitGroupResizeRef.current(resize.element)) return;
|
||||
|
||||
const hasChanged =
|
||||
resize.previewStart !== resize.element.start ||
|
||||
resize.previewDuration !== resize.element.duration ||
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { useCallback, useRef, type RefObject } from "react";
|
||||
import type { TimelineGroupMoveChange } from "../../hooks/useTimelineGroupEditing";
|
||||
import type {
|
||||
TimelineGroupMoveChange,
|
||||
TimelineGroupResizeChange,
|
||||
} from "../../hooks/useTimelineGroupEditing";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { resolveTimelineGroupMove, type TimelineGroupTimingMember } from "./timelineEditing";
|
||||
import {
|
||||
resolveTimelineGroupMove,
|
||||
resolveTimelineGroupResize,
|
||||
type TimelineGroupResizeEdge,
|
||||
type TimelineGroupTimingMember,
|
||||
} from "./timelineEditing";
|
||||
|
||||
type TimelineResizeUpdates = Pick<TimelineElement, "start" | "duration" | "playbackStart">;
|
||||
|
||||
type UpdateTimelineElement = (
|
||||
elementId: string,
|
||||
updates: Partial<Pick<TimelineElement, "start">>,
|
||||
updates: Partial<Pick<TimelineElement, "start" | "duration" | "playbackStart">>,
|
||||
) => void;
|
||||
|
||||
interface GroupTimingMember extends TimelineGroupTimingMember {
|
||||
@@ -20,13 +30,27 @@ interface MoveSession {
|
||||
hasChanged: boolean;
|
||||
}
|
||||
|
||||
interface ResizeSession {
|
||||
grabbedKey: string;
|
||||
edge: TimelineGroupResizeEdge;
|
||||
members: GroupTimingMember[];
|
||||
changes: TimelineGroupResizeChange[];
|
||||
hasChanged: boolean;
|
||||
}
|
||||
|
||||
interface UseTimelineClipGroupDragInput {
|
||||
timelineElementsRef: RefObject<TimelineElement[]>;
|
||||
updateElement: UpdateTimelineElement;
|
||||
onMoveElementsRef: RefObject<
|
||||
((changes: TimelineGroupMoveChange[]) => Promise<void> | void) | undefined
|
||||
>;
|
||||
onResizeElementsRef: RefObject<
|
||||
((changes: TimelineGroupResizeChange[]) => Promise<void> | void) | undefined
|
||||
>;
|
||||
onPreviewMoveElementsRef: RefObject<((changes: TimelineGroupMoveChange[]) => void) | undefined>;
|
||||
onPreviewResizeElementsRef: RefObject<
|
||||
((changes: TimelineGroupResizeChange[]) => void) | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
interface PreviewGroupMoveResult {
|
||||
@@ -34,31 +58,61 @@ interface PreviewGroupMoveResult {
|
||||
previewStart: number;
|
||||
}
|
||||
|
||||
interface PreviewGroupResizeResult {
|
||||
active: boolean;
|
||||
updates: TimelineResizeUpdates;
|
||||
}
|
||||
|
||||
function elementKey(element: TimelineElement): string {
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
|
||||
function isMediaElement(element: TimelineElement): boolean {
|
||||
const normalizedTag = element.tag.toLowerCase();
|
||||
return normalizedTag === "audio" || normalizedTag === "video";
|
||||
}
|
||||
|
||||
function selectedElementSet(selectedElementIdsInput: Set<string>): Set<string> {
|
||||
return selectedElementIdsInput instanceof Set ? selectedElementIdsInput : new Set<string>();
|
||||
}
|
||||
|
||||
function selectedMembers(
|
||||
grabbedElement: TimelineElement,
|
||||
selectedElementIdsInput: Set<string>,
|
||||
timelineElements: readonly TimelineElement[],
|
||||
mapMember: (element: TimelineElement) => GroupTimingMember,
|
||||
): GroupTimingMember[] | null {
|
||||
const selectedElementIds =
|
||||
selectedElementIdsInput instanceof Set ? selectedElementIdsInput : new Set<string>();
|
||||
const selectedElementIds = selectedElementSet(selectedElementIdsInput);
|
||||
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,
|
||||
}));
|
||||
.map(mapMember);
|
||||
return members.length > 1 ? members : null;
|
||||
}
|
||||
|
||||
function moveMember(element: TimelineElement): GroupTimingMember {
|
||||
return {
|
||||
element,
|
||||
key: elementKey(element),
|
||||
start: element.start,
|
||||
duration: element.duration,
|
||||
};
|
||||
}
|
||||
|
||||
function resizeMember(edge: TimelineGroupResizeEdge, element: TimelineElement): GroupTimingMember {
|
||||
const shouldSeedPlaybackStart = edge === "start" && isMediaElement(element);
|
||||
return {
|
||||
element,
|
||||
key: elementKey(element),
|
||||
start: element.start,
|
||||
duration: element.duration,
|
||||
playbackStart: shouldSeedPlaybackStart ? (element.playbackStart ?? 0) : element.playbackStart,
|
||||
playbackRate: element.playbackRate,
|
||||
};
|
||||
}
|
||||
|
||||
function sameGesture(sessionKey: string, element: TimelineElement): boolean {
|
||||
return sessionKey === elementKey(element);
|
||||
}
|
||||
@@ -68,7 +122,7 @@ function createMoveSession(
|
||||
selectedElementIds: Set<string>,
|
||||
timelineElements: readonly TimelineElement[],
|
||||
): MoveSession | null {
|
||||
const members = selectedMembers(element, selectedElementIds, timelineElements);
|
||||
const members = selectedMembers(element, selectedElementIds, timelineElements, moveMember);
|
||||
if (!members) return null;
|
||||
return {
|
||||
grabbedKey: elementKey(element),
|
||||
@@ -78,6 +132,25 @@ function createMoveSession(
|
||||
};
|
||||
}
|
||||
|
||||
function createResizeSession(
|
||||
element: TimelineElement,
|
||||
selectedElementIds: Set<string>,
|
||||
timelineElements: readonly TimelineElement[],
|
||||
edge: TimelineGroupResizeEdge,
|
||||
): ResizeSession | null {
|
||||
const members = selectedMembers(element, selectedElementIds, timelineElements, (member) =>
|
||||
resizeMember(edge, member),
|
||||
);
|
||||
if (!members) return null;
|
||||
return {
|
||||
grabbedKey: elementKey(element),
|
||||
edge,
|
||||
members,
|
||||
changes: [],
|
||||
hasChanged: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMoveChanges(
|
||||
session: MoveSession,
|
||||
previewStart: number,
|
||||
@@ -91,6 +164,29 @@ function resolveMoveChanges(
|
||||
}));
|
||||
}
|
||||
|
||||
function resizeRawDelta(session: ResizeSession, updates: TimelineResizeUpdates): number | null {
|
||||
const grabbed = session.members.find((member) => member.key === session.grabbedKey);
|
||||
if (!grabbed) return null;
|
||||
return session.edge === "start"
|
||||
? updates.start - grabbed.start
|
||||
: updates.duration - grabbed.duration;
|
||||
}
|
||||
|
||||
function resolveResizeChanges(
|
||||
session: ResizeSession,
|
||||
updates: TimelineResizeUpdates,
|
||||
): TimelineGroupResizeChange[] | null {
|
||||
const rawDelta = resizeRawDelta(session, updates);
|
||||
if (rawDelta == null) return null;
|
||||
const result = resolveTimelineGroupResize(session.members, session.edge, rawDelta);
|
||||
return result.members.map((member, index) => ({
|
||||
element: session.members[index]!.element,
|
||||
start: member.start,
|
||||
duration: member.duration,
|
||||
playbackStart: member.playbackStart,
|
||||
}));
|
||||
}
|
||||
|
||||
function moveSessionHasChanged(
|
||||
session: MoveSession,
|
||||
changes: readonly TimelineGroupMoveChange[],
|
||||
@@ -98,6 +194,20 @@ function moveSessionHasChanged(
|
||||
return changes.some((change, index) => change.start !== session.members[index]!.start);
|
||||
}
|
||||
|
||||
function resizeSessionHasChanged(
|
||||
session: ResizeSession,
|
||||
changes: readonly TimelineGroupResizeChange[],
|
||||
): boolean {
|
||||
return changes.some((change, index) => {
|
||||
const member = session.members[index]!;
|
||||
return (
|
||||
change.start !== member.start ||
|
||||
change.duration !== member.duration ||
|
||||
change.playbackStart !== member.playbackStart
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function previewStartForGrabbed(
|
||||
session: MoveSession,
|
||||
changes: readonly TimelineGroupMoveChange[],
|
||||
@@ -107,13 +217,30 @@ function previewStartForGrabbed(
|
||||
return change?.start ?? fallback;
|
||||
}
|
||||
|
||||
function resizeUpdatesForGrabbed(
|
||||
session: ResizeSession,
|
||||
changes: readonly TimelineGroupResizeChange[],
|
||||
fallback: TimelineResizeUpdates,
|
||||
): TimelineResizeUpdates {
|
||||
const change = changes.find((candidate) => elementKey(candidate.element) === session.grabbedKey);
|
||||
if (!change) return fallback;
|
||||
return {
|
||||
start: change.start,
|
||||
duration: change.duration,
|
||||
playbackStart: change.playbackStart,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTimelineClipGroupDrag({
|
||||
timelineElementsRef,
|
||||
updateElement,
|
||||
onMoveElementsRef,
|
||||
onResizeElementsRef,
|
||||
onPreviewMoveElementsRef,
|
||||
onPreviewResizeElementsRef,
|
||||
}: UseTimelineClipGroupDragInput) {
|
||||
const moveSessionRef = useRef<MoveSession | null>(null);
|
||||
const resizeSessionRef = useRef<ResizeSession | null>(null);
|
||||
|
||||
const rollbackMove = useCallback(
|
||||
(session: MoveSession) => {
|
||||
@@ -129,6 +256,26 @@ export function useTimelineClipGroupDrag({
|
||||
[onPreviewMoveElementsRef, updateElement],
|
||||
);
|
||||
|
||||
const rollbackResize = useCallback(
|
||||
(session: ResizeSession) => {
|
||||
const changes = session.members.map((member) => ({
|
||||
element: member.element,
|
||||
start: member.start,
|
||||
duration: member.duration,
|
||||
playbackStart: member.playbackStart,
|
||||
}));
|
||||
for (const change of changes) {
|
||||
updateElement(elementKey(change.element), {
|
||||
start: change.start,
|
||||
duration: change.duration,
|
||||
playbackStart: change.playbackStart,
|
||||
});
|
||||
}
|
||||
onPreviewResizeElementsRef.current?.(changes);
|
||||
},
|
||||
[onPreviewResizeElementsRef, updateElement],
|
||||
);
|
||||
|
||||
const previewGroupMove = useCallback(
|
||||
(
|
||||
element: TimelineElement,
|
||||
@@ -161,6 +308,48 @@ export function useTimelineClipGroupDrag({
|
||||
[onMoveElementsRef, onPreviewMoveElementsRef, timelineElementsRef, updateElement],
|
||||
);
|
||||
|
||||
const previewGroupResize = useCallback(
|
||||
(
|
||||
element: TimelineElement,
|
||||
selectedElementIds: Set<string>,
|
||||
edge: TimelineGroupResizeEdge,
|
||||
updates: TimelineResizeUpdates,
|
||||
): PreviewGroupResizeResult => {
|
||||
let session = resizeSessionRef.current;
|
||||
if (!session || !sameGesture(session.grabbedKey, element) || session.edge !== edge) {
|
||||
if (!onResizeElementsRef.current) return { active: false, updates };
|
||||
session = createResizeSession(
|
||||
element,
|
||||
selectedElementIds,
|
||||
timelineElementsRef.current,
|
||||
edge,
|
||||
);
|
||||
if (!session) return { active: false, updates };
|
||||
resizeSessionRef.current = session;
|
||||
}
|
||||
|
||||
const changes = resolveResizeChanges(session, updates);
|
||||
if (!changes) return { active: false, updates };
|
||||
session.changes = changes;
|
||||
session.hasChanged = resizeSessionHasChanged(session, changes);
|
||||
|
||||
for (const change of changes) {
|
||||
updateElement(elementKey(change.element), {
|
||||
start: change.start,
|
||||
duration: change.duration,
|
||||
playbackStart: change.playbackStart,
|
||||
});
|
||||
}
|
||||
onPreviewResizeElementsRef.current?.(changes);
|
||||
|
||||
return {
|
||||
active: true,
|
||||
updates: resizeUpdatesForGrabbed(session, changes, updates),
|
||||
};
|
||||
},
|
||||
[onPreviewResizeElementsRef, onResizeElementsRef, timelineElementsRef, updateElement],
|
||||
);
|
||||
|
||||
const commitGroupMove = useCallback(
|
||||
(element: TimelineElement): boolean => {
|
||||
const session = moveSessionRef.current;
|
||||
@@ -177,13 +366,32 @@ export function useTimelineClipGroupDrag({
|
||||
[onMoveElementsRef, rollbackMove],
|
||||
);
|
||||
|
||||
const commitGroupResize = useCallback(
|
||||
(element: TimelineElement): boolean => {
|
||||
const session = resizeSessionRef.current;
|
||||
if (!session || !sameGesture(session.grabbedKey, element)) return false;
|
||||
resizeSessionRef.current = null;
|
||||
if (!session.hasChanged) return true;
|
||||
|
||||
Promise.resolve(onResizeElementsRef.current?.(session.changes)).catch((error) => {
|
||||
rollbackResize(session);
|
||||
console.error("[Timeline] Failed to persist group clip resize", error);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[onResizeElementsRef, rollbackResize],
|
||||
);
|
||||
|
||||
const clearGroupDragSessions = useCallback(() => {
|
||||
moveSessionRef.current = null;
|
||||
resizeSessionRef.current = null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
previewGroupMove,
|
||||
previewGroupResize,
|
||||
commitGroupMove,
|
||||
commitGroupResize,
|
||||
clearGroupDragSessions,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user