feat(studio): let a host app mount the timeline toolbar, undo buttons, meters and clipboard (#4629)

* feat(studio): let a host app mount the timeline toolbar, undo buttons, meters and clipboard

* test(studio): lock in history button rules and trim duplicate export tests
This commit is contained in:
Miguel Ángel
2026-09-28 03:37:32 +00:00
committed by GitHub
parent 04d56d06aa
commit bc73c411f0
11 changed files with 235 additions and 38 deletions
@@ -16,8 +16,10 @@ const handleUndo = vi.fn();
const handleRedo = vi.fn();
const trackStudioEvent = vi.fn();
const studioShell = { editHistory, handleUndo, handleRedo };
let shell: typeof studioShell | null = studioShell;
vi.mock("../contexts/StudioContext", () => ({
useStudioShellContext: () => ({ editHistory, handleUndo, handleRedo }),
useStudioShellContextOptional: () => shell,
}));
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent }));
@@ -31,6 +33,7 @@ beforeEach(() => {
undoLabel: undefined,
redoLabel: undefined,
});
shell = studioShell;
vi.clearAllMocks();
});
@@ -115,3 +118,37 @@ it("classifies Undo and Redo for the hotkey filters at their new location (KTD13
expect(shouldIgnorePlaybackShortcutTarget(el), label).toBe(true);
}
});
it("takes a host's history props over the shell's", async () => {
editHistory.canUndo = true;
editHistory.redoLabel = "shell move";
const onUndo = vi.fn();
const onRedo = vi.fn();
const host = mount(
<TimelineHistoryButtons
canUndo={false}
canRedo
redoLabel="host trim"
onUndo={onUndo}
onRedo={onRedo}
/>,
);
expect(button(host, "Undo").disabled).toBe(true);
act(() => button(host, "Redo").focus());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(document.querySelector('[role="tooltip"]')?.textContent).toContain("Redo host trim");
click(button(host, "Redo"));
expect(onRedo).toHaveBeenCalledTimes(1);
expect(handleRedo).not.toHaveBeenCalled();
});
it("keeps a button disabled when it has no handler, whatever canUndo/canRedo say", () => {
shell = null;
const host = mount(<TimelineHistoryButtons canUndo canRedo />);
for (const label of ["Undo", "Redo"]) expect(button(host, label).disabled).toBe(true);
});
@@ -1,4 +1,4 @@
import { useStudioShellContext } from "../contexts/StudioContext";
import { useStudioShellContextOptional } from "../contexts/StudioContext";
import { RotateCcw, RotateCw } from "../icons/SystemIcons";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { historyTooltipLabel } from "../utils/studioHelpers";
@@ -7,13 +7,14 @@ import { Tooltip } from "./ui";
interface HistoryButtonProps {
action: "undo" | "redo";
enabled: boolean;
can: boolean;
label: string | undefined;
onClick: () => Promise<void> | void;
onClick: (() => Promise<void> | void) | undefined;
}
function HistoryButton({ action, enabled, label, onClick }: HistoryButtonProps) {
function HistoryButton({ action, can, label, onClick }: HistoryButtonProps) {
const Icon = action === "undo" ? RotateCcw : RotateCw;
const enabled = Boolean(onClick) && can;
return (
<Tooltip label={historyTooltipLabel(action, label)}>
<button
@@ -23,7 +24,7 @@ function HistoryButton({ action, enabled, label, onClick }: HistoryButtonProps)
className={enabled ? flatIdle : flatDisabled}
onClick={() => {
trackStudioEvent("toolbar_action", { action });
void onClick();
void onClick?.();
}}
>
<Icon size={16} />
@@ -32,22 +33,33 @@ function HistoryButton({ action, enabled, label, onClick }: HistoryButtonProps)
);
}
/** Undo and Redo, on the one edit-history path the shell already owns. */
export function TimelineHistoryButtons() {
const { editHistory, handleUndo, handleRedo } = useStudioShellContext();
export interface TimelineHistoryButtonsProps {
canUndo?: boolean;
canRedo?: boolean;
undoLabel?: string;
redoLabel?: string;
onUndo?: () => Promise<void> | void;
onRedo?: () => Promise<void> | void;
}
/** Undo and Redo: a host's props win, else the shell's edit history. */
export function TimelineHistoryButtons(props: TimelineHistoryButtonsProps) {
const shell = useStudioShellContextOptional();
const onUndo = props.onUndo ?? shell?.handleUndo;
const onRedo = props.onRedo ?? shell?.handleRedo;
return (
<>
<HistoryButton
action="undo"
enabled={editHistory.canUndo}
label={editHistory.undoLabel}
onClick={handleUndo}
can={props.canUndo ?? shell?.editHistory.canUndo ?? false}
label={props.undoLabel ?? shell?.editHistory.undoLabel}
onClick={onUndo}
/>
<HistoryButton
action="redo"
enabled={editHistory.canRedo}
label={editHistory.redoLabel}
onClick={handleRedo}
can={props.canRedo ?? shell?.editHistory.canRedo ?? false}
label={props.redoLabel ?? shell?.editHistory.redoLabel}
onClick={onRedo}
/>
</>
);
@@ -12,7 +12,7 @@ import { AudioMeterStrip } from "./nle/AudioMeterStrip";
import { TimelineToolbar } from "./TimelineToolbar";
vi.mock("../contexts/StudioContext", () => ({
useStudioShellContext: () => ({
useStudioShellContextOptional: () => ({
previewIframeRef: { current: null },
editHistory: { canUndo: false, canRedo: false },
handleUndo: vi.fn(),
@@ -214,3 +214,11 @@ describe("TimelineToolbar audio meters", () => {
}
});
});
describe("TimelineToolbar add beat", () => {
it("shows Add beat by default, as Studio does", () => {
const { host, root } = renderToolbar();
expect(host.querySelector('button[aria-label="Add beat at playhead"]')).not.toBeNull();
act(() => root.unmount());
});
});
@@ -19,7 +19,7 @@ import { Tooltip } from "./ui";
import { AudioMetersIcon } from "./icons/AudioMetersIcon";
import { RippleEditIcon } from "./icons/RippleEditIcon";
import { flatActive, flatBtn, flatDisabled, flatIdle } from "./timelineToolbarStyles";
import { TimelineHistoryButtons } from "./TimelineHistoryButtons";
import { TimelineHistoryButtons, type TimelineHistoryButtonsProps } from "./TimelineHistoryButtons";
import { Scissors } from "../icons/SystemIcons";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";
@@ -33,9 +33,11 @@ interface DomEditSessionSlice extends EnableKeyframesSession {
selectedGsapAnimations: GsapAnimation[];
}
interface TimelineToolbarProps {
export interface TimelineToolbarProps {
domEditSession?: DomEditSessionSlice;
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
history?: TimelineHistoryButtonsProps;
showAddBeat?: boolean;
}
interface KeyframeToggleState {
@@ -127,7 +129,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
}
// fallow-ignore-next-line complexity
export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineToolbarProps) {
export function TimelineToolbar({
domEditSession,
onSplitElement,
history,
showAddBeat = true,
}: TimelineToolbarProps) {
const activeTool = usePlayerStore((s) => s.activeTool);
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
@@ -195,7 +202,7 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
<div className="border-b border-neutral-800/60">
<div className="flex items-center justify-between px-2 py-0.5">
<div className="flex items-center gap-0.5">
<TimelineHistoryButtons />
<TimelineHistoryButtons {...history} />
<Tooltip label="Selection tool (V)">
<button
type="button"
@@ -416,7 +423,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
);
})()}
{(() => {
// Always rendered (CapCut-style): before beat analysis loads (or when
if (!showAddBeat) return null;
// Rendered whenever shown (CapCut-style): before beat analysis loads (or when
// the project has no analyzed music) the button fades to a disabled
// state instead of unmounting, so the toolbar layout never shifts.
const canAdd = beatAnalysisReady && canAddBeatAt(currentTime);
@@ -11,6 +11,7 @@ import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
import { SILENT_CHANNEL } from "../../utils/audioMeterMath";
import {
AudioMeterStrip,
type AudioMeterStripProps,
evictGoneMeterState,
followMeterHook,
stepAndPaintStrips,
@@ -20,8 +21,9 @@ import {
const iframe = { contentWindow: null as unknown };
const previewIframeRef = { current: iframe };
let shell: { previewIframeRef: typeof previewIframeRef } | null = { previewIframeRef };
vi.mock("../../contexts/StudioContext", () => ({
useStudioShellContext: () => ({ previewIframeRef }),
useStudioShellContextOptional: () => shell,
}));
const onSetAudioGroupAttributeLive = vi.fn();
@@ -88,6 +90,7 @@ const tick = () => {
};
beforeEach(() => {
shell = { previewIframeRef };
frames = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
vi.stubGlobal("cancelAnimationFrame", () => {});
@@ -103,11 +106,11 @@ afterEach(() => {
vi.unstubAllGlobals();
});
function mount() {
function mount(props: AudioMeterStripProps = {}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => root.render(<AudioMeterStrip />));
act(() => root.render(<AudioMeterStrip {...props} />));
roots.push(root);
return { host, root };
}
@@ -177,6 +180,26 @@ describe("AudioMeterStrip", () => {
expect(second.read).toHaveBeenCalled();
});
it("reads a host's preview iframe over the shell's", () => {
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
const shellHook = makeHook();
setHook(shellHook);
const hostHook = makeHook();
const hostIframe = { contentWindow: { __hf: { audioMeter: hostHook } } };
mount({ previewIframeRef: { current: hostIframe as unknown as HTMLIFrameElement } });
tick();
expect(hostHook.start).toHaveBeenCalledTimes(1);
expect(shellHook.start).not.toHaveBeenCalled();
});
it("mounts with no shell and no preview iframe", () => {
shell = null;
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
const { host } = mount();
tick();
expect(host.querySelector("[data-testid=audio-meter-strip]")).not.toBeNull();
});
it("keeps authored gain 2 above unity and writes exactly 1 at the midpoint", () => {
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo", audioGroupVolume: 2 })] });
const { host } = mount();
@@ -20,7 +20,7 @@ import type { StereoLevel } from "@hyperframes/core/runtime/levelTap";
import { usePlayerStore } from "../../player";
import { clampNumber } from "../../utils/studioHelpers";
import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
import { useStudioShellContext } from "../../contexts/StudioContext";
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import {
METER_DB_MARKS,
@@ -107,7 +107,7 @@ function useVolumeHandlers(): {
type PreviewWindow = (Window & { __hf?: { audioMeter?: AudioMeterHook } }) | null | undefined;
function readHook(iframe: HTMLIFrameElement | null): AudioMeterHook | null {
function readHook(iframe: HTMLIFrameElement | null | undefined): AudioMeterHook | null {
try {
return (iframe?.contentWindow as PreviewWindow)?.__hf?.audioMeter ?? null;
} catch {
@@ -123,9 +123,8 @@ function paintPeak(el: HTMLElement | null, peak: number): void {
el.style.setProperty("transform", peak >= 1 ? "translateY(1px)" : "none");
}
/** The fill is a fixed green/amber/red backdrop; painting only moves the dark
* mask that covers the unlit top portion, so a loud peak lights the real red
* band instead of tinting a flat colour brighter. */
/** The fill is a fixed green/amber/red backdrop; painting only moves the dark mask that covers the unlit top
* portion, so a loud peak lights the real red band instead of tinting a flat colour brighter. */
function paint(bars: StripBars | undefined, channels: Pair): void {
channels.forEach((ch, i) => {
bars?.[i]?.mask?.style.setProperty("height", `${(1 - ch.level) * 100}%`);
@@ -179,8 +178,13 @@ export function stepAndPaintStrips(
}
/** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripBars>>) {
const { previewIframeRef } = useStudioShellContext();
function useMeterLoop(
strips: Strip[],
bars: RefObject<Map<string | null, StripBars>>,
iframeRef: RefObject<HTMLIFrameElement | null> | undefined,
) {
const shell = useStudioShellContextOptional();
const previewIframeRef = iframeRef ?? shell?.previewIframeRef;
const stripsRef = useRef(strips);
stripsRef.current = strips;
useEffect(() => {
@@ -190,7 +194,7 @@ function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripB
const state = new Map<string | null, Pair>();
const tick = (now: number) => {
raf = requestAnimationFrame(tick);
active = followMeterHook(active, readHook(previewIframeRef.current));
active = followMeterHook(active, readHook(previewIframeRef?.current));
const levels = active?.read();
const dt = now - last;
last = now;
@@ -395,14 +399,21 @@ function MeterStrip({
);
}
export const AudioMeterStrip = memo(function AudioMeterStrip() {
export interface AudioMeterStripProps {
/** Pass a stable ref (useRef): a new object each render restarts the meter loop. */
previewIframeRef?: RefObject<HTMLIFrameElement | null>;
}
export const AudioMeterStrip = memo(function AudioMeterStrip({
previewIframeRef,
}: AudioMeterStripProps) {
const visible = useAudioMetersVisible((s) => s.visible);
const projectHasAudio = useProjectHasAudio();
if (!visible || !projectHasAudio) return null;
return <MeterStripBody />;
return <MeterStripBody previewIframeRef={previewIframeRef} />;
});
function MeterStripBody() {
function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
const strips = useStrips();
const bars = useRef(new Map<string | null, StripBars>());
const register = useRef((id: string | null, b: StripBars | null) => {
@@ -410,7 +421,7 @@ function MeterStripBody() {
else bars.current.delete(id);
}).current;
const { onLive, onCommit } = useVolumeHandlers();
useMeterLoop(strips, bars);
useMeterLoop(strips, bars, previewIframeRef);
return (
<div
data-testid="audio-meter-strip"
@@ -7,7 +7,7 @@ import { CompositionsTab } from "./sidebar/CompositionsTab";
import { TimelineToolbar } from "./TimelineToolbar";
vi.mock("../contexts/StudioContext", () => ({
useStudioShellContext: () => ({
useStudioShellContextOptional: () => ({
editHistory: { canUndo: false, canRedo: false },
handleUndo: vi.fn(),
handleRedo: vi.fn(),
@@ -338,3 +338,14 @@ describe("a copy that fails", () => {
expect(deleted).toEqual([]);
});
});
describe("with no DOM edit session", () => {
it("refuses copy, cut and duplicate when nothing is selected", async () => {
clearSelection();
const { clipboard, deleted } = mountClipboard();
expect(clipboard().handleCopy()).toBe(false);
expect(await clipboard().handleCut()).toBe(false);
expect(await clipboard().handleDuplicate()).toBe(false);
expect(deleted).toEqual([]);
});
});
+1 -1
View File
@@ -33,7 +33,7 @@ interface RecordEditInput {
files: Record<string, { before: string; after: string }>;
}
interface UseClipboardOptions {
export interface UseClipboardOptions {
projectId: string | null;
activeCompPath: string | null;
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
+8
View File
@@ -184,6 +184,14 @@ export type { TimelinePaneProps } from "./components/nle/TimelinePane";
export { TimelineEditProvider } from "./contexts/TimelineEditContext";
export type { TimelineEditCallbacks } from "./player/components/timelineCallbacks";
export type { BlockedTimelineEditIntent } from "./player/components/timelineEditing";
export { TimelineToolbar } from "./components/TimelineToolbar";
export type { TimelineToolbarProps } from "./components/TimelineToolbar";
export { TimelineHistoryButtons } from "./components/TimelineHistoryButtons";
export type { TimelineHistoryButtonsProps } from "./components/TimelineHistoryButtons";
export { AudioMeterStrip } from "./components/nle/AudioMeterStrip";
export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
export { useClipboard } from "./hooks/useClipboard";
export type { UseClipboardOptions } from "./hooks/useClipboard";
export {
playSeamTransitionLoop,
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
// Mounts the timeline chrome by package name with no StudioShellProvider, as a host app does.
import { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
AudioMeterStrip,
TimelineHistoryButtons,
TimelineToolbar,
useClipboard,
usePlayerStore,
type UseClipboardOptions,
} from "@hyperframes/studio";
import { installReactActEnvironment, mountReactHarness } from "./hooks/domSelectionTestHarness";
import { useAudioMetersVisible } from "./utils/audioMeterVisibility";
installReactActEnvironment();
afterEach(() => {
document.body.innerHTML = "";
usePlayerStore.setState({ elements: [], selectedElementId: null });
useAudioMetersVisible.setState(useAudioMetersVisible.getInitialState());
});
describe("timeline chrome package exports, outside Studio's shell", () => {
it("mounts the toolbar with the host's history and no Add beat", async () => {
const onUndo = vi.fn();
const root = mountReactHarness(
<TimelineToolbar history={{ canUndo: true, onUndo }} showAddBeat={false} />,
);
const undo = document.querySelector<HTMLButtonElement>('button[aria-label="Undo"]');
act(() => undo?.click());
expect(onUndo).toHaveBeenCalledTimes(1);
expect(document.querySelector('button[aria-label="Add beat at playhead"]')).toBeNull();
await act(async () => root.unmount());
});
it("mounts the history buttons disabled when given nothing", async () => {
const root = mountReactHarness(<TimelineHistoryButtons />);
const buttons = Array.from(document.querySelectorAll("button"));
expect(buttons.map((b) => b.disabled)).toEqual([true, true]);
await act(async () => root.unmount());
});
it("mounts the meter strip on the host's preview iframe", async () => {
usePlayerStore.setState({
elements: [{ id: "music", tag: "audio", start: 0, duration: 4, track: 0 }],
});
useAudioMetersVisible.setState({ visible: true });
const root = mountReactHarness(<AudioMeterStrip previewIframeRef={{ current: null }} />);
expect(document.querySelector('[data-testid="audio-meter-strip"]')).not.toBeNull();
await act(async () => root.unmount());
});
it("runs the clipboard with no DOM edit session", async () => {
const toasts: string[] = [];
const options: UseClipboardOptions = {
projectId: "p",
activeCompPath: "index.html",
domEditSelectionRef: { current: null },
showToast: (message) => toasts.push(message),
writeProjectFile: async () => {},
recordEdit: async () => {},
reloadPreview: () => {},
handleTimelineElementsDelete: async () => {},
handleDomEditElementDelete: async () => {},
previewIframeRef: { current: null },
waitForPendingDomEditSaves: async () => {},
};
const api: { current: ReturnType<typeof useClipboard> | null } = { current: null };
function Harness() {
api.current = useClipboard(options);
return null;
}
const root = mountReactHarness(<Harness />);
expect(api.current?.handleCopy()).toBe(false);
expect(toasts).toEqual(["Nothing selected to copy."]);
await act(async () => root.unmount());
});
});