diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx
index 28260a2752..c4da46f5e3 100644
--- a/packages/studio/src/player/components/Timeline.tsx
+++ b/packages/studio/src/player/components/Timeline.tsx
@@ -29,7 +29,6 @@ import {
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
import { useTrackGapMenu } from "./useTrackGapMenu";
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
-import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
import {
@@ -45,6 +44,7 @@ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
import { useTimelineActiveClips } from "./useTimelineActiveClips";
+import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
export {
shouldAutoScrollTimeline,
@@ -106,11 +106,7 @@ export const Timeline = memo(function Timeline({
onSplitElement: onSplitElementOverride,
});
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
- const playbackContext = useStudioPlaybackContextOptional();
- const setRefreshKey = playbackContext?.setRefreshKey;
- const refreshAfterLaneMove = useCallback(() => {
- setRefreshKey?.((key) => key + 1);
- }, [setRefreshKey]);
+ const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
useMusicBeatAnalysis();
const rawElements = usePlayerStore((s) => s.elements);
const expandedElements = useExpandedTimelineElements();
@@ -129,14 +125,8 @@ export const Timeline = memo(function Timeline({
const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
- const hasKeyframedClips = useMemo(
- () => hasKeyframedTimelineClips(gsapAnimations),
- [gsapAnimations],
- );
- const labelMode = hasKeyframedClips;
- // Without the label column the pre-t=0 breathing room is still TRACKS_LEFT_PAD
- // (dropping it would jam clip 0 against the gutter on every non-keyframed
- // composition); in label mode the 232px label column already provides it.
+ const labelMode = useMemo(() => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations]);
+ // The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
const contentGutter = labelMode ? GUTTER : 0;
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
@@ -236,7 +226,6 @@ export const Timeline = memo(function Timeline({
setResizingClip,
blockedClipRef,
suppressClickRef,
- syncClipDragAutoScroll,
} = useTimelineClipDrag({
scrollRef,
ppsRef,
@@ -253,6 +242,7 @@ export const Timeline = memo(function Timeline({
readZIndex: zSyncEnabled ? readClipZIndex : undefined,
onStackingPatches: zSyncEnabled ? applyStackingPatches : undefined,
refreshAfterLaneMove,
+ sessionEpoch,
});
const { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview } =
@@ -269,6 +259,9 @@ export const Timeline = memo(function Timeline({
onCompositionDrop: pinnedOnCompositionDrop,
});
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
+ const resizingElementIds =
+ resizingClip?.groupPreview?.map((change) => change.key) ??
+ (resizingClip ? [getTimelineElementIdentity(resizingClip.element)] : undefined);
const { recordTimelineScroll } = useTimelinePerformanceTelemetry({
totalClipCount: expandedElements.length,
totalRowCount: displayLayout.displayTrackOrder.length,
@@ -289,7 +282,7 @@ export const Timeline = memo(function Timeline({
selectedElementId,
revealElementId: clipRevealRequest?.elementId ?? null,
draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
- resizingRowKey: resizingClip?.element.track,
+ resizingElementIds,
clipContextMenuRowKey: clipContextMenu?.element.track,
keyframeContextMenuRowKey: kfContextMenu?.element.track,
lastScrollLeftRef,
@@ -332,7 +325,7 @@ export const Timeline = memo(function Timeline({
duration: displayDuration,
selectedElementId: selectedElementId ?? undefined,
draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined,
- resizingElementId: resizingClip ? getTimelineElementIdentity(resizingClip.element) : undefined,
+ resizingElementIds,
revealElementId: clipRevealRequest?.elementId,
focusedEaseElementId: focusedEaseSegment?.elementId,
clipContextMenuElementId: clipContextMenu
@@ -468,6 +461,7 @@ export const Timeline = memo(function Timeline({
>
s.beatDragging);
const draggedElement = draggedClip?.element ?? null;
const draggedElementIdentity = draggedElement ? getTimelineElementIdentity(draggedElement) : null;
- const activeDraggedElement =
- draggedClip?.started === true && draggedElement && draggedElementIdentity
- ? getRenderedTimelineElement({
- element: draggedElement,
- draggedElementId: draggedElementIdentity,
- previewStart: draggedClip.previewStart,
- previewTrack: draggedClip.previewTrack,
- })
- : null;
// The drag ghost follows the cursor freely (both axes) — CapCut-style. The
// "magnetic" affordance is a highlight on the destination lane (draggedRowIndex),
// which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it.
@@ -94,22 +82,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
selectedKeys: selectedElementIds,
}
: null;
- const activeDraggedPosition =
- draggedClip?.started === true && activeDraggedElement && scrollRef.current
- ? {
- left:
- draggedClip.pointerClientX -
- scrollRef.current.getBoundingClientRect().left +
- scrollRef.current.scrollLeft -
- draggedClip.pointerOffsetX,
- top:
- draggedClip.pointerClientY -
- scrollRef.current.getBoundingClientRect().top +
- scrollRef.current.scrollTop -
- draggedClip.pointerOffsetY,
- }
- : null;
-
return (
)}
- {/* Drag ghost */}
- {activeDraggedElement && activeDraggedPosition && (
-
- {}}
- onHoverEnd={() => {}}
- onResizeStart={() => {}}
- onClick={() => {}}
- onDoubleClick={() => {}}
- >
- {renderClipChildren(
- activeDraggedElement,
- props.getTrackStyle(activeDraggedElement.tag),
- props.renderClipContent,
- props.renderClipOverlay,
- )}
-
-
- )}
+
{/* Marquee (rubber-band) multi-select rectangle — mirrors the canvas
MarqueeOverlay look: semi-transparent accent fill + dashed border. */}
diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx
index e471a2f1a9..40410b565f 100644
--- a/packages/studio/src/player/components/TimelineClip.tsx
+++ b/packages/studio/src/player/components/TimelineClip.tsx
@@ -12,6 +12,7 @@ interface TimelineClipProps {
isSelected: boolean;
isHovered: boolean;
isDragging?: boolean;
+ isGestureActor?: boolean;
isActive?: boolean;
hasCustomContent: boolean;
capabilities: TimelineEditCapabilities;
@@ -36,6 +37,7 @@ export const TimelineClip = memo(function TimelineClip({
isSelected,
isHovered,
isDragging = false,
+ isGestureActor = false,
isActive = false,
hasCustomContent,
capabilities,
@@ -85,13 +87,14 @@ export const TimelineClip = memo(function TimelineClip({
return (
document.body.replaceChildren());
+
+describe("TimelineGestureOverlay", () => {
+ it("keeps the drag actor mounted without a source-row node", () => {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ const scroll = {
+ scrollLeft: 500,
+ scrollTop: 300,
+ getBoundingClientRect: () => ({ left: 50, top: 40 }),
+ } as HTMLDivElement;
+ act(() => {
+ root.render(
+
,
+ );
+ });
+ const actor = host.querySelector
('[data-timeline-gesture-actor="hero"]');
+ expect(actor?.style.left).toBe("780px");
+ expect(actor?.style.top).toBe("490px");
+ expect(actor?.querySelector(".timeline-clip")).not.toBeNull();
+ expect(actor?.querySelector("[data-el-id]")).toBeNull();
+ expect(actor?.querySelector("[data-clip]")).toBeNull();
+ expect(host.querySelector("[data-source-row]")).toBeNull();
+ act(() => root.unmount());
+ });
+
+ it("keeps the stable overlay host after terminal cleanup", () => {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+ expect(host.querySelector("[data-timeline-gesture-overlay]")).not.toBeNull();
+ expect(host.querySelector("[data-timeline-gesture-actor]")).toBeNull();
+ act(() => root.unmount());
+ });
+});
diff --git a/packages/studio/src/player/components/TimelineGestureOverlay.tsx b/packages/studio/src/player/components/TimelineGestureOverlay.tsx
new file mode 100644
index 0000000000..500d2c0ea2
--- /dev/null
+++ b/packages/studio/src/player/components/TimelineGestureOverlay.tsx
@@ -0,0 +1,96 @@
+import { memo, type ReactNode } from "react";
+import type { TimelineElement } from "../store/playerStore";
+import type { TimelineTheme } from "./timelineTheme";
+import { getRenderedTimelineElement } from "./timelineTheme";
+import { TimelineClip } from "./TimelineClip";
+import { getTimelineEditCapabilities } from "./timelineEditing";
+import { renderClipChildren } from "./timelineClipChildren";
+import { getTimelineDragOverlayPosition } from "./timelineClipDragPreview";
+import type { DraggedClipState } from "./timelineClipDragTypes";
+import type { TrackVisualStyle } from "./timelineIcons";
+import { isTimelineClipActive } from "./useTimelineActiveClips";
+
+interface TimelineGestureOverlayProps {
+ drag: DraggedClipState | null;
+ scrollRef: React.RefObject;
+ pixelsPerSecond: number;
+ rowHeight: number;
+ selectedElementId: string | null;
+ currentTime: number;
+ theme: TimelineTheme;
+ getTrackStyle: (tag: string) => TrackVisualStyle;
+ renderClipContent?: (
+ element: TimelineElement,
+ style: { clip: string; label: string },
+ ) => ReactNode;
+ renderClipOverlay?: (element: TimelineElement) => ReactNode;
+}
+
+/** Stable canvas child that owns the live drag actor independently of source rows. */
+export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({
+ drag,
+ scrollRef,
+ pixelsPerSecond,
+ rowHeight,
+ selectedElementId,
+ currentTime,
+ theme,
+ getTrackStyle,
+ renderClipContent,
+ renderClipOverlay,
+}: TimelineGestureOverlayProps) {
+ const element =
+ drag?.started === true
+ ? getRenderedTimelineElement({
+ element: drag.element,
+ draggedElementId: drag.element.key ?? drag.element.id,
+ previewStart: drag.previewStart,
+ previewTrack: drag.previewTrack,
+ })
+ : null;
+ const position = drag ? getTimelineDragOverlayPosition(drag, scrollRef.current) : null;
+ return (
+
+ {element && position && (
+
+ {}}
+ onHoverEnd={() => {}}
+ onResizeStart={() => {}}
+ onClick={() => {}}
+ onDoubleClick={() => {}}
+ >
+ {renderClipChildren(
+ element,
+ getTrackStyle(element.tag),
+ renderClipContent,
+ renderClipOverlay,
+ )}
+
+
+ )}
+
+ );
+});
diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx
index 39565398d6..ac68b6fb0b 100644
--- a/packages/studio/src/player/components/TimelineLanes.test.tsx
+++ b/packages/studio/src/player/components/TimelineLanes.test.tsx
@@ -116,7 +116,6 @@ function renderLanes(options: RenderLanesOptions = {}): {
setResizingClip={vi.fn()}
setDraggedClip={vi.fn()}
setSelectedElementId={vi.fn()}
- syncClipDragAutoScroll={vi.fn()}
shiftClickClipRef={createRef()}
getPreviewElement={(el) => el}
getTrackStyle={getTrackStyle}
diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx
index 954131587e..bd4de63b88 100644
--- a/packages/studio/src/player/components/TimelineLanes.tsx
+++ b/packages/studio/src/player/components/TimelineLanes.tsx
@@ -76,7 +76,6 @@ export function TimelineLanes({
setResizingClip,
setDraggedClip,
setSelectedElementId,
- syncClipDragAutoScroll,
shiftClickClipRef,
getPreviewElement,
getTrackStyle,
@@ -350,6 +349,7 @@ export function TimelineLanes({
setShowPopover(false);
setRangeSelection(null);
setResizingClip({
+ pointerId: e.pointerId,
element: el,
edge,
originClientX: e.clientX,
@@ -388,6 +388,7 @@ export function TimelineLanes({
(blockedIntent !== "move" && onResizeElement))
) {
blockedClipRef.current = {
+ pointerId: e.pointerId,
element: el,
intent: blockedIntent,
originClientX: e.clientX,
@@ -401,6 +402,7 @@ export function TimelineLanes({
setShowPopover(false);
setRangeSelection(null);
setDraggedClip({
+ pointerId: e.pointerId,
element: el,
originClientX: e.clientX,
originClientY: e.clientY,
@@ -418,7 +420,6 @@ export function TimelineLanes({
snapType: null,
started: false,
});
- syncClipDragAutoScroll(e.clientX, e.clientY);
}
}
onClick={
diff --git a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.test.ts b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.test.ts
index b52b984e39..74b7c2e085 100644
--- a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.test.ts
+++ b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.test.ts
@@ -21,6 +21,7 @@ describe("timeline clip drag gesture lifecycle", () => {
track: 0,
};
const drag: DraggedClipState = {
+ pointerId: 0,
element,
originClientX: 0,
originClientY: 0,
@@ -49,23 +50,36 @@ describe("timeline clip drag gesture lifecycle", () => {
}));
const onMoveElement = vi.fn();
const stopAutoScroll = vi.fn();
+ const cancelGestureRef = { current: () => false };
const dispose = mountTimelineClipDragGestureLifecycle({
+ lifecycleRef: {
+ current: {
+ kind: "drag",
+ phase: "active",
+ pointerId: null,
+ sessionEpoch: 0,
+ },
+ },
+ sessionEpochRef: { current: 0 },
+ cancelGestureRef,
+ scrollRef: { current: null },
draggedClipRef,
resizingClipRef,
blockedClipRef: { current: null },
groupResizeRef: { current: null },
suppressClickRef: { current: false },
+ gestureSelectedKeysRef: { current: new Set() },
elementsRef: { current: [element] },
trackOrderRef: { current: [0] },
- setDraggedClip,
- setResizingClip: () => {},
+ setDraggedClipState: setDraggedClip,
+ setResizingClipState: () => {},
setShowPopover: () => {},
setRangeSelectionRef: { current: null },
applyResizePointerRef: { current: () => {} },
syncClipDragAutoScrollRef: { current: () => {} },
stopClipDragAutoScrollRef: { current: stopAutoScroll },
updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
- restoreGroupResizeMembers: () => {},
+ publishDraggedClip: setDraggedClip,
updateElement: vi.fn(),
onMoveElementRef: { current: onMoveElement },
onMoveElementsRef: { current: undefined },
@@ -93,5 +107,6 @@ describe("timeline clip drag gesture lifecycle", () => {
expect(stopAutoScroll).toHaveBeenCalledTimes(1);
dispose();
+ expect(cancelGestureRef.current()).toBe(false);
});
});
diff --git a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts
index c9fc2b42b0..cfbb387159 100644
--- a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts
+++ b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts
@@ -14,20 +14,44 @@ import {
} from "./timelineOptimisticRevision";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import type { StackingPatch } from "./timelineStackingSync";
-import { usePlayerStore, type TimelineElement } from "../store/playerStore";
+import type { TimelineElement, usePlayerStore } from "../store/playerStore";
+
+export type TimelineGestureKind = "drag" | "resize";
+type TimelineGesturePhase = "active" | "committing" | "cancelled" | "complete";
+
+export interface TimelineGestureLifecycle {
+ kind: TimelineGestureKind | null;
+ phase: TimelineGesturePhase;
+ pointerId: number | null;
+ sessionEpoch: number;
+}
+
+interface TimelineGestureCommit {
+ kind: TimelineGestureKind;
+ drag: DraggedClipState | null;
+ resize: ResizingClipState | null;
+ groupResize: TimelineGroupResizeSession | null;
+}
type UpdateElement = ReturnType["updateElement"];
interface TimelineClipDragGestureLifecycleInput {
+ lifecycleRef: RefObject;
+ sessionEpochRef: RefObject;
+ cancelGestureRef: RefObject<
+ (options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean
+ >;
+ scrollRef: RefObject;
draggedClipRef: RefObject;
resizingClipRef: RefObject;
blockedClipRef: RefObject;
groupResizeRef: RefObject;
suppressClickRef: RefObject;
+ gestureSelectedKeysRef: RefObject>;
elementsRef: RefObject;
trackOrderRef: RefObject;
- setDraggedClip: Dispatch>;
- setResizingClip: Dispatch>;
+ setDraggedClipState: Dispatch>;
+ setResizingClipState: Dispatch>;
setShowPopover: (show: boolean) => void;
setRangeSelectionRef: RefObject<((selection: null) => void) | null>;
applyResizePointerRef: RefObject<(resize: ResizingClipState, clientX: number) => void>;
@@ -36,7 +60,7 @@ interface TimelineClipDragGestureLifecycleInput {
updateDraggedClipPreviewRef: RefObject<
(drag: DraggedClipState, clientX: number, clientY: number) => DraggedClipState
>;
- restoreGroupResizeMembers: (session: TimelineGroupResizeSession, all?: boolean) => void;
+ publishDraggedClip: (next: DraggedClipState | null) => void;
updateElement: UpdateElement;
onMoveElementRef: RefObject;
onMoveElementsRef: RefObject;
@@ -55,22 +79,27 @@ interface TimelineClipDragGestureLifecycleInput {
export function mountTimelineClipDragGestureLifecycle({
// The explicit destructuring mirrors the single call site's dependency object by design.
// fallow-ignore-next-line code-duplication
+ lifecycleRef,
+ sessionEpochRef,
+ cancelGestureRef,
+ scrollRef,
draggedClipRef,
resizingClipRef,
blockedClipRef,
groupResizeRef,
suppressClickRef,
+ gestureSelectedKeysRef,
elementsRef,
trackOrderRef,
- setDraggedClip,
- setResizingClip,
+ setDraggedClipState,
+ setResizingClipState,
setShowPopover,
setRangeSelectionRef,
applyResizePointerRef,
syncClipDragAutoScrollRef,
stopClipDragAutoScrollRef,
updateDraggedClipPreviewRef,
- restoreGroupResizeMembers,
+ publishDraggedClip,
updateElement,
onMoveElementRef,
onMoveElementsRef,
@@ -87,9 +116,65 @@ export function mountTimelineClipDragGestureLifecycle({
});
};
+ const pointerMatchesGesture = (event: PointerEvent): boolean => {
+ const pointerId = lifecycleRef.current.pointerId;
+ return pointerId === null || event.pointerId === pointerId;
+ };
+
+ const releasePointerCapture = (pointerId: number | null) => {
+ if (pointerId === null) return;
+ const scroll = scrollRef.current;
+ try {
+ if (scroll?.hasPointerCapture(pointerId)) scroll.releasePointerCapture(pointerId);
+ } catch {
+ // Window listeners are authoritative when native capture is unavailable.
+ }
+ };
+
+ const capturePointer = (pointerId: number | null) => {
+ if (pointerId === null) return;
+ try {
+ scrollRef.current?.setPointerCapture(pointerId);
+ } catch {
+ // Window listeners remain authoritative when native capture is unavailable.
+ }
+ };
+
+ const clearGestureProjection = (updateReact: boolean) => {
+ draggedClipRef.current = null;
+ resizingClipRef.current = null;
+ groupResizeRef.current = null;
+ if (updateReact) {
+ setDraggedClipState(null);
+ setResizingClipState(null);
+ }
+ };
+
+ const cancelGesture = ({
+ updateReact = true,
+ suppressClick = false,
+ }: {
+ updateReact?: boolean;
+ suppressClick?: boolean;
+ } = {}): boolean => {
+ const lifecycle = lifecycleRef.current;
+ if (lifecycle.phase !== "active") return false;
+ lifecycle.phase = "cancelled";
+ stopClipDragAutoScrollRef.current();
+ clearGestureProjection(updateReact);
+ releasePointerCapture(lifecycle.pointerId);
+ if (suppressClick) suppressClickRef.current = true;
+ lifecycle.kind = null;
+ lifecycle.pointerId = null;
+ lifecycle.phase = "complete";
+ return true;
+ };
+ cancelGestureRef.current = cancelGesture;
+
const handleResizePointerMove = (event: PointerEvent, resize: ResizingClipState) => {
const distance = Math.abs(event.clientX - resize.originClientX);
if (!resize.started && distance < 2) return;
+ if (!resize.started) capturePointer(resize.pointerId);
setShowPopover(false);
setRangeSelectionRef.current?.(null);
applyResizePointerRef.current(resize, event.clientX);
@@ -106,6 +191,7 @@ export function mountTimelineClipDragGestureLifecycle({
if (!blocked.started) {
blocked.started = true;
blockedClipRef.current = blocked;
+ capturePointer(blocked.pointerId);
suppressClickRef.current = true;
setShowPopover(false);
setRangeSelectionRef.current?.(null);
@@ -119,34 +205,33 @@ export function mountTimelineClipDragGestureLifecycle({
event.clientY - drag.originClientY,
);
if (!drag.started && distance < 4) return;
+ if (!drag.started) capturePointer(drag.pointerId);
setShowPopover(false);
setRangeSelectionRef.current?.(null);
- setDraggedClip((previous) =>
- previous
- ? updateDraggedClipPreviewRef.current(previous, event.clientX, event.clientY)
- : previous,
- );
+ publishDraggedClip(updateDraggedClipPreviewRef.current(drag, event.clientX, event.clientY));
syncClipDragAutoScrollRef.current(event.clientX, event.clientY);
};
const handleWindowPointerMove = (event: PointerEvent) => {
const resize = resizingClipRef.current;
- if (resize) return handleResizePointerMove(event, resize);
+ if (resize) {
+ if (!pointerMatchesGesture(event)) return;
+ return handleResizePointerMove(event, resize);
+ }
const blocked = blockedClipRef.current;
- if (blocked) return handleBlockedPointerMove(event, blocked);
+ if (blocked) {
+ if (blocked.pointerId !== event.pointerId) return;
+ return handleBlockedPointerMove(event, blocked);
+ }
const drag = draggedClipRef.current;
- if (drag) handleDragPointerMove(event, drag);
+ if (drag && pointerMatchesGesture(event)) handleDragPointerMove(event, drag);
};
- const commitResizePointerUp = (resize: ResizingClipState) => {
- resizingClipRef.current = null;
- setResizingClip(null);
- const groupSession = groupResizeRef.current;
- groupResizeRef.current = null;
- if (!resize.started) {
- if (groupSession) restoreGroupResizeMembers(groupSession);
- return;
- }
+ const commitResizePointerUp = (
+ resize: ResizingClipState,
+ groupSession: TimelineGroupResizeSession | null,
+ ) => {
+ if (!resize.started) return;
suppressClickRef.current = true;
clearSuppressedClick();
if (groupSession) {
@@ -193,8 +278,6 @@ export function mountTimelineClipDragGestureLifecycle({
};
const commitDragPointerUp = (drag: DraggedClipState) => {
- draggedClipRef.current = null;
- setDraggedClip(null);
if (!drag.started) return;
suppressClickRef.current = true;
clearSuppressedClick();
@@ -204,25 +287,96 @@ export function mountTimelineClipDragGestureLifecycle({
updateElement,
onMoveElement: onMoveElementRef.current,
onMoveElements: onMoveElementsRef.current,
- selectedKeys: usePlayerStore.getState().selectedElementIds,
+ selectedKeys: gestureSelectedKeysRef.current,
readZIndex: readZIndexRef.current,
onStackingPatches: onStackingPatchesRef.current,
refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
});
};
- const handleWindowPointerUp = () => {
+ /** Group gestures commit atomically: one missing member cancels the whole resize. */
+ const gestureSourcesStillExist = (): boolean => {
+ const gestureElements = groupResizeRef.current?.members.map((member) => member.element) ?? [
+ resizingClipRef.current?.element ?? draggedClipRef.current?.element,
+ ];
+ const liveKeys = new Set(elementsRef.current.map((element) => element.key ?? element.id));
+ return gestureElements.every(
+ (element) => element === undefined || liveKeys.has(element.key ?? element.id),
+ );
+ };
+
+ const claimActiveGesture = (event: PointerEvent): TimelineGestureCommit | "ignored" | null => {
+ const lifecycle = lifecycleRef.current;
+ if (lifecycle.phase !== "active") return null;
+ if (!pointerMatchesGesture(event)) return "ignored";
+ if (lifecycle.sessionEpoch !== sessionEpochRef.current) {
+ cancelGesture();
+ return "ignored";
+ }
+ if (!gestureSourcesStillExist()) {
+ cancelGesture();
+ return "ignored";
+ }
+ lifecycle.phase = "committing";
+ const claimed = lifecycle.kind
+ ? {
+ kind: lifecycle.kind,
+ drag: draggedClipRef.current,
+ resize: resizingClipRef.current,
+ groupResize: groupResizeRef.current,
+ }
+ : "ignored";
stopClipDragAutoScrollRef.current();
- const resize = resizingClipRef.current;
- if (resize) return commitResizePointerUp(resize);
- const blocked = blockedClipRef.current;
- if (blocked) return finishBlockedPointerUp(blocked);
- const drag = draggedClipRef.current;
- if (!drag) {
- if (suppressClickRef.current) clearSuppressedClick();
+ clearGestureProjection(true);
+ releasePointerCapture(lifecycle.pointerId);
+ if (claimed === "ignored") lifecycle.phase = "complete";
+ return claimed;
+ };
+
+ const commitClaimedGesture = (gesture: TimelineGestureCommit) => {
+ try {
+ if (gesture.kind === "resize" && gesture.resize) {
+ commitResizePointerUp(gesture.resize, gesture.groupResize);
+ } else if (gesture.kind === "drag" && gesture.drag) {
+ commitDragPointerUp(gesture.drag);
+ }
+ } finally {
+ const lifecycle = lifecycleRef.current;
+ lifecycle.kind = null;
+ lifecycle.pointerId = null;
+ lifecycle.phase = "complete";
+ }
+ };
+
+ const handleWindowPointerUp = (event: PointerEvent) => {
+ const claimed = claimActiveGesture(event);
+ if (claimed === "ignored") return;
+ if (claimed) {
+ commitClaimedGesture(claimed);
return;
}
- commitDragPointerUp(drag);
+ const blocked = blockedClipRef.current;
+ if (blocked) {
+ if (blocked.pointerId !== event.pointerId) return;
+ return finishBlockedPointerUp(blocked);
+ }
+ if (suppressClickRef.current) clearSuppressedClick();
+ };
+
+ const handleWindowPointerCancel = (event: PointerEvent) => {
+ if (lifecycleRef.current.phase === "active" && pointerMatchesGesture(event)) {
+ if (cancelGesture({ suppressClick: true })) clearSuppressedClick();
+ }
+ const blocked = blockedClipRef.current;
+ if (blocked && blocked.pointerId !== event.pointerId) return;
+ blockedClipRef.current = null;
+ if (suppressClickRef.current) clearSuppressedClick();
+ };
+
+ const handleLostPointerCapture = (event: PointerEvent) => {
+ if (lifecycleRef.current.phase === "active" && pointerMatchesGesture(event)) {
+ if (cancelGesture({ suppressClick: true })) clearSuppressedClick();
+ }
};
const handleWindowKeyDown = (event: KeyboardEvent) => {
@@ -235,27 +389,22 @@ export function mountTimelineClipDragGestureLifecycle({
if (!decision.cancel) return;
event.preventDefault();
event.stopPropagation();
- stopClipDragAutoScrollRef.current();
- draggedClipRef.current = null;
- setDraggedClip(null);
- resizingClipRef.current = null;
- setResizingClip(null);
- const groupSession = groupResizeRef.current;
- groupResizeRef.current = null;
- if (groupSession) restoreGroupResizeMembers(groupSession);
blockedClipRef.current = null;
- if (decision.suppressClick) suppressClickRef.current = true;
+ cancelGesture({ suppressClick: decision.suppressClick });
};
window.addEventListener("pointermove", handleWindowPointerMove);
window.addEventListener("pointerup", handleWindowPointerUp);
- window.addEventListener("pointercancel", handleWindowPointerUp);
+ window.addEventListener("pointercancel", handleWindowPointerCancel);
+ window.addEventListener("lostpointercapture", handleLostPointerCapture);
window.addEventListener("keydown", handleWindowKeyDown, true);
return () => {
- stopClipDragAutoScrollRef.current();
+ cancelGesture({ updateReact: false });
+ cancelGestureRef.current = () => false;
window.removeEventListener("pointermove", handleWindowPointerMove);
window.removeEventListener("pointerup", handleWindowPointerUp);
- window.removeEventListener("pointercancel", handleWindowPointerUp);
+ window.removeEventListener("pointercancel", handleWindowPointerCancel);
+ window.removeEventListener("lostpointercapture", handleLostPointerCapture);
window.removeEventListener("keydown", handleWindowKeyDown, true);
};
}
diff --git a/packages/studio/src/player/components/timelineClipDragPreview.test.ts b/packages/studio/src/player/components/timelineClipDragPreview.test.ts
index c71e9e73f6..d14fb1f7bb 100644
--- a/packages/studio/src/player/components/timelineClipDragPreview.test.ts
+++ b/packages/studio/src/player/components/timelineClipDragPreview.test.ts
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
import {
computeDragPreview,
computeResizePreview,
+ getTimelineDragOverlayPosition,
type DragPreviewContext,
} from "./timelineClipDragPreview";
import type { DraggedClipState } from "./timelineClipDragTypes";
@@ -84,6 +85,7 @@ function horizontalDrag(
const originClientX = 800;
const originClientY = yForRow(grabRowFloat);
const drag: DraggedClipState = {
+ pointerId: 0,
element,
originClientX,
originClientY,
@@ -129,6 +131,7 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
const originClientX = 800;
const originClientY = yForRow(0.5);
const drag: DraggedClipState = {
+ pointerId: 0,
element: moodboard,
originClientX,
originClientY,
@@ -170,6 +173,7 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
const occupied = [dragged, clip("block-0", 0, 0, 1, 2), clip("block-1", 1, 0, 1, 1)];
const clientY = RULER_H + TRACKS_TOP_PAD + 30;
const drag: DraggedClipState = {
+ pointerId: 0,
element: dragged,
originClientX: 0,
originClientY: clientY,
@@ -221,3 +225,32 @@ describe("computeResizePreview — composition source continuity", () => {
});
});
});
+
+describe("getTimelineDragOverlayPosition", () => {
+ it("keeps the gesture actor under the pointer across two-axis autoscroll", () => {
+ const { drag } = horizontalDrag(moodboard, 0.5, 2);
+ const scroll = {
+ scrollLeft: 500,
+ scrollTop: 300,
+ getBoundingClientRect: () => ({ left: 20, top: 40 }),
+ } as Pick;
+ expect(
+ getTimelineDragOverlayPosition(
+ {
+ ...drag,
+ pointerClientX: 900,
+ pointerClientY: 700,
+ pointerOffsetX: 25,
+ pointerOffsetY: 10,
+ },
+ scroll,
+ ),
+ ).toEqual({ left: 1_355, top: 950 });
+ });
+
+ it("does not mount an actor before threshold or without the stable viewport", () => {
+ const { drag } = horizontalDrag(moodboard, 0.5, 2);
+ expect(getTimelineDragOverlayPosition({ ...drag, started: false }, fakeScroll())).toBeNull();
+ expect(getTimelineDragOverlayPosition(drag, null)).toBeNull();
+ });
+});
diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts
index 982f42fbff..935878205d 100644
--- a/packages/studio/src/player/components/timelineClipDragPreview.ts
+++ b/packages/studio/src/player/components/timelineClipDragPreview.ts
@@ -45,6 +45,19 @@ export interface DragPreviewContext {
audioTracks?: ReadonlySet;
}
+/** Content-space position for the stable viewport drag actor. */
+export function getTimelineDragOverlayPosition(
+ drag: DraggedClipState,
+ scroll: Pick | null,
+): { left: number; top: number } | null {
+ if (!drag.started || !scroll) return null;
+ const rect = scroll.getBoundingClientRect();
+ return {
+ left: drag.pointerClientX - rect.left + scroll.scrollLeft - drag.pointerOffsetX,
+ top: drag.pointerClientY - rect.top + scroll.scrollTop - drag.pointerOffsetY,
+ };
+}
+
/**
* Max start a drag may reach. Allow dragging past the current content into the
* rendered timeline extent (the viewport-fill keeps that ≥ the viewport width).
@@ -328,29 +341,22 @@ export function computeResizePreview(
/**
* Apply a rigid group-resize preview: fold the grabbed clip's raw delta into the
- * session, preview every non-grabbed member through the store (`updateElement`),
- * and set the grabbed clip's preview state (it renders from resizingClip state, so
- * its store value stays pristine until commit — like the single-clip path).
+ * session and publish a coordinator-owned projection. Canonical elements stay
+ * pristine until the exactly-once commit.
*/
export function previewGroupResize(
session: TimelineGroupResizeSession,
next: ResizePreviewResult,
- grabbedKey: string,
- updateElement: (
- key: string,
- patch: { start: number; duration: number; playbackStart?: number },
+ setResizeState: (
+ v: ResizePreviewResult & { groupPreview: TimelineGroupResizeSession["changes"] },
) => void,
- setResizeState: (v: ResizePreviewResult) => void,
): void {
const grabbedChange = applyTimelineGroupResizePreview(session, next);
- for (const c of session.changes) {
- if (c.key === grabbedKey) continue;
- updateElement(c.key, { start: c.start, duration: c.duration, playbackStart: c.playbackStart });
- }
setResizeState({
originScrollLeft: next.originScrollLeft,
previewStart: grabbedChange?.start ?? next.previewStart,
previewDuration: grabbedChange?.duration ?? next.previewDuration,
previewPlaybackStart: grabbedChange?.playbackStart ?? next.previewPlaybackStart,
+ groupPreview: session.changes,
});
}
diff --git a/packages/studio/src/player/components/timelineClipDragTypes.ts b/packages/studio/src/player/components/timelineClipDragTypes.ts
index 8ac36c9a68..251a299f6d 100644
--- a/packages/studio/src/player/components/timelineClipDragTypes.ts
+++ b/packages/studio/src/player/components/timelineClipDragTypes.ts
@@ -4,6 +4,8 @@ import type { BlockedTimelineEditIntent } from "./timelineEditing";
/* ── Shared clip-drag state types ───────────────────────────────── */
export interface DraggedClipState {
+ /** Pointer captured by the stable viewport coordinator for this gesture. */
+ pointerId: number;
element: TimelineElement;
originClientX: number;
originClientY: number;
@@ -39,6 +41,8 @@ export interface DraggedClipState {
}
export interface ResizingClipState {
+ /** Pointer captured by the stable viewport coordinator for this gesture. */
+ pointerId: number;
element: TimelineElement;
edge: "start" | "end";
originClientX: number;
@@ -53,10 +57,18 @@ export interface ResizingClipState {
previewStart: number;
previewDuration: number;
previewPlaybackStart?: number;
+ /** Coordinator-owned group projection; canonical elements change only on commit. */
+ groupPreview?: readonly {
+ key: string;
+ start: number;
+ duration: number;
+ playbackStart?: number;
+ }[];
started: boolean;
}
export interface BlockedClipState {
+ pointerId: number;
element: TimelineElement;
intent: BlockedTimelineEditIntent;
originClientX: number;
diff --git a/packages/studio/src/player/components/timelineLaneProps.ts b/packages/studio/src/player/components/timelineLaneProps.ts
index 23fb4f6f68..b16a03b3b3 100644
--- a/packages/studio/src/player/components/timelineLaneProps.ts
+++ b/packages/studio/src/player/components/timelineLaneProps.ts
@@ -55,7 +55,6 @@ export interface TimelineLaneBaseProps {
setResizingClip: (v: ResizingClipState | null) => void;
setDraggedClip: (v: DraggedClipState | null) => void;
setSelectedElementId: (id: string | null) => void;
- syncClipDragAutoScroll: (x: number, y: number) => void;
shiftClickClipRef: React.RefObject<{
element: TimelineElement;
anchorX: number;
diff --git a/packages/studio/src/player/components/timelineViewModel.ts b/packages/studio/src/player/components/timelineViewModel.ts
index ace13a4d1c..3eec25cb79 100644
--- a/packages/studio/src/player/components/timelineViewModel.ts
+++ b/packages/studio/src/player/components/timelineViewModel.ts
@@ -29,10 +29,10 @@ export function getTimelinePreviewElement(
element: TimelineElement,
resizingClip: ResizingClipState | null,
): TimelineElement {
- if (
- resizingClip &&
- getTimelineElementIdentity(resizingClip.element) === getTimelineElementIdentity(element)
- ) {
+ const elementIdentity = getTimelineElementIdentity(element);
+ const groupPreview = resizingClip?.groupPreview?.find((change) => change.key === elementIdentity);
+ if (groupPreview) return { ...element, ...groupPreview };
+ if (resizingClip && getTimelineElementIdentity(resizingClip.element) === elementIdentity) {
return {
...element,
start: resizingClip.previewStart,
diff --git a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx
index 33ebd8ff47..43568db6da 100644
--- a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx
+++ b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx
@@ -4,7 +4,7 @@ import React, { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
-import type { ResizingClipState } from "./useTimelineClipDrag";
+import type { BlockedClipState, DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
import { useTimelineClipDrag } from "./useTimelineClipDrag";
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
@@ -38,38 +38,77 @@ function renderResizeHarness(
usePlayerStore.getState().setSelectedElementIds(new Set(selected));
const scroll = document.createElement("div");
+ const setPointerCapture = vi.fn();
+ scroll.setPointerCapture = setPointerCapture;
document.body.append(scroll);
const onResizeElement = vi.fn();
+ const onMoveElement = vi.fn();
+ const onBlockedEditAttempt = vi.fn();
const onResizeElements = vi.fn().mockResolvedValue(undefined);
let setResizingClip: ((s: ResizingClipState | null) => void) | null = null;
+ let setDraggedClip: ((s: DraggedClipState | null) => void) | null = null;
+ let resizingClip: ResizingClipState | null = null;
+ let blockedClipRef: React.RefObject | null = null;
+ let epoch = 0;
- function Harness() {
+ function Harness({ sessionEpoch }: { sessionEpoch: number }) {
const hook = useTimelineClipDrag({
scrollRef: { current: scroll },
ppsRef: { current: 100 },
durationRef: { current: 100 },
trackOrderRef: { current: [0, 1] },
onResizeElement,
+ onMoveElement,
+ onBlockedEditAttempt,
onResizeElements: options.wireGroupResize === false ? undefined : onResizeElements,
setShowPopover: vi.fn(),
setRangeSelectionRef: { current: vi.fn() },
+ sessionEpoch,
});
setResizingClip = hook.setResizingClip;
+ setDraggedClip = hook.setDraggedClip;
+ resizingClip = hook.resizingClip;
+ blockedClipRef = hook.blockedClipRef;
return null;
}
- const root = mountReactHarness();
+ const root = mountReactHarness();
const apply = setResizingClip!;
+ const dispatchPointer = (type: string, clientX: number, pointerId = 0) => {
+ const event = new MouseEvent(type, { bubbles: true, clientX, clientY: 0 });
+ Object.defineProperty(event, "pointerId", { value: pointerId });
+ window.dispatchEvent(event);
+ };
return {
onResizeElement,
+ onMoveElement,
+ onBlockedEditAttempt,
onResizeElements,
+ setPointerCapture,
storeById(id: string) {
return usePlayerStore.getState().elements.find((e) => e.id === id)!;
},
- startResize(element: TimelineElement, edge: "start" | "end") {
+ getResizeProjection() {
+ return resizingClip?.groupPreview ?? [];
+ },
+ getBlockedClip() {
+ return blockedClipRef?.current ?? null;
+ },
+ startBlocked(element: TimelineElement, pointerId = 0) {
+ blockedClipRef!.current = {
+ pointerId,
+ element,
+ intent: "move",
+ originClientX: 0,
+ originClientY: 0,
+ started: false,
+ };
+ },
+ startResize(element: TimelineElement, edge: "start" | "end", pointerId = 0) {
act(() => {
apply({
+ pointerId,
element,
edge,
originClientX: 0,
@@ -80,16 +119,54 @@ function renderResizeHarness(
});
});
},
- movePointer(clientX: number) {
+ startDrag(element: TimelineElement, pointerId = 0) {
+ act(() =>
+ setDraggedClip?.({
+ pointerId,
+ element,
+ originClientX: 0,
+ originClientY: 0,
+ originScrollLeft: 0,
+ originScrollTop: 0,
+ pointerClientX: 0,
+ pointerClientY: 0,
+ pointerOffsetX: 0,
+ pointerOffsetY: 0,
+ previewStart: element.start,
+ previewTrack: element.track,
+ insertRow: null,
+ snapTime: null,
+ snapType: null,
+ started: false,
+ }),
+ );
+ },
+ movePointer(clientX: number, pointerId = 0) {
act(() => {
- window.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX, clientY: 0 }));
+ dispatchPointer("pointermove", clientX, pointerId);
+ });
+ },
+ async dropPointer(pointerId = 0) {
+ await act(async () => {
+ dispatchPointer("pointerup", 0, pointerId);
});
},
- async dropPointer() {
+ async moveAndDropPointer(clientX: number, pointerId = 0) {
await act(async () => {
- window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
+ dispatchPointer("pointermove", clientX, pointerId);
+ dispatchPointer("pointerup", clientX, pointerId);
});
},
+ cancelPointer(pointerId = 0) {
+ act(() => dispatchPointer("pointercancel", 0, pointerId));
+ },
+ losePointerCapture(pointerId = 0) {
+ act(() => dispatchPointer("lostpointercapture", 0, pointerId));
+ },
+ setSessionEpoch(next: number) {
+ epoch = next;
+ act(() => root.render());
+ },
pressEscape() {
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
@@ -161,14 +238,131 @@ describe("useTimelineClipDrag — single-clip resize (unchanged path)", () => {
});
});
+describe("useTimelineClipDrag — gesture lifecycle", () => {
+ it("does not capture a stationary click before the drag threshold", () => {
+ const a = el("a", { duration: 2 });
+ const h = renderResizeHarness([a], []);
+ h.startDrag(a, 7);
+ expect(h.setPointerCapture).not.toHaveBeenCalled();
+
+ h.movePointer(3, 7);
+ expect(h.setPointerCapture).not.toHaveBeenCalled();
+
+ h.movePointer(5, 7);
+ expect(h.setPointerCapture).toHaveBeenCalledOnce();
+ expect(h.setPointerCapture).toHaveBeenCalledWith(7);
+ h.unmount();
+ });
+
+ it("commits the final drag position exactly once", async () => {
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const a = el("a", { duration: 2 });
+ const h = renderResizeHarness([a], []);
+ h.startDrag(a, 7);
+ await h.moveAndDropPointer(50, 7);
+ await h.dropPointer(7);
+ expect(h.onMoveElement).toHaveBeenCalledTimes(1);
+ expect(h.onMoveElement).toHaveBeenCalledWith(a, expect.objectContaining({ start: 0.5 }));
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining("only single-clip onMoveElement wired"),
+ );
+ warnSpy.mockRestore();
+ h.unmount();
+ });
+
+ it("ignores a foreign pointer cancellation during a blocked gesture", async () => {
+ const a = el("a", { duration: 2 });
+ const h = renderResizeHarness([a], []);
+ h.startBlocked(a, 7);
+ h.movePointer(5, 7);
+ expect(h.onBlockedEditAttempt).toHaveBeenCalledOnce();
+
+ h.cancelPointer(8);
+ expect(h.getBlockedClip()?.pointerId).toBe(7);
+
+ await h.dropPointer(7);
+ expect(h.getBlockedClip()).toBeNull();
+ h.unmount();
+ });
+
+ it("commits the final same-turn pointer move exactly once", async () => {
+ const a = el("a", { duration: 2 });
+ const h = renderResizeHarness([a], []);
+ h.startResize(a, "end", 7);
+ await h.moveAndDropPointer(75, 7);
+ await h.dropPointer(7);
+ expect(h.onResizeElement).toHaveBeenCalledTimes(1);
+ expect(h.onResizeElement).toHaveBeenCalledWith(a, expect.objectContaining({ duration: 2.75 }));
+ h.unmount();
+ });
+
+ it("ignores another pointer and cancels without committing", async () => {
+ const a = el("a", { duration: 2 });
+ const h = renderResizeHarness([a], []);
+ h.startResize(a, "end", 7);
+ h.movePointer(50, 8);
+ await h.dropPointer(8);
+ expect(h.onResizeElement).not.toHaveBeenCalled();
+ h.cancelPointer(7);
+ await h.dropPointer(7);
+ expect(h.onResizeElement).not.toHaveBeenCalled();
+ expect(h.storeById("a").duration).toBe(2);
+ h.unmount();
+ });
+
+ it("cancels an active projection when the project epoch changes", () => {
+ const a = el("a", { duration: 2 });
+ const h = renderResizeHarness([a], []);
+ h.startResize(a, "end", 7);
+ h.movePointer(50, 7);
+ h.setSessionEpoch(1);
+ expect(h.getResizeProjection()).toHaveLength(0);
+ expect(h.onResizeElement).not.toHaveBeenCalled();
+ expect(h.storeById("a").duration).toBe(2);
+ h.unmount();
+ });
+
+ it("does not commit a stale clip deleted during the gesture", async () => {
+ const a = el("a", { duration: 2 });
+ const h = renderResizeHarness([a], []);
+ h.startResize(a, "end", 7);
+ h.movePointer(50, 7);
+ act(() => usePlayerStore.getState().setElements([]));
+ await h.dropPointer(7);
+ expect(h.onResizeElement).not.toHaveBeenCalled();
+ expect(usePlayerStore.getState().elements).toEqual([]);
+ h.unmount();
+ });
+
+ it("treats lost capture and unmount as cancellation", async () => {
+ const a = el("a", { duration: 2 });
+ const lost = renderResizeHarness([a], []);
+ lost.startResize(a, "end", 7);
+ lost.movePointer(50, 7);
+ lost.losePointerCapture(7);
+ await lost.dropPointer(7);
+ expect(lost.onResizeElement).not.toHaveBeenCalled();
+ lost.unmount();
+
+ const unmounted = renderResizeHarness([a], []);
+ unmounted.startResize(a, "end", 9);
+ unmounted.movePointer(50, 9);
+ unmounted.unmount();
+ expect(unmounted.onResizeElement).not.toHaveBeenCalled();
+ expect(usePlayerStore.getState().elements[0]?.duration).toBe(2);
+ });
+});
+
describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
- it("previews the non-grabbed member through the store, grabbed stays out until commit", () => {
+ it("previews every member without mutating canonical elements", () => {
const { h } = startGroupResize(); // grabbed asks +0.5
- // Non-grabbed member is previewed in the store; the grabbed clip renders from
- // resizingClip state, so its store value is still the original until commit.
- expect(h.storeById("b").duration).toBe(3.5);
+ expect(h.getResizeProjection()).toEqual([
+ expect.objectContaining({ key: "a", duration: 2.5 }),
+ expect.objectContaining({ key: "b", duration: 3.5 }),
+ ]);
expect(h.storeById("a").duration).toBe(2);
+ expect(h.storeById("b").duration).toBe(3);
h.unmount();
});
@@ -186,6 +380,7 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
{ coalesceKey: expect.stringMatching(/^clip-group-resize:/) },
);
expect(h.storeById("a").duration).toBe(2.5);
+ expect(h.getResizeProjection()).toHaveLength(0);
expect(h.storeById("b").duration).toBe(3.5);
h.unmount();
});
@@ -213,7 +408,10 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
});
h.startResize(a, "end");
h.movePointer(50);
- expect(h.storeById("b").duration).toBe(3.5);
+ expect(h.getResizeProjection()).toEqual(
+ expect.arrayContaining([expect.objectContaining({ key: "b", duration: 3.5 })]),
+ );
+ expect(h.storeById("b").duration).toBe(3);
await h.dropPointer();
expect(h.storeById("a").duration).toBe(2);
@@ -229,12 +427,13 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
h.unmount();
});
- it("Escape rolls back the previewed non-grabbed member and persists nothing", () => {
+ it("Escape discards the projection and persists nothing", () => {
const { h } = startGroupResize();
- expect(h.storeById("b").duration).toBe(3.5); // previewed
+ expect(h.getResizeProjection()).toHaveLength(2);
h.pressEscape();
- expect(h.storeById("b").duration).toBe(3); // restored
+ expect(h.getResizeProjection()).toHaveLength(0);
+ expect(h.storeById("b").duration).toBe(3);
expect(h.onResizeElement).not.toHaveBeenCalled();
h.unmount();
});
diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts
index 86ce7173e2..ea44dda075 100644
--- a/packages/studio/src/player/components/useTimelineClipDrag.ts
+++ b/packages/studio/src/player/components/useTimelineClipDrag.ts
@@ -1,4 +1,4 @@
-import { useRef, useState, useCallback, useMemo } from "react";
+import { useRef, useState, useCallback, useMemo, useEffect } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import {
applyTimelineAutoScrollStep,
@@ -25,9 +25,13 @@ import type {
ResizingClipState,
BlockedClipState,
} from "./timelineClipDragTypes";
-import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle";
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import type { TimelineRowGeometry } from "./timelineLayout";
+import {
+ mountTimelineClipDragGestureLifecycle,
+ type TimelineGestureKind,
+ type TimelineGestureLifecycle,
+} from "./timelineClipDragGestureLifecycle";
export type {
DraggedClipState,
@@ -74,6 +78,7 @@ interface UseTimelineClipDragInput {
readZIndex?: (element: TimelineElement) => number;
onStackingPatches?: (patches: StackingPatch[]) => Promise | void;
refreshAfterLaneMove?: () => void;
+ sessionEpoch?: number;
}
export function useTimelineClipDrag({
@@ -92,6 +97,7 @@ export function useTimelineClipDrag({
readZIndex,
onStackingPatches,
refreshAfterLaneMove,
+ sessionEpoch = 0,
}: UseTimelineClipDragInput) {
const updateElement = usePlayerStore((s) => s.updateElement);
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
@@ -162,41 +168,72 @@ export function useTimelineClipDrag({
[],
);
- const [draggedClip, setDraggedClip] = useState(null);
+ const [draggedClip, setDraggedClipState] = useState(null);
const draggedClipRef = useRef(null);
- draggedClipRef.current = draggedClip;
+ const publishDraggedClip = useCallback((next: DraggedClipState | null) => {
+ draggedClipRef.current = next;
+ setDraggedClipState(next);
+ }, []);
- const [resizingClip, setResizingClip] = useState(null);
+ const [resizingClip, setResizingClipState] = useState(null);
const resizingClipRef = useRef(null);
- resizingClipRef.current = resizingClip;
+ const publishResizingClip = useCallback((next: ResizingClipState | null) => {
+ resizingClipRef.current = next;
+ setResizingClipState(next);
+ }, []);
+
+ const lifecycleRef = useRef({
+ kind: null,
+ phase: "complete",
+ pointerId: null,
+ sessionEpoch,
+ });
+ const sessionEpochRef = useRef(sessionEpoch);
+ sessionEpochRef.current = sessionEpoch;
+ const gestureSelectedKeysRef = useRef>(new Set());
+ const cancelGestureRef = useRef<
+ (options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean
+ >(() => false);
+ const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number) => {
+ if (lifecycleRef.current.phase === "active") cancelGestureRef.current();
+ lifecycleRef.current = {
+ kind,
+ phase: "active",
+ pointerId,
+ sessionEpoch: sessionEpochRef.current,
+ };
+ gestureSelectedKeysRef.current = new Set(usePlayerStore.getState().selectedElementIds);
+ }, []);
+ const setDraggedClip = useCallback(
+ (next: DraggedClipState | null) => {
+ if (!next) {
+ cancelGestureRef.current();
+ return;
+ }
+ beginGesture("drag", next.pointerId);
+ publishDraggedClip(next);
+ },
+ [beginGesture, publishDraggedClip],
+ );
+ const setResizingClip = useCallback(
+ (next: ResizingClipState | null) => {
+ if (!next) {
+ cancelGestureRef.current();
+ return;
+ }
+ beginGesture("resize", next.pointerId);
+ publishResizingClip(next);
+ },
+ [beginGesture, publishResizingClip],
+ );
const blockedClipRef = useRef(null);
const suppressClickRef = useRef(false);
- // Active multi-select group-resize session (restored from main 36413da7f): set
- // lazily on the first resize pointermove when the grabbed clip is part of a
- // capability-clean multi-selection (null ⇒ single-clip resize). Holds the
- // pre-gesture snapshot so the non-grabbed members (previewed through the store)
- // roll back on escape / cancel / failed persist.
+ // Active multi-select group-resize session, created lazily on first movement.
+ // It owns a projection only; canonical store timing changes at commit.
const groupResizeRef = useRef(null);
- // Restore the non-grabbed group members to their pre-gesture timing (the
- // grabbed clip renders from resizingClip state, so it is never written during
- // preview). `all` also restores the grabbed clip after a committed persist fails.
- const restoreGroupResizeMembers = useCallback(
- (session: TimelineGroupResizeSession, all = false) => {
- for (const m of session.members) {
- if (!all && m.key === session.grabbedKey) continue;
- updateElement(m.key, {
- start: m.start,
- duration: m.duration,
- playbackStart: m.playbackStart,
- });
- }
- },
- [updateElement],
- );
-
const onMoveElementRef = useRef(onMoveElement);
onMoveElementRef.current = onMoveElement;
const onMoveElementsRef = useRef(onMoveElements);
@@ -237,7 +274,7 @@ export function useTimelineClipDrag({
trackOrder: trackOrderRef.current,
rowHeights: rowGeometryRef?.current.rowHeights,
elements: elementsRef.current,
- selectedKeys: usePlayerStore.getState().selectedElementIds,
+ selectedKeys: gestureSelectedKeysRef.current,
buildSnapTargets,
audioTracks: dragAudioTracksRef.current,
});
@@ -256,18 +293,19 @@ export function useTimelineClipDrag({
buildSnapTargets,
});
const setResizeState = (v: ResizePreviewResult) =>
- setResizingClip((prev) => (prev ? { ...prev, started: true, ...v } : prev));
+ publishResizingClip(
+ resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null,
+ );
// Group resize: a capability-clean multi-selection resizes rigidly by one
// shared, member-clamped delta (legacy main 36413da7f). The grabbed clip
- // drives the raw delta and renders from resizingClip state; non-grabbed
- // members preview through the store (their store value stays pristine).
+ // drives the raw delta; every member renders from the coordinator projection.
const grabbedKey = resize.element.key ?? resize.element.id;
let session = groupResizeRef.current;
if (!session || session.grabbedKey !== grabbedKey || session.edge !== resize.edge) {
const members = buildTimelineGroupResizeMembers(
elementsRef.current,
- usePlayerStore.getState().selectedElementIds,
+ gestureSelectedKeysRef.current,
grabbedKey,
resize.edge,
);
@@ -287,9 +325,9 @@ export function useTimelineClipDrag({
setResizeState(next);
return;
}
- previewGroupResize(session, next, grabbedKey, updateElement, setResizeState);
+ previewGroupResize(session, next, setResizeState);
},
- [scrollRef, ppsRef, buildSnapTargets, updateElement],
+ [scrollRef, ppsRef, buildSnapTargets, publishResizingClip],
);
const applyResizePointerRef = useRef(applyResizePointer);
applyResizePointerRef.current = applyResizePointer;
@@ -300,9 +338,7 @@ export function useTimelineClipDrag({
cancelAnimationFrame(clipDragScrollRaf.current);
clipDragScrollRaf.current = 0;
}
- // Gesture teardown: drop the frozen-per-gesture perf caches so the next drag
- // rebuilds them against fresh store state (see snapTargetsCacheRef). Does NOT
- // touch groupResizeRef — commit reads it after this runs.
+ // Gesture teardown: drop frozen caches so the next gesture reads fresh state.
snapTargetsCacheRef.current.clear();
dragAudioTracksRef.current = null;
}, []);
@@ -317,16 +353,14 @@ export function useTimelineClipDrag({
if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
if (drag) {
- setDraggedClip((prev) =>
- prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev,
- );
+ publishDraggedClip(updateDraggedClipPreview(drag, pointer.clientX, pointer.clientY));
} else if (resize) {
// Re-run the trim preview so the edge keeps tracking while the content
// scrolls under the stationary pointer (scroll-compensated pointer x).
applyResizePointerRef.current(resize, pointer.clientX);
}
clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
- }, [scrollRef, updateDraggedClipPreview]);
+ }, [publishDraggedClip, scrollRef, updateDraggedClipPreview]);
const syncClipDragAutoScroll = useCallback(
(clientX: number, clientY: number) => {
@@ -354,35 +388,44 @@ export function useTimelineClipDrag({
const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
stopClipDragAutoScrollRef.current = stopClipDragAutoScroll;
- useMountEffect(() => {
- return mountTimelineClipDragGestureLifecycle({
- draggedClipRef,
- resizingClipRef,
- blockedClipRef,
- groupResizeRef,
- suppressClickRef,
- elementsRef,
- trackOrderRef,
- setDraggedClip,
- setResizingClip,
- setShowPopover,
- setRangeSelectionRef,
- applyResizePointerRef,
- syncClipDragAutoScrollRef,
- stopClipDragAutoScrollRef,
- updateDraggedClipPreviewRef,
- restoreGroupResizeMembers,
- updateElement,
- onMoveElementRef,
- onMoveElementsRef,
- onResizeElementRef,
- onResizeElementsRef,
- onBlockedEditAttemptRef,
- readZIndexRef,
+ useMountEffect(() =>
+ mountTimelineClipDragGestureLifecycle({
onStackingPatchesRef,
refreshAfterLaneMoveRef,
- });
- });
+ readZIndexRef,
+ onBlockedEditAttemptRef,
+ onResizeElementsRef,
+ onResizeElementRef,
+ onMoveElementsRef,
+ onMoveElementRef,
+ updateElement,
+ publishDraggedClip,
+ updateDraggedClipPreviewRef,
+ stopClipDragAutoScrollRef,
+ syncClipDragAutoScrollRef,
+ applyResizePointerRef,
+ setRangeSelectionRef,
+ setShowPopover,
+ setResizingClipState,
+ setDraggedClipState,
+ trackOrderRef,
+ elementsRef,
+ gestureSelectedKeysRef,
+ suppressClickRef,
+ groupResizeRef,
+ blockedClipRef,
+ resizingClipRef,
+ draggedClipRef,
+ scrollRef,
+ cancelGestureRef,
+ sessionEpochRef,
+ lifecycleRef,
+ }),
+ );
+
+ useEffect(() => {
+ cancelGestureRef.current();
+ }, [sessionEpoch]);
return {
draggedClip,
@@ -391,7 +434,6 @@ export function useTimelineClipDrag({
setResizingClip,
blockedClipRef,
suppressClickRef,
- syncClipDragAutoScroll,
stopClipDragAutoScroll,
};
}
diff --git a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts
index d0850bbe2c..e5bf746f5e 100644
--- a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts
+++ b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts
@@ -14,7 +14,7 @@ interface UseTimelineClipRenderWindowInput {
duration: number;
selectedElementId?: string;
draggedElementId?: string;
- resizingElementId?: string;
+ resizingElementIds?: readonly string[];
revealElementId?: string;
focusedEaseElementId?: string;
clipContextMenuElementId?: string;
@@ -36,7 +36,7 @@ export function useTimelineClipRenderWindow({
duration,
selectedElementId,
draggedElementId,
- resizingElementId,
+ resizingElementIds,
revealElementId,
focusedEaseElementId,
clipContextMenuElementId,
@@ -60,7 +60,7 @@ export function useTimelineClipRenderWindow({
[
selectedElementId,
draggedElementId,
- resizingElementId,
+ ...(resizingElementIds ?? []),
revealElementId,
focusedEaseElementId,
clipContextMenuElementId,
@@ -74,7 +74,7 @@ export function useTimelineClipRenderWindow({
focusedEaseElementId,
focusedElementId,
keyframeContextMenuElementId,
- resizingElementId,
+ resizingElementIds,
revealElementId,
selectedElementId,
],
diff --git a/packages/studio/src/player/components/useTimelineLaneMoveRefresh.ts b/packages/studio/src/player/components/useTimelineLaneMoveRefresh.ts
new file mode 100644
index 0000000000..3c9fc23154
--- /dev/null
+++ b/packages/studio/src/player/components/useTimelineLaneMoveRefresh.ts
@@ -0,0 +1,7 @@
+import { useCallback } from "react";
+import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
+
+export function useTimelineLaneMoveRefresh(): () => void {
+ const setRefreshKey = useStudioPlaybackContextOptional()?.setRefreshKey;
+ return useCallback(() => setRefreshKey?.((key) => key + 1), [setRefreshKey]);
+}
diff --git a/packages/studio/src/player/components/useTimelineRowVirtualization.ts b/packages/studio/src/player/components/useTimelineRowVirtualization.ts
index 97c39d4507..23297d435f 100644
--- a/packages/studio/src/player/components/useTimelineRowVirtualization.ts
+++ b/packages/studio/src/player/components/useTimelineRowVirtualization.ts
@@ -24,7 +24,7 @@ interface UseTimelineRowVirtualizationInput {
selectedElementId: string | null;
revealElementId: string | null;
draggedRowKey?: number;
- resizingRowKey?: number;
+ resizingElementIds?: readonly string[];
clipContextMenuRowKey?: number;
keyframeContextMenuRowKey?: number;
lastScrollLeftRef: RefObject;
@@ -55,7 +55,7 @@ export function useTimelineRowVirtualization({
selectedElementId,
revealElementId,
draggedRowKey,
- resizingRowKey,
+ resizingElementIds,
clipContextMenuRowKey,
keyframeContextMenuRowKey,
lastScrollLeftRef,
@@ -77,11 +77,18 @@ export function useTimelineRowVirtualization({
() => resolveTimelineFocusIdentity(elements, revealElementId),
[elements, revealElementId],
);
+ const resizingRowKeys = useMemo(
+ () =>
+ resizingElementIds
+ ?.map((elementId) => resolveTimelineFocusIdentity(elements, elementId)?.rowKey)
+ .filter((rowKey): rowKey is number => rowKey !== undefined) ?? [],
+ [elements, resizingElementIds],
+ );
const pinnedRowKeys = useMemo(
() =>
[
draggedRowKey,
- resizingRowKey,
+ ...resizingRowKeys,
revealIdentity?.rowKey,
clipContextMenuRowKey,
keyframeContextMenuRowKey,
@@ -90,7 +97,7 @@ export function useTimelineRowVirtualization({
clipContextMenuRowKey,
draggedRowKey,
keyframeContextMenuRowKey,
- resizingRowKey,
+ resizingRowKeys,
revealIdentity,
],
);