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, + }; +}