mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
co-authored by
Claude Opus 4.8
parent
7af3eb8f80
commit
3861e8e9fc
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user