mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
perf(studio): stabilize virtualized beat gestures (#2709)
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user