@hyperframes/studio 0.6.112 → 0.6.114
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-BRjQ5a5_.js → hyperframes-player-C1qkW56w.js} +1 -1
- package/dist/assets/{index-C7VLzTP-.js → index-BR5v5xu2.js} +1 -1
- package/dist/assets/index-DApfviyW.js +269 -0
- package/dist/assets/index-DS7Kfzvb.css +1 -0
- package/dist/assets/{index-kfy9U4bN.js → index-DSLrl2tB.js} +24 -24
- package/dist/index.html +2 -2
- package/package.json +5 -5
- package/src/App.tsx +4 -0
- package/src/components/StudioRightPanel.tsx +98 -4
- package/src/components/panels/SlideshowPanel.test.ts +520 -0
- package/src/components/panels/SlideshowPanel.tsx +484 -0
- package/src/components/panels/SlideshowSubPanels.tsx +536 -0
- package/src/components/panels/slideshowPanelHelpers.ts +232 -0
- package/src/hooks/useGsapAnimationOps.ts +96 -0
- package/src/hooks/useSlideshowPersist.ts +68 -0
- package/src/utils/sdkCutover.ts +83 -2
- package/src/utils/setSlideshowManifest.test.ts +146 -0
- package/src/utils/setSlideshowManifest.ts +102 -0
- package/src/utils/studioHelpers.ts +2 -1
- package/dist/assets/index-C9b6Siu5.js +0 -370
- package/dist/assets/index-DP8pPIk2.css +0 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure manifest-transform helpers for SlideshowPanel.
|
|
3
|
+
* No React, no side-effects — fully unit-testable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SlideshowManifest, SlideRef, SlideHotspot } from "@hyperframes/core/slideshow";
|
|
7
|
+
|
|
8
|
+
// ── Scene shape used by the panel UI ──────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface SceneInfo {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
start: number;
|
|
14
|
+
duration: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Pure manifest transforms ───────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** Toggle a scene in the main-line slide list. */
|
|
20
|
+
export function toggleMainLineSlide(
|
|
21
|
+
manifest: SlideshowManifest,
|
|
22
|
+
sceneId: string,
|
|
23
|
+
): SlideshowManifest {
|
|
24
|
+
const exists = manifest.slides.some((s) => s.sceneId === sceneId);
|
|
25
|
+
const slides: SlideRef[] = exists
|
|
26
|
+
? manifest.slides.filter((s) => s.sceneId !== sceneId)
|
|
27
|
+
: [...manifest.slides, { sceneId }];
|
|
28
|
+
return { ...manifest, slides };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// fallow-ignore-next-line complexity
|
|
32
|
+
/** Move a main-line slide up or down by one position. */
|
|
33
|
+
/** Swap the slide with `sceneId` one step up/down within a slide list. */
|
|
34
|
+
function swapSlide(slides: SlideRef[], sceneId: string, direction: "up" | "down"): SlideRef[] {
|
|
35
|
+
const idx = slides.findIndex((s) => s.sceneId === sceneId);
|
|
36
|
+
if (idx === -1) return slides;
|
|
37
|
+
const next = direction === "up" ? idx - 1 : idx + 1;
|
|
38
|
+
if (next < 0 || next >= slides.length) return slides;
|
|
39
|
+
const out = [...slides];
|
|
40
|
+
const a = out[idx];
|
|
41
|
+
const b = out[next];
|
|
42
|
+
if (!a || !b) return slides;
|
|
43
|
+
out[idx] = b;
|
|
44
|
+
out[next] = a;
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function reorderMainLineSlide(
|
|
49
|
+
manifest: SlideshowManifest,
|
|
50
|
+
sceneId: string,
|
|
51
|
+
direction: "up" | "down",
|
|
52
|
+
): SlideshowManifest {
|
|
53
|
+
return mapSlidesIn(manifest, undefined, (slides) => swapSlide(slides, sceneId, direction));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Reorder a slide within a branch sequence (parallel to reorderMainLineSlide). */
|
|
57
|
+
export function reorderBranchSlide(
|
|
58
|
+
manifest: SlideshowManifest,
|
|
59
|
+
sequenceId: string,
|
|
60
|
+
sceneId: string,
|
|
61
|
+
direction: "up" | "down",
|
|
62
|
+
): SlideshowManifest {
|
|
63
|
+
return mapSlidesIn(manifest, sequenceId, (slides) => swapSlide(slides, sceneId, direction));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Apply fn to a branch's slide list (sequenceId) or the main line (undefined). */
|
|
67
|
+
function mapSlidesIn(
|
|
68
|
+
manifest: SlideshowManifest,
|
|
69
|
+
sequenceId: string | undefined,
|
|
70
|
+
fn: (slides: SlideRef[]) => SlideRef[],
|
|
71
|
+
): SlideshowManifest {
|
|
72
|
+
if (sequenceId === undefined) {
|
|
73
|
+
return { ...manifest, slides: fn(manifest.slides) };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
...manifest,
|
|
77
|
+
slideSequences: (manifest.slideSequences ?? []).map((seq) =>
|
|
78
|
+
seq.id === sequenceId ? { ...seq, slides: fn(seq.slides) } : seq,
|
|
79
|
+
),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Update notes on a main-line slide (adds slide entry if absent). */
|
|
84
|
+
export function setSlideNotes(
|
|
85
|
+
manifest: SlideshowManifest,
|
|
86
|
+
sceneId: string,
|
|
87
|
+
notes: string,
|
|
88
|
+
sequenceId?: string,
|
|
89
|
+
): SlideshowManifest {
|
|
90
|
+
return mapSlidesIn(manifest, sequenceId, (slides) => {
|
|
91
|
+
const exists = slides.some((s) => s.sceneId === sceneId);
|
|
92
|
+
if (exists) return slides.map((s) => (s.sceneId === sceneId ? { ...s, notes } : s));
|
|
93
|
+
return sequenceId === undefined ? [...slides, { sceneId, notes }] : slides;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Push a fragment hold-point time onto a main-line slide. Deduplicates + sorts. */
|
|
98
|
+
export function addFragment(
|
|
99
|
+
manifest: SlideshowManifest,
|
|
100
|
+
sceneId: string,
|
|
101
|
+
time: number,
|
|
102
|
+
sequenceId?: string,
|
|
103
|
+
): SlideshowManifest {
|
|
104
|
+
return mapSlidesIn(manifest, sequenceId, (slides) => {
|
|
105
|
+
const exists = slides.some((s) => s.sceneId === sceneId);
|
|
106
|
+
if (exists)
|
|
107
|
+
return slides.map((s) => {
|
|
108
|
+
if (s.sceneId !== sceneId) return s;
|
|
109
|
+
const frags = [...new Set([...(s.fragments ?? []), time])].sort((a, b) => a - b);
|
|
110
|
+
return { ...s, fragments: frags };
|
|
111
|
+
});
|
|
112
|
+
return sequenceId === undefined ? [...slides, { sceneId, fragments: [time] }] : slides;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Remove a fragment hold-point by value from a main-line slide. */
|
|
117
|
+
export function removeFragment(
|
|
118
|
+
manifest: SlideshowManifest,
|
|
119
|
+
sceneId: string,
|
|
120
|
+
time: number,
|
|
121
|
+
sequenceId?: string,
|
|
122
|
+
): SlideshowManifest {
|
|
123
|
+
return mapSlidesIn(manifest, sequenceId, (slides) =>
|
|
124
|
+
slides.map((s) =>
|
|
125
|
+
s.sceneId === sceneId
|
|
126
|
+
? { ...s, fragments: (s.fragments ?? []).filter((f) => f !== time) }
|
|
127
|
+
: s,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Create a new branch sequence. Rejects duplicate ids. */
|
|
133
|
+
export function createSequence(
|
|
134
|
+
manifest: SlideshowManifest,
|
|
135
|
+
id: string,
|
|
136
|
+
label: string,
|
|
137
|
+
): SlideshowManifest {
|
|
138
|
+
const existing = manifest.slideSequences ?? [];
|
|
139
|
+
if (existing.some((seq) => seq.id === id)) return manifest;
|
|
140
|
+
return {
|
|
141
|
+
...manifest,
|
|
142
|
+
slideSequences: [...existing, { id, label, slides: [] }],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Rename an existing branch sequence label. */
|
|
147
|
+
export function renameSequence(
|
|
148
|
+
manifest: SlideshowManifest,
|
|
149
|
+
id: string,
|
|
150
|
+
label: string,
|
|
151
|
+
): SlideshowManifest {
|
|
152
|
+
return {
|
|
153
|
+
...manifest,
|
|
154
|
+
slideSequences: (manifest.slideSequences ?? []).map((seq) =>
|
|
155
|
+
seq.id === id ? { ...seq, label } : seq,
|
|
156
|
+
),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function pruneHotspots(slides: SlideRef[], targetId: string): SlideRef[] {
|
|
161
|
+
return slides.map((s) => {
|
|
162
|
+
if (!s.hotspots) return s;
|
|
163
|
+
const hotspots = s.hotspots.filter((h) => h.target !== targetId);
|
|
164
|
+
return hotspots.length === s.hotspots.length ? s : { ...s, hotspots };
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Delete a branch sequence by id, removing any hotspot targeting it. */
|
|
169
|
+
export function deleteSequence(manifest: SlideshowManifest, id: string): SlideshowManifest {
|
|
170
|
+
const remainingSequences = (manifest.slideSequences ?? []).filter((seq) => seq.id !== id);
|
|
171
|
+
return {
|
|
172
|
+
...manifest,
|
|
173
|
+
slides: pruneHotspots(manifest.slides, id),
|
|
174
|
+
slideSequences: remainingSequences.map((seq) => ({
|
|
175
|
+
...seq,
|
|
176
|
+
slides: pruneHotspots(seq.slides, id),
|
|
177
|
+
})),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Add or remove a scene slide from a branch sequence. */
|
|
182
|
+
export function assignToBranch(
|
|
183
|
+
manifest: SlideshowManifest,
|
|
184
|
+
sequenceId: string,
|
|
185
|
+
sceneId: string,
|
|
186
|
+
assign: boolean,
|
|
187
|
+
): SlideshowManifest {
|
|
188
|
+
return {
|
|
189
|
+
...manifest,
|
|
190
|
+
slideSequences: (manifest.slideSequences ?? []).map((seq) => {
|
|
191
|
+
if (seq.id !== sequenceId) return seq;
|
|
192
|
+
if (assign) {
|
|
193
|
+
if (seq.slides.some((s) => s.sceneId === sceneId)) return seq;
|
|
194
|
+
return { ...seq, slides: [...seq.slides, { sceneId }] };
|
|
195
|
+
}
|
|
196
|
+
return { ...seq, slides: seq.slides.filter((s) => s.sceneId !== sceneId) };
|
|
197
|
+
}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Add a hotspot to a main-line slide. */
|
|
202
|
+
export function addHotspot(
|
|
203
|
+
manifest: SlideshowManifest,
|
|
204
|
+
sceneId: string,
|
|
205
|
+
hotspot: SlideHotspot,
|
|
206
|
+
sequenceId?: string,
|
|
207
|
+
): SlideshowManifest {
|
|
208
|
+
return mapSlidesIn(manifest, sequenceId, (slides) =>
|
|
209
|
+
slides.map((s) => {
|
|
210
|
+
if (s.sceneId !== sceneId) return s;
|
|
211
|
+
const existing = s.hotspots ?? [];
|
|
212
|
+
if (existing.some((h) => h.id === hotspot.id)) return s;
|
|
213
|
+
return { ...s, hotspots: [...existing, hotspot] };
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Remove a hotspot by id from a main-line slide. */
|
|
219
|
+
export function removeHotspot(
|
|
220
|
+
manifest: SlideshowManifest,
|
|
221
|
+
sceneId: string,
|
|
222
|
+
hotspotId: string,
|
|
223
|
+
sequenceId?: string,
|
|
224
|
+
): SlideshowManifest {
|
|
225
|
+
return mapSlidesIn(manifest, sequenceId, (slides) =>
|
|
226
|
+
slides.map((s) =>
|
|
227
|
+
s.sceneId === sceneId
|
|
228
|
+
? { ...s, hotspots: (s.hotspots ?? []).filter((h) => h.id !== hotspotId) }
|
|
229
|
+
: s,
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -5,6 +5,8 @@ import { roundTo3 } from "../utils/rounding";
|
|
|
5
5
|
import {
|
|
6
6
|
sdkGsapTweenPersist,
|
|
7
7
|
sdkGsapDeleteAllForSelectorPersist,
|
|
8
|
+
sdkAddWithKeyframesPersist,
|
|
9
|
+
sdkReplaceWithKeyframesPersist,
|
|
8
10
|
type CutoverDeps,
|
|
9
11
|
} from "../utils/sdkCutover";
|
|
10
12
|
import {
|
|
@@ -181,10 +183,104 @@ export function useGsapAnimationOps({
|
|
|
181
183
|
[activeCompPath, commitMutation, projectIdRef, showToast, sdkSession, sdkDeps],
|
|
182
184
|
);
|
|
183
185
|
|
|
186
|
+
type KeyframeEntry = {
|
|
187
|
+
percentage: number;
|
|
188
|
+
properties: Record<string, number | string>;
|
|
189
|
+
ease?: string;
|
|
190
|
+
auto?: boolean;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const addWithKeyframes = useCallback(
|
|
194
|
+
async (
|
|
195
|
+
selection: DomEditSelection,
|
|
196
|
+
targetSelector: string,
|
|
197
|
+
position: number,
|
|
198
|
+
duration: number,
|
|
199
|
+
keyframes: KeyframeEntry[],
|
|
200
|
+
ease?: string,
|
|
201
|
+
label = "Add animation with keyframes",
|
|
202
|
+
) => {
|
|
203
|
+
if (sdkSession && sdkDeps) {
|
|
204
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
205
|
+
const handled = await sdkAddWithKeyframesPersist(
|
|
206
|
+
targetPath,
|
|
207
|
+
targetSelector,
|
|
208
|
+
position,
|
|
209
|
+
duration,
|
|
210
|
+
keyframes,
|
|
211
|
+
ease,
|
|
212
|
+
sdkSession,
|
|
213
|
+
sdkDeps,
|
|
214
|
+
{ label },
|
|
215
|
+
);
|
|
216
|
+
if (handled) return;
|
|
217
|
+
}
|
|
218
|
+
void commitMutation(
|
|
219
|
+
selection,
|
|
220
|
+
{
|
|
221
|
+
type: "add-with-keyframes",
|
|
222
|
+
targetSelector,
|
|
223
|
+
position,
|
|
224
|
+
duration,
|
|
225
|
+
keyframes,
|
|
226
|
+
...(ease ? { ease } : {}),
|
|
227
|
+
},
|
|
228
|
+
{ label, softReload: true },
|
|
229
|
+
);
|
|
230
|
+
},
|
|
231
|
+
[commitMutation, activeCompPath, sdkSession, sdkDeps],
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const replaceWithKeyframes = useCallback(
|
|
235
|
+
async (
|
|
236
|
+
selection: DomEditSelection,
|
|
237
|
+
animationId: string,
|
|
238
|
+
targetSelector: string,
|
|
239
|
+
position: number,
|
|
240
|
+
duration: number,
|
|
241
|
+
keyframes: KeyframeEntry[],
|
|
242
|
+
ease?: string,
|
|
243
|
+
label = "Replace animation with keyframes",
|
|
244
|
+
) => {
|
|
245
|
+
if (sdkSession && sdkDeps) {
|
|
246
|
+
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
247
|
+
const handled = await sdkReplaceWithKeyframesPersist(
|
|
248
|
+
targetPath,
|
|
249
|
+
animationId,
|
|
250
|
+
targetSelector,
|
|
251
|
+
position,
|
|
252
|
+
duration,
|
|
253
|
+
keyframes,
|
|
254
|
+
ease,
|
|
255
|
+
sdkSession,
|
|
256
|
+
sdkDeps,
|
|
257
|
+
{ label },
|
|
258
|
+
);
|
|
259
|
+
if (handled) return;
|
|
260
|
+
}
|
|
261
|
+
void commitMutation(
|
|
262
|
+
selection,
|
|
263
|
+
{
|
|
264
|
+
type: "replace-with-keyframes",
|
|
265
|
+
animationId,
|
|
266
|
+
targetSelector,
|
|
267
|
+
position,
|
|
268
|
+
duration,
|
|
269
|
+
keyframes,
|
|
270
|
+
...(ease ? { ease } : {}),
|
|
271
|
+
},
|
|
272
|
+
{ label, softReload: true },
|
|
273
|
+
);
|
|
274
|
+
},
|
|
275
|
+
[commitMutation, activeCompPath, sdkSession, sdkDeps],
|
|
276
|
+
);
|
|
277
|
+
|
|
184
278
|
return {
|
|
185
279
|
updateGsapMeta,
|
|
186
280
|
deleteGsapAnimation,
|
|
187
281
|
deleteAllForSelector,
|
|
188
282
|
addGsapAnimation,
|
|
283
|
+
addWithKeyframes,
|
|
284
|
+
replaceWithKeyframes,
|
|
189
285
|
};
|
|
190
286
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useCallback, type MutableRefObject } from "react";
|
|
2
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
3
|
+
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
|
|
4
|
+
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
|
+
import { persistSlideshowManifest } from "../utils/setSlideshowManifest";
|
|
6
|
+
|
|
7
|
+
export interface UseSlideshowPersistParams {
|
|
8
|
+
sdkSession: Composition | null;
|
|
9
|
+
activeCompPath: string | null;
|
|
10
|
+
readProjectFile: (path: string) => Promise<string>;
|
|
11
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
12
|
+
recordEdit: (entry: {
|
|
13
|
+
label: string;
|
|
14
|
+
kind: EditHistoryKind;
|
|
15
|
+
files: Record<string, { before: string; after: string }>;
|
|
16
|
+
}) => Promise<void>;
|
|
17
|
+
reloadPreview: () => void;
|
|
18
|
+
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
19
|
+
/**
|
|
20
|
+
* When provided, rapid writes with the same key coalesce through the
|
|
21
|
+
* save-queue infra (via recordEdit's coalesceKey) so back-to-back persists
|
|
22
|
+
* collapse to a single undo entry rather than polluting history.
|
|
23
|
+
* Pass e.g. `"slideshow-notes:" + activeCompPath` for the notes path.
|
|
24
|
+
*/
|
|
25
|
+
coalesceKey?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useSlideshowPersist({
|
|
29
|
+
sdkSession,
|
|
30
|
+
activeCompPath,
|
|
31
|
+
readProjectFile,
|
|
32
|
+
writeProjectFile,
|
|
33
|
+
recordEdit,
|
|
34
|
+
reloadPreview,
|
|
35
|
+
domEditSaveTimestampRef,
|
|
36
|
+
coalesceKey,
|
|
37
|
+
}: UseSlideshowPersistParams): (manifest: SlideshowManifest) => Promise<void> {
|
|
38
|
+
return useCallback(
|
|
39
|
+
async (manifest: SlideshowManifest) => {
|
|
40
|
+
if (!sdkSession) return;
|
|
41
|
+
const path = activeCompPath ?? "index.html";
|
|
42
|
+
const originalContent = await readProjectFile(path);
|
|
43
|
+
await persistSlideshowManifest({
|
|
44
|
+
manifest,
|
|
45
|
+
sdkSession,
|
|
46
|
+
originalContent,
|
|
47
|
+
targetPath: path,
|
|
48
|
+
deps: {
|
|
49
|
+
editHistory: { recordEdit },
|
|
50
|
+
writeProjectFile,
|
|
51
|
+
reloadPreview,
|
|
52
|
+
domEditSaveTimestampRef,
|
|
53
|
+
},
|
|
54
|
+
coalesceKey,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
[
|
|
58
|
+
sdkSession,
|
|
59
|
+
activeCompPath,
|
|
60
|
+
readProjectFile,
|
|
61
|
+
writeProjectFile,
|
|
62
|
+
recordEdit,
|
|
63
|
+
reloadPreview,
|
|
64
|
+
domEditSaveTimestampRef,
|
|
65
|
+
coalesceKey,
|
|
66
|
+
],
|
|
67
|
+
);
|
|
68
|
+
}
|
package/src/utils/sdkCutover.ts
CHANGED
|
@@ -144,10 +144,11 @@ interface CutoverOptions {
|
|
|
144
144
|
skipRefresh?: boolean;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
// ponytail:
|
|
147
|
+
// ponytail: exported for setSlideshowManifest (third caller — island write bypasses
|
|
148
|
+
// the SDK dispatch path since <script> nodes are not in the element tree).
|
|
148
149
|
// `after` is serialized once by the caller (which also did the no-op check
|
|
149
150
|
// against its pre-dispatch snapshot), so this never re-serializes.
|
|
150
|
-
async function persistSdkSerialize(
|
|
151
|
+
export async function persistSdkSerialize(
|
|
151
152
|
after: string,
|
|
152
153
|
targetPath: string,
|
|
153
154
|
originalContent: string,
|
|
@@ -435,6 +436,86 @@ export function sdkGsapConvertToKeyframesPersist(
|
|
|
435
436
|
);
|
|
436
437
|
}
|
|
437
438
|
|
|
439
|
+
type KeyframeSpec = {
|
|
440
|
+
percentage: number;
|
|
441
|
+
properties: Record<string, number | string>;
|
|
442
|
+
ease?: string;
|
|
443
|
+
auto?: boolean;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
type KeyframesPayload = {
|
|
447
|
+
targetSelector: string;
|
|
448
|
+
position: number;
|
|
449
|
+
duration: number;
|
|
450
|
+
keyframes: KeyframeSpec[];
|
|
451
|
+
ease?: string;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
/** Shared inner dispatch for addWithKeyframes / replaceWithKeyframes ops. */
|
|
455
|
+
function dispatchWithKeyframes(
|
|
456
|
+
s: Composition,
|
|
457
|
+
payload: KeyframesPayload,
|
|
458
|
+
animationId?: string,
|
|
459
|
+
): void {
|
|
460
|
+
if (animationId !== undefined) {
|
|
461
|
+
s.dispatch({ type: "replaceWithKeyframes", animationId, ...payload });
|
|
462
|
+
} else {
|
|
463
|
+
s.dispatch({ type: "addWithKeyframes", ...payload });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function sdkAddWithKeyframesPersist(
|
|
468
|
+
targetPath: string,
|
|
469
|
+
targetSelector: string,
|
|
470
|
+
position: number,
|
|
471
|
+
duration: number,
|
|
472
|
+
keyframes: KeyframeSpec[],
|
|
473
|
+
ease: string | undefined,
|
|
474
|
+
sdkSession: Composition | null | undefined,
|
|
475
|
+
deps: CutoverDeps,
|
|
476
|
+
options?: CutoverOptions,
|
|
477
|
+
): Promise<boolean> {
|
|
478
|
+
const payload: KeyframesPayload = {
|
|
479
|
+
targetSelector,
|
|
480
|
+
position,
|
|
481
|
+
duration,
|
|
482
|
+
keyframes,
|
|
483
|
+
...(ease ? { ease } : {}),
|
|
484
|
+
};
|
|
485
|
+
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
|
|
486
|
+
dispatchWithKeyframes(s, payload),
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function sdkReplaceWithKeyframesPersist(
|
|
491
|
+
targetPath: string,
|
|
492
|
+
animationId: string,
|
|
493
|
+
targetSelector: string,
|
|
494
|
+
position: number,
|
|
495
|
+
duration: number,
|
|
496
|
+
keyframes: KeyframeSpec[],
|
|
497
|
+
ease: string | undefined,
|
|
498
|
+
sdkSession: Composition | null | undefined,
|
|
499
|
+
deps: CutoverDeps,
|
|
500
|
+
options?: CutoverOptions,
|
|
501
|
+
): Promise<boolean> {
|
|
502
|
+
const payload: KeyframesPayload = {
|
|
503
|
+
targetSelector,
|
|
504
|
+
position,
|
|
505
|
+
duration,
|
|
506
|
+
keyframes,
|
|
507
|
+
...(ease ? { ease } : {}),
|
|
508
|
+
};
|
|
509
|
+
return dispatchGsapOpAndPersist(
|
|
510
|
+
targetPath,
|
|
511
|
+
sdkSession,
|
|
512
|
+
deps,
|
|
513
|
+
options,
|
|
514
|
+
(s) => dispatchWithKeyframes(s, payload, animationId),
|
|
515
|
+
{ animationId, opLabel: "replaceWithKeyframes" },
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
438
519
|
export async function sdkDeletePersist(
|
|
439
520
|
hfId: string,
|
|
440
521
|
originalContent: string,
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildSlideshowIslandHtml } from "./setSlideshowManifest";
|
|
3
|
+
import { parseSlideshowManifest } from "@hyperframes/core/slideshow";
|
|
4
|
+
import type { CutoverDeps } from "./sdkCutover";
|
|
5
|
+
|
|
6
|
+
// Fix 3: vi.mock must be at module top level so Vitest can hoist them.
|
|
7
|
+
vi.mock("../components/editor/manualEditingAvailability", () => ({
|
|
8
|
+
STUDIO_SDK_CUTOVER_ENABLED: true,
|
|
9
|
+
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
|
|
10
|
+
}));
|
|
11
|
+
vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
12
|
+
|
|
13
|
+
describe("buildSlideshowIslandHtml", () => {
|
|
14
|
+
it("serializes a manifest into a script island", () => {
|
|
15
|
+
const html = buildSlideshowIslandHtml({ slides: [{ sceneId: "a" }] });
|
|
16
|
+
expect(html).toContain('type="application/hyperframes-slideshow+json"');
|
|
17
|
+
expect(html).toContain('"sceneId": "a"');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("stamps version 1, preserving an existing version", () => {
|
|
21
|
+
expect(buildSlideshowIslandHtml({ slides: [] })).toContain('"version": 1');
|
|
22
|
+
expect(buildSlideshowIslandHtml({ version: 2, slides: [] })).toContain('"version": 2');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("round-trips through parseSlideshowManifest", () => {
|
|
26
|
+
const html = `<html><body>${buildSlideshowIslandHtml({ slides: [{ sceneId: "x" }] })}</body></html>`;
|
|
27
|
+
const parsed = parseSlideshowManifest(html);
|
|
28
|
+
expect(parsed?.slides[0]?.sceneId).toBe("x");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("wraps the JSON in a script tag with no extra nesting", () => {
|
|
32
|
+
const html = buildSlideshowIslandHtml({ slides: [] });
|
|
33
|
+
expect(html.startsWith("<script")).toBe(true);
|
|
34
|
+
expect(html.trimEnd().endsWith("</script>")).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Fix 1: </script> breakout test
|
|
38
|
+
it("does NOT embed a literal </script> inside the JSON body", () => {
|
|
39
|
+
const manifest = { slides: [{ sceneId: "s1", notes: "</script><b>x</b>" }] };
|
|
40
|
+
const html = buildSlideshowIslandHtml(manifest);
|
|
41
|
+
// The only closing </script> should be the real one at the very end.
|
|
42
|
+
// Strip that trailing tag and confirm no </script> remains.
|
|
43
|
+
const withoutClosingTag = html.slice(0, html.lastIndexOf("</script>"));
|
|
44
|
+
expect(withoutClosingTag).not.toContain("</script>");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("round-trips a manifest containing </script> in notes via parseSlideshowManifest", () => {
|
|
48
|
+
const notes = "</script><b>x</b>";
|
|
49
|
+
const manifest = { slides: [{ sceneId: "s1", notes }] };
|
|
50
|
+
const html = `<html><body>${buildSlideshowIslandHtml(manifest)}</body></html>`;
|
|
51
|
+
const parsed = parseSlideshowManifest(html);
|
|
52
|
+
expect(parsed?.slides[0]).toMatchObject({ sceneId: "s1", notes });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("persistSlideshowManifest — op construction", () => {
|
|
57
|
+
function makeDeps(writeProjectFile: ReturnType<typeof vi.fn>): CutoverDeps {
|
|
58
|
+
return {
|
|
59
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
60
|
+
writeProjectFile,
|
|
61
|
+
reloadPreview: vi.fn(),
|
|
62
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
it("writes the serialized manifest when the island already exists", async () => {
|
|
67
|
+
const { persistSlideshowManifest } = await import("./setSlideshowManifest");
|
|
68
|
+
|
|
69
|
+
const manifest = { slides: [{ sceneId: "scene-1" }] };
|
|
70
|
+
const island = buildSlideshowIslandHtml(manifest);
|
|
71
|
+
const originalHtml = `<html><head></head><body>${island}</body></html>`;
|
|
72
|
+
|
|
73
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
74
|
+
const deps = makeDeps(writeProjectFile);
|
|
75
|
+
const recordEdit = deps.editHistory.recordEdit as ReturnType<typeof vi.fn>;
|
|
76
|
+
|
|
77
|
+
const mockSession = { serialize: vi.fn().mockReturnValue(originalHtml) };
|
|
78
|
+
|
|
79
|
+
await persistSlideshowManifest({
|
|
80
|
+
manifest: { slides: [{ sceneId: "scene-2" }] },
|
|
81
|
+
sdkSession: mockSession as never,
|
|
82
|
+
originalContent: originalHtml,
|
|
83
|
+
targetPath: "/proj/comp.html",
|
|
84
|
+
deps,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(writeProjectFile).toHaveBeenCalledOnce();
|
|
88
|
+
const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
|
|
89
|
+
expect(written).toContain('"sceneId": "scene-2"');
|
|
90
|
+
expect(recordEdit).toHaveBeenCalledWith(expect.objectContaining({ label: "Edit slideshow" }));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("inserts the island when none exists in the serialized HTML", async () => {
|
|
94
|
+
const { persistSlideshowManifest } = await import("./setSlideshowManifest");
|
|
95
|
+
|
|
96
|
+
const baseHtml = "<html><head></head><body></body></html>";
|
|
97
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
98
|
+
const deps = makeDeps(writeProjectFile);
|
|
99
|
+
const mockSession = { serialize: vi.fn().mockReturnValue(baseHtml) };
|
|
100
|
+
|
|
101
|
+
await persistSlideshowManifest({
|
|
102
|
+
manifest: { slides: [{ sceneId: "new-scene" }] },
|
|
103
|
+
sdkSession: mockSession as never,
|
|
104
|
+
originalContent: baseHtml,
|
|
105
|
+
targetPath: "/proj/comp.html",
|
|
106
|
+
deps,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(writeProjectFile).toHaveBeenCalledOnce();
|
|
110
|
+
const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
|
|
111
|
+
expect(written).toContain('"sceneId": "new-scene"');
|
|
112
|
+
expect(written).toContain('type="application/hyperframes-slideshow+json"');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Fix 2: two stale islands should collapse to exactly one after persist
|
|
116
|
+
it("collapses two stale islands into exactly one after persist", async () => {
|
|
117
|
+
const { persistSlideshowManifest } = await import("./setSlideshowManifest");
|
|
118
|
+
|
|
119
|
+
const staleIsland1 = buildSlideshowIslandHtml({ slides: [{ sceneId: "old-1" }] });
|
|
120
|
+
const staleIsland2 = buildSlideshowIslandHtml({ slides: [{ sceneId: "old-2" }] });
|
|
121
|
+
const twoIslandHtml = `<html><head></head><body>${staleIsland1}${staleIsland2}</body></html>`;
|
|
122
|
+
|
|
123
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
124
|
+
const deps = makeDeps(writeProjectFile);
|
|
125
|
+
const mockSession = { serialize: vi.fn().mockReturnValue(twoIslandHtml) };
|
|
126
|
+
|
|
127
|
+
await persistSlideshowManifest({
|
|
128
|
+
manifest: { slides: [{ sceneId: "fresh" }] },
|
|
129
|
+
sdkSession: mockSession as never,
|
|
130
|
+
originalContent: twoIslandHtml,
|
|
131
|
+
targetPath: "/proj/comp.html",
|
|
132
|
+
deps,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(writeProjectFile).toHaveBeenCalledOnce();
|
|
136
|
+
const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
|
|
137
|
+
|
|
138
|
+
// Count occurrences of the island script open tag
|
|
139
|
+
const islandCount = (written.match(/type="application\/hyperframes-slideshow\+json"/g) ?? [])
|
|
140
|
+
.length;
|
|
141
|
+
expect(islandCount).toBe(1);
|
|
142
|
+
expect(written).toContain('"sceneId": "fresh"');
|
|
143
|
+
expect(written).not.toContain('"sceneId": "old-1"');
|
|
144
|
+
expect(written).not.toContain('"sceneId": "old-2"');
|
|
145
|
+
});
|
|
146
|
+
});
|