mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user