@hyperframes/studio 0.7.90 → 0.7.93
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-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +40 -36
- package/dist/index.html +2 -2
- package/dist/index.js +9598 -6798
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- 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/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- 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/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +10 -4
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- 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/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- 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/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +26 -8
- package/src/player/components/Timeline.tsx +87 -88
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +20 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
- package/src/player/components/TimelineClipDiamonds.tsx +118 -223
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +64 -7
- package/src/player/components/TimelineLanes.tsx +84 -66
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- 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 +18 -21
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- 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 +8 -39
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- 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/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -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 +26 -29
- 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/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +106 -23
- package/src/player/store/playerStore.ts +43 -40
- package/src/player/store/timelineFocusState.ts +15 -0
- 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/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-Cql15Yur.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -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
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef, useState, useCallback, useMemo } from "react";
|
|
1
|
+
import { useRef, useState, useCallback, useMemo, useEffect } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import {
|
|
4
4
|
applyTimelineAutoScrollStep,
|
|
@@ -25,9 +25,13 @@ import type {
|
|
|
25
25
|
ResizingClipState,
|
|
26
26
|
BlockedClipState,
|
|
27
27
|
} from "./timelineClipDragTypes";
|
|
28
|
-
import { mountTimelineClipDragGestureLifecycle } from "./timelineClipDragGestureLifecycle";
|
|
29
28
|
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
|
30
29
|
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
30
|
+
import {
|
|
31
|
+
mountTimelineClipDragGestureLifecycle,
|
|
32
|
+
type TimelineGestureKind,
|
|
33
|
+
type TimelineGestureLifecycle,
|
|
34
|
+
} from "./timelineClipDragGestureLifecycle";
|
|
31
35
|
|
|
32
36
|
export type {
|
|
33
37
|
DraggedClipState,
|
|
@@ -74,6 +78,7 @@ interface UseTimelineClipDragInput {
|
|
|
74
78
|
readZIndex?: (element: TimelineElement) => number;
|
|
75
79
|
onStackingPatches?: (patches: StackingPatch[]) => Promise<unknown> | void;
|
|
76
80
|
refreshAfterLaneMove?: () => void;
|
|
81
|
+
sessionEpoch?: number;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
export function useTimelineClipDrag({
|
|
@@ -92,6 +97,7 @@ export function useTimelineClipDrag({
|
|
|
92
97
|
readZIndex,
|
|
93
98
|
onStackingPatches,
|
|
94
99
|
refreshAfterLaneMove,
|
|
100
|
+
sessionEpoch = 0,
|
|
95
101
|
}: UseTimelineClipDragInput) {
|
|
96
102
|
const updateElement = usePlayerStore((s) => s.updateElement);
|
|
97
103
|
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
|
|
@@ -162,41 +168,72 @@ export function useTimelineClipDrag({
|
|
|
162
168
|
[],
|
|
163
169
|
);
|
|
164
170
|
|
|
165
|
-
const [draggedClip,
|
|
171
|
+
const [draggedClip, setDraggedClipState] = useState<DraggedClipState | null>(null);
|
|
166
172
|
const draggedClipRef = useRef<DraggedClipState | null>(null);
|
|
167
|
-
|
|
173
|
+
const publishDraggedClip = useCallback((next: DraggedClipState | null) => {
|
|
174
|
+
draggedClipRef.current = next;
|
|
175
|
+
setDraggedClipState(next);
|
|
176
|
+
}, []);
|
|
168
177
|
|
|
169
|
-
const [resizingClip,
|
|
178
|
+
const [resizingClip, setResizingClipState] = useState<ResizingClipState | null>(null);
|
|
170
179
|
const resizingClipRef = useRef<ResizingClipState | null>(null);
|
|
171
|
-
|
|
180
|
+
const publishResizingClip = useCallback((next: ResizingClipState | null) => {
|
|
181
|
+
resizingClipRef.current = next;
|
|
182
|
+
setResizingClipState(next);
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
const lifecycleRef = useRef<TimelineGestureLifecycle>({
|
|
186
|
+
kind: null,
|
|
187
|
+
phase: "complete",
|
|
188
|
+
pointerId: null,
|
|
189
|
+
sessionEpoch,
|
|
190
|
+
});
|
|
191
|
+
const sessionEpochRef = useRef(sessionEpoch);
|
|
192
|
+
sessionEpochRef.current = sessionEpoch;
|
|
193
|
+
const gestureSelectedKeysRef = useRef<ReadonlySet<string>>(new Set());
|
|
194
|
+
const cancelGestureRef = useRef<
|
|
195
|
+
(options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean
|
|
196
|
+
>(() => false);
|
|
197
|
+
const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number) => {
|
|
198
|
+
if (lifecycleRef.current.phase === "active") cancelGestureRef.current();
|
|
199
|
+
lifecycleRef.current = {
|
|
200
|
+
kind,
|
|
201
|
+
phase: "active",
|
|
202
|
+
pointerId,
|
|
203
|
+
sessionEpoch: sessionEpochRef.current,
|
|
204
|
+
};
|
|
205
|
+
gestureSelectedKeysRef.current = new Set(usePlayerStore.getState().selectedElementIds);
|
|
206
|
+
}, []);
|
|
207
|
+
const setDraggedClip = useCallback(
|
|
208
|
+
(next: DraggedClipState | null) => {
|
|
209
|
+
if (!next) {
|
|
210
|
+
cancelGestureRef.current();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
beginGesture("drag", next.pointerId);
|
|
214
|
+
publishDraggedClip(next);
|
|
215
|
+
},
|
|
216
|
+
[beginGesture, publishDraggedClip],
|
|
217
|
+
);
|
|
218
|
+
const setResizingClip = useCallback(
|
|
219
|
+
(next: ResizingClipState | null) => {
|
|
220
|
+
if (!next) {
|
|
221
|
+
cancelGestureRef.current();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
beginGesture("resize", next.pointerId);
|
|
225
|
+
publishResizingClip(next);
|
|
226
|
+
},
|
|
227
|
+
[beginGesture, publishResizingClip],
|
|
228
|
+
);
|
|
172
229
|
|
|
173
230
|
const blockedClipRef = useRef<BlockedClipState | null>(null);
|
|
174
231
|
const suppressClickRef = useRef(false);
|
|
175
232
|
|
|
176
|
-
// Active multi-select group-resize session
|
|
177
|
-
//
|
|
178
|
-
// capability-clean multi-selection (null ⇒ single-clip resize). Holds the
|
|
179
|
-
// pre-gesture snapshot so the non-grabbed members (previewed through the store)
|
|
180
|
-
// roll back on escape / cancel / failed persist.
|
|
233
|
+
// Active multi-select group-resize session, created lazily on first movement.
|
|
234
|
+
// It owns a projection only; canonical store timing changes at commit.
|
|
181
235
|
const groupResizeRef = useRef<TimelineGroupResizeSession | null>(null);
|
|
182
236
|
|
|
183
|
-
// Restore the non-grabbed group members to their pre-gesture timing (the
|
|
184
|
-
// grabbed clip renders from resizingClip state, so it is never written during
|
|
185
|
-
// preview). `all` also restores the grabbed clip after a committed persist fails.
|
|
186
|
-
const restoreGroupResizeMembers = useCallback(
|
|
187
|
-
(session: TimelineGroupResizeSession, all = false) => {
|
|
188
|
-
for (const m of session.members) {
|
|
189
|
-
if (!all && m.key === session.grabbedKey) continue;
|
|
190
|
-
updateElement(m.key, {
|
|
191
|
-
start: m.start,
|
|
192
|
-
duration: m.duration,
|
|
193
|
-
playbackStart: m.playbackStart,
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
},
|
|
197
|
-
[updateElement],
|
|
198
|
-
);
|
|
199
|
-
|
|
200
237
|
const onMoveElementRef = useRef(onMoveElement);
|
|
201
238
|
onMoveElementRef.current = onMoveElement;
|
|
202
239
|
const onMoveElementsRef = useRef(onMoveElements);
|
|
@@ -237,7 +274,7 @@ export function useTimelineClipDrag({
|
|
|
237
274
|
trackOrder: trackOrderRef.current,
|
|
238
275
|
rowHeights: rowGeometryRef?.current.rowHeights,
|
|
239
276
|
elements: elementsRef.current,
|
|
240
|
-
selectedKeys:
|
|
277
|
+
selectedKeys: gestureSelectedKeysRef.current,
|
|
241
278
|
buildSnapTargets,
|
|
242
279
|
audioTracks: dragAudioTracksRef.current,
|
|
243
280
|
});
|
|
@@ -256,18 +293,19 @@ export function useTimelineClipDrag({
|
|
|
256
293
|
buildSnapTargets,
|
|
257
294
|
});
|
|
258
295
|
const setResizeState = (v: ResizePreviewResult) =>
|
|
259
|
-
|
|
296
|
+
publishResizingClip(
|
|
297
|
+
resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null,
|
|
298
|
+
);
|
|
260
299
|
|
|
261
300
|
// Group resize: a capability-clean multi-selection resizes rigidly by one
|
|
262
301
|
// shared, member-clamped delta (legacy main 36413da7f). The grabbed clip
|
|
263
|
-
// drives the raw delta
|
|
264
|
-
// members preview through the store (their store value stays pristine).
|
|
302
|
+
// drives the raw delta; every member renders from the coordinator projection.
|
|
265
303
|
const grabbedKey = resize.element.key ?? resize.element.id;
|
|
266
304
|
let session = groupResizeRef.current;
|
|
267
305
|
if (!session || session.grabbedKey !== grabbedKey || session.edge !== resize.edge) {
|
|
268
306
|
const members = buildTimelineGroupResizeMembers(
|
|
269
307
|
elementsRef.current,
|
|
270
|
-
|
|
308
|
+
gestureSelectedKeysRef.current,
|
|
271
309
|
grabbedKey,
|
|
272
310
|
resize.edge,
|
|
273
311
|
);
|
|
@@ -287,9 +325,9 @@ export function useTimelineClipDrag({
|
|
|
287
325
|
setResizeState(next);
|
|
288
326
|
return;
|
|
289
327
|
}
|
|
290
|
-
previewGroupResize(session, next,
|
|
328
|
+
previewGroupResize(session, next, setResizeState);
|
|
291
329
|
},
|
|
292
|
-
[scrollRef, ppsRef, buildSnapTargets,
|
|
330
|
+
[scrollRef, ppsRef, buildSnapTargets, publishResizingClip],
|
|
293
331
|
);
|
|
294
332
|
const applyResizePointerRef = useRef(applyResizePointer);
|
|
295
333
|
applyResizePointerRef.current = applyResizePointer;
|
|
@@ -300,9 +338,7 @@ export function useTimelineClipDrag({
|
|
|
300
338
|
cancelAnimationFrame(clipDragScrollRaf.current);
|
|
301
339
|
clipDragScrollRaf.current = 0;
|
|
302
340
|
}
|
|
303
|
-
// Gesture teardown: drop
|
|
304
|
-
// rebuilds them against fresh store state (see snapTargetsCacheRef). Does NOT
|
|
305
|
-
// touch groupResizeRef — commit reads it after this runs.
|
|
341
|
+
// Gesture teardown: drop frozen caches so the next gesture reads fresh state.
|
|
306
342
|
snapTargetsCacheRef.current.clear();
|
|
307
343
|
dragAudioTracksRef.current = null;
|
|
308
344
|
}, []);
|
|
@@ -317,16 +353,14 @@ export function useTimelineClipDrag({
|
|
|
317
353
|
if (!applyTimelineAutoScrollStep(scroll, pointer.clientX, pointer.clientY)) return;
|
|
318
354
|
|
|
319
355
|
if (drag) {
|
|
320
|
-
|
|
321
|
-
prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev,
|
|
322
|
-
);
|
|
356
|
+
publishDraggedClip(updateDraggedClipPreview(drag, pointer.clientX, pointer.clientY));
|
|
323
357
|
} else if (resize) {
|
|
324
358
|
// Re-run the trim preview so the edge keeps tracking while the content
|
|
325
359
|
// scrolls under the stationary pointer (scroll-compensated pointer x).
|
|
326
360
|
applyResizePointerRef.current(resize, pointer.clientX);
|
|
327
361
|
}
|
|
328
362
|
clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
|
|
329
|
-
}, [scrollRef, updateDraggedClipPreview]);
|
|
363
|
+
}, [publishDraggedClip, scrollRef, updateDraggedClipPreview]);
|
|
330
364
|
|
|
331
365
|
const syncClipDragAutoScroll = useCallback(
|
|
332
366
|
(clientX: number, clientY: number) => {
|
|
@@ -354,35 +388,44 @@ export function useTimelineClipDrag({
|
|
|
354
388
|
const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
|
|
355
389
|
stopClipDragAutoScrollRef.current = stopClipDragAutoScroll;
|
|
356
390
|
|
|
357
|
-
useMountEffect(() =>
|
|
358
|
-
|
|
359
|
-
draggedClipRef,
|
|
360
|
-
resizingClipRef,
|
|
361
|
-
blockedClipRef,
|
|
362
|
-
groupResizeRef,
|
|
363
|
-
suppressClickRef,
|
|
364
|
-
elementsRef,
|
|
365
|
-
trackOrderRef,
|
|
366
|
-
setDraggedClip,
|
|
367
|
-
setResizingClip,
|
|
368
|
-
setShowPopover,
|
|
369
|
-
setRangeSelectionRef,
|
|
370
|
-
applyResizePointerRef,
|
|
371
|
-
syncClipDragAutoScrollRef,
|
|
372
|
-
stopClipDragAutoScrollRef,
|
|
373
|
-
updateDraggedClipPreviewRef,
|
|
374
|
-
restoreGroupResizeMembers,
|
|
375
|
-
updateElement,
|
|
376
|
-
onMoveElementRef,
|
|
377
|
-
onMoveElementsRef,
|
|
378
|
-
onResizeElementRef,
|
|
379
|
-
onResizeElementsRef,
|
|
380
|
-
onBlockedEditAttemptRef,
|
|
381
|
-
readZIndexRef,
|
|
391
|
+
useMountEffect(() =>
|
|
392
|
+
mountTimelineClipDragGestureLifecycle({
|
|
382
393
|
onStackingPatchesRef,
|
|
383
394
|
refreshAfterLaneMoveRef,
|
|
384
|
-
|
|
385
|
-
|
|
395
|
+
readZIndexRef,
|
|
396
|
+
onBlockedEditAttemptRef,
|
|
397
|
+
onResizeElementsRef,
|
|
398
|
+
onResizeElementRef,
|
|
399
|
+
onMoveElementsRef,
|
|
400
|
+
onMoveElementRef,
|
|
401
|
+
updateElement,
|
|
402
|
+
publishDraggedClip,
|
|
403
|
+
updateDraggedClipPreviewRef,
|
|
404
|
+
stopClipDragAutoScrollRef,
|
|
405
|
+
syncClipDragAutoScrollRef,
|
|
406
|
+
applyResizePointerRef,
|
|
407
|
+
setRangeSelectionRef,
|
|
408
|
+
setShowPopover,
|
|
409
|
+
setResizingClipState,
|
|
410
|
+
setDraggedClipState,
|
|
411
|
+
trackOrderRef,
|
|
412
|
+
elementsRef,
|
|
413
|
+
gestureSelectedKeysRef,
|
|
414
|
+
suppressClickRef,
|
|
415
|
+
groupResizeRef,
|
|
416
|
+
blockedClipRef,
|
|
417
|
+
resizingClipRef,
|
|
418
|
+
draggedClipRef,
|
|
419
|
+
scrollRef,
|
|
420
|
+
cancelGestureRef,
|
|
421
|
+
sessionEpochRef,
|
|
422
|
+
lifecycleRef,
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
cancelGestureRef.current();
|
|
428
|
+
}, [sessionEpoch]);
|
|
386
429
|
|
|
387
430
|
return {
|
|
388
431
|
draggedClip,
|
|
@@ -391,7 +434,6 @@ export function useTimelineClipDrag({
|
|
|
391
434
|
setResizingClip,
|
|
392
435
|
blockedClipRef,
|
|
393
436
|
suppressClickRef,
|
|
394
|
-
syncClipDragAutoScroll,
|
|
395
437
|
stopClipDragAutoScroll,
|
|
396
438
|
};
|
|
397
439
|
}
|