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,4 +1,5 @@
import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../../player";
import {
buildInsetClipPathSides,
buildStrokeStyleUpdates,
@@ -11,6 +12,7 @@ import {
parseInsetClipPathSides,
setCssFilterFunctionPx,
} from "./PropertyPanel";
import { isSelectedElementHidden } from "./propertyPanelHelpers";
describe("PropertyPanel style helpers", () => {
it("normalizes bounded pixel values without accepting incompatible units", () => {
@@ -113,3 +115,26 @@ describe("PropertyPanel style helpers", () => {
expect(buildStrokeStyleUpdates("solid", "4px")).toEqual([["border-style", "solid"]]);
});
});
describe("isSelectedElementHidden", () => {
it("reads hidden state by selected timeline id or key", () => {
const elements: TimelineElement[] = [
{ id: "visible", tag: "div", start: 0, duration: 1, track: 0 },
{ id: "hidden", tag: "div", start: 0, duration: 1, track: 0, hidden: true },
{
id: "keyed-hidden",
key: "scene.html:#keyed-hidden",
tag: "div",
start: 0,
duration: 1,
track: 0,
hidden: true,
},
];
expect(isSelectedElementHidden(elements, null)).toBe(false);
expect(isSelectedElementHidden(elements, "visible")).toBe(false);
expect(isSelectedElementHidden(elements, "hidden")).toBe(true);
expect(isSelectedElementHidden(elements, "scene.html:#keyed-hidden")).toBe(true);
});
});
@@ -1,5 +1,6 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { Move } from "../../icons/SystemIcons";
import { Eye, EyeSlash } from "@phosphor-icons/react";
import { InspectorHeaderActions } from "./InspectorHeaderActions";
import { useStudioShellContext } from "../../contexts/StudioContext";
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
@@ -10,6 +11,7 @@ import {
RESPONSIVE_GRID,
readGsapRuntimeValuesForPanel,
readGsapBorderRadiusForPanel,
isSelectedElementHidden,
} from "./propertyPanelHelpers";
import { MetricField, Section } from "./propertyPanelPrimitives";
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
@@ -67,6 +69,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onAddTextField,
onRemoveTextField,
onAskAgent: _onAskAgent,
onToggleElementHidden,
onImportAssets,
fontAssets = [],
onImportFonts,
@@ -106,6 +109,10 @@ export const PropertyPanel = memo(function PropertyPanel({
const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const storeTime = usePlayerStore((s) => s.currentTime);
const isPlaying = usePlayerStore((s) => s.isPlaying);
const timelineElements = usePlayerStore((s) => s.elements);
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
const liveTimeRef = useRef(storeTime);
const [, forceRender] = useState(0);
useEffect(() => {
@@ -288,13 +295,32 @@ export const PropertyPanel = memo(function PropertyPanel({
</div>
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
</div>
<InspectorHeaderActions
element={element}
copied={clipboardCopied}
onCopy={handleCopyElementInfo}
onClear={onClearSelection}
onUngroup={onUngroup}
/>
<div className="flex items-center gap-1">
{selectedElementId && onToggleElementHidden && (
<button
type="button"
aria-label={visibilityToggleLabel}
title={visibilityToggleLabel}
onClick={() => {
void onToggleElementHidden(selectedElementId, !selectedElementHidden);
}}
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
>
{selectedElementHidden ? (
<EyeSlash size={13} weight="bold" aria-hidden="true" />
) : (
<Eye size={13} weight="bold" aria-hidden="true" />
)}
</button>
)}
<InspectorHeaderActions
element={element}
copied={clipboardCopied}
onCopy={handleCopyElementInfo}
onClear={onClearSelection}
onUngroup={onUngroup}
/>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto">
@@ -28,6 +28,7 @@ export function isElementComputedVisible(el: HTMLElement): boolean {
const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
// fallow-ignore-next-line complexity
function hasVisualPresence(el: HTMLElement): boolean {
const win = el.ownerDocument.defaultView;
if (!win) return false;
@@ -236,9 +237,13 @@ export function isLargeRasterDomEditSelection(
// ─── Element finders ──────────────────────────────────────────────────────────
type FindElementSelection = Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex"> & {
sourceFile?: string;
};
export function findElementForSelection(
doc: Document,
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
selection: FindElementSelection,
activeCompositionPath: string | null = null,
): HTMLElement | null {
if (selection.hfId) {
@@ -259,6 +264,7 @@ export function findElementForSelection(
if (!selection.selector) return null;
// fallow-ignore-next-line code-duplication
if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
const matches = querySelectorAllSafely(doc, selection.selector).filter(
(candidate): candidate is HTMLElement =>
@@ -270,6 +276,7 @@ export function findElementForSelection(
return matches[selection.selectorIndex] ?? null;
}
// fallow-ignore-next-line code-duplication
const matches = querySelectorAllSafely(doc, selection.selector).filter(
(candidate): candidate is HTMLElement =>
isHtmlElement(candidate) &&
@@ -33,6 +33,7 @@ import {
import { roundRotationAngle } from "./manualEditsParsing";
import { applyStudioMotionFromDom } from "./studioMotion";
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
/* ── Gesture tracking ─────────────────────────────────────────────── */
let studioManualEditGestureId = 0;
@@ -162,24 +163,6 @@ export function restoreInlineDisplay(element: HTMLElement): void {
}
/* ── Translate helpers ────────────────────────────────────────────── */
function splitTopLevelWhitespace(value: string): string[] {
const parts: string[] = [];
let depth = 0;
let current = "";
for (const char of value.trim()) {
if (char === "(") depth += 1;
if (char === ")") depth = Math.max(0, depth - 1);
if (/\s/.test(char) && depth === 0) {
if (current) parts.push(current);
current = "";
} else {
current += char;
}
}
if (current) parts.push(current);
return parts;
}
function composeTranslateValue(element: HTMLElement, x: string, y: string): string {
const original = element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR)?.trim();
if (!original || original === "none") return `${x} ${y}`;
@@ -0,0 +1,18 @@
export function splitTopLevelWhitespace(value: string): string[] {
const parts: string[] = [];
let depth = 0;
// fallow-ignore-next-line code-duplication
let current = "";
for (const char of value.trim()) {
if (char === "(") depth += 1;
if (char === ")") depth = Math.max(0, depth - 1);
if (/\s/.test(char) && depth === 0) {
if (current) parts.push(current);
current = "";
} else {
current += char;
}
}
if (current) parts.push(current);
return parts;
}
@@ -2,6 +2,7 @@ import { parseCssColor, type ParsedColor } from "./colorValue";
import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog";
import type { DomEditSelection } from "./domEditing";
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
import type { TimelineElement } from "../../player";
import { roundToCenti } from "../../utils/rounding";
export type {
@@ -18,6 +19,16 @@ export function stripQueryAndHash(value: string): string {
return value.slice(0, Math.min(queryIndex, hashIndex));
}
export function isSelectedElementHidden(
elements: readonly TimelineElement[],
selectedElementId: string | null,
): boolean {
if (!selectedElementId) return false;
return (
elements.find((element) => (element.key ?? element.id) === selectedElementId)?.hidden === true
);
}
/* ------------------------------------------------------------------ */
/* Font types & constants (shared by font and section modules) */
/* ------------------------------------------------------------------ */
@@ -52,6 +52,7 @@ export interface PropertyPanelProps {
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
onRemoveTextField: (fieldKey: string) => void;
onAskAgent: () => void;
onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
fontAssets?: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;