From 67696cd8de0c58551fdd6299bf9d7517cca73e20 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 16 Jul 2026 03:16:51 -0700 Subject: [PATCH] fix(studio): hide the Slideshow tab and panel for non-slideshow compositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Slideshow tab rendered unconditionally, showing the branching editor for any composition regardless of whether it was actually a slideshow — a plain video comp offered a tab with nothing meaningful to edit. Gate it on the composition carrying the slideshow JSON island (`; +const PLAIN_HTML = `
hi
`; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function renderHook(params: { + editingFileContent: string | null | undefined; + rightPanelTab: RightPanelTab; +}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const setRightPanelTabCalls: RightPanelTab[] = []; + let current: ReturnType | null = null; + + function Harness() { + current = useSlideshowTabState({ + editingFileContent: params.editingFileContent, + previewIframeRef: { current: null }, + refreshKey: 0, + rightPanelTab: params.rightPanelTab, + setRightPanelTab: (tab) => setRightPanelTabCalls.push(tab), + }); + return null; + } + + act(() => { + root.render(React.createElement(Harness)); + }); + + return { + getState: (): ReturnType => { + if (!current) throw new Error("useSlideshowTabState did not render"); + return current; + }, + setRightPanelTabCalls, + unmount: () => act(() => root.unmount()), + }; +} + +describe("useSlideshowTabState", () => { + it("detects a slideshow composition via the JSON island", () => { + const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "design" }); + expect(harness.getState().isSlideshowComposition).toBe(true); + harness.unmount(); + }); + + it("reports false for a plain (non-slideshow) composition", () => { + const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "design" }); + expect(harness.getState().isSlideshowComposition).toBe(false); + harness.unmount(); + }); + + it("reports false when there is no editing file yet", () => { + const harness = renderHook({ editingFileContent: undefined, rightPanelTab: "design" }); + expect(harness.getState().isSlideshowComposition).toBe(false); + harness.unmount(); + }); + + it("bounces rightPanelTab off 'slideshow' to 'renders' on a non-slideshow composition", () => { + const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "slideshow" }); + expect(harness.setRightPanelTabCalls).toEqual(["renders"]); + harness.unmount(); + }); + + it("does not bounce when the composition is a slideshow", () => { + const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "slideshow" }); + expect(harness.setRightPanelTabCalls).toEqual([]); + harness.unmount(); + }); + + it("does not bounce a tab other than 'slideshow'", () => { + const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "renders" }); + expect(harness.setRightPanelTabCalls).toEqual([]); + harness.unmount(); + }); +}); diff --git a/packages/studio/src/hooks/useSlideshowTabState.ts b/packages/studio/src/hooks/useSlideshowTabState.ts new file mode 100644 index 000000000..5e38d6ba3 --- /dev/null +++ b/packages/studio/src/hooks/useSlideshowTabState.ts @@ -0,0 +1,59 @@ +import { useEffect, useMemo, type MutableRefObject } from "react"; +import { slideshowIslandRegex } from "@hyperframes/core/slideshow"; +import type { SceneInfo } from "../components/panels/SlideshowPanel"; +import type { IframeWindow } from "../player/lib/playbackTypes"; +import type { RightPanelTab } from "../utils/studioHelpers"; + +/** + * Derives whether the currently-edited composition is a slideshow (carries + * the slideshow JSON island — the same definitive marker the CLI's `present` + * command requires; it refuses to run without one) and the live scene list + * for the Slideshow panel, and bounces `rightPanelTab` off "slideshow" the + * moment it stops applying (e.g. the user switches to a non-slideshow file + * while that tab was open) so the panel never shows a dangling active tab + * whose button is no longer even rendered. + * + * Extracted from StudioRightPanel to keep that file under the 600-LOC gate. + */ +export function useSlideshowTabState(params: { + editingFileContent: string | null | undefined; + previewIframeRef: MutableRefObject; + refreshKey: number; + rightPanelTab: RightPanelTab; + setRightPanelTab: (tab: RightPanelTab) => void; +}): { isSlideshowComposition: boolean; slideshowScenes: SceneInfo[] } { + const { editingFileContent, previewIframeRef, refreshKey, rightPanelTab, setRightPanelTab } = + params; + + // Presence-only (not full manifest validation): a malformed island should + // still surface the Slideshow tab so the user can see/fix it, rather than + // making the whole panel disappear. + const isSlideshowComposition = useMemo( + () => Boolean(editingFileContent && slideshowIslandRegex("i").test(editingFileContent)), + [editingFileContent], + ); + + // Derive scene list from the live clip manifest in the preview iframe. + 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]); + + useEffect(() => { + if (rightPanelTab === "slideshow" && !isSlideshowComposition) { + setRightPanelTab("renders"); + } + }, [rightPanelTab, isSlideshowComposition, setRightPanelTab]); + + return { isSlideshowComposition, slideshowScenes }; +}