@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,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
|
+
}
|