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
41 changes: 31 additions & 10 deletions packages/studio/src/player/components/TimelineClipFades.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ function render(
};
}

function pointer(type: string, clientX: number, pointerId = 1) {
const event = new MouseEvent(type, { bubbles: true, clientX, button: 0 });
function pointer(type: string, clientX: number, pointerId = 1, clientY = 0) {
const event = new MouseEvent(type, { bubbles: true, clientX, clientY, button: 0 });
Object.defineProperty(event, "pointerId", { value: pointerId });
return event;
}
Expand Down Expand Up @@ -134,14 +134,14 @@ describe("TimelineClipFades", () => {
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
if (!handle) throw new Error("expected a fade-out handle");
armCapture(handle);
// Handle at x=800 (2 s before the end). 300 px left → 5 s.
act(() => handle.dispatchEvent(pointer("pointerdown", 800)));
act(() => handle.dispatchEvent(pointer("pointermove", 500)));
// Pressed at x=1000 with a 2 s fade-out. 300 px left → 5 s.
act(() => handle.dispatchEvent(pointer("pointerdown", 1000)));
act(() => handle.dispatchEvent(pointer("pointermove", 700)));
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "5");
// Way past the start: clamps to duration − fadeIn = 9 s.
act(() => handle.dispatchEvent(pointer("pointermove", -500)));
act(() => handle.dispatchEvent(pointer("pointermove", 0)));
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "9");
act(() => handle.dispatchEvent(pointer("pointerup", -500)));
act(() => handle.dispatchEvent(pointer("pointerup", 0)));
expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-out", "9", "Fade out");
act(() => root.unmount());
});
Expand All @@ -151,9 +151,9 @@ describe("TimelineClipFades", () => {
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
if (!handle) throw new Error("expected a fade-in handle");
armCapture(handle);
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
act(() => handle.dispatchEvent(pointer("pointermove", -40)));
act(() => handle.dispatchEvent(pointer("pointerup", -40)));
act(() => handle.dispatchEvent(pointer("pointerdown", 200)));
act(() => handle.dispatchEvent(pointer("pointermove", 0)));
act(() => handle.dispatchEvent(pointer("pointerup", 0)));
expect(onSetElementAttributeQuiet).toHaveBeenCalledWith(clip, "data-fade-in", null, "Fade in");
act(() => root.unmount());
});
Expand All @@ -179,6 +179,27 @@ describe("TimelineClipFades", () => {
act(() => root.unmount());
});

it.each([
["above the window", 300, -40],
["on the window's right edge", window.innerWidth, 10],
])("restores the fade and saves nothing when released %s", (_, clientX, clientY) => {
const { host, root, onSetElementAttributeLive, onRevertElementAttributeLive, ...rest } =
render(clip);
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
if (!handle) throw new Error("expected a fade-in handle");
armCapture(handle);
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
act(() => handle.dispatchEvent(pointer("pointermove", 300)));
act(() => handle.dispatchEvent(pointer("pointerup", clientX, 1, clientY)));
expect(rest.onSetElementAttributeQuiet).not.toHaveBeenCalled();
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "1");
expect(onRevertElementAttributeLive).toHaveBeenCalledWith(clip, "data-fade-in");
expect(host.querySelector('[data-testid="clip-fade-in"]')?.getAttribute("points")).toBe(
"0,0 100,0 0,100",
);
act(() => root.unmount());
});

