Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 19 additions & 26 deletions packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from "react";
import { useCallback, useEffect, useRef, type MutableRefObject } from "react";
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 { VariablesPanel } from "./panels/VariablesPanel";
import { PanelTabButton } from "./PanelTabButton";
import { usePreviewVariablesStore } from "../hooks/previewVariablesStore";
import type { RenderJob } from "./renders/useRenderQueue";
import type { BlockParam } from "@hyperframes/core/registry";
import type { IframeWindow } from "../player/lib/playbackTypes";
import {
STUDIO_FLAT_INSPECTOR_ENABLED,
STUDIO_INSPECTOR_PANELS_ENABLED,
} from "./editor/manualEditingAvailability";
import type { Composition } from "@hyperframes/sdk";
import type { EditHistoryKind } from "../utils/editHistory";
import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
import { useSlideshowTabState } from "../hooks/useSlideshowTabState";
import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";

import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
Expand Down Expand Up @@ -168,6 +167,7 @@ export function StudioRightPanel({
readProjectFile,
writeProjectFile,
fileTree,
editingFile,
} = useFileManagerContext();

// Discrete ops (toggle, reorder, add/delete, hotspot): persist immediately,
Expand Down Expand Up @@ -216,22 +216,13 @@ 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 { isSlideshowComposition, slideshowScenes } = useSlideshowTabState({
editingFileContent: editingFile?.content,
previewIframeRef,
refreshKey,
rightPanelTab,
setRightPanelTab,
});
const designPaneOpen = inspectorTabActive && rightInspectorPanes.design && designPanelActive;
const layersPaneOpen =
inspectorTabActive && rightInspectorPanes.layers && STUDIO_INSPECTOR_PANELS_ENABLED;
Expand Down Expand Up @@ -506,12 +497,14 @@ export function StudioRightPanel({
active={rightPanelTab === "renders"}
onClick={() => setRightPanelTab("renders")}
/>
<PanelTabButton
label="Slideshow"
tooltip="Slideshow branching editor"
active={rightPanelTab === "slideshow"}
onClick={() => setRightPanelTab("slideshow")}
/>
{isSlideshowComposition && (
<PanelTabButton
label="Slideshow"
tooltip="Slideshow branching editor"
active={rightPanelTab === "slideshow"}
onClick={() => setRightPanelTab("slideshow")}
/>
)}
<PanelTabButton
label="Variables"
tooltip="Template variables — declare, preview with values"
Expand All @@ -528,7 +521,7 @@ export function StudioRightPanel({
compositionPath={activeBlockParams.compositionPath}
onClose={onCloseBlockParams ?? (() => {})}
/>
) : rightPanelTab === "slideshow" ? (
) : rightPanelTab === "slideshow" && isSlideshowComposition ? (
<SlideshowPanel
scenes={slideshowScenes}
onPersist={onPersistSlideshow}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,13 @@ describe("manual editing availability", () => {
expect(resolveStudioBooleanEnvFlag({ UNKNOWN: "maybe" }, ["UNKNOWN"], false)).toBe(false);
});

it("defaults the flat inspector flag to false and honors an explicit override", async () => {
const off = await loadAvailabilityWithEnv({});
expect(off.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(false);
it("defaults the flat inspector flag to true and honors an explicit override", async () => {
const on = await loadAvailabilityWithEnv({});
expect(on.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(true);

const on = await loadAvailabilityWithEnv({
VITE_STUDIO_FLAT_INSPECTOR_ENABLED: "true",
const off = await loadAvailabilityWithEnv({
VITE_STUDIO_FLAT_INSPECTOR_ENABLED: "false",
});
expect(on.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(true);
expect(off.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,14 @@ export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
);

// Studio inspector redesign ("Ledger, flat" — design_handoff_studio_inspector):
// flat identity header/footer/groups behind a flag for incremental review.
// Default false; enable via VITE_STUDIO_FLAT_INSPECTOR_ENABLED=true.
// flat identity header/footer/groups. Default true as of v0.7.59+ bug-fix pass
// (right-aligned values, Stroke select-only, promote-badge overlap, Layout/
// Style section gating); disable via VITE_STUDIO_FLAT_INSPECTOR_ENABLED=false
// to fall back to the legacy panel.
export const STUDIO_FLAT_INSPECTOR_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_ENABLE_FLAT_INSPECTOR", "VITE_STUDIO_FLAT_INSPECTOR_ENABLED"],
false,
true,
);

export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,13 @@ function representativeElement() {

describe("classic PropertyPanel input coverage", () => {
it("emits only named, known-section events across body inputs and header/footer chrome", async () => {
vi.resetModules();
vi.doMock("./manualEditingAvailability", async () => {
const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
"./manualEditingAvailability",
);
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false };
});
const { PropertyPanel } = await import("./PropertyPanel");
const host = render(
<PropertyPanel
Expand Down
96 changes: 96 additions & 0 deletions packages/studio/src/hooks/useSlideshowTabState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// @vitest-environment happy-dom

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { useSlideshowTabState } from "./useSlideshowTabState";
import type { RightPanelTab } from "../utils/studioHelpers";

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

const SLIDESHOW_HTML = `<html><body><script type="application/hyperframes-slideshow+json">{"slides":[]}</script></body></html>`;
const PLAIN_HTML = `<html><body><div id="title">hi</div></body></html>`;

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<typeof useSlideshowTabState> | 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<typeof useSlideshowTabState> => {
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("still detects a malformed island — presence-only, not full manifest validation", () => {
const malformed = `<html><body><script type="application/hyperframes-slideshow+json">{not valid json</script></body></html>`;
const harness = renderHook({ editingFileContent: malformed, rightPanelTab: "design" });
expect(harness.getState().isSlideshowComposition).toBe(true);
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();
});
});
61 changes: 61 additions & 0 deletions packages/studio/src/hooks/useSlideshowTabState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useEffect, useMemo, type MutableRefObject } from "react";
import { SLIDESHOW_ISLAND_TYPE, 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<HTMLIFrameElement | null>;
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. The plain substring check short-circuits
// the regex scan on every non-slideshow file (the common case) without
// paying for a full-content RegExp pass.
const isSlideshowComposition = useMemo(() => {
if (!editingFileContent || !editingFileContent.includes(SLIDESHOW_ISLAND_TYPE)) return false;
return slideshowIslandRegex("i").test(editingFileContent);
}, [editingFileContent]);

// Derive scene list from the live clip manifest in the preview iframe.
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]);

useEffect(() => {
if (rightPanelTab === "slideshow" && !isSlideshowComposition) {
setRightPanelTab("renders");
}
}, [rightPanelTab, isSlideshowComposition, setRightPanelTab]);

return { isSlideshowComposition, slideshowScenes };
}
Loading