mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
fix(studio): harden composition timeline reliability (#2615)
* fix(studio): preserve composition playback continuity * feat(studio): drag compositions into the timeline * fix(studio): collapse expanded composition move aliases * fix(studio): make timeline cuts atomic * fix(studio): group inspector gesture history * test(studio): cover masked text selection * fix(studio): harden composition timeline reliability * fix(studio): satisfy CI source gates * fix(studio): harden composition mutation requests
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
||||
import { CompositionsTab } from "./CompositionsTab";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
(
|
||||
window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
|
||||
).happyDOM.settings.disableIframePageLoading = true;
|
||||
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount());
|
||||
root = null;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CompositionsTab
|
||||
projectId="demo"
|
||||
compositions={["compositions/headline.html"]}
|
||||
activeComposition={null}
|
||||
onSelect={onSelect}
|
||||
onAddToTimeline={onAddToTimeline}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const card = host.querySelector<HTMLElement>('[draggable="true"]');
|
||||
if (!card) throw new Error("composition card did not render");
|
||||
return { host, card, onSelect, onAddToTimeline };
|
||||
}
|
||||
|
||||
describe("composition card drag", () => {
|
||||
it("keeps ordinary click navigation", () => {
|
||||
const { card, onSelect } = mount();
|
||||
act(() => card.click());
|
||||
expect(onSelect).toHaveBeenCalledWith("compositions/headline.html");
|
||||
});
|
||||
|
||||
it("emits only source identity and suppresses the click following a drag", () => {
|
||||
const { card, onSelect } = mount();
|
||||
const data = new Map<string, string>();
|
||||
const event = new Event("dragstart", { bubbles: true });
|
||||
Object.defineProperty(event, "dataTransfer", {
|
||||
value: {
|
||||
effectAllowed: "none",
|
||||
setData: (type: string, value: string) => data.set(type, value),
|
||||
},
|
||||
});
|
||||
act(() => {
|
||||
card.dispatchEvent(event);
|
||||
card.click();
|
||||
});
|
||||
|
||||
expect(JSON.parse(data.get(TIMELINE_COMPOSITION_MIME) ?? "null")).toEqual({
|
||||
sourcePath: "compositions/headline.html",
|
||||
});
|
||||
expect(card.className).toContain("select-none");
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers pointer and keyboard add-at-playhead actions without opening the card", () => {
|
||||
const { host, onSelect, onAddToTimeline } = mount();
|
||||
const add = host.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Add headline to timeline at playhead"]',
|
||||
);
|
||||
if (!add) throw new Error("add action did not render");
|
||||
act(() => {
|
||||
add.click();
|
||||
add.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
add.click();
|
||||
});
|
||||
|
||||
expect(onAddToTimeline).toHaveBeenCalledTimes(2);
|
||||
expect(onAddToTimeline).toHaveBeenLastCalledWith("compositions/headline.html");
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
|
||||
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
||||
|
||||
interface CompositionsTabProps {
|
||||
projectId: string;
|
||||
@@ -7,6 +8,7 @@ interface CompositionsTabProps {
|
||||
activeComposition: string | null;
|
||||
onSelect: (comp: string) => void;
|
||||
onRenderComposition?: (comp: string) => void;
|
||||
onAddToTimeline?: (comp: string) => void;
|
||||
isRendering?: boolean;
|
||||
lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
|
||||
}
|
||||
@@ -115,6 +117,7 @@ function CompCard({
|
||||
onRender,
|
||||
isRendering,
|
||||
lintInfo,
|
||||
onAddToTimeline,
|
||||
}: {
|
||||
projectId: string;
|
||||
comp: string;
|
||||
@@ -123,12 +126,14 @@ function CompCard({
|
||||
onRender?: () => void;
|
||||
isRendering?: boolean;
|
||||
lintInfo?: { count: number; messages: string[] };
|
||||
onAddToTimeline?: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const draggedRef = useRef(false);
|
||||
|
||||
const requestIframePlaybackSync = useCallback((shouldPlay: boolean) => {
|
||||
if (syncTimer.current) {
|
||||
@@ -179,10 +184,32 @@ function CompCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
draggedRef.current = true;
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
event.dataTransfer.setData(TIMELINE_COMPOSITION_MIME, JSON.stringify({ sourcePath: comp }));
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
window.setTimeout(() => {
|
||||
draggedRef.current = false;
|
||||
}, 0);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!draggedRef.current) onSelect();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
onPointerEnter={handleEnter}
|
||||
onPointerLeave={handleLeave}
|
||||
className={`group/card w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
|
||||
className={`group/card w-full select-none text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-grab active:cursor-grabbing ${
|
||||
isActive
|
||||
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
||||
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
||||
@@ -232,6 +259,20 @@ function CompCard({
|
||||
</div>
|
||||
<span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
|
||||
</div>
|
||||
{onAddToTimeline && (
|
||||
<button
|
||||
type="button"
|
||||
title={`Add ${name} to timeline at playhead`}
|
||||
aria-label={`Add ${name} to timeline at playhead`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onAddToTimeline();
|
||||
}}
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded text-neutral-600 opacity-0 transition-[color,background-color,opacity] hover:bg-neutral-800 hover:text-studio-accent group-hover/card:opacity-100 group-focus-within/card:opacity-100 focus:opacity-100"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
)}
|
||||
{onRender && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -274,6 +315,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
||||
activeComposition,
|
||||
onSelect,
|
||||
onRenderComposition,
|
||||
onAddToTimeline,
|
||||
isRendering,
|
||||
lintFindingsByFile,
|
||||
}: CompositionsTabProps) {
|
||||
@@ -295,6 +337,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
||||
isActive={activeComposition === comp}
|
||||
onSelect={() => onSelect(comp)}
|
||||
onRender={onRenderComposition ? () => onRenderComposition(comp) : undefined}
|
||||
onAddToTimeline={onAddToTimeline ? () => onAddToTimeline(comp) : undefined}
|
||||
isRendering={isRendering}
|
||||
lintInfo={lintFindingsByFile?.get(comp)}
|
||||
/>
|
||||
|
||||
@@ -61,6 +61,7 @@ interface LeftSidebarProps {
|
||||
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
|
||||
takeoverContent?: ReactNode;
|
||||
onAddAssetToTimeline?: (path: string) => void;
|
||||
onAddCompositionToTimeline?: (path: string) => void;
|
||||
}
|
||||
|
||||
export const LeftSidebar = memo(
|
||||
@@ -94,6 +95,7 @@ export const LeftSidebar = memo(
|
||||
onPreviewBlock,
|
||||
takeoverContent,
|
||||
onAddAssetToTimeline,
|
||||
onAddCompositionToTimeline,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -220,6 +222,7 @@ export const LeftSidebar = memo(
|
||||
compositions={compositions}
|
||||
activeComposition={activeComposition}
|
||||
onSelect={onSelectComposition}
|
||||
onAddToTimeline={onAddCompositionToTimeline}
|
||||
onRenderComposition={onRenderComposition}
|
||||
isRendering={isRendering}
|
||||
lintFindingsByFile={lintFindingsByFile}
|
||||
|
||||
Reference in New Issue
Block a user