@hyperframes/studio 0.7.45 → 0.7.47
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/index-Dq7FEg0K.css +1 -0
- package/dist/assets/{index-CZkdm5YL.js → index-DsckwbdW.js} +1 -1
- package/dist/assets/index-VsAbY3rl.js +423 -0
- package/dist/assets/{index-CbQKp0Ek.js → index-uvB30_yI.js} +1 -1
- package/dist/index.d.ts +96 -29
- package/dist/index.html +2 -2
- package/dist/index.js +8443 -4305
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -32
- package/src/components/DesignPanelPromoteProvider.tsx +45 -0
- package/src/components/PanelTabButton.tsx +31 -0
- package/src/components/StudioPreviewArea.tsx +75 -3
- package/src/components/StudioRightPanel.tsx +111 -113
- package/src/components/editor/LayersPanel.tsx +2 -22
- package/src/components/editor/PromotableControl.tsx +105 -0
- package/src/components/editor/propertyPanelSections.tsx +42 -21
- package/src/components/panels/VariablesBindElement.tsx +266 -0
- package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
- package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
- package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
- package/src/components/panels/VariablesPanel.tsx +554 -0
- package/src/components/panels/VariablesRowAction.tsx +25 -0
- package/src/components/panels/VariablesValueControls.tsx +215 -0
- package/src/components/renders/useRenderQueue.ts +12 -0
- package/src/contexts/TimelineEditContext.tsx +7 -2
- package/src/contexts/VariablePromoteContext.tsx +138 -0
- package/src/contexts/variablePromoteHelpers.test.ts +124 -0
- package/src/contexts/variablePromoteHelpers.ts +53 -0
- package/src/contexts/variablePromoteIntegration.test.ts +80 -0
- package/src/hooks/previewVariablesStore.ts +34 -0
- package/src/hooks/timelineEditingHelpers.test.ts +104 -0
- package/src/hooks/timelineEditingHelpers.ts +386 -10
- package/src/hooks/useDomEditWiring.ts +9 -7
- package/src/hooks/useDomSelection.test.ts +36 -2
- package/src/hooks/useDomSelection.ts +56 -75
- package/src/hooks/useElementLifecycleOps.ts +57 -17
- package/src/hooks/usePreviewDocumentVersion.ts +27 -0
- package/src/hooks/useProjectCompositionVariables.ts +131 -0
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useStudioSdkSessions.ts +37 -0
- package/src/hooks/useTimelineEditing.test.tsx +963 -0
- package/src/hooks/useTimelineEditing.ts +122 -100
- package/src/hooks/useTimelineEditingTypes.ts +15 -0
- package/src/hooks/useTimelineGroupEditing.ts +370 -0
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
- package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
- package/src/hooks/useVariablesPersist.ts +61 -0
- package/src/player/components/BeatStrip.tsx +19 -3
- package/src/player/components/Player.tsx +7 -1
- package/src/player/components/Timeline.test.ts +38 -0
- package/src/player/components/Timeline.tsx +102 -80
- package/src/player/components/TimelineCanvas.tsx +340 -296
- package/src/player/components/TimelineClip.test.tsx +11 -0
- package/src/player/components/TimelineClipDiamonds.tsx +40 -9
- package/src/player/components/TimelineDragGhost.tsx +59 -0
- package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
- package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
- package/src/player/components/TimelineLayerGutter.tsx +61 -0
- package/src/player/components/TimelineRuler.tsx +4 -2
- package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
- package/src/player/components/timelineCallbacks.ts +20 -2
- package/src/player/components/timelineClipDragPreview.ts +52 -0
- package/src/player/components/timelineDragDrop.ts +19 -3
- package/src/player/components/timelineDropIndicator.test.ts +48 -0
- package/src/player/components/timelineDropIndicator.ts +33 -0
- package/src/player/components/timelineEditing.test.ts +251 -1
- package/src/player/components/timelineEditing.ts +178 -19
- package/src/player/components/timelineGroupEditing.ts +159 -0
- package/src/player/components/timelineLayerDrag.test.ts +200 -0
- package/src/player/components/timelineLayerDrag.ts +383 -0
- package/src/player/components/timelineLayout.test.ts +34 -0
- package/src/player/components/timelineLayout.ts +47 -0
- package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
- package/src/player/components/timelineSnapTargets.test.ts +144 -0
- package/src/player/components/timelineSnapTargets.ts +164 -0
- package/src/player/components/timelineStacking.ts +62 -0
- package/src/player/components/timelineTrackOrder.test.ts +144 -0
- package/src/player/components/timelineTrackOrder.ts +173 -0
- package/src/player/components/useTimelineActiveClips.test.ts +20 -0
- package/src/player/components/useTimelineActiveClips.ts +15 -4
- package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
- package/src/player/components/useTimelineClipDrag.ts +172 -126
- package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
- package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
- package/src/player/hooks/useTimelinePlayer.ts +7 -0
- package/src/player/lib/layerOrdering.test.ts +77 -0
- package/src/player/lib/layerOrdering.ts +98 -0
- package/src/player/lib/playbackTypes.ts +3 -0
- package/src/player/lib/timelineDOM.test.ts +103 -0
- package/src/player/lib/timelineDOM.ts +80 -0
- package/src/player/lib/timelineElementHelpers.ts +1 -1
- package/src/player/store/playerStore.test.ts +92 -0
- package/src/player/store/playerStore.ts +69 -9
- package/src/utils/blockInstaller.ts +2 -14
- package/src/utils/editHistory.test.ts +61 -0
- package/src/utils/editHistory.ts +7 -1
- package/src/utils/rootDuration.test.ts +34 -0
- package/src/utils/rootDuration.ts +17 -0
- package/src/utils/sdkCutover.ts +45 -0
- package/src/utils/studioHelpers.test.ts +31 -0
- package/src/utils/studioHelpers.ts +29 -1
- package/src/utils/studioUrlState.test.ts +29 -0
- package/src/utils/studioUrlState.ts +16 -1
- package/dist/assets/index-CZUbpYhQ.js +0 -416
- package/dist/assets/index-DoVLXke0.css +0 -1
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { useCallback, useRef, type RefObject } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
TimelineGroupMoveChange,
|
|
4
|
+
TimelineGroupResizeChange,
|
|
5
|
+
} from "../../hooks/useTimelineGroupEditing";
|
|
6
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
7
|
+
import {
|
|
8
|
+
getTimelineEditCapabilities,
|
|
9
|
+
resolveTimelineGroupMove,
|
|
10
|
+
resolveTimelineGroupResize,
|
|
11
|
+
type TimelineGroupResizeEdge,
|
|
12
|
+
type TimelineGroupTimingMember,
|
|
13
|
+
} from "./timelineEditing";
|
|
14
|
+
|
|
15
|
+
type TimelineResizeUpdates = Pick<TimelineElement, "start" | "duration" | "playbackStart">;
|
|
16
|
+
|
|
17
|
+
type UpdateTimelineElement = (
|
|
18
|
+
elementId: string,
|
|
19
|
+
updates: Partial<Pick<TimelineElement, "start" | "duration" | "playbackStart">>,
|
|
20
|
+
) => void;
|
|
21
|
+
|
|
22
|
+
interface GroupTimingMember extends TimelineGroupTimingMember {
|
|
23
|
+
element: TimelineElement;
|
|
24
|
+
key: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface MoveSession {
|
|
28
|
+
grabbedKey: string;
|
|
29
|
+
members: GroupTimingMember[];
|
|
30
|
+
changes: TimelineGroupMoveChange[];
|
|
31
|
+
hasChanged: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ResizeSession {
|
|
35
|
+
grabbedKey: string;
|
|
36
|
+
edge: TimelineGroupResizeEdge;
|
|
37
|
+
members: GroupTimingMember[];
|
|
38
|
+
changes: TimelineGroupResizeChange[];
|
|
39
|
+
hasChanged: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface UseTimelineClipGroupDragInput {
|
|
43
|
+
timelineElementsRef: RefObject<TimelineElement[]>;
|
|
44
|
+
updateElement: UpdateTimelineElement;
|
|
45
|
+
onMoveElementsRef: RefObject<
|
|
46
|
+
((changes: TimelineGroupMoveChange[]) => Promise<void> | void) | undefined
|
|
47
|
+
>;
|
|
48
|
+
onResizeElementsRef: RefObject<
|
|
49
|
+
((changes: TimelineGroupResizeChange[]) => Promise<void> | void) | undefined
|
|
50
|
+
>;
|
|
51
|
+
onPreviewMoveElementsRef: RefObject<((changes: TimelineGroupMoveChange[]) => void) | undefined>;
|
|
52
|
+
onPreviewResizeElementsRef: RefObject<
|
|
53
|
+
((changes: TimelineGroupResizeChange[]) => void) | undefined
|
|
54
|
+
>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface PreviewGroupMoveResult {
|
|
58
|
+
active: boolean;
|
|
59
|
+
previewStart: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface PreviewGroupResizeResult {
|
|
63
|
+
active: boolean;
|
|
64
|
+
updates: TimelineResizeUpdates;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function elementKey(element: TimelineElement): string {
|
|
68
|
+
return element.key ?? element.id;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isMediaElement(element: TimelineElement): boolean {
|
|
72
|
+
const normalizedTag = element.tag.toLowerCase();
|
|
73
|
+
return normalizedTag === "audio" || normalizedTag === "video";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function selectedElementSet(selectedElementIdsInput: Set<string>): Set<string> {
|
|
77
|
+
return selectedElementIdsInput instanceof Set ? selectedElementIdsInput : new Set<string>();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function selectedMembers(
|
|
81
|
+
grabbedElement: TimelineElement,
|
|
82
|
+
selectedElementIdsInput: Set<string>,
|
|
83
|
+
timelineElements: readonly TimelineElement[],
|
|
84
|
+
mapMember: (element: TimelineElement) => GroupTimingMember,
|
|
85
|
+
canEdit: (element: TimelineElement) => boolean,
|
|
86
|
+
): GroupTimingMember[] | null {
|
|
87
|
+
const selectedElementIds = selectedElementSet(selectedElementIdsInput);
|
|
88
|
+
const grabbedKey = elementKey(grabbedElement);
|
|
89
|
+
if (selectedElementIds.size <= 1 || !selectedElementIds.has(grabbedKey)) return null;
|
|
90
|
+
|
|
91
|
+
const elements = timelineElements.filter((element) =>
|
|
92
|
+
selectedElementIds.has(elementKey(element)),
|
|
93
|
+
);
|
|
94
|
+
// A group edit must not touch a member that individually forbids this operation
|
|
95
|
+
// (e.g. a locked or implicitly-timed clip). If any member can't take it, don't form
|
|
96
|
+
// a group; the gesture degrades to a normal single-clip edit of the grabbed clip.
|
|
97
|
+
if (!elements.every(canEdit)) return null;
|
|
98
|
+
const members = elements.map(mapMember);
|
|
99
|
+
return members.length > 1 ? members : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function moveMember(element: TimelineElement): GroupTimingMember {
|
|
103
|
+
return {
|
|
104
|
+
element,
|
|
105
|
+
key: elementKey(element),
|
|
106
|
+
start: element.start,
|
|
107
|
+
duration: element.duration,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resizeMember(edge: TimelineGroupResizeEdge, element: TimelineElement): GroupTimingMember {
|
|
112
|
+
const shouldSeedPlaybackStart = edge === "start" && isMediaElement(element);
|
|
113
|
+
return {
|
|
114
|
+
element,
|
|
115
|
+
key: elementKey(element),
|
|
116
|
+
start: element.start,
|
|
117
|
+
duration: element.duration,
|
|
118
|
+
playbackStart: shouldSeedPlaybackStart ? (element.playbackStart ?? 0) : element.playbackStart,
|
|
119
|
+
playbackRate: element.playbackRate,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sameGesture(sessionKey: string, element: TimelineElement): boolean {
|
|
124
|
+
return sessionKey === elementKey(element);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createMoveSession(
|
|
128
|
+
element: TimelineElement,
|
|
129
|
+
selectedElementIds: Set<string>,
|
|
130
|
+
timelineElements: readonly TimelineElement[],
|
|
131
|
+
): MoveSession | null {
|
|
132
|
+
const members = selectedMembers(
|
|
133
|
+
element,
|
|
134
|
+
selectedElementIds,
|
|
135
|
+
timelineElements,
|
|
136
|
+
moveMember,
|
|
137
|
+
(candidate) => getTimelineEditCapabilities(candidate).canMove,
|
|
138
|
+
);
|
|
139
|
+
if (!members) return null;
|
|
140
|
+
return {
|
|
141
|
+
grabbedKey: elementKey(element),
|
|
142
|
+
members,
|
|
143
|
+
changes: [],
|
|
144
|
+
hasChanged: false,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createResizeSession(
|
|
149
|
+
element: TimelineElement,
|
|
150
|
+
selectedElementIds: Set<string>,
|
|
151
|
+
timelineElements: readonly TimelineElement[],
|
|
152
|
+
edge: TimelineGroupResizeEdge,
|
|
153
|
+
): ResizeSession | null {
|
|
154
|
+
const members = selectedMembers(
|
|
155
|
+
element,
|
|
156
|
+
selectedElementIds,
|
|
157
|
+
timelineElements,
|
|
158
|
+
(member) => resizeMember(edge, member),
|
|
159
|
+
(candidate) => {
|
|
160
|
+
const caps = getTimelineEditCapabilities(candidate);
|
|
161
|
+
return edge === "start" ? caps.canTrimStart : caps.canTrimEnd;
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
if (!members) return null;
|
|
165
|
+
return {
|
|
166
|
+
grabbedKey: elementKey(element),
|
|
167
|
+
edge,
|
|
168
|
+
members,
|
|
169
|
+
changes: [],
|
|
170
|
+
hasChanged: false,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function resolveMoveChanges(
|
|
175
|
+
session: MoveSession,
|
|
176
|
+
previewStart: number,
|
|
177
|
+
): TimelineGroupMoveChange[] | null {
|
|
178
|
+
const grabbed = session.members.find((member) => member.key === session.grabbedKey);
|
|
179
|
+
if (!grabbed) return null;
|
|
180
|
+
const result = resolveTimelineGroupMove(session.members, previewStart - grabbed.start);
|
|
181
|
+
return result.members.map((member, index) => ({
|
|
182
|
+
element: session.members[index]!.element,
|
|
183
|
+
start: member.start,
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resizeRawDelta(session: ResizeSession, updates: TimelineResizeUpdates): number | null {
|
|
188
|
+
const grabbed = session.members.find((member) => member.key === session.grabbedKey);
|
|
189
|
+
if (!grabbed) return null;
|
|
190
|
+
return session.edge === "start"
|
|
191
|
+
? updates.start - grabbed.start
|
|
192
|
+
: updates.duration - grabbed.duration;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resolveResizeChanges(
|
|
196
|
+
session: ResizeSession,
|
|
197
|
+
updates: TimelineResizeUpdates,
|
|
198
|
+
): TimelineGroupResizeChange[] | null {
|
|
199
|
+
const rawDelta = resizeRawDelta(session, updates);
|
|
200
|
+
if (rawDelta == null) return null;
|
|
201
|
+
const result = resolveTimelineGroupResize(session.members, session.edge, rawDelta);
|
|
202
|
+
return result.members.map((member, index) => ({
|
|
203
|
+
element: session.members[index]!.element,
|
|
204
|
+
start: member.start,
|
|
205
|
+
duration: member.duration,
|
|
206
|
+
playbackStart: member.playbackStart,
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function moveSessionHasChanged(
|
|
211
|
+
session: MoveSession,
|
|
212
|
+
changes: readonly TimelineGroupMoveChange[],
|
|
213
|
+
): boolean {
|
|
214
|
+
return changes.some((change, index) => change.start !== session.members[index]!.start);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function resizeSessionHasChanged(
|
|
218
|
+
session: ResizeSession,
|
|
219
|
+
changes: readonly TimelineGroupResizeChange[],
|
|
220
|
+
): boolean {
|
|
221
|
+
return changes.some((change, index) => {
|
|
222
|
+
const member = session.members[index]!;
|
|
223
|
+
return (
|
|
224
|
+
change.start !== member.start ||
|
|
225
|
+
change.duration !== member.duration ||
|
|
226
|
+
change.playbackStart !== member.playbackStart
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function previewStartForGrabbed(
|
|
232
|
+
session: MoveSession,
|
|
233
|
+
changes: readonly TimelineGroupMoveChange[],
|
|
234
|
+
fallback: number,
|
|
235
|
+
): number {
|
|
236
|
+
const change = changes.find((candidate) => elementKey(candidate.element) === session.grabbedKey);
|
|
237
|
+
return change?.start ?? fallback;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function resizeUpdatesForGrabbed(
|
|
241
|
+
session: ResizeSession,
|
|
242
|
+
changes: readonly TimelineGroupResizeChange[],
|
|
243
|
+
fallback: TimelineResizeUpdates,
|
|
244
|
+
): TimelineResizeUpdates {
|
|
245
|
+
const change = changes.find((candidate) => elementKey(candidate.element) === session.grabbedKey);
|
|
246
|
+
if (!change) return fallback;
|
|
247
|
+
return {
|
|
248
|
+
start: change.start,
|
|
249
|
+
duration: change.duration,
|
|
250
|
+
playbackStart: change.playbackStart,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function useTimelineClipGroupDrag({
|
|
255
|
+
timelineElementsRef,
|
|
256
|
+
updateElement,
|
|
257
|
+
onMoveElementsRef,
|
|
258
|
+
onResizeElementsRef,
|
|
259
|
+
onPreviewMoveElementsRef,
|
|
260
|
+
onPreviewResizeElementsRef,
|
|
261
|
+
}: UseTimelineClipGroupDragInput) {
|
|
262
|
+
const moveSessionRef = useRef<MoveSession | null>(null);
|
|
263
|
+
const resizeSessionRef = useRef<ResizeSession | null>(null);
|
|
264
|
+
|
|
265
|
+
const rollbackMove = useCallback(
|
|
266
|
+
(session: MoveSession) => {
|
|
267
|
+
const changes = session.members.map((member) => ({
|
|
268
|
+
element: member.element,
|
|
269
|
+
start: member.start,
|
|
270
|
+
}));
|
|
271
|
+
for (const change of changes) {
|
|
272
|
+
updateElement(elementKey(change.element), { start: change.start });
|
|
273
|
+
}
|
|
274
|
+
onPreviewMoveElementsRef.current?.(changes);
|
|
275
|
+
},
|
|
276
|
+
[onPreviewMoveElementsRef, updateElement],
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const rollbackResize = useCallback(
|
|
280
|
+
(session: ResizeSession) => {
|
|
281
|
+
const changes = session.members.map((member) => ({
|
|
282
|
+
element: member.element,
|
|
283
|
+
start: member.start,
|
|
284
|
+
duration: member.duration,
|
|
285
|
+
playbackStart: member.playbackStart,
|
|
286
|
+
}));
|
|
287
|
+
for (const change of changes) {
|
|
288
|
+
updateElement(elementKey(change.element), {
|
|
289
|
+
start: change.start,
|
|
290
|
+
duration: change.duration,
|
|
291
|
+
playbackStart: change.playbackStart,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
onPreviewResizeElementsRef.current?.(changes);
|
|
295
|
+
},
|
|
296
|
+
[onPreviewResizeElementsRef, updateElement],
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const previewGroupMove = useCallback(
|
|
300
|
+
(
|
|
301
|
+
element: TimelineElement,
|
|
302
|
+
selectedElementIds: Set<string>,
|
|
303
|
+
previewStart: number,
|
|
304
|
+
): PreviewGroupMoveResult => {
|
|
305
|
+
let session = moveSessionRef.current;
|
|
306
|
+
if (!session || !sameGesture(session.grabbedKey, element)) {
|
|
307
|
+
if (!onMoveElementsRef.current) return { active: false, previewStart };
|
|
308
|
+
session = createMoveSession(element, selectedElementIds, timelineElementsRef.current);
|
|
309
|
+
if (!session) return { active: false, previewStart };
|
|
310
|
+
moveSessionRef.current = session;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const changes = resolveMoveChanges(session, previewStart);
|
|
314
|
+
if (!changes) return { active: false, previewStart };
|
|
315
|
+
session.changes = changes;
|
|
316
|
+
session.hasChanged = moveSessionHasChanged(session, changes);
|
|
317
|
+
|
|
318
|
+
for (const change of changes) {
|
|
319
|
+
updateElement(elementKey(change.element), { start: change.start });
|
|
320
|
+
}
|
|
321
|
+
onPreviewMoveElementsRef.current?.(changes);
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
active: true,
|
|
325
|
+
previewStart: previewStartForGrabbed(session, changes, previewStart),
|
|
326
|
+
};
|
|
327
|
+
},
|
|
328
|
+
[onMoveElementsRef, onPreviewMoveElementsRef, timelineElementsRef, updateElement],
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const previewGroupResize = useCallback(
|
|
332
|
+
(
|
|
333
|
+
element: TimelineElement,
|
|
334
|
+
selectedElementIds: Set<string>,
|
|
335
|
+
edge: TimelineGroupResizeEdge,
|
|
336
|
+
updates: TimelineResizeUpdates,
|
|
337
|
+
): PreviewGroupResizeResult => {
|
|
338
|
+
let session = resizeSessionRef.current;
|
|
339
|
+
if (!session || !sameGesture(session.grabbedKey, element) || session.edge !== edge) {
|
|
340
|
+
if (!onResizeElementsRef.current) return { active: false, updates };
|
|
341
|
+
session = createResizeSession(
|
|
342
|
+
element,
|
|
343
|
+
selectedElementIds,
|
|
344
|
+
timelineElementsRef.current,
|
|
345
|
+
edge,
|
|
346
|
+
);
|
|
347
|
+
if (!session) return { active: false, updates };
|
|
348
|
+
resizeSessionRef.current = session;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const changes = resolveResizeChanges(session, updates);
|
|
352
|
+
if (!changes) return { active: false, updates };
|
|
353
|
+
session.changes = changes;
|
|
354
|
+
session.hasChanged = resizeSessionHasChanged(session, changes);
|
|
355
|
+
|
|
356
|
+
for (const change of changes) {
|
|
357
|
+
updateElement(elementKey(change.element), {
|
|
358
|
+
start: change.start,
|
|
359
|
+
duration: change.duration,
|
|
360
|
+
playbackStart: change.playbackStart,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
onPreviewResizeElementsRef.current?.(changes);
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
active: true,
|
|
367
|
+
updates: resizeUpdatesForGrabbed(session, changes, updates),
|
|
368
|
+
};
|
|
369
|
+
},
|
|
370
|
+
[onPreviewResizeElementsRef, onResizeElementsRef, timelineElementsRef, updateElement],
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const commitGroupMove = useCallback(
|
|
374
|
+
(element: TimelineElement): boolean => {
|
|
375
|
+
const session = moveSessionRef.current;
|
|
376
|
+
if (!session || !sameGesture(session.grabbedKey, element)) return false;
|
|
377
|
+
moveSessionRef.current = null;
|
|
378
|
+
if (!session.hasChanged) return true;
|
|
379
|
+
|
|
380
|
+
Promise.resolve(onMoveElementsRef.current?.(session.changes)).catch((error) => {
|
|
381
|
+
rollbackMove(session);
|
|
382
|
+
console.error("[Timeline] Failed to persist group clip move", error);
|
|
383
|
+
});
|
|
384
|
+
return true;
|
|
385
|
+
},
|
|
386
|
+
[onMoveElementsRef, rollbackMove],
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const commitGroupResize = useCallback(
|
|
390
|
+
(element: TimelineElement): boolean => {
|
|
391
|
+
const session = resizeSessionRef.current;
|
|
392
|
+
if (!session || !sameGesture(session.grabbedKey, element)) return false;
|
|
393
|
+
resizeSessionRef.current = null;
|
|
394
|
+
if (!session.hasChanged) return true;
|
|
395
|
+
|
|
396
|
+
Promise.resolve(onResizeElementsRef.current?.(session.changes)).catch((error) => {
|
|
397
|
+
rollbackResize(session);
|
|
398
|
+
console.error("[Timeline] Failed to persist group clip resize", error);
|
|
399
|
+
});
|
|
400
|
+
return true;
|
|
401
|
+
},
|
|
402
|
+
[onResizeElementsRef, rollbackResize],
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const clearGroupDragSessions = useCallback(() => {
|
|
406
|
+
moveSessionRef.current = null;
|
|
407
|
+
resizeSessionRef.current = null;
|
|
408
|
+
}, []);
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
previewGroupMove,
|
|
412
|
+
previewGroupResize,
|
|
413
|
+
commitGroupMove,
|
|
414
|
+
commitGroupResize,
|
|
415
|
+
clearGroupDragSessions,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useCallback, type MouseEvent as ReactMouseEvent } from "react";
|
|
2
|
+
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
|
|
3
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
4
|
+
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
|
5
|
+
|
|
6
|
+
interface UseTimelineKeyframeHandlersInput {
|
|
7
|
+
expandedElements: TimelineElement[];
|
|
8
|
+
keyframeCache: Map<string, KeyframeCacheEntry>;
|
|
9
|
+
onSelectElement?: (element: TimelineElement | null) => void;
|
|
10
|
+
onSeek?: (time: number) => void;
|
|
11
|
+
setSelectedElementId: (id: string | null) => void;
|
|
12
|
+
setKfContextMenu: (state: KeyframeDiamondContextMenuState | null) => void;
|
|
13
|
+
toggleSelectedKeyframe: (key: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useTimelineKeyframeHandlers({
|
|
17
|
+
expandedElements,
|
|
18
|
+
keyframeCache,
|
|
19
|
+
onSelectElement,
|
|
20
|
+
onSeek,
|
|
21
|
+
setSelectedElementId,
|
|
22
|
+
setKfContextMenu,
|
|
23
|
+
toggleSelectedKeyframe,
|
|
24
|
+
}: UseTimelineKeyframeHandlersInput) {
|
|
25
|
+
const onClickKeyframe = useCallback(
|
|
26
|
+
(el: TimelineElement, pct: number) => {
|
|
27
|
+
usePlayerStore.getState().clearSelectedKeyframes();
|
|
28
|
+
const elKey = el.key ?? el.id;
|
|
29
|
+
setSelectedElementId(elKey);
|
|
30
|
+
onSelectElement?.(el);
|
|
31
|
+
toggleSelectedKeyframe(`${elKey}:${pct}`);
|
|
32
|
+
onSeek?.(el.start + (pct / 100) * el.duration);
|
|
33
|
+
const kfData = keyframeCache.get(elKey);
|
|
34
|
+
const kf = kfData?.keyframes.find((item) => Math.abs(item.percentage - pct) < 0.5);
|
|
35
|
+
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
|
|
36
|
+
},
|
|
37
|
+
[keyframeCache, onSeek, onSelectElement, setSelectedElementId, toggleSelectedKeyframe],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const onShiftClickKeyframe = useCallback(
|
|
41
|
+
(elId: string, pct: number) => {
|
|
42
|
+
toggleSelectedKeyframe(`${elId}:${pct}`);
|
|
43
|
+
},
|
|
44
|
+
[toggleSelectedKeyframe],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const onContextMenuKeyframe = useCallback(
|
|
48
|
+
(e: ReactMouseEvent, elId: string, pct: number) => {
|
|
49
|
+
const el = expandedElements.find((item) => (item.key ?? item.id) === elId);
|
|
50
|
+
if (el) {
|
|
51
|
+
setSelectedElementId(elId);
|
|
52
|
+
onSelectElement?.(el);
|
|
53
|
+
}
|
|
54
|
+
const kfData = keyframeCache.get(elId);
|
|
55
|
+
const kf = kfData?.keyframes.find((item) => Math.abs(item.percentage - pct) < 0.2);
|
|
56
|
+
setKfContextMenu({
|
|
57
|
+
x: e.clientX + 4,
|
|
58
|
+
y: e.clientY + 2,
|
|
59
|
+
elementId: elId,
|
|
60
|
+
percentage: pct,
|
|
61
|
+
tweenPercentage: kf?.tweenPercentage,
|
|
62
|
+
currentEase: kf?.ease ?? kfData?.ease,
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
[expandedElements, keyframeCache, onSelectElement, setKfContextMenu, setSelectedElementId],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
onClickKeyframe,
|
|
70
|
+
onShiftClickKeyframe,
|
|
71
|
+
onContextMenuKeyframe,
|
|
72
|
+
};
|
|
73
|
+
}
|