it("does not let a press on the dot start the clip's own move gesture", () => {
const outer = vi.fn();
const { host, root } = render(clip, { onClipPointerDown: outer });
Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/player/components/TimelineClipFades.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@hyperframes/core/audio-fade";
import type { TimelineElement } from "../store/playerStore";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { releasedOutsideWindow } from "./timelinePointerRelease";

type FadeEdge = "in" | "out";

Expand Down Expand Up @@ -136,7 +137,7 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
const finish = (e: PointerEvent<HTMLDivElement>, cancelled: boolean) => {
const g = endGesture(e);
if (!g) return;
if (cancelled || !g.moved) return revertGesture(g);
if (cancelled || !g.moved || releasedOutsideWindow(e)) return revertGesture(g);
setDraft({ edge: g.edge, seconds: g.last });
void onSetElementAttributeQuiet?.(
el,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,102 +11,158 @@ afterEach(() => {
usePlayerStore.getState().reset();
});

const element: TimelineElement = {
id: "clip-1",
tag: "div",
start: 0,
duration: 2,
track: 0,
};

function mountGesture(kind: "drag" | "resize") {
const drag: DraggedClipState = {
pointerId: 0,
element,
originClientX: 0,
originClientY: 0,
originScrollLeft: 0,
originScrollTop: 0,
pointerClientX: 0,
pointerClientY: 0,
pointerOffsetX: 0,
pointerOffsetY: 0,
previewStart: 1,
previewTrack: 0,
desiredTrack: 0,
insertRow: null,
snapTime: null,
snapType: null,
started: true,
};
const resize: ResizingClipState = {
pointerId: 0,
element,
edge: "end",
originClientX: 0,
previewStart: 0,
previewDuration: 3,
started: true,
};
const draggedClipRef = { current: kind === "drag" ? drag : null };
const resizingClipRef = { current: kind === "resize" ? resize : null };
const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
};
const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
...previous,
previewStart: 3,
}));
const onMoveElement = vi.fn();
const onResizeElement = vi.fn();
const updateElement = vi.fn();
const stopAutoScroll = vi.fn();
const cancelGestureRef = { current: () => false };
const dispose = mountTimelineClipDragGestureLifecycle({
lifecycleRef: { current: { kind, 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] },
setDraggedClipState: setDraggedClip,
setResizingClipState: () => {},
setShowPopover: () => {},
setRangeSelectionRef: { current: null },
applyResizePointerRef: { current: () => {} },
syncClipDragAutoScrollRef: { current: () => {} },
stopClipDragAutoScrollRef: { current: stopAutoScroll },
updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
publishDraggedClip: setDraggedClip,
updateElement,
onMoveElementRef: { current: onMoveElement },
onMoveElementsRef: { current: undefined },
onResizeElementRef: { current: onResizeElement },
onResizeElementsRef: { current: undefined },
onBlockedEditAttemptRef: { current: undefined },
readZIndexRef: { current: undefined },
onStackingPatchesRef: { current: undefined },
refreshAfterLaneMoveRef: { current: undefined },
});
return {
draggedClipRef,
resizingClipRef,
updateDraggedClipPreview,
onMoveElement,
onResizeElement,
updateElement,
stopAutoScroll,
cancelGestureRef,
dispose,
};
}

const release = (clientX: number, clientY: number) =>
window.dispatchEvent(new MouseEvent("pointerup", { clientX, clientY }));

describe("timeline clip drag gesture lifecycle", () => {
it("finishes a drag after virtualization unmounts its source row", () => {
const element: TimelineElement = {
id: "clip-1",
tag: "div",
start: 0,
duration: 2,
track: 0,
};
const drag: DraggedClipState = {
pointerId: 0,
element,
originClientX: 0,
originClientY: 0,
originScrollLeft: 0,
originScrollTop: 0,
pointerClientX: 0,
pointerClientY: 0,
pointerOffsetX: 0,
pointerOffsetY: 0,
previewStart: 1,
previewTrack: 0,
desiredTrack: 0,
insertRow: null,
snapTime: null,
snapType: null,
started: true,
};
const draggedClipRef = { current: drag as DraggedClipState | null };
const resizingClipRef = { current: null as ResizingClipState | null };
const setDraggedClip = (next: SetStateAction<DraggedClipState | null>) => {
draggedClipRef.current = typeof next === "function" ? next(draggedClipRef.current) : next;
};
const updateDraggedClipPreview = vi.fn((previous: DraggedClipState) => ({
...previous,
previewStart: 3,
}));
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] },
setDraggedClipState: setDraggedClip,
setResizingClipState: () => {},
setShowPopover: () => {},
setRangeSelectionRef: { current: null },
applyResizePointerRef: { current: () => {} },
syncClipDragAutoScrollRef: { current: () => {} },
stopClipDragAutoScrollRef: { current: stopAutoScroll },
updateDraggedClipPreviewRef: { current: updateDraggedClipPreview },
publishDraggedClip: setDraggedClip,
updateElement: vi.fn(),
onMoveElementRef: { current: onMoveElement },
onMoveElementsRef: { current: undefined },
onResizeElementRef: { current: undefined },
onResizeElementsRef: { current: undefined },
onBlockedEditAttemptRef: { current: undefined },
readZIndexRef: { current: undefined },
onStackingPatchesRef: { current: undefined },
refreshAfterLaneMoveRef: { current: undefined },
});

