@hyperframes/studio 0.7.90 → 0.7.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-9qOa08Tv.js} +1 -1
- package/dist/assets/{index-Bi-CwQDX.js → index-Bw4lNdtO.js} +1 -1
- package/dist/assets/{index-CquRJgm-.js → index-CDTN2ZVa.js} +1 -1
- package/dist/assets/index-DIntkrQl.js +428 -0
- package/dist/index.d.ts +20 -16
- package/dist/index.html +1 -1
- package/dist/index.js +6495 -5429
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- package/src/components/editor/propertyPanelTypes.ts +1 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- package/src/player/components/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/Timeline.test.ts +2 -3
- package/src/player/components/Timeline.tsx +47 -48
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.tsx +6 -3
- package/src/player/components/TimelineClipDiamonds.test.tsx +442 -18
- package/src/player/components/TimelineClipDiamonds.tsx +66 -211
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +0 -1
- package/src/player/components/TimelineLanes.tsx +3 -2
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- package/src/player/components/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +5 -15
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineLaneProps.ts +0 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +4 -4
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
- package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +11 -4
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/playerStore.test.ts +76 -0
- package/src/player/store/playerStore.ts +19 -11
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -0
- package/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-Cql15Yur.js +0 -428
|
@@ -1,12 +1,285 @@
|
|
|
1
|
-
import { memo,
|
|
2
|
-
import {
|
|
1
|
+
import { memo, useSyncExternalStore } from "react";
|
|
2
|
+
import {
|
|
3
|
+
deleteBeatAtCompositionTime,
|
|
4
|
+
moveBeatCompositionTime,
|
|
5
|
+
remapBeatAnalysisToComposition,
|
|
6
|
+
} from "../../utils/beatEditActions";
|
|
3
7
|
import { usePlayerStore } from "../store/playerStore";
|
|
4
8
|
import { CLIP_Y, getTimelineBeatEntries } from "./timelineLayout";
|
|
5
9
|
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
|
10
|
+
import {
|
|
11
|
+
applyTimelineAutoScrollStep,
|
|
12
|
+
resolveTimelineAutoScrollLoopAction,
|
|
13
|
+
} from "./timelineEditing";
|
|
14
|
+
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
|
6
15
|
|
|
7
16
|
export const BEAT_BAND_H = 14; // dark band height at top of track
|
|
8
17
|
const BEAT_HIT_W = 12; // grab width per beat (px)
|
|
9
18
|
|
|
19
|
+
interface BeatDragActor {
|
|
20
|
+
readonly pointerId: number;
|
|
21
|
+
readonly startX: number;
|
|
22
|
+
readonly clientX: number;
|
|
23
|
+
readonly clientY: number;
|
|
24
|
+
readonly originalTime: number;
|
|
25
|
+
readonly pixelsPerSecond: number;
|
|
26
|
+
readonly startScrollLeft: number;
|
|
27
|
+
readonly dx: number;
|
|
28
|
+
readonly started: boolean;
|
|
29
|
+
readonly sessionEpoch: number;
|
|
30
|
+
readonly projectId: string | null;
|
|
31
|
+
readonly musicElement: NonNullable<ReturnType<typeof getTimelineElementIndexes>["musicElement"]>;
|
|
32
|
+
readonly musicKey: string;
|
|
33
|
+
readonly musicSrc: string;
|
|
34
|
+
readonly scroll: HTMLElement;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let beatDragActor: BeatDragActor | null = null;
|
|
38
|
+
let beatDragRaf = 0;
|
|
39
|
+
let unsubscribeBeatDragSession: (() => void) | null = null;
|
|
40
|
+
let beatDragViewportObserver: MutationObserver | null = null;
|
|
41
|
+
const beatDragSubscribers = new Set<() => void>();
|
|
42
|
+
|
|
43
|
+
function publishBeatDrag(next: BeatDragActor | null): void {
|
|
44
|
+
beatDragActor = next;
|
|
45
|
+
for (const subscriber of beatDragSubscribers) subscriber();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function subscribeBeatDrag(subscriber: () => void): () => void {
|
|
49
|
+
beatDragSubscribers.add(subscriber);
|
|
50
|
+
return () => beatDragSubscribers.delete(subscriber);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getBeatDragSnapshot(): BeatDragActor | null {
|
|
54
|
+
return beatDragActor;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function releaseBeatDragResources(actor: BeatDragActor): void {
|
|
58
|
+
if (beatDragRaf !== 0) {
|
|
59
|
+
cancelAnimationFrame(beatDragRaf);
|
|
60
|
+
beatDragRaf = 0;
|
|
61
|
+
}
|
|
62
|
+
unsubscribeBeatDragSession?.();
|
|
63
|
+
unsubscribeBeatDragSession = null;
|
|
64
|
+
beatDragViewportObserver?.disconnect();
|
|
65
|
+
beatDragViewportObserver = null;
|
|
66
|
+
window.removeEventListener("pointermove", handleBeatDragPointerMove);
|
|
67
|
+
window.removeEventListener("pointerup", handleBeatDragPointerUp);
|
|
68
|
+
window.removeEventListener("pointercancel", handleBeatDragPointerCancel);
|
|
69
|
+
window.removeEventListener("lostpointercapture", handleBeatDragPointerCancel);
|
|
70
|
+
window.removeEventListener("keydown", handleBeatDragKeyDown);
|
|
71
|
+
window.removeEventListener("blur", cancelBeatDrag);
|
|
72
|
+
try {
|
|
73
|
+
if (actor.scroll.hasPointerCapture?.(actor.pointerId)) {
|
|
74
|
+
actor.scroll.releasePointerCapture(actor.pointerId);
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Window listeners retain terminal ownership when pointer capture is unavailable.
|
|
78
|
+
}
|
|
79
|
+
usePlayerStore.getState().setBeatDragging(false);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Claiming clears the shared actor before cleanup, so later terminal events are no-ops. */
|
|
83
|
+
function claimBeatDrag(pointerId?: number): BeatDragActor | null {
|
|
84
|
+
const actor = beatDragActor;
|
|
85
|
+
if (!actor || (pointerId !== undefined && actor.pointerId !== pointerId)) return null;
|
|
86
|
+
publishBeatDrag(null);
|
|
87
|
+
releaseBeatDragResources(actor);
|
|
88
|
+
return actor;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cancelBeatDrag(): void {
|
|
92
|
+
claimBeatDrag();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function beatDragTime(actor: BeatDragActor): number {
|
|
96
|
+
return Math.max(0, actor.originalTime + actor.dx / actor.pixelsPerSecond);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isBeatDragSourceCurrent(
|
|
100
|
+
actor: BeatDragActor,
|
|
101
|
+
state: ReturnType<typeof usePlayerStore.getState>,
|
|
102
|
+
): boolean {
|
|
103
|
+
const currentMusic = getTimelineElementIndexes(state.elements).musicElement;
|
|
104
|
+
if (
|
|
105
|
+
!actor.scroll.isConnected ||
|
|
106
|
+
state.timelineSessionEpoch !== actor.sessionEpoch ||
|
|
107
|
+
state.timelineProjectId !== actor.projectId ||
|
|
108
|
+
currentMusic !== actor.musicElement ||
|
|
109
|
+
(currentMusic.key ?? currentMusic.id) !== actor.musicKey ||
|
|
110
|
+
currentMusic.src !== actor.musicSrc
|
|
111
|
+
) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const analysis = remapBeatAnalysisToComposition(
|
|
115
|
+
state.beatAnalysis,
|
|
116
|
+
currentMusic,
|
|
117
|
+
state.beatEdits,
|
|
118
|
+
);
|
|
119
|
+
return analysis?.beatTimes.some((time) => Math.abs(time - actor.originalTime) < 1e-3) === true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function updateBeatDrag(clientX: number, clientY: number): BeatDragActor | null {
|
|
123
|
+
const actor = beatDragActor;
|
|
124
|
+
if (!actor) return null;
|
|
125
|
+
const pointerDx = clientX - actor.startX;
|
|
126
|
+
const next = {
|
|
127
|
+
...actor,
|
|
128
|
+
clientX,
|
|
129
|
+
clientY,
|
|
130
|
+
dx: pointerDx + actor.scroll.scrollLeft - actor.startScrollLeft,
|
|
131
|
+
started: actor.started || Math.abs(pointerDx) > 2,
|
|
132
|
+
};
|
|
133
|
+
publishBeatDrag(next);
|
|
134
|
+
usePlayerStore.getState().requestSeek(beatDragTime(next));
|
|
135
|
+
return next;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function stepBeatDragAutoScroll(): void {
|
|
139
|
+
beatDragRaf = 0;
|
|
140
|
+
const actor = beatDragActor;
|
|
141
|
+
if (!actor?.started) return;
|
|
142
|
+
if (!applyTimelineAutoScrollStep(actor.scroll, actor.clientX, actor.clientY)) return;
|
|
143
|
+
updateBeatDrag(actor.clientX, actor.clientY);
|
|
144
|
+
beatDragRaf = requestAnimationFrame(stepBeatDragAutoScroll);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function syncBeatDragAutoScroll(actor: BeatDragActor): void {
|
|
148
|
+
const action = resolveTimelineAutoScrollLoopAction(
|
|
149
|
+
actor.scroll,
|
|
150
|
+
actor.clientX,
|
|
151
|
+
actor.clientY,
|
|
152
|
+
beatDragRaf !== 0,
|
|
153
|
+
);
|
|
154
|
+
if (action === "start" && actor.started) {
|
|
155
|
+
beatDragRaf = requestAnimationFrame(stepBeatDragAutoScroll);
|
|
156
|
+
} else if (action === "stop" && beatDragRaf !== 0) {
|
|
157
|
+
cancelAnimationFrame(beatDragRaf);
|
|
158
|
+
beatDragRaf = 0;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function handleBeatDragPointerMove(event: PointerEvent): void {
|
|
163
|
+
if (event.pointerId !== beatDragActor?.pointerId) return;
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
const actor = updateBeatDrag(event.clientX, event.clientY);
|
|
166
|
+
if (actor) syncBeatDragAutoScroll(actor);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function handleBeatDragPointerUp(event: PointerEvent): void {
|
|
170
|
+
if (event.pointerId !== beatDragActor?.pointerId) return;
|
|
171
|
+
const latest = updateBeatDrag(event.clientX, event.clientY);
|
|
172
|
+
const actor = claimBeatDrag(event.pointerId);
|
|
173
|
+
if (!actor || !latest?.started) return;
|
|
174
|
+
const store = usePlayerStore.getState();
|
|
175
|
+
if (!isBeatDragSourceCurrent(actor, store)) return;
|
|
176
|
+
const newTime = beatDragTime(latest);
|
|
177
|
+
moveBeatCompositionTime(actor.originalTime, newTime);
|
|
178
|
+
store.requestSeek(newTime);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function handleBeatDragPointerCancel(event: PointerEvent): void {
|
|
182
|
+
claimBeatDrag(event.pointerId);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function handleBeatDragKeyDown(event: KeyboardEvent): void {
|
|
186
|
+
if (event.key !== "Escape" || !beatDragActor) return;
|
|
187
|
+
event.preventDefault();
|
|
188
|
+
cancelBeatDrag();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function resolveBeatDragViewport(
|
|
192
|
+
event: React.PointerEvent<HTMLDivElement>,
|
|
193
|
+
pixelsPerSecond: number,
|
|
194
|
+
): HTMLElement | null {
|
|
195
|
+
if (event.button !== 0 || !(pixelsPerSecond > 0)) return null;
|
|
196
|
+
return event.currentTarget.closest<HTMLElement>("[data-timeline-scroll-viewport]");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function createBeatDragActor(
|
|
200
|
+
event: React.PointerEvent<HTMLDivElement>,
|
|
201
|
+
originalTime: number,
|
|
202
|
+
pixelsPerSecond: number,
|
|
203
|
+
scroll: HTMLElement,
|
|
204
|
+
): BeatDragActor | null {
|
|
205
|
+
const store = usePlayerStore.getState();
|
|
206
|
+
const musicElement = getTimelineElementIndexes(store.elements).musicElement;
|
|
207
|
+
if (!musicElement?.src) return null;
|
|
208
|
+
const actor: BeatDragActor = {
|
|
209
|
+
pointerId: event.pointerId,
|
|
210
|
+
startX: event.clientX,
|
|
211
|
+
clientX: event.clientX,
|
|
212
|
+
clientY: event.clientY,
|
|
213
|
+
originalTime,
|
|
214
|
+
pixelsPerSecond,
|
|
215
|
+
startScrollLeft: scroll.scrollLeft,
|
|
216
|
+
dx: 0,
|
|
217
|
+
started: false,
|
|
218
|
+
sessionEpoch: store.timelineSessionEpoch,
|
|
219
|
+
projectId: store.timelineProjectId,
|
|
220
|
+
musicElement,
|
|
221
|
+
musicKey: musicElement.key ?? musicElement.id,
|
|
222
|
+
musicSrc: musicElement.src,
|
|
223
|
+
scroll,
|
|
224
|
+
};
|
|
225
|
+
return isBeatDragSourceCurrent(actor, store) ? actor : null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function activateBeatDrag(actor: BeatDragActor): void {
|
|
229
|
+
publishBeatDrag(actor);
|
|
230
|
+
window.addEventListener("pointermove", handleBeatDragPointerMove);
|
|
231
|
+
window.addEventListener("pointerup", handleBeatDragPointerUp);
|
|
232
|
+
window.addEventListener("pointercancel", handleBeatDragPointerCancel);
|
|
233
|
+
window.addEventListener("lostpointercapture", handleBeatDragPointerCancel);
|
|
234
|
+
window.addEventListener("keydown", handleBeatDragKeyDown);
|
|
235
|
+
window.addEventListener("blur", cancelBeatDrag);
|
|
236
|
+
unsubscribeBeatDragSession = usePlayerStore.subscribe((state, previous) => {
|
|
237
|
+
// Unlike gestures that can validate only at pointerup, beat drag must stop
|
|
238
|
+
// immediately when its music source disappears or changes mid-stream. This
|
|
239
|
+
// subscription owns that interruption; the identity guard keeps requestSeek's
|
|
240
|
+
// per-frame store writes allocation-free unless a source input changed.
|
|
241
|
+
if (
|
|
242
|
+
state.timelineSessionEpoch === previous.timelineSessionEpoch &&
|
|
243
|
+
state.timelineProjectId === previous.timelineProjectId &&
|
|
244
|
+
state.elements === previous.elements &&
|
|
245
|
+
state.beatAnalysis === previous.beatAnalysis &&
|
|
246
|
+
state.beatEdits === previous.beatEdits
|
|
247
|
+
) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (!isBeatDragSourceCurrent(actor, state)) cancelBeatDrag();
|
|
251
|
+
});
|
|
252
|
+
const viewportParent = actor.scroll.parentNode;
|
|
253
|
+
if (viewportParent) {
|
|
254
|
+
beatDragViewportObserver = new MutationObserver(() => {
|
|
255
|
+
if (!actor.scroll.isConnected) cancelBeatDrag();
|
|
256
|
+
});
|
|
257
|
+
beatDragViewportObserver.observe(viewportParent, { childList: true });
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
actor.scroll.setPointerCapture?.(actor.pointerId);
|
|
261
|
+
} catch {
|
|
262
|
+
// Window listeners retain terminal ownership when pointer capture is unavailable.
|
|
263
|
+
}
|
|
264
|
+
const store = usePlayerStore.getState();
|
|
265
|
+
store.setBeatDragging(true);
|
|
266
|
+
store.requestSeek(Math.max(0, actor.originalTime));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function beginBeatDrag(
|
|
270
|
+
event: React.PointerEvent<HTMLDivElement>,
|
|
271
|
+
originalTime: number,
|
|
272
|
+
pixelsPerSecond: number,
|
|
273
|
+
): void {
|
|
274
|
+
// One pointer owns the actor until a terminal event claims it. A second touch
|
|
275
|
+
// must not silently discard the first gesture and make its release a no-op.
|
|
276
|
+
if (beatDragActor) return;
|
|
277
|
+
const scroll = resolveBeatDragViewport(event, pixelsPerSecond);
|
|
278
|
+
if (!scroll) return;
|
|
279
|
+
const actor = createBeatDragActor(event, originalTime, pixelsPerSecond, scroll);
|
|
280
|
+
if (actor) activateBeatDrag(actor);
|
|
281
|
+
}
|
|
282
|
+
|
|
10
283
|
/** Hide both layers when beats are packed tighter than this (px) — too dense to read. */
|
|
11
284
|
function beatsTooDense(beatTimes: number[], pps: number): boolean {
|
|
12
285
|
if (beatTimes.length < 2) return true;
|
|
@@ -96,17 +369,31 @@ export const BeatStrip = memo(function BeatStrip({
|
|
|
96
369
|
pps: number;
|
|
97
370
|
renderTimeRange?: TimelineTimeRange;
|
|
98
371
|
}) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
372
|
+
const activeActor = useSyncExternalStore(
|
|
373
|
+
subscribeBeatDrag,
|
|
374
|
+
getBeatDragSnapshot,
|
|
375
|
+
getBeatDragSnapshot,
|
|
376
|
+
);
|
|
377
|
+
const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
|
|
378
|
+
const projectId = usePlayerStore((state) => state.timelineProjectId);
|
|
102
379
|
|
|
103
380
|
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
|
|
381
|
+
const activeBeatIndex = activeActor
|
|
382
|
+
? beatTimes.findIndex((time) => Math.abs(time - activeActor.originalTime) < 1e-3)
|
|
383
|
+
: -1;
|
|
384
|
+
const drag =
|
|
385
|
+
activeActor &&
|
|
386
|
+
activeActor.sessionEpoch === sessionEpoch &&
|
|
387
|
+
activeActor.projectId === projectId &&
|
|
388
|
+
activeBeatIndex >= 0
|
|
389
|
+
? activeActor
|
|
390
|
+
: null;
|
|
104
391
|
const cy = BEAT_BAND_H / 2;
|
|
105
392
|
const beatEntries = getTimelineBeatEntries(
|
|
106
393
|
beatTimes,
|
|
107
394
|
beatStrengths,
|
|
108
395
|
renderTimeRange,
|
|
109
|
-
drag ? new Set([
|
|
396
|
+
drag ? new Set([activeBeatIndex]) : undefined,
|
|
110
397
|
);
|
|
111
398
|
|
|
112
399
|
return (
|
|
@@ -119,7 +406,7 @@ export const BeatStrip = memo(function BeatStrip({
|
|
|
119
406
|
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
|
|
120
407
|
const r = 1.5 + strength * 2.5;
|
|
121
408
|
const opacity = 0.25 + strength * 0.75;
|
|
122
|
-
const dxPx = drag
|
|
409
|
+
const dxPx = drag && activeBeatIndex === i ? drag.dx : 0;
|
|
123
410
|
const x = t * pps + dxPx;
|
|
124
411
|
return (
|
|
125
412
|
<div
|
|
@@ -141,36 +428,7 @@ export const BeatStrip = memo(function BeatStrip({
|
|
|
141
428
|
// selection (which otherwise "selects" the whole panel mid-drag).
|
|
142
429
|
e.preventDefault();
|
|
143
430
|
e.stopPropagation();
|
|
144
|
-
|
|
145
|
-
dragRef.current = { index: i, startX: e.clientX, origTime: t };
|
|
146
|
-
setDrag({ index: i, dx: 0 });
|
|
147
|
-
usePlayerStore.getState().setBeatDragging(true); // hide the playhead guideline
|
|
148
|
-
usePlayerStore.getState().requestSeek(Math.max(0, t)); // scrub audio at beat
|
|
149
|
-
}}
|
|
150
|
-
onPointerMove={(e) => {
|
|
151
|
-
const d = dragRef.current;
|
|
152
|
-
if (!d || d.index !== i) return;
|
|
153
|
-
e.preventDefault();
|
|
154
|
-
const dx = e.clientX - d.startX;
|
|
155
|
-
setDrag({ index: i, dx });
|
|
156
|
-
// Scrub the audio (and move the playhead) to follow the dragged beat.
|
|
157
|
-
usePlayerStore.getState().requestSeek(Math.max(0, d.origTime + dx / pps));
|
|
158
|
-
}}
|
|
159
|
-
onPointerUp={(e) => {
|
|
160
|
-
const d = dragRef.current;
|
|
161
|
-
dragRef.current = null;
|
|
162
|
-
setDrag(null);
|
|
163
|
-
usePlayerStore.getState().setBeatDragging(false);
|
|
164
|
-
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) {
|
|
165
|
-
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
166
|
-
}
|
|
167
|
-
if (!d || d.index !== i) return;
|
|
168
|
-
const dx = e.clientX - d.startX;
|
|
169
|
-
if (Math.abs(dx) > 2) {
|
|
170
|
-
const newTime = Math.max(0, d.origTime + dx / pps);
|
|
171
|
-
moveBeatCompositionTime(d.origTime, newTime);
|
|
172
|
-
usePlayerStore.getState().requestSeek(newTime); // park scrubber at new beat
|
|
173
|
-
}
|
|
431
|
+
beginBeatDrag(e, t, pps);
|
|
174
432
|
}}
|
|
175
433
|
onDoubleClick={(e) => {
|
|
176
434
|
e.stopPropagation();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { act } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { TimelineElement } from "../store/playerStore";
|
|
6
6
|
import {
|
|
7
7
|
KeyframeDiamondContextMenu,
|
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
|
|
11
11
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
12
|
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
document.body.innerHTML = "";
|
|
15
|
+
});
|
|
16
|
+
|
|
13
17
|
const element = { id: "box", start: 0, duration: 2, track: 0 } as unknown as TimelineElement;
|
|
14
18
|
|
|
15
19
|
const state: KeyframeDiamondContextMenuState = {
|
|
@@ -87,4 +91,15 @@ describe("KeyframeDiamondContextMenu", () => {
|
|
|
87
91
|
act(() => root.unmount());
|
|
88
92
|
host.remove();
|
|
89
93
|
});
|
|
94
|
+
|
|
95
|
+
// The layer-wide delete takes every keyframed tween. Opened from a diamond it
|
|
96
|
+
// has to stay on that diamond's own lane, or right-clicking the opacity lane
|
|
97
|
+
// silently clears position too.
|
|
98
|
+
it("deletes all keyframes from the animation that opened the menu", () => {
|
|
99
|
+
const onDeleteAll = vi.fn();
|
|
100
|
+
|
|
101
|
+
clickMenuItem("Delete All Keyframes", { onDeleteAll });
|
|
102
|
+
|
|
103
|
+
expect(onDeleteAll).toHaveBeenCalledExactlyOnceWith(element, "box-to-1-position");
|
|
104
|
+
});
|
|
90
105
|
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
2
1
|
import { createPortal } from "react-dom";
|
|
3
2
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
4
3
|
import type { TimelineElement } from "../store/playerStore";
|
|
@@ -7,6 +6,8 @@ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
|
7
6
|
export interface KeyframeDiamondContextMenuState {
|
|
8
7
|
x: number;
|
|
9
8
|
y: number;
|
|
9
|
+
/** Timeline project session that created this portaled target. */
|
|
10
|
+
sessionEpoch?: number;
|
|
10
11
|
element: TimelineElement;
|
|
11
12
|
elementId: string;
|
|
12
13
|
percentage: number;
|
|
@@ -23,12 +24,12 @@ interface KeyframeDiamondContextMenuProps {
|
|
|
23
24
|
* floor in removeMotionPathPointInScript): an entry that silently no-ops is
|
|
24
25
|
* worse than no entry. */
|
|
25
26
|
onDelete?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
|
26
|
-
onDeleteAll: (element: TimelineElement) => void;
|
|
27
|
+
onDeleteAll: (element: TimelineElement, animationId?: string) => void;
|
|
27
28
|
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
|
28
29
|
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
export
|
|
32
|
+
export function KeyframeDiamondContextMenu({
|
|
32
33
|
state,
|
|
33
34
|
onClose,
|
|
34
35
|
onDelete,
|
|
@@ -93,7 +94,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
93
94
|
type="button"
|
|
94
95
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
|
95
96
|
onClick={() => {
|
|
96
|
-
onDeleteAll(state.element);
|
|
97
|
+
onDeleteAll(state.element, state.animationId);
|
|
97
98
|
onClose();
|
|
98
99
|
}}
|
|
99
100
|
>
|
|
@@ -102,4 +103,4 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
|
|
102
103
|
</div>,
|
|
103
104
|
document.body,
|
|
104
105
|
);
|
|
105
|
-
}
|
|
106
|
+
}
|
|
@@ -305,7 +305,6 @@ describe("Timeline provider boundary", () => {
|
|
|
305
305
|
act(() => root.unmount());
|
|
306
306
|
});
|
|
307
307
|
|
|
308
|
-
// fallow-ignore-next-line code-duplication
|
|
309
308
|
it("renders the gutter without legacy icons or hue dots", () => {
|
|
310
309
|
const { host, root } = renderBasicTimeline();
|
|
311
310
|
|
|
@@ -404,8 +403,8 @@ describe("Timeline provider boundary", () => {
|
|
|
404
403
|
);
|
|
405
404
|
});
|
|
406
405
|
|
|
407
|
-
const viewport = host.querySelector(
|
|
408
|
-
expect(viewport).
|
|
406
|
+
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
|
407
|
+
expect(viewport).not.toBeNull();
|
|
409
408
|
act(() => {
|
|
410
409
|
viewport?.dispatchEvent(
|
|
411
410
|
new MouseEvent("pointerdown", {
|