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:
Miguel Ángel
2026-07-07 04:26:56 -04:00
committed by GitHub
parent 5d59835446
commit 037266e72b
56 changed files with 2407 additions and 623 deletions
@@ -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]);
}