@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.
@@ -0,0 +1,536 @@
1
+ /**
2
+ * SlideshowSubPanels — internal sub-surface components for SlideshowPanel.
3
+ * Not exported from the package index; used only by SlideshowPanel.tsx.
4
+ */
5
+
6
+ import { useState, useCallback, useId } from "react";
7
+ import type { SlideRef, SlideHotspot, SlideSequence } from "@hyperframes/core/slideshow";
8
+ import type { DomEditSelection } from "../editor/domEditing";
9
+ import type { SceneInfo } from "./slideshowPanelHelpers";
10
+
11
+ // ── Section header (accordion toggle) ────────────────────────────────────
12
+
13
+ export function SectionHeader({
14
+ children,
15
+ expanded,
16
+ onToggle,
17
+ }: {
18
+ children: React.ReactNode;
19
+ expanded: boolean;
20
+ onToggle: () => void;
21
+ }) {
22
+ return (
23
+ <button
24
+ type="button"
25
+ className="flex w-full items-center justify-between px-3 py-2 text-[11px] font-medium text-neutral-400 hover:text-neutral-200 border-b border-neutral-800 transition-colors"
26
+ onClick={onToggle}
27
+ aria-expanded={expanded}
28
+ >
29
+ <span>{children}</span>
30
+ <span className="text-[10px] text-neutral-600">{expanded ? "▲" : "▼"}</span>
31
+ </button>
32
+ );
33
+ }
34
+
35
+ // ── Sub-surface: Slide List ──────────────────────────────────────────────
36
+
37
+ export interface SlideListProps {
38
+ scenes: SceneInfo[];
39
+ slides: SlideRef[];
40
+ selectedSceneId: string | null;
41
+ onSelect: (sceneId: string) => void;
42
+ onToggle: (sceneId: string) => void;
43
+ onReorder: (sceneId: string, dir: "up" | "down") => void;
44
+ }
45
+
46
+ export function SlideList({
47
+ scenes,
48
+ slides,
49
+ selectedSceneId,
50
+ onSelect,
51
+ onToggle,
52
+ onReorder,
53
+ }: SlideListProps) {
54
+ const slideIds = new Set(slides.map((s) => s.sceneId));
55
+ const sceneById = new Map(scenes.map((s) => [s.id, s]));
56
+ const orderedSlideScenes = slides
57
+ .map((sl) => sceneById.get(sl.sceneId))
58
+ .filter((s): s is SceneInfo => s !== undefined);
59
+ const nonSlideScenes = scenes.filter((sc) => !slideIds.has(sc.id));
60
+ const rows = [...orderedSlideScenes, ...nonSlideScenes];
61
+ return (
62
+ <div className="flex flex-col gap-px">
63
+ {rows.map((scene) => {
64
+ const isSlide = slideIds.has(scene.id);
65
+ const isSelected = selectedSceneId === scene.id;
66
+ return (
67
+ <div
68
+ key={scene.id}
69
+ role="button"
70
+ tabIndex={0}
71
+ aria-pressed={isSelected}
72
+ className={`flex items-center gap-2 px-3 py-1.5 rounded cursor-pointer text-[11px] transition-colors ${
73
+ isSelected
74
+ ? "bg-studio-accent/20 text-white"
75
+ : "hover:bg-neutral-800/60 text-neutral-300"
76
+ }`}
77
+ onClick={() => onSelect(scene.id)}
78
+ onKeyDown={(e) => {
79
+ if (e.key === "Enter" || e.key === " ") {
80
+ e.preventDefault();
81
+ onSelect(scene.id);
82
+ }
83
+ }}
84
+ >
85
+ <input
86
+ type="checkbox"
87
+ aria-label={`Include ${scene.label} as main-line slide`}
88
+ checked={isSlide}
89
+ onChange={() => onToggle(scene.id)}
90
+ onClick={(e) => e.stopPropagation()}
91
+ className="accent-studio-accent flex-shrink-0"
92
+ />
93
+ <span className="flex-1 truncate">{scene.label || scene.id}</span>
94
+ {isSlide && (
95
+ <span className="flex gap-0.5 flex-shrink-0">
96
+ <button
97
+ type="button"
98
+ aria-label="Move slide up"
99
+ title="Move up"
100
+ className="px-1 py-0.5 text-[10px] text-neutral-400 hover:text-white disabled:opacity-30"
101
+ onClick={(e) => {
102
+ e.stopPropagation();
103
+ onReorder(scene.id, "up");
104
+ }}
105
+ >
106
+
107
+ </button>
108
+ <button
109
+ type="button"
110
+ aria-label="Move slide down"
111
+ title="Move down"
112
+ className="px-1 py-0.5 text-[10px] text-neutral-400 hover:text-white disabled:opacity-30"
113
+ onClick={(e) => {
114
+ e.stopPropagation();
115
+ onReorder(scene.id, "down");
116
+ }}
117
+ >
118
+
119
+ </button>
120
+ </span>
121
+ )}
122
+ </div>
123
+ );
124
+ })}
125
+ {scenes.length === 0 && (
126
+ <p className="px-3 py-2 text-[11px] text-neutral-500 italic">No scenes found</p>
127
+ )}
128
+ </div>
129
+ );
130
+ }
131
+
132
+ // ── Sub-surface: Slide Inspector ─────────────────────────────────────────
133
+
134
+ export interface SlideInspectorProps {
135
+ sceneId: string;
136
+ slide: SlideRef | undefined;
137
+ currentTime: number;
138
+ onSetNotes: (notes: string) => void;
139
+ onMarkFragment: () => void;
140
+ onRemoveFragment: (time: number) => void;
141
+ }
142
+
143
+ // fallow-ignore-next-line complexity
144
+ export function SlideInspector({
145
+ sceneId,
146
+ slide,
147
+ currentTime,
148
+ onSetNotes,
149
+ onMarkFragment,
150
+ onRemoveFragment,
151
+ }: SlideInspectorProps) {
152
+ const fragments = slide?.fragments ?? [];
153
+ return (
154
+ <div className="flex flex-col gap-3 px-3 py-2">
155
+ <p className="text-[10px] text-neutral-500 font-medium uppercase tracking-wide truncate">
156
+ Scene: {sceneId}
157
+ </p>
158
+ <div className="flex flex-col gap-1">
159
+ <label className="text-[11px] text-neutral-400">Notes</label>
160
+ <textarea
161
+ className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1.5 text-[11px] text-white resize-none placeholder-neutral-600 focus:border-studio-accent/60 focus:outline-none"
162
+ rows={3}
163
+ placeholder="Speaker notes or script..."
164
+ value={slide?.notes ?? ""}
165
+ onChange={(e) => onSetNotes(e.target.value)}
166
+ />
167
+ </div>
168
+ <div className="flex flex-col gap-1.5">
169
+ <div className="flex items-center justify-between">
170
+ <span className="text-[11px] text-neutral-400">Fragment hold-points</span>
171
+ <button
172
+ type="button"
173
+ className="text-[10px] px-2 py-0.5 rounded bg-neutral-700 hover:bg-neutral-600 text-neutral-200 transition-colors"
174
+ onClick={onMarkFragment}
175
+ title={`Mark ${currentTime.toFixed(2)}s as hold-point`}
176
+ >
177
+ Mark {currentTime.toFixed(2)}s
178
+ </button>
179
+ </div>
180
+ {fragments.length > 0 ? (
181
+ <div className="flex flex-wrap gap-1">
182
+ {fragments.map((t, i) => (
183
+ <span
184
+ key={`frag-${i}`}
185
+ className="inline-flex items-center gap-1 bg-neutral-700 rounded px-1.5 py-0.5 text-[10px] text-neutral-200"
186
+ >
187
+ {t.toFixed(2)}s
188
+ <button
189
+ type="button"
190
+ aria-label={`Remove fragment at ${t.toFixed(2)}s`}
191
+ className="text-neutral-400 hover:text-red-400 transition-colors"
192
+ onClick={() => onRemoveFragment(t)}
193
+ >
194
+ ×
195
+ </button>
196
+ </span>
197
+ ))}
198
+ </div>
199
+ ) : (
200
+ <p className="text-[10px] text-neutral-600 italic">No hold-points yet</p>
201
+ )}
202
+ </div>
203
+ </div>
204
+ );
205
+ }
206
+
207
+ // ── Sub-surface: Branch Tree ──────────────────────────────────────────────
208
+
209
+ export interface BranchTreeProps {
210
+ sequences: SlideSequence[];
211
+ scenes: SceneInfo[];
212
+ onCreateSequence: (label: string) => void;
213
+ onRenameSequence: (id: string, label: string) => void;
214
+ onDeleteSequence: (id: string) => void;
215
+ onAssign: (sequenceId: string, sceneId: string, assign: boolean) => void;
216
+ selectedSceneId: string | null;
217
+ selectedSequenceId: string | null;
218
+ onSelectBranchSlide: (sequenceId: string, sceneId: string) => void;
219
+ onReorderBranchSlide: (sequenceId: string, sceneId: string, dir: "up" | "down") => void;
220
+ }
221
+
222
+ export function BranchTree({
223
+ sequences,
224
+ scenes,
225
+ onCreateSequence,
226
+ onRenameSequence,
227
+ onDeleteSequence,
228
+ onAssign,
229
+ selectedSceneId,
230
+ selectedSequenceId,
231
+ onSelectBranchSlide,
232
+ onReorderBranchSlide,
233
+ }: BranchTreeProps) {
234
+ const [newLabel, setNewLabel] = useState("");
235
+ const inputId = useId();
236
+
237
+ const handleCreate = useCallback(() => {
238
+ const label = newLabel.trim();
239
+ if (!label) return;
240
+ onCreateSequence(label);
241
+ setNewLabel("");
242
+ }, [newLabel, onCreateSequence]);
243
+
244
+ return (
245
+ <div className="flex flex-col gap-3 px-3 py-2">
246
+ <div className="flex gap-1.5">
247
+ <input
248
+ id={inputId}
249
+ type="text"
250
+ className="flex-1 bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-[11px] text-white placeholder-neutral-600 focus:border-studio-accent/60 focus:outline-none"
251
+ placeholder="New branch name..."
252
+ value={newLabel}
253
+ onChange={(e) => setNewLabel(e.target.value)}
254
+ onKeyDown={(e) => {
255
+ if (e.key === "Enter") handleCreate();
256
+ }}
257
+ aria-label="New branch sequence name"
258
+ />
259
+ <button
260
+ type="button"
261
+ className="px-2 py-1 rounded bg-neutral-700 hover:bg-neutral-600 text-[11px] text-neutral-200 transition-colors flex-shrink-0"
262
+ onClick={handleCreate}
263
+ >
264
+ Add
265
+ </button>
266
+ </div>
267
+
268
+ {sequences.length === 0 ? (
269
+ <p className="text-[10px] text-neutral-600 italic">No branches yet</p>
270
+ ) : (
271
+ <div className="flex flex-col gap-3">
272
+ {sequences.map((seq) => (
273
+ <BranchItem
274
+ key={seq.id}
275
+ seq={seq}
276
+ scenes={scenes}
277
+ onRename={onRenameSequence}
278
+ onDelete={onDeleteSequence}
279
+ onAssign={onAssign}
280
+ selectedSceneId={selectedSceneId}
281
+ selectedSequenceId={selectedSequenceId}
282
+ onSelectBranchSlide={onSelectBranchSlide}
283
+ onReorderBranchSlide={onReorderBranchSlide}
284
+ />
285
+ ))}
286
+ </div>
287
+ )}
288
+ </div>
289
+ );
290
+ }
291
+
292
+ interface BranchItemProps {
293
+ seq: SlideSequence;
294
+ scenes: SceneInfo[];
295
+ onRename: (id: string, label: string) => void;
296
+ onDelete: (id: string) => void;
297
+ onAssign: (sequenceId: string, sceneId: string, assign: boolean) => void;
298
+ selectedSceneId: string | null;
299
+ selectedSequenceId: string | null;
300
+ onSelectBranchSlide: (sequenceId: string, sceneId: string) => void;
301
+ onReorderBranchSlide: (sequenceId: string, sceneId: string, dir: "up" | "down") => void;
302
+ }
303
+
304
+ function BranchItem({
305
+ seq,
306
+ scenes,
307
+ onRename,
308
+ onDelete,
309
+ onAssign,
310
+ selectedSceneId,
311
+ selectedSequenceId,
312
+ onSelectBranchSlide,
313
+ onReorderBranchSlide,
314
+ }: BranchItemProps) {
315
+ const [editing, setEditing] = useState(false);
316
+ const [draft, setDraft] = useState(seq.label);
317
+
318
+ const commitRename = useCallback(() => {
319
+ const label = draft.trim();
320
+ if (label && label !== seq.label) onRename(seq.id, label);
321
+ setEditing(false);
322
+ }, [draft, onRename, seq.id, seq.label]);
323
+
324
+ return (
325
+ <div className="border border-neutral-700/60 rounded p-2 flex flex-col gap-2">
326
+ <div className="flex items-center gap-1">
327
+ {editing ? (
328
+ <input
329
+ className="flex-1 bg-neutral-800 border border-neutral-600 rounded px-1.5 py-0.5 text-[11px] text-white focus:border-studio-accent/60 focus:outline-none"
330
+ value={draft}
331
+ autoFocus
332
+ onChange={(e) => setDraft(e.target.value)}
333
+ onBlur={commitRename}
334
+ onKeyDown={(e) => {
335
+ if (e.key === "Enter") commitRename();
336
+ if (e.key === "Escape") setEditing(false);
337
+ }}
338
+ aria-label={`Rename branch ${seq.label}`}
339
+ />
340
+ ) : (
341
+ <span
342
+ role="button"
343
+ tabIndex={0}
344
+ className="flex-1 text-[11px] text-white font-medium truncate cursor-pointer hover:text-neutral-300"
345
+ title="Click to rename"
346
+ onClick={() => setEditing(true)}
347
+ onKeyDown={(e) => {
348
+ if (e.key === "Enter" || e.key === " ") setEditing(true);
349
+ }}
350
+ >
351
+ {seq.label}
352
+ </span>
353
+ )}
354
+ <button
355
+ type="button"
356
+ aria-label={`Delete branch ${seq.label}`}
357
+ className="text-[10px] text-neutral-500 hover:text-red-400 transition-colors px-1"
358
+ onClick={() => onDelete(seq.id)}
359
+ >
360
+
361
+ </button>
362
+ </div>
363
+ <div className="flex flex-col gap-px pl-2">
364
+ {scenes.map((scene) => {
365
+ const branchPos = seq.slides.findIndex((s) => s.sceneId === scene.id);
366
+ const assigned = branchPos !== -1;
367
+ const isSelected = selectedSequenceId === seq.id && selectedSceneId === scene.id;
368
+ return (
369
+ <div
370
+ key={scene.id}
371
+ className="flex items-center gap-1.5 py-0.5 text-[11px] text-neutral-400"
372
+ >
373
+ <input
374
+ type="checkbox"
375
+ aria-label={`Assign ${scene.label || scene.id} to branch ${seq.label}`}
376
+ checked={assigned}
377
+ onChange={(e) => onAssign(seq.id, scene.id, e.target.checked)}
378
+ className="accent-studio-accent flex-shrink-0"
379
+ />
380
+ {assigned ? (
381
+ <>
382
+ <button
383
+ type="button"
384
+ aria-pressed={isSelected}
385
+ className={`flex-1 text-left truncate transition-colors hover:text-neutral-200 ${
386
+ isSelected ? "text-white" : "text-neutral-400"
387
+ }`}
388
+ onClick={() => onSelectBranchSlide(seq.id, scene.id)}
389
+ >
390
+ {scene.label || scene.id}
391
+ </button>
392
+ <span className="text-[9px] text-neutral-600 tabular-nums flex-shrink-0">
393
+ {branchPos + 1}/{seq.slides.length}
394
+ </span>
395
+ <button
396
+ type="button"
397
+ aria-label={`Move ${scene.label || scene.id} earlier in branch ${seq.label}`}
398
+ disabled={branchPos <= 0}
399
+ className="text-[10px] text-neutral-500 hover:text-neutral-200 disabled:opacity-30 disabled:cursor-default px-0.5"
400
+ onClick={() => onReorderBranchSlide(seq.id, scene.id, "up")}
401
+ >
402
+
403
+ </button>
404
+ <button
405
+ type="button"
406
+ aria-label={`Move ${scene.label || scene.id} later in branch ${seq.label}`}
407
+ disabled={branchPos >= seq.slides.length - 1}
408
+ className="text-[10px] text-neutral-500 hover:text-neutral-200 disabled:opacity-30 disabled:cursor-default px-0.5"
409
+ onClick={() => onReorderBranchSlide(seq.id, scene.id, "down")}
410
+ >
411
+
412
+ </button>
413
+ </>
414
+ ) : (
415
+ <span className="flex-1 truncate">{scene.label || scene.id}</span>
416
+ )}
417
+ </div>
418
+ );
419
+ })}
420
+ {scenes.length === 0 && <p className="text-[10px] text-neutral-600 italic">No scenes</p>}
421
+ </div>
422
+ </div>
423
+ );
424
+ }
425
+
426
+ // ── Sub-surface: Hotspot Tool ─────────────────────────────────────────────
427
+
428
+ export interface HotspotToolProps {
429
+ selectedSceneId: string | null;
430
+ slide: SlideRef | undefined;
431
+ domEditSelection: DomEditSelection | null;
432
+ sequences: SlideSequence[];
433
+ onAddHotspot: (sceneId: string, hotspot: SlideHotspot) => void;
434
+ onRemoveHotspot: (sceneId: string, hotspotId: string) => void;
435
+ }
436
+
437
+ // fallow-ignore-next-line complexity
438
+ export function HotspotTool({
439
+ selectedSceneId,
440
+ slide,
441
+ domEditSelection,
442
+ sequences,
443
+ onAddHotspot,
444
+ onRemoveHotspot,
445
+ }: HotspotToolProps) {
446
+ const [targetSequenceId, setTargetSequenceId] = useState("");
447
+ const [hotspotLabel, setHotspotLabel] = useState("");
448
+ const hotspots = slide?.hotspots ?? [];
449
+
450
+ const selectedElementId = domEditSelection?.element?.id ?? null;
451
+ const selectedHfId = domEditSelection?.hfId ?? null;
452
+ const elementKey = selectedElementId || selectedHfId;
453
+
454
+ // fallow-ignore-next-line complexity
455
+ const handleMakeHotspot = useCallback(() => {
456
+ if (!selectedSceneId || !targetSequenceId || !elementKey) return;
457
+ const id = `hotspot-${elementKey}-${crypto.randomUUID()}`;
458
+ const label = hotspotLabel.trim() || elementKey;
459
+ onAddHotspot(selectedSceneId, { id, label, target: targetSequenceId });
460
+ setHotspotLabel("");
461
+ }, [selectedSceneId, targetSequenceId, elementKey, hotspotLabel, onAddHotspot]);
462
+
463
+ if (!selectedSceneId) {
464
+ return (
465
+ <div className="px-3 py-2">
466
+ <p className="text-[11px] text-neutral-500 italic">Select a scene in the Slides list</p>
467
+ </div>
468
+ );
469
+ }
470
+
471
+ return (
472
+ <div className="flex flex-col gap-3 px-3 py-2">
473
+ <div className="flex flex-col gap-1.5">
474
+ <p className="text-[11px] text-neutral-400">
475
+ Selected element:{" "}
476
+ <span className="text-neutral-200 font-mono">{elementKey ?? "none"}</span>
477
+ </p>
478
+ <label className="text-[11px] text-neutral-400">Hotspot label</label>
479
+ <input
480
+ type="text"
481
+ className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-[11px] text-white placeholder-neutral-600 focus:border-studio-accent/60 focus:outline-none"
482
+ placeholder="Button label..."
483
+ value={hotspotLabel}
484
+ onChange={(e) => setHotspotLabel(e.target.value)}
485
+ aria-label="Hotspot label"
486
+ />
487
+ <label className="text-[11px] text-neutral-400">Target branch</label>
488
+ <select
489
+ className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-[11px] text-white focus:border-studio-accent/60 focus:outline-none"
490
+ value={targetSequenceId}
491
+ onChange={(e) => setTargetSequenceId(e.target.value)}
492
+ aria-label="Target branch sequence"
493
+ >
494
+ <option value="">— select branch —</option>
495
+ {sequences.map((seq) => (
496
+ <option key={seq.id} value={seq.id}>
497
+ {seq.label}
498
+ </option>
499
+ ))}
500
+ </select>
501
+ <button
502
+ type="button"
503
+ disabled={!elementKey || !targetSequenceId}
504
+ className="px-3 py-1.5 rounded bg-studio-accent/80 hover:bg-studio-accent text-white text-[11px] font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
505
+ onClick={handleMakeHotspot}
506
+ >
507
+ Make hotspot
508
+ </button>
509
+ </div>
510
+
511
+ {hotspots.length > 0 && (
512
+ <div className="flex flex-col gap-1.5">
513
+ <p className="text-[11px] text-neutral-400 font-medium">Hotspots on this slide</p>
514
+ {hotspots.map((h) => {
515
+ const seqLabel = sequences.find((s) => s.id === h.target)?.label ?? h.target;
516
+ return (
517
+ <div key={h.id} className="flex items-center gap-2 bg-neutral-800 rounded px-2 py-1">
518
+ <span className="flex-1 text-[11px] text-neutral-200 truncate">
519
+ {h.label} → <span className="text-neutral-400">{seqLabel}</span>
520
+ </span>
521
+ <button
522
+ type="button"
523
+ aria-label={`Remove hotspot ${h.label}`}
524
+ className="text-[10px] text-neutral-500 hover:text-red-400 transition-colors"
525
+ onClick={() => onRemoveHotspot(selectedSceneId, h.id)}
526
+ >
527
+
528
+ </button>
529
+ </div>
530
+ );
531
+ })}
532
+ </div>
533
+ )}
534
+ </div>
535
+ );
536
+ }