Skip to content

Commit 54123bb

Browse files
committed
perf(studio): stabilize virtualized clip gestures
1 parent 6826c9f commit 54123bb

16 files changed

Lines changed: 762 additions & 234 deletions

‎packages/studio/src/player/components/Timeline.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,6 @@ export const Timeline = memo(function Timeline({
246246
setResizingClip,
247247
blockedClipRef,
248248
suppressClickRef,
249-
syncClipDragAutoScroll,
250249
} = useTimelineClipDrag({
251250
scrollRef,
252251
ppsRef,
@@ -263,6 +262,7 @@ export const Timeline = memo(function Timeline({
263262
readZIndex: zSyncEnabled ? readClipZIndex : undefined,
264263
onStackingPatches: zSyncEnabled ? applyStackingPatches : undefined,
265264
refreshAfterLaneMove,
265+
sessionEpoch,
266266
});
267267

268268
const { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview } =
@@ -338,7 +338,9 @@ export const Timeline = memo(function Timeline({
338338
duration: displayDuration,
339339
selectedElementId: selectedElementId ?? undefined,
340340
draggedElementId: draggedClip?.element.key ?? draggedClip?.element.id,
341-
resizingElementId: resizingClip?.element.key ?? resizingClip?.element.id,
341+
resizingElementIds:
342+
resizingClip?.groupPreview?.map((change) => change.key) ??
343+
(resizingClip ? [resizingClip.element.key ?? resizingClip.element.id] : undefined),
342344
revealElementId: clipRevealRequest?.elementId,
343345
focusedEaseElementId: focusedEaseSegment?.elementId,
344346
clipContextMenuElementId: clipContextMenu?.element.key ?? clipContextMenu?.element.id,
@@ -537,7 +539,6 @@ export const Timeline = memo(function Timeline({
537539
setResizingClip={setResizingClip}
538540
setDraggedClip={setDraggedClip}
539541
setSelectedElementId={setSelectedElementId}
540-
syncClipDragAutoScroll={syncClipDragAutoScroll}
541542
shiftClickClipRef={shiftClickClipRef}
542543
getPreviewElement={getPreviewElement}
543544
getTrackStyle={getTrackStyle}

‎packages/studio/src/player/components/TimelineCanvas.tsx‎

Lines changed: 14 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { memo } from "react";
22
import { TimelineRuler } from "./TimelineRuler";
33
import { PlayheadIndicator } from "./PlayheadIndicator";
4-
import { getTimelineEditCapabilities, type TimelineRangeSelection } from "./timelineEditing";
5-
import { getRenderedTimelineElement } from "./timelineTheme";
4+
import type { TimelineRangeSelection } from "./timelineEditing";
65
import {
76
RULER_H,
87
CLIP_Y,
@@ -19,12 +18,10 @@ import type { ResizingClipState } from "./useTimelineClipDrag";
1918
import { type MultiDragPreviewInput } from "./timelineMultiDragPreview";
2019
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
2120
import type { Rect } from "../../utils/marqueeGeometry";
22-
import { TimelineClip } from "./TimelineClip";
2321
import { TimelineLanes } from "./TimelineLanes";
2422
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
25-
import { renderClipChildren } from "./timelineClipChildren";
2623
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
27-
import { isTimelineClipActive } from "./useTimelineActiveClips";
24+
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
2825

2926
interface TimelineCanvasProps extends TimelineLaneBaseProps {
3027
major: number[];
@@ -63,15 +60,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
6360
} = useTimelineEditContextOptional();
6461
const beatDragging = usePlayerStore((s) => s.beatDragging);
6562
const draggedElement = draggedClip?.element ?? null;
66-
const activeDraggedElement =
67-
draggedClip?.started === true && draggedElement
68-
? getRenderedTimelineElement({
69-
element: draggedElement,
70-
draggedElementId: draggedElement.key ?? draggedElement.id,
71-
previewStart: draggedClip.previewStart,
72-
previewTrack: draggedClip.previewTrack,
73-
})
74-
: null;
7563
// The drag ghost follows the cursor freely (both axes) — CapCut-style. The
7664
// "magnetic" affordance is a highlight on the destination lane (draggedRowIndex),
7765
// which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it.
@@ -92,22 +80,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
9280
selectedKeys: selectedElementIds,
9381
}
9482
: null;
95-
const activeDraggedPosition =
96-
draggedClip?.started === true && activeDraggedElement && scrollRef.current
97-
? {
98-
left:
99-
draggedClip.pointerClientX -
100-
scrollRef.current.getBoundingClientRect().left +
101-
scrollRef.current.scrollLeft -
102-
draggedClip.pointerOffsetX,
103-
top:
104-
draggedClip.pointerClientY -
105-
scrollRef.current.getBoundingClientRect().top +
106-
scrollRef.current.scrollTop -
107-
draggedClip.pointerOffsetY,
108-
}
109-
: null;
110-
11183
return (
11284
<div
11385
className="relative"
@@ -233,47 +205,18 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
233205
/>
234206
)}
235207

236-
{/* Drag ghost */}
237-
{activeDraggedElement && activeDraggedPosition && (
238-
<div
239-
className="absolute pointer-events-none"
240-
style={{
241-
top: activeDraggedPosition.top,
242-
left: activeDraggedPosition.left,
243-
width: Math.max(activeDraggedElement.duration * props.pps, 4),
244-
height: draggedClipHeight,
245-
zIndex: 40,
246-
}}
247-
>
248-
<TimelineClip
249-
el={{ ...activeDraggedElement, start: 0 }}
250-
pps={props.pps}
251-
clipY={0}
252-
isSelected={
253-
props.selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)
254-
}
255-
isHovered={false}
256-
isDragging={true}
257-
isActive={isTimelineClipActive(activeDraggedElement, props.currentTime)}
258-
hasCustomContent={!!props.renderClipContent}
259-
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
260-
theme={props.theme}
261-
isComposition={!!activeDraggedElement.compositionSrc}
262-
onHoverStart={() => {}}
263-
onHoverEnd={() => {}}
264-
onResizeStart={() => {}}
265-
onClick={() => {}}
266-
onDoubleClick={() => {}}
267-
>
268-
{renderClipChildren(
269-
activeDraggedElement,
270-
props.getTrackStyle(activeDraggedElement.tag),
271-
props.renderClipContent,
272-
props.renderClipOverlay,
273-
)}
274-
</TimelineClip>
275-
</div>
276-
)}
208+
<TimelineGestureOverlay
209+
drag={draggedClip}
210+
scrollRef={scrollRef}
211+
pixelsPerSecond={props.pps}
212+
rowHeight={draggedClipHeight}
213+
selectedElementId={props.selectedElementId}
214+
currentTime={props.currentTime}
215+
theme={props.theme}
216+
getTrackStyle={props.getTrackStyle}
217+
renderClipContent={props.renderClipContent}
218+
renderClipOverlay={props.renderClipOverlay}
219+
/>
277220

278221
{/* Marquee (rubber-band) multi-select rectangle — mirrors the canvas
279222
MarqueeOverlay look: semi-transparent accent fill + dashed border. */}

‎packages/studio/src/player/components/TimelineClip.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface TimelineClipProps {
1212
isSelected: boolean;
1313
isHovered: boolean;
1414
isDragging?: boolean;
15+
isGestureActor?: boolean;
1516
isActive?: boolean;
1617
hasCustomContent: boolean;
1718
capabilities: TimelineEditCapabilities;
@@ -36,6 +37,7 @@ export const TimelineClip = memo(function TimelineClip({
3637
isSelected,
3738
isHovered,
3839
isDragging = false,
40+
isGestureActor = false,
3941
isActive = false,
4042
hasCustomContent,
4143
capabilities,
@@ -85,13 +87,14 @@ export const TimelineClip = memo(function TimelineClip({
8587

8688
return (
8789
<div
88-
data-clip="true"
89-
data-el-id={el.key ?? el.id}
90+
data-clip={isGestureActor ? undefined : "true"}
91+
data-el-id={isGestureActor ? undefined : (el.key ?? el.id)}
9092
data-clip-start={el.start}
9193
data-clip-end={el.start + el.duration}
9294
data-clip-hidden={el.hidden ? "true" : undefined}
9395
data-active={isActive ? "" : undefined}
94-
tabIndex={-1}
96+
aria-hidden={isGestureActor ? "true" : undefined}
97+
tabIndex={isGestureActor ? undefined : -1}
9598
className={clipClassName}
9699
style={style}
97100
title={
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// @vitest-environment happy-dom
2+
3+
import React, { act } from "react";
4+
import { createRoot } from "react-dom/client";
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import { defaultTimelineTheme } from "./timelineTheme";
7+
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
8+
import type { DraggedClipState } from "./timelineClipDragTypes";
9+
import { getTrackStyle } from "./useTimelineTrackLayout";
10+
11+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
12+
13+
const drag: DraggedClipState = {
14+
element: { id: "hero", tag: "div", start: 2, duration: 3, track: 1 },
15+
originClientX: 100,
16+
originClientY: 100,
17+
originScrollLeft: 0,
18+
originScrollTop: 0,
19+
pointerClientX: 350,
20+
pointerClientY: 240,
21+
pointerOffsetX: 20,
22+
pointerOffsetY: 10,
23+
previewStart: 4,
24+
previewTrack: 2,
25+
insertRow: null,
26+
snapTime: null,
27+
snapType: null,
28+
started: true,
29+
};
30+
31+
afterEach(() => document.body.replaceChildren());
32+
33+
describe("TimelineGestureOverlay", () => {
34+
it("keeps the drag actor mounted without a source-row node", () => {
35+
const host = document.createElement("div");
36+
document.body.append(host);
37+
const root = createRoot(host);
38+
const scroll = {
39+
scrollLeft: 500,
40+
scrollTop: 300,
41+
getBoundingClientRect: () => ({ left: 50, top: 40 }),
42+
} as HTMLDivElement;
43+
act(() => {
44+
root.render(
45+
<TimelineGestureOverlay
46+
drag={drag}
47+
scrollRef={{ current: scroll }}
48+
pixelsPerSecond={100}
49+
rowHeight={42}
50+
selectedElementId="hero"
51+
currentTime={4}
52+
theme={defaultTimelineTheme}
53+
getTrackStyle={getTrackStyle}
54+
/>,
55+
);
56+
});
57+
const actor = host.querySelector<HTMLElement>('[data-timeline-gesture-actor="hero"]');
58+
expect(actor?.style.left).toBe("780px");
59+
expect(actor?.style.top).toBe("490px");
60+
expect(actor?.querySelector(".timeline-clip")).not.toBeNull();
61+
expect(actor?.querySelector("[data-el-id]")).toBeNull();
62+
expect(actor?.querySelector("[data-clip]")).toBeNull();
63+
expect(host.querySelector("[data-source-row]")).toBeNull();
64+
act(() => root.unmount());
65+
});
66+
67+
it("keeps the stable overlay host after terminal cleanup", () => {
68+
const host = document.createElement("div");
69+
document.body.append(host);
70+
const root = createRoot(host);
71+
act(() => {
72+
root.render(
73+
<TimelineGestureOverlay
74+
drag={null}
75+
scrollRef={{ current: null }}
76+
pixelsPerSecond={100}
77+
rowHeight={42}
78+
selectedElementId={null}
79+
currentTime={0}
80+
theme={defaultTimelineTheme}
81+
getTrackStyle={getTrackStyle}
82+
/>,
83+
);
84+
});
85+
expect(host.querySelector("[data-timeline-gesture-overlay]")).not.toBeNull();
86+
expect(host.querySelector("[data-timeline-gesture-actor]")).toBeNull();
87+
act(() => root.unmount());
88+
});
89+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { memo, type ReactNode } from "react";
2+
import type { TimelineElement } from "../store/playerStore";
3+
import type { TimelineTheme } from "./timelineTheme";
4+
import { getRenderedTimelineElement } from "./timelineTheme";
5+
import { TimelineClip } from "./TimelineClip";
6+
import { getTimelineEditCapabilities } from "./timelineEditing";
7+
import { renderClipChildren } from "./timelineClipChildren";
8+
import { getTimelineDragOverlayPosition } from "./timelineClipDragPreview";
9+
import type { DraggedClipState } from "./timelineClipDragTypes";
10+
import type { TrackVisualStyle } from "./timelineIcons";
11+
import { isTimelineClipActive } from "./useTimelineActiveClips";
12+
13+
interface TimelineGestureOverlayProps {
14+
drag: DraggedClipState | null;
15+
scrollRef: React.RefObject<HTMLDivElement | null>;
16+
pixelsPerSecond: number;
17+
rowHeight: number;
18+
selectedElementId: string | null;
19+
currentTime: number;
20+
theme: TimelineTheme;
21+
getTrackStyle: (tag: string) => TrackVisualStyle;
22+
renderClipContent?: (
23+
element: TimelineElement,
24+
style: { clip: string; label: string },
25+
) => ReactNode;
26+
renderClipOverlay?: (element: TimelineElement) => ReactNode;
27+
}
28+
29+
/** Stable canvas child that owns the live drag actor independently of source rows. */
30+
export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({
31+
drag,
32+
scrollRef,
33+
pixelsPerSecond,
34+
rowHeight,
35+
selectedElementId,
36+
currentTime,
37+
theme,
38+
getTrackStyle,
39+
renderClipContent,
40+
renderClipOverlay,
41+
}: TimelineGestureOverlayProps) {
42+
const element =
43+
drag?.started === true
44+
? getRenderedTimelineElement({
45+
element: drag.element,
46+
draggedElementId: drag.element.key ?? drag.element.id,
47+
previewStart: drag.previewStart,
48+
previewTrack: drag.previewTrack,
49+
})
50+
: null;
51+
const position = drag ? getTimelineDragOverlayPosition(drag, scrollRef.current) : null;
52+
return (
53+
<div data-timeline-gesture-overlay className="absolute inset-0 pointer-events-none">
54+
{element && position && (
55+
<div
56+
data-timeline-gesture-actor={element.key ?? element.id}
57+
className="absolute"
58+
style={{
59+
top: position.top,
60+
left: position.left,
61+
width: Math.max(element.duration * pixelsPerSecond, 4),
62+
height: rowHeight,
63+
zIndex: 40,
64+
}}
65+
>
66+
<TimelineClip
67+
el={{ ...element, start: 0 }}
68+
pps={pixelsPerSecond}
69+
clipY={0}
70+
isSelected={selectedElementId === (element.key ?? element.id)}
71+
isHovered={false}
72+
isDragging
73+
isGestureActor
74+
isActive={isTimelineClipActive(element, currentTime)}
75+
hasCustomContent={!!renderClipContent}
76+
capabilities={getTimelineEditCapabilities(element)}
77+
theme={theme}
78+
isComposition={!!element.compositionSrc}
79+
onHoverStart={() => {}}
80+
onHoverEnd={() => {}}
81+
onResizeStart={() => {}}
82+
onClick={() => {}}
83+
onDoubleClick={() => {}}
84+
>
85+
{renderClipChildren(
86+
element,
87+
getTrackStyle(element.tag),
88+
renderClipContent,
89+
renderClipOverlay,
90+
)}
91+
</TimelineClip>
92+
</div>
93+
)}
94+
</div>
95+
);
96+
});

‎packages/studio/src/player/components/TimelineLanes.test.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,6 @@ function renderLanes(options: RenderLanesOptions = {}): {
116116
setResizingClip={vi.fn()}
117117
setDraggedClip={vi.fn()}
118118
setSelectedElementId={vi.fn()}
119-
syncClipDragAutoScroll={vi.fn()}
120119
shiftClickClipRef={createRef()}
121120
getPreviewElement={(el) => el}
122121
getTrackStyle={getTrackStyle}

0 commit comments

Comments
 (0)