@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,13 +1,475 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
type MouseEvent as ReactMouseEvent,
|
|
4
|
+
type PointerEvent as ReactPointerEvent,
|
|
5
|
+
} from "react";
|
|
6
|
+
import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
|
|
7
|
+
import {
|
|
8
|
+
KEYFRAME_DRAG_THRESHOLD_PX,
|
|
9
|
+
previewClipPct,
|
|
10
|
+
resolveKeyframeDrag,
|
|
11
|
+
} from "../../components/editor/keyframeDrag";
|
|
2
12
|
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
|
|
13
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
3
14
|
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
|
|
4
15
|
import { usePlayerStore } from "../store/playerStore";
|
|
5
16
|
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
|
17
|
+
import {
|
|
18
|
+
applyTimelineHorizontalAutoScrollStep,
|
|
19
|
+
resolveTimelineAutoScrollLoopAction,
|
|
20
|
+
} from "./timelineEditing";
|
|
6
21
|
import {
|
|
7
22
|
timelineKeyframeSelectionKey,
|
|
8
23
|
type TimelineKeyframeTarget,
|
|
9
24
|
} from "./timelineKeyframeIdentity";
|
|
10
25
|
|
|
26
|
+
interface TimelineRetimeKeyframe {
|
|
27
|
+
percentage: number;
|
|
28
|
+
tweenPercentage?: number;
|
|
29
|
+
propertyGroup?: string;
|
|
30
|
+
animationId?: string;
|
|
31
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TimelineKeyframeRetimeInput {
|
|
35
|
+
event: ReactPointerEvent<HTMLElement>;
|
|
36
|
+
elementId: string;
|
|
37
|
+
keyframeKey: string;
|
|
38
|
+
target: TimelineKeyframeTarget;
|
|
39
|
+
keyframes: readonly TimelineRetimeKeyframe[];
|
|
40
|
+
clipWidthPx: number;
|
|
41
|
+
draggedIndex: number;
|
|
42
|
+
sortedClipPercentages: readonly number[];
|
|
43
|
+
onMove: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
|
|
44
|
+
onSelect: (target: TimelineKeyframeTarget, additive: boolean) => void;
|
|
45
|
+
suppressNextClick: () => void;
|
|
46
|
+
/**
|
|
47
|
+
* Selection key of a cache keyframe, used to retire a pending entry once the
|
|
48
|
+
* authoritative cache reflects THAT keyframe at its destination. Without it a
|
|
49
|
+
* bare "some keyframe is near that %" test retires the entry whenever an
|
|
50
|
+
* unrelated sibling happens to sit there, which is easy to hit on an evenly
|
|
51
|
+
* spaced row.
|
|
52
|
+
*/
|
|
53
|
+
keyframeKeyOf: (keyframe: TimelineRetimeKeyframe) => string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface PendingTimelineKeyframeRetime {
|
|
57
|
+
elementId: string;
|
|
58
|
+
clipPercentage: number;
|
|
59
|
+
tweenPercentage: number;
|
|
60
|
+
destinationKeyframeKey: string;
|
|
61
|
+
sessionEpoch: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface TimelineKeyframeRetimePreview {
|
|
65
|
+
keyframeKey: string;
|
|
66
|
+
clipPercentage: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface TimelineKeyframeRetimeActor extends TimelineKeyframeRetimeInput {
|
|
70
|
+
phase: "active" | "committing" | "cancelled" | "complete";
|
|
71
|
+
pointerId: number | null;
|
|
72
|
+
pointerDownX: number;
|
|
73
|
+
lastClientX: number;
|
|
74
|
+
lastClientY: number;
|
|
75
|
+
originScrollLeft: number;
|
|
76
|
+
fromClipPercentage: number;
|
|
77
|
+
moved: boolean;
|
|
78
|
+
sessionEpoch: number;
|
|
79
|
+
sourceWasPresent: boolean;
|
|
80
|
+
scrollRaf: number;
|
|
81
|
+
unsubscribeStore: (() => void) | null;
|
|
82
|
+
teardownListeners: (() => void) | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface TimelineKeyframeRetimeCoordinator {
|
|
86
|
+
actor: TimelineKeyframeRetimeActor | null;
|
|
87
|
+
pending: Map<string, PendingTimelineKeyframeRetime>;
|
|
88
|
+
preview: TimelineKeyframeRetimePreview | null;
|
|
89
|
+
previewListeners: Set<(preview: TimelineKeyframeRetimePreview | null) => void>;
|
|
90
|
+
/**
|
|
91
|
+
* The most recent retime dispatched through this viewport, whichever diamond
|
|
92
|
+
* it came from. Selection is viewport-wide, so "is my revert still relevant"
|
|
93
|
+
* is a viewport-wide question, not a per-keyframe one.
|
|
94
|
+
*/
|
|
95
|
+
latest: PendingTimelineKeyframeRetime | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type TimelineRetimePointerEvent = Pick<
|
|
99
|
+
PointerEvent,
|
|
100
|
+
"clientX" | "clientY" | "pointerId" | "shiftKey"
|
|
101
|
+
>;
|
|
102
|
+
|
|
103
|
+
const keyframeRetimeCoordinators = new WeakMap<EventTarget, TimelineKeyframeRetimeCoordinator>();
|
|
104
|
+
|
|
105
|
+
function getRetimeOwner(target: HTMLElement): EventTarget {
|
|
106
|
+
return target.closest<HTMLElement>("[data-timeline-scroll-viewport]") ?? target.ownerDocument;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getRetimeCoordinator(owner: EventTarget): TimelineKeyframeRetimeCoordinator {
|
|
110
|
+
const existing = keyframeRetimeCoordinators.get(owner);
|
|
111
|
+
if (existing) return existing;
|
|
112
|
+
const coordinator: TimelineKeyframeRetimeCoordinator = {
|
|
113
|
+
actor: null,
|
|
114
|
+
pending: new Map(),
|
|
115
|
+
preview: null,
|
|
116
|
+
previewListeners: new Set(),
|
|
117
|
+
latest: null,
|
|
118
|
+
};
|
|
119
|
+
keyframeRetimeCoordinators.set(owner, coordinator);
|
|
120
|
+
return coordinator;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function stablePointerId(pointerId: number): number | null {
|
|
124
|
+
return Number.isFinite(pointerId) ? pointerId : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The retime destinations already dispatched from `source`'s viewport but not
|
|
129
|
+
* yet reflected in the keyframe cache. A renderer clamping a drag against its
|
|
130
|
+
* neighbours has to compose these in, or a second drag can cross a neighbour
|
|
131
|
+
* that already moved past it.
|
|
132
|
+
*/
|
|
133
|
+
export function readPendingTimelineKeyframeRetimes(
|
|
134
|
+
source: HTMLElement | null | undefined,
|
|
135
|
+
): ReadonlyMap<string, { clipPercentage: number; tweenPercentage: number }> {
|
|
136
|
+
if (!source) return EMPTY_PENDING_RETIMES;
|
|
137
|
+
return keyframeRetimeCoordinators.get(getRetimeOwner(source))?.pending ?? EMPTY_PENDING_RETIMES;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const EMPTY_PENDING_RETIMES: ReadonlyMap<
|
|
141
|
+
string,
|
|
142
|
+
{ clipPercentage: number; tweenPercentage: number }
|
|
143
|
+
> = new Map();
|
|
144
|
+
|
|
145
|
+
function publishRetimePreview(
|
|
146
|
+
coordinator: TimelineKeyframeRetimeCoordinator,
|
|
147
|
+
preview: TimelineKeyframeRetimePreview | null,
|
|
148
|
+
): void {
|
|
149
|
+
coordinator.preview = preview;
|
|
150
|
+
for (const listener of coordinator.previewListeners) listener(preview);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function subscribeTimelineKeyframeRetimePreview(
|
|
154
|
+
source: HTMLElement,
|
|
155
|
+
listener: (preview: TimelineKeyframeRetimePreview | null) => void,
|
|
156
|
+
): () => void {
|
|
157
|
+
const coordinator = getRetimeCoordinator(getRetimeOwner(source));
|
|
158
|
+
coordinator.previewListeners.add(listener);
|
|
159
|
+
listener(coordinator.preview);
|
|
160
|
+
return () => coordinator.previewListeners.delete(listener);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function resolveRetimeTweenPercentage(
|
|
164
|
+
actor: TimelineKeyframeRetimeActor,
|
|
165
|
+
toClipPercentage: number,
|
|
166
|
+
): number {
|
|
167
|
+
const animationKeyframes =
|
|
168
|
+
actor.target.animationId === undefined
|
|
169
|
+
? actor.keyframes
|
|
170
|
+
: actor.keyframes.filter((keyframe) => keyframe.animationId === actor.target.animationId);
|
|
171
|
+
const tweenPercentages = animationKeyframes
|
|
172
|
+
.map((keyframe) => keyframe.tweenPercentage)
|
|
173
|
+
.filter((value): value is number => typeof value === "number");
|
|
174
|
+
const mapped = clipToTweenPercentage(animationKeyframes, toClipPercentage);
|
|
175
|
+
if (tweenPercentages.length === 0) return mapped;
|
|
176
|
+
return Math.max(Math.min(...tweenPercentages), Math.min(Math.max(...tweenPercentages), mapped));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface TimelineKeyframeRetimeHandle {
|
|
180
|
+
update: (event: ReactPointerEvent<HTMLElement>) => void;
|
|
181
|
+
commit: (event: ReactPointerEvent<HTMLElement>) => void;
|
|
182
|
+
cancel: (event: ReactPointerEvent<HTMLElement>) => void;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Starts a keyframe retime on the stable timeline viewport. The row/button is
|
|
187
|
+
* only an entry point: window listeners own the gesture through virtualization.
|
|
188
|
+
*/
|
|
189
|
+
export function beginTimelineKeyframeRetime(
|
|
190
|
+
input: TimelineKeyframeRetimeInput,
|
|
191
|
+
): TimelineKeyframeRetimeHandle {
|
|
192
|
+
const source = input.event.currentTarget;
|
|
193
|
+
const owner = getRetimeOwner(source);
|
|
194
|
+
const viewport = owner instanceof HTMLElement ? owner : null;
|
|
195
|
+
const coordinator = getRetimeCoordinator(owner);
|
|
196
|
+
const sessionEpoch = usePlayerStore.getState().timelineSessionEpoch;
|
|
197
|
+
|
|
198
|
+
const cancel = (actor: TimelineKeyframeRetimeActor) => {
|
|
199
|
+
if (actor.phase !== "active") return;
|
|
200
|
+
actor.phase = "cancelled";
|
|
201
|
+
publishRetimePreview(coordinator, null);
|
|
202
|
+
if (actor.scrollRaf) cancelAnimationFrame(actor.scrollRaf);
|
|
203
|
+
actor.unsubscribeStore?.();
|
|
204
|
+
actor.teardownListeners?.();
|
|
205
|
+
if (viewport && actor.pointerId !== null) {
|
|
206
|
+
try {
|
|
207
|
+
viewport.releasePointerCapture(actor.pointerId);
|
|
208
|
+
} catch {
|
|
209
|
+
// Window listeners remain the native fallback when capture is unavailable.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (coordinator.actor === actor) {
|
|
213
|
+
coordinator.actor = null;
|
|
214
|
+
}
|
|
215
|
+
actor.phase = "complete";
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (coordinator.actor) cancel(coordinator.actor);
|
|
219
|
+
|
|
220
|
+
for (const [key, pending] of coordinator.pending) {
|
|
221
|
+
if (pending.sessionEpoch !== sessionEpoch) coordinator.pending.delete(key);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const [key, pending] of coordinator.pending) {
|
|
225
|
+
// Tolerance, not equality: cache writers round clip %s, so an exact check
|
|
226
|
+
// would leak an entry after every successful retime.
|
|
227
|
+
if (
|
|
228
|
+
pending.elementId === input.elementId &&
|
|
229
|
+
input.keyframes.some(
|
|
230
|
+
(keyframe) =>
|
|
231
|
+
input.keyframeKeyOf(keyframe) === pending.destinationKeyframeKey &&
|
|
232
|
+
Math.abs(keyframe.percentage - pending.clipPercentage) < 0.2,
|
|
233
|
+
)
|
|
234
|
+
) {
|
|
235
|
+
coordinator.pending.delete(key);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const pending = coordinator.pending.get(input.keyframeKey);
|
|
240
|
+
const actor: TimelineKeyframeRetimeActor = {
|
|
241
|
+
...input,
|
|
242
|
+
phase: "active",
|
|
243
|
+
pointerId: stablePointerId(input.event.pointerId),
|
|
244
|
+
pointerDownX: input.event.clientX,
|
|
245
|
+
lastClientX: input.event.clientX,
|
|
246
|
+
lastClientY: input.event.clientY,
|
|
247
|
+
originScrollLeft: viewport?.scrollLeft ?? 0,
|
|
248
|
+
fromClipPercentage: pending?.clipPercentage ?? input.target.percentage,
|
|
249
|
+
moved: false,
|
|
250
|
+
sessionEpoch,
|
|
251
|
+
sourceWasPresent: usePlayerStore
|
|
252
|
+
.getState()
|
|
253
|
+
.elements.some((element) => (element.key ?? element.id) === input.elementId),
|
|
254
|
+
scrollRaf: 0,
|
|
255
|
+
unsubscribeStore: null,
|
|
256
|
+
teardownListeners: null,
|
|
257
|
+
};
|
|
258
|
+
coordinator.actor = actor;
|
|
259
|
+
|
|
260
|
+
const matchesPointer = (event: TimelineRetimePointerEvent) =>
|
|
261
|
+
actor.pointerId === null || event.pointerId === actor.pointerId;
|
|
262
|
+
const pointerXWithScroll = () =>
|
|
263
|
+
actor.lastClientX + (viewport?.scrollLeft ?? 0) - actor.originScrollLeft;
|
|
264
|
+
const publishPreview = () => {
|
|
265
|
+
publishRetimePreview(coordinator, {
|
|
266
|
+
keyframeKey: actor.keyframeKey,
|
|
267
|
+
clipPercentage: previewClipPct({
|
|
268
|
+
pointerDownX: actor.pointerDownX,
|
|
269
|
+
pointerMoveX: pointerXWithScroll(),
|
|
270
|
+
clipWidthPx: actor.clipWidthPx,
|
|
271
|
+
draggedClipPct: actor.fromClipPercentage,
|
|
272
|
+
draggedIndex: actor.draggedIndex,
|
|
273
|
+
sortedClipPcts: actor.sortedClipPercentages,
|
|
274
|
+
}),
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
const stopAutoScroll = () => {
|
|
278
|
+
if (actor.scrollRaf) cancelAnimationFrame(actor.scrollRaf);
|
|
279
|
+
actor.scrollRaf = 0;
|
|
280
|
+
};
|
|
281
|
+
const stepAutoScroll = () => {
|
|
282
|
+
actor.scrollRaf = 0;
|
|
283
|
+
if (
|
|
284
|
+
actor.phase !== "active" ||
|
|
285
|
+
!viewport ||
|
|
286
|
+
!applyTimelineHorizontalAutoScrollStep(viewport, actor.lastClientX)
|
|
287
|
+
) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
publishPreview();
|
|
291
|
+
actor.scrollRaf = requestAnimationFrame(stepAutoScroll);
|
|
292
|
+
};
|
|
293
|
+
const syncAutoScroll = () => {
|
|
294
|
+
if (!viewport || !actor.moved) return;
|
|
295
|
+
const action = resolveTimelineAutoScrollLoopAction(
|
|
296
|
+
viewport,
|
|
297
|
+
actor.lastClientX,
|
|
298
|
+
actor.lastClientY,
|
|
299
|
+
actor.scrollRaf !== 0,
|
|
300
|
+
);
|
|
301
|
+
if (action === "stop") stopAutoScroll();
|
|
302
|
+
else if (action === "start") actor.scrollRaf = requestAnimationFrame(stepAutoScroll);
|
|
303
|
+
};
|
|
304
|
+
const teardown = () => {
|
|
305
|
+
stopAutoScroll();
|
|
306
|
+
actor.unsubscribeStore?.();
|
|
307
|
+
actor.unsubscribeStore = null;
|
|
308
|
+
actor.teardownListeners?.();
|
|
309
|
+
actor.teardownListeners = null;
|
|
310
|
+
};
|
|
311
|
+
const releaseCapture = () => {
|
|
312
|
+
if (!viewport || actor.pointerId === null) return;
|
|
313
|
+
try {
|
|
314
|
+
viewport.releasePointerCapture(actor.pointerId);
|
|
315
|
+
} catch {
|
|
316
|
+
// Capture may already have been released by the browser.
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const claimActorForCommit = (event: TimelineRetimePointerEvent): boolean => {
|
|
321
|
+
if (actor.phase !== "active" || !matchesPointer(event)) return false;
|
|
322
|
+
if (actor.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch) {
|
|
323
|
+
cancel(actor);
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
actor.phase = "committing";
|
|
327
|
+
actor.lastClientX = event.clientX;
|
|
328
|
+
actor.lastClientY = event.clientY;
|
|
329
|
+
teardown();
|
|
330
|
+
releaseCapture();
|
|
331
|
+
publishRetimePreview(coordinator, null);
|
|
332
|
+
if (coordinator.actor === actor) {
|
|
333
|
+
coordinator.actor = null;
|
|
334
|
+
}
|
|
335
|
+
actor.suppressNextClick();
|
|
336
|
+
return true;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const commitMove = (toClipPercentage: number) => {
|
|
340
|
+
const newTweenPercentage = resolveRetimeTweenPercentage(actor, toClipPercentage);
|
|
341
|
+
const pendingBefore = coordinator.pending.get(actor.keyframeKey);
|
|
342
|
+
const fromTarget = pendingBefore
|
|
343
|
+
? {
|
|
344
|
+
...actor.target,
|
|
345
|
+
percentage: pendingBefore.clipPercentage,
|
|
346
|
+
tweenPercentage: pendingBefore.tweenPercentage,
|
|
347
|
+
}
|
|
348
|
+
: actor.target;
|
|
349
|
+
const nextPending = {
|
|
350
|
+
elementId: actor.elementId,
|
|
351
|
+
clipPercentage: toClipPercentage,
|
|
352
|
+
tweenPercentage: newTweenPercentage,
|
|
353
|
+
destinationKeyframeKey: timelineKeyframeSelectionKey(actor.elementId, {
|
|
354
|
+
...actor.target,
|
|
355
|
+
percentage: toClipPercentage,
|
|
356
|
+
tweenPercentage: newTweenPercentage,
|
|
357
|
+
}),
|
|
358
|
+
sessionEpoch: actor.sessionEpoch,
|
|
359
|
+
};
|
|
360
|
+
coordinator.pending.set(actor.keyframeKey, nextPending);
|
|
361
|
+
coordinator.latest = nextPending;
|
|
362
|
+
const clearPending = () => {
|
|
363
|
+
if (coordinator.pending.get(actor.keyframeKey) === nextPending) {
|
|
364
|
+
coordinator.pending.delete(actor.keyframeKey);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
// A rejected drop (the destination time is already occupied) snaps the
|
|
368
|
+
// diamond back to its source position, so the pending entry AND the
|
|
369
|
+
// selection have to revert with it: parking on the ghost drop position
|
|
370
|
+
// strands the playhead + selection on a keyframe that is not there.
|
|
371
|
+
const revertRetime = () => {
|
|
372
|
+
// Only the newest gesture owns the selection. A rejected first drag
|
|
373
|
+
// whose commit settles after a second one started would otherwise park
|
|
374
|
+
// the selection back on ITS source keyframe, undoing a retime the user
|
|
375
|
+
// has already made and moving the playhead with it.
|
|
376
|
+
const isLatest = coordinator.latest === nextPending;
|
|
377
|
+
clearPending();
|
|
378
|
+
if (isLatest) actor.onSelect(fromTarget, false);
|
|
379
|
+
};
|
|
380
|
+
void actor.onMove(fromTarget, toClipPercentage).then((committed) => {
|
|
381
|
+
if (!committed) revertRetime();
|
|
382
|
+
}, revertRetime);
|
|
383
|
+
actor.onSelect(
|
|
384
|
+
{
|
|
385
|
+
...actor.target,
|
|
386
|
+
percentage: toClipPercentage,
|
|
387
|
+
tweenPercentage: newTweenPercentage,
|
|
388
|
+
},
|
|
389
|
+
false,
|
|
390
|
+
);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const finishCommit = (event: TimelineRetimePointerEvent) => {
|
|
394
|
+
if (!claimActorForCommit(event)) return;
|
|
395
|
+
const result = resolveKeyframeDrag({
|
|
396
|
+
pointerDownX: actor.pointerDownX,
|
|
397
|
+
pointerUpX: pointerXWithScroll(),
|
|
398
|
+
clipWidthPx: actor.clipWidthPx,
|
|
399
|
+
draggedClipPct: actor.fromClipPercentage,
|
|
400
|
+
draggedIndex: actor.draggedIndex,
|
|
401
|
+
sortedClipPcts: actor.sortedClipPercentages,
|
|
402
|
+
});
|
|
403
|
+
if (result.kind === "move" && result.toClipPct !== undefined) {
|
|
404
|
+
commitMove(result.toClipPct);
|
|
405
|
+
} else {
|
|
406
|
+
actor.onSelect(actor.target, event.shiftKey);
|
|
407
|
+
}
|
|
408
|
+
actor.phase = "complete";
|
|
409
|
+
};
|
|
410
|
+
const onPointerMove = (event: TimelineRetimePointerEvent) => {
|
|
411
|
+
if (actor.phase !== "active" || !matchesPointer(event)) return;
|
|
412
|
+
actor.lastClientX = event.clientX;
|
|
413
|
+
actor.lastClientY = event.clientY;
|
|
414
|
+
if (
|
|
415
|
+
!actor.moved &&
|
|
416
|
+
Math.abs(pointerXWithScroll() - actor.pointerDownX) >= KEYFRAME_DRAG_THRESHOLD_PX
|
|
417
|
+
) {
|
|
418
|
+
actor.moved = true;
|
|
419
|
+
}
|
|
420
|
+
if (actor.moved) publishPreview();
|
|
421
|
+
syncAutoScroll();
|
|
422
|
+
};
|
|
423
|
+
const onPointerUp = (event: TimelineRetimePointerEvent) => finishCommit(event);
|
|
424
|
+
const onPointerCancel = (event: TimelineRetimePointerEvent) => {
|
|
425
|
+
if (actor.phase === "active" && matchesPointer(event)) cancel(actor);
|
|
426
|
+
};
|
|
427
|
+
const onLostPointerCapture = (event: PointerEvent) => {
|
|
428
|
+
if (actor.phase === "active" && matchesPointer(event)) cancel(actor);
|
|
429
|
+
};
|
|
430
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
431
|
+
if (event.key === "Escape") cancel(actor);
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
window.addEventListener("pointermove", onPointerMove, true);
|
|
435
|
+
window.addEventListener("pointerup", onPointerUp, true);
|
|
436
|
+
window.addEventListener("pointercancel", onPointerCancel, true);
|
|
437
|
+
window.addEventListener("keydown", onKeyDown);
|
|
438
|
+
viewport?.addEventListener("lostpointercapture", onLostPointerCapture);
|
|
439
|
+
actor.teardownListeners = () => {
|
|
440
|
+
window.removeEventListener("pointermove", onPointerMove, true);
|
|
441
|
+
window.removeEventListener("pointerup", onPointerUp, true);
|
|
442
|
+
window.removeEventListener("pointercancel", onPointerCancel, true);
|
|
443
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
444
|
+
viewport?.removeEventListener("lostpointercapture", onLostPointerCapture);
|
|
445
|
+
};
|
|
446
|
+
actor.unsubscribeStore = usePlayerStore.subscribe((state) => {
|
|
447
|
+
const sourceStillPresent = state.elements.some(
|
|
448
|
+
(element) => (element.key ?? element.id) === actor.elementId,
|
|
449
|
+
);
|
|
450
|
+
if (state.timelineSessionEpoch !== actor.sessionEpoch) {
|
|
451
|
+
coordinator.pending.clear();
|
|
452
|
+
coordinator.latest = null;
|
|
453
|
+
cancel(actor);
|
|
454
|
+
} else if (actor.sourceWasPresent && !sourceStillPresent) {
|
|
455
|
+
for (const [key, pending] of coordinator.pending) {
|
|
456
|
+
if (pending.elementId === actor.elementId) coordinator.pending.delete(key);
|
|
457
|
+
}
|
|
458
|
+
if (coordinator.latest?.elementId === actor.elementId) coordinator.latest = null;
|
|
459
|
+
cancel(actor);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
if (viewport && actor.pointerId !== null) {
|
|
464
|
+
try {
|
|
465
|
+
viewport.setPointerCapture(actor.pointerId);
|
|
466
|
+
} catch {
|
|
467
|
+
// Window listeners are the native fallback when capture is unavailable.
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return { update: onPointerMove, commit: onPointerUp, cancel: onPointerCancel };
|
|
471
|
+
}
|
|
472
|
+
|
|
11
473
|
interface UseTimelineKeyframeHandlersInput {
|
|
12
474
|
expandedElements: TimelineElement[];
|
|
13
475
|
keyframeCache: Map<string, KeyframeCacheEntry>;
|
|
@@ -88,12 +550,13 @@ export function useTimelineKeyframeHandlers({
|
|
|
88
550
|
setKfContextMenu({
|
|
89
551
|
x: e.clientX + 4,
|
|
90
552
|
y: e.clientY + 2,
|
|
553
|
+
sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
|
|
554
|
+
element: el,
|
|
91
555
|
elementId: elId,
|
|
92
556
|
percentage: target.percentage,
|
|
93
557
|
tweenPercentage: target.tweenPercentage ?? kf?.tweenPercentage,
|
|
94
558
|
propertyGroup: target.propertyGroup,
|
|
95
559
|
animationId: target.animationId,
|
|
96
|
-
element: el,
|
|
97
560
|
currentEase: kf?.ease ?? kfData?.ease,
|
|
98
561
|
});
|
|
99
562
|
},
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
|
|
3
|
+
|
|
4
|
+
export function useTimelineLaneMoveRefresh(): () => void {
|
|
5
|
+
const setRefreshKey = useStudioPlaybackContextOptional()?.setRefreshKey;
|
|
6
|
+
return useCallback(() => setRefreshKey?.((key) => key + 1), [setRefreshKey]);
|
|
7
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { createTimelineRowGeometry, getTimelineRowTop } from "./timelineLayout";
|
|
8
|
+
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
9
|
+
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
|
10
|
+
import { configureTimelineTestViewport } from "./timelineTestViewport";
|
|
11
|
+
|
|
12
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
|
+
|
|
14
|
+
const elements: TimelineElement[] = [
|
|
15
|
+
{ id: "first", tag: "div", start: 1, duration: 1, track: 0 },
|
|
16
|
+
{ id: "offscreen", tag: "div", start: 2, duration: 1, track: 50 },
|
|
17
|
+
{ id: "base", tag: "div", start: 8, duration: 1, track: 99 },
|
|
18
|
+
];
|
|
19
|
+
const tracks = Array.from({ length: 100 }, (_, index) => index);
|
|
20
|
+
const geometry = createTimelineRowGeometry(
|
|
21
|
+
tracks,
|
|
22
|
+
tracks.map(() => 48),
|
|
23
|
+
);
|
|
24
|
+
const clipIndex = createTimelineClipIndex(
|
|
25
|
+
tracks.map((track) => [track, elements.filter((element) => element.track === track)]),
|
|
26
|
+
);
|
|
27
|
+
const FIRST_ROW_Y = getTimelineRowTop(0) + 4;
|
|
28
|
+
const OFFSCREEN_ROW_Y = getTimelineRowTop(50) + 40;
|
|
29
|
+
|
|
30
|
+
function pointer(
|
|
31
|
+
currentTarget: HTMLElement,
|
|
32
|
+
pointerId: number,
|
|
33
|
+
clientX: number,
|
|
34
|
+
clientY: number,
|
|
35
|
+
init: Partial<React.PointerEvent> = {},
|
|
36
|
+
): React.PointerEvent {
|
|
37
|
+
return {
|
|
38
|
+
button: 0,
|
|
39
|
+
clientX,
|
|
40
|
+
clientY,
|
|
41
|
+
ctrlKey: false,
|
|
42
|
+
metaKey: false,
|
|
43
|
+
shiftKey: false,
|
|
44
|
+
pointerId,
|
|
45
|
+
currentTarget,
|
|
46
|
+
target: currentTarget,
|
|
47
|
+
...init,
|
|
48
|
+
} as React.PointerEvent;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderHarness(sessionEpoch = 1) {
|
|
52
|
+
usePlayerStore.setState({ timelineSessionEpoch: sessionEpoch });
|
|
53
|
+
const host = document.createElement("div");
|
|
54
|
+
const scroll = document.createElement("div");
|
|
55
|
+
scroll.setPointerCapture = vi.fn();
|
|
56
|
+
configureTimelineTestViewport(scroll, geometry.canvasHeight);
|
|
57
|
+
host.append(scroll);
|
|
58
|
+
document.body.append(host);
|
|
59
|
+
const root = createRoot(host);
|
|
60
|
+
let api: ReturnType<typeof useTimelineRangeSelection> | null = null;
|
|
61
|
+
const ppsRef = { current: 100 };
|
|
62
|
+
const dragScrollRaf = { current: 0 };
|
|
63
|
+
const isDragging = { current: false };
|
|
64
|
+
const elementsRef = { current: elements };
|
|
65
|
+
const rowGeometryRef = { current: geometry };
|
|
66
|
+
const seekFromX = vi.fn();
|
|
67
|
+
|
|
68
|
+
function Probe({ epoch }: { epoch: number }) {
|
|
69
|
+
api = useTimelineRangeSelection({
|
|
70
|
+
scrollRef: { current: scroll },
|
|
71
|
+
ppsRef,
|
|
72
|
+
effectiveDuration: 60,
|
|
73
|
+
pps: 100,
|
|
74
|
+
seekFromX,
|
|
75
|
+
autoScrollDuringDrag: vi.fn(),
|
|
76
|
+
dragScrollRaf,
|
|
77
|
+
isDragging,
|
|
78
|
+
setShowPopover: vi.fn(),
|
|
79
|
+
elementsRef,
|
|
80
|
+
clipIndex,
|
|
81
|
+
rowGeometryRef,
|
|
82
|
+
contentOrigin: 0,
|
|
83
|
+
sessionEpoch: epoch,
|
|
84
|
+
});
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
act(() => root.render(<Probe epoch={sessionEpoch} />));
|
|
89
|
+
return {
|
|
90
|
+
scroll,
|
|
91
|
+
root,
|
|
92
|
+
get api() {
|
|
93
|
+
if (!api) throw new Error("selection harness did not render");
|
|
94
|
+
return api;
|
|
95
|
+
},
|
|
96
|
+
rerender(epoch: number) {
|
|
97
|
+
usePlayerStore.setState({ timelineSessionEpoch: epoch });
|
|
98
|
+
act(() => root.render(<Probe epoch={epoch} />));
|
|
99
|
+
},
|
|
100
|
+
seekFromX,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function dragMarquee(
|
|
105
|
+
view: ReturnType<typeof renderHarness>,
|
|
106
|
+
options: { secondPointer?: boolean; release?: boolean } = {},
|
|
107
|
+
): void {
|
|
108
|
+
act(() => {
|
|
109
|
+
view.api.handlePointerDown(pointer(view.scroll, 7, 0, FIRST_ROW_Y));
|
|
110
|
+
if (options.secondPointer) {
|
|
111
|
+
view.api.handlePointerDown(pointer(view.scroll, 8, 500, FIRST_ROW_Y));
|
|
112
|
+
}
|
|
113
|
+
view.api.handlePointerMove(pointer(view.scroll, 7, 400, OFFSCREEN_ROW_Y));
|
|
114
|
+
if (options.release) {
|
|
115
|
+
view.api.handlePointerUp(pointer(view.scroll, 7, 400, OFFSCREEN_ROW_Y));
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function expectSelectedIds(...ids: string[]): void {
|
|
121
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(ids));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function unmountHarness(view: ReturnType<typeof renderHarness>): void {
|
|
125
|
+
act(() => view.root.unmount());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
usePlayerStore.getState().reset();
|
|
130
|
+
document.body.innerHTML = "";
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("useTimelineRangeSelection", () => {
|
|
134
|
+
it("marquee-selects model clips across unmounted virtual rows", () => {
|
|
135
|
+
const view = renderHarness();
|
|
136
|
+
dragMarquee(view);
|
|
137
|
+
|
|
138
|
+
expect(document.querySelectorAll("[data-clip]")).toHaveLength(0);
|
|
139
|
+
expectSelectedIds("first", "offscreen");
|
|
140
|
+
unmountHarness(view);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("ignores another pointer and restores the pre-drag selection on cancellation", () => {
|
|
144
|
+
usePlayerStore.getState().setSelectedElementId("base");
|
|
145
|
+
const view = renderHarness();
|
|
146
|
+
dragMarquee(view);
|
|
147
|
+
act(() => view.api.handlePointerUp(pointer(view.scroll, 8, 400, OFFSCREEN_ROW_Y)));
|
|
148
|
+
expectSelectedIds("first", "offscreen");
|
|
149
|
+
|
|
150
|
+
act(() => view.api.handlePointerCancel(pointer(view.scroll, 7, 400, OFFSCREEN_ROW_Y)));
|
|
151
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("base");
|
|
152
|
+
expectSelectedIds("base");
|
|
153
|
+
unmountHarness(view);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("keeps the original pointer owner when a second pointer presses", () => {
|
|
157
|
+
const view = renderHarness();
|
|
158
|
+
dragMarquee(view, { secondPointer: true, release: true });
|
|
159
|
+
|
|
160
|
+
expectSelectedIds("first", "offscreen");
|
|
161
|
+
unmountHarness(view);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("commits a ruler click at its pointerdown position without requiring pointer movement", () => {
|
|
165
|
+
const view = renderHarness();
|
|
166
|
+
|
|
167
|
+
act(() => {
|
|
168
|
+
view.api.handlePointerDown(pointer(view.scroll, 7, 375, 5));
|
|
169
|
+
view.api.handlePointerUp(pointer(view.scroll, 7, 375, 5));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(view.seekFromX).toHaveBeenNthCalledWith(1, 375);
|
|
173
|
+
expect(view.seekFromX).toHaveBeenNthCalledWith(2, 375);
|
|
174
|
+
unmountHarness(view);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("does not clear a finalized range when capture is lost after pointerup", () => {
|
|
178
|
+
const view = renderHarness();
|
|
179
|
+
|
|
180
|
+
act(() => {
|
|
181
|
+
view.api.handlePointerDown(pointer(view.scroll, 7, 100, 80, { shiftKey: true }));
|
|
182
|
+
view.api.handlePointerMove(pointer(view.scroll, 7, 300, 80, { shiftKey: true }));
|
|
183
|
+
view.api.handlePointerUp(pointer(view.scroll, 7, 300, 80, { shiftKey: true }));
|
|
184
|
+
});
|
|
185
|
+
const finalized = view.api.rangeSelection;
|
|
186
|
+
expect(finalized).toMatchObject({ start: 1, end: 3 });
|
|
187
|
+
|
|
188
|
+
act(() => view.api.handlePointerCancel(pointer(view.scroll, 7, 300, 80)));
|
|
189
|
+
expect(view.api.rangeSelection).toEqual(finalized);
|
|
190
|
+
unmountHarness(view);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("cancels a live marquee when the project session changes", () => {
|
|
194
|
+
usePlayerStore.getState().setSelectedElementId("base");
|
|
195
|
+
const view = renderHarness(1);
|
|
196
|
+
dragMarquee(view);
|
|
197
|
+
usePlayerStore.getState().setSelectedElementId("base");
|
|
198
|
+
view.rerender(2);
|
|
199
|
+
|
|
200
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("base");
|
|
201
|
+
expectSelectedIds("base");
|
|
202
|
+
unmountHarness(view);
|
|
203
|
+
});
|
|
204
|
+
});
|