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
12 changes: 6 additions & 6 deletions packages/studio/src/components/EditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
import type { GestureRecordingState } from "./editor/GestureRecordControl";
import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
import { StudioAgentTools } from "../webmcp/StudioAgentTools";
import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
import type {
TimelineDropCallbacks,
TimelineDropPlacement,
} from "../player/components/timelineCallbacks";
import type { TimelineProps } from "../player/components/TimelineTypes";
import { Dock } from "./dock/Dock";

Expand All @@ -34,10 +37,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
timelineToolbar: ReactNode;
renderClipContent: RenderClipContent;
handleTimelineElementDelete: (element: TimelineElement) => Promise<void> | void;
handleTimelineAssetDrop: (
assetPath: string,
placement: TimelineDropPlacement,
) => Promise<void> | void;
handleTimelineAssetDrop: NonNullable<TimelineDropCallbacks["onAssetDrop"]>;
handleTimelineBlockDrop?: (
blockName: string,
placement: TimelineDropPlacement,
Expand Down Expand Up @@ -246,7 +246,7 @@ interface EditorShellBodyProps {
timelineToolbar: ReactNode;
renderClipContent: RenderClipContent;
onFileDrop: (files: File[], placement?: TimelineDropPlacement) => Promise<void> | void;
onAssetDrop: (assetPath: string, placement: TimelineDropPlacement) => Promise<void> | void;
onAssetDrop: NonNullable<TimelineDropCallbacks["onAssetDrop"]>;
onBlockDrop?: (blockName: string, placement: TimelineDropPlacement) => Promise<void> | void;
onCompositionDrop?: (
sourcePath: string,
Expand Down
79 changes: 79 additions & 0 deletions packages/studio/src/components/StudioHeader.embed.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom

/** Embedded in promptwise: no header; the Timeline tab's toolbar drives Inspector and Export by message. */
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";

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

const layout = { rightCollapsed: true, setRightCollapsed: vi.fn(), setRightPanelTab: vi.fn() };

vi.mock("../promptwise/embed", () => ({ embeddedInPromptwise: true }));
vi.mock("../contexts/StudioContext", () => ({
useStudioShellContext: () => ({
projectId: "773d81ab-4ac0-4f9d-acd7-4cd8838312dd",
renderQueue: { isRendering: false, ffmpegMissing: false },
}),
}));
vi.mock("../contexts/PanelLayoutContext", () => ({ usePanelLayoutContext: () => layout }));
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));

const { StudioHeader } = await import("./StudioHeader");

function command(name: string) {
act(() => {
window.dispatchEvent(
new MessageEvent("message", {
data: { type: "promptwise:command", command: name },
source: window.parent,
}),
);
});
}

it("draws no header, runs the toolbar's presses and reports their state", () => {
const posted: unknown[] = [];
const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation((message) => {
posted.push(message);
});
const onExport = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() =>
root.render(
<StudioHeader
captureFrameHref="blob:frame"
captureFrameFilename="frame.png"
handleCaptureFrameClick={vi.fn()}
refreshCaptureFrameTime={vi.fn()}
inspectorButtonActive={false}
inspectorPanelActive={false}
onExport={onExport}
/>,
),
);

expect(host.innerHTML).toBe("");
expect(posted).toEqual([{ type: "promptwise:state", inspectorOpen: false, rendering: false }]);

command("inspector");
expect(layout.setRightPanelTab).toHaveBeenLastCalledWith("design");
expect(layout.setRightCollapsed).toHaveBeenLastCalledWith(false);

command("export");
expect(layout.setRightPanelTab).toHaveBeenLastCalledWith("renders");
expect(onExport).toHaveBeenCalledOnce();

// Only the parent page may drive Studio, and only with the commands it knows.
window.dispatchEvent(
new MessageEvent("message", { data: { type: "promptwise:command", command: "export" } }),
);
command("delete-everything");
expect(onExport).toHaveBeenCalledOnce();

act(() => root.unmount());
host.remove();
postMessage.mockRestore();
});
61 changes: 37 additions & 24 deletions packages/studio/src/components/StudioHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { Button, cn, Tooltip } from "./ui";
import { ButtonGroup, ButtonGroupItem } from "../boardui/components/base/buttons/button-group";
import { Dock } from "./dock/Dock";
import { PromptwiseMark } from "../promptwise/PromptwiseMark";
import { embeddedInPromptwise } from "../promptwise/embed";
import { usePromptwiseToolbar } from "../promptwise/usePromptwiseToolbar";
import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle";

