mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 12:00:26 +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:
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildExpandedElements } from "./useExpandedTimelineElements";
|
||||
import {
|
||||
buildExpandedElements,
|
||||
resolveTimelineExpansionRawId,
|
||||
} from "./useExpandedTimelineElements";
|
||||
import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
@@ -19,8 +22,14 @@ const clip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
const el = (over: Partial<TimelineElement>): TimelineElement =>
|
||||
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as TimelineElement;
|
||||
const el = (over: Partial<TimelineElement>): TimelineElement => ({
|
||||
id: "x",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
tag: "div",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("buildExpandedElements", () => {
|
||||
it("rebases a 1-level child onto its sub-comp host (start + sourceFile)", () => {
|
||||
@@ -42,6 +51,7 @@ describe("buildExpandedElements", () => {
|
||||
expect(child.sourceFile).toBe("stats.html");
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("rebases a 2-level child onto its NESTED host, not the top-level scene", () => {
|
||||
// top host A@10 (a.html) embeds host B@12 (b.html); child C lives in b.html.
|
||||
// Edits must rebase onto B (12 / b.html), not A (10 / a.html).
|
||||
@@ -65,6 +75,7 @@ describe("buildExpandedElements", () => {
|
||||
expect(child.sourceFile).toBe("b.html"); // B's file, not a.html
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("rebases a 3-level child onto its deepest host, not intermediate or top", () => {
|
||||
// A@10 (a.html) → B@12 (b.html) → C@13 (c.html); leaf D lives in c.html.
|
||||
// Edits must rebase onto C (13 / c.html), not B (12 / b.html) or A (10 / a.html).
|
||||
@@ -166,3 +177,99 @@ describe("buildExpandedElements", () => {
|
||||
expect(out.some((e) => e.domId === "scene-host")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTimelineExpansionRawId", () => {
|
||||
it("returns null when paused inside a childless top-level clip", () => {
|
||||
const manifest = [clip({ id: "title", start: 0, duration: 4 })];
|
||||
|
||||
expect(
|
||||
resolveTimelineExpansionRawId({
|
||||
selectedElementId: null,
|
||||
isPlaying: false,
|
||||
currentTime: 2,
|
||||
manifest,
|
||||
parentMap: new Map(),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-expands an active composition with children when paused and nothing is selected", () => {
|
||||
const manifest = [
|
||||
clip({ id: "scene", start: 1, duration: 5 }),
|
||||
clip({ id: "headline", start: 1.5, duration: 2 }),
|
||||
];
|
||||
const parentMap = new Map([["headline", "scene"]]);
|
||||
|
||||
expect(
|
||||
resolveTimelineExpansionRawId({
|
||||
selectedElementId: null,
|
||||
isPlaying: false,
|
||||
currentTime: 2,
|
||||
manifest,
|
||||
parentMap,
|
||||
}),
|
||||
).toBe("scene");
|
||||
});
|
||||
|
||||
it("auto-expands the innermost active nested composition when paused", () => {
|
||||
const manifest = [
|
||||
clip({ id: "outer", start: 0, duration: 10 }),
|
||||
clip({ id: "inner", start: 2, duration: 5 }),
|
||||
clip({ id: "leaf", start: 3, duration: 1 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["inner", "outer"],
|
||||
["leaf", "inner"],
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveTimelineExpansionRawId({
|
||||
selectedElementId: null,
|
||||
isPlaying: false,
|
||||
currentTime: 3.5,
|
||||
manifest,
|
||||
parentMap,
|
||||
}),
|
||||
).toBe("inner");
|
||||
});
|
||||
|
||||
it("does not auto-expand an active composition while playing", () => {
|
||||
const manifest = [
|
||||
clip({ id: "scene", start: 0, duration: 5 }),
|
||||
clip({ id: "headline", start: 1, duration: 2 }),
|
||||
];
|
||||
const parentMap = new Map([["headline", "scene"]]);
|
||||
|
||||
expect(
|
||||
resolveTimelineExpansionRawId({
|
||||
selectedElementId: null,
|
||||
isPlaying: true,
|
||||
currentTime: 2,
|
||||
manifest,
|
||||
parentMap,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps selected elements ahead of paused active composition auto-expansion", () => {
|
||||
const manifest = [
|
||||
clip({ id: "scene", start: 0, duration: 6 }),
|
||||
clip({ id: "headline", start: 1, duration: 2 }),
|
||||
clip({ id: "caption", start: 4, duration: 1 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["headline", "scene"],
|
||||
["caption", "scene"],
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveTimelineExpansionRawId({
|
||||
selectedElementId: "caption",
|
||||
isPlaying: false,
|
||||
currentTime: 1.5,
|
||||
manifest,
|
||||
parentMap,
|
||||
}),
|
||||
).toBe("caption");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@ function findTopLevelAncestor(id: string, parentMap: Map<string, string>): strin
|
||||
while (parentMap.has(current)) {
|
||||
if (visited.has(current)) return current;
|
||||
visited.add(current);
|
||||
current = parentMap.get(current)!;
|
||||
const parent = parentMap.get(current);
|
||||
if (!parent) return current;
|
||||
current = parent;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
@@ -36,6 +38,67 @@ function resolveRawId(
|
||||
return null;
|
||||
}
|
||||
|
||||
interface TimelineExpansionRawIdInput {
|
||||
selectedElementId: string | null;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
manifest: ClipManifestClip[];
|
||||
parentMap: Map<string, string>;
|
||||
}
|
||||
|
||||
function clipContainsTime(clip: ClipManifestClip, time: number): boolean {
|
||||
return Number.isFinite(time) && time >= clip.start && time < clip.start + clip.duration;
|
||||
}
|
||||
|
||||
function getActiveParentDepth(id: string, parentMap: Map<string, string>, activeIds: Set<string>) {
|
||||
let depth = 0;
|
||||
let parent = parentMap.get(id);
|
||||
const visited = new Set<string>();
|
||||
visited.add(id);
|
||||
while (parent) {
|
||||
if (visited.has(parent)) return depth;
|
||||
visited.add(parent);
|
||||
if (activeIds.has(parent)) depth += 1;
|
||||
parent = parentMap.get(parent);
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
function findActiveExpandableCompositionId(
|
||||
currentTime: number,
|
||||
manifest: ClipManifestClip[],
|
||||
parentMap: Map<string, string>,
|
||||
): string | null {
|
||||
const parentIds = new Set(parentMap.values());
|
||||
const activeIds = new Set<string>();
|
||||
for (const clip of manifest) {
|
||||
if (!clip.id || !parentIds.has(clip.id) || !clipContainsTime(clip, currentTime)) continue;
|
||||
activeIds.add(clip.id);
|
||||
}
|
||||
let bestId: string | null = null;
|
||||
let bestDepth = -1;
|
||||
for (const id of activeIds) {
|
||||
const depth = getActiveParentDepth(id, parentMap, activeIds);
|
||||
if (depth <= bestDepth) continue;
|
||||
bestId = id;
|
||||
bestDepth = depth;
|
||||
}
|
||||
return bestId;
|
||||
}
|
||||
|
||||
export function resolveTimelineExpansionRawId({
|
||||
selectedElementId,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
manifest,
|
||||
parentMap,
|
||||
}: TimelineExpansionRawIdInput): string | null {
|
||||
const selectedRawId = resolveRawId(selectedElementId, manifest, parentMap);
|
||||
if (selectedRawId) return selectedRawId;
|
||||
if (isPlaying) return null;
|
||||
return findActiveExpandableCompositionId(currentTime, manifest, parentMap);
|
||||
}
|
||||
|
||||
function filterToTopLevel(
|
||||
elements: TimelineElement[],
|
||||
parentMap: Map<string, string>,
|
||||
@@ -105,7 +168,7 @@ function buildChildElements(
|
||||
domId,
|
||||
selector,
|
||||
sourceFile: editBasis.sourceFile,
|
||||
timingSource: "authored" as const,
|
||||
timingSource: "authored",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
@@ -122,19 +185,21 @@ function domSiblingClips(
|
||||
): ClipManifestClip[] {
|
||||
return domClipChildren
|
||||
.filter((c) => c.parentId === siblingParentId)
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
start: host.start,
|
||||
duration: host.duration,
|
||||
track: host.track,
|
||||
kind: "element" as const,
|
||||
tagName: null,
|
||||
compositionId: null,
|
||||
parentCompositionId: host.id ?? null,
|
||||
compositionSrc: host.compositionSrc ?? null,
|
||||
assetUrl: null,
|
||||
}));
|
||||
.map(
|
||||
(c): ClipManifestClip => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
start: host.start,
|
||||
duration: host.duration,
|
||||
track: host.track,
|
||||
kind: "element",
|
||||
tagName: null,
|
||||
compositionId: null,
|
||||
parentCompositionId: host.id ?? null,
|
||||
compositionSrc: host.compositionSrc ?? null,
|
||||
assetUrl: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Exported for tests.
|
||||
@@ -191,16 +256,38 @@ export function useExpandedTimelineElements(): TimelineElement[] {
|
||||
const clipParentMap = usePlayerStore((s) => s.clipParentMap);
|
||||
const domClipChildren = usePlayerStore((s) => s.domClipChildren);
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
|
||||
// Resolve which raw clip drives expansion. This reads currentTime (for paused
|
||||
// auto-expand) so it re-runs each scrub tick, but it's a cheap manifest scan and
|
||||
// its RESULT only changes when the playhead crosses a composition boundary. Keying
|
||||
// the expensive build below on these ids (not raw currentTime) avoids re-allocating
|
||||
// expandedElements — and cascading TimelineClip re-renders — on every tick.
|
||||
const { rawId, selectedRawId } = useMemo(() => {
|
||||
if (!clipManifest || clipManifest.length === 0 || clipParentMap.size === 0) {
|
||||
return { rawId: null as string | null, selectedRawId: null as string | null };
|
||||
}
|
||||
return {
|
||||
rawId: resolveTimelineExpansionRawId({
|
||||
selectedElementId,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
manifest: clipManifest,
|
||||
parentMap: clipParentMap,
|
||||
}),
|
||||
selectedRawId: resolveRawId(selectedElementId, clipManifest, clipParentMap),
|
||||
};
|
||||
}, [clipManifest, clipParentMap, selectedElementId, isPlaying, currentTime]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!clipManifest || clipManifest.length === 0 || clipParentMap.size === 0) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const rawId = resolveRawId(selectedElementId, clipManifest, clipParentMap);
|
||||
if (!rawId) return filterToTopLevel(elements, clipParentMap);
|
||||
|
||||
const immediateParent = clipParentMap.get(rawId)!;
|
||||
const immediateParent = selectedRawId ? clipParentMap.get(rawId) : rawId;
|
||||
if (!immediateParent) return filterToTopLevel(elements, clipParentMap);
|
||||
const topLevel = findTopLevelAncestor(rawId, clipParentMap) ?? immediateParent;
|
||||
return buildExpandedElements(
|
||||
elements,
|
||||
@@ -210,5 +297,5 @@ export function useExpandedTimelineElements(): TimelineElement[] {
|
||||
immediateParent,
|
||||
domClipChildren,
|
||||
);
|
||||
}, [elements, clipManifest, clipParentMap, domClipChildren, selectedElementId]);
|
||||
}, [elements, clipManifest, clipParentMap, domClipChildren, rawId, selectedRawId]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user