@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,5 +1,7 @@
|
|
|
1
|
-
import { RULER_H, CLIP_Y,
|
|
1
|
+
import { RULER_H, CLIP_Y, TRACK_H, type TimelineRowGeometry } from "./timelineLayout";
|
|
2
2
|
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
|
|
3
|
+
import { queryTimelineClipIndex, type TimelineClipIndex } from "../lib/timelineClipIndex";
|
|
4
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
5
|
|
|
4
6
|
/** Pointer must travel at least this far (either axis) before a pointerdown on
|
|
5
7
|
* the empty timeline body becomes a marquee drag instead of a plain click. */
|
|
@@ -62,23 +64,22 @@ export function getMarqueeRect(
|
|
|
62
64
|
/**
|
|
63
65
|
* A clip's rendered rect in canvas/content coordinates (the same space the
|
|
64
66
|
* marquee rect lives in): x from the shared content origin + start * pps, y from the clip's row
|
|
65
|
-
* index within the
|
|
67
|
+
* index within the canonical row geometry (cumulative row top + CLIP_Y).
|
|
66
68
|
* Returns null when the clip's track is not currently displayed.
|
|
67
69
|
*/
|
|
68
70
|
export function getTimelineClipRect(
|
|
69
71
|
clip: Pick<MarqueeClipInput, "start" | "duration" | "track">,
|
|
70
|
-
|
|
72
|
+
rowGeometry: TimelineRowGeometry,
|
|
71
73
|
pps: number,
|
|
72
74
|
contentOrigin: number,
|
|
73
|
-
rowHeights: readonly number[] = [],
|
|
74
75
|
): Rect | null {
|
|
75
|
-
const row =
|
|
76
|
+
const row = rowGeometry.getRowIndex(clip.track);
|
|
76
77
|
if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
|
|
77
78
|
return {
|
|
78
79
|
left: contentOrigin + clip.start * pps,
|
|
79
|
-
top:
|
|
80
|
+
top: rowGeometry.getRowTop(row) + CLIP_Y,
|
|
80
81
|
width: Math.max(clip.duration * pps, MIN_CLIP_W),
|
|
81
|
-
height:
|
|
82
|
+
height: TRACK_H - CLIP_Y * 2,
|
|
82
83
|
};
|
|
83
84
|
}
|
|
84
85
|
|
|
@@ -90,29 +91,56 @@ export interface MarqueeSelectionResult {
|
|
|
90
91
|
primaryId: string | null;
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/** Narrow a marquee hit test to the intersecting logical rows and time span. */
|
|
95
|
+
export function getMarqueeClipCandidates(input: {
|
|
96
|
+
clipIndex: TimelineClipIndex;
|
|
97
|
+
rowGeometry: TimelineRowGeometry;
|
|
98
|
+
marquee: Rect;
|
|
99
|
+
pps: number;
|
|
100
|
+
contentOrigin: number;
|
|
101
|
+
}): readonly TimelineElement[] {
|
|
102
|
+
if (!(input.pps > 0) || input.marquee.width <= 0 || input.marquee.height <= 0) return [];
|
|
103
|
+
const lastRow = input.rowGeometry.rowKeys.length - 1;
|
|
104
|
+
const first = Math.max(0, Math.floor(input.rowGeometry.getRowFromY(input.marquee.top)));
|
|
105
|
+
const last = Math.min(
|
|
106
|
+
lastRow,
|
|
107
|
+
Math.floor(input.rowGeometry.getRowFromY(input.marquee.top + input.marquee.height)),
|
|
108
|
+
);
|
|
109
|
+
if (first > last) return [];
|
|
110
|
+
const paddingSeconds = MIN_CLIP_W / input.pps;
|
|
111
|
+
const start = Math.max(
|
|
112
|
+
0,
|
|
113
|
+
(input.marquee.left - input.contentOrigin) / input.pps - paddingSeconds,
|
|
114
|
+
);
|
|
115
|
+
const end =
|
|
116
|
+
(input.marquee.left + input.marquee.width - input.contentOrigin) / input.pps + paddingSeconds;
|
|
117
|
+
if (end <= start) return [];
|
|
118
|
+
|
|
119
|
+
const candidates: TimelineElement[] = [];
|
|
120
|
+
for (let row = first; row <= last; row += 1) {
|
|
121
|
+
const rowKey = input.rowGeometry.rowKeys[row];
|
|
122
|
+
if (rowKey === undefined) continue;
|
|
123
|
+
candidates.push(...queryTimelineClipIndex(input.clipIndex, rowKey, { start, end }));
|
|
124
|
+
}
|
|
125
|
+
return candidates;
|
|
126
|
+
}
|
|
127
|
+
|
|
93
128
|
/**
|
|
94
129
|
* Live marquee selection: every clip whose rendered rect intersects the marquee.
|
|
95
130
|
* `baseSelection` (shift/cmd-additive) is unioned in but never affects primaryId.
|
|
96
131
|
*/
|
|
97
132
|
export function computeMarqueeSelection(input: {
|
|
98
133
|
clips: MarqueeClipInput[];
|
|
99
|
-
|
|
134
|
+
rowGeometry: TimelineRowGeometry;
|
|
100
135
|
pps: number;
|
|
101
136
|
contentOrigin: number;
|
|
102
137
|
marquee: Rect;
|
|
103
138
|
baseSelection?: Iterable<string>;
|
|
104
|
-
rowHeights?: readonly number[];
|
|
105
139
|
}): MarqueeSelectionResult {
|
|
106
140
|
const ids = new Set<string>(input.baseSelection ?? []);
|
|
107
141
|
let primaryId: string | null = null;
|
|
108
142
|
for (const clip of input.clips) {
|
|
109
|
-
const rect = getTimelineClipRect(
|
|
110
|
-
clip,
|
|
111
|
-
input.trackOrder,
|
|
112
|
-
input.pps,
|
|
113
|
-
input.contentOrigin,
|
|
114
|
-
input.rowHeights,
|
|
115
|
-
);
|
|
143
|
+
const rect = getTimelineClipRect(clip, input.rowGeometry, input.pps, input.contentOrigin);
|
|
116
144
|
if (rect && rectsOverlap(rect, input.marquee)) {
|
|
117
145
|
ids.add(clip.id);
|
|
118
146
|
primaryId = clip.id;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Configure the shared viewport geometry used by timeline gesture hook tests. */
|
|
2
|
+
export function configureTimelineTestViewport(scroll: HTMLElement, scrollHeight: number): void {
|
|
3
|
+
scroll.getBoundingClientRect = () =>
|
|
4
|
+
({ left: 0, top: 0, right: 800, bottom: 240, width: 800, height: 240 }) as DOMRect;
|
|
5
|
+
Object.defineProperties(scroll, {
|
|
6
|
+
scrollLeft: { configurable: true, writable: true, value: 0 },
|
|
7
|
+
scrollTop: { configurable: true, writable: true, value: 0 },
|
|
8
|
+
scrollWidth: { configurable: true, value: 10_000 },
|
|
9
|
+
scrollHeight: { configurable: true, value: scrollHeight },
|
|
10
|
+
clientWidth: { configurable: true, value: 800 },
|
|
11
|
+
clientHeight: { configurable: true, value: 240 },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -29,10 +29,10 @@ export function getTimelinePreviewElement(
|
|
|
29
29
|
element: TimelineElement,
|
|
30
30
|
resizingClip: ResizingClipState | null,
|
|
31
31
|
): TimelineElement {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
) {
|
|
32
|
+
const elementIdentity = getTimelineElementIdentity(element);
|
|
33
|
+
const groupPreview = resizingClip?.groupPreview?.find((change) => change.key === elementIdentity);
|
|
34
|
+
if (groupPreview) return { ...element, ...groupPreview };
|
|
35
|
+
if (resizingClip && getTimelineElementIdentity(resizingClip.element) === elementIdentity) {
|
|
36
36
|
return {
|
|
37
37
|
...element,
|
|
38
38
|
start: resizingClip.previewStart,
|
|
@@ -4,7 +4,7 @@ import React, { act } from "react";
|
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { TimelineElement } from "../store/playerStore";
|
|
6
6
|
import { usePlayerStore } from "../store/playerStore";
|
|
7
|
-
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
7
|
+
import type { BlockedClipState, DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
|
|
8
8
|
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
|
9
9
|
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
|
10
10
|
|
|
@@ -38,38 +38,77 @@ function renderResizeHarness(
|
|
|
38
38
|
usePlayerStore.getState().setSelectedElementIds(new Set(selected));
|
|
39
39
|
|
|
40
40
|
const scroll = document.createElement("div");
|
|
41
|
+
const setPointerCapture = vi.fn();
|
|
42
|
+
scroll.setPointerCapture = setPointerCapture;
|
|
41
43
|
document.body.append(scroll);
|
|
42
44
|
const onResizeElement = vi.fn();
|
|
45
|
+
const onMoveElement = vi.fn();
|
|
46
|
+
const onBlockedEditAttempt = vi.fn();
|
|
43
47
|
const onResizeElements = vi.fn().mockResolvedValue(undefined);
|
|
44
48
|
let setResizingClip: ((s: ResizingClipState | null) => void) | null = null;
|
|
49
|
+
let setDraggedClip: ((s: DraggedClipState | null) => void) | null = null;
|
|
50
|
+
let resizingClip: ResizingClipState | null = null;
|
|
51
|
+
let blockedClipRef: React.RefObject<BlockedClipState | null> | null = null;
|
|
52
|
+
let epoch = 0;
|
|
45
53
|
|
|
46
|
-
function Harness() {
|
|
54
|
+
function Harness({ sessionEpoch }: { sessionEpoch: number }) {
|
|
47
55
|
const hook = useTimelineClipDrag({
|
|
48
56
|
scrollRef: { current: scroll },
|
|
49
57
|
ppsRef: { current: 100 },
|
|
50
58
|
durationRef: { current: 100 },
|
|
51
59
|
trackOrderRef: { current: [0, 1] },
|
|
52
60
|
onResizeElement,
|
|
61
|
+
onMoveElement,
|
|
62
|
+
onBlockedEditAttempt,
|
|
53
63
|
onResizeElements: options.wireGroupResize === false ? undefined : onResizeElements,
|
|
54
64
|
setShowPopover: vi.fn(),
|
|
55
65
|
setRangeSelectionRef: { current: vi.fn() },
|
|
66
|
+
sessionEpoch,
|
|
56
67
|
});
|
|
57
68
|
setResizingClip = hook.setResizingClip;
|
|
69
|
+
setDraggedClip = hook.setDraggedClip;
|
|
70
|
+
resizingClip = hook.resizingClip;
|
|
71
|
+
blockedClipRef = hook.blockedClipRef;
|
|
58
72
|
return null;
|
|
59
73
|
}
|
|
60
74
|
|
|
61
|
-
const root = mountReactHarness(<Harness />);
|
|
75
|
+
const root = mountReactHarness(<Harness sessionEpoch={epoch} />);
|
|
62
76
|
const apply = setResizingClip!;
|
|
77
|
+
const dispatchPointer = (type: string, clientX: number, pointerId = 0) => {
|
|
78
|
+
const event = new MouseEvent(type, { bubbles: true, clientX, clientY: 0 });
|
|
79
|
+
Object.defineProperty(event, "pointerId", { value: pointerId });
|
|
80
|
+
window.dispatchEvent(event);
|
|
81
|
+
};
|
|
63
82
|
|
|
64
83
|
return {
|
|
65
84
|
onResizeElement,
|
|
85
|
+
onMoveElement,
|
|
86
|
+
onBlockedEditAttempt,
|
|
66
87
|
onResizeElements,
|
|
88
|
+
setPointerCapture,
|
|
67
89
|
storeById(id: string) {
|
|
68
90
|
return usePlayerStore.getState().elements.find((e) => e.id === id)!;
|
|
69
91
|
},
|
|
70
|
-
|
|
92
|
+
getResizeProjection() {
|
|
93
|
+
return resizingClip?.groupPreview ?? [];
|
|
94
|
+
},
|
|
95
|
+
getBlockedClip() {
|
|
96
|
+
return blockedClipRef?.current ?? null;
|
|
97
|
+
},
|
|
98
|
+
startBlocked(element: TimelineElement, pointerId = 0) {
|
|
99
|
+
blockedClipRef!.current = {
|
|
100
|
+
pointerId,
|
|
101
|
+
element,
|
|
102
|
+
intent: "move",
|
|
103
|
+
originClientX: 0,
|
|
104
|
+
originClientY: 0,
|
|
105
|
+
started: false,
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
startResize(element: TimelineElement, edge: "start" | "end", pointerId = 0) {
|
|
71
109
|
act(() => {
|
|
72
110
|
apply({
|
|
111
|
+
pointerId,
|
|
73
112
|
element,
|
|
74
113
|
edge,
|
|
75
114
|
originClientX: 0,
|
|
@@ -80,19 +119,57 @@ function renderResizeHarness(
|
|
|
80
119
|
});
|
|
81
120
|
});
|
|
82
121
|
},
|
|
83
|
-
|
|
122
|
+
startDrag(element: TimelineElement, pointerId = 0) {
|
|
123
|
+
act(() =>
|
|
124
|
+
setDraggedClip?.({
|
|
125
|
+
pointerId,
|
|
126
|
+
element,
|
|
127
|
+
originClientX: 0,
|
|
128
|
+
originClientY: 0,
|
|
129
|
+
originScrollLeft: 0,
|
|
130
|
+
originScrollTop: 0,
|
|
131
|
+
pointerClientX: 0,
|
|
132
|
+
pointerClientY: 0,
|
|
133
|
+
pointerOffsetX: 0,
|
|
134
|
+
pointerOffsetY: 0,
|
|
135
|
+
previewStart: element.start,
|
|
136
|
+
previewTrack: element.track,
|
|
137
|
+
insertRow: null,
|
|
138
|
+
snapTime: null,
|
|
139
|
+
snapType: null,
|
|
140
|
+
started: false,
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
},
|
|
144
|
+
movePointer(clientX: number, pointerId = 0) {
|
|
84
145
|
act(() => {
|
|
85
|
-
|
|
146
|
+
dispatchPointer("pointermove", clientX, pointerId);
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
async dropPointer(pointerId = 0) {
|
|
150
|
+
await act(async () => {
|
|
151
|
+
dispatchPointer("pointerup", 0, pointerId);
|
|
86
152
|
});
|
|
87
153
|
},
|
|
88
|
-
async
|
|
154
|
+
async moveAndDropPointer(clientX: number, pointerId = 0) {
|
|
89
155
|
await act(async () => {
|
|
90
|
-
|
|
156
|
+
dispatchPointer("pointermove", clientX, pointerId);
|
|
157
|
+
dispatchPointer("pointerup", clientX, pointerId);
|
|
91
158
|
});
|
|
92
159
|
},
|
|
160
|
+
cancelPointer(pointerId = 0) {
|
|
161
|
+
act(() => dispatchPointer("pointercancel", 0, pointerId));
|
|
162
|
+
},
|
|
163
|
+
losePointerCapture(pointerId = 0) {
|
|
164
|
+
act(() => dispatchPointer("lostpointercapture", 0, pointerId));
|
|
165
|
+
},
|
|
166
|
+
setSessionEpoch(next: number) {
|
|
167
|
+
epoch = next;
|
|
168
|
+
act(() => root.render(<Harness sessionEpoch={epoch} />));
|
|
169
|
+
},
|
|
93
170
|
pressEscape() {
|
|
94
171
|
act(() => {
|
|
95
|
-
|
|
172
|
+
scroll.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
96
173
|
});
|
|
97
174
|
},
|
|
98
175
|
unmount() {
|
|
@@ -161,14 +238,131 @@ describe("useTimelineClipDrag — single-clip resize (unchanged path)", () => {
|
|
|
161
238
|
});
|
|
162
239
|
});
|
|
163
240
|
|
|
241
|
+
describe("useTimelineClipDrag — gesture lifecycle", () => {
|
|
242
|
+
it("does not capture a stationary click before the drag threshold", () => {
|
|
243
|
+
const a = el("a", { duration: 2 });
|
|
244
|
+
const h = renderResizeHarness([a], []);
|
|
245
|
+
h.startDrag(a, 7);
|
|
246
|
+
expect(h.setPointerCapture).not.toHaveBeenCalled();
|
|
247
|
+
|
|
248
|
+
h.movePointer(3, 7);
|
|
249
|
+
expect(h.setPointerCapture).not.toHaveBeenCalled();
|
|
250
|
+
|
|
251
|
+
h.movePointer(5, 7);
|
|
252
|
+
expect(h.setPointerCapture).toHaveBeenCalledOnce();
|
|
253
|
+
expect(h.setPointerCapture).toHaveBeenCalledWith(7);
|
|
254
|
+
h.unmount();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("commits the final drag position exactly once", async () => {
|
|
258
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
259
|
+
const a = el("a", { duration: 2 });
|
|
260
|
+
const h = renderResizeHarness([a], []);
|
|
261
|
+
h.startDrag(a, 7);
|
|
262
|
+
await h.moveAndDropPointer(50, 7);
|
|
263
|
+
await h.dropPointer(7);
|
|
264
|
+
expect(h.onMoveElement).toHaveBeenCalledTimes(1);
|
|
265
|
+
expect(h.onMoveElement).toHaveBeenCalledWith(a, expect.objectContaining({ start: 0.5 }));
|
|
266
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
267
|
+
expect.stringContaining("only single-clip onMoveElement wired"),
|
|
268
|
+
);
|
|
269
|
+
warnSpy.mockRestore();
|
|
270
|
+
h.unmount();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("ignores a foreign pointer cancellation during a blocked gesture", async () => {
|
|
274
|
+
const a = el("a", { duration: 2 });
|
|
275
|
+
const h = renderResizeHarness([a], []);
|
|
276
|
+
h.startBlocked(a, 7);
|
|
277
|
+
h.movePointer(5, 7);
|
|
278
|
+
expect(h.onBlockedEditAttempt).toHaveBeenCalledOnce();
|
|
279
|
+
|
|
280
|
+
h.cancelPointer(8);
|
|
281
|
+
expect(h.getBlockedClip()?.pointerId).toBe(7);
|
|
282
|
+
|
|
283
|
+
await h.dropPointer(7);
|
|
284
|
+
expect(h.getBlockedClip()).toBeNull();
|
|
285
|
+
h.unmount();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("commits the final same-turn pointer move exactly once", async () => {
|
|
289
|
+
const a = el("a", { duration: 2 });
|
|
290
|
+
const h = renderResizeHarness([a], []);
|
|
291
|
+
h.startResize(a, "end", 7);
|
|
292
|
+
await h.moveAndDropPointer(75, 7);
|
|
293
|
+
await h.dropPointer(7);
|
|
294
|
+
expect(h.onResizeElement).toHaveBeenCalledTimes(1);
|
|
295
|
+
expect(h.onResizeElement).toHaveBeenCalledWith(a, expect.objectContaining({ duration: 2.75 }));
|
|
296
|
+
h.unmount();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("ignores another pointer and cancels without committing", async () => {
|
|
300
|
+
const a = el("a", { duration: 2 });
|
|
301
|
+
const h = renderResizeHarness([a], []);
|
|
302
|
+
h.startResize(a, "end", 7);
|
|
303
|
+
h.movePointer(50, 8);
|
|
304
|
+
await h.dropPointer(8);
|
|
305
|
+
expect(h.onResizeElement).not.toHaveBeenCalled();
|
|
306
|
+
h.cancelPointer(7);
|
|
307
|
+
await h.dropPointer(7);
|
|
308
|
+
expect(h.onResizeElement).not.toHaveBeenCalled();
|
|
309
|
+
expect(h.storeById("a").duration).toBe(2);
|
|
310
|
+
h.unmount();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("cancels an active projection when the project epoch changes", () => {
|
|
314
|
+
const a = el("a", { duration: 2 });
|
|
315
|
+
const h = renderResizeHarness([a], []);
|
|
316
|
+
h.startResize(a, "end", 7);
|
|
317
|
+
h.movePointer(50, 7);
|
|
318
|
+
h.setSessionEpoch(1);
|
|
319
|
+
expect(h.getResizeProjection()).toHaveLength(0);
|
|
320
|
+
expect(h.onResizeElement).not.toHaveBeenCalled();
|
|
321
|
+
expect(h.storeById("a").duration).toBe(2);
|
|
322
|
+
h.unmount();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("does not commit a stale clip deleted during the gesture", async () => {
|
|
326
|
+
const a = el("a", { duration: 2 });
|
|
327
|
+
const h = renderResizeHarness([a], []);
|
|
328
|
+
h.startResize(a, "end", 7);
|
|
329
|
+
h.movePointer(50, 7);
|
|
330
|
+
act(() => usePlayerStore.getState().setElements([]));
|
|
331
|
+
await h.dropPointer(7);
|
|
332
|
+
expect(h.onResizeElement).not.toHaveBeenCalled();
|
|
333
|
+
expect(usePlayerStore.getState().elements).toEqual([]);
|
|
334
|
+
h.unmount();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("treats lost capture and unmount as cancellation", async () => {
|
|
338
|
+
const a = el("a", { duration: 2 });
|
|
339
|
+
const lost = renderResizeHarness([a], []);
|
|
340
|
+
lost.startResize(a, "end", 7);
|
|
341
|
+
lost.movePointer(50, 7);
|
|
342
|
+
lost.losePointerCapture(7);
|
|
343
|
+
await lost.dropPointer(7);
|
|
344
|
+
expect(lost.onResizeElement).not.toHaveBeenCalled();
|
|
345
|
+
lost.unmount();
|
|
346
|
+
|
|
347
|
+
const unmounted = renderResizeHarness([a], []);
|
|
348
|
+
unmounted.startResize(a, "end", 9);
|
|
349
|
+
unmounted.movePointer(50, 9);
|
|
350
|
+
unmounted.unmount();
|
|
351
|
+
expect(unmounted.onResizeElement).not.toHaveBeenCalled();
|
|
352
|
+
expect(usePlayerStore.getState().elements[0]?.duration).toBe(2);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
164
356
|
describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
|
|
165
|
-
it("previews
|
|
357
|
+
it("previews every member without mutating canonical elements", () => {
|
|
166
358
|
const { h } = startGroupResize(); // grabbed asks +0.5
|
|
167
359
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
360
|
+
expect(h.getResizeProjection()).toEqual([
|
|
361
|
+
expect.objectContaining({ key: "a", duration: 2.5 }),
|
|
362
|
+
expect.objectContaining({ key: "b", duration: 3.5 }),
|
|
363
|
+
]);
|
|
171
364
|
expect(h.storeById("a").duration).toBe(2);
|
|
365
|
+
expect(h.storeById("b").duration).toBe(3);
|
|
172
366
|
h.unmount();
|
|
173
367
|
});
|
|
174
368
|
|
|
@@ -186,6 +380,7 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
|
|
|
186
380
|
{ coalesceKey: expect.stringMatching(/^clip-group-resize:/) },
|
|
187
381
|
);
|
|
188
382
|
expect(h.storeById("a").duration).toBe(2.5);
|
|
383
|
+
expect(h.getResizeProjection()).toHaveLength(0);
|
|
189
384
|
expect(h.storeById("b").duration).toBe(3.5);
|
|
190
385
|
h.unmount();
|
|
191
386
|
});
|
|
@@ -213,7 +408,10 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
|
|
|
213
408
|
});
|
|
214
409
|
h.startResize(a, "end");
|
|
215
410
|
h.movePointer(50);
|
|
216
|
-
expect(h.
|
|
411
|
+
expect(h.getResizeProjection()).toEqual(
|
|
412
|
+
expect.arrayContaining([expect.objectContaining({ key: "b", duration: 3.5 })]),
|
|
413
|
+
);
|
|
414
|
+
expect(h.storeById("b").duration).toBe(3);
|
|
217
415
|
|
|
218
416
|
await h.dropPointer();
|
|
219
417
|
expect(h.storeById("a").duration).toBe(2);
|
|
@@ -229,12 +427,16 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
|
|
|
229
427
|
h.unmount();
|
|
230
428
|
});
|
|
231
429
|
|
|
232
|
-
it("Escape
|
|
430
|
+
it("Escape discards the projection and persists nothing", () => {
|
|
233
431
|
const { h } = startGroupResize();
|
|
234
|
-
expect(h.
|
|
432
|
+
expect(h.getResizeProjection()).toHaveLength(2);
|
|
433
|
+
const sawEscape = vi.fn();
|
|
434
|
+
document.addEventListener("keydown", sawEscape, { once: true });
|
|
235
435
|
|
|
236
436
|
h.pressEscape();
|
|
237
|
-
expect(
|
|
437
|
+
expect(sawEscape).toHaveBeenCalledOnce();
|
|
438
|
+
expect(h.getResizeProjection()).toHaveLength(0);
|
|
439
|
+
expect(h.storeById("b").duration).toBe(3);
|
|
238
440
|
expect(h.onResizeElement).not.toHaveBeenCalled();
|
|
239
441
|
h.unmount();
|
|
240
442
|
});
|