perf(studio): stabilize virtualized beat gestures (#2709)

This commit is contained in:
Miguel Ángel
2026-08-03 21:35:40 -07:00
committed by GitHub
parent 423c5ffadb
commit 677fd31e9a
2 changed files with 584 additions and 35 deletions
@@ -0,0 +1,307 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import { BeatStrip } from "./BeatStrip";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const roots: Root[] = [];
const requestSeek = vi.fn();
const commitBeatEdits = usePlayerStore.getState().commitBeatEdits;
const commitBeatEditsSpy = vi.fn((...args: Parameters<typeof commitBeatEdits>) =>
commitBeatEdits(...args),
);
const MUSIC_ELEMENT: TimelineElement = {
id: "background-music",
label: "Music",
tag: "audio",
src: "music.mp3",
start: 0,
duration: 10,
track: 0,
timelineRole: "music",
};
const BEAT_ANALYSIS = {
beatTimes: [1, 3],
beatStrengths: [0.5, 0.8],
bpm: 120,
bpmConfidence: "high" as const,
channelData: null,
sampleRate: 48_000,
peak: 1,
};
function pointerEvent(type: string, init: PointerEventInit): Event {
if (typeof PointerEvent === "function") return new PointerEvent(type, init);
const event = new MouseEvent(type, init);
Object.defineProperty(event, "pointerId", { value: init.pointerId ?? 0 });
return event;
}
function mountBeatStrip(renderTimeRange?: { start: number; end: number }) {
const viewport = document.createElement("div");
viewport.dataset.timelineScrollViewport = "";
Object.defineProperties(viewport, {
clientWidth: { configurable: true, value: 500 },
clientHeight: { configurable: true, value: 300 },
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;
Object.assign(viewport, {
setPointerCapture: vi.fn(),
hasPointerCapture: vi.fn(() => true),
releasePointerCapture: vi.fn(),
});
document.body.appendChild(viewport);
const root = createRoot(viewport);
roots.push(root);
act(() => {
root.render(
<BeatStrip
beatTimes={[1, 3]}
beatStrengths={[0.5, 0.8]}
pps={100}
renderTimeRange={renderTimeRange}
/>,
);
});
return { root, viewport };
}
function firstBeat(): HTMLDivElement {
const beat = document.querySelector<HTMLDivElement>(
'[title="Drag to move · double-click to delete"]',
);
if (!beat) throw new Error("Expected a beat handle");
return beat;
}
function startBeatDrag(clientX = 100, pointerId = 1): void {
act(() => {
firstBeat().dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX, clientY: 100, pointerId }),
);
});
expect(usePlayerStore.getState().beatDragging).toBe(true);
}
function releaseBeatDrag(clientX = 140, pointerId = 1): void {
act(() => {
window.dispatchEvent(
pointerEvent("pointerup", { bubbles: true, clientX, clientY: 100, pointerId }),
);
});
}
function expectCancelledBeatDrag(): void {
expect(commitBeatEditsSpy).not.toHaveBeenCalled();
expect(usePlayerStore.getState().beatDragging).toBe(false);
}
function expectCommittedBeatAt(time: number): void {
expect(commitBeatEditsSpy).toHaveBeenCalledExactlyOnceWith(expect.anything(), "move beat");
expect(usePlayerStore.getState().beatEdits?.added[0]?.time).toBe(time);
}
beforeEach(() => {
commitBeatEditsSpy.mockClear();
requestSeek.mockReset();
usePlayerStore.setState({
timelineSessionEpoch: 1,
timelineProjectId: "project-a",
beatDragging: false,
requestSeek,
elements: [MUSIC_ELEMENT],
beatAnalysis: BEAT_ANALYSIS,
beatEdits: null,
commitBeatEdits: commitBeatEditsSpy,
});
});
afterEach(() => {
act(() => window.dispatchEvent(new Event("blur")));
for (const root of roots.splice(0)) act(() => root.unmount());
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
describe("BeatStrip gesture ownership", () => {
it("commits once after its source row unmounts", () => {
const { root } = mountBeatStrip();
startBeatDrag();
act(() => root.render(null));
expect(usePlayerStore.getState().beatDragging).toBe(true);
act(() => {
window.dispatchEvent(
pointerEvent("pointermove", {
bubbles: true,
clientX: 140,
clientY: 100,
pointerId: 1,
}),
);
window.dispatchEvent(
pointerEvent("pointerup", {
bubbles: true,
clientX: 140,
clientY: 100,
pointerId: 1,
}),
);
window.dispatchEvent(
pointerEvent("pointerup", {
bubbles: true,
clientX: 160,
clientY: 100,
pointerId: 1,
}),
);
});
expectCommittedBeatAt(1.4);
expect(usePlayerStore.getState().beatDragging).toBe(false);
});
it("includes viewport scrolling in the final beat time", () => {
const { viewport } = mountBeatStrip();
startBeatDrag(100);
viewport.scrollLeft = 50;
releaseBeatDrag(110);
expectCommittedBeatAt(1.6);
});
it("cancels without mutation on pointer cancel, Escape, and project switch", () => {
mountBeatStrip();
startBeatDrag();
act(() => {
window.dispatchEvent(pointerEvent("pointercancel", { pointerId: 1 }));
window.dispatchEvent(pointerEvent("pointerup", { clientX: 140, clientY: 100, pointerId: 1 }));
});
startBeatDrag();
act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
startBeatDrag();
act(() => usePlayerStore.getState().beginTimelineSession("project-b"));
releaseBeatDrag();
expectCancelledBeatDrag();
});
it("cancels without mutation when the captured music source disappears", () => {
mountBeatStrip();
startBeatDrag();
act(() => usePlayerStore.setState({ elements: [] }));
releaseBeatDrag();
expectCancelledBeatDrag();
});
it("cancels when the captured beat disappears during the gesture", () => {
mountBeatStrip();
startBeatDrag();
act(() => {
usePlayerStore.setState({
beatAnalysis: { ...BEAT_ANALYSIS, beatTimes: [3], beatStrengths: [0.8] },
});
});
releaseBeatDrag();
expectCancelledBeatDrag();
});
it("releases the actor when the owning timeline viewport unmounts", async () => {
const { viewport } = mountBeatStrip();
startBeatDrag();
await act(async () => {
viewport.remove();
await Promise.resolve();
});
releaseBeatDrag();
expectCancelledBeatDrag();
});
it("ignores unrelated pointers and keeps the active beat rendered outside the time window", () => {
const { root } = mountBeatStrip();
startBeatDrag();
act(() => {
window.dispatchEvent(
pointerEvent("pointermove", {
bubbles: true,
clientX: 150,
clientY: 100,
pointerId: 2,
}),
);
window.dispatchEvent(
pointerEvent("pointerup", {
bubbles: true,
clientX: 150,
clientY: 100,
pointerId: 2,
}),
);
root.render(
<BeatStrip
beatTimes={[1, 3]}
beatStrengths={[0.5, 0.8]}
pps={100}
renderTimeRange={{ start: 2.5, end: 3.5 }}
/>,
);
});
expect(
document.querySelectorAll('[title="Drag to move · double-click to delete"]'),
).toHaveLength(2);
expect(commitBeatEditsSpy).not.toHaveBeenCalled();
act(() => {
window.dispatchEvent(
pointerEvent("pointerup", {
bubbles: true,
clientX: 150,
clientY: 100,
pointerId: 1,
}),
);
});
expect(commitBeatEditsSpy).toHaveBeenCalledOnce();
});
it("does not autoscroll or mutate below the drag threshold", () => {
const requestAnimationFrame = vi.fn(() => 1);
vi.stubGlobal("requestAnimationFrame", requestAnimationFrame);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
mountBeatStrip();
startBeatDrag(990);
act(() => {
window.dispatchEvent(
pointerEvent("pointermove", {
bubbles: true,
clientX: 991,
clientY: 100,
pointerId: 1,
}),
);
window.dispatchEvent(
pointerEvent("pointerup", {
bubbles: true,
clientX: 991,
clientY: 100,
pointerId: 1,
}),
);
});
expect(requestAnimationFrame).not.toHaveBeenCalled();
expect(commitBeatEditsSpy).not.toHaveBeenCalled();
});
});
@@ -1,12 +1,272 @@
import { memo, useRef, useState } from "react";
import { moveBeatCompositionTime, deleteBeatAtCompositionTime } from "../../utils/beatEditActions";
import { memo, useSyncExternalStore } from "react";
import {
deleteBeatAtCompositionTime,
moveBeatCompositionTime,
remapBeatAnalysisToComposition,
} from "../../utils/beatEditActions";
import { usePlayerStore } from "../store/playerStore";
import { CLIP_Y, getTimelineBeatEntries } from "./timelineLayout";
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
import {
applyTimelineAutoScrollStep,
resolveTimelineAutoScrollLoopAction,
} from "./timelineEditing";
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
export const BEAT_BAND_H = 14; // dark band height at top of track
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;
readonly originalTime: number;
readonly pixelsPerSecond: number;
readonly startScrollLeft: number;
readonly dx: number;
readonly started: boolean;
readonly sessionEpoch: number;
readonly projectId: string | null;
readonly musicElement: NonNullable<ReturnType<typeof getTimelineElementIndexes>["musicElement"]>;
readonly musicKey: string;
readonly musicSrc: string;
readonly scroll: HTMLElement;
}
let beatDragActor: BeatDragActor | null = null;
let beatDragRaf = 0;
let unsubscribeBeatDragSession: (() => void) | null = null;
let beatDragViewportObserver: MutationObserver | null = null;
const beatDragSubscribers = new Set<() => void>();
function publishBeatDrag(next: BeatDragActor | null): void {
beatDragActor = next;
for (const subscriber of beatDragSubscribers) subscriber();
}
function subscribeBeatDrag(subscriber: () => void): () => void {
beatDragSubscribers.add(subscriber);
return () => beatDragSubscribers.delete(subscriber);
}
function getBeatDragSnapshot(): BeatDragActor | null {
return beatDragActor;
}
function releaseBeatDragResources(actor: BeatDragActor): void {
if (beatDragRaf !== 0) {
cancelAnimationFrame(beatDragRaf);
beatDragRaf = 0;
}
unsubscribeBeatDragSession?.();
unsubscribeBeatDragSession = null;
beatDragViewportObserver?.disconnect();
beatDragViewportObserver = null;
window.removeEventListener("pointermove", handleBeatDragPointerMove);
window.removeEventListener("pointerup", handleBeatDragPointerUp);
window.removeEventListener("pointercancel", handleBeatDragPointerCancel);
window.removeEventListener("lostpointercapture", handleBeatDragPointerCancel);
window.removeEventListener("keydown", handleBeatDragKeyDown, true);
window.removeEventListener("blur", cancelBeatDrag);
try {
if (actor.scroll.hasPointerCapture?.(actor.pointerId)) {
actor.scroll.releasePointerCapture(actor.pointerId);
}
} catch {
// Window listeners retain terminal ownership when pointer capture is unavailable.
}
usePlayerStore.getState().setBeatDragging(false);
}
/** Claiming clears the shared actor before cleanup, so later terminal events are no-ops. */
function claimBeatDrag(pointerId?: number): BeatDragActor | null {
const actor = beatDragActor;
if (!actor || (pointerId !== undefined && actor.pointerId !== pointerId)) return null;
publishBeatDrag(null);
releaseBeatDragResources(actor);
return actor;
}
function cancelBeatDrag(): void {
claimBeatDrag();
}
function beatDragTime(actor: BeatDragActor): number {
return Math.max(0, actor.originalTime + actor.dx / actor.pixelsPerSecond);
}
function isBeatDragSourceCurrent(
actor: BeatDragActor,
state: ReturnType<typeof usePlayerStore.getState>,
): boolean {
const currentMusic = getTimelineElementIndexes(state.elements).musicElement;
if (
!actor.scroll.isConnected ||
state.timelineSessionEpoch !== actor.sessionEpoch ||
state.timelineProjectId !== actor.projectId ||
currentMusic !== actor.musicElement ||
(currentMusic.key ?? currentMusic.id) !== actor.musicKey ||
currentMusic.src !== actor.musicSrc
) {
return false;
}
const analysis = remapBeatAnalysisToComposition(
state.beatAnalysis,
currentMusic,
state.beatEdits,
);
return analysis?.beatTimes.some((time) => Math.abs(time - actor.originalTime) < 1e-3) === true;
}
function updateBeatDrag(clientX: number, clientY: number): BeatDragActor | null {
const actor = beatDragActor;
if (!actor) return null;
const pointerDx = clientX - actor.startX;
const next = {
...actor,
clientX,
clientY,
dx: pointerDx + actor.scroll.scrollLeft - actor.startScrollLeft,
started: actor.started || Math.abs(pointerDx) > 2,
};
publishBeatDrag(next);
usePlayerStore.getState().requestSeek(beatDragTime(next));
return next;
}
function stepBeatDragAutoScroll(): void {
beatDragRaf = 0;
const actor = beatDragActor;
if (!actor?.started) return;
if (!applyTimelineAutoScrollStep(actor.scroll, actor.clientX, actor.clientY)) return;
updateBeatDrag(actor.clientX, actor.clientY);
beatDragRaf = requestAnimationFrame(stepBeatDragAutoScroll);
}
function syncBeatDragAutoScroll(actor: BeatDragActor): void {
const action = resolveTimelineAutoScrollLoopAction(
actor.scroll,
actor.clientX,
actor.clientY,
beatDragRaf !== 0,
);
if (action === "start" && actor.started) {
beatDragRaf = requestAnimationFrame(stepBeatDragAutoScroll);
} else if (action === "stop" && beatDragRaf !== 0) {
cancelAnimationFrame(beatDragRaf);
beatDragRaf = 0;
}
}
function handleBeatDragPointerMove(event: PointerEvent): void {
if (event.pointerId !== beatDragActor?.pointerId) return;
event.preventDefault();
const actor = updateBeatDrag(event.clientX, event.clientY);
if (actor) syncBeatDragAutoScroll(actor);
}
function handleBeatDragPointerUp(event: PointerEvent): void {
if (event.pointerId !== beatDragActor?.pointerId) return;
const latest = updateBeatDrag(event.clientX, event.clientY);
const actor = claimBeatDrag(event.pointerId);
if (!actor || !latest?.started) return;
const store = usePlayerStore.getState();
if (!isBeatDragSourceCurrent(actor, store)) return;
const newTime = beatDragTime(latest);
moveBeatCompositionTime(actor.originalTime, newTime);
store.requestSeek(newTime);
}
function handleBeatDragPointerCancel(event: PointerEvent): void {
claimBeatDrag(event.pointerId);
}
function handleBeatDragKeyDown(event: KeyboardEvent): void {
if (event.key !== "Escape" || !beatDragActor) return;
event.preventDefault();
event.stopPropagation();
cancelBeatDrag();
}
function resolveBeatDragViewport(
event: React.PointerEvent<HTMLDivElement>,
pixelsPerSecond: number,
): HTMLElement | null {
if (event.button !== 0 || !(pixelsPerSecond > 0)) return null;
return event.currentTarget.closest<HTMLElement>("[data-timeline-scroll-viewport]");
}
function createBeatDragActor(
event: React.PointerEvent<HTMLDivElement>,
index: number,
originalTime: number,
pixelsPerSecond: number,
scroll: HTMLElement,
): BeatDragActor | null {
const store = usePlayerStore.getState();
const musicElement = getTimelineElementIndexes(store.elements).musicElement;
if (!musicElement?.src) return null;
const actor: BeatDragActor = {
pointerId: event.pointerId,
index,
startX: event.clientX,
clientX: event.clientX,
clientY: event.clientY,
originalTime,
pixelsPerSecond,
startScrollLeft: scroll.scrollLeft,
dx: 0,
started: false,
sessionEpoch: store.timelineSessionEpoch,
projectId: store.timelineProjectId,
musicElement,
musicKey: musicElement.key ?? musicElement.id,
musicSrc: musicElement.src,
scroll,
};
return isBeatDragSourceCurrent(actor, store) ? actor : null;
}
function activateBeatDrag(actor: BeatDragActor): void {
publishBeatDrag(actor);
window.addEventListener("pointermove", handleBeatDragPointerMove);
window.addEventListener("pointerup", handleBeatDragPointerUp);
window.addEventListener("pointercancel", handleBeatDragPointerCancel);
window.addEventListener("lostpointercapture", handleBeatDragPointerCancel);
window.addEventListener("keydown", handleBeatDragKeyDown, true);
window.addEventListener("blur", cancelBeatDrag);
unsubscribeBeatDragSession = usePlayerStore.subscribe((state) => {
if (!isBeatDragSourceCurrent(actor, state)) cancelBeatDrag();
});
beatDragViewportObserver = new MutationObserver(() => {
if (!actor.scroll.isConnected) cancelBeatDrag();
});
beatDragViewportObserver.observe(document, { childList: true, subtree: true });
try {
actor.scroll.setPointerCapture?.(actor.pointerId);
} catch {
// Window listeners retain terminal ownership when pointer capture is unavailable.
}
const store = usePlayerStore.getState();
store.setBeatDragging(true);
store.requestSeek(Math.max(0, actor.originalTime));
}
function beginBeatDrag(
event: React.PointerEvent<HTMLDivElement>,
index: number,
originalTime: number,
pixelsPerSecond: number,
): void {
const scroll = resolveBeatDragViewport(event, pixelsPerSecond);
if (!scroll) return;
cancelBeatDrag();
const actor = createBeatDragActor(event, index, originalTime, pixelsPerSecond, scroll);
if (actor) activateBeatDrag(actor);
}
/** Hide both layers when beats are packed tighter than this (px) — too dense to read. */
function beatsTooDense(beatTimes: number[], pps: number): boolean {
if (beatTimes.length < 2) return true;
@@ -96,11 +356,22 @@ export const BeatStrip = memo(function BeatStrip({
pps: number;
renderTimeRange?: TimelineTimeRange;
}) {
// Active drag: which beat and how far (px) it's been dragged.
const [drag, setDrag] = useState<{ index: number; dx: number } | null>(null);
const dragRef = useRef<{ index: number; startX: number; origTime: number } | null>(null);
const activeActor = useSyncExternalStore(
subscribeBeatDrag,
getBeatDragSnapshot,
getBeatDragSnapshot,
);
const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
const projectId = usePlayerStore((state) => state.timelineProjectId);
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
const drag =
activeActor &&
activeActor.sessionEpoch === sessionEpoch &&
activeActor.projectId === projectId &&
Math.abs((beatTimes[activeActor.index] ?? Number.NaN) - activeActor.originalTime) < 1e-3
? activeActor
: null;
const cy = BEAT_BAND_H / 2;
const beatEntries = getTimelineBeatEntries(
beatTimes,
@@ -141,36 +412,7 @@ export const BeatStrip = memo(function BeatStrip({
// selection (which otherwise "selects" the whole panel mid-drag).
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = { index: i, startX: e.clientX, origTime: t };
setDrag({ index: i, dx: 0 });
usePlayerStore.getState().setBeatDragging(true); // hide the playhead guideline
usePlayerStore.getState().requestSeek(Math.max(0, t)); // scrub audio at beat
}}
onPointerMove={(e) => {
const d = dragRef.current;
if (!d || d.index !== i) return;
e.preventDefault();
const dx = e.clientX - d.startX;
setDrag({ index: i, dx });
// Scrub the audio (and move the playhead) to follow the dragged beat.
usePlayerStore.getState().requestSeek(Math.max(0, d.origTime + dx / pps));
}}
onPointerUp={(e) => {
const d = dragRef.current;
dragRef.current = null;
setDrag(null);
usePlayerStore.getState().setBeatDragging(false);
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
if (!d || d.index !== i) return;
const dx = e.clientX - d.startX;
if (Math.abs(dx) > 2) {
const newTime = Math.max(0, d.origTime + dx / pps);
moveBeatCompositionTime(d.origTime, newTime);
usePlayerStore.getState().requestSeek(newTime); // park scrubber at new beat
}
beginBeatDrag(e, i, t, pps);
}}
onDoubleClick={(e) => {
e.stopPropagation();