@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.
@@ -0,0 +1,484 @@
1
+ /**
2
+ * SlideshowPanel — Studio right-panel tab for authoring the slideshow island.
3
+ *
4
+ * Four sub-surfaces:
5
+ * 1. Slide list: scenes → toggle main-line slide; reorder via up/down arrows.
6
+ * 2. Slide inspector: notes textarea; fragment hold-points.
7
+ * 3. Branch tree: create/rename sequences; assign scenes to a branch.
8
+ * 4. Hotspot tool: mark selected element as a hotspot on the active slide.
9
+ *
10
+ * State: the manifest is parsed from the current composition HTML on mount and
11
+ * on each `compHtml` change. Every edit calls `onPersist(manifest)` and
12
+ * updates local state.
13
+ *
14
+ * All manifest transforms are pure helpers — see slideshowPanelHelpers.ts.
15
+ */
16
+
17
+ import { useState, useEffect, useCallback, useRef } from "react";
18
+ import { parseSlideshowManifest } from "@hyperframes/core/slideshow";
19
+ import type { SlideshowManifest, SlideHotspot } from "@hyperframes/core/slideshow";
20
+ import { usePlayerStore } from "../../player";
21
+ import { useDomEditSelectionContext } from "../../contexts/DomEditContext";
22
+ import { useFileManagerContext } from "../../contexts/FileManagerContext";
23
+ import {
24
+ SectionHeader,
25
+ SlideList,
26
+ SlideInspector,
27
+ BranchTree,
28
+ HotspotTool,
29
+ } from "./SlideshowSubPanels";
30
+
31
+ // Re-export pure helpers so the test file can import from "./SlideshowPanel".
32
+ export {
33
+ toggleMainLineSlide,
34
+ reorderMainLineSlide,
35
+ reorderBranchSlide,
36
+ setSlideNotes,
37
+ addFragment,
38
+ removeFragment,
39
+ createSequence,
40
+ renameSequence,
41
+ deleteSequence,
42
+ assignToBranch,
43
+ addHotspot,
44
+ removeHotspot,
45
+ } from "./slideshowPanelHelpers";
46
+ export type { SceneInfo } from "./slideshowPanelHelpers";
47
+
48
+ export function safeParseManifest(html: string): SlideshowManifest {
49
+ try {
50
+ return parseSlideshowManifest(html) ?? { slides: [] };
51
+ } catch {
52
+ console.warn("[SlideshowPanel] Failed to parse slideshow manifest; using empty manifest");
53
+ return { slides: [] };
54
+ }
55
+ }
56
+
57
+ import {
58
+ toggleMainLineSlide,
59
+ reorderMainLineSlide,
60
+ reorderBranchSlide,
61
+ setSlideNotes,
62
+ addFragment,
63
+ removeFragment,
64
+ createSequence,
65
+ renameSequence,
66
+ deleteSequence,
67
+ assignToBranch,
68
+ addHotspot,
69
+ removeHotspot,
70
+ } from "./slideshowPanelHelpers";
71
+
72
+ // ── Notes-attribution controller (pure, testable) ─────────────────────────
73
+ //
74
+ // The React component delegates debounce scheduling to these functions so
75
+ // the flush-attribution invariant can be tested without a DOM or React renderer.
76
+
77
+ export interface NotesController {
78
+ /** Record a notes keystroke; returns the timer id. */
79
+ schedule: (
80
+ manifest: SlideshowManifest,
81
+ persist: (m: SlideshowManifest) => Promise<void>,
82
+ delayMs: number,
83
+ ) => ReturnType<typeof setTimeout>;
84
+ /** Flush any pending notes synchronously (e.g. on comp-switch or unmount). */
85
+ flush: () => void;
86
+ /** Cancel without flushing (used when a discrete action absorbs the notes). */
87
+ cancel: () => void;
88
+ /** Merge any pending notes into an incoming discrete manifest, then clear. */
89
+ mergeIntoDiscrete: (next: SlideshowManifest) => SlideshowManifest;
90
+ }
91
+
92
+ export function makeSlideshowNotesController(): NotesController {
93
+ type Pending = { manifest: SlideshowManifest; persist: (m: SlideshowManifest) => Promise<void> };
94
+ let pending: Pending | null = null;
95
+ let timer: ReturnType<typeof setTimeout> | null = null;
96
+
97
+ return {
98
+ schedule(manifest, persist, delayMs) {
99
+ if (timer !== null) clearTimeout(timer);
100
+ pending = { manifest, persist };
101
+ timer = setTimeout(() => {
102
+ timer = null;
103
+ const p = pending;
104
+ if (p !== null) {
105
+ pending = null;
106
+ p.persist(p.manifest).catch((err: unknown) => {
107
+ console.error("[slideshow] notes persist failed:", err);
108
+ });
109
+ }
110
+ }, delayMs);
111
+ return timer;
112
+ },
113
+
114
+ flush() {
115
+ if (timer !== null) {
116
+ clearTimeout(timer);
117
+ timer = null;
118
+ }
119
+ const p = pending;
120
+ if (p !== null) {
121
+ pending = null;
122
+ p.persist(p.manifest).catch((err: unknown) => {
123
+ console.error("[slideshow] notes persist failed:", err);
124
+ });
125
+ }
126
+ },
127
+
128
+ cancel() {
129
+ if (timer !== null) {
130
+ clearTimeout(timer);
131
+ timer = null;
132
+ }
133
+ pending = null;
134
+ },
135
+
136
+ mergeIntoDiscrete(next) {
137
+ const p = pending;
138
+ if (p === null) return next;
139
+ pending = null;
140
+ if (timer !== null) {
141
+ clearTimeout(timer);
142
+ timer = null;
143
+ }
144
+ return {
145
+ ...next,
146
+ slides: next.slides.map((slide) => {
147
+ const ps = p.manifest.slides.find((s) => s.sceneId === slide.sceneId);
148
+ if (ps?.notes !== undefined && slide.notes === undefined) {
149
+ return { ...slide, notes: ps.notes };
150
+ }
151
+ return slide;
152
+ }),
153
+ };
154
+ },
155
+ };
156
+ }
157
+
158
+ // ── Component ─────────────────────────────────────────────────────────────
159
+
160
+ export interface SlideshowPanelProps {
161
+ /** Scenes from the live clip manifest (passed from StudioRightPanel). */
162
+ scenes: import("./slideshowPanelHelpers").SceneInfo[];
163
+ /**
164
+ * Called with the updated manifest after every discrete edit (toggle, add,
165
+ * delete, reorder, hotspot). Notes changes use the debounced variant instead.
166
+ */
167
+ onPersist: (manifest: SlideshowManifest) => Promise<void>;
168
+ /** Called with the updated manifest after the notes idle delay (~450 ms). */
169
+ onPersistNotes: (manifest: SlideshowManifest) => Promise<void>;
170
+ }
171
+
172
+ type SectionKey = "slides" | "inspector" | "branches" | "hotspot";
173
+
174
+ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowPanelProps) {
175
+ const { editingFile } = useFileManagerContext();
176
+ const compHtml = editingFile?.content ?? null;
177
+
178
+ const [manifest, setManifest] = useState<SlideshowManifest>(() => {
179
+ if (!compHtml) return { slides: [] };
180
+ return safeParseManifest(compHtml);
181
+ });
182
+
183
+ const [selectedSceneId, setSelectedSceneId] = useState<string | null>(null);
184
+ const [selectedSequenceId, setSelectedSequenceId] = useState<string | null>(null);
185
+ const [expandedSections, setExpandedSections] = useState<Set<SectionKey>>(
186
+ () => new Set<SectionKey>(["slides", "inspector"]),
187
+ );
188
+
189
+ const currentTime = usePlayerStore((s) => s.currentTime);
190
+ const { domEditSelection } = useDomEditSelectionContext();
191
+
192
+ // Keep a ref to the latest manifest so discrete handlers always operate on
193
+ // the freshest state, never a stale closure snapshot.
194
+ const manifestRef = useRef<SlideshowManifest>(manifest);
195
+
196
+ // Controller pairs each pending notes update with the callback that owns it,
197
+ // so a flush always writes to the composition the notes were typed in.
198
+ const notesCtrlRef = useRef<NotesController>(makeSlideshowNotesController());
199
+
200
+ useEffect(() => {
201
+ if (!compHtml) {
202
+ // Flush any pending notes for the OLD composition before clearing state.
203
+ notesCtrlRef.current.flush();
204
+ setManifest({ slides: [] });
205
+ manifestRef.current = { slides: [] };
206
+ return;
207
+ }
208
+ const parsed = safeParseManifest(compHtml);
209
+ // Flush pending notes for the OLD composition before switching to the new one.
210
+ notesCtrlRef.current.flush();
211
+ setManifest(parsed);
212
+ manifestRef.current = parsed;
213
+ setSelectedSequenceId(null);
214
+ }, [compHtml]);
215
+
216
+ /** Discrete actions (toggle, reorder, add/delete, hotspot): persist immediately. */
217
+ const applyManifest = useCallback(
218
+ async (next: SlideshowManifest) => {
219
+ // Fold any in-flight typed notes into the discrete manifest so they are
220
+ // not silently dropped when the debounce timer would have fired later.
221
+ const merged = notesCtrlRef.current.mergeIntoDiscrete(next);
222
+ setManifest(merged);
223
+ manifestRef.current = merged;
224
+ // Surface persist failures instead of swallowing them at each call site.
225
+ try {
226
+ await onPersist(merged);
227
+ } catch (err) {
228
+ console.error("[slideshow] failed to persist manifest edit:", err);
229
+ }
230
+ },
231
+ [onPersist],
232
+ );
233
+
234
+ /**
235
+ * Notes path: update in-memory state immediately for a responsive UI, but
236
+ * debounce the disk persist to ~450 ms after the last keystroke. The pending
237
+ * notes are paired with the callback that owns them (the one bound to the
238
+ * current composition path), so a composition switch before the timer fires
239
+ * will flush to the correct file.
240
+ */
241
+ const applyNotesManifest = useCallback(
242
+ (next: SlideshowManifest) => {
243
+ setManifest(next);
244
+ manifestRef.current = next;
245
+ notesCtrlRef.current.schedule(next, onPersistNotes, 450);
246
+ },
247
+ [onPersistNotes],
248
+ );
249
+
250
+ // Flush any pending notes persist when the component unmounts so we never
251
+ // silently drop an edit the user made right before navigating away.
252
+ useEffect(() => {
253
+ const ctrl = notesCtrlRef.current;
254
+ return () => {
255
+ ctrl.flush();
256
+ };
257
+ }, []);
258
+
259
+ const toggleSection = useCallback((key: SectionKey) => {
260
+ setExpandedSections((prev) => {
261
+ const next = new Set(prev);
262
+ if (next.has(key)) {
263
+ next.delete(key);
264
+ } else {
265
+ next.add(key);
266
+ }
267
+ return next;
268
+ });
269
+ }, []);
270
+
271
+ const activeSlides = selectedSequenceId
272
+ ? ((manifest.slideSequences ?? []).find((s) => s.id === selectedSequenceId)?.slides ?? [])
273
+ : manifest.slides;
274
+ const selectedSlide = activeSlides.find((s) => s.sceneId === selectedSceneId);
275
+ const sequences = manifest.slideSequences ?? [];
276
+
277
+ const handleSelectBranchSlide = useCallback((sequenceId: string, sceneId: string) => {
278
+ setSelectedSceneId(sceneId);
279
+ setSelectedSequenceId(sequenceId);
280
+ }, []);
281
+
282
+ const handleToggleSlide = useCallback(
283
+ (sceneId: string) => {
284
+ applyManifest(toggleMainLineSlide(manifestRef.current, sceneId)).catch(() => {});
285
+ },
286
+ [applyManifest],
287
+ );
288
+
289
+ const handleReorder = useCallback(
290
+ (sceneId: string, dir: "up" | "down") => {
291
+ applyManifest(reorderMainLineSlide(manifestRef.current, sceneId, dir)).catch(() => {});
292
+ },
293
+ [applyManifest],
294
+ );
295
+
296
+ const handleReorderBranchSlide = useCallback(
297
+ (sequenceId: string, sceneId: string, dir: "up" | "down") => {
298
+ applyManifest(reorderBranchSlide(manifestRef.current, sequenceId, sceneId, dir)).catch(
299
+ () => {},
300
+ );
301
+ },
302
+ [applyManifest],
303
+ );
304
+
305
+ const handleSetNotes = useCallback(
306
+ (notes: string) => {
307
+ if (!selectedSceneId) return;
308
+ applyNotesManifest(
309
+ setSlideNotes(manifestRef.current, selectedSceneId, notes, selectedSequenceId ?? undefined),
310
+ );
311
+ },
312
+ [selectedSceneId, selectedSequenceId, applyNotesManifest],
313
+ );
314
+
315
+ const handleMarkFragment = useCallback(() => {
316
+ if (!selectedSceneId) return;
317
+ applyManifest(
318
+ addFragment(
319
+ manifestRef.current,
320
+ selectedSceneId,
321
+ currentTime,
322
+ selectedSequenceId ?? undefined,
323
+ ),
324
+ ).catch(() => {});
325
+ }, [selectedSceneId, selectedSequenceId, currentTime, applyManifest]);
326
+
327
+ const handleRemoveFragment = useCallback(
328
+ (time: number) => {
329
+ if (!selectedSceneId) return;
330
+ applyManifest(
331
+ removeFragment(manifestRef.current, selectedSceneId, time, selectedSequenceId ?? undefined),
332
+ ).catch(() => {});
333
+ },
334
+ [selectedSceneId, selectedSequenceId, applyManifest],
335
+ );
336
+
337
+ const handleCreateSequence = useCallback(
338
+ (label: string) => {
339
+ const id = `seq-${crypto.randomUUID()}`;
340
+ applyManifest(createSequence(manifestRef.current, id, label)).catch(() => {});
341
+ },
342
+ [applyManifest],
343
+ );
344
+
345
+ const handleRenameSequence = useCallback(
346
+ (id: string, label: string) => {
347
+ applyManifest(renameSequence(manifestRef.current, id, label)).catch(() => {});
348
+ },
349
+ [applyManifest],
350
+ );
351
+
352
+ const handleDeleteSequence = useCallback(
353
+ (id: string) => {
354
+ // Deleting a branch removes its slides and orphans any hotspot targeting it —
355
+ // confirm first to prevent accidental data loss.
356
+ const seq = (manifestRef.current.slideSequences ?? []).find((s) => s.id === id);
357
+ const count = seq?.slides.length ?? 0;
358
+ const label = seq?.label ?? id;
359
+ const ok = window.confirm(
360
+ `Delete branch "${label}"${count ? ` and its ${count} slide${count === 1 ? "" : "s"}` : ""}? ` +
361
+ `Hotspots pointing to it will no longer resolve.`,
362
+ );
363
+ if (!ok) return;
364
+ applyManifest(deleteSequence(manifestRef.current, id)).catch(() => {});
365
+ },
366
+ [applyManifest],
367
+ );
368
+
369
+ const handleAssign = useCallback(
370
+ (sequenceId: string, sceneId: string, assign: boolean) => {
371
+ applyManifest(assignToBranch(manifestRef.current, sequenceId, sceneId, assign)).catch(
372
+ () => {},
373
+ );
374
+ },
375
+ [applyManifest],
376
+ );
377
+
378
+ const handleAddHotspot = useCallback(
379
+ (sceneId: string, hotspot: SlideHotspot) => {
380
+ applyManifest(
381
+ addHotspot(manifestRef.current, sceneId, hotspot, selectedSequenceId ?? undefined),
382
+ ).catch(() => {});
383
+ },
384
+ [selectedSequenceId, applyManifest],
385
+ );
386
+
387
+ const handleRemoveHotspot = useCallback(
388
+ (sceneId: string, hotspotId: string) => {
389
+ applyManifest(
390
+ removeHotspot(manifestRef.current, sceneId, hotspotId, selectedSequenceId ?? undefined),
391
+ ).catch(() => {});
392
+ },
393
+ [selectedSequenceId, applyManifest],
394
+ );
395
+
396
+ return (
397
+ <div className="flex flex-col h-full overflow-y-auto text-white">
398
+ <SectionHeader
399
+ expanded={expandedSections.has("slides")}
400
+ onToggle={() => toggleSection("slides")}
401
+ >
402
+ Slides ({manifest.slides.length})
403
+ </SectionHeader>
404
+ {expandedSections.has("slides") && (
405
+ <div className="py-1">
406
+ <SlideList
407
+ scenes={scenes}
408
+ slides={manifest.slides}
409
+ selectedSceneId={selectedSceneId}
410
+ onSelect={(id) => {
411
+ setSelectedSceneId(id);
412
+ setSelectedSequenceId(null);
413
+ }}
414
+ onToggle={handleToggleSlide}
415
+ onReorder={handleReorder}
416
+ />
417
+ </div>
418
+ )}
419
+
420
+ <SectionHeader
421
+ expanded={expandedSections.has("inspector")}
422
+ onToggle={() => toggleSection("inspector")}
423
+ >
424
+ Slide Inspector
425
+ </SectionHeader>
426
+ {expandedSections.has("inspector") && (
427
+ <>
428
+ {selectedSceneId ? (
429
+ <SlideInspector
430
+ sceneId={selectedSceneId}
431
+ slide={selectedSlide}
432
+ currentTime={currentTime}
433
+ onSetNotes={handleSetNotes}
434
+ onMarkFragment={handleMarkFragment}
435
+ onRemoveFragment={handleRemoveFragment}
436
+ />
437
+ ) : (
438
+ <p className="px-3 py-2 text-[11px] text-neutral-500 italic">
439
+ Select a scene above to inspect
440
+ </p>
441
+ )}
442
+ </>
443
+ )}
444
+
445
+ <SectionHeader
446
+ expanded={expandedSections.has("branches")}
447
+ onToggle={() => toggleSection("branches")}
448
+ >
449
+ Branches ({sequences.length})
450
+ </SectionHeader>
451
+ {expandedSections.has("branches") && (
452
+ <BranchTree
453
+ sequences={sequences}
454
+ scenes={scenes}
455
+ onCreateSequence={handleCreateSequence}
456
+ onRenameSequence={handleRenameSequence}
457
+ onDeleteSequence={handleDeleteSequence}
458
+ onAssign={handleAssign}
459
+ selectedSceneId={selectedSceneId}
460
+ selectedSequenceId={selectedSequenceId}
461
+ onSelectBranchSlide={handleSelectBranchSlide}
462
+ onReorderBranchSlide={handleReorderBranchSlide}
463
+ />
464
+ )}
465
+
466
+ <SectionHeader
467
+ expanded={expandedSections.has("hotspot")}
468
+ onToggle={() => toggleSection("hotspot")}
469
+ >
470
+ Hotspot Tool
471
+ </SectionHeader>
472
+ {expandedSections.has("hotspot") && (
473
+ <HotspotTool
474
+ selectedSceneId={selectedSceneId}
475
+ slide={selectedSlide}
476
+ domEditSelection={domEditSelection}
477
+ sequences={sequences}
478
+ onAddHotspot={handleAddHotspot}
479
+ onRemoveHotspot={handleRemoveHotspot}
480
+ />
481
+ )}
482
+ </div>
483
+ );
484
+ }