fix(studio): scope timeline ease focus lifecycle (#2710)

This commit is contained in:
Miguel Ángel
2026-08-03 23:06:46 -07:00
committed by GitHub
parent 8c6cf90ff9
commit 0a70ba6717
28 changed files with 767 additions and 263 deletions
@@ -51,8 +51,7 @@ function mountBeatStrip(renderTimeRange?: { start: number; end: number }) {
scrollWidth: { configurable: true, value: 2_000 },
scrollHeight: { configurable: true, value: 2_000 },
});
viewport.getBoundingClientRect = () =>
({ left: 0, right: 1_000, top: 0, bottom: 500, width: 1_000, height: 500 }) as DOMRect;
viewport.getBoundingClientRect = () => new DOMRect(0, 0, 1_000, 500);
Object.assign(viewport, {
setPointerCapture: vi.fn(),
hasPointerCapture: vi.fn(() => true),
@@ -275,6 +274,74 @@ describe("BeatStrip gesture ownership", () => {
expect(commitBeatEditsSpy).toHaveBeenCalledOnce();
});
it("keeps the dragged beat pinned by time when another beat is inserted before it", () => {
const { root } = mountBeatStrip();
startBeatDrag();
act(() => {
window.dispatchEvent(
pointerEvent("pointermove", {
bubbles: true,
clientX: 140,
clientY: 100,
pointerId: 1,
}),
);
usePlayerStore.setState({
beatAnalysis: {
...BEAT_ANALYSIS,
beatTimes: [0.5, 1, 3],
beatStrengths: [0.3, 0.5, 0.8],
},
});
root.render(<BeatStrip beatTimes={[0.5, 1, 3]} beatStrengths={[0.3, 0.5, 0.8]} pps={100} />);
});
const lefts = Array.from(
document.querySelectorAll<HTMLDivElement>('[title="Drag to move · double-click to delete"]'),
(beat) => beat.style.left,
);
expect(lefts).toContain("134px");
releaseBeatDrag(140);
expectCommittedBeatAt(1.4);
});
it("keeps the first pointer in control when a second touch starts", () => {
mountBeatStrip();
startBeatDrag();
const beats = document.querySelectorAll<HTMLDivElement>(
'[title="Drag to move · double-click to delete"]',
);
act(() => {
beats[1]?.dispatchEvent(
pointerEvent("pointerdown", {
bubbles: true,
button: 0,
clientX: 300,
clientY: 100,
pointerId: 2,
}),
);
});
releaseBeatDrag(140, 1);
expectCommittedBeatAt(1.4);
});
it("lets Escape bubble before cancelling the active drag", () => {
mountBeatStrip();
startBeatDrag();
const sawEscape = vi.fn();
document.addEventListener("keydown", sawEscape, { once: true });
act(() => {
firstBeat().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
expect(sawEscape).toHaveBeenCalledOnce();
expectCancelledBeatDrag();
});
it("does not autoscroll or mutate below the drag threshold", () => {
const requestAnimationFrame = vi.fn(() => 1);
vi.stubGlobal("requestAnimationFrame", requestAnimationFrame);
@@ -18,7 +18,6 @@ const BEAT_HIT_W = 12; // grab width per beat (px)
interface BeatDragActor {
readonly pointerId: number;
readonly index: number;
readonly startX: number;
readonly clientX: number;
readonly clientY: number;
@@ -68,7 +67,7 @@ function releaseBeatDragResources(actor: BeatDragActor): void {
window.removeEventListener("pointerup", handleBeatDragPointerUp);
window.removeEventListener("pointercancel", handleBeatDragPointerCancel);
window.removeEventListener("lostpointercapture", handleBeatDragPointerCancel);
window.removeEventListener("keydown", handleBeatDragKeyDown, true);
window.removeEventListener("keydown", handleBeatDragKeyDown);
window.removeEventListener("blur", cancelBeatDrag);
try {
if (actor.scroll.hasPointerCapture?.(actor.pointerId)) {
@@ -186,7 +185,6 @@ function handleBeatDragPointerCancel(event: PointerEvent): void {
function handleBeatDragKeyDown(event: KeyboardEvent): void {
if (event.key !== "Escape" || !beatDragActor) return;
event.preventDefault();
event.stopPropagation();
cancelBeatDrag();
}
@@ -200,7 +198,6 @@ function resolveBeatDragViewport(
function createBeatDragActor(
event: React.PointerEvent<HTMLDivElement>,
index: number,
originalTime: number,
pixelsPerSecond: number,
scroll: HTMLElement,
@@ -210,7 +207,6 @@ function createBeatDragActor(
if (!musicElement?.src) return null;
const actor: BeatDragActor = {
pointerId: event.pointerId,
index,
startX: event.clientX,
clientX: event.clientX,
clientY: event.clientY,
@@ -235,15 +231,31 @@ function activateBeatDrag(actor: BeatDragActor): void {
window.addEventListener("pointerup", handleBeatDragPointerUp);
window.addEventListener("pointercancel", handleBeatDragPointerCancel);
window.addEventListener("lostpointercapture", handleBeatDragPointerCancel);
window.addEventListener("keydown", handleBeatDragKeyDown, true);
window.addEventListener("keydown", handleBeatDragKeyDown);
window.addEventListener("blur", cancelBeatDrag);
unsubscribeBeatDragSession = usePlayerStore.subscribe((state) => {
unsubscribeBeatDragSession = usePlayerStore.subscribe((state, previous) => {
// Unlike gestures that can validate only at pointerup, beat drag must stop
// immediately when its music source disappears or changes mid-stream. This
// subscription owns that interruption; the identity guard keeps requestSeek's
// per-frame store writes allocation-free unless a source input changed.
if (
state.timelineSessionEpoch === previous.timelineSessionEpoch &&
state.timelineProjectId === previous.timelineProjectId &&
state.elements === previous.elements &&
state.beatAnalysis === previous.beatAnalysis &&
state.beatEdits === previous.beatEdits
) {
return;
}
if (!isBeatDragSourceCurrent(actor, state)) cancelBeatDrag();
});
beatDragViewportObserver = new MutationObserver(() => {
if (!actor.scroll.isConnected) cancelBeatDrag();
});
beatDragViewportObserver.observe(document, { childList: true, subtree: true });
const viewportParent = actor.scroll.parentNode;
if (viewportParent) {
beatDragViewportObserver = new MutationObserver(() => {
if (!actor.scroll.isConnected) cancelBeatDrag();
});
beatDragViewportObserver.observe(viewportParent, { childList: true });
}
try {
actor.scroll.setPointerCapture?.(actor.pointerId);
} catch {
@@ -256,14 +268,15 @@ function activateBeatDrag(actor: BeatDragActor): void {
function beginBeatDrag(
event: React.PointerEvent<HTMLDivElement>,
index: number,
originalTime: number,
pixelsPerSecond: number,
): void {
// One pointer owns the actor until a terminal event claims it. A second touch
// must not silently discard the first gesture and make its release a no-op.
if (beatDragActor) return;
const scroll = resolveBeatDragViewport(event, pixelsPerSecond);
if (!scroll) return;
cancelBeatDrag();
const actor = createBeatDragActor(event, index, originalTime, pixelsPerSecond, scroll);
const actor = createBeatDragActor(event, originalTime, pixelsPerSecond, scroll);
if (actor) activateBeatDrag(actor);
}
@@ -365,11 +378,14 @@ export const BeatStrip = memo(function BeatStrip({
const projectId = usePlayerStore((state) => state.timelineProjectId);
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
const activeBeatIndex = activeActor
? beatTimes.findIndex((time) => Math.abs(time - activeActor.originalTime) < 1e-3)
: -1;
const drag =
activeActor &&
activeActor.sessionEpoch === sessionEpoch &&
activeActor.projectId === projectId &&
Math.abs((beatTimes[activeActor.index] ?? Number.NaN) - activeActor.originalTime) < 1e-3
activeBeatIndex >= 0
? activeActor
: null;
const cy = BEAT_BAND_H / 2;
@@ -377,7 +393,7 @@ export const BeatStrip = memo(function BeatStrip({
beatTimes,
beatStrengths,
renderTimeRange,
drag ? new Set([drag.index]) : undefined,
drag ? new Set([activeBeatIndex]) : undefined,
);
return (
@@ -390,7 +406,7 @@ export const BeatStrip = memo(function BeatStrip({
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
const r = 1.5 + strength * 2.5;
const opacity = 0.25 + strength * 0.75;
const dxPx = drag?.index === i ? drag.dx : 0;
const dxPx = drag && activeBeatIndex === i ? drag.dx : 0;
const x = t * pps + dxPx;
return (
<div
@@ -412,7 +428,7 @@ export const BeatStrip = memo(function BeatStrip({
// selection (which otherwise "selects" the whole panel mid-drag).
e.preventDefault();
e.stopPropagation();
beginBeatDrag(e, i, t, pps);
beginBeatDrag(e, t, pps);
}}
onDoubleClick={(e) => {
e.stopPropagation();
@@ -1,4 +1,3 @@
import { memo } from "react";
import { createPortal } from "react-dom";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
import type { TimelineElement } from "../store/playerStore";
@@ -30,7 +29,7 @@ interface KeyframeDiamondContextMenuProps {
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
}
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
export function KeyframeDiamondContextMenu({
state,
onClose,
onDelete,
@@ -104,4 +103,4 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
</div>,
document.body,
);
});
}
@@ -305,7 +305,6 @@ describe("Timeline provider boundary", () => {
act(() => root.unmount());
});
// fallow-ignore-next-line code-duplication
it("renders the gutter without legacy icons or hue dots", () => {
const { host, root } = renderBasicTimeline();
@@ -404,8 +403,8 @@ describe("Timeline provider boundary", () => {
);
});
const viewport = host.querySelector('[aria-label="Timeline"]')?.firstElementChild;
expect(viewport).toBeInstanceOf(HTMLElement);
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
expect(viewport).not.toBeNull();
act(() => {
viewport?.dispatchEvent(
new MouseEvent("pointerdown", {
@@ -1,6 +1,21 @@
import { describe, expect, it } from "vitest";
// @vitest-environment happy-dom
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { resolveTimelineContextElement } from "./TimelineOverlays";
import { usePlayerStore } from "../store/playerStore";
import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
import { TimelineOverlays, resolveTimelineContextElement } from "./TimelineOverlays";
import { defaultTimelineTheme } from "./timelineTheme";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) act(() => root.unmount());
document.body.innerHTML = "";
usePlayerStore.setState({ selectedElementId: null, timelineSessionEpoch: 0 });
});
const captured: TimelineElement = {
id: "child",
@@ -52,3 +67,95 @@ describe("resolveTimelineContextElement", () => {
expect(resolveTimelineContextElement({ ...input, elements: [] })).toBeNull();
});
});
function renderKeyframeOverlay(options: {
capturedElement: TimelineElement;
currentElement: TimelineElement;
setKfContextMenu?: ReturnType<typeof vi.fn>;
onDeleteAllKeyframes?: ReturnType<typeof vi.fn>;
}) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const elements = [options.currentElement];
const setKfContextMenu = options.setKfContextMenu ?? vi.fn();
const onDeleteAllKeyframes = options.onDeleteAllKeyframes ?? vi.fn();
const menu: KeyframeDiamondContextMenuState = {
x: 10,
y: 10,
sessionEpoch: 2,
element: options.capturedElement,
elementId: options.capturedElement.key ?? options.capturedElement.id,
percentage: 50,
animationId: "child-position",
};
act(() => {
usePlayerStore.setState({
selectedElementId: options.capturedElement.key ?? options.capturedElement.id,
timelineSessionEpoch: 2,
});
root.render(
createElement(TimelineOverlays, {
elements,
elementsRef: { current: elements },
theme: defaultTimelineTheme,
showShortcutHint: false,
showPopover: false,
rangeSelection: null,
setShowPopover: vi.fn(),
setRangeSelection: vi.fn(),
kfContextMenu: menu,
setKfContextMenu,
onDeleteKeyframe: vi.fn(),
onDeleteAllKeyframes,
onMoveKeyframeToPlayhead: vi.fn(),
clipContextMenu: null,
setClipContextMenu: vi.fn(),
currentTime: 0,
onSplitElement: vi.fn(),
pinZoomBeforeEdit: vi.fn(),
onDeleteElement: vi.fn(),
gapContextMenu: null,
onDismissGapContextMenu: vi.fn(),
onCloseTrackGap: vi.fn(),
onCloseAllTrackGaps: vi.fn(),
onHoverGapAction: vi.fn(),
}),
);
});
return { setKfContextMenu, onDeleteAllKeyframes };
}
describe("TimelineOverlays context lifecycle", () => {
it("dismisses a keyframe menu when its selected target becomes stale", () => {
const setKfContextMenu = vi.fn();
renderKeyframeOverlay({
capturedElement: captured,
currentElement: captured,
setKfContextMenu,
});
act(() => usePlayerStore.setState({ selectedElementId: "other" }));
expect(setKfContextMenu).toHaveBeenCalledExactlyOnceWith(null);
});
it("dispatches a menu action with the current model element", () => {
const current = { ...captured, start: 4, track: 7 };
const onDeleteAllKeyframes = vi.fn();
renderKeyframeOverlay({
capturedElement: captured,
currentElement: current,
onDeleteAllKeyframes,
});
const button = Array.from(document.body.querySelectorAll("button")).find(
(candidate) => candidate.textContent === "Delete All Keyframes",
);
act(() => button?.click());
expect(onDeleteAllKeyframes).toHaveBeenCalledExactlyOnceWith(current, "child-position");
});
});
@@ -74,7 +74,6 @@ export interface TimelineEditCallbacks {
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
/** Drag-to-retime: `keyframe` identifies the dragged keyframe (its percentage
* is clip-relative), `toClipPercentage` is the neighbour-clamped drop. */
@@ -388,7 +388,6 @@ export function mountTimelineClipDragGestureLifecycle({
});
if (!decision.cancel) return;
event.preventDefault();
event.stopPropagation();
blockedClipRef.current = null;
cancelGesture({ suppressClick: decision.suppressClick });
};
@@ -397,7 +396,7 @@ export function mountTimelineClipDragGestureLifecycle({
window.addEventListener("pointerup", handleWindowPointerUp);
window.addEventListener("pointercancel", handleWindowPointerCancel);
window.addEventListener("lostpointercapture", handleLostPointerCapture);
window.addEventListener("keydown", handleWindowKeyDown, true);
window.addEventListener("keydown", handleWindowKeyDown);
return () => {
cancelGesture({ updateReact: false });
cancelGestureRef.current = () => false;
@@ -405,6 +404,6 @@ export function mountTimelineClipDragGestureLifecycle({
window.removeEventListener("pointerup", handleWindowPointerUp);
window.removeEventListener("pointercancel", handleWindowPointerCancel);
window.removeEventListener("lostpointercapture", handleLostPointerCapture);
window.removeEventListener("keydown", handleWindowKeyDown, true);
window.removeEventListener("keydown", handleWindowKeyDown);
};
}
@@ -169,7 +169,7 @@ function renderResizeHarness(
},
pressEscape() {
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
scroll.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
},
unmount() {
@@ -430,8 +430,11 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
it("Escape discards the projection and persists nothing", () => {
const { h } = startGroupResize();
expect(h.getResizeProjection()).toHaveLength(2);
const sawEscape = vi.fn();
document.addEventListener("keydown", sawEscape, { once: true });
h.pressEscape();
expect(sawEscape).toHaveBeenCalledOnce();
expect(h.getResizeProjection()).toHaveLength(0);
expect(h.storeById("b").duration).toBe(3);
expect(h.onResizeElement).not.toHaveBeenCalled();
@@ -91,7 +91,7 @@ describe("useTimelineKeyframeHandlers", () => {
const { root, handlers } = mountHandlers();
act(() => handlers.onSelectSegment?.(ELEMENT.id, COLLIDING_TARGET));
expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
expect(usePlayerStore.getState().focusedEaseSegment).toMatchObject({
animationId: "position-tween",
collidingAnimationTargets: [
{ animationId: "position-tween", tweenPercentage: 100 },
@@ -112,7 +112,7 @@ describe("useTimelineKeyframeHandlers", () => {
// Selecting a segment must NOT move the playhead.
act(() => handlers.onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
expect(onSeek).not.toHaveBeenCalled();
expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
expect(usePlayerStore.getState().focusedEaseSegment).toMatchObject({
animationId: "position-tween",
tweenPercentage: 100,
elementId: ELEMENT.id,