From 5d27af30b23e35d8fa2346909eb502bea05cd76f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 9 Jul 2026 02:12:18 -0400 Subject: [PATCH] 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. --- packages/studio/src/App.tsx | 1 + .../src/components/StudioPreviewArea.tsx | 28 ++- .../src/contexts/TimelineEditContext.tsx | 5 +- .../studio/src/player/components/Timeline.tsx | 10 +- .../player/components/timelineCallbacks.ts | 1 + .../player/components/timelineEditing.test.ts | 42 +++- .../src/player/components/timelineEditing.ts | 8 +- .../player/components/timelineGroupEditing.ts | 38 ++++ .../components/useTimelineClipDrag.test.tsx | 123 +++++++++++- .../player/components/useTimelineClipDrag.ts | 91 ++++++++- .../components/useTimelineClipGroupDrag.ts | 189 ++++++++++++++++++ 11 files changed, 511 insertions(+), 25 deletions(-) create mode 100644 packages/studio/src/player/components/timelineGroupEditing.ts create mode 100644 packages/studio/src/player/components/useTimelineClipGroupDrag.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index b82f59b79..0b3eec08b 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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} diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 1f03e67b1..71ffd0938 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -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, ) => Promise | void; + handleTimelineGroupMove: (changes: TimelineGroupMoveChange[]) => Promise | void; handleToggleTrackHidden: (track: number, hidden: boolean) => Promise | void; handleToggleElementHidden: (elementKey: string, hidden: boolean) => Promise | 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) { diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index 7b523cbeb..b5a1372ed 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -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, diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 937554bcc..a9cca4c84 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -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(null); - const containerRef = useRef(null); const scrollRef = useRef(null); const activeTool = usePlayerStore((s) => s.activeTool); const [hoveredClip, setHoveredClip] = useState(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 (
{ diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts index 6b8602e39..a6c19fec9 100644 --- a/packages/studio/src/player/components/timelineCallbacks.ts +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -47,6 +47,7 @@ export interface TimelineEditCallbacks { changes: TimelineGroupResizeChange[], options?: TimelineGroupCommitOptions, ) => Promise | void; + onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void; onToggleTrackHidden?: (track: number, hidden: boolean) => Promise | void; onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; diff --git a/packages/studio/src/player/components/timelineEditing.test.ts b/packages/studio/src/player/components/timelineEditing.test.ts index dfada1656..eaf7e9a76 100644 --- a/packages/studio/src/player/components/timelineEditing.test.ts +++ b/packages/studio/src/player/components/timelineEditing.test.ts @@ -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"); diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index 776536afd..e92d914c3 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -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)"} diff --git a/packages/studio/src/player/components/timelineGroupEditing.ts b/packages/studio/src/player/components/timelineGroupEditing.ts new file mode 100644 index 000000000..3d35414f2 --- /dev/null +++ b/packages/studio/src/player/components/timelineGroupEditing.ts @@ -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>; +} + +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, + })), + }; +} diff --git a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx index a90c598f3..e635339aa 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.test.tsx @@ -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 }); diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 1c8aabeb3..634711c88 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -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, ) => Promise | void; + onMoveElements?: (changes: TimelineGroupMoveChange[]) => Promise | 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); diff --git a/packages/studio/src/player/components/useTimelineClipGroupDrag.ts b/packages/studio/src/player/components/useTimelineClipGroupDrag.ts new file mode 100644 index 000000000..e8f691fad --- /dev/null +++ b/packages/studio/src/player/components/useTimelineClipGroupDrag.ts @@ -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>, +) => void; + +interface GroupTimingMember extends TimelineGroupTimingMember { + element: TimelineElement; + key: string; +} + +interface MoveSession { + grabbedKey: string; + members: GroupTimingMember[]; + changes: TimelineGroupMoveChange[]; + hasChanged: boolean; +} + +interface UseTimelineClipGroupDragInput { + timelineElementsRef: RefObject; + updateElement: UpdateTimelineElement; + onMoveElementsRef: RefObject< + ((changes: TimelineGroupMoveChange[]) => Promise | 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, + timelineElements: readonly TimelineElement[], +): GroupTimingMember[] | null { + const selectedElementIds = + selectedElementIdsInput instanceof Set ? selectedElementIdsInput : new Set(); + 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, + 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(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, + 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, + }; +}