interface StudioHeaderProps {
Expand Down Expand Up @@ -55,6 +57,39 @@ export const StudioHeader = memo(function StudioHeader({
const isRendering = renderQueue.isRendering;
const ffmpegMissing = renderQueue.ffmpegMissing;

const toggleInspector = () => {
if (shouldOpenInspector(rightCollapsed, inspectorPanelActive)) {
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
setRightPanelTab("design");
setRightCollapsed(false);
return;
}
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
// Keep the current selection when collapsing the Inspector — closing
// the panel shouldn't deselect the element.
setRightCollapsed(true);
};

const exportVideo = () => {
if (isRendering) return;
setRightPanelTab("renders");
setRightCollapsed(false);
// Without an encoder this render cannot finish, so the click
// delivers the user to the prompt that fixes it instead of
// queueing a job that exists only to fail. Disabling the button
// would leave them staring at a dead control with no route to
// the explanation.
if (ffmpegMissing) return;
onExport?.();
};

usePromptwiseToolbar(
{ inspectorOpen: inspectorButtonActive, rendering: isRendering },
{ inspector: toggleInspector, export: exportVideo },
);
// promptwise's Timeline tab has its own toolbar and tab name, so a second header would only repeat them.
if (embeddedInPromptwise) return null;

return (
<div className="flex h-12 shrink-0 items-center justify-between border-b border-border-button-default bg-background-primary-default px-3">
<div className="flex items-center gap-3">
Expand Down Expand Up @@ -104,18 +139,7 @@ export const StudioHeader = memo(function StudioHeader({
aria-pressed={inspectorButtonActive}
selected={inspectorButtonActive}
leadingIcon={RiLayoutRightLine}
onClick={() => {
if (shouldOpenInspector(rightCollapsed, inspectorPanelActive)) {
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
setRightPanelTab("design");
setRightCollapsed(false);
return;
}
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
// Keep the current selection when collapsing the Inspector — closing
// the panel shouldn't deselect the element.
setRightCollapsed(true);
}}
onClick={toggleInspector}
>
Inspector
</ButtonGroupItem>
Expand All @@ -137,18 +161,7 @@ export const StudioHeader = memo(function StudioHeader({
variant="primary"
data-testid="header-export"
disabled={isRendering}
onClick={() => {
if (isRendering) return;
setRightPanelTab("renders");
setRightCollapsed(false);
// Without an encoder this render cannot finish, so the click
// delivers the user to the prompt that fixes it instead of
// queueing a job that exists only to fail. Disabling the button
// would leave them staring at a dead control with no route to
// the explanation.
if (ffmpegMissing) return;
onExport?.();
}}
onClick={exportVideo}
>
{isRendering ? "Rendering…" : "Export"}
</Button>
Expand Down
44 changes: 44 additions & 0 deletions packages/studio/src/components/TimelineToolbar.embed.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// @vitest-environment happy-dom

/** Embedded in promptwise the toolbar starts as its own Timeline's does; the rest opens with More. */
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";

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

vi.mock("../promptwise/embed", async (original) => ({
...(await original<typeof import("../promptwise/embed")>()),
embeddedInPromptwise: true,
}));
vi.mock("../contexts/StudioContext", () => ({
useStudioShellContextOptional: () => ({
previewIframeRef: { current: null },
editHistory: { canUndo: false, canRedo: false },
handleUndo: vi.fn(),
handleRedo: vi.fn(),
}),
}));

const { TimelineToolbar } = await import("./TimelineToolbar");

it("shows history, the tool, split and zoom, and the other tools once More is pressed", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => root.render(<TimelineToolbar onSplitElement={() => {}} />));
const labels = () =>
[...host.querySelectorAll("button, input")].map((el) => el.getAttribute("aria-label") ?? "");
const rare = ["Toggle timeline snapping", "Toggle ripple edit", "Add beat at playhead"];

expect(labels()).toEqual(
expect.arrayContaining(["Split at playhead", "Fit timeline to width", "Zoom out", "Zoom in"]),
);
for (const label of rare) expect(labels()).not.toContain(label);

act(() => host.querySelector<HTMLButtonElement>('[aria-label="More tools"]')!.click());
expect(labels()).toEqual(expect.arrayContaining([...rare, "Fewer tools"]));

act(() => root.unmount());
host.remove();
});
Loading