@hyperframes/studio 0.6.113 → 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-C0tFko2e.js → index-BR5v5xu2.js} +1 -1
- package/dist/assets/{index-B8Qzwcpc.js → index-DApfviyW.js} +120 -116
- package/dist/assets/index-DS7Kfzvb.css +1 -0
- 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/useSlideshowPersist.ts +68 -0
- package/src/utils/sdkCutover.ts +3 -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-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
|
+
}
|
|
@@ -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,
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* setSlideshowManifest — Studio persist helper for the slideshow JSON island.
|
|
3
|
+
*
|
|
4
|
+
* The island is a `<script type="application/hyperframes-slideshow+json">` node
|
|
5
|
+
* embedded in the composition HTML. Because <script> nodes are not tracked by
|
|
6
|
+
* the SDK element tree (they have no hf-id), we cannot use a `setText` dispatch
|
|
7
|
+
* op. Instead we:
|
|
8
|
+
* 1. Call `sdkSession.serialize()` to get the current HTML.
|
|
9
|
+
* 2. Replace or insert the island with the new manifest JSON.
|
|
10
|
+
* 3. Write via `persistSdkSerialize` — the same low-level writer used by the
|
|
11
|
+
* other SDK-cutover paths (sdkDeletePersist, sdkTimingPersist, etc.).
|
|
12
|
+
*
|
|
13
|
+
* Inserting when absent: if no island exists in the serialized HTML we insert
|
|
14
|
+
* one before `</body>` (or, if no </body>, append to the end of the document).
|
|
15
|
+
* This means callers do NOT need to pre-scaffold the island; Task 11 / the panel
|
|
16
|
+
* can call persistSlideshowManifest on a fresh composition.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
|
|
20
|
+
import {
|
|
21
|
+
SLIDESHOW_ISLAND_TYPE,
|
|
22
|
+
SLIDESHOW_MANIFEST_VERSION,
|
|
23
|
+
parseSlideshowManifest,
|
|
24
|
+
slideshowIslandRegex,
|
|
25
|
+
} from "@hyperframes/core/slideshow";
|
|
26
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
27
|
+
import type { CutoverDeps } from "./sdkCutover";
|
|
28
|
+
import { persistSdkSerialize } from "./sdkCutover";
|
|
29
|
+
|
|
30
|
+
// Matches ALL <script type="application/hyperframes-slideshow+json"> ... </script>
|
|
31
|
+
// blocks (global + case-insensitive) so we can strip every stale island in one pass.
|
|
32
|
+
const ISLAND_RE = slideshowIslandRegex("gi");
|
|
33
|
+
|
|
34
|
+
export function buildSlideshowIslandHtml(manifest: SlideshowManifest): string {
|
|
35
|
+
// Stamp the schema version (preserve an existing one) so future schema
|
|
36
|
+
// changes can detect and migrate older islands.
|
|
37
|
+
const versioned: SlideshowManifest = {
|
|
38
|
+
version: manifest.version ?? SLIDESHOW_MANIFEST_VERSION,
|
|
39
|
+
...manifest,
|
|
40
|
+
};
|
|
41
|
+
// Escape `<` and `>` so that a manifest field containing `</script>` cannot
|
|
42
|
+
// break out of the script tag. JSON.parse round-trips </> unchanged.
|
|
43
|
+
const json = JSON.stringify(versioned, null, 2).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
|
|
44
|
+
return `<script type="${SLIDESHOW_ISLAND_TYPE}">\n${json}\n</script>`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PersistSlideshowArgs {
|
|
48
|
+
manifest: SlideshowManifest;
|
|
49
|
+
/** Live SDK Composition session — used only to read the current serialized HTML. */
|
|
50
|
+
sdkSession: Pick<Composition, "serialize">;
|
|
51
|
+
/** Exact on-disk bytes for the undo-history `before` baseline. */
|
|
52
|
+
originalContent: string;
|
|
53
|
+
targetPath: string;
|
|
54
|
+
deps: CutoverDeps;
|
|
55
|
+
/** Optional label override (default: "Edit slideshow"). */
|
|
56
|
+
label?: string;
|
|
57
|
+
/**
|
|
58
|
+
* When provided, threads a coalesceKey into recordEdit so rapid writes
|
|
59
|
+
* (e.g. per-keystroke notes changes) collapse to a single undo entry.
|
|
60
|
+
*/
|
|
61
|
+
coalesceKey?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function persistSlideshowManifest(args: PersistSlideshowArgs): Promise<void> {
|
|
65
|
+
const { manifest, sdkSession, originalContent, targetPath, deps, label, coalesceKey } = args;
|
|
66
|
+
|
|
67
|
+
const islandHtml = buildSlideshowIslandHtml(manifest);
|
|
68
|
+
|
|
69
|
+
// Write-time validation: confirm the island we just built round-trips to a
|
|
70
|
+
// valid manifest before touching disk, so a malformed edit can't corrupt the
|
|
71
|
+
// composition. parseSlideshowManifest throws on a structurally-invalid island.
|
|
72
|
+
try {
|
|
73
|
+
if (!parseSlideshowManifest(islandHtml)) {
|
|
74
|
+
throw new Error("built island did not parse back to a manifest");
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
throw new Error(`refusing to persist invalid slideshow manifest: ${(err as Error).message}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const current = sdkSession.serialize();
|
|
81
|
+
|
|
82
|
+
// Strip ALL existing islands (handles the case where two stale islands
|
|
83
|
+
// accumulated) then insert exactly one fresh island.
|
|
84
|
+
const stripped = current.replace(ISLAND_RE, "");
|
|
85
|
+
|
|
86
|
+
let after: string;
|
|
87
|
+
const bodyClose = stripped.lastIndexOf("</body>");
|
|
88
|
+
if (bodyClose !== -1) {
|
|
89
|
+
after = stripped.slice(0, bodyClose) + islandHtml + "\n" + stripped.slice(bodyClose);
|
|
90
|
+
} else {
|
|
91
|
+
after = stripped + "\n" + islandHtml;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// No-op gate: if the rewritten HTML is byte-identical to the current serialized
|
|
95
|
+
// HTML, skip the write — avoids a spurious disk write and a no-op undo entry.
|
|
96
|
+
if (after === current) return;
|
|
97
|
+
|
|
98
|
+
await persistSdkSerialize(after, targetPath, originalContent, deps, {
|
|
99
|
+
label: label ?? "Edit slideshow",
|
|
100
|
+
...(coalesceKey ? { coalesceKey } : {}),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -13,7 +13,7 @@ export interface AppToast {
|
|
|
13
13
|
tone: "error" | "info";
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export type RightPanelTab = "layers" | "design" | "renders" | "block-params";
|
|
16
|
+
export type RightPanelTab = "layers" | "design" | "renders" | "block-params" | "slideshow";
|
|
17
17
|
export type RightInspectorPane = "layers" | "design";
|
|
18
18
|
|
|
19
19
|
export interface RightInspectorPanes {
|
|
@@ -204,6 +204,7 @@ export function clampNumber(value: number, min: number, max: number): number {
|
|
|
204
204
|
return Math.min(Math.max(value, min), max);
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// fallow-ignore-next-line unused-export
|
|
207
208
|
export { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
|
|
208
209
|
|
|
209
210
|
export function collectHtmlIds(source: string): string[] {
|