const g = mountGesture("drag");
const sourceRow = document.createElement("div");
sourceRow.dataset.timelineRow = "";
sourceRow.append(document.createElement("div"));
document.body.append(sourceRow);
sourceRow.remove();

window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
expect(updateDraggedClipPreview).toHaveBeenCalledTimes(1);
expect(draggedClipRef.current?.previewStart).toBe(3);
expect(g.updateDraggedClipPreview).toHaveBeenCalledTimes(1);
expect(g.draggedClipRef.current?.previewStart).toBe(3);

window.dispatchEvent(new MouseEvent("pointerup"));
expect(onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
expect(draggedClipRef.current).toBeNull();
expect(stopAutoScroll).toHaveBeenCalledTimes(1);
expect(g.onMoveElement).toHaveBeenCalledWith(element, { start: 3, track: 0 });
expect(g.draggedClipRef.current).toBeNull();
expect(g.stopAutoScroll).toHaveBeenCalledTimes(1);

g.dispose();
expect(g.cancelGestureRef.current()).toBe(false);
});

it.each([
["above the window", 20, -40],
["past the window's right edge", window.innerWidth + 40, 10],
["on the window's right edge", window.innerWidth, 10],
["on the window's bottom edge", 20, window.innerHeight],
])("cancels a drag released %s and leaves the clip where it was", (_, clientX, clientY) => {
const g = mountGesture("drag");
window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 10 }));
release(clientX, clientY);
expect(g.onMoveElement).not.toHaveBeenCalled();
expect(g.updateElement).not.toHaveBeenCalled();
expect(g.draggedClipRef.current).toBeNull();
// The cancel ended the gesture, so a later release has nothing to commit.
release(20, 10);
expect(g.onMoveElement).not.toHaveBeenCalled();
g.dispose();
});

it("cancels a trim released outside the window", () => {
const g = mountGesture("resize");
release(20, -40);
expect(g.onResizeElement).not.toHaveBeenCalled();
expect(g.updateElement).not.toHaveBeenCalled();
expect(g.resizingClipRef.current).toBeNull();
g.dispose();
});

dispose();
expect(cancelGestureRef.current()).toBe(false);
it("still commits a drag released on the window's last pixel", () => {
const g = mountGesture("drag");
release(window.innerWidth - 1, window.innerHeight - 1);
expect(g.onMoveElement).toHaveBeenCalledWith(element, { start: 1, track: 0 });
g.dispose();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
} from "./timelineClipDragTypes";
import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
import { commitTimelineGroupResize } from "./timelineGroupResizeCommit";
import { releasedOutsideWindow } from "./timelinePointerRelease";
import {
beginTimelineOptimisticGesture,
rollbackLatestTimelineOptimisticGesture,
Expand Down Expand Up @@ -349,6 +350,7 @@ export function mountTimelineClipDragGestureLifecycle({
};

const handleWindowPointerUp = (event: PointerEvent) => {
if (releasedOutsideWindow(event)) return handleWindowPointerCancel(event);
const claimed = claimActiveGesture(event);
if (claimed === "ignored") return;
if (claimed) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export function releasedOutsideWindow(point: Pick<MouseEvent, "clientX" | "clientY">): boolean {
const { clientX: x, clientY: y } = point;
return x < 0 || y < 0 || x >= window.innerWidth || y >= window.innerHeight;
}
Loading