@hyperframes/studio 0.7.46 → 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-BGVoOXdg.js → index-DsckwbdW.js} +1 -1
- package/dist/assets/index-VsAbY3rl.js +423 -0
- package/dist/assets/{index-DO2MAFSG.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-BsLQ2qGn.js +0 -420
- package/dist/assets/index-DoVLXke0.css +0 -1
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { useCallback, type MutableRefObject, type RefObject } from "react";
|
|
2
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
3
|
+
import type { TimelineElement } from "../player";
|
|
4
|
+
import { sdkTimingBatchPersist } from "../utils/sdkCutover";
|
|
5
|
+
import {
|
|
6
|
+
buildTimelineMoveTimingPatch,
|
|
7
|
+
buildTimelineResizeTimingPatch,
|
|
8
|
+
extendRootDurationIfNeeded,
|
|
9
|
+
finishTimelineTimingFallback,
|
|
10
|
+
foldGsapMutationIntoHistory,
|
|
11
|
+
formatTimelineAttributeNumber,
|
|
12
|
+
patchIframeDomTiming,
|
|
13
|
+
persistTimelineBatchEdit,
|
|
14
|
+
readFileContent,
|
|
15
|
+
scaleGsapPositions,
|
|
16
|
+
shiftGsapPositions,
|
|
17
|
+
type PersistTimelineBatchChange,
|
|
18
|
+
type RecordEditInput,
|
|
19
|
+
} from "./timelineEditingHelpers";
|
|
20
|
+
|
|
21
|
+
export interface TimelineGroupMoveChange {
|
|
22
|
+
element: TimelineElement;
|
|
23
|
+
start: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TimelineGroupResizeChange {
|
|
27
|
+
element: TimelineElement;
|
|
28
|
+
start: number;
|
|
29
|
+
duration: number;
|
|
30
|
+
playbackStart?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TimelineGroupCommitOptions {
|
|
34
|
+
beforeTiming?: Promise<void>;
|
|
35
|
+
coalesceKey?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface UseTimelineGroupEditingOptions {
|
|
39
|
+
activeCompPath: string | null;
|
|
40
|
+
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
41
|
+
editQueueRef: MutableRefObject<Promise<unknown>>;
|
|
42
|
+
forceReloadSdkSession?: () => void;
|
|
43
|
+
isRecordingRef?: RefObject<boolean>;
|
|
44
|
+
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
|
45
|
+
previewIframeRef: RefObject<HTMLIFrameElement | null>;
|
|
46
|
+
projectIdRef: MutableRefObject<string | null>;
|
|
47
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
48
|
+
reloadPreview: () => void;
|
|
49
|
+
sdkSession?: Composition | null;
|
|
50
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
51
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function targetPathFor(element: TimelineElement, activeCompPath: string | null): string {
|
|
55
|
+
return element.sourceFile || activeCompPath || "index.html";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function allChangesSharePath(
|
|
59
|
+
changes: readonly { element: TimelineElement }[],
|
|
60
|
+
activeCompPath: string | null,
|
|
61
|
+
): string | null {
|
|
62
|
+
const firstPath = changes[0] ? targetPathFor(changes[0].element, activeCompPath) : null;
|
|
63
|
+
if (!firstPath) return null;
|
|
64
|
+
return changes.every((change) => targetPathFor(change.element, activeCompPath) === firstPath)
|
|
65
|
+
? firstPath
|
|
66
|
+
: null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function moveCoalesceKey(changes: readonly TimelineGroupMoveChange[]): string {
|
|
70
|
+
return `timeline-group-move:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): string {
|
|
74
|
+
return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean {
|
|
78
|
+
return (
|
|
79
|
+
change.playbackStart != null ||
|
|
80
|
+
(change.start !== change.element.start && change.element.playbackStart != null)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function useTimelineGroupEditing({
|
|
85
|
+
activeCompPath,
|
|
86
|
+
domEditSaveTimestampRef,
|
|
87
|
+
editQueueRef,
|
|
88
|
+
forceReloadSdkSession,
|
|
89
|
+
isRecordingRef,
|
|
90
|
+
pendingTimelineEditPathRef,
|
|
91
|
+
previewIframeRef,
|
|
92
|
+
projectIdRef,
|
|
93
|
+
recordEdit,
|
|
94
|
+
reloadPreview,
|
|
95
|
+
sdkSession,
|
|
96
|
+
showToast,
|
|
97
|
+
writeProjectFile,
|
|
98
|
+
}: UseTimelineGroupEditingOptions) {
|
|
99
|
+
const enqueueGroupOperation = useCallback(
|
|
100
|
+
(label: string, operation: (projectId: string) => Promise<void>): Promise<void> => {
|
|
101
|
+
if (isRecordingRef?.current) {
|
|
102
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
103
|
+
return Promise.reject(new Error(`${label}: blocked while recording`));
|
|
104
|
+
}
|
|
105
|
+
const projectId = projectIdRef.current;
|
|
106
|
+
if (!projectId) return Promise.reject(new Error(`${label}: no active project`));
|
|
107
|
+
const run = editQueueRef.current.then(() => operation(projectId));
|
|
108
|
+
// Keep the shared edit queue from wedging on a rejection, but return the raw
|
|
109
|
+
// (rejecting) promise so the gesture owner can roll back on a real failure.
|
|
110
|
+
editQueueRef.current = run.then(
|
|
111
|
+
() => undefined,
|
|
112
|
+
(error) => {
|
|
113
|
+
console.error(`[Timeline] Failed to persist: ${label}`, error);
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
return run;
|
|
117
|
+
},
|
|
118
|
+
[editQueueRef, isRecordingRef, projectIdRef, showToast],
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const persistServerBatch = useCallback(
|
|
122
|
+
async (
|
|
123
|
+
projectId: string,
|
|
124
|
+
label: string,
|
|
125
|
+
batchChanges: PersistTimelineBatchChange[],
|
|
126
|
+
coalesceKey: string,
|
|
127
|
+
) => {
|
|
128
|
+
await persistTimelineBatchEdit({
|
|
129
|
+
projectId,
|
|
130
|
+
activeCompPath,
|
|
131
|
+
label,
|
|
132
|
+
changes: batchChanges,
|
|
133
|
+
writeProjectFile,
|
|
134
|
+
recordEdit,
|
|
135
|
+
domEditSaveTimestampRef,
|
|
136
|
+
pendingTimelineEditPathRef,
|
|
137
|
+
coalesceKey,
|
|
138
|
+
});
|
|
139
|
+
forceReloadSdkSession?.();
|
|
140
|
+
},
|
|
141
|
+
[
|
|
142
|
+
activeCompPath,
|
|
143
|
+
domEditSaveTimestampRef,
|
|
144
|
+
forceReloadSdkSession,
|
|
145
|
+
pendingTimelineEditPathRef,
|
|
146
|
+
recordEdit,
|
|
147
|
+
writeProjectFile,
|
|
148
|
+
],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const handleTimelineGroupMove = useCallback(
|
|
152
|
+
(changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => {
|
|
153
|
+
if (changes.length === 0) return Promise.resolve();
|
|
154
|
+
for (const change of changes) {
|
|
155
|
+
patchIframeDomTiming(previewIframeRef.current, change.element, [
|
|
156
|
+
["data-start", formatTimelineAttributeNumber(change.start)],
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
|
|
161
|
+
const needsExtension = extendRootDurationIfNeeded(maxEnd);
|
|
162
|
+
const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes);
|
|
163
|
+
return enqueueGroupOperation("Move timeline clips", async (projectId) => {
|
|
164
|
+
await options?.beforeTiming;
|
|
165
|
+
const sharedPath = allChangesSharePath(changes, activeCompPath);
|
|
166
|
+
const sdkChanges = changes.map((change) =>
|
|
167
|
+
change.element.hfId
|
|
168
|
+
? { hfId: change.element.hfId, timingUpdate: { start: change.start } }
|
|
169
|
+
: null,
|
|
170
|
+
);
|
|
171
|
+
const canUseSdk =
|
|
172
|
+
!needsExtension && sharedPath !== null && sdkChanges.every((change) => change !== null);
|
|
173
|
+
if (canUseSdk) {
|
|
174
|
+
const handled = await sdkTimingBatchPersist(
|
|
175
|
+
sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
|
|
176
|
+
sharedPath,
|
|
177
|
+
sdkSession,
|
|
178
|
+
{
|
|
179
|
+
editHistory: { recordEdit },
|
|
180
|
+
writeProjectFile,
|
|
181
|
+
reloadPreview,
|
|
182
|
+
domEditSaveTimestampRef,
|
|
183
|
+
compositionPath: activeCompPath,
|
|
184
|
+
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
|
185
|
+
},
|
|
186
|
+
{ label: "Move timeline clips", coalesceKey },
|
|
187
|
+
);
|
|
188
|
+
if (handled) return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await persistServerBatch(
|
|
192
|
+
projectId,
|
|
193
|
+
"Move timeline clips",
|
|
194
|
+
changes.map((change) => ({
|
|
195
|
+
element: change.element,
|
|
196
|
+
buildPatches: (original, target) =>
|
|
197
|
+
buildTimelineMoveTimingPatch(original, target, change.start, change.element.duration),
|
|
198
|
+
})),
|
|
199
|
+
coalesceKey,
|
|
200
|
+
);
|
|
201
|
+
await finishTimelineTimingFallback({
|
|
202
|
+
iframe: previewIframeRef.current,
|
|
203
|
+
needsExtension,
|
|
204
|
+
rootDurationSeconds: maxEnd,
|
|
205
|
+
reloadPreview,
|
|
206
|
+
gsapMutation: () =>
|
|
207
|
+
foldGsapMutationIntoHistory({
|
|
208
|
+
projectId,
|
|
209
|
+
paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
|
|
210
|
+
label: "Move timeline clips",
|
|
211
|
+
coalesceKey,
|
|
212
|
+
recordEdit,
|
|
213
|
+
gsapMutation: async () => {
|
|
214
|
+
let mutated = false;
|
|
215
|
+
for (const change of changes) {
|
|
216
|
+
const delta = change.start - change.element.start;
|
|
217
|
+
const domId = change.element.domId;
|
|
218
|
+
if (delta === 0 || !domId) continue;
|
|
219
|
+
const status = await shiftGsapPositions(
|
|
220
|
+
projectId,
|
|
221
|
+
targetPathFor(change.element, activeCompPath),
|
|
222
|
+
domId,
|
|
223
|
+
delta,
|
|
224
|
+
);
|
|
225
|
+
mutated = mutated || status.mutated;
|
|
226
|
+
}
|
|
227
|
+
return { mutated };
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
[
|
|
235
|
+
activeCompPath,
|
|
236
|
+
domEditSaveTimestampRef,
|
|
237
|
+
enqueueGroupOperation,
|
|
238
|
+
persistServerBatch,
|
|
239
|
+
previewIframeRef,
|
|
240
|
+
projectIdRef,
|
|
241
|
+
recordEdit,
|
|
242
|
+
reloadPreview,
|
|
243
|
+
sdkSession,
|
|
244
|
+
writeProjectFile,
|
|
245
|
+
],
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const handleTimelineGroupResize = useCallback(
|
|
249
|
+
(changes: TimelineGroupResizeChange[], options?: TimelineGroupCommitOptions) => {
|
|
250
|
+
if (changes.length === 0) return Promise.resolve();
|
|
251
|
+
for (const change of changes) {
|
|
252
|
+
const liveAttrs: Array<[string, string]> = [
|
|
253
|
+
["data-start", formatTimelineAttributeNumber(change.start)],
|
|
254
|
+
["data-duration", formatTimelineAttributeNumber(change.duration)],
|
|
255
|
+
];
|
|
256
|
+
if (change.playbackStart != null) {
|
|
257
|
+
const liveAttr =
|
|
258
|
+
change.element.playbackStartAttr === "playback-start"
|
|
259
|
+
? "data-playback-start"
|
|
260
|
+
: "data-media-start";
|
|
261
|
+
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]);
|
|
262
|
+
}
|
|
263
|
+
patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const maxEnd = Math.max(...changes.map((change) => change.start + change.duration));
|
|
267
|
+
const needsExtension = extendRootDurationIfNeeded(maxEnd);
|
|
268
|
+
const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes);
|
|
269
|
+
return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
|
|
270
|
+
await options?.beforeTiming;
|
|
271
|
+
const sharedPath = allChangesSharePath(changes, activeCompPath);
|
|
272
|
+
const sdkChanges = changes.map((change) =>
|
|
273
|
+
change.element.hfId
|
|
274
|
+
? {
|
|
275
|
+
hfId: change.element.hfId,
|
|
276
|
+
timingUpdate: { start: change.start, duration: change.duration },
|
|
277
|
+
}
|
|
278
|
+
: null,
|
|
279
|
+
);
|
|
280
|
+
const canUseSdk =
|
|
281
|
+
!needsExtension &&
|
|
282
|
+
sharedPath !== null &&
|
|
283
|
+
changes.every((change) => !resizeHasPlaybackStartAdjustment(change)) &&
|
|
284
|
+
sdkChanges.every((change) => change !== null);
|
|
285
|
+
if (canUseSdk) {
|
|
286
|
+
const handled = await sdkTimingBatchPersist(
|
|
287
|
+
sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
|
|
288
|
+
sharedPath,
|
|
289
|
+
sdkSession,
|
|
290
|
+
{
|
|
291
|
+
editHistory: { recordEdit },
|
|
292
|
+
writeProjectFile,
|
|
293
|
+
reloadPreview,
|
|
294
|
+
domEditSaveTimestampRef,
|
|
295
|
+
compositionPath: activeCompPath,
|
|
296
|
+
readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
|
|
297
|
+
},
|
|
298
|
+
{ label: "Resize timeline clips", coalesceKey },
|
|
299
|
+
);
|
|
300
|
+
if (handled) return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
await persistServerBatch(
|
|
304
|
+
projectId,
|
|
305
|
+
"Resize timeline clips",
|
|
306
|
+
changes.map((change) => ({
|
|
307
|
+
element: change.element,
|
|
308
|
+
buildPatches: (original, target) =>
|
|
309
|
+
buildTimelineResizeTimingPatch(original, target, change.element, {
|
|
310
|
+
start: change.start,
|
|
311
|
+
duration: change.duration,
|
|
312
|
+
playbackStart: change.playbackStart,
|
|
313
|
+
}),
|
|
314
|
+
})),
|
|
315
|
+
coalesceKey,
|
|
316
|
+
);
|
|
317
|
+
await finishTimelineTimingFallback({
|
|
318
|
+
iframe: previewIframeRef.current,
|
|
319
|
+
needsExtension,
|
|
320
|
+
rootDurationSeconds: maxEnd,
|
|
321
|
+
reloadPreview,
|
|
322
|
+
gsapMutation: () =>
|
|
323
|
+
foldGsapMutationIntoHistory({
|
|
324
|
+
projectId,
|
|
325
|
+
paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
|
|
326
|
+
label: "Resize timeline clips",
|
|
327
|
+
coalesceKey,
|
|
328
|
+
recordEdit,
|
|
329
|
+
gsapMutation: async () => {
|
|
330
|
+
let mutated = false;
|
|
331
|
+
for (const change of changes) {
|
|
332
|
+
const domId = change.element.domId;
|
|
333
|
+
const timingChanged =
|
|
334
|
+
change.start !== change.element.start ||
|
|
335
|
+
change.duration !== change.element.duration;
|
|
336
|
+
if (!timingChanged || !domId) continue;
|
|
337
|
+
const status = await scaleGsapPositions(
|
|
338
|
+
projectId,
|
|
339
|
+
targetPathFor(change.element, activeCompPath),
|
|
340
|
+
domId,
|
|
341
|
+
change.element.start,
|
|
342
|
+
change.element.duration,
|
|
343
|
+
change.start,
|
|
344
|
+
change.duration,
|
|
345
|
+
);
|
|
346
|
+
mutated = mutated || status.mutated;
|
|
347
|
+
}
|
|
348
|
+
return { mutated };
|
|
349
|
+
},
|
|
350
|
+
}),
|
|
351
|
+
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
[
|
|
356
|
+
activeCompPath,
|
|
357
|
+
domEditSaveTimestampRef,
|
|
358
|
+
enqueueGroupOperation,
|
|
359
|
+
persistServerBatch,
|
|
360
|
+
previewIframeRef,
|
|
361
|
+
projectIdRef,
|
|
362
|
+
recordEdit,
|
|
363
|
+
reloadPreview,
|
|
364
|
+
sdkSession,
|
|
365
|
+
writeProjectFile,
|
|
366
|
+
],
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
return { handleTimelineGroupMove, handleTimelineGroupResize };
|
|
370
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { TimelineElement } from "../player";
|
|
7
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
8
|
+
import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
|
|
9
|
+
import { useTimelineSelectionPreviewSync } from "./useTimelineSelectionPreviewSync";
|
|
10
|
+
|
|
11
|
+
installReactActEnvironment();
|
|
12
|
+
|
|
13
|
+
interface HarnessProps {
|
|
14
|
+
selectedElementId: string | null;
|
|
15
|
+
selectedElementIds: Set<string>;
|
|
16
|
+
timelineElements: TimelineElement[];
|
|
17
|
+
domEditSelection: DomEditSelection | null;
|
|
18
|
+
domEditGroupSelections: DomEditSelection[];
|
|
19
|
+
buildDomSelectionForTimelineElement: (
|
|
20
|
+
element: TimelineElement,
|
|
21
|
+
) => Promise<DomEditSelection | null>;
|
|
22
|
+
applyDomSelection: (
|
|
23
|
+
selection: DomEditSelection | null,
|
|
24
|
+
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
|
25
|
+
) => void;
|
|
26
|
+
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
document.body.innerHTML = "";
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function renderHarness() {
|
|
34
|
+
const host = document.createElement("div");
|
|
35
|
+
document.body.append(host);
|
|
36
|
+
const root = createRoot(host);
|
|
37
|
+
|
|
38
|
+
function Harness(nextProps: HarnessProps) {
|
|
39
|
+
useTimelineSelectionPreviewSync({
|
|
40
|
+
...nextProps,
|
|
41
|
+
activeCompPath: "index.html",
|
|
42
|
+
});
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const rerender = async (nextProps: HarnessProps) => {
|
|
47
|
+
await act(async () => {
|
|
48
|
+
root.render(React.createElement(Harness, nextProps));
|
|
49
|
+
await Promise.resolve();
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
rerender,
|
|
55
|
+
cleanup: () => {
|
|
56
|
+
act(() => root.unmount());
|
|
57
|
+
host.remove();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeSyncFixture() {
|
|
63
|
+
const firstElement = document.createElement("div");
|
|
64
|
+
firstElement.id = "clip-1";
|
|
65
|
+
const secondElement = document.createElement("div");
|
|
66
|
+
secondElement.id = "clip-2";
|
|
67
|
+
const firstSelection = makeSelection("First", firstElement);
|
|
68
|
+
const secondSelection = makeSelection("Second", secondElement);
|
|
69
|
+
const timelineElements: TimelineElement[] = [
|
|
70
|
+
{ id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
|
|
71
|
+
{ id: "clip-2", tag: "div", start: 1, duration: 1, track: 1 },
|
|
72
|
+
];
|
|
73
|
+
const selectionById = new Map([
|
|
74
|
+
["clip-1", firstSelection],
|
|
75
|
+
["clip-2", secondSelection],
|
|
76
|
+
]);
|
|
77
|
+
return { firstSelection, secondSelection, timelineElements, selectionById };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("useTimelineSelectionPreviewSync", () => {
|
|
81
|
+
it("syncs a multi-id timeline selection into preview group selections", async () => {
|
|
82
|
+
const { firstSelection, secondSelection, timelineElements, selectionById } = makeSyncFixture();
|
|
83
|
+
const applyDomSelection = vi.fn();
|
|
84
|
+
const applyMarqueeSelection = vi.fn();
|
|
85
|
+
const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) => {
|
|
86
|
+
return selectionById.get(element.id) ?? null;
|
|
87
|
+
});
|
|
88
|
+
const harness = renderHarness();
|
|
89
|
+
|
|
90
|
+
await harness.rerender({
|
|
91
|
+
selectedElementId: "clip-2",
|
|
92
|
+
selectedElementIds: new Set(["clip-1", "clip-2"]),
|
|
93
|
+
timelineElements,
|
|
94
|
+
domEditSelection: null,
|
|
95
|
+
domEditGroupSelections: [],
|
|
96
|
+
buildDomSelectionForTimelineElement,
|
|
97
|
+
applyDomSelection,
|
|
98
|
+
applyMarqueeSelection,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(applyMarqueeSelection).toHaveBeenCalledWith([secondSelection, firstSelection], false);
|
|
102
|
+
expect(applyDomSelection).not.toHaveBeenCalled();
|
|
103
|
+
harness.cleanup();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("clears preview selection when the timeline selection set is empty", async () => {
|
|
107
|
+
const { firstSelection, timelineElements, selectionById } = makeSyncFixture();
|
|
108
|
+
const applyDomSelection = vi.fn();
|
|
109
|
+
const applyMarqueeSelection = vi.fn();
|
|
110
|
+
const harness = renderHarness();
|
|
111
|
+
|
|
112
|
+
await harness.rerender({
|
|
113
|
+
selectedElementId: null,
|
|
114
|
+
selectedElementIds: new Set(),
|
|
115
|
+
timelineElements,
|
|
116
|
+
domEditSelection: firstSelection,
|
|
117
|
+
domEditGroupSelections: [firstSelection],
|
|
118
|
+
buildDomSelectionForTimelineElement: vi.fn(async (element: TimelineElement) => {
|
|
119
|
+
return selectionById.get(element.id) ?? null;
|
|
120
|
+
}),
|
|
121
|
+
applyDomSelection,
|
|
122
|
+
applyMarqueeSelection,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false });
|
|
126
|
+
expect(applyMarqueeSelection).not.toHaveBeenCalled();
|
|
127
|
+
harness.cleanup();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
4
|
+
import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
|
|
5
|
+
|
|
6
|
+
interface UseTimelineSelectionPreviewSyncParams {
|
|
7
|
+
selectedElementId: string | null;
|
|
8
|
+
selectedElementIds: Set<string>;
|
|
9
|
+
timelineElements: TimelineElement[];
|
|
10
|
+
domEditSelection: DomEditSelection | null;
|
|
11
|
+
domEditGroupSelections: DomEditSelection[];
|
|
12
|
+
activeCompPath: string | null;
|
|
13
|
+
buildDomSelectionForTimelineElement: (
|
|
14
|
+
element: TimelineElement,
|
|
15
|
+
) => Promise<DomEditSelection | null>;
|
|
16
|
+
applyDomSelection: (
|
|
17
|
+
selection: DomEditSelection | null,
|
|
18
|
+
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
|
19
|
+
) => void;
|
|
20
|
+
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function orderSelectedIds(ids: Set<string>, anchor: string | null): string[] {
|
|
24
|
+
const ordered = [...ids];
|
|
25
|
+
if (!anchor || !ids.has(anchor)) return ordered;
|
|
26
|
+
return [anchor, ...ordered.filter((id) => id !== anchor)];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function selectionIdsMatch(
|
|
30
|
+
currentIds: string[],
|
|
31
|
+
selectedIds: string[],
|
|
32
|
+
currentAnchor: string | null,
|
|
33
|
+
wantedAnchor: string | null,
|
|
34
|
+
): boolean {
|
|
35
|
+
// Compare as sets in BOTH directions: length equality misreads duplicates (two DOM
|
|
36
|
+
// children resolving to the same clip id) as a full match and skips mirroring the
|
|
37
|
+
// members that never made it into the preview.
|
|
38
|
+
const current = new Set(currentIds);
|
|
39
|
+
const selected = new Set(selectedIds);
|
|
40
|
+
if (current.size !== selected.size) return false;
|
|
41
|
+
for (const id of selected) {
|
|
42
|
+
if (!current.has(id)) return false;
|
|
43
|
+
}
|
|
44
|
+
// The primary/anchor must also agree, or a change of just the anchor within the
|
|
45
|
+
// same set would never re-sync the preview's primary selection.
|
|
46
|
+
return currentAnchor === wantedAnchor;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useTimelineSelectionPreviewSync({
|
|
50
|
+
selectedElementId,
|
|
51
|
+
selectedElementIds,
|
|
52
|
+
timelineElements,
|
|
53
|
+
domEditSelection,
|
|
54
|
+
domEditGroupSelections,
|
|
55
|
+
activeCompPath,
|
|
56
|
+
buildDomSelectionForTimelineElement,
|
|
57
|
+
applyDomSelection,
|
|
58
|
+
applyMarqueeSelection,
|
|
59
|
+
}: UseTimelineSelectionPreviewSyncParams): void {
|
|
60
|
+
const selectedIds = useMemo(
|
|
61
|
+
() => orderSelectedIds(selectedElementIds, selectedElementId),
|
|
62
|
+
[selectedElementId, selectedElementIds],
|
|
63
|
+
);
|
|
64
|
+
const selectedKey = selectedIds.join("\0");
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const currentSelections =
|
|
68
|
+
domEditGroupSelections.length > 1
|
|
69
|
+
? domEditGroupSelections
|
|
70
|
+
: domEditSelection
|
|
71
|
+
? [domEditSelection]
|
|
72
|
+
: [];
|
|
73
|
+
const currentIds = currentSelections
|
|
74
|
+
.map((selection) =>
|
|
75
|
+
resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
|
|
76
|
+
)
|
|
77
|
+
.filter((id): id is string => Boolean(id));
|
|
78
|
+
const currentAnchor = domEditSelection
|
|
79
|
+
? resolveTimelineIdForSelection(domEditSelection, timelineElements, activeCompPath)
|
|
80
|
+
: null;
|
|
81
|
+
|
|
82
|
+
if (selectedIds.length === 0) {
|
|
83
|
+
if (currentSelections.length > 0) applyDomSelection(null, { revealPanel: false });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (selectionIdsMatch(currentIds, selectedIds, currentAnchor, selectedElementId)) return;
|
|
87
|
+
|
|
88
|
+
let cancelled = false;
|
|
89
|
+
const syncSelection = async () => {
|
|
90
|
+
const selections: DomEditSelection[] = [];
|
|
91
|
+
let resolvableCount = 0;
|
|
92
|
+
for (const id of selectedIds) {
|
|
93
|
+
const element = timelineElements.find((item) => (item.key ?? item.id) === id);
|
|
94
|
+
if (!element) continue;
|
|
95
|
+
resolvableCount += 1;
|
|
96
|
+
const selection = await buildDomSelectionForTimelineElement(element);
|
|
97
|
+
if (selection) selections.push(selection);
|
|
98
|
+
}
|
|
99
|
+
if (cancelled) return;
|
|
100
|
+
// The store is the source of truth: applying a partial set would write that
|
|
101
|
+
// shrunk set back and silently drop the members whose DOM node was not ready.
|
|
102
|
+
// Bail instead; a later effect run (on timelineElements/DOM change) applies the
|
|
103
|
+
// full set once every resolvable member has a live node.
|
|
104
|
+
if (selections.length < resolvableCount) return;
|
|
105
|
+
if (selections.length === 0) {
|
|
106
|
+
applyDomSelection(null, { revealPanel: false });
|
|
107
|
+
} else if (selections.length === 1) {
|
|
108
|
+
applyDomSelection(selections[0], { revealPanel: false });
|
|
109
|
+
} else {
|
|
110
|
+
applyMarqueeSelection(selections, false);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
void syncSelection();
|
|
115
|
+
return () => {
|
|
116
|
+
cancelled = true;
|
|
117
|
+
};
|
|
118
|
+
}, [
|
|
119
|
+
activeCompPath,
|
|
120
|
+
applyDomSelection,
|
|
121
|
+
applyMarqueeSelection,
|
|
122
|
+
buildDomSelectionForTimelineElement,
|
|
123
|
+
domEditGroupSelections,
|
|
124
|
+
domEditSelection,
|
|
125
|
+
selectedElementId,
|
|
126
|
+
selectedIds,
|
|
127
|
+
selectedKey,
|
|
128
|
+
timelineElements,
|
|
129
|
+
]);
|
|
130
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
3
|
+
import { persistSdkSerialize } from "../utils/sdkCutover";
|
|
4
|
+
import type { UseSlideshowPersistParams } from "./useSlideshowPersist";
|
|
5
|
+
|
|
6
|
+
/** Same single-writer dependency set the slideshow persist path uses. */
|
|
7
|
+
export type UseVariablesPersistParams = Omit<UseSlideshowPersistParams, "coalesceKey">;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Persist a variable-schema edit: run `mutate` (SDK declaration/value ops)
|
|
11
|
+
* against the session, then write the serialized composition through the
|
|
12
|
+
* standard single-writer path (undo history + self-write echo suppression +
|
|
13
|
+
* preview reload). Mutations that end up changing nothing are skipped, so a
|
|
14
|
+
* no-op dispatch (e.g. declaring a duplicate id) never pollutes undo history.
|
|
15
|
+
*/
|
|
16
|
+
export function useVariablesPersist({
|
|
17
|
+
sdkSession,
|
|
18
|
+
activeCompPath,
|
|
19
|
+
readProjectFile,
|
|
20
|
+
writeProjectFile,
|
|
21
|
+
recordEdit,
|
|
22
|
+
reloadPreview,
|
|
23
|
+
domEditSaveTimestampRef,
|
|
24
|
+
}: UseVariablesPersistParams): (
|
|
25
|
+
label: string,
|
|
26
|
+
mutate: (session: Composition) => void,
|
|
27
|
+
) => Promise<boolean> {
|
|
28
|
+
return useCallback(
|
|
29
|
+
async (label: string, mutate: (session: Composition) => void) => {
|
|
30
|
+
if (!sdkSession) return false;
|
|
31
|
+
const path = activeCompPath ?? "index.html";
|
|
32
|
+
const originalContent = await readProjectFile(path);
|
|
33
|
+
mutate(sdkSession);
|
|
34
|
+
const after = sdkSession.serialize();
|
|
35
|
+
if (after === originalContent) return false;
|
|
36
|
+
await persistSdkSerialize(
|
|
37
|
+
after,
|
|
38
|
+
path,
|
|
39
|
+
originalContent,
|
|
40
|
+
{
|
|
41
|
+
editHistory: { recordEdit },
|
|
42
|
+
writeProjectFile,
|
|
43
|
+
reloadPreview,
|
|
44
|
+
domEditSaveTimestampRef,
|
|
45
|
+
compositionPath: activeCompPath,
|
|
46
|
+
},
|
|
47
|
+
{ label },
|
|
48
|
+
);
|
|
49
|
+
return true;
|
|
50
|
+
},
|
|
51
|
+
[
|
|
52
|
+
sdkSession,
|
|
53
|
+
activeCompPath,
|
|
54
|
+
readProjectFile,
|
|
55
|
+
writeProjectFile,
|
|
56
|
+
recordEdit,
|
|
57
|
+
reloadPreview,
|
|
58
|
+
domEditSaveTimestampRef,
|
|
59
|
+
],
|
|
60
|
+
);
|
|
61
|
+
}
|