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
@@ -58,6 +58,7 @@ export interface StudioPreviewAreaProps {
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -85,6 +86,7 @@ export function StudioPreviewArea({
handleTimelineFileDrop,
handleTimelineElementMove,
handleTimelineElementResize,
handleToggleTrackHidden,
handleBlockedTimelineEdit,
handleTimelineElementSplit,
handleRazorSplit,
@@ -165,6 +167,7 @@ export function StudioPreviewArea({
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the
// anim in the keyframe's property group, falling back to the first keyframed one.
const resolveKeyframeTarget = useCallback(
// fallow-ignore-next-line complexity
(pct: number): { animId: string; tweenPct: number } | null => {
const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
@@ -182,6 +185,7 @@ export function StudioPreviewArea({
() => ({
onMoveElement: handleTimelineElementMove,
onResizeElement: handleTimelineElementResize,
onToggleTrackHidden: handleToggleTrackHidden,
onBlockedEditAttempt: handleBlockedTimelineEdit,
onSplitElement: handleTimelineElementSplit,
onRazorSplit: handleRazorSplit,
@@ -210,6 +214,7 @@ export function StudioPreviewArea({
// drop past the boundary (last keyframe past the end, first before the start)
// resizes the tween — position/duration grow so the dragged keyframe lands at
// the drop while every other keyframe keeps its absolute time (value+ease too).
// fallow-ignore-next-line complexity
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
const target = resolveKeyframeTarget(fromClipPct);
const sel = domEditSelection;
@@ -280,6 +285,7 @@ export function StudioPreviewArea({
[
handleTimelineElementMove,
handleTimelineElementResize,
handleToggleTrackHidden,
handleBlockedTimelineEdit,
handleTimelineElementSplit,
handleRazorSplit,
@@ -62,6 +62,7 @@ export interface StudioRightPanelProps {
kind: EditHistoryKind;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
}
// fallow-ignore-next-line complexity
@@ -78,6 +79,7 @@ export function StudioRightPanel({
reloadPreview,
domEditSaveTimestampRef,
recordEdit,
onToggleElementHidden,
}: StudioRightPanelProps) {
const {
rightWidth,
@@ -350,6 +352,7 @@ export function StudioRightPanel({
multiSelectCount={domEditGroupSelections.length}
copiedAgentPrompt={copiedAgentPrompt}
onClearSelection={clearDomSelection}
onToggleElementHidden={onToggleElementHidden}
onUngroup={handleUngroupSelection}
onSetStyle={handleDomStyleCommit}
onSetAttribute={handleDomAttributeCommit}
@@ -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[]>;