feat(studio): draw the empty lane below the last track as a drop target and tighten the top gap (#4741)

This commit is contained in:
Miguel Ángel
2026-09-30 03:14:08 +00:00
committed by GitHub
parent 2fc5a37fce
commit c3f7066bf3
8 changed files with 221 additions and 34 deletions
@@ -0,0 +1,126 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Timeline } from "./Timeline";
import { installTimelineMountEnv } from "./timelineMountTestEnv";
import { usePlayerStore } from "../store/playerStore";
import { CLIP_Y, TRACK_H, getTimelineRowTop } from "./timelineLayout";
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
import type { TimelineProps } from "./TimelineTypes";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
installTimelineMountEnv();
afterEach(() => {
document.body.innerHTML = "";
});
const GHOST_MID = getTimelineRowTop(3) + TRACK_H / 2;
const ROW1_MID = getTimelineRowTop(1) + TRACK_H / 2;
async function mountThreeTracks(props: TimelineProps = {}) {
usePlayerStore.setState({
duration: 10,
currentTime: 0,
timelineReady: true,
gsapAnimations: new Map(),
selectedElementId: null,
selectedElementIds: new Set(),
elements: [0, 1, 2].map((track) => ({
id: `c${track}`,
key: `c${track}`,
domId: `c${track}`,
tag: "div",
start: 0,
duration: 2,
track,
})),
});
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(<Timeline {...props} />));
const ghost = () => host.querySelector<HTMLElement>('[data-testid="timeline-ghost-lane"]');
return { host, root, ghost };
}
describe("Timeline ghost lane", () => {
it("draws one empty track below the last with a drop hint", async () => {
const { root, ghost } = await mountThreeTracks({ onAssetDrop: vi.fn() });
expect(ghost()?.textContent).toBe("Drop media here");
expect(ghost()?.getAttribute("aria-hidden")).toBe("true");
expect(ghost()?.style.top).toBe(`${getTimelineRowTop(3) + CLIP_Y}px`);
expect(ghost()?.dataset.active).toBeUndefined();
act(() => root.unmount());
});
it("drops the hint without a drop handler and the lane without a bottom pad", async () => {
const readOnly = await mountThreeTracks();
expect(readOnly.ghost()?.textContent).toBe("");
act(() => readOnly.root.unmount());
const unpadded = await mountThreeTracks({ trackPadding: { bottom: 0 } });
expect(unpadded.ghost()).toBeNull();
act(() => unpadded.root.unmount());
});
it("lights up under a file dragged into it and opens a new track on drop", async () => {
const onAssetDrop = vi.fn();
const { host, root, ghost } = await mountThreeTracks({ onAssetDrop });
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
const drag = (type: string, clientY: number) => {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, {
clientX: { value: 400 },
clientY: { value: clientY },
dataTransfer: {
value: {
types: [TIMELINE_ASSET_MIME],
files: [],
dropEffect: "none",
getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
},
},
});
act(() => void viewport.dispatchEvent(event));
};
drag("dragover", ROW1_MID);
expect(ghost()?.dataset.active).toBeUndefined();
drag("dragover", GHOST_MID);
expect(ghost()?.dataset.active).toBe("true");
drag("drop", GHOST_MID);
expect(onAssetDrop).toHaveBeenCalledWith("a.png", expect.objectContaining({ track: 3 }));
act(() => root.unmount());
});
it.each([0, 2])("lights up under clip c%i dragged into it", async (track) => {
const { host, root, ghost } = await mountThreeTracks({
onMoveElement: vi.fn(),
onResizeElement: vi.fn(),
});
const pointer = (target: EventTarget, type: string, clientY: number) =>
act(
() =>
void target.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
button: 0,
pointerId: 1,
clientX: 400,
clientY,
}),
),
);
const clipMid = getTimelineRowTop(track) + TRACK_H / 2;
pointer(host.querySelector(`[data-clip][data-el-id="c${track}"]`)!, "pointerdown", clipMid);
pointer(window, "pointermove", ROW1_MID);
expect(ghost()?.dataset.active).toBeUndefined();
pointer(window, "pointermove", GHOST_MID);
expect(ghost()?.dataset.active).toBe("true");
// Dragging the last track's clip adds a preview row; the lane must stay under the pointer.
expect(ghost()?.style.top).toBe(`${getTimelineRowTop(3) + CLIP_Y}px`);
act(() => root.unmount());
});
});
@@ -35,6 +35,7 @@ import {
getTimelineDisplayContentWidth,
getTimelineFitPps,
getTimelineLaneTop,
getTimelineRowTop,
createTimelineRowGeometry,
} from "./timelineLayout";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
@@ -1292,9 +1293,8 @@ describe("resolveTimelineAssetDrop", () => {
trackOrder: [0, 3, 7],
},
432, // rectLeft(100) + GUTTER(32) + 3s*100pps (contentOrigin = GUTTER)
// clientY: rectTop(200) + RULER_H(24) + TRACKS_TOP_PAD(72) + TRACK_H(48)
// + TRACK_H/2(24) = 368 → row 1 → track 3.
368,
// clientY: rectTop(200) + the middle of row 1 → track 3.
200 + getTimelineRowTop(1) + TRACK_H / 2,
),
).toEqual({ start: 3, track: 3 });
});
@@ -24,6 +24,13 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
const dropRowIndex = dropTrackIndex < 0 ? displayTrackOrder.length : dropTrackIndex;
const insertLineRow =
(draggedClip?.started ? draggedClip.insertRow : null) ?? props.dropPreview?.insertRow ?? null;
const ghostLaneActive =
(insertLineRow ?? -1) >= props.trackOrder.length ||
(props.dropPreview?.insertRow == null && props.dropPreview != null && dropTrackIndex < 0);
// A host bottom pad shorter than a track leaves no lane to draw.
const showGhostLane = props.rowGeometry.padding.bottom >= TRACK_H;
// Past the last committed track: a drag's new bottom-track preview row is the lane itself.
const ghostLaneRow = props.rowGeometry.getRowIndex(props.trackOrder.at(-1) ?? Number.NaN) + 1;
const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
// A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes
// occupy the rest of the row — see TimelineLanes' clipHeight), so the drag
@@ -61,14 +68,35 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
onRazorSplitAll={props.onRazorSplitAll}
/>
{/* Breathing room below the last track lane (1.5 tracks by default) — a real
{/* Breathing room below the last track lane (one track by default) — a real
scrollable surface, so a clip can be dragged into the void to create a
new bottom track comfortably (see TimelineRowGeometry.padding / canvasHeight). */}
new bottom track (see TimelineRowGeometry.padding / canvasHeight). */}
<div
aria-hidden="true"
style={{ height: props.rowsVirtualized ? 0 : props.rowGeometry.padding.bottom }}
/>
{showGhostLane && (
<div
aria-hidden="true"
data-testid="timeline-ghost-lane"
data-active={ghostLaneActive || undefined}
className={`pointer-events-none absolute flex items-center justify-center rounded-lg border border-dashed text-[11px] transition-colors duration-150 ${
ghostLaneActive
? "border-studio-accent/60 bg-studio-accent/6 text-studio-accent"
: "border-neutral-700/50 text-neutral-500"
}`}
style={{
top: props.rowGeometry.getRowTop(ghostLaneRow) + CLIP_Y,
left: props.contentOrigin,
width: props.trackContentWidth,
height: TRACK_H - CLIP_Y * 2,
}}
>
{props.acceptsMediaDrop && "Drop media here"}
</div>
)}
{/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
menu row would collapse; a quiet tint for every gap on the selected
clip's lane. Geometry mirrors the drop placeholder (row top + clip
@@ -51,6 +51,7 @@ export type TimelineCanvasState = Omit<
playheadRef: React.RefObject<HTMLDivElement | null>;
laneGapStrips: TimelineLaneGapStrips[];
dropPreview: TimelineDropPlacement | null;
acceptsMediaDrop: boolean;
setRangeSelection: (value: TimelineRangeSelection | null) => void;
setResizingClip: (value: ResizingClipState | null) => void;
setDraggedClip: (value: TimelineLaneBaseProps["draggedClip"]) => void;
@@ -12,6 +12,8 @@ import { configureTimelineTestViewport } from "./timelineTestViewport";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const ROW0_MID_Y = getTimelineRowTop(0) + TRACK_H / 2;
interface DropTransfer {
types: string[];
files: File[];
@@ -124,7 +126,7 @@ describe("useTimelineAssetDrop", () => {
const parent = document.createElement("div");
const child = document.createElement("div");
parent.append(child);
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
act(() =>
view.api.handleAssetDragLeave({
relatedTarget: child,
@@ -160,8 +162,8 @@ describe("useTimelineAssetDrop", () => {
const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
act(() => {
view.api.handleAssetDragOver(dragEvent(transfer, 80, 100));
view.api.handleAssetDrop(dragEvent(transfer, 80, 100));
view.api.handleAssetDragOver(dragEvent(transfer, 80, ROW0_MID_Y));
view.api.handleAssetDrop(dragEvent(transfer, 80, ROW0_MID_Y));
});
// pps=40, clientX=80 -> 2s, far from the 50s playhead: proves start tracks
@@ -175,12 +177,12 @@ describe("useTimelineAssetDrop", () => {
const view = renderHarness(onAssetDrop, 1);
const transfer = assetTransfer("not-json");
act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, 100)));
act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, ROW0_MID_Y)));
expect(view.api.isDragOver).toBe(true);
view.rerender(2);
expect(view.api.isDragOver).toBe(false);
act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, 100)));
act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, ROW0_MID_Y)));
expect(onAssetDrop).not.toHaveBeenCalled();
act(() => view.root.unmount());
});
@@ -202,8 +204,8 @@ describe("useTimelineAssetDrop", () => {
};
act(() => {
view.api.handleAssetDragOver(dragEvent(transfer, 400, 100));
view.api.handleAssetDrop(dragEvent(transfer, 400, 100));
view.api.handleAssetDragOver(dragEvent(transfer, 400, ROW0_MID_Y));
view.api.handleAssetDrop(dragEvent(transfer, 400, ROW0_MID_Y));
});
expect(onAssetDrop).not.toHaveBeenCalled();
@@ -214,7 +216,7 @@ describe("useTimelineAssetDrop", () => {
it("clears an escaped drag after StrictMode effect replay", () => {
const view = renderHarness(vi.fn(), 1, { strict: true });
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
expect(view.api.isDragOver).toBe(true);
act(() => window.dispatchEvent(new Event("dragend")));
@@ -227,11 +229,11 @@ describe("useTimelineAssetDrop", () => {
const view = renderHarness(onAssetDrop);
const payload = JSON.stringify({ path: "assets/a.png" });
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, 100)));
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, ROW0_MID_Y)));
const preview = view.api.dropPreview;
expect(preview).toEqual({ start: 10, track: 0 });
act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, 100)));
act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, ROW0_MID_Y)));
expect(onAssetDrop).toHaveBeenCalledExactlyOnceWith("assets/a.png", preview);
expect(view.api.dropPreview).toBeNull();
act(() => view.root.unmount());
@@ -239,8 +241,8 @@ describe("useTimelineAssetDrop", () => {
it("moves the preview with the pointer and drops it when the drag leaves", () => {
const view = renderHarness(vi.fn());
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, 100)));
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, ROW0_MID_Y)));
expect(view.api.dropPreview?.start).toBe(20);
act(() =>
@@ -19,6 +19,7 @@ import {
getTimelineBeatEntries,
} from "./timelineLayout";
import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
import { resolveInsertRow } from "./timelineCollision";
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
describe("horizontal timeline window", () => {
@@ -124,27 +125,27 @@ describe("variable timeline row geometry", () => {
describe("collapsed timeline row geometry characterization", () => {
it.each([
[0, 74],
[1, 122],
[4, 266],
[0, 32],
[1, 80],
[4, 224],
])("keeps row %i at content y=%i", (row, expectedTop) => {
expect(getTimelineRowTop(row)).toBe(expectedTop);
});
it.each([
[74, 0],
[86, 0.25],
[146, 1.5],
[290, 4.5],
[32, 0],
[44, 0.25],
[104, 1.5],
[248, 4.5],
])("maps content y=%i to fractional row %f", (contentY, expectedRow) => {
expect(getTimelineRowFromY(contentY)).toBe(expectedRow);
});
it.each([
[0, 146],
[1, 194],
[3, 290],
[5, 386],
[0, 80],
[1, 128],
[3, 224],
[5, 320],
])("keeps the %i-track canvas height at %i", (trackCount, expectedHeight) => {
expect(getTimelineCanvasHeight(baseRows(trackCount))).toBe(expectedHeight);
});
@@ -294,6 +295,34 @@ describe("host track padding", () => {
});
});
describe("default track pads", () => {
const rows = [TRACK_H, TRACK_H, TRACK_H];
const lastLaneBottom = RULER_H + 8 + 3 * TRACK_H;
it("sits the first row 8px under the ruler and leaves one track below the last", () => {
expect(getTimelineRowTop(0)).toBe(RULER_H + 8);
expect(getTimelineCanvasHeight(rows)).toBe(lastLaneBottom + TRACK_H);
});
it("opens a new track from anywhere in the bottom lane", () => {
const drop = {
rectLeft: 0,
rectTop: 0,
scrollLeft: 0,
scrollTop: 0,
contentOrigin: GUTTER,
pixelsPerSecond: 100,
rowHeights: rows,
trackOrder: [0, 1, 2],
};
for (const y of [lastLaneBottom, getTimelineCanvasHeight(rows) - 1]) {
expect(resolveTimelineAssetDrop(drop, GUTTER, y).track).toBe(3);
expect(resolveInsertRow(getTimelineRowFromY(y, rows), rows.length)).toBe(3);
}
expect(resolveTimelineAssetDrop(drop, GUTTER, lastLaneBottom - 1).track).toBe(2);
});
});
describe("getTimelineScrubTime", () => {
const at = (clientX: number, duration = 10) =>
getTimelineScrubTime({
@@ -58,13 +58,13 @@ export function getTimelineLaneTop(laneIndex: number): number {
* Default breathing room INSIDE the scroll area (CapCut-style). A host overrides
* it per Timeline; rows always read TimelineRowGeometry.padding, never these.
*
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
* (~half a track height) so the first clip isn't jammed under the ruler.
* - TRACKS_BOTTOM_PAD: empty space below the last track (~1.5 track heights),
* enough to comfortably drag a clip into the void to create a new bottom lane.
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track,
* just enough that the first clip isn't jammed under the ruler.
* - TRACKS_BOTTOM_PAD: one empty track below the last, drawn as a ghost lane;
* dropping a clip or file there creates a new bottom track.
*/
export const TRACKS_TOP_PAD = 50;
export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5);
export const TRACKS_TOP_PAD = 8;
export const TRACKS_BOTTOM_PAD = TRACK_H;
export interface TimelineTrackPadding {
top?: number;
bottom?: number;
@@ -434,6 +434,7 @@ export function useTimelineProviderState({
marqueeRect,
laneGapStrips,
dropPreview: assetDrop.dropPreview,
acceptsMediaDrop: !!(onFileDrop || onAssetDrop),
theme,
showAudioEffects,
displayTrackOrder: displayLayout.displayTrackOrder,