Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 11 additions & 18 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -236,7 +226,6 @@ export const Timeline = memo(function Timeline({
setResizingClip,
blockedClipRef,
suppressClickRef,
syncClipDragAutoScroll,
} = useTimelineClipDrag({
scrollRef,
ppsRef,
Expand All @@ -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 } =
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (flicker lens — non-blocking) resizingElementIds is a fresh array on every render whenever resizingClip is set: resizingClip?.groupPreview?.map((change) => change.key) ?? (resizingClip ? [getTimelineElementIdentity(resizingClip.element)] : undefined). The two consuming useMemos at useTimelineRowVirtualization.ts:80-86 and :88-101 list resizingElementIds (or resizingRowKeys) in their dep arrays, so the array's identity change on every parent re-render defeats both memos during an active resize. Idle path is safe (returns undefined, stable). Consider useMemo(() => resizingClip?.groupPreview?.map(...) ?? ..., [resizingClip]) to preserve reference stability across parent re-renders while a resize is in flight — otherwise the row-window recomputes on every parent tick. — Rames D Jusso

const { recordTimelineScroll } = useTimelinePerformanceTelemetry({
totalClipCount: expandedElements.length,
totalRowCount: displayLayout.displayTrackOrder.length,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -541,7 +535,6 @@ export const Timeline = memo(function Timeline({
setResizingClip={setResizingClip}
setDraggedClip={setDraggedClip}
setSelectedElementId={setSelectedElementId}
syncClipDragAutoScroll={syncClipDragAutoScroll}
shiftClickClipRef={shiftClickClipRef}
getPreviewElement={getPreviewElement}
getTrackStyle={getTrackStyle}
Expand Down
85 changes: 14 additions & 71 deletions packages/studio/src/player/components/TimelineCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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[];
Expand Down Expand Up @@ -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.
Expand All @@ -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"
Expand Down Expand Up @@ -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. */}
Expand Down
9 changes: 6 additions & 3 deletions packages/studio/src/player/components/TimelineClip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface TimelineClipProps {
isSelected: boolean;
isHovered: boolean;
isDragging?: boolean;
isGestureActor?: boolean;
isActive?: boolean;
hasCustomContent: boolean;
capabilities: TimelineEditCapabilities;
Expand All @@ -36,6 +37,7 @@ export const TimelineClip = memo(function TimelineClip({
isSelected,
isHovered,
isDragging = false,
isGestureActor = false,
isActive = false,
hasCustomContent,
capabilities,
Expand Down Expand Up @@ -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={
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
});
});
Loading
Loading