feat(studio): slideshow branching editor panel (#1582)

New Slideshow right-panel tab — slide list, inspector (notes + fragment
hold-points), branch tree, hotspot tool — backed by pure manifest-transform
helpers and a debounced SDK persist that writes the JSON island.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-19 01:33:31 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7af3eb8f80
commit 3861e8e9fc
11 changed files with 1936 additions and 7 deletions
+4
View File
@@ -566,6 +566,10 @@ export function StudioApp() {
onToggleRecording={
STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined
}
sdkSession={sdkHandle.session}
reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef}
recordEdit={editHistory.recordEdit}
/>
)}
</div>
@@ -1,13 +1,26 @@
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import {
useCallback,
useMemo,
useRef,
useState,
type MutableRefObject,
type PointerEvent as ReactPointerEvent,
} from "react";
import { Tooltip } from "./ui";
import { PropertyPanel } from "./editor/PropertyPanel";
import { LayersPanel } from "./editor/LayersPanel";
import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
import { BlockParamsPanel } from "./editor/BlockParamsPanel";
import { RenderQueue } from "./renders/RenderQueue";
import { SlideshowPanel } from "./panels/SlideshowPanel";
import type { SceneInfo } from "./panels/SlideshowPanel";
import type { RenderJob } from "./renders/useRenderQueue";
import type { BlockParam } from "@hyperframes/core/registry";
import type { IframeWindow } from "../player/lib/playbackTypes";
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailability";
import type { Composition } from "@hyperframes/sdk";
import type { EditHistoryKind } from "../utils/editHistory";
import { useSlideshowPersist } from "../hooks/useSlideshowPersist";
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
@@ -30,6 +43,15 @@ export interface StudioRightPanelProps {
recordingState?: "idle" | "recording" | "preview";
recordingDuration?: number;
onToggleRecording?: () => void;
/** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
sdkSession: Composition | null;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
}
// fallow-ignore-next-line complexity
@@ -40,6 +62,10 @@ export function StudioRightPanel({
recordingState,
recordingDuration,
onToggleRecording,
sdkSession,
reloadPreview,
domEditSaveTimestampRef,
recordEdit,
}: StudioRightPanelProps) {
const {
rightWidth,
@@ -60,7 +86,7 @@ export function StudioRightPanel({
waitForPendingDomEditSaves,
renderQueue,
} = useStudioShellContext();
const { captionEditMode } = useStudioPlaybackContext();
const { captionEditMode, refreshKey } = useStudioPlaybackContext();
const {
domEditSelection,
@@ -100,8 +126,40 @@ export function StudioRightPanel({
handleGsapConvertToKeyframes,
} = useDomEditContext();
const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } =
useFileManagerContext();
const {
assets,
fontAssets,
projectDir,
handleImportFiles,
handleImportFonts,
readProjectFile,
writeProjectFile,
} = useFileManagerContext();
// Discrete ops (toggle, reorder, add/delete, hotspot): persist immediately,
// no coalescing — each is a distinct user action that deserves its own undo entry.
const onPersistSlideshow = useSlideshowPersist({
sdkSession,
activeCompPath,
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
});
// Notes path: persists are debounced in SlideshowPanel; coalesceKey ensures
// rapid writes collapse into a single undo entry via the save-queue infra.
const onPersistSlideshowNotes = useSlideshowPersist({
sdkSession,
activeCompPath,
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
coalesceKey: activeCompPath ? `slideshow-notes:${activeCompPath}` : "slideshow-notes",
});
const [layersPanePercent, setLayersPanePercent] = useState(40);
const splitContainerRef = useRef<HTMLDivElement>(null);
@@ -113,6 +171,23 @@ export function StudioRightPanel({
const renderJobs = renderQueue.jobs as RenderJob[];
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
// Derive scene list from the live clip manifest in the preview iframe.
// fallow-ignore-next-line complexity
const slideshowScenes = useMemo<SceneInfo[]>(() => {
try {
const win = previewIframeRef.current?.contentWindow as IframeWindow | null;
return (win?.__clipManifest?.scenes ?? []).map((s) => ({
id: s.id,
label: s.label,
start: s.start,
duration: s.duration,
}));
} catch {
return [];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewIframeRef, rightPanelTab, refreshKey]);
const designPaneOpen = inspectorTabActive && rightInspectorPanes.design && designPanelActive;
const layersPaneOpen =
inspectorTabActive && rightInspectorPanes.layers && STUDIO_INSPECTOR_PANELS_ENABLED;
@@ -291,6 +366,19 @@ export function StudioRightPanel({
{renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
</button>
</Tooltip>
<Tooltip label="Slideshow branching editor" side="bottom">
<button
type="button"
onClick={() => setRightPanelTab("slideshow")}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
rightPanelTab === "slideshow"
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
}`}
>
Slideshow
</button>
</Tooltip>
</div>
<div className="min-h-0 flex-1">
{rightPanelTab === "block-params" && activeBlockParams ? (
@@ -301,6 +389,12 @@ export function StudioRightPanel({
compositionPath={activeBlockParams.compositionPath}
onClose={onCloseBlockParams ?? (() => {})}
/>
) : rightPanelTab === "slideshow" ? (
<SlideshowPanel
scenes={slideshowScenes}
onPersist={onPersistSlideshow}
onPersistNotes={onPersistSlideshowNotes}
/>
) : layersPaneOpen && designPaneOpen ? (
<div ref={splitContainerRef} className="flex h-full min-h-0 flex-col">
<div
@@ -0,0 +1,460 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
toggleMainLineSlide,
reorderMainLineSlide,
setSlideNotes,
addFragment,
removeFragment,
createSequence,
renameSequence,
deleteSequence,
assignToBranch,
addHotspot,
removeHotspot,
safeParseManifest,
makeSlideshowNotesController,
} from "./SlideshowPanel";
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
// ── toggleMainLineSlide ────────────────────────────────────────────────────
describe("toggleMainLineSlide", () => {
it("adds a scene as a slide when absent", () => {
const m = toggleMainLineSlide({ slides: [] }, "a");
expect(m.slides).toEqual([{ sceneId: "a" }]);
});
it("removes a scene when already present", () => {
const m = toggleMainLineSlide({ slides: [{ sceneId: "a" }] }, "a");
expect(m.slides).toEqual([]);
});
it("does not mutate the input manifest", () => {
const input: SlideshowManifest = { slides: [{ sceneId: "a" }] };
toggleMainLineSlide(input, "a");
expect(input.slides.length).toBe(1);
});
it("leaves other slides intact when removing", () => {
const m = toggleMainLineSlide({ slides: [{ sceneId: "a" }, { sceneId: "b" }] }, "a");
expect(m.slides).toEqual([{ sceneId: "b" }]);
});
});
// ── reorderMainLineSlide ───────────────────────────────────────────────────
describe("reorderMainLineSlide", () => {
it("moves a slide up", () => {
const m = reorderMainLineSlide({ slides: [{ sceneId: "a" }, { sceneId: "b" }] }, "b", "up");
expect(m.slides.map((s) => s.sceneId)).toEqual(["b", "a"]);
});
it("moves a slide down", () => {
const m = reorderMainLineSlide({ slides: [{ sceneId: "a" }, { sceneId: "b" }] }, "a", "down");
expect(m.slides.map((s) => s.sceneId)).toEqual(["b", "a"]);
});
it("returns unchanged manifest when moving first slide up", () => {
const input: SlideshowManifest = { slides: [{ sceneId: "a" }, { sceneId: "b" }] };
const m = reorderMainLineSlide(input, "a", "up");
expect(m.slides.map((s) => s.sceneId)).toEqual(["a", "b"]);
});
it("returns unchanged manifest for unknown sceneId", () => {
const input: SlideshowManifest = { slides: [{ sceneId: "a" }] };
const m = reorderMainLineSlide(input, "z", "up");
expect(m.slides).toEqual(input.slides);
});
});
// ── setSlideNotes ──────────────────────────────────────────────────────────
describe("setSlideNotes", () => {
it("updates notes on an existing slide", () => {
const m = setSlideNotes({ slides: [{ sceneId: "a" }] }, "a", "hello");
expect(m.slides[0]).toMatchObject({ sceneId: "a", notes: "hello" });
});
it("creates the slide entry if absent", () => {
const m = setSlideNotes({ slides: [] }, "a", "note");
expect(m.slides).toEqual([{ sceneId: "a", notes: "note" }]);
});
});
// ── addFragment ───────────────────────────────────────────────────────────
describe("addFragment", () => {
it("adds a fragment time to a slide", () => {
const m = addFragment({ slides: [{ sceneId: "a" }] }, "a", 1.5);
expect(m.slides[0]?.fragments).toEqual([1.5]);
});
it("deduplicates repeated fragment values", () => {
const m1 = addFragment({ slides: [{ sceneId: "a" }] }, "a", 1.5);
const m2 = addFragment(m1, "a", 1.5);
expect(m2.slides[0]?.fragments).toEqual([1.5]);
});
it("keeps fragments sorted ascending", () => {
let m: SlideshowManifest = { slides: [{ sceneId: "a" }] };
m = addFragment(m, "a", 3.0);
m = addFragment(m, "a", 1.0);
m = addFragment(m, "a", 2.0);
expect(m.slides[0]?.fragments).toEqual([1.0, 2.0, 3.0]);
});
it("creates the slide entry if absent", () => {
const m = addFragment({ slides: [] }, "a", 0.5);
expect(m.slides[0]).toMatchObject({ sceneId: "a", fragments: [0.5] });
});
});
// ── removeFragment ─────────────────────────────────────────────────────────
describe("removeFragment", () => {
it("removes the specified fragment", () => {
const m = removeFragment({ slides: [{ sceneId: "a", fragments: [1.0, 2.0] }] }, "a", 1.0);
expect(m.slides[0]?.fragments).toEqual([2.0]);
});
it("no-ops when fragment not present", () => {
const m = removeFragment({ slides: [{ sceneId: "a", fragments: [1.0] }] }, "a", 9.0);
expect(m.slides[0]?.fragments).toEqual([1.0]);
});
});
// ── createSequence ─────────────────────────────────────────────────────────
describe("createSequence", () => {
it("creates a new sequence", () => {
const m = createSequence({ slides: [] }, "seq-1", "Branch A");
expect(m.slideSequences).toEqual([{ id: "seq-1", label: "Branch A", slides: [] }]);
});
it("rejects duplicate ids", () => {
const m1 = createSequence({ slides: [] }, "seq-1", "Branch A");
const m2 = createSequence(m1, "seq-1", "Branch A duplicate");
expect((m2.slideSequences ?? []).length).toBe(1);
});
it("preserves existing sequences", () => {
const m1 = createSequence({ slides: [] }, "seq-1", "A");
const m2 = createSequence(m1, "seq-2", "B");
expect((m2.slideSequences ?? []).length).toBe(2);
});
});
// ── renameSequence ─────────────────────────────────────────────────────────
describe("renameSequence", () => {
it("renames a sequence label", () => {
const m = renameSequence(
{ slides: [], slideSequences: [{ id: "seq-1", label: "Old", slides: [] }] },
"seq-1",
"New",
);
expect(m.slideSequences?.[0]?.label).toBe("New");
});
it("no-ops on unknown id", () => {
const input: SlideshowManifest = {
slides: [],
slideSequences: [{ id: "seq-1", label: "A", slides: [] }],
};
const m = renameSequence(input, "unknown", "B");
expect(m.slideSequences?.[0]?.label).toBe("A");
});
});
// ── deleteSequence ─────────────────────────────────────────────────────────
describe("deleteSequence", () => {
it("removes the sequence by id", () => {
const m = deleteSequence(
{ slides: [], slideSequences: [{ id: "seq-1", label: "A", slides: [] }] },
"seq-1",
);
expect(m.slideSequences).toEqual([]);
});
it("removes hotspots targeting the deleted sequence from main-line slides", () => {
const input: SlideshowManifest = {
slides: [
{
sceneId: "s1",
hotspots: [
{ id: "h1", label: "Go deep", target: "deep" },
{ id: "h2", label: "Other", target: "other-seq" },
],
},
],
slideSequences: [
{ id: "deep", label: "Deep", slides: [] },
{ id: "other-seq", label: "Other", slides: [] },
],
};
const m = deleteSequence(input, "deep");
expect(m.slides[0]?.hotspots?.map((h) => h.id)).toEqual(["h2"]);
expect(m.slideSequences?.some((s) => s.id === "deep")).toBe(false);
// Verify no slide anywhere references 'deep'
const allHotspotTargets = [
...m.slides.flatMap((s) => (s.hotspots ?? []).map((h) => h.target)),
...(m.slideSequences ?? []).flatMap((seq) =>
seq.slides.flatMap((s) => (s.hotspots ?? []).map((h) => h.target)),
),
];
expect(allHotspotTargets).not.toContain("deep");
});
it("removes hotspots targeting the deleted sequence from sequence slides", () => {
const input: SlideshowManifest = {
slides: [],
slideSequences: [
{ id: "deep", label: "Deep", slides: [] },
{
id: "other",
label: "Other",
slides: [
{
sceneId: "s2",
hotspots: [{ id: "h3", label: "To deep", target: "deep" }],
},
],
},
],
};
const m = deleteSequence(input, "deep");
const otherSeq = m.slideSequences?.find((s) => s.id === "other");
expect(otherSeq?.slides[0]?.hotspots).toEqual([]);
});
});
// ── assignToBranch ─────────────────────────────────────────────────────────
describe("assignToBranch", () => {
it("assigns a scene to a branch", () => {
const m = assignToBranch(
{ slides: [], slideSequences: [{ id: "seq-1", label: "A", slides: [] }] },
"seq-1",
"s1",
true,
);
expect(m.slideSequences?.[0]?.slides).toEqual([{ sceneId: "s1" }]);
});
it("does not duplicate when assigning twice", () => {
let m: SlideshowManifest = {
slides: [],
slideSequences: [{ id: "seq-1", label: "A", slides: [] }],
};
m = assignToBranch(m, "seq-1", "s1", true);
m = assignToBranch(m, "seq-1", "s1", true);
expect(m.slideSequences?.[0]?.slides.length).toBe(1);
});
it("removes a scene when assign=false", () => {
const m = assignToBranch(
{
slides: [],
slideSequences: [{ id: "seq-1", label: "A", slides: [{ sceneId: "s1" }] }],
},
"seq-1",
"s1",
false,
);
expect(m.slideSequences?.[0]?.slides).toEqual([]);
});
});
// ── addHotspot / removeHotspot ─────────────────────────────────────────────
describe("addHotspot", () => {
it("adds a hotspot to a slide", () => {
const m = addHotspot({ slides: [{ sceneId: "a" }] }, "a", {
id: "h1",
label: "Go to B",
target: "seq-b",
});
expect(m.slides[0]?.hotspots).toEqual([{ id: "h1", label: "Go to B", target: "seq-b" }]);
});
it("does not duplicate hotspot ids", () => {
let m: SlideshowManifest = { slides: [{ sceneId: "a" }] };
m = addHotspot(m, "a", { id: "h1", label: "X", target: "seq-b" });
m = addHotspot(m, "a", { id: "h1", label: "Y", target: "seq-c" });
expect(m.slides[0]?.hotspots?.length).toBe(1);
});
});
describe("removeHotspot", () => {
it("removes a hotspot by id", () => {
const m = removeHotspot(
{
slides: [{ sceneId: "a", hotspots: [{ id: "h1", label: "X", target: "seq-b" }] }],
},
"a",
"h1",
);
expect(m.slides[0]?.hotspots).toEqual([]);
});
it("no-ops for unknown hotspot id", () => {
const m = removeHotspot(
{
slides: [{ sceneId: "a", hotspots: [{ id: "h1", label: "X", target: "seq-b" }] }],
},
"a",
"no-such-id",
);
expect(m.slides[0]?.hotspots?.length).toBe(1);
});
});
// ── safeParseManifest ──────────────────────────────────────────────────────
describe("safeParseManifest", () => {
it("parses a valid slideshow island", () => {
const manifest = { slides: [{ sceneId: "a" }] };
const island = `<script type="application/hyperframes-slideshow+json">${JSON.stringify(manifest)}</script>`;
const html = `<html><body>${island}</body></html>`;
const result = safeParseManifest(html);
expect(result.slides[0]?.sceneId).toBe("a");
});
it("returns {slides:[]} for malformed JSON in the island", () => {
const html = `<html><body><script type="application/hyperframes-slideshow+json">NOT_JSON</script></body></html>`;
const result = safeParseManifest(html);
expect(result).toEqual({ slides: [] });
});
it("returns {slides:[]} when no island is present", () => {
const result = safeParseManifest("<html><body></body></html>");
expect(result).toEqual({ slides: [] });
});
});
// ── makeSlideshowNotesController ──────────────────────────────────────────
//
// These tests prove the two stale-closure invariants without needing a DOM:
// (a) Notes typed in comp A always flush to comp A's callback, never comp B's.
// (b) A discrete action after typing does NOT drop the typed note.
describe("makeSlideshowNotesController", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("(a) typing notes then switching composition flushes to the ORIGINAL callback", () => {
const ctrl = makeSlideshowNotesController();
const persistA = vi.fn().mockResolvedValue(undefined);
const persistB = vi.fn().mockResolvedValue(undefined);
const manifestA = { slides: [{ sceneId: "s1", notes: "typed in A" }] };
const manifestB = { slides: [{ sceneId: "s2" }] };
// User types a note in composition A — schedules debounce with persistA.
ctrl.schedule(manifestA, persistA, 450);
// Before the debounce fires, the composition switches to B.
// The panel calls flush() so the pending notes go to A's callback.
ctrl.flush();
// Now the panel re-schedules with B's manifest + callback.
ctrl.schedule(manifestB, persistB, 450);
// Advance time past the debounce delay.
vi.advanceTimersByTime(500);
// persistA must have been called with manifestA (the A-composition notes).
expect(persistA).toHaveBeenCalledOnce();
expect(persistA.mock.calls[0]?.[0]).toEqual(manifestA);
// persistB must have been called with manifestB (the B-composition timer).
expect(persistB).toHaveBeenCalledOnce();
expect(persistB.mock.calls[0]?.[0]).toEqual(manifestB);
});
it("(a) flush after composition switch does NOT call the new composition's callback", () => {
const ctrl = makeSlideshowNotesController();
const persistA = vi.fn().mockResolvedValue(undefined);
const persistB = vi.fn().mockResolvedValue(undefined);
const manifestA = { slides: [{ sceneId: "s1", notes: "A notes" }] };
ctrl.schedule(manifestA, persistA, 450);
// Simulate comp switch: flush before B's manifest arrives.
ctrl.flush();
// B never schedules anything.
vi.advanceTimersByTime(1000);
expect(persistA).toHaveBeenCalledOnce();
expect(persistB).not.toHaveBeenCalled();
});
it("(b) discrete action right after typing does NOT drop the note", () => {
const ctrl = makeSlideshowNotesController();
const persistNotes = vi.fn().mockResolvedValue(undefined);
const manifestWithNotes = { slides: [{ sceneId: "s1", notes: "hello" }] };
// User types "hello" — schedules debounce.
ctrl.schedule(manifestWithNotes, persistNotes, 450);
// Before debounce fires, user triggers a discrete action (e.g. mark fragment).
// The discrete manifest comes from the helper and does NOT include the note yet
// (it was computed from an older state snapshot).
const discreteManifest = { slides: [{ sceneId: "s1", fragments: [1.5] }] };
const merged = ctrl.mergeIntoDiscrete(discreteManifest);
// The merged manifest must include BOTH the fragment AND the note.
expect(merged.slides[0]).toMatchObject({ sceneId: "s1", notes: "hello", fragments: [1.5] });
// After mergeIntoDiscrete, pending is cleared — debounce no longer fires.
vi.advanceTimersByTime(500);
expect(persistNotes).not.toHaveBeenCalled();
});
it("(b) notes from a different scene are not merged into an unrelated slide", () => {
const ctrl = makeSlideshowNotesController();
const persistNotes = vi.fn().mockResolvedValue(undefined);
// Pending notes are for scene s1.
const manifestWithNotes = { slides: [{ sceneId: "s1", notes: "s1 notes" }] };
ctrl.schedule(manifestWithNotes, persistNotes, 450);
// Discrete action affects scene s2 only.
const discreteManifest = { slides: [{ sceneId: "s2", fragments: [2.0] }] };
const merged = ctrl.mergeIntoDiscrete(discreteManifest);
// s2 slide should have no notes (pending notes belong to s1 which is not in discrete).
expect(merged.slides[0]).toMatchObject({ sceneId: "s2" });
expect(merged.slides[0]?.notes).toBeUndefined();
});
it("flush is idempotent — second flush does nothing", () => {
const ctrl = makeSlideshowNotesController();
const persist = vi.fn().mockResolvedValue(undefined);
ctrl.schedule({ slides: [{ sceneId: "x" }] }, persist, 450);
ctrl.flush();
ctrl.flush();
expect(persist).toHaveBeenCalledOnce();
});
it("cancel clears pending without calling persist", () => {
const ctrl = makeSlideshowNotesController();
const persist = vi.fn().mockResolvedValue(undefined);
ctrl.schedule({ slides: [] }, persist, 450);
ctrl.cancel();
vi.advanceTimersByTime(1000);
expect(persist).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,422 @@
/**
* SlideshowPanel — Studio right-panel tab for authoring the slideshow island.
*
* Four sub-surfaces:
* 1. Slide list: scenes → toggle main-line slide; reorder via up/down arrows.
* 2. Slide inspector: notes textarea; fragment hold-points.
* 3. Branch tree: create/rename sequences; assign scenes to a branch.
* 4. Hotspot tool: mark selected element as a hotspot on the active slide.
*
* State: the manifest is parsed from the current composition HTML on mount and
* on each `compHtml` change. Every edit calls `onPersist(manifest)` and
* updates local state.
*
* All manifest transforms are pure helpers — see slideshowPanelHelpers.ts.
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { parseSlideshowManifest } from "@hyperframes/core/slideshow";
import type { SlideshowManifest, SlideHotspot } from "@hyperframes/core/slideshow";
import { usePlayerStore } from "../../player";
import { useDomEditSelectionContext } from "../../contexts/DomEditContext";
import { useFileManagerContext } from "../../contexts/FileManagerContext";
import {
SectionHeader,
SlideList,
SlideInspector,
BranchTree,
HotspotTool,
} from "./SlideshowSubPanels";
// Re-export pure helpers so the test file can import from "./SlideshowPanel".
export {
toggleMainLineSlide,
reorderMainLineSlide,
setSlideNotes,
addFragment,
removeFragment,
createSequence,
renameSequence,
deleteSequence,
assignToBranch,
addHotspot,
removeHotspot,
} from "./slideshowPanelHelpers";
export type { SceneInfo } from "./slideshowPanelHelpers";
export function safeParseManifest(html: string): SlideshowManifest {
try {
return parseSlideshowManifest(html) ?? { slides: [] };
} catch {
console.warn("[SlideshowPanel] Failed to parse slideshow manifest; using empty manifest");
return { slides: [] };
}
}
import {
toggleMainLineSlide,
reorderMainLineSlide,
setSlideNotes,
addFragment,
removeFragment,
createSequence,
renameSequence,
deleteSequence,
assignToBranch,
addHotspot,
removeHotspot,
} from "./slideshowPanelHelpers";
// ── Notes-attribution controller (pure, testable) ─────────────────────────
//
// The React component delegates debounce scheduling to these functions so
// the flush-attribution invariant can be tested without a DOM or React renderer.
export interface NotesController {
/** Record a notes keystroke; returns the timer id. */
schedule: (
manifest: SlideshowManifest,
persist: (m: SlideshowManifest) => Promise<void>,
delayMs: number,
) => ReturnType<typeof setTimeout>;
/** Flush any pending notes synchronously (e.g. on comp-switch or unmount). */
flush: () => void;
/** Cancel without flushing (used when a discrete action absorbs the notes). */
cancel: () => void;
/** Merge any pending notes into an incoming discrete manifest, then clear. */
mergeIntoDiscrete: (next: SlideshowManifest) => SlideshowManifest;
}
export function makeSlideshowNotesController(): NotesController {
type Pending = { manifest: SlideshowManifest; persist: (m: SlideshowManifest) => Promise<void> };
let pending: Pending | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
return {
schedule(manifest, persist, delayMs) {
if (timer !== null) clearTimeout(timer);
pending = { manifest, persist };
timer = setTimeout(() => {
timer = null;
const p = pending;
if (p !== null) {
pending = null;
p.persist(p.manifest).catch(() => {});
}
}, delayMs);
return timer;
},
flush() {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
const p = pending;
if (p !== null) {
pending = null;
p.persist(p.manifest).catch(() => {});
}
},
cancel() {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
pending = null;
},
mergeIntoDiscrete(next) {
const p = pending;
if (p === null) return next;
pending = null;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
return {
...next,
slides: next.slides.map((slide) => {
const ps = p.manifest.slides.find((s) => s.sceneId === slide.sceneId);
if (ps?.notes !== undefined && slide.notes === undefined) {
return { ...slide, notes: ps.notes };
}
return slide;
}),
};
},
};
}
// ── Component ─────────────────────────────────────────────────────────────
export interface SlideshowPanelProps {
/** Scenes from the live clip manifest (passed from StudioRightPanel). */
scenes: import("./slideshowPanelHelpers").SceneInfo[];
/**
* Called with the updated manifest after every discrete edit (toggle, add,
* delete, reorder, hotspot). Notes changes use the debounced variant instead.
*/
onPersist: (manifest: SlideshowManifest) => Promise<void>;
/** Called with the updated manifest after the notes idle delay (~450 ms). */
onPersistNotes: (manifest: SlideshowManifest) => Promise<void>;
}
type SectionKey = "slides" | "inspector" | "branches" | "hotspot";
export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowPanelProps) {
const { editingFile } = useFileManagerContext();
const compHtml = editingFile?.content ?? null;
const [manifest, setManifest] = useState<SlideshowManifest>(() => {
if (!compHtml) return { slides: [] };
return safeParseManifest(compHtml);
});
const [selectedSceneId, setSelectedSceneId] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<SectionKey>>(
() => new Set<SectionKey>(["slides", "inspector"]),
);
const currentTime = usePlayerStore((s) => s.currentTime);
const { domEditSelection } = useDomEditSelectionContext();
// Keep a ref to the latest manifest so discrete handlers always operate on
// the freshest state, never a stale closure snapshot.
const manifestRef = useRef<SlideshowManifest>(manifest);
// Controller pairs each pending notes update with the callback that owns it,
// so a flush always writes to the composition the notes were typed in.
const notesCtrlRef = useRef<NotesController>(makeSlideshowNotesController());
useEffect(() => {
if (!compHtml) {
// Flush any pending notes for the OLD composition before clearing state.
notesCtrlRef.current.flush();
setManifest({ slides: [] });
manifestRef.current = { slides: [] };
return;
}
const parsed = safeParseManifest(compHtml);
// Flush pending notes for the OLD composition before switching to the new one.
notesCtrlRef.current.flush();
setManifest(parsed);
manifestRef.current = parsed;
}, [compHtml]);
/** Discrete actions (toggle, reorder, add/delete, hotspot): persist immediately. */
const applyManifest = useCallback(
async (next: SlideshowManifest) => {
// Fold any in-flight typed notes into the discrete manifest so they are
// not silently dropped when the debounce timer would have fired later.
const merged = notesCtrlRef.current.mergeIntoDiscrete(next);
setManifest(merged);
manifestRef.current = merged;
await onPersist(merged);
},
[onPersist],
);
/**
* Notes path: update in-memory state immediately for a responsive UI, but
* debounce the disk persist to ~450 ms after the last keystroke. The pending
* notes are paired with the callback that owns them (the one bound to the
* current composition path), so a composition switch before the timer fires
* will flush to the correct file.
*/
const applyNotesManifest = useCallback(
(next: SlideshowManifest) => {
setManifest(next);
manifestRef.current = next;
notesCtrlRef.current.schedule(next, onPersistNotes, 450);
},
[onPersistNotes],
);
// Flush any pending notes persist when the component unmounts so we never
// silently drop an edit the user made right before navigating away.
useEffect(() => {
const ctrl = notesCtrlRef.current;
return () => {
ctrl.flush();
};
}, []);
const toggleSection = useCallback((key: SectionKey) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const selectedSlide = manifest.slides.find((s) => s.sceneId === selectedSceneId);
const sequences = manifest.slideSequences ?? [];
const handleToggleSlide = useCallback(
(sceneId: string) => {
applyManifest(toggleMainLineSlide(manifestRef.current, sceneId)).catch(() => {});
},
[applyManifest],
);
const handleReorder = useCallback(
(sceneId: string, dir: "up" | "down") => {
applyManifest(reorderMainLineSlide(manifestRef.current, sceneId, dir)).catch(() => {});
},
[applyManifest],
);
const handleSetNotes = useCallback(
(notes: string) => {
if (!selectedSceneId) return;
applyNotesManifest(setSlideNotes(manifestRef.current, selectedSceneId, notes));
},
[selectedSceneId, applyNotesManifest],
);
const handleMarkFragment = useCallback(() => {
if (!selectedSceneId) return;
applyManifest(addFragment(manifestRef.current, selectedSceneId, currentTime)).catch(() => {});
}, [selectedSceneId, currentTime, applyManifest]);
const handleRemoveFragment = useCallback(
(time: number) => {
if (!selectedSceneId) return;
applyManifest(removeFragment(manifestRef.current, selectedSceneId, time)).catch(() => {});
},
[selectedSceneId, applyManifest],
);
const handleCreateSequence = useCallback(
(label: string) => {
const id = `seq-${Date.now()}`;
applyManifest(createSequence(manifestRef.current, id, label)).catch(() => {});
},
[applyManifest],
);
const handleRenameSequence = useCallback(
(id: string, label: string) => {
applyManifest(renameSequence(manifestRef.current, id, label)).catch(() => {});
},
[applyManifest],
);
const handleDeleteSequence = useCallback(
(id: string) => {
applyManifest(deleteSequence(manifestRef.current, id)).catch(() => {});
},
[applyManifest],
);
const handleAssign = useCallback(
(sequenceId: string, sceneId: string, assign: boolean) => {
applyManifest(assignToBranch(manifestRef.current, sequenceId, sceneId, assign)).catch(
() => {},
);
},
[applyManifest],
);
const handleAddHotspot = useCallback(
(sceneId: string, hotspot: SlideHotspot) => {
applyManifest(addHotspot(manifestRef.current, sceneId, hotspot)).catch(() => {});
},
[applyManifest],
);
const handleRemoveHotspot = useCallback(
(sceneId: string, hotspotId: string) => {
applyManifest(removeHotspot(manifestRef.current, sceneId, hotspotId)).catch(() => {});
},
[applyManifest],
);
return (
<div className="flex flex-col h-full overflow-y-auto text-white">
<SectionHeader
expanded={expandedSections.has("slides")}
onToggle={() => toggleSection("slides")}
>
Slides ({manifest.slides.length})
</SectionHeader>
{expandedSections.has("slides") && (
<div className="py-1">
<SlideList
scenes={scenes}
slides={manifest.slides}
selectedSceneId={selectedSceneId}
onSelect={setSelectedSceneId}
onToggle={handleToggleSlide}
onReorder={handleReorder}
/>
</div>
)}
<SectionHeader
expanded={expandedSections.has("inspector")}
onToggle={() => toggleSection("inspector")}
>
Slide Inspector
</SectionHeader>
{expandedSections.has("inspector") && (
<>
{selectedSceneId ? (
<SlideInspector
sceneId={selectedSceneId}
slide={selectedSlide}
currentTime={currentTime}
onSetNotes={handleSetNotes}
onMarkFragment={handleMarkFragment}
onRemoveFragment={handleRemoveFragment}
/>
) : (
<p className="px-3 py-2 text-[11px] text-neutral-500 italic">
Select a scene above to inspect
</p>
)}
</>
)}
<SectionHeader
expanded={expandedSections.has("branches")}
onToggle={() => toggleSection("branches")}
>
Branches ({sequences.length})
</SectionHeader>
{expandedSections.has("branches") && (
<BranchTree
sequences={sequences}
scenes={scenes}
onCreateSequence={handleCreateSequence}
onRenameSequence={handleRenameSequence}
onDeleteSequence={handleDeleteSequence}
onAssign={handleAssign}
/>
)}
<SectionHeader
expanded={expandedSections.has("hotspot")}
onToggle={() => toggleSection("hotspot")}
>
Hotspot Tool
</SectionHeader>
{expandedSections.has("hotspot") && (
<HotspotTool
selectedSceneId={selectedSceneId}
slide={selectedSlide}
domEditSelection={domEditSelection}
sequences={sequences}
onAddHotspot={handleAddHotspot}
onRemoveHotspot={handleRemoveHotspot}
/>
)}
</div>
);
}
@@ -0,0 +1,464 @@
/**
* SlideshowSubPanels — internal sub-surface components for SlideshowPanel.
* Not exported from the package index; used only by SlideshowPanel.tsx.
*/
import { useState, useCallback, useId } from "react";
import type { SlideRef, SlideHotspot, SlideSequence } from "@hyperframes/core/slideshow";
import type { DomEditSelection } from "../editor/domEditing";
import type { SceneInfo } from "./slideshowPanelHelpers";
// ── Section header (accordion toggle) ────────────────────────────────────
export function SectionHeader({
children,
expanded,
onToggle,
}: {
children: React.ReactNode;
expanded: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
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"
onClick={onToggle}
aria-expanded={expanded}
>
<span>{children}</span>
<span className="text-[10px] text-neutral-600">{expanded ? "▲" : "▼"}</span>
</button>
);
}
// ── Sub-surface: Slide List ──────────────────────────────────────────────
export interface SlideListProps {
scenes: SceneInfo[];
slides: SlideRef[];
selectedSceneId: string | null;
onSelect: (sceneId: string) => void;
onToggle: (sceneId: string) => void;
onReorder: (sceneId: string, dir: "up" | "down") => void;
}
export function SlideList({
scenes,
slides,
selectedSceneId,
onSelect,
onToggle,
onReorder,
}: SlideListProps) {
return (
<div className="flex flex-col gap-px">
{scenes.map((scene) => {
const isSlide = slides.some((s) => s.sceneId === scene.id);
const isSelected = selectedSceneId === scene.id;
return (
<div
key={scene.id}
role="button"
tabIndex={0}
aria-pressed={isSelected}
className={`flex items-center gap-2 px-3 py-1.5 rounded cursor-pointer text-[11px] transition-colors ${
isSelected
? "bg-studio-accent/20 text-white"
: "hover:bg-neutral-800/60 text-neutral-300"
}`}
onClick={() => onSelect(scene.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(scene.id);
}
}}
>
<input
type="checkbox"
aria-label={`Include ${scene.label} as main-line slide`}
checked={isSlide}
onChange={() => onToggle(scene.id)}
onClick={(e) => e.stopPropagation()}
className="accent-studio-accent flex-shrink-0"
/>
<span className="flex-1 truncate">{scene.label || scene.id}</span>
{isSlide && (
<span className="flex gap-0.5 flex-shrink-0">
<button
type="button"
aria-label="Move slide up"
title="Move up"
className="px-1 py-0.5 text-[10px] text-neutral-400 hover:text-white disabled:opacity-30"
onClick={(e) => {
e.stopPropagation();
onReorder(scene.id, "up");
}}
>
</button>
<button
type="button"
aria-label="Move slide down"
title="Move down"
className="px-1 py-0.5 text-[10px] text-neutral-400 hover:text-white disabled:opacity-30"
onClick={(e) => {
e.stopPropagation();
onReorder(scene.id, "down");
}}
>
</button>
</span>
)}
</div>
);
})}
{scenes.length === 0 && (
<p className="px-3 py-2 text-[11px] text-neutral-500 italic">No scenes found</p>
)}
</div>
);
}
// ── Sub-surface: Slide Inspector ─────────────────────────────────────────
export interface SlideInspectorProps {
sceneId: string;
slide: SlideRef | undefined;
currentTime: number;
onSetNotes: (notes: string) => void;
onMarkFragment: () => void;
onRemoveFragment: (time: number) => void;
}
// fallow-ignore-next-line complexity
export function SlideInspector({
sceneId,
slide,
currentTime,
onSetNotes,
onMarkFragment,
onRemoveFragment,
}: SlideInspectorProps) {
const fragments = slide?.fragments ?? [];
return (
<div className="flex flex-col gap-3 px-3 py-2">
<p className="text-[10px] text-neutral-500 font-medium uppercase tracking-wide truncate">
Scene: {sceneId}
</p>
<div className="flex flex-col gap-1">
<label className="text-[11px] text-neutral-400">Notes</label>
<textarea
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"
rows={3}
placeholder="Speaker notes or script..."
value={slide?.notes ?? ""}
onChange={(e) => onSetNotes(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] text-neutral-400">Fragment hold-points</span>
<button
type="button"
className="text-[10px] px-2 py-0.5 rounded bg-neutral-700 hover:bg-neutral-600 text-neutral-200 transition-colors"
onClick={onMarkFragment}
title={`Mark ${currentTime.toFixed(2)}s as hold-point`}
>
Mark {currentTime.toFixed(2)}s
</button>
</div>
{fragments.length > 0 ? (
<div className="flex flex-wrap gap-1">
{fragments.map((t) => (
<span
key={t}
className="inline-flex items-center gap-1 bg-neutral-700 rounded px-1.5 py-0.5 text-[10px] text-neutral-200"
>
{t.toFixed(2)}s
<button
type="button"
aria-label={`Remove fragment at ${t.toFixed(2)}s`}
className="text-neutral-400 hover:text-red-400 transition-colors"
onClick={() => onRemoveFragment(t)}
>
×
</button>
</span>
))}
</div>
) : (
<p className="text-[10px] text-neutral-600 italic">No hold-points yet</p>
)}
</div>
</div>
);
}
// ── Sub-surface: Branch Tree ──────────────────────────────────────────────
export interface BranchTreeProps {
sequences: SlideSequence[];
scenes: SceneInfo[];
onCreateSequence: (label: string) => void;
onRenameSequence: (id: string, label: string) => void;
onDeleteSequence: (id: string) => void;
onAssign: (sequenceId: string, sceneId: string, assign: boolean) => void;
}
export function BranchTree({
sequences,
scenes,
onCreateSequence,
onRenameSequence,
onDeleteSequence,
onAssign,
}: BranchTreeProps) {
const [newLabel, setNewLabel] = useState("");
const inputId = useId();
const handleCreate = useCallback(() => {
const label = newLabel.trim();
if (!label) return;
onCreateSequence(label);
setNewLabel("");
}, [newLabel, onCreateSequence]);
return (
<div className="flex flex-col gap-3 px-3 py-2">
<div className="flex gap-1.5">
<input
id={inputId}
type="text"
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"
placeholder="New branch name..."
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
aria-label="New branch sequence name"
/>
<button
type="button"
className="px-2 py-1 rounded bg-neutral-700 hover:bg-neutral-600 text-[11px] text-neutral-200 transition-colors flex-shrink-0"
onClick={handleCreate}
>
Add
</button>
</div>
{sequences.length === 0 ? (
<p className="text-[10px] text-neutral-600 italic">No branches yet</p>
) : (
<div className="flex flex-col gap-3">
{sequences.map((seq) => (
<BranchItem
key={seq.id}
seq={seq}
scenes={scenes}
onRename={onRenameSequence}
onDelete={onDeleteSequence}
onAssign={onAssign}
/>
))}
</div>
)}
</div>
);
}
interface BranchItemProps {
seq: SlideSequence;
scenes: SceneInfo[];
onRename: (id: string, label: string) => void;
onDelete: (id: string) => void;
onAssign: (sequenceId: string, sceneId: string, assign: boolean) => void;
}
function BranchItem({ seq, scenes, onRename, onDelete, onAssign }: BranchItemProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(seq.label);
const commitRename = useCallback(() => {
const label = draft.trim();
if (label && label !== seq.label) onRename(seq.id, label);
setEditing(false);
}, [draft, onRename, seq.id, seq.label]);
return (
<div className="border border-neutral-700/60 rounded p-2 flex flex-col gap-2">
<div className="flex items-center gap-1">
{editing ? (
<input
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"
value={draft}
autoFocus
onChange={(e) => setDraft(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename();
if (e.key === "Escape") setEditing(false);
}}
aria-label={`Rename branch ${seq.label}`}
/>
) : (
<span
role="button"
tabIndex={0}
className="flex-1 text-[11px] text-white font-medium truncate cursor-pointer hover:text-neutral-300"
title="Click to rename"
onClick={() => setEditing(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setEditing(true);
}}
>
{seq.label}
</span>
)}
<button
type="button"
aria-label={`Delete branch ${seq.label}`}
className="text-[10px] text-neutral-500 hover:text-red-400 transition-colors px-1"
onClick={() => onDelete(seq.id)}
>
</button>
</div>
<div className="flex flex-col gap-px pl-2">
{scenes.map((scene) => {
const assigned = seq.slides.some((s) => s.sceneId === scene.id);
return (
<label
key={scene.id}
className="flex items-center gap-1.5 py-0.5 cursor-pointer text-[11px] text-neutral-400 hover:text-neutral-200"
>
<input
type="checkbox"
checked={assigned}
onChange={(e) => onAssign(seq.id, scene.id, e.target.checked)}
className="accent-studio-accent"
/>
<span className="truncate">{scene.label || scene.id}</span>
</label>
);
})}
{scenes.length === 0 && <p className="text-[10px] text-neutral-600 italic">No scenes</p>}
</div>
</div>
);
}
// ── Sub-surface: Hotspot Tool ─────────────────────────────────────────────
export interface HotspotToolProps {
selectedSceneId: string | null;
slide: SlideRef | undefined;
domEditSelection: DomEditSelection | null;
sequences: SlideSequence[];
onAddHotspot: (sceneId: string, hotspot: SlideHotspot) => void;
onRemoveHotspot: (sceneId: string, hotspotId: string) => void;
}
// fallow-ignore-next-line complexity
export function HotspotTool({
selectedSceneId,
slide,
domEditSelection,
sequences,
onAddHotspot,
onRemoveHotspot,
}: HotspotToolProps) {
const [targetSequenceId, setTargetSequenceId] = useState("");
const [hotspotLabel, setHotspotLabel] = useState("");
const hotspots = slide?.hotspots ?? [];
const selectedElementId = domEditSelection?.element?.id ?? null;
const selectedHfId = domEditSelection?.hfId ?? null;
const elementKey = selectedElementId || selectedHfId;
// fallow-ignore-next-line complexity
const handleMakeHotspot = useCallback(() => {
if (!selectedSceneId || !targetSequenceId || !elementKey) return;
const id = `hotspot-${elementKey}-${Date.now()}`;
const label = hotspotLabel.trim() || elementKey;
onAddHotspot(selectedSceneId, { id, label, target: targetSequenceId });
setHotspotLabel("");
}, [selectedSceneId, targetSequenceId, elementKey, hotspotLabel, onAddHotspot]);
if (!selectedSceneId) {
return (
<div className="px-3 py-2">
<p className="text-[11px] text-neutral-500 italic">Select a scene in the Slides list</p>
</div>
);
}
return (
<div className="flex flex-col gap-3 px-3 py-2">
<div className="flex flex-col gap-1.5">
<p className="text-[11px] text-neutral-400">
Selected element:{" "}
<span className="text-neutral-200 font-mono">{elementKey ?? "none"}</span>
</p>
<label className="text-[11px] text-neutral-400">Hotspot label</label>
<input
type="text"
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"
placeholder="Button label..."
value={hotspotLabel}
onChange={(e) => setHotspotLabel(e.target.value)}
aria-label="Hotspot label"
/>
<label className="text-[11px] text-neutral-400">Target branch</label>
<select
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"
value={targetSequenceId}
onChange={(e) => setTargetSequenceId(e.target.value)}
aria-label="Target branch sequence"
>
<option value=""> select branch </option>
{sequences.map((seq) => (
<option key={seq.id} value={seq.id}>
{seq.label}
</option>
))}
</select>
<button
type="button"
disabled={!elementKey || !targetSequenceId}
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"
onClick={handleMakeHotspot}
>
Make hotspot
</button>
</div>
{hotspots.length > 0 && (
<div className="flex flex-col gap-1.5">
<p className="text-[11px] text-neutral-400 font-medium">Hotspots on this slide</p>
{hotspots.map((h) => {
const seqLabel = sequences.find((s) => s.id === h.target)?.label ?? h.target;
return (
<div key={h.id} className="flex items-center gap-2 bg-neutral-800 rounded px-2 py-1">
<span className="flex-1 text-[11px] text-neutral-200 truncate">
{h.label} <span className="text-neutral-400">{seqLabel}</span>
</span>
<button
type="button"
aria-label={`Remove hotspot ${h.label}`}
className="text-[10px] text-neutral-500 hover:text-red-400 transition-colors"
onClick={() => onRemoveHotspot(selectedSceneId, h.id)}
>
</button>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,195 @@
/**
* Pure manifest-transform helpers for SlideshowPanel.
* No React, no side-effects — fully unit-testable.
*/
import type { SlideshowManifest, SlideRef, SlideHotspot } from "@hyperframes/core/slideshow";
// ── Scene shape used by the panel UI ──────────────────────────────────────
export interface SceneInfo {
id: string;
label: string;
start: number;
duration: number;
}
// ── Pure manifest transforms ───────────────────────────────────────────────
/** Toggle a scene in the main-line slide list. */
export function toggleMainLineSlide(
manifest: SlideshowManifest,
sceneId: string,
): SlideshowManifest {
const exists = manifest.slides.some((s) => s.sceneId === sceneId);
const slides: SlideRef[] = exists
? manifest.slides.filter((s) => s.sceneId !== sceneId)
: [...manifest.slides, { sceneId }];
return { ...manifest, slides };
}
// fallow-ignore-next-line complexity
/** Move a main-line slide up or down by one position. */
export function reorderMainLineSlide(
manifest: SlideshowManifest,
sceneId: string,
direction: "up" | "down",
): SlideshowManifest {
const idx = manifest.slides.findIndex((s) => s.sceneId === sceneId);
if (idx === -1) return manifest;
const next = direction === "up" ? idx - 1 : idx + 1;
if (next < 0 || next >= manifest.slides.length) return manifest;
const slides = [...manifest.slides];
const a = slides[idx];
const b = slides[next];
if (!a || !b) return manifest;
slides[idx] = b;
slides[next] = a;
return { ...manifest, slides };
}
/** Update notes on a main-line slide (adds slide entry if absent). */
export function setSlideNotes(
manifest: SlideshowManifest,
sceneId: string,
notes: string,
): SlideshowManifest {
const exists = manifest.slides.some((s) => s.sceneId === sceneId);
const slides: SlideRef[] = exists
? manifest.slides.map((s) => (s.sceneId === sceneId ? { ...s, notes } : s))
: [...manifest.slides, { sceneId, notes }];
return { ...manifest, slides };
}
/** Push a fragment hold-point time onto a main-line slide. Deduplicates + sorts. */
export function addFragment(
manifest: SlideshowManifest,
sceneId: string,
time: number,
): SlideshowManifest {
const exists = manifest.slides.some((s) => s.sceneId === sceneId);
const slides: SlideRef[] = exists
? manifest.slides.map((s) => {
if (s.sceneId !== sceneId) return s;
const frags = [...new Set([...(s.fragments ?? []), time])].sort((a, b) => a - b);
return { ...s, fragments: frags };
})
: [...manifest.slides, { sceneId, fragments: [time] }];
return { ...manifest, slides };
}
/** Remove a fragment hold-point by value from a main-line slide. */
export function removeFragment(
manifest: SlideshowManifest,
sceneId: string,
time: number,
): SlideshowManifest {
return {
...manifest,
slides: manifest.slides.map((s) => {
if (s.sceneId !== sceneId) return s;
return { ...s, fragments: (s.fragments ?? []).filter((f) => f !== time) };
}),
};
}
/** Create a new branch sequence. Rejects duplicate ids. */
export function createSequence(
manifest: SlideshowManifest,
id: string,
label: string,
): SlideshowManifest {
const existing = manifest.slideSequences ?? [];
if (existing.some((seq) => seq.id === id)) return manifest;
return {
...manifest,
slideSequences: [...existing, { id, label, slides: [] }],
};
}
/** Rename an existing branch sequence label. */
export function renameSequence(
manifest: SlideshowManifest,
id: string,
label: string,
): SlideshowManifest {
return {
...manifest,
slideSequences: (manifest.slideSequences ?? []).map((seq) =>
seq.id === id ? { ...seq, label } : seq,
),
};
}
function pruneHotspots(slides: SlideRef[], targetId: string): SlideRef[] {
return slides.map((s) => {
if (!s.hotspots) return s;
const hotspots = s.hotspots.filter((h) => h.target !== targetId);
return hotspots.length === s.hotspots.length ? s : { ...s, hotspots };
});
}
/** Delete a branch sequence by id, removing any hotspot targeting it. */
export function deleteSequence(manifest: SlideshowManifest, id: string): SlideshowManifest {
const remainingSequences = (manifest.slideSequences ?? []).filter((seq) => seq.id !== id);
return {
...manifest,
slides: pruneHotspots(manifest.slides, id),
slideSequences: remainingSequences.map((seq) => ({
...seq,
slides: pruneHotspots(seq.slides, id),
})),
};
}
/** Add or remove a scene slide from a branch sequence. */
export function assignToBranch(
manifest: SlideshowManifest,
sequenceId: string,
sceneId: string,
assign: boolean,
): SlideshowManifest {
return {
...manifest,
slideSequences: (manifest.slideSequences ?? []).map((seq) => {
if (seq.id !== sequenceId) return seq;
if (assign) {
if (seq.slides.some((s) => s.sceneId === sceneId)) return seq;
return { ...seq, slides: [...seq.slides, { sceneId }] };
}
return { ...seq, slides: seq.slides.filter((s) => s.sceneId !== sceneId) };
}),
};
}
/** Add a hotspot to a main-line slide. */
export function addHotspot(
manifest: SlideshowManifest,
sceneId: string,
hotspot: SlideHotspot,
): SlideshowManifest {
return {
...manifest,
slides: manifest.slides.map((s) => {
if (s.sceneId !== sceneId) return s;
const existing = s.hotspots ?? [];
if (existing.some((h) => h.id === hotspot.id)) return s;
return { ...s, hotspots: [...existing, hotspot] };
}),
};
}
/** Remove a hotspot by id from a main-line slide. */
export function removeHotspot(
manifest: SlideshowManifest,
sceneId: string,
hotspotId: string,
): SlideshowManifest {
return {
...manifest,
slides: manifest.slides.map((s) => {
if (s.sceneId !== sceneId) return s;
return { ...s, hotspots: (s.hotspots ?? []).filter((h) => h.id !== hotspotId) };
}),
};
}
@@ -0,0 +1,68 @@
import { useCallback, type MutableRefObject } from "react";
import type { Composition } from "@hyperframes/sdk";
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
import type { EditHistoryKind } from "../utils/editHistory";
import { persistSlideshowManifest } from "../utils/setSlideshowManifest";
export interface UseSlideshowPersistParams {
sdkSession: Composition | null;
activeCompPath: string | null;
readProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
reloadPreview: () => void;
domEditSaveTimestampRef: MutableRefObject<number>;
/**
* When provided, rapid writes with the same key coalesce through the
* save-queue infra (via recordEdit's coalesceKey) so back-to-back persists
* collapse to a single undo entry rather than polluting history.
* Pass e.g. `"slideshow-notes:" + activeCompPath` for the notes path.
*/
coalesceKey?: string;
}
export function useSlideshowPersist({
sdkSession,
activeCompPath,
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
coalesceKey,
}: UseSlideshowPersistParams): (manifest: SlideshowManifest) => Promise<void> {
return useCallback(
async (manifest: SlideshowManifest) => {
if (!sdkSession) return;
const path = activeCompPath ?? "index.html";
const originalContent = await readProjectFile(path);
await persistSlideshowManifest({
manifest,
sdkSession,
originalContent,
targetPath: path,
deps: {
editHistory: { recordEdit },
writeProjectFile,
reloadPreview,
domEditSaveTimestampRef,
},
coalesceKey,
});
},
[
sdkSession,
activeCompPath,
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
domEditSaveTimestampRef,
coalesceKey,
],
);
}
+3 -2
View File
@@ -144,10 +144,11 @@ interface CutoverOptions {
skipRefresh?: boolean;
}
// ponytail: internal; export only if a third caller appears.
// ponytail: exported for setSlideshowManifest (third caller — island write bypasses
// the SDK dispatch path since <script> nodes are not in the element tree).
// `after` is serialized once by the caller (which also did the no-op check
// against its pre-dispatch snapshot), so this never re-serializes.
async function persistSdkSerialize(
export async function persistSdkSerialize(
after: string,
targetPath: string,
originalContent: string,
@@ -0,0 +1,141 @@
import { describe, expect, it, vi } from "vitest";
import { buildSlideshowIslandHtml } from "./setSlideshowManifest";
import { parseSlideshowManifest } from "@hyperframes/core/slideshow";
import type { CutoverDeps } from "./sdkCutover";
// Fix 3: vi.mock must be at module top level so Vitest can hoist them.
vi.mock("../components/editor/manualEditingAvailability", () => ({
STUDIO_SDK_CUTOVER_ENABLED: true,
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
}));
vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
describe("buildSlideshowIslandHtml", () => {
it("serializes a manifest into a script island", () => {
const html = buildSlideshowIslandHtml({ slides: [{ sceneId: "a" }] });
expect(html).toContain('type="application/hyperframes-slideshow+json"');
expect(html).toContain('"sceneId": "a"');
});
it("round-trips through parseSlideshowManifest", () => {
const html = `<html><body>${buildSlideshowIslandHtml({ slides: [{ sceneId: "x" }] })}</body></html>`;
const parsed = parseSlideshowManifest(html);
expect(parsed?.slides[0]?.sceneId).toBe("x");
});
it("wraps the JSON in a script tag with no extra nesting", () => {
const html = buildSlideshowIslandHtml({ slides: [] });
expect(html.startsWith("<script")).toBe(true);
expect(html.trimEnd().endsWith("</script>")).toBe(true);
});
// Fix 1: </script> breakout test
it("does NOT embed a literal </script> inside the JSON body", () => {
const manifest = { slides: [{ sceneId: "s1", notes: "</script><b>x</b>" }] };
const html = buildSlideshowIslandHtml(manifest);
// The only closing </script> should be the real one at the very end.
// Strip that trailing tag and confirm no </script> remains.
const withoutClosingTag = html.slice(0, html.lastIndexOf("</script>"));
expect(withoutClosingTag).not.toContain("</script>");
});
it("round-trips a manifest containing </script> in notes via parseSlideshowManifest", () => {
const notes = "</script><b>x</b>";
const manifest = { slides: [{ sceneId: "s1", notes }] };
const html = `<html><body>${buildSlideshowIslandHtml(manifest)}</body></html>`;
const parsed = parseSlideshowManifest(html);
expect(parsed?.slides[0]).toMatchObject({ sceneId: "s1", notes });
});
});
describe("persistSlideshowManifest — op construction", () => {
function makeDeps(writeProjectFile: ReturnType<typeof vi.fn>): CutoverDeps {
return {
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile,
reloadPreview: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
};
}
it("writes the serialized manifest when the island already exists", async () => {
const { persistSlideshowManifest } = await import("./setSlideshowManifest");
const manifest = { slides: [{ sceneId: "scene-1" }] };
const island = buildSlideshowIslandHtml(manifest);
const originalHtml = `<html><head></head><body>${island}</body></html>`;
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
const deps = makeDeps(writeProjectFile);
const recordEdit = deps.editHistory.recordEdit as ReturnType<typeof vi.fn>;
const mockSession = { serialize: vi.fn().mockReturnValue(originalHtml) };
await persistSlideshowManifest({
manifest: { slides: [{ sceneId: "scene-2" }] },
sdkSession: mockSession as never,
originalContent: originalHtml,
targetPath: "/proj/comp.html",
deps,
});
expect(writeProjectFile).toHaveBeenCalledOnce();
const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
expect(written).toContain('"sceneId": "scene-2"');
expect(recordEdit).toHaveBeenCalledWith(expect.objectContaining({ label: "Edit slideshow" }));
});
it("inserts the island when none exists in the serialized HTML", async () => {
const { persistSlideshowManifest } = await import("./setSlideshowManifest");
const baseHtml = "<html><head></head><body></body></html>";
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
const deps = makeDeps(writeProjectFile);
const mockSession = { serialize: vi.fn().mockReturnValue(baseHtml) };
await persistSlideshowManifest({
manifest: { slides: [{ sceneId: "new-scene" }] },
sdkSession: mockSession as never,
originalContent: baseHtml,
targetPath: "/proj/comp.html",
deps,
});
expect(writeProjectFile).toHaveBeenCalledOnce();
const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
expect(written).toContain('"sceneId": "new-scene"');
expect(written).toContain('type="application/hyperframes-slideshow+json"');
});
// Fix 2: two stale islands should collapse to exactly one after persist
it("collapses two stale islands into exactly one after persist", async () => {
const { persistSlideshowManifest } = await import("./setSlideshowManifest");
const staleIsland1 = buildSlideshowIslandHtml({ slides: [{ sceneId: "old-1" }] });
const staleIsland2 = buildSlideshowIslandHtml({ slides: [{ sceneId: "old-2" }] });
const twoIslandHtml = `<html><head></head><body>${staleIsland1}${staleIsland2}</body></html>`;
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
const deps = makeDeps(writeProjectFile);
const mockSession = { serialize: vi.fn().mockReturnValue(twoIslandHtml) };
await persistSlideshowManifest({
manifest: { slides: [{ sceneId: "fresh" }] },
sdkSession: mockSession as never,
originalContent: twoIslandHtml,
targetPath: "/proj/comp.html",
deps,
});
expect(writeProjectFile).toHaveBeenCalledOnce();
const written: string = writeProjectFile.mock.calls[0]?.[1] as string;
// Count occurrences of the island script open tag
const islandCount = (written.match(/type="application\/hyperframes-slideshow\+json"/g) ?? [])
.length;
expect(islandCount).toBe(1);
expect(written).toContain('"sceneId": "fresh"');
expect(written).not.toContain('"sceneId": "old-1"');
expect(written).not.toContain('"sceneId": "old-2"');
});
});
@@ -0,0 +1,79 @@
/**
* setSlideshowManifest Studio persist helper for the slideshow JSON island.
*
* The island is a `<script type="application/hyperframes-slideshow+json">` node
* embedded in the composition HTML. Because <script> nodes are not tracked by
* the SDK element tree (they have no hf-id), we cannot use a `setText` dispatch
* op. Instead we:
* 1. Call `sdkSession.serialize()` to get the current HTML.
* 2. Replace or insert the island with the new manifest JSON.
* 3. Write via `persistSdkSerialize` the same low-level writer used by the
* other SDK-cutover paths (sdkDeletePersist, sdkTimingPersist, etc.).
*
* Inserting when absent: if no island exists in the serialized HTML we insert
* one before `</body>` (or, if no </body>, append to the end of the document).
* This means callers do NOT need to pre-scaffold the island; Task 11 / the panel
* can call persistSlideshowManifest on a fresh composition.
*/
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
import type { Composition } from "@hyperframes/sdk";
import type { CutoverDeps } from "./sdkCutover";
import { persistSdkSerialize } from "./sdkCutover";
const ISLAND_TYPE = "application/hyperframes-slideshow+json";
// Matches ALL <script type="application/hyperframes-slideshow+json"> ... </script>
// blocks (global + case-insensitive) so we can strip every stale island in one pass.
const ISLAND_RE = new RegExp(
`<script[^>]*type=["']${ISLAND_TYPE.replace(/[.+]/g, "\\$&")}["'][^>]*>[\\s\\S]*?<\\/script>`,
"gi",
);
export function buildSlideshowIslandHtml(manifest: SlideshowManifest): string {
// Escape `<` and `>` so that a manifest field containing `</script>` cannot
// break out of the script tag. JSON.parse round-trips </> unchanged.
const json = JSON.stringify(manifest, null, 2).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
return `<script type="${ISLAND_TYPE}">\n${json}\n</script>`;
}
export interface PersistSlideshowArgs {
manifest: SlideshowManifest;
/** Live SDK Composition session — used only to read the current serialized HTML. */
sdkSession: Pick<Composition, "serialize">;
/** Exact on-disk bytes for the undo-history `before` baseline. */
originalContent: string;
targetPath: string;
deps: CutoverDeps;
/** Optional label override (default: "Edit slideshow"). */
label?: string;
/**
* When provided, threads a coalesceKey into recordEdit so rapid writes
* (e.g. per-keystroke notes changes) collapse to a single undo entry.
*/
coalesceKey?: string;
}
export async function persistSlideshowManifest(args: PersistSlideshowArgs): Promise<void> {
const { manifest, sdkSession, originalContent, targetPath, deps, label, coalesceKey } = args;
const islandHtml = buildSlideshowIslandHtml(manifest);
const current = sdkSession.serialize();
// Strip ALL existing islands (handles the case where two stale islands
// accumulated) then insert exactly one fresh island.
const stripped = current.replace(ISLAND_RE, "");
let after: string;
const bodyClose = stripped.lastIndexOf("</body>");
if (bodyClose !== -1) {
after = stripped.slice(0, bodyClose) + islandHtml + "\n" + stripped.slice(bodyClose);
} else {
after = stripped + "\n" + islandHtml;
}
await persistSdkSerialize(after, targetPath, originalContent, deps, {
label: label ?? "Edit slideshow",
...(coalesceKey ? { coalesceKey } : {}),
});
}
+2 -1
View File
@@ -13,7 +13,7 @@ export interface AppToast {
tone: "error" | "info";
}
export type RightPanelTab = "layers" | "design" | "renders" | "block-params";
export type RightPanelTab = "layers" | "design" | "renders" | "block-params" | "slideshow";
export type RightInspectorPane = "layers" | "design";
export interface RightInspectorPanes {
@@ -204,6 +204,7 @@ export function clampNumber(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
// fallow-ignore-next-line unused-export
export { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
export function collectHtmlIds(source: string): string[] {