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
@@ -0,0 +1,93 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { updateTimelineActiveClipClasses } from "./useTimelineActiveClips";
function appendClip(container: HTMLElement, id: string, start: string, end: string): HTMLElement {
const clip = document.createElement("div");
clip.dataset.clip = "true";
clip.dataset.elId = id;
clip.dataset.clipStart = start;
clip.dataset.clipEnd = end;
container.append(clip);
return clip;
}
describe("updateTimelineActiveClipClasses", () => {
it("toggles data-active only for clips containing the current time", () => {
const container = document.createElement("div");
const intro = appendClip(container, "intro", "0", "2");
const hero = appendClip(container, "hero", "2", "5");
const outro = appendClip(container, "outro", "5", "8");
const previous = new Set<string>();
updateTimelineActiveClipClasses(container, previous, 2.25);
expect(intro.hasAttribute("data-active")).toBe(false);
expect(hero.hasAttribute("data-active")).toBe(true);
expect(outro.hasAttribute("data-active")).toBe(false);
expect(previous).toEqual(new Set(["hero"]));
});
it("never marks hidden clips active inside their time window", () => {
const container = document.createElement("div");
const hidden = appendClip(container, "hidden", "0", "5");
const visible = appendClip(container, "visible", "0", "5");
hidden.dataset.clipHidden = "true";
const previous = new Set<string>();
updateTimelineActiveClipClasses(container, previous, 2);
expect(hidden.hasAttribute("data-active")).toBe(false);
expect(visible.hasAttribute("data-active")).toBe(true);
expect(previous).toEqual(new Set(["visible"]));
});
it("diffs against the previous active set", () => {
const container = document.createElement("div");
const intro = appendClip(container, "intro", "0", "2");
const hero = appendClip(container, "hero", "2", "5");
const previous = new Set(["intro"]);
intro.toggleAttribute("data-active", true);
updateTimelineActiveClipClasses(container, previous, 2);
expect(intro.hasAttribute("data-active")).toBe(true);
expect(hero.hasAttribute("data-active")).toBe(true);
expect(previous).toEqual(new Set(["intro", "hero"]));
});
it("keeps a clip active through its inclusive end boundary", () => {
const container = document.createElement("div");
const intro = appendClip(container, "intro", "0", "2");
const previous = new Set<string>();
updateTimelineActiveClipClasses(container, previous, 0);
expect(intro.hasAttribute("data-active")).toBe(true);
expect(previous).toEqual(new Set(["intro"]));
updateTimelineActiveClipClasses(container, previous, 2);
expect(intro.hasAttribute("data-active")).toBe(true);
expect(previous).toEqual(new Set(["intro"]));
updateTimelineActiveClipClasses(container, previous, 2.001);
expect(intro.hasAttribute("data-active")).toBe(false);
expect(previous).toEqual(new Set());
});
it("ignores clips with invalid timing data", () => {
const container = document.createElement("div");
const missingId = appendClip(container, "", "0", "2");
const missingTiming = appendClip(container, "bad", "", "2");
const previous = new Set<string>();
updateTimelineActiveClipClasses(container, previous, 1);
expect(missingId.hasAttribute("data-active")).toBe(false);
expect(missingTiming.hasAttribute("data-active")).toBe(false);
expect(previous).toEqual(new Set());
});
});