diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 8b7b5a803..15fddfda7 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -566,6 +566,10 @@ export function StudioApp() { onToggleRecording={ STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined } + sdkSession={sdkHandle.session} + reloadPreview={reloadPreview} + domEditSaveTimestampRef={domEditSaveTimestampRef} + recordEdit={editHistory.recordEdit} /> )} diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 30720a9c8..36bcc33ff 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -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; + recordEdit: (entry: { + label: string; + kind: EditHistoryKind; + files: Record; + }) => Promise; } // 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(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(() => { + 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"} + + +
{rightPanelTab === "block-params" && activeBlockParams ? ( @@ -301,6 +389,12 @@ export function StudioRightPanel({ compositionPath={activeBlockParams.compositionPath} onClose={onCloseBlockParams ?? (() => {})} /> + ) : rightPanelTab === "slideshow" ? ( + ) : layersPaneOpen && designPaneOpen ? (
{ + 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 = ``; + const html = `${island}`; + const result = safeParseManifest(html); + expect(result.slides[0]?.sceneId).toBe("a"); + }); + + it("returns {slides:[]} for malformed JSON in the island", () => { + const html = ``; + const result = safeParseManifest(html); + expect(result).toEqual({ slides: [] }); + }); + + it("returns {slides:[]} when no island is present", () => { + const result = safeParseManifest(""); + 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(); + }); +}); diff --git a/packages/studio/src/components/panels/SlideshowPanel.tsx b/packages/studio/src/components/panels/SlideshowPanel.tsx new file mode 100644 index 000000000..0aafe0a02 --- /dev/null +++ b/packages/studio/src/components/panels/SlideshowPanel.tsx @@ -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, + delayMs: number, + ) => ReturnType; + /** 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 }; + let pending: Pending | null = null; + let timer: ReturnType | 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; + /** Called with the updated manifest after the notes idle delay (~450 ms). */ + onPersistNotes: (manifest: SlideshowManifest) => Promise; +} + +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(() => { + if (!compHtml) return { slides: [] }; + return safeParseManifest(compHtml); + }); + + const [selectedSceneId, setSelectedSceneId] = useState(null); + const [expandedSections, setExpandedSections] = useState>( + () => new Set(["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(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(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 ( +
+ toggleSection("slides")} + > + Slides ({manifest.slides.length}) + + {expandedSections.has("slides") && ( +
+ +
+ )} + + toggleSection("inspector")} + > + Slide Inspector + + {expandedSections.has("inspector") && ( + <> + {selectedSceneId ? ( + + ) : ( +

+ Select a scene above to inspect +

+ )} + + )} + + toggleSection("branches")} + > + Branches ({sequences.length}) + + {expandedSections.has("branches") && ( + + )} + + toggleSection("hotspot")} + > + Hotspot Tool + + {expandedSections.has("hotspot") && ( + + )} +
+ ); +} diff --git a/packages/studio/src/components/panels/SlideshowSubPanels.tsx b/packages/studio/src/components/panels/SlideshowSubPanels.tsx new file mode 100644 index 000000000..b97e0b8a5 --- /dev/null +++ b/packages/studio/src/components/panels/SlideshowSubPanels.tsx @@ -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 ( + + ); +} + +// ── 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 ( +
+ {scenes.map((scene) => { + const isSlide = slides.some((s) => s.sceneId === scene.id); + const isSelected = selectedSceneId === scene.id; + return ( +
onSelect(scene.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(scene.id); + } + }} + > + onToggle(scene.id)} + onClick={(e) => e.stopPropagation()} + className="accent-studio-accent flex-shrink-0" + /> + {scene.label || scene.id} + {isSlide && ( + + + + + )} +
+ ); + })} + {scenes.length === 0 && ( +

No scenes found

+ )} +
+ ); +} + +// ── 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 ( +
+

+ Scene: {sceneId} +

+
+ +