mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
feat(studio): timeline revamp with active-clip highlighting and hide controls (#2017)
Timeline UI - Highlight clips visible at the playhead in the primary color; others share one neutral color - Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels - Per-track eye toggle and a per-element hide button in the design panel - Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom - Sticky gutter so track controls stay visible while scrolling WYSIWYG visibility (data-hidden) - Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview - HTML stays the source of truth; hide state persists and round-trips on reload Split several studio files to stay under the 600-line cap; pure relocations, no behavior change.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePlayerStore, type TimelineElement } from "../player";
|
||||
import { toggleTimelineElementHidden, toggleTimelineTrackHidden } from "./timelineTrackVisibility";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
function element(overrides: Partial<TimelineElement>): TimelineElement {
|
||||
return {
|
||||
id: "clip",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 2,
|
||||
track: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stubProjectFiles(files: Map<string, string>) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const encodedPath = url.slice(url.lastIndexOf("/") + 1);
|
||||
const path = decodeURIComponent(encodedPath);
|
||||
const content = files.get(path);
|
||||
return new Response(JSON.stringify({ content }), {
|
||||
status: content === undefined ? 404 : 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("toggleTimelineTrackHidden", () => {
|
||||
it("patches iframe DOM and persists all track elements as one edit-history entry", async () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
if (iframe.contentDocument) {
|
||||
iframe.contentDocument.body.innerHTML = `
|
||||
<div id="hero"></div>
|
||||
<div id="subtitle"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
const files = new Map([
|
||||
[
|
||||
"index.html",
|
||||
`<div id="hero" data-start="0" data-duration="2"></div>
|
||||
<div id="skip" data-start="0" data-duration="2"></div>`,
|
||||
],
|
||||
["scene.html", `<div id="subtitle" data-start="1" data-duration="2"></div>`],
|
||||
]);
|
||||
stubProjectFiles(files);
|
||||
|
||||
const writes = new Map<string, string>();
|
||||
const recordEdit = vi.fn();
|
||||
const timestampRef = { current: 0 };
|
||||
const pendingRef = { current: new Set<string>() };
|
||||
|
||||
await toggleTimelineTrackHidden({
|
||||
projectId: "project-1",
|
||||
activeCompPath: "index.html",
|
||||
timelineElements: [
|
||||
element({ id: "hero", domId: "hero", track: 0 }),
|
||||
element({ id: "skip", domId: "skip", track: 1 }),
|
||||
element({ id: "subtitle", domId: "subtitle", track: 0, sourceFile: "scene.html" }),
|
||||
],
|
||||
track: 0,
|
||||
hidden: true,
|
||||
previewIframe: iframe,
|
||||
writeProjectFile: async (path, content) => {
|
||||
writes.set(path, content);
|
||||
},
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef: timestampRef,
|
||||
pendingTimelineEditPathRef: pendingRef,
|
||||
});
|
||||
|
||||
expect(iframe.contentDocument?.getElementById("hero")?.hasAttribute("data-hidden")).toBe(true);
|
||||
expect(iframe.contentDocument?.getElementById("subtitle")?.hasAttribute("data-hidden")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(writes.get("index.html")).toContain('id="hero" data-start="0" data-duration="2"');
|
||||
expect(writes.get("index.html")).toContain('data-hidden=""');
|
||||
expect(writes.get("index.html")).toContain('id="skip" data-start="0" data-duration="2"');
|
||||
expect(writes.get("scene.html")).toContain('data-hidden=""');
|
||||
expect(pendingRef.current).toEqual(new Set(["index.html", "scene.html"]));
|
||||
expect(timestampRef.current).toBeGreaterThan(0);
|
||||
expect(recordEdit).toHaveBeenCalledTimes(1);
|
||||
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 0");
|
||||
expect(Object.keys(recordEdit.mock.calls[0]?.[0]?.files ?? {}).sort()).toEqual([
|
||||
"index.html",
|
||||
"scene.html",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes data-hidden from every element on the track", async () => {
|
||||
const files = new Map([
|
||||
[
|
||||
"index.html",
|
||||
`<div id="hero" data-start="0" data-duration="2" data-hidden=""></div>
|
||||
<div id="caption" data-start="2" data-duration="2" data-hidden=""></div>`,
|
||||
],
|
||||
]);
|
||||
stubProjectFiles(files);
|
||||
|
||||
const writes = new Map<string, string>();
|
||||
|
||||
await toggleTimelineTrackHidden({
|
||||
projectId: "project-1",
|
||||
activeCompPath: "index.html",
|
||||
timelineElements: [
|
||||
element({ id: "hero", domId: "hero", track: 0, hidden: true }),
|
||||
element({ id: "caption", domId: "caption", track: 0, hidden: true }),
|
||||
],
|
||||
track: 0,
|
||||
hidden: false,
|
||||
previewIframe: null,
|
||||
writeProjectFile: async (path, content) => {
|
||||
writes.set(path, content);
|
||||
},
|
||||
recordEdit: vi.fn(),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
pendingTimelineEditPathRef: { current: new Set() },
|
||||
});
|
||||
|
||||
expect(writes.get("index.html")).not.toContain("data-hidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleTimelineElementHidden", () => {
|
||||
it("persists data-hidden for only the selected element and updates the player store", async () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
const seek = vi.fn();
|
||||
const win = iframe.contentWindow;
|
||||
if (!win) throw new Error("Expected iframe contentWindow");
|
||||
const playerWindow: Window & { __player?: { seek?: (time: number) => void } } = win;
|
||||
playerWindow.__player = { seek };
|
||||
|
||||
const files = new Map([
|
||||
[
|
||||
"index.html",
|
||||
`<div id="hero" data-start="0" data-duration="2"></div>
|
||||
<div id="track-mate" data-start="1" data-duration="2"></div>`,
|
||||
],
|
||||
]);
|
||||
stubProjectFiles(files);
|
||||
|
||||
const hero = element({ id: "hero", key: "index.html:#hero", domId: "hero", track: 0 });
|
||||
const trackMate = element({
|
||||
id: "track-mate",
|
||||
key: "index.html:#track-mate",
|
||||
domId: "track-mate",
|
||||
track: 0,
|
||||
});
|
||||
usePlayerStore.getState().setElements([hero, trackMate]);
|
||||
usePlayerStore.getState().setCurrentTime(1.25);
|
||||
|
||||
const writes = new Map<string, string>();
|
||||
const recordEdit = vi.fn();
|
||||
|
||||
const changedPaths = await toggleTimelineElementHidden({
|
||||
projectId: "project-1",
|
||||
activeCompPath: "index.html",
|
||||
timelineElements: [hero, trackMate],
|
||||
elementKey: "index.html:#hero",
|
||||
hidden: true,
|
||||
previewIframe: iframe,
|
||||
writeProjectFile: async (path, content) => {
|
||||
writes.set(path, content);
|
||||
},
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
pendingTimelineEditPathRef: { current: new Set() },
|
||||
});
|
||||
|
||||
expect(changedPaths).toEqual(["index.html"]);
|
||||
expect(writes.get("index.html")).toContain('id="hero" data-start="0" data-duration="2"');
|
||||
expect(writes.get("index.html")).toContain(
|
||||
'id="hero" data-start="0" data-duration="2" data-hidden=""',
|
||||
);
|
||||
expect(writes.get("index.html")).toContain(
|
||||
'id="track-mate" data-start="1" data-duration="2"></div>',
|
||||
);
|
||||
expect(recordEdit).toHaveBeenCalledTimes(1);
|
||||
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide element");
|
||||
expect(seek).toHaveBeenCalledWith(1.25);
|
||||
expect(
|
||||
usePlayerStore.getState().elements.find((el) => el.key === "index.html:#hero")?.hidden,
|
||||
).toBe(true);
|
||||
expect(
|
||||
usePlayerStore.getState().elements.find((el) => el.key === "index.html:#track-mate")?.hidden,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user