Skip to content

Commit 037266e

Browse files
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.
1 parent 5d59835 commit 037266e

56 files changed

Lines changed: 2403 additions & 619 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.fallowrc.jsonc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,12 @@
214214
"file": "packages/studio/src/player/components/timelineCallbacks.ts",
215215
"exports": ["*"],
216216
},
217+
// Shared timeline DOM barrel for downstream consumers; fallow does not
218+
// trace all re-export-only modules.
219+
{
220+
"file": "packages/studio/src/player/lib/timelineDOM.ts",
221+
"exports": ["*"],
222+
},
217223
// gsapTargetCache: cached O(1) GSAP target lookup, consumed by
218224
// useDomEditCommits and intended to replace the local copy in
219225
// useDomGeometryCommits once callers migrate.

packages/core/src/runtime/init.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,52 @@ describe("initSandboxRuntimeModular", () => {
776776
expect(bottomBand.style.visibility).toBe("hidden");
777777
});
778778

779+
it("forces data-hidden timed elements out of layout until the attribute is removed", () => {
780+
const root = document.createElement("div");
781+
root.setAttribute("data-composition-id", "main");
782+
root.setAttribute("data-root", "true");
783+
root.setAttribute("data-start", "0");
784+
root.setAttribute("data-duration", "10");
785+
root.setAttribute("data-width", "1920");
786+
root.setAttribute("data-height", "1080");
787+
document.body.appendChild(root);
788+
789+
const hiddenClip = document.createElement("div");
790+
hiddenClip.style.position = "absolute";
791+
hiddenClip.setAttribute("data-start", "2");
792+
hiddenClip.setAttribute("data-duration", "4");
793+
hiddenClip.setAttribute("data-hidden", "");
794+
root.appendChild(hiddenClip);
795+
796+
window.__timelines = {
797+
main: createMockTimeline(10),
798+
};
799+
800+
initSandboxRuntimeModular();
801+
802+
const player = window.__player;
803+
expect(player).toBeDefined();
804+
805+
player?.seek(0);
806+
expect(hiddenClip.style.display).toBe("none");
807+
808+
player?.seek(3);
809+
expect(hiddenClip.style.display).toBe("none");
810+
811+
player?.seek(7);
812+
expect(hiddenClip.style.display).toBe("none");
813+
814+
hiddenClip.removeAttribute("data-hidden");
815+
816+
player?.seek(3);
817+
expect(hiddenClip.style.visibility).toBe("visible");
818+
expect(hiddenClip.style.display).toBe("");
819+
820+
player?.seek(7);
821+
expect(hiddenClip.style.visibility).toBe("hidden");
822+
expect(hiddenClip.style.display).toBe("");
823+
});
824+
779825
it("does not stamp Studio timing on GSAP targets inside authored timed clips", () => {
780826
withStudioIframe(() => {
781827
const root = document.createElement("div");

packages/core/src/runtime/init.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1666,6 +1666,8 @@ export function initSandboxRuntimeModular(): void {
16661666
timedClipInFlow = new WeakMap<Element, boolean>();
16671667
timedClipIsLeaf = new WeakMap<Element, boolean>();
16681668
};
1669+
const dataHiddenDisplayRestores = new WeakMap<HTMLElement, string>();
1670+
const dataHiddenDisplayNodes = new WeakSet<HTMLElement>();
16691671

16701672
const syncMediaForCurrentState = () => {
16711673
const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => {
@@ -1754,6 +1756,29 @@ export function initSandboxRuntimeModular(): void {
17541756
for (const rawNode of visibilityNodes) {
17551757
if (!(rawNode instanceof HTMLElement)) continue;
17561758

1759+
if (rawNode.hasAttribute("data-hidden")) {
1760+
if (!dataHiddenDisplayNodes.has(rawNode)) {
1761+
dataHiddenDisplayRestores.set(rawNode, rawNode.style.getPropertyValue("display"));
1762+
dataHiddenDisplayNodes.add(rawNode);
1763+
}
1764+
rawNode.style.display = "none";
1765+
if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
1766+
colorGradingRuntime?.setSourceVisibility(rawNode, false);
1767+
}
1768+
continue;
1769+
}
1770+
1771+
if (dataHiddenDisplayNodes.has(rawNode)) {
1772+
const previousDisplay = dataHiddenDisplayRestores.get(rawNode);
1773+
if (previousDisplay) {
1774+
rawNode.style.display = previousDisplay;
1775+
} else {
1776+
rawNode.style.removeProperty("display");
1777+
}
1778+
dataHiddenDisplayRestores.delete(rawNode);
1779+
dataHiddenDisplayNodes.delete(rawNode);
1780+
}
1781+
17571782
let isVisibleNow = isTimedElementVisibleAt(rawNode, state.currentTime);
17581783
// Descendants must not override a hidden ancestor clip. CSS visibility can
17591784
// otherwise leak child pixels through inactive scenes because a descendant

packages/studio-server/src/helpers/waveform.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function computePeaks(floats: Float32Array, count: number): number[] {
1818
const end = Math.min(Math.floor((i + 1) * step), floats.length);
1919
let max = 0;
2020
for (let j = start; j < end; j++) {
21+
// fallow-ignore-next-line code-duplication
2122
const abs = Math.abs(floats[j] ?? 0);
2223
if (abs > max) max = abs;
2324
}

packages/studio/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,6 @@ export function StudioApp() {
360360
resetErrors: resetConsoleErrors,
361361
} = useConsoleErrorCapture(previewIframe);
362362
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
363-
// Gesture recording
364363
const handleToggleRecordingRef = useRef<() => void>(() => {});
365364
const domEditSessionRef = useRef(domEditSession);
366365
domEditSessionRef.current = domEditSession;
@@ -531,6 +530,7 @@ export function StudioApp() {
531530
handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
532531
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
533532
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
533+
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
534534
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
535535
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
536536
handleRazorSplit={timelineEditing.handleRazorSplit}
@@ -572,6 +572,7 @@ export function StudioApp() {
572572
domEditSaveTimestampRef={domEditSaveTimestampRef}
573573
recordEdit={editHistory.recordEdit}
574574
{...cropModeProps}
575+
onToggleElementHidden={timelineEditing.handleToggleElementHidden}
575576
/>
576577
)}
577578
</div>

packages/studio/src/components/StudioPreviewArea.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export interface StudioPreviewAreaProps {
5858
element: TimelineElement,
5959
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
6060
) => Promise<void> | void;
61+
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
6162
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
6263
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
6364
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -85,6 +86,7 @@ export function StudioPreviewArea({
8586
handleTimelineFileDrop,
8687
handleTimelineElementMove,
8788
handleTimelineElementResize,
89+
handleToggleTrackHidden,
8890
handleBlockedTimelineEdit,
8991
handleTimelineElementSplit,
9092
handleRazorSplit,
@@ -165,6 +167,7 @@ export function StudioPreviewArea({
165167
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the
166168
// anim in the keyframe's property group, falling back to the first keyframed one.
167169
const resolveKeyframeTarget = useCallback(
170+
// fallow-ignore-next-line complexity
168171
(pct: number): { animId: string; tweenPct: number } | null => {
169172
const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
170173
const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
@@ -182,6 +185,7 @@ export function StudioPreviewArea({
182185
() => ({
183186
onMoveElement: handleTimelineElementMove,
184187
onResizeElement: handleTimelineElementResize,
188+
onToggleTrackHidden: handleToggleTrackHidden,
185189
onBlockedEditAttempt: handleBlockedTimelineEdit,
186190
onSplitElement: handleTimelineElementSplit,
187191
onRazorSplit: handleRazorSplit,
@@ -210,6 +214,7 @@ export function StudioPreviewArea({
210214
// drop past the boundary (last keyframe past the end, first before the start)
211215
// resizes the tween — position/duration grow so the dragged keyframe lands at
212216
// the drop while every other keyframe keeps its absolute time (value+ease too).
217+
// fallow-ignore-next-line complexity
213218
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
214219
const target = resolveKeyframeTarget(fromClipPct);
215220
const sel = domEditSelection;
@@ -280,6 +285,7 @@ export function StudioPreviewArea({
280285
[
281286
handleTimelineElementMove,
282287
handleTimelineElementResize,
288+
handleToggleTrackHidden,
283289
handleBlockedTimelineEdit,
284290
handleTimelineElementSplit,
285291
handleRazorSplit,

packages/studio/src/components/StudioRightPanel.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export interface StudioRightPanelProps {
6262
kind: EditHistoryKind;
6363
files: Record<string, { before: string; after: string }>;
6464
}) => Promise<void>;
65+
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
6566
}
6667

6768
// fallow-ignore-next-line complexity
@@ -78,6 +79,7 @@ export function StudioRightPanel({
7879
reloadPreview,
7980
domEditSaveTimestampRef,
8081
recordEdit,
82+
onToggleElementHidden,
8183
}: StudioRightPanelProps) {
8284
const {
8385
rightWidth,
@@ -350,6 +352,7 @@ export function StudioRightPanel({
350352
multiSelectCount={domEditGroupSelections.length}
351353
copiedAgentPrompt={copiedAgentPrompt}
352354
onClearSelection={clearDomSelection}
355+
onToggleElementHidden={onToggleElementHidden}
353356
onUngroup={handleUngroupSelection}
354357
onSetStyle={handleDomStyleCommit}
355358
onSetAttribute={handleDomAttributeCommit}

packages/studio/src/components/editor/PropertyPanel.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from "vitest";
2+
import type { TimelineElement } from "../../player";
23
import {
34
buildInsetClipPathSides,
45
buildStrokeStyleUpdates,
@@ -11,6 +12,7 @@ import {
1112
parseInsetClipPathSides,
1213
setCssFilterFunctionPx,
1314
} from "./PropertyPanel";
15+
import { isSelectedElementHidden } from "./propertyPanelHelpers";
1416

1517
describe("PropertyPanel style helpers", () => {
1618
it("normalizes bounded pixel values without accepting incompatible units", () => {
@@ -113,3 +115,26 @@ describe("PropertyPanel style helpers", () => {
113115
expect(buildStrokeStyleUpdates("solid", "4px")).toEqual([["border-style", "solid"]]);
114116
});
115117
});
118+
119+
describe("isSelectedElementHidden", () => {
120+
it("reads hidden state by selected timeline id or key", () => {
121+
const elements: TimelineElement[] = [
122+
{ id: "visible", tag: "div", start: 0, duration: 1, track: 0 },
123+
{ id: "hidden", tag: "div", start: 0, duration: 1, track: 0, hidden: true },
124+
{
125+
id: "keyed-hidden",
126+
key: "scene.html:#keyed-hidden",
127+
tag: "div",
128+
start: 0,
129+
duration: 1,
130+
track: 0,
131+
hidden: true,
132+
},
133+
];
134+
135+
expect(isSelectedElementHidden(elements, null)).toBe(false);
136+
expect(isSelectedElementHidden(elements, "visible")).toBe(false);
137+
expect(isSelectedElementHidden(elements, "hidden")).toBe(true);
138+
expect(isSelectedElementHidden(elements, "scene.html:#keyed-hidden")).toBe(true);
139+
});
140+
});

packages/studio/src/components/editor/PropertyPanel.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { memo, useEffect, useMemo, useRef, useState } from "react";
22
import { Move } from "../../icons/SystemIcons";
3+
import { Eye, EyeSlash } from "@phosphor-icons/react";
34
import { InspectorHeaderActions } from "./InspectorHeaderActions";
45
import { useStudioShellContext } from "../../contexts/StudioContext";
56
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
@@ -10,6 +11,7 @@ import {
1011
RESPONSIVE_GRID,
1112
readGsapRuntimeValuesForPanel,
1213
readGsapBorderRadiusForPanel,
14+
isSelectedElementHidden,
1315
} from "./propertyPanelHelpers";
1416
import { MetricField, Section } from "./propertyPanelPrimitives";
1517
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
@@ -67,6 +69,7 @@ export const PropertyPanel = memo(function PropertyPanel({
6769
onAddTextField,
6870
onRemoveTextField,
6971
onAskAgent: _onAskAgent,
72+
onToggleElementHidden,
7073
onImportAssets,
7174
fontAssets = [],
7275
onImportFonts,
@@ -106,6 +109,10 @@ export const PropertyPanel = memo(function PropertyPanel({
106109
const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
107110
const storeTime = usePlayerStore((s) => s.currentTime);
108111
const isPlaying = usePlayerStore((s) => s.isPlaying);
112+
const timelineElements = usePlayerStore((s) => s.elements);
113+
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
114+
const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
115+
const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
109116
const liveTimeRef = useRef(storeTime);
110117
const [, forceRender] = useState(0);
111118
useEffect(() => {
@@ -288,13 +295,32 @@ export const PropertyPanel = memo(function PropertyPanel({
288295
</div>
289296
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
290297
</div>
291-
<InspectorHeaderActions
292-
element={element}
293-
copied={clipboardCopied}
294-
onCopy={handleCopyElementInfo}
295-
onClear={onClearSelection}
296-
onUngroup={onUngroup}
297-
/>
298+
<div className="flex items-center gap-1">
299+
{selectedElementId && onToggleElementHidden && (
300+
<button
301+
type="button"
302+
aria-label={visibilityToggleLabel}
303+
title={visibilityToggleLabel}
304+
onClick={() => {
305+
void onToggleElementHidden(selectedElementId, !selectedElementHidden);
306+
}}
307+
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
308+
>
309+
{selectedElementHidden ? (
310+
<EyeSlash size={13} weight="bold" aria-hidden="true" />
311+
) : (
312+
<Eye size={13} weight="bold" aria-hidden="true" />
313+
)}
314+
</button>
315+
)}
316+
<InspectorHeaderActions
317+
element={element}
318+
copied={clipboardCopied}
319+
onCopy={handleCopyElementInfo}
320+
onClear={onClearSelection}
321+
onUngroup={onUngroup}
322+
/>
323+
</div>
298324
</div>
299325
</div>
300326
<div className="flex-1 overflow-y-auto">

packages/studio/src/components/editor/domEditingElement.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export function isElementComputedVisible(el: HTMLElement): boolean {
2828

2929
const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
3030

31+
// fallow-ignore-next-line complexity
3132
function hasVisualPresence(el: HTMLElement): boolean {
3233
const win = el.ownerDocument.defaultView;
3334
if (!win) return false;
@@ -236,9 +237,13 @@ export function isLargeRasterDomEditSelection(
236237

237238
// ─── Element finders ──────────────────────────────────────────────────────────
238239

240+
type FindElementSelection = Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex"> & {
241+
sourceFile?: string;
242+
};
243+
239244
export function findElementForSelection(
240245
doc: Document,
241-
selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
246+
selection: FindElementSelection,
242247
activeCompositionPath: string | null = null,
243248
): HTMLElement | null {
244249
if (selection.hfId) {
@@ -259,6 +264,7 @@ export function findElementForSelection(
259264

260265
if (!selection.selector) return null;
261266

267+
// fallow-ignore-next-line code-duplication
262268
if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
263269
const matches = querySelectorAllSafely(doc, selection.selector).filter(
264270
(candidate): candidate is HTMLElement =>
@@ -270,6 +276,7 @@ export function findElementForSelection(
270276
return matches[selection.selectorIndex] ?? null;
271277
}
272278

279+
// fallow-ignore-next-line code-duplication
273280
const matches = querySelectorAllSafely(doc, selection.selector).filter(
274281
(candidate): candidate is HTMLElement =>
275282
isHtmlElement(candidate) &&

0 commit comments

Comments
 (0)