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:
Miguel Ángel
2026-07-17 14:15:30 -04:00
committed by GitHub
parent 2be8a62c00
commit 2b65b4efce
93 changed files with 3925 additions and 758 deletions
@@ -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}