@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,21 +1,20 @@
|
|
|
1
1
|
import { memo, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { BEAT_BAND_H } from "./BeatStrip";
|
|
3
|
-
import {
|
|
4
|
-
KEYFRAME_DRAG_THRESHOLD_PX,
|
|
5
|
-
previewClipPct,
|
|
6
|
-
resolveKeyframeDrag,
|
|
7
|
-
} from "../../components/editor/keyframeDrag";
|
|
8
3
|
import { TimelineDiamondConnectors } from "./TimelineDiamondConnectors";
|
|
9
|
-
import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
|
|
10
4
|
import { LANE_H } from "./timelineLayout";
|
|
11
5
|
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
12
6
|
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
|
7
|
+
import {
|
|
8
|
+
beginTimelineKeyframeRetime,
|
|
9
|
+
readPendingTimelineKeyframeRetimes,
|
|
10
|
+
subscribeTimelineKeyframeRetimePreview,
|
|
11
|
+
type TimelineKeyframeRetimeHandle,
|
|
12
|
+
} from "./useTimelineKeyframeHandlers";
|
|
13
13
|
import {
|
|
14
14
|
DIAMOND_RATIO,
|
|
15
15
|
KF_MAX_PCT,
|
|
16
16
|
KF_MIN_PCT,
|
|
17
17
|
keyframeTarget,
|
|
18
|
-
type DragState,
|
|
19
18
|
type TimelineClipDiamondsProps,
|
|
20
19
|
type TimelineDiamondKeyframe,
|
|
21
20
|
type TimelineDiamondLaneProps,
|
|
@@ -86,65 +85,29 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
86
85
|
globalEase = "none",
|
|
87
86
|
}: TimelineDiamondLaneProps) {
|
|
88
87
|
// Hooks must run before the early return below.
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
//
|
|
95
|
-
// the first
|
|
96
|
-
|
|
97
|
-
const pendingRetimes =
|
|
98
|
-
// The most recent retime dispatched from this lane, whichever diamond it came
|
|
99
|
-
// from. Selection is lane-wide, so "is my revert still relevant" is a lane-wide
|
|
100
|
-
// question, not a per-keyframe one.
|
|
101
|
-
const latestRetimeRef = useRef<{ clipPct: number; tweenPct: number } | null>(null);
|
|
102
|
-
useEffect(() => {
|
|
103
|
-
// Clear a pending entry once the authoritative cache reflects THAT keyframe
|
|
104
|
-
// at ~its destination. Match by tolerance, not equality: cache writers round
|
|
105
|
-
// clip %s, so an exact check would leak an entry after every successful
|
|
106
|
-
// retime. Match by identity too: a bare "some keyframe is near that %" test
|
|
107
|
-
// cleared the entry whenever an unrelated sibling happened to sit there,
|
|
108
|
-
// which is easy to hit on an evenly spaced row.
|
|
109
|
-
const pendingEntries = pendingRetimeRef.current;
|
|
110
|
-
if (!pendingEntries) return;
|
|
111
|
-
for (const [key, pending] of pendingEntries) {
|
|
112
|
-
const settled = keyframesData.keyframes.some(
|
|
113
|
-
(k) =>
|
|
114
|
-
timelineKeyframeSelectionKey(elementId, keyframeTarget(k)) === key &&
|
|
115
|
-
Math.abs(k.percentage - pending.clipPct) < 0.2,
|
|
116
|
-
);
|
|
117
|
-
if (settled) pendingEntries.delete(key);
|
|
118
|
-
}
|
|
119
|
-
}, [keyframesData.keyframes, elementId]);
|
|
88
|
+
// The retime itself lives on the stable scroll viewport (beginTimelineKeyframeRetime),
|
|
89
|
+
// so a row unmounted by virtualization mid-drag does not drop the gesture.
|
|
90
|
+
// This lane only arms it and renders the preview it publishes.
|
|
91
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
92
|
+
const retimeHandleRef = useRef<TimelineKeyframeRetimeHandle | null>(null);
|
|
93
|
+
// Retime destinations already dispatched but not yet in the keyframe cache, so
|
|
94
|
+
// a rapid second drag composes from where the first move left the keyframe
|
|
95
|
+
// instead of the stale rendered value.
|
|
96
|
+
const pendingRetimes = readPendingTimelineKeyframeRetimes(rootRef.current);
|
|
120
97
|
// Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold
|
|
121
98
|
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
|
|
122
99
|
// on drop re-keys the diamond from source.
|
|
123
100
|
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
|
|
124
|
-
// One preview render per frame: a 120Hz trackpad fires pointermove far faster
|
|
125
|
-
// than the lane can repaint, and every diamond in the row re-evaluates its
|
|
126
|
-
// memo on each of those renders.
|
|
127
|
-
const previewFrameRef = useRef<number | null>(null);
|
|
128
|
-
const cancelPreviewFrame = () => {
|
|
129
|
-
if (previewFrameRef.current === null) return;
|
|
130
|
-
cancelAnimationFrame(previewFrameRef.current);
|
|
131
|
-
previewFrameRef.current = null;
|
|
132
|
-
};
|
|
133
|
-
// Escape backs out of an in-flight retime, the way clip and element drags
|
|
134
|
-
// already do. Nothing was written yet (the commit happens on pointerup), so
|
|
135
|
-
// dropping the preview is the whole undo.
|
|
136
101
|
useEffect(() => {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
cancelPreviewFrame();
|
|
147
|
-
};
|
|
102
|
+
const source = rootRef.current;
|
|
103
|
+
if (!source) return;
|
|
104
|
+
return subscribeTimelineKeyframeRetimePreview(source, (nextPreview) => {
|
|
105
|
+
setPreview(
|
|
106
|
+
nextPreview === null
|
|
107
|
+
? null
|
|
108
|
+
: { kfKey: nextPreview.keyframeKey, clipPct: nextPreview.clipPercentage },
|
|
109
|
+
);
|
|
110
|
+
});
|
|
148
111
|
}, []);
|
|
149
112
|
// The button element can re-render (reposition/unmount) synchronously from
|
|
150
113
|
// the state updates onClickKeyframe/onMoveKeyframe trigger, before the
|
|
@@ -197,7 +160,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
197
160
|
// O(keyframes squared) allocations on every playhead tick.
|
|
198
161
|
const pendingClipPctOf = (keyframe: TimelineDiamondKeyframe) =>
|
|
199
162
|
pendingRetimes.get(timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)))
|
|
200
|
-
?.
|
|
163
|
+
?.clipPercentage ?? keyframe.percentage;
|
|
201
164
|
const siblingRows = new Map<
|
|
202
165
|
string | undefined,
|
|
203
166
|
{ keyframes: TimelineDiamondKeyframe[]; clipPcts: number[] }
|
|
@@ -247,6 +210,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
247
210
|
|
|
248
211
|
return (
|
|
249
212
|
<div
|
|
213
|
+
ref={rootRef}
|
|
250
214
|
className="absolute inset-0"
|
|
251
215
|
style={{
|
|
252
216
|
// Above the clip's trim-handle strips (TimelineClip.tsx, z-index 4) so
|
|
@@ -294,150 +258,43 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
294
258
|
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
295
259
|
if (e.button !== 0) return;
|
|
296
260
|
e.stopPropagation();
|
|
297
|
-
if (canDrag)
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const live = dragRef.current;
|
|
320
|
-
if (!live || live.kfKey !== kfKey || live.cancelled) return;
|
|
321
|
-
setPreview({
|
|
322
|
-
kfKey,
|
|
323
|
-
clipPct: previewClipPct({
|
|
324
|
-
pointerDownX: live.startX,
|
|
325
|
-
pointerMoveX: live.lastX,
|
|
326
|
-
clipWidthPx,
|
|
327
|
-
draggedClipPct: live.fromClipPct,
|
|
328
|
-
draggedIndex: live.index,
|
|
329
|
-
sortedClipPcts: siblingClipPcts,
|
|
330
|
-
}),
|
|
331
|
-
});
|
|
261
|
+
if (!canDrag) return;
|
|
262
|
+
retimeHandleRef.current = beginTimelineKeyframeRetime({
|
|
263
|
+
event: e,
|
|
264
|
+
elementId,
|
|
265
|
+
keyframeKey: kfKey,
|
|
266
|
+
target,
|
|
267
|
+
keyframes: keyframesData.keyframes,
|
|
268
|
+
clipWidthPx,
|
|
269
|
+
// Clamp against this keyframe's own tween, not the whole merged row:
|
|
270
|
+
// a merged row interleaves several animations, and two colliding at
|
|
271
|
+
// one percentage would otherwise pin each other's diamonds in place.
|
|
272
|
+
draggedIndex: siblingIndex,
|
|
273
|
+
sortedClipPercentages: siblingClipPcts,
|
|
274
|
+
keyframeKeyOf: (keyframe) =>
|
|
275
|
+
timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)),
|
|
276
|
+
onMove: (fromTarget, toClipPercentage) =>
|
|
277
|
+
onMoveKeyframe?.(fromTarget, toClipPercentage) ?? Promise.resolve(false),
|
|
278
|
+
onSelect: (nextTarget, additive) => {
|
|
279
|
+
if (additive) onShiftClickKeyframe?.(nextTarget);
|
|
280
|
+
else onClickKeyframe?.(nextTarget);
|
|
281
|
+
},
|
|
282
|
+
suppressNextClick,
|
|
332
283
|
});
|
|
333
284
|
};
|
|
334
285
|
const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
// No drag armed (canDrag false / non-primary press) → treat as a click.
|
|
344
|
-
if (!d || d.kfKey !== kfKey) {
|
|
345
|
-
if (e.button !== 0) return;
|
|
346
|
-
suppressNextClick();
|
|
347
|
-
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
|
348
|
-
else onClickKeyframe?.(target);
|
|
286
|
+
// The viewport coordinator owns an armed retime; this local path is
|
|
287
|
+
// only for diamonds that cannot be dragged.
|
|
288
|
+
if (canDrag) {
|
|
289
|
+
retimeHandleRef.current?.commit(e);
|
|
290
|
+
retimeHandleRef.current = null;
|
|
291
|
+
e.stopPropagation();
|
|
349
292
|
return;
|
|
350
293
|
}
|
|
351
|
-
e.
|
|
352
|
-
dragRef.current = null;
|
|
353
|
-
cancelPreviewFrame();
|
|
354
|
-
setPreview(null);
|
|
355
|
-
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
294
|
+
if (e.button !== 0) return;
|
|
356
295
|
suppressNextClick();
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
// do not express yet. Selecting several and dragging one moves only
|
|
360
|
-
// the dragged one.
|
|
361
|
-
const res = resolveKeyframeDrag({
|
|
362
|
-
pointerDownX: d.startX,
|
|
363
|
-
pointerUpX: e.clientX,
|
|
364
|
-
clipWidthPx,
|
|
365
|
-
draggedClipPct: d.fromClipPct,
|
|
366
|
-
draggedIndex: siblingIndex,
|
|
367
|
-
sortedClipPcts: siblingClipPcts,
|
|
368
|
-
});
|
|
369
|
-
if (res.kind === "click" || res.kind === "noop") {
|
|
370
|
-
// "noop" is a press with enough pointer jitter to arm a drag (canDrag
|
|
371
|
-
// is on for every diamond once the clip is selected) that resolved
|
|
372
|
-
// back onto ~the same position — no real retime, so treat it as the
|
|
373
|
-
// click it was. Otherwise a normal click with a few px of mouse/
|
|
374
|
-
// trackpad drift silently does nothing: no selection, no move.
|
|
375
|
-
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
|
376
|
-
else onClickKeyframe?.(target);
|
|
377
|
-
} else if (res.kind === "move" && res.toClipPct != null) {
|
|
378
|
-
const animKfs =
|
|
379
|
-
target.animationId === undefined
|
|
380
|
-
? keyframesData.keyframes
|
|
381
|
-
: keyframesData.keyframes.filter((k) => k.animationId === target.animationId);
|
|
382
|
-
// Clamp to the mapped tween range: clipToTweenPercentage extrapolates
|
|
383
|
-
// linearly, so a boundary drag past the range would otherwise reselect
|
|
384
|
-
// an out-of-range tween % (e.g. 150%) even though the mutation clamps
|
|
385
|
-
// the moved endpoint back to the boundary.
|
|
386
|
-
const tweenPcts = animKfs
|
|
387
|
-
.map((k) => k.tweenPercentage)
|
|
388
|
-
.filter((v): v is number => typeof v === "number");
|
|
389
|
-
const clampTween = (v: number) =>
|
|
390
|
-
tweenPcts.length
|
|
391
|
-
? Math.max(Math.min(...tweenPcts), Math.min(Math.max(...tweenPcts), v))
|
|
392
|
-
: v;
|
|
393
|
-
const newTweenPct = clampTween(clipToTweenPercentage(animKfs, res.toClipPct));
|
|
394
|
-
// For a rapid second retime the diamond still renders the stale cache
|
|
395
|
-
// position, so identify the FROM keyframe by the pending (already-moved)
|
|
396
|
-
// position; the mutation locates the source keyframe by this identity.
|
|
397
|
-
const pendingBefore = pendingRetimes.get(kfKey);
|
|
398
|
-
const fromTarget = pendingBefore
|
|
399
|
-
? {
|
|
400
|
-
...target,
|
|
401
|
-
percentage: pendingBefore.clipPct,
|
|
402
|
-
tweenPercentage: pendingBefore.tweenPct,
|
|
403
|
-
}
|
|
404
|
-
: target;
|
|
405
|
-
const pending = { clipPct: res.toClipPct, tweenPct: newTweenPct };
|
|
406
|
-
pendingRetimes.set(kfKey, pending);
|
|
407
|
-
latestRetimeRef.current = pending;
|
|
408
|
-
const clearPending = () => {
|
|
409
|
-
if (pendingRetimes.get(kfKey) === pending) {
|
|
410
|
-
pendingRetimes.delete(kfKey);
|
|
411
|
-
}
|
|
412
|
-
};
|
|
413
|
-
// A rejected drop (the destination time is already occupied) snaps
|
|
414
|
-
// the diamond back to its source position, so the pending entry AND
|
|
415
|
-
// the selection have to revert with it — parking on the ghost drop
|
|
416
|
-
// position strands the playhead + selection on a keyframe that does
|
|
417
|
-
// not exist there.
|
|
418
|
-
const revertRetime = () => {
|
|
419
|
-
// Only the newest gesture owns the selection. A rejected first drag
|
|
420
|
-
// whose commit settles after a second one started would otherwise
|
|
421
|
-
// park the selection back on ITS source keyframe, undoing a retime
|
|
422
|
-
// the user has already made and moving the playhead with it.
|
|
423
|
-
const isLatest = latestRetimeRef.current === pending;
|
|
424
|
-
clearPending();
|
|
425
|
-
if (isLatest) onClickKeyframe?.(fromTarget);
|
|
426
|
-
};
|
|
427
|
-
void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
|
|
428
|
-
if (!committed) revertRetime();
|
|
429
|
-
}, revertRetime);
|
|
430
|
-
// A retime still targeted this exact diamond — park/select it at its
|
|
431
|
-
// new position, same as a plain click, or a drag that actually moved
|
|
432
|
-
// something looks identical to one that silently did nothing. Done
|
|
433
|
-
// optimistically so the gesture stays responsive; revertRetime puts
|
|
434
|
-
// it back if the move is rejected.
|
|
435
|
-
onClickKeyframe?.({
|
|
436
|
-
...target,
|
|
437
|
-
percentage: res.toClipPct,
|
|
438
|
-
tweenPercentage: newTweenPct,
|
|
439
|
-
});
|
|
440
|
-
}
|
|
296
|
+
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
|
297
|
+
else onClickKeyframe?.(target);
|
|
441
298
|
};
|
|
442
299
|
|
|
443
300
|
return (
|
|
@@ -476,18 +333,16 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
|
476
333
|
overflow: "visible",
|
|
477
334
|
}}
|
|
478
335
|
onPointerDown={onPointerDown}
|
|
479
|
-
onPointerMove={
|
|
336
|
+
onPointerMove={canDrag ? (e) => retimeHandleRef.current?.update(e) : undefined}
|
|
480
337
|
onPointerUp={onPointerUp}
|
|
481
|
-
onPointerCancel={
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
490
|
-
}}
|
|
338
|
+
onPointerCancel={
|
|
339
|
+
canDrag
|
|
340
|
+
? (e) => {
|
|
341
|
+
retimeHandleRef.current?.cancel(e);
|
|
342
|
+
retimeHandleRef.current = null;
|
|
343
|
+
}
|
|
344
|
+
: undefined
|
|
345
|
+
}
|
|
491
346
|
onContextMenu={(e) => {
|
|
492
347
|
e.preventDefault();
|
|
493
348
|
e.stopPropagation();
|
|
@@ -0,0 +1,90 @@
|
|
|
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 } from "vitest";
|
|
6
|
+
import { defaultTimelineTheme } from "./timelineTheme";
|
|
7
|
+
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
|
|
8
|
+
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
9
|
+
import { getTrackStyle } from "./useTimelineTrackLayout";
|
|
10
|
+
|
|
11
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
12
|
+
|
|
13
|
+
const drag: DraggedClipState = {
|
|
14
|
+
pointerId: 0,
|
|
15
|
+
element: { id: "hero", tag: "div", start: 2, duration: 3, track: 1 },
|
|
16
|
+
originClientX: 100,
|
|
17
|
+
originClientY: 100,
|
|
18
|
+
originScrollLeft: 0,
|
|
19
|
+
originScrollTop: 0,
|
|
20
|
+
pointerClientX: 350,
|
|
21
|
+
pointerClientY: 240,
|
|
22
|
+
pointerOffsetX: 20,
|
|
23
|
+
pointerOffsetY: 10,
|
|
24
|
+
previewStart: 4,
|
|
25
|
+
previewTrack: 2,
|
|
26
|
+
insertRow: null,
|
|
27
|
+
snapTime: null,
|
|
28
|
+
snapType: null,
|
|
29
|
+
started: true,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
afterEach(() => document.body.replaceChildren());
|
|
33
|
+
|
|
34
|
+
describe("TimelineGestureOverlay", () => {
|
|
35
|
+
it("keeps the drag actor mounted without a source-row node", () => {
|
|
36
|
+
const host = document.createElement("div");
|
|
37
|
+
document.body.append(host);
|
|
38
|
+
const root = createRoot(host);
|
|
39
|
+
const scroll = {
|
|
40
|
+
scrollLeft: 500,
|
|
41
|
+
scrollTop: 300,
|
|
42
|
+
getBoundingClientRect: () => ({ left: 50, top: 40 }),
|
|
43
|
+
} as HTMLDivElement;
|
|
44
|
+
act(() => {
|
|
45
|
+
root.render(
|
|
46
|
+
<TimelineGestureOverlay
|
|
47
|
+
drag={drag}
|
|
48
|
+
scrollRef={{ current: scroll }}
|
|
49
|
+
pixelsPerSecond={100}
|
|
50
|
+
rowHeight={42}
|
|
51
|
+
selectedElementId="hero"
|
|
52
|
+
currentTime={4}
|
|
53
|
+
theme={defaultTimelineTheme}
|
|
54
|
+
getTrackStyle={getTrackStyle}
|
|
55
|
+
/>,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
const actor = host.querySelector<HTMLElement>('[data-timeline-gesture-actor="hero"]');
|
|
59
|
+
expect(actor?.style.left).toBe("780px");
|
|
60
|
+
expect(actor?.style.top).toBe("490px");
|
|
61
|
+
expect(actor?.querySelector(".timeline-clip")).not.toBeNull();
|
|
62
|
+
expect(actor?.querySelector("[data-el-id]")).toBeNull();
|
|
63
|
+
expect(actor?.querySelector("[data-clip]")).toBeNull();
|
|
64
|
+
expect(host.querySelector("[data-source-row]")).toBeNull();
|
|
65
|
+
act(() => root.unmount());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("keeps the stable overlay host after terminal cleanup", () => {
|
|
69
|
+
const host = document.createElement("div");
|
|
70
|
+
document.body.append(host);
|
|
71
|
+
const root = createRoot(host);
|
|
72
|
+
act(() => {
|
|
73
|
+
root.render(
|
|
74
|
+
<TimelineGestureOverlay
|
|
75
|
+
drag={null}
|
|
76
|
+
scrollRef={{ current: null }}
|
|
77
|
+
pixelsPerSecond={100}
|
|
78
|
+
rowHeight={42}
|
|
79
|
+
selectedElementId={null}
|
|
80
|
+
currentTime={0}
|
|
81
|
+
theme={defaultTimelineTheme}
|
|
82
|
+
getTrackStyle={getTrackStyle}
|
|
83
|
+
/>,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
expect(host.querySelector("[data-timeline-gesture-overlay]")).not.toBeNull();
|
|
87
|
+
expect(host.querySelector("[data-timeline-gesture-actor]")).toBeNull();
|
|
88
|
+
act(() => root.unmount());
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { memo, type ReactNode } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
4
|
+
import { getRenderedTimelineElement } from "./timelineTheme";
|
|
5
|
+
import { TimelineClip } from "./TimelineClip";
|
|
6
|
+
import { getTimelineEditCapabilities } from "./timelineEditing";
|
|
7
|
+
import { renderClipChildren } from "./timelineClipChildren";
|
|
8
|
+
import { getTimelineDragOverlayPosition } from "./timelineClipDragPreview";
|
|
9
|
+
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
10
|
+
import type { TrackVisualStyle } from "./timelineIcons";
|
|
11
|
+
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
|
12
|
+
|
|
13
|
+
interface TimelineGestureOverlayProps {
|
|
14
|
+
drag: DraggedClipState | null;
|
|
15
|
+
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
16
|
+
pixelsPerSecond: number;
|
|
17
|
+
rowHeight: number;
|
|
18
|
+
selectedElementId: string | null;
|
|
19
|
+
currentTime: number;
|
|
20
|
+
theme: TimelineTheme;
|
|
21
|
+
getTrackStyle: (tag: string) => TrackVisualStyle;
|
|
22
|
+
renderClipContent?: (
|
|
23
|
+
element: TimelineElement,
|
|
24
|
+
style: { clip: string; label: string },
|
|
25
|
+
) => ReactNode;
|
|
26
|
+
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Stable canvas child that owns the live drag actor independently of source rows. */
|
|
30
|
+
export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({
|
|
31
|
+
drag,
|
|
32
|
+
scrollRef,
|
|
33
|
+
pixelsPerSecond,
|
|
34
|
+
rowHeight,
|
|
35
|
+
selectedElementId,
|
|
36
|
+
currentTime,
|
|
37
|
+
theme,
|
|
38
|
+
getTrackStyle,
|
|
39
|
+
renderClipContent,
|
|
40
|
+
renderClipOverlay,
|
|
41
|
+
}: TimelineGestureOverlayProps) {
|
|
42
|
+
const element =
|
|
43
|
+
drag?.started === true
|
|
44
|
+
? getRenderedTimelineElement({
|
|
45
|
+
element: drag.element,
|
|
46
|
+
draggedElementId: drag.element.key ?? drag.element.id,
|
|
47
|
+
previewStart: drag.previewStart,
|
|
48
|
+
previewTrack: drag.previewTrack,
|
|
49
|
+
})
|
|
50
|
+
: null;
|
|
51
|
+
const position = drag ? getTimelineDragOverlayPosition(drag, scrollRef.current) : null;
|
|
52
|
+
return (
|
|
53
|
+
<div data-timeline-gesture-overlay className="absolute inset-0 pointer-events-none">
|
|
54
|
+
{element && position && (
|
|
55
|
+
<div
|
|
56
|
+
data-timeline-gesture-actor={element.key ?? element.id}
|
|
57
|
+
className="absolute"
|
|
58
|
+
style={{
|
|
59
|
+
top: position.top,
|
|
60
|
+
left: position.left,
|
|
61
|
+
width: Math.max(element.duration * pixelsPerSecond, 4),
|
|
62
|
+
height: rowHeight,
|
|
63
|
+
zIndex: 40,
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<TimelineClip
|
|
67
|
+
el={{ ...element, start: 0 }}
|
|
68
|
+
pps={pixelsPerSecond}
|
|
69
|
+
clipY={0}
|
|
70
|
+
isSelected={selectedElementId === (element.key ?? element.id)}
|
|
71
|
+
isHovered={false}
|
|
72
|
+
isDragging
|
|
73
|
+
isGestureActor
|
|
74
|
+
isActive={isTimelineClipActive(element, currentTime)}
|
|
75
|
+
hasCustomContent={!!renderClipContent}
|
|
76
|
+
capabilities={getTimelineEditCapabilities(element)}
|
|
77
|
+
theme={theme}
|
|
78
|
+
isComposition={!!element.compositionSrc}
|
|
79
|
+
onHoverStart={() => {}}
|
|
80
|
+
onHoverEnd={() => {}}
|
|
81
|
+
onResizeStart={() => {}}
|
|
82
|
+
onClick={() => {}}
|
|
83
|
+
onDoubleClick={() => {}}
|
|
84
|
+
>
|
|
85
|
+
{renderClipChildren(
|
|
86
|
+
element,
|
|
87
|
+
getTrackStyle(element.tag),
|
|
88
|
+
renderClipContent,
|
|
89
|
+
renderClipOverlay,
|
|
90
|
+
)}
|
|
91
|
+
</TimelineClip>
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
});
|
|
@@ -116,7 +116,6 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
116
116
|
setResizingClip={vi.fn()}
|
|
117
117
|
setDraggedClip={vi.fn()}
|
|
118
118
|
setSelectedElementId={vi.fn()}
|
|
119
|
-
syncClipDragAutoScroll={vi.fn()}
|
|
120
119
|
shiftClickClipRef={createRef()}
|
|
121
120
|
getPreviewElement={(el) => el}
|
|
122
121
|
getTrackStyle={getTrackStyle}
|
|
@@ -76,7 +76,6 @@ export function TimelineLanes({
|
|
|
76
76
|
setResizingClip,
|
|
77
77
|
setDraggedClip,
|
|
78
78
|
setSelectedElementId,
|
|
79
|
-
syncClipDragAutoScroll,
|
|
80
79
|
shiftClickClipRef,
|
|
81
80
|
getPreviewElement,
|
|
82
81
|
getTrackStyle,
|
|
@@ -350,6 +349,7 @@ export function TimelineLanes({
|
|
|
350
349
|
setShowPopover(false);
|
|
351
350
|
setRangeSelection(null);
|
|
352
351
|
setResizingClip({
|
|
352
|
+
pointerId: e.pointerId,
|
|
353
353
|
element: el,
|
|
354
354
|
edge,
|
|
355
355
|
originClientX: e.clientX,
|
|
@@ -388,6 +388,7 @@ export function TimelineLanes({
|
|
|
388
388
|
(blockedIntent !== "move" && onResizeElement))
|
|
389
389
|
) {
|
|
390
390
|
blockedClipRef.current = {
|
|
391
|
+
pointerId: e.pointerId,
|
|
391
392
|
element: el,
|
|
392
393
|
intent: blockedIntent,
|
|
393
394
|
originClientX: e.clientX,
|
|
@@ -401,6 +402,7 @@ export function TimelineLanes({
|
|
|
401
402
|
setShowPopover(false);
|
|
402
403
|
setRangeSelection(null);
|
|
403
404
|
setDraggedClip({
|
|
405
|
+
pointerId: e.pointerId,
|
|
404
406
|
element: el,
|
|
405
407
|
originClientX: e.clientX,
|
|
406
408
|
originClientY: e.clientY,
|
|
@@ -418,7 +420,6 @@ export function TimelineLanes({
|
|
|
418
420
|
snapType: null,
|
|
419
421
|
started: false,
|
|
420
422
|
});
|
|
421
|
-
syncClipDragAutoScroll(e.clientX, e.clientY);
|
|
422
423
|
}
|
|
423
424
|
}
|
|
424
425
|
onClick={
|