perf(studio): stabilize virtualized clip gestures (#2704)

This commit is contained in:
Miguel Ángel
2026-08-04 04:58:15 +02:00
committed by GitHub
parent cbd8a77d16
commit f52398ceef
19 changed files with 851 additions and 257 deletions
@@ -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({
>
<div
ref={setScrollRef}
// Stable owner for gestures that must survive virtual row/clip unmounts.
data-timeline-scroll-viewport
data-timeline-auto-scroll-left-inset={labelMode ? LABEL_COL_W : 0}
tabIndex={-1}
@@ -541,7 +535,6 @@ export const Timeline = memo(function Timeline({
setResizingClip={setResizingClip}
setDraggedClip={setDraggedClip}
setSelectedElementId={setSelectedElementId}
syncClipDragAutoScroll={syncClipDragAutoScroll}
shiftClickClipRef={shiftClickClipRef}
getPreviewElement={getPreviewElement}
getTrackStyle={getTrackStyle}
@@ -1,8 +1,7 @@
import { memo } from "react";
import { TimelineRuler } from "./TimelineRuler";
import { PlayheadIndicator } from "./PlayheadIndicator";
import { getTimelineEditCapabilities, type TimelineRangeSelection } from "./timelineEditing";
import { getRenderedTimelineElement } from "./timelineTheme";
import type { TimelineRangeSelection } from "./timelineEditing";
import {
RULER_H,
CLIP_Y,
@@ -19,13 +18,11 @@ import type { ResizingClipState } from "./useTimelineClipDrag";
import { type MultiDragPreviewInput } from "./timelineMultiDragPreview";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import type { Rect } from "../../utils/marqueeGeometry";
import { TimelineClip } from "./TimelineClip";
import { TimelineLanes } from "./TimelineLanes";
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
import { renderClipChildren } from "./timelineClipChildren";
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
import { isTimelineClipActive } from "./useTimelineActiveClips";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
interface TimelineCanvasProps extends TimelineLaneBaseProps {
major: number[];
@@ -65,15 +62,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
const beatDragging = usePlayerStore((s) => 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 (
<div
className="relative"
@@ -235,47 +207,18 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
/>
)}
{/* Drag ghost */}
{activeDraggedElement && activeDraggedPosition && (
<div
className="absolute pointer-events-none"
style={{
top: activeDraggedPosition.top,
left: activeDraggedPosition.left,
width: Math.max(activeDraggedElement.duration * props.pps, 4),
height: draggedClipHeight,
zIndex: 40,
}}
>
<TimelineClip
el={{ ...activeDraggedElement, start: 0 }}
pps={props.pps}
clipY={0}
isSelected={
props.selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)
}
isHovered={false}
isDragging={true}
isActive={isTimelineClipActive(activeDraggedElement, props.currentTime)}
hasCustomContent={!!props.renderClipContent}
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
theme={props.theme}
isComposition={!!activeDraggedElement.compositionSrc}
onHoverStart={() => {}}
onHoverEnd={() => {}}
onResizeStart={() => {}}
onClick={() => {}}
onDoubleClick={() => {}}
>
{renderClipChildren(
activeDraggedElement,
props.getTrackStyle(activeDraggedElement.tag),
props.renderClipContent,
props.renderClipOverlay,
)}
</TimelineClip>
</div>
)}
<TimelineGestureOverlay
drag={draggedClip}
scrollRef={scrollRef}
pixelsPerSecond={props.pps}
rowHeight={draggedClipHeight}
selectedElementId={props.selectedElementId}
currentTime={props.currentTime}
theme={props.theme}
getTrackStyle={props.getTrackStyle}
renderClipContent={props.renderClipContent}
renderClipOverlay={props.renderClipOverlay}
/>
{/* Marquee (rubber-band) multi-select rectangle — mirrors the canvas
MarqueeOverlay look: semi-transparent accent fill + dashed border. */}
@@ -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 (
<div
data-clip="true"
data-el-id={el.key ?? el.id}
data-clip={isGestureActor ? undefined : "true"}
data-el-id={isGestureActor ? undefined : (el.key ?? el.id)}
data-clip-start={el.start}
data-clip-end={el.start + el.duration}
data-clip-hidden={el.hidden ? "true" : undefined}
data-active={isActive ? "" : undefined}
tabIndex={-1}
aria-hidden={isGestureActor ? "true" : undefined}
tabIndex={isGestureActor ? undefined : -1}
className={clipClassName}
style={style}
title={
@@ -0,0 +1,90 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { defaultTimelineTheme } from "./timelineTheme";
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
import type { DraggedClipState } from "./timelineClipDragTypes";
import { getTrackStyle } from "./useTimelineTrackLayout";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const drag: DraggedClipState = {
pointerId: 0,
element: { id: "hero", tag: "div", start: 2, duration: 3, track: 1 },
originClientX: 100,
originClientY: 100,
originScrollLeft: 0,
originScrollTop: 0,
pointerClientX: 350,
pointerClientY: 240,
pointerOffsetX: 20,
pointerOffsetY: 10,
previewStart: 4,
previewTrack: 2,
insertRow: null,
snapTime: null,
snapType: null,
started: true,
};
afterEach(() => 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(
<TimelineGestureOverlay
drag={drag}
scrollRef={{ current: scroll }}
pixelsPerSecond={100}
rowHeight={42}
selectedElementId="hero"
currentTime={4}
theme={defaultTimelineTheme}
getTrackStyle={getTrackStyle}
/>,
);
});
const actor = host.querySelector<HTMLElement>('[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(
<TimelineGestureOverlay
drag={null}
scrollRef={{ current: null }}
pixelsPerSecond={100}
rowHeight={42}
selectedElementId={null}
currentTime={0}
theme={defaultTimelineTheme}
getTrackStyle={getTrackStyle}
/>,
);
});
expect(host.querySelector("[data-timeline-gesture-overlay]")).not.toBeNull();
expect(host.querySelector("[data-timeline-gesture-actor]")).toBeNull();
act(() => root.unmount());
});
});
@@ -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<HTMLDivElement | null>;
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 (
<div data-timeline-gesture-overlay className="absolute inset-0 pointer-events-none">
{element && position && (
<div
data-timeline-gesture-actor={element.key ?? element.id}
className="absolute"
style={{
top: position.top,
left: position.left,
width: Math.max(element.duration * pixelsPerSecond, 4),
height: rowHeight,
zIndex: 40,
}}
>
<TimelineClip
el={{ ...element, start: 0 }}
pps={pixelsPerSecond}
clipY={0}
isSelected={selectedElementId === (element.key ?? element.id)}
isHovered={false}
isDragging
isGestureActor
isActive={isTimelineClipActive(element, currentTime)}
hasCustomContent={!!renderClipContent}
capabilities={getTimelineEditCapabilities(element)}
theme={theme}
isComposition={!!element.compositionSrc}
onHoverStart={() => {}}
onHoverEnd={() => {}}
onResizeStart={() => {}}
onClick={() => {}}
onDoubleClick={() => {}}
>
{renderClipChildren(
element,
getTrackStyle(element.tag),
renderClipContent,
renderClipOverlay,
)}
</TimelineClip>
</div>
)}
</div>
);
});
@@ -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}
@@ -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={
@@ -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);
});
});
@@ -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<typeof usePlayerStore.getState>["updateElement"];
interface TimelineClipDragGestureLifecycleInput {
lifecycleRef: RefObject<TimelineGestureLifecycle>;
sessionEpochRef: RefObject<number>;
cancelGestureRef: RefObject<
(options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean
>;
scrollRef: RefObject<HTMLDivElement | null>;
draggedClipRef: RefObject<DraggedClipState | null>;
resizingClipRef: RefObject<ResizingClipState | null>;
blockedClipRef: RefObject<BlockedClipState | null>;
groupResizeRef: RefObject<TimelineGroupResizeSession | null>;
suppressClickRef: RefObject<boolean>;
gestureSelectedKeysRef: RefObject<ReadonlySet<string>>;
elementsRef: RefObject<TimelineElement[]>;
trackOrderRef: RefObject<number[]>;
setDraggedClip: Dispatch<SetStateAction<DraggedClipState | null>>;
setResizingClip: Dispatch<SetStateAction<ResizingClipState | null>>;
setDraggedClipState: Dispatch<SetStateAction<DraggedClipState | null>>;
setResizingClipState: Dispatch<SetStateAction<ResizingClipState | null>>;
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<TimelineEditCallbacks["onMoveElement"]>;
onMoveElementsRef: RefObject<TimelineEditCallbacks["onMoveElements"]>;
@@ -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);
};
}
@@ -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<HTMLDivElement, "scrollLeft" | "scrollTop" | "getBoundingClientRect">;
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();
});
});
@@ -45,6 +45,19 @@ export interface DragPreviewContext {
audioTracks?: ReadonlySet<number>;
}
/** Content-space position for the stable viewport drag actor. */
export function getTimelineDragOverlayPosition(
drag: DraggedClipState,
scroll: Pick<HTMLDivElement, "scrollLeft" | "scrollTop" | "getBoundingClientRect"> | 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,
});
}
@@ -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;
@@ -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;
@@ -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,
@@ -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<BlockedClipState | null> | 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(<Harness />);
const root = mountReactHarness(<Harness sessionEpoch={epoch} />);
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() {
async dropPointer(pointerId = 0) {
await act(async () => {
window.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
dispatchPointer("pointerup", 0, pointerId);
});
},
async moveAndDropPointer(clientX: number, pointerId = 0) {
await act(async () => {
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(<Harness sessionEpoch={epoch} />));
},
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();
});
@@ -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<unknown> | 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<DraggedClipState | null>(null);
const [draggedClip, setDraggedClipState] = useState<DraggedClipState | null>(null);
const draggedClipRef = useRef<DraggedClipState | null>(null);
draggedClipRef.current = draggedClip;
const publishDraggedClip = useCallback((next: DraggedClipState | null) => {
draggedClipRef.current = next;
setDraggedClipState(next);
}, []);
const [resizingClip, setResizingClip] = useState<ResizingClipState | null>(null);
const [resizingClip, setResizingClipState] = useState<ResizingClipState | null>(null);
const resizingClipRef = useRef<ResizingClipState | null>(null);
resizingClipRef.current = resizingClip;
const publishResizingClip = useCallback((next: ResizingClipState | null) => {
resizingClipRef.current = next;
setResizingClipState(next);
}, []);
const lifecycleRef = useRef<TimelineGestureLifecycle>({
kind: null,
phase: "complete",
pointerId: null,
sessionEpoch,
});
const sessionEpochRef = useRef(sessionEpoch);
sessionEpochRef.current = sessionEpoch;
const gestureSelectedKeysRef = useRef<ReadonlySet<string>>(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<BlockedClipState | null>(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<TimelineGroupResizeSession | null>(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,
};
}
@@ -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,
],
@@ -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]);
}
@@ -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<number>;
@@ -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,
],
);