mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user