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
+2 -1
View File
@@ -360,7 +360,6 @@ export function StudioApp() {
resetErrors: resetConsoleErrors,
} = useConsoleErrorCapture(previewIframe);
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
// Gesture recording
const handleToggleRecordingRef = useRef<() => void>(() => {});
const domEditSessionRef = useRef(domEditSession);
domEditSessionRef.current = domEditSession;
@@ -531,6 +530,7 @@ export function StudioApp() {
handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
handleRazorSplit={timelineEditing.handleRazorSplit}
@@ -572,6 +572,7 @@ export function StudioApp() {
domEditSaveTimestampRef={domEditSaveTimestampRef}
recordEdit={editHistory.recordEdit}
{...cropModeProps}
onToggleElementHidden={timelineEditing.handleToggleElementHidden}
/>
)}
</div>
@@ -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[]>;
@@ -32,6 +32,7 @@ export function TimelineEditProvider({
[
value.onMoveElement,
value.onResizeElement,
value.onToggleTrackHidden,
value.onBlockedEditAttempt,
value.onSplitElement,
value.onRazorSplit,
@@ -41,19 +41,30 @@ export type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
// The runtime re-reads data-start/data-duration from the DOM on each sync tick
// (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
// picked up automatically on the next frame without a rebind call.
export function findTimelineElementInIframe(
iframe: HTMLIFrameElement | null,
element: TimelineElement,
): Element | null {
try {
const doc = iframe?.contentDocument;
if (!doc) return null;
return element.domId
? doc.getElementById(element.domId)
: element.selector
? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
: null;
} catch {
return null;
}
}
export function patchIframeDomTiming(
iframe: HTMLIFrameElement | null,
element: TimelineElement,
attrs: Array<[string, string]>,
): void {
try {
const doc = iframe?.contentDocument;
if (!doc) return;
const el = element.domId
? doc.getElementById(element.domId)
: element.selector
? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
: null;
const el = findTimelineElementInIframe(iframe, element);
if (!el) return;
for (const [name, value] of attrs) el.setAttribute(name, value);
} catch {
@@ -61,6 +72,7 @@ export function patchIframeDomTiming(
}
}
// fallow-ignore-next-line complexity
export function resolveResizePlaybackStart(
original: string,
target: PatchTarget,
@@ -0,0 +1,202 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore, type TimelineElement } from "../player";
import { toggleTimelineElementHidden, toggleTimelineTrackHidden } from "./timelineTrackVisibility";
afterEach(() => {
document.body.innerHTML = "";
vi.unstubAllGlobals();
usePlayerStore.getState().reset();
});
function element(overrides: Partial<TimelineElement>): TimelineElement {
return {
id: "clip",
tag: "div",
start: 0,
duration: 2,
track: 0,
...overrides,
};
}
function stubProjectFiles(files: Map<string, string>) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const encodedPath = url.slice(url.lastIndexOf("/") + 1);
const path = decodeURIComponent(encodedPath);
const content = files.get(path);
return new Response(JSON.stringify({ content }), {
status: content === undefined ? 404 : 200,
headers: { "Content-Type": "application/json" },
});
}),
);
}
describe("toggleTimelineTrackHidden", () => {
it("patches iframe DOM and persists all track elements as one edit-history entry", async () => {
const iframe = document.createElement("iframe");
document.body.append(iframe);
if (iframe.contentDocument) {
iframe.contentDocument.body.innerHTML = `
<div id="hero"></div>
<div id="subtitle"></div>
`;
}
const files = new Map([
[
"index.html",
`<div id="hero" data-start="0" data-duration="2"></div>
<div id="skip" data-start="0" data-duration="2"></div>`,
],
["scene.html", `<div id="subtitle" data-start="1" data-duration="2"></div>`],
]);
stubProjectFiles(files);
const writes = new Map<string, string>();
const recordEdit = vi.fn();
const timestampRef = { current: 0 };
const pendingRef = { current: new Set<string>() };
await toggleTimelineTrackHidden({
projectId: "project-1",
activeCompPath: "index.html",
timelineElements: [
element({ id: "hero", domId: "hero", track: 0 }),
element({ id: "skip", domId: "skip", track: 1 }),
element({ id: "subtitle", domId: "subtitle", track: 0, sourceFile: "scene.html" }),
],
track: 0,
hidden: true,
previewIframe: iframe,
writeProjectFile: async (path, content) => {
writes.set(path, content);
},
recordEdit,
domEditSaveTimestampRef: timestampRef,
pendingTimelineEditPathRef: pendingRef,
});
expect(iframe.contentDocument?.getElementById("hero")?.hasAttribute("data-hidden")).toBe(true);
expect(iframe.contentDocument?.getElementById("subtitle")?.hasAttribute("data-hidden")).toBe(
true,
);
expect(writes.get("index.html")).toContain('id="hero" data-start="0" data-duration="2"');
expect(writes.get("index.html")).toContain('data-hidden=""');
expect(writes.get("index.html")).toContain('id="skip" data-start="0" data-duration="2"');
expect(writes.get("scene.html")).toContain('data-hidden=""');
expect(pendingRef.current).toEqual(new Set(["index.html", "scene.html"]));
expect(timestampRef.current).toBeGreaterThan(0);
expect(recordEdit).toHaveBeenCalledTimes(1);
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 0");
expect(Object.keys(recordEdit.mock.calls[0]?.[0]?.files ?? {}).sort()).toEqual([
"index.html",
"scene.html",
]);
});
it("removes data-hidden from every element on the track", async () => {
const files = new Map([
[
"index.html",
`<div id="hero" data-start="0" data-duration="2" data-hidden=""></div>
<div id="caption" data-start="2" data-duration="2" data-hidden=""></div>`,
],
]);
stubProjectFiles(files);
const writes = new Map<string, string>();
await toggleTimelineTrackHidden({
projectId: "project-1",
activeCompPath: "index.html",
timelineElements: [
element({ id: "hero", domId: "hero", track: 0, hidden: true }),
element({ id: "caption", domId: "caption", track: 0, hidden: true }),
],
track: 0,
hidden: false,
previewIframe: null,
writeProjectFile: async (path, content) => {
writes.set(path, content);
},
recordEdit: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set() },
});
expect(writes.get("index.html")).not.toContain("data-hidden");
});
});
describe("toggleTimelineElementHidden", () => {
it("persists data-hidden for only the selected element and updates the player store", async () => {
const iframe = document.createElement("iframe");
document.body.append(iframe);
const seek = vi.fn();
const win = iframe.contentWindow;
if (!win) throw new Error("Expected iframe contentWindow");
const playerWindow: Window & { __player?: { seek?: (time: number) => void } } = win;
playerWindow.__player = { seek };
const files = new Map([
[
"index.html",
`<div id="hero" data-start="0" data-duration="2"></div>
<div id="track-mate" data-start="1" data-duration="2"></div>`,
],
]);
stubProjectFiles(files);
const hero = element({ id: "hero", key: "index.html:#hero", domId: "hero", track: 0 });
const trackMate = element({
id: "track-mate",
key: "index.html:#track-mate",
domId: "track-mate",
track: 0,
});
usePlayerStore.getState().setElements([hero, trackMate]);
usePlayerStore.getState().setCurrentTime(1.25);
const writes = new Map<string, string>();
const recordEdit = vi.fn();
const changedPaths = await toggleTimelineElementHidden({
projectId: "project-1",
activeCompPath: "index.html",
timelineElements: [hero, trackMate],
elementKey: "index.html:#hero",
hidden: true,
previewIframe: iframe,
writeProjectFile: async (path, content) => {
writes.set(path, content);
},
recordEdit,
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set() },
});
expect(changedPaths).toEqual(["index.html"]);
expect(writes.get("index.html")).toContain('id="hero" data-start="0" data-duration="2"');
expect(writes.get("index.html")).toContain(
'id="hero" data-start="0" data-duration="2" data-hidden=""',
);
expect(writes.get("index.html")).toContain(
'id="track-mate" data-start="1" data-duration="2"></div>',
);
expect(recordEdit).toHaveBeenCalledTimes(1);
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide element");
expect(seek).toHaveBeenCalledWith(1.25);
expect(
usePlayerStore.getState().elements.find((el) => el.key === "index.html:#hero")?.hidden,
).toBe(true);
expect(
usePlayerStore.getState().elements.find((el) => el.key === "index.html:#track-mate")?.hidden,
).toBeUndefined();
});
});
@@ -0,0 +1,371 @@
import { useCallback } from "react";
import { usePlayerStore, type TimelineElement } from "../player";
import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
import {
applyPatchByTarget,
buildPatchTarget,
findTimelineElementInIframe,
readFileContent,
type RecordEditInput,
} from "./timelineEditingHelpers";
interface MutableRef<T> {
current: T;
}
interface ReadonlyRef<T> {
readonly current: T;
}
interface ToggleTimelineTrackHiddenInput {
projectId: string;
activeCompPath: string | null;
timelineElements: readonly TimelineElement[];
track: number;
hidden: boolean;
previewIframe: HTMLIFrameElement | null;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRef<number>;
pendingTimelineEditPathRef: MutableRef<Set<string>>;
}
interface ToggleTimelineElementHiddenInput extends Omit<ToggleTimelineTrackHiddenInput, "track"> {
elementKey: string;
}
interface SetElementsHiddenInput {
projectId: string;
activeCompPath: string | null;
elements: readonly TimelineElement[];
hidden: boolean;
label: string;
previewIframe: HTMLIFrameElement | null;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRef<number>;
pendingTimelineEditPathRef: MutableRef<Set<string>>;
}
interface UseTimelineTrackVisibilityEditingInput extends Omit<
ToggleTimelineTrackHiddenInput,
"projectId" | "track" | "hidden" | "previewIframe"
> {
projectIdRef: ReadonlyRef<string | null>;
previewIframeRef: ReadonlyRef<HTMLIFrameElement | null>;
showToast: (message: string, tone?: "error" | "info") => void;
isRecordingRef?: ReadonlyRef<boolean>;
forceReloadSdkSession?: () => void;
}
interface UseTimelineElementVisibilityEditingInput extends Omit<
ToggleTimelineElementHiddenInput,
"projectId" | "elementKey" | "hidden" | "previewIframe"
> {
projectIdRef: ReadonlyRef<string | null>;
previewIframeRef: ReadonlyRef<HTMLIFrameElement | null>;
showToast: (message: string, tone?: "error" | "info") => void;
isRecordingRef?: ReadonlyRef<boolean>;
forceReloadSdkSession?: () => void;
}
function getTimelineElementTargetPath(
element: TimelineElement,
activeCompPath: string | null,
): string {
return element.sourceFile || activeCompPath || "index.html";
}
function patchLiveHiddenState(
iframe: HTMLIFrameElement | null,
elements: readonly TimelineElement[],
hidden: boolean,
): void {
for (const element of elements) {
const target = findTimelineElementInIframe(iframe, element);
if (!target) continue;
if (hidden) {
target.setAttribute("data-hidden", "");
} else {
target.removeAttribute("data-hidden");
}
}
}
function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void {
try {
const win: (Window & { __player?: { seek?: (time: number) => void } }) | null =
iframe?.contentWindow ?? null;
win?.__player?.seek?.(usePlayerStore.getState().currentTime);
} catch {}
}
function groupElementsByTargetPath(
elements: readonly TimelineElement[],
activeCompPath: string | null,
): Map<string, TimelineElement[]> {
const byPath = new Map<string, TimelineElement[]>();
for (const element of elements) {
const targetPath = getTimelineElementTargetPath(element, activeCompPath);
const existing = byPath.get(targetPath);
if (existing) {
existing.push(element);
} else {
byPath.set(targetPath, [element]);
}
}
return byPath;
}
// fallow-ignore-next-line complexity
async function setElementsHidden({
projectId,
activeCompPath,
elements,
hidden,
label,
previewIframe,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}: SetElementsHiddenInput): Promise<string[]> {
if (elements.length === 0) return [];
patchLiveHiddenState(previewIframe, elements, hidden);
reseekPreviewRuntime(previewIframe);
const hiddenOperation: PatchOperation = {
type: "attribute",
property: "hidden",
value: hidden ? "" : null,
};
const originalByPath = new Map<string, string>();
const files: Record<string, string> = {};
try {
for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
let patchedContent = await readFileContent(projectId, targetPath);
originalByPath.set(targetPath, patchedContent);
for (const element of fileElements) {
const patchTarget = buildPatchTarget(element);
if (!patchTarget) {
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
}
if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
}
patchedContent = applyPatchByTarget(patchedContent, patchTarget, hiddenOperation);
}
files[targetPath] = patchedContent;
pendingTimelineEditPathRef.current.add(targetPath);
}
domEditSaveTimestampRef.current = Date.now();
const changedPaths = await saveProjectFilesWithHistory({
projectId,
label,
kind: "timeline",
files,
readFile: async (path) => {
const original = originalByPath.get(path);
if (original !== undefined) return original;
return readFileContent(projectId, path);
},
writeFile: writeProjectFile,
recordEdit,
});
domEditSaveTimestampRef.current = Date.now();
for (const element of elements) {
usePlayerStore.getState().updateElement(element.key ?? element.id, { hidden });
}
return changedPaths;
} catch (error) {
// The optimistic live patch already ran; a patch-target/save failure here would
// otherwise leave the preview showing the wrong visibility until a reload. Revert
// the live DOM to the prior state so what's on screen matches what persisted.
patchLiveHiddenState(previewIframe, elements, !hidden);
reseekPreviewRuntime(previewIframe);
throw error;
}
}
export async function toggleTimelineTrackHidden({
projectId,
activeCompPath,
timelineElements,
track,
hidden,
previewIframe,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}: ToggleTimelineTrackHiddenInput): Promise<string[]> {
return setElementsHidden({
projectId,
activeCompPath,
elements: timelineElements.filter((element) => element.track === track),
hidden,
label: hidden ? `Hide track ${track}` : `Show track ${track}`,
previewIframe,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
});
}
export async function toggleTimelineElementHidden({
projectId,
activeCompPath,
timelineElements,
elementKey,
hidden,
previewIframe,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}: ToggleTimelineElementHiddenInput): Promise<string[]> {
const element = timelineElements.find((item) => (item.key ?? item.id) === elementKey);
return setElementsHidden({
projectId,
activeCompPath,
elements: element ? [element] : [],
hidden,
label: hidden ? "Hide element" : "Show element",
previewIframe,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
});
}
export function useTimelineTrackVisibilityEditing({
projectIdRef,
activeCompPath,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
previewIframeRef,
pendingTimelineEditPathRef,
isRecordingRef,
forceReloadSdkSession,
}: UseTimelineTrackVisibilityEditingInput): (track: number, hidden: boolean) => Promise<void> {
// Resolve the eye toggle against the EXPANDED rows the canvas actually renders:
// virtual sub-comp children carry their own (display.track + idx) track numbers,
// so filtering the raw store list by a virtual track number would hide the wrong
// outer-scene sibling sharing that index.
const expandedElements = useExpandedTimelineElements();
return useCallback(
async (track: number, hidden: boolean) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) return;
try {
await toggleTimelineTrackHidden({
projectId: pid,
activeCompPath,
timelineElements: expandedElements,
track,
hidden,
previewIframe: previewIframeRef.current,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
});
forceReloadSdkSession?.();
} catch (error) {
console.error("[Timeline] Failed to toggle track visibility", error);
const message =
error instanceof Error ? error.message : "Failed to toggle track visibility";
showToast(message);
}
},
[
activeCompPath,
expandedElements,
previewIframeRef,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
isRecordingRef,
showToast,
forceReloadSdkSession,
projectIdRef,
],
);
}
export function useTimelineElementVisibilityEditing({
projectIdRef,
activeCompPath,
timelineElements,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
previewIframeRef,
pendingTimelineEditPathRef,
isRecordingRef,
forceReloadSdkSession,
}: UseTimelineElementVisibilityEditingInput): (
elementKey: string,
hidden: boolean,
) => Promise<void> {
return useCallback(
async (elementKey: string, hidden: boolean) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
}
const pid = projectIdRef.current;
if (!pid) return;
try {
await toggleTimelineElementHidden({
projectId: pid,
activeCompPath,
timelineElements,
elementKey,
hidden,
previewIframe: previewIframeRef.current,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
});
forceReloadSdkSession?.();
} catch (error) {
console.error("[Timeline] Failed to toggle element visibility", error);
const message =
error instanceof Error ? error.message : "Failed to toggle element visibility";
showToast(message);
}
},
[
activeCompPath,
timelineElements,
previewIframeRef,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
isRecordingRef,
showToast,
forceReloadSdkSession,
projectIdRef,
],
);
}
@@ -3,6 +3,7 @@
import React, { act, isValidElement, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { CompositionThumbnail, VideoThumbnail } from "../player";
import { AudioWaveform } from "../player/components/AudioWaveform";
import type { TimelineElement } from "../player/store/playerStore";
import { normalizeCompositionSrc } from "./useRenderClipContent";
@@ -63,7 +64,10 @@ describe("normalizeCompositionSrc", () => {
});
describe("useRenderClipContent", () => {
function renderClipContent(el: TimelineElement): ReactNode {
function renderClipContent(
el: TimelineElement,
activePreviewUrl: string | null = "/api/projects/my-project/preview",
): ReactNode {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
@@ -73,7 +77,7 @@ describe("useRenderClipContent", () => {
const render = useRenderClipContent({
projectIdRef: { current: "my-project" },
compIdToSrc: new Map(),
activePreviewUrl: "/api/projects/my-project/preview",
activePreviewUrl,
effectiveTimelineDuration: 12,
});
content = render(el, { clip: "#222", label: "#fff" });
@@ -100,4 +104,66 @@ describe("useRenderClipContent", () => {
expect(isValidElement(content)).toBe(true);
if (isValidElement(content)) expect(content.type).toBe(AudioWaveform);
});
it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
const cases: Array<{ content: ReactNode; type: unknown }> = [
{
content: renderClipContent({
id: "voiceover",
tag: "audio",
start: 1,
duration: 4,
track: 1,
src: "assets/voiceover.mp3",
}),
type: AudioWaveform,
},
{
content: renderClipContent({
id: "nested",
tag: "div",
start: 0,
duration: 4,
track: 0,
compositionSrc: "compositions/nested.html",
}),
type: CompositionThumbnail,
},
{
content: renderClipContent(
{
id: "clip-video",
tag: "video",
start: 0,
duration: 4,
track: 0,
src: "assets/clip.mp4",
},
null,
),
type: VideoThumbnail,
},
{
content: renderClipContent(
{
id: "headline",
tag: "div",
start: 1,
duration: 4,
track: 0,
},
null,
),
type: CompositionThumbnail,
},
];
for (const item of cases) {
expect(isValidElement<{ label: string }>(item.content)).toBe(true);
if (isValidElement<{ label: string }>(item.content)) {
expect(item.content.type).toBe(item.type);
expect(item.content.props.label).toBe("");
}
}
});
});
@@ -3,7 +3,6 @@ import { createElement } from "react";
import { CompositionThumbnail, VideoThumbnail } from "../player";
import type { TimelineElement } from "../player";
import { AudioWaveform } from "../player/components/AudioWaveform";
import { getTimelineElementLabel } from "../utils/studioHelpers";
export function normalizeCompositionSrc(
compSrc: string,
@@ -58,7 +57,7 @@ function renderAudioClip(el: TimelineElement, pid: string, labelColor: string):
return createElement(AudioWaveform, {
audioUrl,
waveformUrl,
label: getTimelineElementLabel(el),
label: "",
labelColor,
trimStartFraction: start,
trimEndFraction: end,
@@ -102,7 +101,7 @@ export function useRenderClipContent({
if (compSrc) {
return createElement(CompositionThumbnail, {
previewUrl: `/api/projects/${pid}/preview/comp/${compSrc}`,
label: getTimelineElementLabel(el),
label: "",
labelColor: style.label,
seekTime: 0,
@@ -122,7 +121,7 @@ export function useRenderClipContent({
if (activePreviewUrl && el.duration > 0) {
return createElement(CompositionThumbnail, {
previewUrl: activePreviewUrl,
label: getTimelineElementLabel(el),
label: "",
labelColor: style.label,
selector: el.selector,
@@ -144,7 +143,7 @@ export function useRenderClipContent({
: `/api/projects/${pid}/preview/${el.src}`;
return createElement(VideoThumbnail, {
videoSrc: mediaSrc,
label: getTimelineElementLabel(el),
label: "",
labelColor: style.label,
duration: el.duration,
});
@@ -153,7 +152,7 @@ export function useRenderClipContent({
if (htmlPreviewEligible) {
return createElement(CompositionThumbnail, {
previewUrl: `/api/projects/${pid}/preview`,
label: getTimelineElementLabel(el),
label: "",
labelColor: style.label,
selector: el.selector,
+35 -30
View File
@@ -20,7 +20,6 @@ import {
collectHtmlIds,
resolveDroppedAssetDuration,
} from "../utils/studioHelpers";
import type { EditHistoryKind } from "../utils/editHistory";
import {
buildPatchTarget,
patchIframeDomTiming,
@@ -33,36 +32,12 @@ import {
scaleGsapPositions,
} from "./timelineEditingHelpers";
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
import {
useTimelineElementVisibilityEditing,
useTimelineTrackVisibilityEditing,
} from "./timelineTrackVisibility";
import { sdkTimingPersist } from "../utils/sdkCutover";
import type { Composition } from "@hyperframes/sdk";
// ── Types ──
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}
interface UseTimelineEditingOptions {
projectId: string | null;
activeCompPath: string | null;
timelineElements: TimelineElement[];
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
reloadPreview: () => void;
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
isRecordingRef?: React.RefObject<boolean>;
/** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
sdkSession?: Composition | null;
/** Resync the SDK session after a server-authoritative timeline write. */
forceReloadSdkSession?: () => void;
}
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
// ── Hook ──
@@ -323,6 +298,34 @@ export function useTimelineEditing({
],
);
const handleToggleTrackHidden = useTimelineTrackVisibilityEditing({
projectIdRef,
activeCompPath,
timelineElements,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
previewIframeRef,
pendingTimelineEditPathRef,
isRecordingRef,
forceReloadSdkSession,
});
const handleToggleElementHidden = useTimelineElementVisibilityEditing({
projectIdRef,
activeCompPath,
timelineElements,
showToast,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
previewIframeRef,
pendingTimelineEditPathRef,
isRecordingRef,
forceReloadSdkSession,
});
// fallow-ignore-next-line complexity
const handleTimelineElementDelete = useCallback(
// fallow-ignore-next-line complexity
@@ -562,6 +565,8 @@ export function useTimelineEditing({
return {
handleTimelineElementMove,
handleTimelineElementResize,
handleToggleTrackHidden,
handleToggleElementHidden,
handleTimelineElementDelete,
handleTimelineElementSplit: handleRazorSplit,
handleRazorSplit,
@@ -0,0 +1,30 @@
import type { MutableRefObject, RefObject } from "react";
import type { Composition } from "@hyperframes/sdk";
import type { TimelineElement } from "../player";
import type { EditHistoryKind } from "../utils/editHistory";
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}
export interface UseTimelineEditingOptions {
projectId: string | null;
activeCompPath: string | null;
timelineElements: TimelineElement[];
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: MutableRefObject<number>;
reloadPreview: () => void;
previewIframeRef: RefObject<HTMLIFrameElement | null>;
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
isRecordingRef?: RefObject<boolean>;
/** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
sdkSession?: Composition | null;
/** Resync the SDK session after a server-authoritative timeline write. */
forceReloadSdkSession?: () => void;
}
+12 -4
View File
@@ -4,7 +4,6 @@ import {
Eye as PhEye,
FilmStrip,
Stack,
ChatCenteredText,
ArrowsOutCardinal,
MusicNote,
Palette as PhPalette,
@@ -15,7 +14,6 @@ import {
TextT,
X as PhX,
Lightning,
CaretDown,
CaretRight,
ClipboardText,
ArrowCounterClockwise,
@@ -41,7 +39,6 @@ export const Clock = makeIcon(PhClock);
export const Eye = makeIcon(PhEye);
export const Film = makeIcon(FilmStrip);
export const Layers = makeIcon(Stack);
export const MessageSquare = makeIcon(ChatCenteredText);
export const Move = makeIcon(ArrowsOutCardinal);
export const Music = makeIcon(MusicNote);
export const Palette = makeIcon(PhPalette);
@@ -53,7 +50,18 @@ export const Type = makeIcon(TextT);
export const X = makeIcon(PhX);
export const Zap = makeIcon(Lightning);
// Extra icons used in this project (not in lucide's default mapping above)
export const ChevronDown = makeIcon(CaretDown);
export const ChevronDown = ({ title, style, ...props }: IconProps) => {
const transform = style?.transform ? `${style.transform} rotate(90deg)` : "rotate(90deg)";
return (
<CaretRight
alt={title}
aria-label={title}
aria-hidden={title ? undefined : true}
style={{ ...style, transform }}
{...props}
/>
);
};
export const ChevronRight = makeIcon(CaretRight);
export const ClipboardList = makeIcon(ClipboardText);
export const RotateCcw = makeIcon(ArrowCounterClockwise);
@@ -32,6 +32,7 @@ function extractPeaks(channelData: Float32Array, barCount: number): number[] {
const start = i * samplesPerBar;
const end = Math.min(start + samplesPerBar, channelData.length);
for (let j = start; j < end; j++) {
// fallow-ignore-next-line code-duplication
const abs = Math.abs(channelData[j] ?? 0);
if (abs > max) max = abs;
}
@@ -195,14 +196,16 @@ export const AudioWaveform = memo(function AudioWaveform({
}}
/>
)}
<div className="absolute top-0 left-0 right-0 px-1.5 py-0.5 z-10">
<span
className="text-[9px] font-semibold truncate block leading-tight"
style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
>
{label}
</span>
</div>
{label && (
<div className="absolute top-0 left-0 right-0 px-1.5 py-0.5 z-10">
<span
className="text-[9px] font-semibold truncate block leading-tight"
style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
>
{label}
</span>
</div>
)}
</div>
);
});
@@ -66,6 +66,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
if (!el) return;
const measured = el.parentElement?.clientWidth || el.clientWidth;
// fallow-ignore-next-line code-duplication
setContainerWidth(measured);
const target = el.parentElement || el;
@@ -130,17 +131,19 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
</div>
)}
<div className="absolute left-3 top-0 bottom-0 flex items-center" style={{ zIndex: 10 }}>
<span
className="block max-w-full truncate text-[10px] font-semibold leading-none"
style={{
color: labelColor,
textShadow: loaded ? "0 1px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)" : "none",
}}
>
{label}
</span>
</div>
{label && (
<div className="absolute left-3 top-0 bottom-0 flex items-center" style={{ zIndex: 10 }}>
<span
className="block max-w-full truncate text-[10px] font-semibold leading-none"
style={{
color: labelColor,
textShadow: loaded ? "0 1px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)" : "none",
}}
>
{label}
</span>
</div>
)}
</div>
);
});
@@ -12,29 +12,39 @@ interface PlayheadIndicatorProps {
export function PlayheadIndicator({
color = "var(--hf-accent, #3CE6AC)",
glowColor = "rgba(60,230,172,0.5)",
glowColor = "rgba(60,230,172,0.14)",
}: PlayheadIndicatorProps) {
return (
<>
<div
aria-hidden="true"
className="absolute top-0 bottom-0"
style={{
left: "50%",
width: 2,
marginLeft: -1,
background: color,
boxShadow: `0 0 8px ${glowColor}`,
width: 13,
transform: "translateX(-50%)",
background: `radial-gradient(closest-side, ${glowColor}, transparent)`,
}}
/>
<div className="absolute" style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}>
<div
className="absolute top-0 bottom-0"
style={{
left: "50%",
width: 1,
marginLeft: -0.5,
background: color,
boxShadow: `0 0 6px ${glowColor}`,
}}
/>
<div className="absolute" style={{ left: "50%", top: 1, transform: "translateX(-50%)" }}>
<div
style={{
width: 0,
height: 0,
borderLeft: "6px solid transparent",
borderRight: "6px solid transparent",
borderTop: `8px solid ${color}`,
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
width: 9,
height: 9,
borderRadius: 2,
background: color,
boxShadow: `0 1px 3px rgba(0,0,0,0.55), 0 0 5px ${glowColor}`,
transform: "rotate(45deg)",
}}
/>
</div>
@@ -21,6 +21,7 @@ import {
import { RULER_H, TRACK_H } from "./timelineLayout";
import { formatTime } from "../lib/time";
import { usePlayerStore } from "../store/playerStore";
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -30,6 +31,7 @@ afterEach(() => {
});
describe("Timeline provider boundary", () => {
// fallow-ignore-next-line code-duplication
it("renders the public Timeline export without TimelineEditProvider", () => {
const host = document.createElement("div");
document.body.append(host);
@@ -55,6 +57,99 @@ describe("Timeline provider boundary", () => {
act(() => root.unmount());
});
// fallow-ignore-next-line code-duplication
it("renders the gutter without legacy icons or hue dots", () => {
const host = document.createElement("div");
document.body.append(host);
Object.defineProperty(host, "clientWidth", {
configurable: true,
value: 640,
});
usePlayerStore.setState({
duration: 4,
timelineReady: true,
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
});
const root = createRoot(host);
act(() => {
root.render(React.createElement(Timeline));
});
const hueDot = Array.from(host.querySelectorAll("div")).find(
(node) =>
node.style.width === "6px" &&
node.style.height === "6px" &&
node.style.borderRadius === "9999px",
);
expect(host.querySelector('img[src^="/icons/timeline/"]')).toBeNull();
expect(hueDot).toBeUndefined();
act(() => root.unmount());
});
// fallow-ignore-next-line code-duplication
it("requests persisted track visibility from the gutter without seeking", () => {
const host = document.createElement("div");
document.body.append(host);
Object.defineProperty(host, "clientWidth", {
configurable: true,
value: 640,
});
usePlayerStore.setState({
duration: 4,
timelineReady: true,
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0, hidden: true }],
});
const onSeek = vi.fn();
const onToggleTrackHidden = vi.fn();
const root = createRoot(host);
act(() => {
root.render(
React.createElement(
TimelineEditProvider,
{ value: { onToggleTrackHidden } },
React.createElement(Timeline, { onSeek }),
),
);
});
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Show track 0"]');
expect(button).not.toBeNull();
if (!button) throw new Error("Expected a track visibility toggle");
act(() => {
button.dispatchEvent(
new MouseEvent("pointerdown", {
bubbles: true,
cancelable: true,
button: 0,
clientX: 120,
clientY: 40,
}),
);
});
expect(onSeek).not.toHaveBeenCalled();
act(() => {
button.click();
});
const row = button.parentElement?.parentElement;
const trackContent = row?.children.item(1);
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
expect(trackContent).toBeInstanceOf(HTMLElement);
if (!(trackContent instanceof HTMLElement)) {
throw new Error("Expected track content element");
}
expect(trackContent.style.opacity).toBe("0.35");
act(() => root.unmount());
});
it("opens the keyframe context menu without seeking to that keyframe", () => {
const host = document.createElement("div");
document.body.append(host);
@@ -177,10 +272,10 @@ describe("generateTicks", () => {
it("uses denser major labels as timeline zoom increases", () => {
const fitTicks = generateTicks(180, 10);
const zoomedTicks = generateTicks(180, 48);
expect(fitTicks.major[1] - fitTicks.major[0]).toBe(15);
expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5);
expect(fitTicks.major[1] - fitTicks.major[0]).toBe(10);
expect(fitTicks.minor).toContain(5);
expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(2);
expect(zoomedTicks.minor).toContain(1);
expect(zoomedTicks.minor).toContain(4);
});
it("keeps labels readable instead of placing one at every tiny tick", () => {
@@ -194,6 +289,7 @@ describe("formatTime", () => {
expect(formatTime(0)).toBe("0:00");
});
// fallow-ignore-next-line code-duplication
it("formats seconds below a minute", () => {
expect(formatTime(5)).toBe("0:05");
expect(formatTime(30)).toBe("0:30");
@@ -261,8 +357,12 @@ describe("getTimelineScrollLeftForZoomTransition", () => {
expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);
});
it("preserves the current scroll offset for other zoom transitions", () => {
expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(480);
it("resets horizontal scroll whenever the next zoom mode is fit", () => {
expect(getTimelineScrollLeftForZoomTransition("fit", "fit", 480)).toBe(0);
expect(getTimelineScrollLeftForZoomTransition(null, "fit", 480)).toBe(0);
});
it("preserves the current scroll offset for manual zoom transitions", () => {
expect(getTimelineScrollLeftForZoomTransition("fit", "manual", 480)).toBe(480);
expect(getTimelineScrollLeftForZoomTransition("manual", "manual", 480)).toBe(480);
});
@@ -1,4 +1,4 @@
import { useRef, useMemo, useCallback, useState, useEffect, memo, type ReactNode } from "react";
import { useRef, useMemo, useCallback, useState, useEffect, memo } from "react";
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
import { isMusicTrack } from "../../utils/timelineInspector";
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
@@ -6,9 +6,10 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
import { useMountEffect } from "../../hooks/useMountEffect";
import { EditPopover } from "./EditModal";
import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
import { defaultTimelineTheme } from "./timelineTheme";
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
import { useTimelinePlayhead } from "./useTimelinePlayhead";
import { useTimelineActiveClips } from "./useTimelineActiveClips";
import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
import { getTimelinePixelsPerSecond } from "./timelineZoom";
import { useTimelineZoom } from "./useTimelineZoom";
@@ -21,17 +22,15 @@ import {
} from "./KeyframeDiamondContextMenu";
import { useTimelineClipDrag } from "./useTimelineClipDrag";
import { ClipContextMenu } from "./ClipContextMenu";
import { TimelineShortcutHint } from "./TimelineShortcutHint";
import {
GUTTER,
generateTicks,
getTimelineCanvasHeight,
shouldShowTimelineShortcutHint,
} from "./timelineLayout";
import type { TimelineDropCallbacks } from "./timelineCallbacks";
import {
useResolvedTimelineEditCallbacks,
type TimelineEditOverrides,
} from "./useResolvedTimelineEditCallbacks";
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
import type { TimelineProps } from "./TimelineTypes";
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
export {
@@ -48,19 +47,6 @@ export {
getDefaultDroppedTrack,
} from "./timelineLayout";
interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
onSeek?: (time: number) => void;
onDrillDown?: (element: TimelineElement) => void;
renderClipContent?: (
element: TimelineElement,
style: { clip: string; label: string },
) => ReactNode;
renderClipOverlay?: (element: TimelineElement) => ReactNode;
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
onSelectElement?: (element: TimelineElement | null) => void;
theme?: Partial<TimelineTheme>;
}
export const Timeline = memo(function Timeline({
onSeek,
onDrillDown,
@@ -162,20 +148,26 @@ export const Timeline = memo(function Timeline({
});
}, [syncShortcutHintVisibility]);
const setContainerRef = useCallback(
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
containerRef.current = el;
}, []);
const setScrollRef = useCallback(
(el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
containerRef.current = el;
scrollRef.current = el;
if (!el) return;
setViewportWidth(el.clientWidth);
scheduleShortcutHintVisibilitySync();
roRef.current = new ResizeObserver(([entry]) => {
setViewportWidth(entry.contentRect.width);
const syncScrollViewport = () => {
setViewportWidth(el.clientWidth);
scheduleShortcutHintVisibilitySync();
});
};
syncScrollViewport();
roRef.current = new ResizeObserver(syncScrollViewport);
roRef.current.observe(el);
},
[scheduleShortcutHintVisibilitySync],
@@ -273,6 +265,13 @@ export const Timeline = memo(function Timeline({
const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
ppsRef.current = pps;
const trackContentWidth = Math.max(0, effectiveDuration * pps);
const clipStateVersion = useMemo(
() =>
expandedElements
.map((el) => `${el.key ?? el.id}:${el.start}:${el.duration}:${el.track}`)
.join("|"),
[expandedElements],
);
const zoomModeRef = useRef(zoomMode);
zoomModeRef.current = zoomMode;
const manualZoomPercentRef = useRef(manualZoomPercent);
@@ -301,6 +300,11 @@ export const Timeline = memo(function Timeline({
setManualZoomPercent,
onSeek,
});
useTimelineActiveClips({
scrollRef,
currentTime,
clipStateVersion,
});
const {
rangeSelection,
@@ -340,8 +344,7 @@ export const Timeline = memo(function Timeline({
() => generateTicks(effectiveDuration, pps),
[effectiveDuration, pps],
);
const majorTickInterval =
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
useEffect(() => {
syncShortcutHintVisibility();
@@ -406,7 +409,7 @@ export const Timeline = memo(function Timeline({
}}
>
<div
ref={scrollRef}
ref={setScrollRef}
tabIndex={-1}
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
onDragOver={handleAssetDragOver}
@@ -434,7 +437,6 @@ export const Timeline = memo(function Timeline({
totalH={totalH}
effectiveDuration={effectiveDuration}
majorTickInterval={majorTickInterval}
shiftHeld={shiftHeld}
rangeSelection={rangeSelection}
theme={theme}
displayTrackOrder={displayTrackOrder}
@@ -472,6 +474,9 @@ export const Timeline = memo(function Timeline({
const elKey = el.key ?? el.id;
setSelectedElementId(elKey);
onSelectElement?.(el);
// Visually select the clicked diamond (matches shift-click / motion-path
// selection); cleared above so this single-selects it.
toggleSelectedKeyframe(`${elKey}:${pct}`);
const absTime = el.start + (pct / 100) * el.duration;
onSeek?.(absTime);
const kfData = keyframeCache?.get(elKey);
@@ -519,22 +524,7 @@ export const Timeline = memo(function Timeline({
</div>
{showShortcutHint && !showPopover && !rangeSelection && (
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
style={{ background: "rgba(17,23,35,0.84)", borderColor: theme.gutterBorder }}
>
<kbd
className="text-[9px] font-mono px-1 py-0.5 rounded"
style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.06)" }}
>
Shift
</kbd>
<span className="text-[9px]" style={{ color: theme.textSecondary }}>
+ drag/click to edit range
</span>
</div>
</div>
<TimelineShortcutHint theme={theme} />
)}
{showPopover && rangeSelection && (
@@ -1,4 +1,5 @@
import { memo, type ReactNode } from "react";
import { Eye, EyeSlash } from "@phosphor-icons/react";
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
import { TimelineClip } from "./TimelineClip";
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
@@ -24,21 +25,15 @@ import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { isMusicTrack } from "../../utils/timelineInspector";
function ClipLabel({ element, color }: { element: TimelineElement; color: string }) {
function ClipLintDot({ element }: { element: TimelineElement }) {
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
if (!lint || lint.count === 0) return null;
return (
<span
className="flex items-center gap-1 truncate text-[10px] font-medium leading-none"
style={{ color }}
>
{element.label || element.id || element.tag}
{lint && lint.count > 0 && (
<span
className="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-amber-400"
title={lint.messages.join("\n")}
/>
)}
</span>
className="absolute w-1.5 h-1.5 rounded-full bg-amber-400"
style={{ top: 7, right: 7 }}
title={lint.messages.join("\n")}
/>
);
}
@@ -50,7 +45,6 @@ interface TimelineCanvasProps {
totalH: number;
effectiveDuration: number;
majorTickInterval: number;
shiftHeld: boolean;
rangeSelection: TimelineRangeSelection | null;
theme: TimelineTheme;
displayTrackOrder: number[];
@@ -109,7 +103,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({
totalH,
effectiveDuration,
majorTickInterval,
shiftHeld,
rangeSelection,
theme,
displayTrackOrder,
@@ -148,7 +141,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
onContextMenuClip,
beatAnalysis,
}: TimelineCanvasProps) {
const { onResizeElement, onMoveElement, onRazorSplit, onRazorSplitAll } =
const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } =
useTimelineEditContextOptional();
const beatDragging = usePlayerStore((s) => s.beatDragging);
const draggedElement = draggedClip?.element ?? null;
@@ -180,17 +173,12 @@ export const TimelineCanvas = memo(function TimelineCanvas({
const renderClipChildren = (element: TimelineElement, clipStyle: TrackVisualStyle) => (
<>
{renderClipOverlay?.(element)}
<div
className={
renderClipContent
? "absolute inset-0 overflow-hidden"
: "flex items-center overflow-hidden flex-1 min-w-0 px-3 gap-2"
}
>
{renderClipContent?.(element, clipStyle) ?? (
<ClipLabel element={element} color={clipStyle.label} />
)}
</div>
{!renderClipContent && <ClipLintDot element={element} />}
{renderClipContent && (
<div className="absolute inset-0 overflow-hidden">
{renderClipContent(element, clipStyle)}
</div>
)}
</>
);
@@ -204,258 +192,290 @@ export const TimelineCanvas = memo(function TimelineCanvas({
totalH={totalH}
effectiveDuration={effectiveDuration}
majorTickInterval={majorTickInterval}
shiftHeld={shiftHeld}
rangeSelection={rangeSelection}
theme={theme}
beatAnalysis={beatAnalysis}
/>
{displayTrackOrder.map((trackNum) => {
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
const isPendingTrack =
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
// The beat-dot strip occupies the top of this track's lane (active track,
// or the music track when nothing is selected). When shown, keyframe
// diamonds shrink + drop to the bottom half so they don't collide with it.
const beatStripOnTrack =
(beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
(selectedElementId
? els.some((e) => (e.key ?? e.id) === selectedElementId)
: els.some(isMusicTrack));
return (
<div
key={trackNum}
className="relative flex"
style={{
height: TRACK_H,
background: theme.rowBackground,
borderBottom: `1px solid ${theme.rowBorder}`,
}}
>
{
// fallow-ignore-next-line complexity
displayTrackOrder.map((trackNum) => {
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
const isPendingTrack =
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
const rowBackground =
displayTrackOrder.indexOf(trackNum) % 2 === 0 ? theme.rowBackground : "#0D0E12";
// The beat-dot strip occupies the top of this track's lane (active track,
// or the music track when nothing is selected). When shown, keyframe
// diamonds shrink + drop to the bottom half so they don't collide with it.
const beatStripOnTrack =
(beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
(selectedElementId
? els.some((e) => (e.key ?? e.id) === selectedElementId)
: els.some(isMusicTrack));
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
return (
<div
className="flex-shrink-0 flex items-center justify-center"
key={trackNum}
className="relative flex"
style={{
width: GUTTER,
background: theme.gutterBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
height: TRACK_H,
background: rowBackground,
borderBottom: `1px solid ${theme.rowBorder}`,
}}
>
<div
className="flex items-center justify-center"
className="sticky left-0 z-[12] flex-shrink-0 flex items-center justify-center"
style={{
width: 18,
height: 18,
borderRadius: 6,
backgroundColor: ts.iconBackground,
border: `1px solid ${theme.gutterBorder}`,
color: "#fff",
width: GUTTER,
background: theme.gutterBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
}}
>
{ts.icon}
<button
type="button"
aria-label={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
title={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
isTrackHidden
? "text-[#3CE6AC] hover:text-white"
: "text-white/35 hover:text-white/75"
}`}
onPointerDown={(e) => {
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
void onToggleTrackHidden?.(trackNum, !isTrackHidden);
}}
>
{isTrackHidden ? (
<EyeSlash size={14} weight="bold" aria-hidden="true" />
) : (
<Eye size={14} weight="bold" aria-hidden="true" />
)}
</button>
</div>
</div>
<div style={{ width: trackContentWidth }} className="relative">
{/* Faint beat lines in every track's background (behind the clips);
the active move-snap target is highlighted. */}
<BeatBackgroundLines
beatTimes={beatAnalysis?.beatTimes}
beatStrengths={beatAnalysis?.beatStrengths}
pps={pps}
highlightTime={draggedClip?.started ? draggedClip.snapBeatTime : null}
/>
{/* Beat dots on the active track (the one holding the selection),
falling back to the music track when nothing is selected. */}
{beatStripOnTrack && (
<BeatStrip
<div
style={{
width: trackContentWidth,
opacity: isTrackHidden ? 0.35 : 1,
transition: "opacity 120ms ease",
}}
className="relative"
>
{/* Faint beat lines in every track's background (behind the clips);
the active move-snap target is highlighted. */}
<BeatBackgroundLines
beatTimes={beatAnalysis?.beatTimes}
beatStrengths={beatAnalysis?.beatStrengths}
pps={pps}
highlightTime={draggedClip?.started ? draggedClip.snapBeatTime : null}
/>
)}
{isPendingTrack && (
<div
className="absolute inset-0 flex items-center"
style={{
paddingLeft: 16,
color: ts.label,
fontSize: 11,
letterSpacing: "0.06em",
textTransform: "uppercase",
opacity: 0.5,
}}
>
New track
</div>
)}
{els.map((el, i) => {
const clipStyle = getTrackStyle(el.tag);
const elementKey = el.key ?? el.id;
const capabilities = getTimelineEditCapabilities(el);
const isSelected = selectedElementId === elementKey;
const isComposition = !!el.compositionSrc;
const clipKey = `${elementKey}-${i}`;
const isDraggingClip =
draggedClip?.started === true &&
(draggedElement?.key ?? draggedElement?.id) === elementKey;
if (isDraggingClip) return null;
const previewElement = getPreviewElement(el);
return (
<TimelineClip
key={clipKey}
onContextMenu={(e: React.MouseEvent) => {
e.preventDefault();
onContextMenuClip?.(e, el);
}}
el={previewElement}
{/* Beat dots on the active track (the one holding the selection),
falling back to the music track when nothing is selected. */}
{beatStripOnTrack && (
<BeatStrip
beatTimes={beatAnalysis?.beatTimes}
beatStrengths={beatAnalysis?.beatStrengths}
pps={pps}
clipY={CLIP_Y}
isSelected={isSelected}
isHovered={hoveredClip === clipKey}
isDragging={false}
hasCustomContent={!!renderClipContent}
capabilities={capabilities}
theme={theme}
trackStyle={clipStyle}
isComposition={isComposition}
onHoverStart={() => setHoveredClip(clipKey)}
onHoverEnd={() => setHoveredClip(null)}
onResizeStart={(edge, e) => {
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
if (edge === "start" && !capabilities.canTrimStart) return;
if (edge === "end" && !capabilities.canTrimEnd) return;
e.stopPropagation();
blockedClipRef.current = null;
setShowPopover(false);
setRangeSelection(null);
setResizingClip({
element: el,
edge,
originClientX: e.clientX,
previewStart: el.start,
previewDuration: el.duration,
previewPlaybackStart: el.playbackStart,
started: false,
});
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
if (usePlayerStore.getState().activeTool === "razor") return;
if (e.shiftKey) {
shiftClickClipRef.current = {
element: el,
anchorX: e.clientX,
anchorY: e.clientY,
};
return;
}
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const blockedIntent = resolveBlockedTimelineEditIntent({
width: rect.width,
offsetX: e.clientX - rect.left,
handleWidth: CLIP_HANDLE_W,
capabilities,
});
if (
blockedIntent &&
((blockedIntent === "move" && onMoveElement) ||
(blockedIntent !== "move" && onResizeElement))
) {
blockedClipRef.current = {
element: el,
intent: blockedIntent,
originClientX: e.clientX,
originClientY: e.clientY,
started: false,
};
return;
}
if (!onMoveElement || !capabilities.canMove) return;
blockedClipRef.current = null;
setShowPopover(false);
setRangeSelection(null);
setDraggedClip({
element: el,
originClientX: e.clientX,
originClientY: e.clientY,
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
originScrollTop: scrollRef.current?.scrollTop ?? 0,
pointerClientX: e.clientX,
pointerClientY: e.clientY,
pointerOffsetX: e.clientX - rect.left,
pointerOffsetY: e.clientY - rect.top,
previewStart: el.start,
previewTrack: el.track,
snapBeatTime: null,
started: false,
});
syncClipDragAutoScroll(e.clientX, e.clientY);
}}
onClick={(e) => {
e.stopPropagation();
if (suppressClickRef.current) return;
const { activeTool } = usePlayerStore.getState();
if (activeTool === "razor" && onRazorSplit) {
const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const clickOffsetX = e.clientX - clipRect.left;
const splitTime = previewElement.start + clickOffsetX / pps;
const clampedTime = Math.max(
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
Math.min(
previewElement.start +
previewElement.duration -
SPLIT_BOUNDARY_EPSILON_S,
splitTime,
),
);
if (e.shiftKey && onRazorSplitAll) {
onRazorSplitAll(clampedTime);
} else {
onRazorSplit(el, clampedTime);
}
return;
}
const nextElement = isSelected ? null : el;
setSelectedElementId(nextElement ? elementKey : null);
onSelectElement?.(nextElement);
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (suppressClickRef.current) return;
if (isComposition && onDrillDown) onDrillDown(el);
/>
)}
{isPendingTrack && (
<div
className="absolute inset-0 flex items-center"
style={{
paddingLeft: 16,
color: ts.label,
fontSize: 11,
letterSpacing: "0.06em",
textTransform: "uppercase",
opacity: 0.5,
}}
>
{renderClipChildren(previewElement, clipStyle)}
{STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
<TimelineClipDiamonds
keyframesData={keyframeCache.get(elementKey)!}
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
clipHeightPx={TRACK_H - 2 * CLIP_Y}
beatsActive={beatStripOnTrack}
accentColor={clipStyle.accent}
New track
</div>
)}
{
// fallow-ignore-next-line complexity
els.map((el) => {
const clipStyle = getTrackStyle(el.tag);
const elementKey = el.key ?? el.id;
const capabilities = getTimelineEditCapabilities(el);
const isSelected = selectedElementId === elementKey;
const isComposition = !!el.compositionSrc;
// elementKey (el.key ?? el.id) is already unique per clip; do NOT
// fold in the map index, or a splice/reorder remounts every clip
// at/after the change (DOM flash, drag interruption).
const clipKey = elementKey;
const isDraggingClip =
draggedClip?.started === true &&
(draggedElement?.key ?? draggedElement?.id) === elementKey;
if (isDraggingClip) return null;
const previewElement = getPreviewElement(el);
return (
<TimelineClip
key={clipKey}
onContextMenu={(e: React.MouseEvent) => {
e.preventDefault();
onContextMenuClip?.(e, el);
}}
el={previewElement}
pps={pps}
clipY={CLIP_Y}
isSelected={isSelected}
currentPercentage={
previewElement.duration > 0
? ((currentTime - previewElement.start) / previewElement.duration) * 100
: 0
isHovered={hoveredClip === clipKey}
isDragging={false}
hasCustomContent={!!renderClipContent}
capabilities={capabilities}
theme={theme}
isComposition={isComposition}
onHoverStart={() => setHoveredClip(clipKey)}
onHoverEnd={() => setHoveredClip(null)}
onResizeStart={(edge, e) => {
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
if (edge === "start" && !capabilities.canTrimStart) return;
if (edge === "end" && !capabilities.canTrimEnd) return;
e.stopPropagation();
blockedClipRef.current = null;
setShowPopover(false);
setRangeSelection(null);
setResizingClip({
element: el,
edge,
originClientX: e.clientX,
previewStart: el.start,
previewDuration: el.duration,
previewPlaybackStart: el.playbackStart,
started: false,
});
}}
onPointerDown={
// fallow-ignore-next-line complexity
(e) => {
if (e.button !== 0) return;
if (usePlayerStore.getState().activeTool === "razor") return;
if (e.shiftKey) {
shiftClickClipRef.current = {
element: el,
anchorX: e.clientX,
anchorY: e.clientY,
};
return;
}
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const blockedIntent = resolveBlockedTimelineEditIntent({
width: rect.width,
offsetX: e.clientX - rect.left,
handleWidth: CLIP_HANDLE_W,
capabilities,
});
if (
blockedIntent &&
((blockedIntent === "move" && onMoveElement) ||
(blockedIntent !== "move" && onResizeElement))
) {
blockedClipRef.current = {
element: el,
intent: blockedIntent,
originClientX: e.clientX,
originClientY: e.clientY,
started: false,
};
return;
}
if (!onMoveElement || !capabilities.canMove) return;
blockedClipRef.current = null;
setShowPopover(false);
setRangeSelection(null);
setDraggedClip({
element: el,
originClientX: e.clientX,
originClientY: e.clientY,
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
originScrollTop: scrollRef.current?.scrollTop ?? 0,
pointerClientX: e.clientX,
pointerClientY: e.clientY,
pointerOffsetX: e.clientX - rect.left,
pointerOffsetY: e.clientY - rect.top,
previewStart: el.start,
previewTrack: el.track,
snapBeatTime: null,
started: false,
});
syncClipDragAutoScroll(e.clientX, e.clientY);
}
}
elementId={elementKey}
selectedKeyframes={selectedKeyframes}
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
onShiftClickKeyframe={onShiftClickKeyframe}
onContextMenuKeyframe={onContextMenuKeyframe}
onMoveKeyframe={onMoveKeyframe}
suppressClickRef={suppressClickRef}
/>
)}
</TimelineClip>
);
})}
onClick={(e) => {
e.stopPropagation();
if (suppressClickRef.current) return;
const { activeTool } = usePlayerStore.getState();
if (activeTool === "razor" && onRazorSplit) {
const clipRect = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
const clickOffsetX = e.clientX - clipRect.left;
const splitTime = previewElement.start + clickOffsetX / pps;
const clampedTime = Math.max(
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
Math.min(
previewElement.start +
previewElement.duration -
SPLIT_BOUNDARY_EPSILON_S,
splitTime,
),
);
if (e.shiftKey && onRazorSplitAll) {
onRazorSplitAll(clampedTime);
} else {
onRazorSplit(el, clampedTime);
}
return;
}
const nextElement = isSelected ? null : el;
setSelectedElementId(nextElement ? elementKey : null);
onSelectElement?.(nextElement);
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (suppressClickRef.current) return;
if (isComposition && onDrillDown) onDrillDown(el);
}}
>
{renderClipChildren(previewElement, clipStyle)}
{STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
<TimelineClipDiamonds
keyframesData={keyframeCache.get(elementKey)!}
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
clipHeightPx={TRACK_H - 2 * CLIP_Y}
beatsActive={beatStripOnTrack}
accentColor={clipStyle.accent}
isSelected={isSelected}
currentPercentage={
previewElement.duration > 0
? ((currentTime - previewElement.start) / previewElement.duration) *
100
: 0
}
elementId={elementKey}
selectedKeyframes={selectedKeyframes}
onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
onShiftClickKeyframe={onShiftClickKeyframe}
onContextMenuKeyframe={onContextMenuKeyframe}
onMoveKeyframe={onMoveKeyframe}
suppressClickRef={suppressClickRef}
/>
)}
</TimelineClip>
);
})
}
</div>
</div>
</div>
);
})}
);
})
}
{/* Drag ghost */}
{activeDraggedElement && activeDraggedPosition && (
@@ -479,7 +499,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({
hasCustomContent={!!renderClipContent}
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
theme={theme}
trackStyle={getTrackStyle(activeDraggedElement.tag)}
isComposition={!!activeDraggedElement.compositionSrc}
onHoverStart={() => {}}
onHoverEnd={() => {}}
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { TimelineClip } from "./TimelineClip";
import type { TimelineEditCapabilities } from "./timelineEditing";
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: true,
});
afterEach(() => {
document.body.innerHTML = "";
});
const capabilities: TimelineEditCapabilities = {
canMove: true,
canTrimStart: true,
canTrimEnd: true,
};
function renderClip({
element,
pps = 100,
isSelected = false,
hasCustomContent = true,
}: {
element: TimelineElement;
pps?: number;
isSelected?: boolean;
hasCustomContent?: boolean;
}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<TimelineClip
el={element}
pps={pps}
clipY={0}
isSelected={isSelected}
isHovered={false}
hasCustomContent={hasCustomContent}
capabilities={capabilities}
isComposition={false}
onHoverStart={vi.fn()}
onHoverEnd={vi.fn()}
onClick={vi.fn()}
onDoubleClick={vi.fn()}
>
<div data-custom-content="true" />
</TimelineClip>,
);
});
return { host, root };
}
describe("TimelineClip", () => {
it("renders the clip label above custom content without showing default timecode", () => {
const { host, root } = renderClip({
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 0.5, track: 0 },
});
expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("Hero");
expect(host.querySelector(".timeline-clip__timecode")).toBeNull();
act(() => root.unmount());
});
it("keeps selected narrow clips labeled even when they render custom content", () => {
const { host, root } = renderClip({
element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 0.1, track: 0 },
isSelected: true,
});
expect(host.querySelector(".timeline-clip__label")?.textContent).toBe("FX");
expect(host.querySelector(".timeline-clip__timecode")).toBeNull();
act(() => root.unmount());
});
it("marks hidden clips for active-state suppression", () => {
const { host, root } = renderClip({
element: {
id: "hidden",
label: "Hidden",
tag: "div",
start: 0,
duration: 1,
track: 0,
hidden: true,
},
});
expect(host.querySelector(".timeline-clip")?.getAttribute("data-clip-hidden")).toBe("true");
act(() => root.unmount());
});
});
@@ -1,6 +1,4 @@
import type { TimelineTrackStyle } from "./timelineTheme";
import { memo, type ReactNode } from "react";
import { memo, type CSSProperties, type ReactNode } from "react";
import type { TimelineElement } from "../store/playerStore";
import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
import type { TimelineEditCapabilities } from "./timelineEditing";
@@ -15,7 +13,6 @@ interface TimelineClipProps {
hasCustomContent: boolean;
capabilities: TimelineEditCapabilities;
theme?: TimelineTheme;
trackStyle: TimelineTrackStyle;
isComposition: boolean;
onHoverStart: () => void;
onHoverEnd: () => void;
@@ -27,6 +24,7 @@ interface TimelineClipProps {
children?: ReactNode;
}
// fallow-ignore-next-line complexity
export const TimelineClip = memo(function TimelineClip({
el,
pps,
@@ -37,7 +35,6 @@ export const TimelineClip = memo(function TimelineClip({
hasCustomContent,
capabilities,
theme = defaultTimelineTheme,
trackStyle,
isComposition,
onHoverStart,
onHoverEnd,
@@ -51,45 +48,43 @@ export const TimelineClip = memo(function TimelineClip({
const leftPx = el.start * pps;
const widthPx = Math.max(el.duration * pps, 4);
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
const borderColor = isSelected
? trackStyle.accent
: isHovered
? theme.clipBorderHover
: theme.clipBorder;
const boxShadow = isDragging
? theme.clipShadowDragging
: isSelected
? `0 0 0 1px ${trackStyle.accent}80, 0 0 8px ${trackStyle.accent}25`
: isHovered
? theme.clipShadowHover
: theme.clipShadow;
const displayLabel = el.label || el.id || el.tag;
const showHandles = handleOpacity > 0.01;
const showHandles = handleOpacity > 0.01 && (widthPx >= 32 || isSelected);
const showLabel = widthPx >= 40 || isSelected;
const showDefaultText = !hasCustomContent && (widthPx >= 40 || isSelected);
const startLabel = el.start.toFixed(1);
const endLabel = (el.start + el.duration).toFixed(1);
const clipClassName = [
"timeline-clip",
"absolute",
hasCustomContent ? "overflow-visible" : "overflow-hidden",
isSelected ? "is-selected" : "",
isHovered ? "is-hovered" : "",
isDragging ? "is-dragging" : "",
showDefaultText ? "" : "is-micro",
]
.filter((className) => className.length > 0)
.join(" ");
const style: CSSProperties = {
left: leftPx,
width: widthPx,
top: clipY,
bottom: clipY,
borderRadius: theme.clipRadius,
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
cursor: capabilities.canMove ? "grab" : "default",
transform: isDragging ? "translateY(-1px)" : undefined,
};
return (
<div
data-clip="true"
className={
hasCustomContent
? "absolute overflow-visible"
: "absolute flex items-center overflow-visible"
}
style={{
left: leftPx,
width: widthPx,
top: clipY,
bottom: clipY,
borderRadius: theme.clipRadius,
background: trackStyle.clip,
border: `1px solid ${borderColor}`,
boxShadow,
transition: "border-color 100ms, box-shadow 100ms",
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
cursor: capabilities.canMove ? "grab" : "default",
transform: isDragging ? "translateY(-1px)" : undefined,
opacity: isDragging ? 0.92 : 1,
}}
data-el-id={el.key ?? el.id}
data-clip-start={el.start}
data-clip-end={el.start + el.duration}
data-clip-hidden={el.hidden ? "true" : undefined}
className={clipClassName}
style={style}
title={
isComposition
? `${el.compositionSrc} • Double-click to open`
@@ -102,22 +97,6 @@ export const TimelineClip = memo(function TimelineClip({
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
>
{/* Left accent stripe — wider + brighter for expanded sub-comp children */}
<div
aria-hidden="true"
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: el.expandedParentStart !== undefined ? 4 : 3,
background: trackStyle.accent,
opacity: el.expandedParentStart !== undefined ? 0.8 : isSelected ? 0.7 : 0.3,
borderRadius: `${theme.clipRadius} 0 0 ${theme.clipRadius}`,
zIndex: 2,
pointerEvents: "none",
}}
/>
{/* Left trim handle */}
{showHandles && capabilities.canTrimStart && (
<div
@@ -134,6 +113,7 @@ export const TimelineClip = memo(function TimelineClip({
}}
>
<div
className="timeline-clip__handle-bar"
style={{
position: "absolute",
left: 4,
@@ -141,7 +121,7 @@ export const TimelineClip = memo(function TimelineClip({
bottom: 6,
width: 2,
borderRadius: 1,
background: trackStyle.accent,
background: "rgba(255, 255, 255, 0.55)",
opacity: handleOpacity * 0.6,
}}
/>
@@ -163,6 +143,7 @@ export const TimelineClip = memo(function TimelineClip({
}}
>
<div
className="timeline-clip__handle-bar"
style={{
position: "absolute",
right: 4,
@@ -170,12 +151,18 @@ export const TimelineClip = memo(function TimelineClip({
bottom: 6,
width: 2,
borderRadius: 1,
background: trackStyle.accent,
background: "rgba(255, 255, 255, 0.55)",
opacity: handleOpacity * 0.6,
}}
/>
</div>
)}
{showLabel && <span className="timeline-clip__label">{displayLabel}</span>}
{showDefaultText && (
<span className="timeline-clip__timecode">
{startLabel}-{endLabel}s
</span>
)}
{children}
</div>
);
@@ -1,6 +1,5 @@
import { memo } from "react";
import type { TimelineTheme } from "./timelineTheme";
import type { TimelineRangeSelection } from "./timelineEditing";
import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
@@ -12,8 +11,6 @@ interface TimelineRulerProps {
totalH: number;
effectiveDuration: number;
majorTickInterval: number;
shiftHeld: boolean;
rangeSelection: TimelineRangeSelection | null;
theme: TimelineTheme;
beatAnalysis?: MusicBeatAnalysis | null;
}
@@ -26,8 +23,6 @@ export const TimelineRuler = memo(function TimelineRuler({
totalH,
effectiveDuration,
majorTickInterval,
shiftHeld,
rangeSelection,
theme,
beatAnalysis,
}: TimelineRulerProps) {
@@ -87,35 +82,34 @@ export const TimelineRuler = memo(function TimelineRuler({
{/* Ruler */}
<div
className="relative overflow-hidden"
style={{ height: RULER_H, marginLeft: GUTTER, width: trackContentWidth }}
style={{
height: RULER_H,
marginLeft: GUTTER,
width: trackContentWidth,
background: theme.gutterBackground,
borderBottom: `1px solid ${theme.rulerBorder}`,
}}
>
{shiftHeld && !rangeSelection && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<span className="text-[9px] font-medium" style={{ color: theme.textSecondary }}>
Drag or click a clip to edit range
</span>
</div>
)}
{minor.map((t) => (
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
<div className="w-px h-[3px]" style={{ background: theme.tickMinor }} />
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
</div>
))}
{major.map((t) => (
<div
key={`M-${t}`}
className="absolute bottom-0 flex flex-col items-center"
style={{ left: t * pps }}
>
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps }}>
<span
className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
style={{ color: theme.tickText }}
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
style={{
color: theme.tickText,
left: 5,
top: 5,
fontSize: 10,
}}
>
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
</span>
<div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
</div>
))}
</div>
@@ -0,0 +1,26 @@
import type { TimelineTheme } from "./timelineTheme";
interface TimelineShortcutHintProps {
theme: TimelineTheme;
}
export function TimelineShortcutHint({ theme }: TimelineShortcutHintProps) {
return (
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
style={{ background: "rgba(17,23,35,0.84)", borderColor: theme.gutterBorder }}
>
<kbd
className="text-[9px] font-mono px-1 py-0.5 rounded"
style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.06)" }}
>
Shift
</kbd>
<span className="text-[9px]" style={{ color: theme.textSecondary }}>
+ drag/click to edit range
</span>
</div>
</div>
);
}
@@ -0,0 +1,18 @@
import type { ReactNode } from "react";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineDropCallbacks } from "./timelineCallbacks";
import type { TimelineTheme } from "./timelineTheme";
import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
onSeek?: (time: number) => void;
onDrillDown?: (element: TimelineElement) => void;
renderClipContent?: (
element: TimelineElement,
style: { clip: string; label: string },
) => ReactNode;
renderClipOverlay?: (element: TimelineElement) => ReactNode;
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
onSelectElement?: (element: TimelineElement | null) => void;
theme?: Partial<TimelineTheme>;
}
@@ -47,6 +47,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
},
{ rootMargin: "200px" },
);
// fallow-ignore-next-line code-duplication
ioRef.current.observe(el);
const target = el.parentElement || el;
@@ -177,20 +178,22 @@ export const VideoThumbnail = memo(function VideoThumbnail({
/>
)}
<div
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
style={{
background:
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
}}
>
<span
className="text-[9px] font-semibold truncate block leading-tight"
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
{label && (
<div
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
style={{
background:
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
}}
>
{label}
</span>
</div>
<span
className="text-[9px] font-semibold truncate block leading-tight"
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
>
{label}
</span>
</div>
)}
</div>
);
});
@@ -32,6 +32,7 @@ export interface TimelineEditCallbacks {
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -1,52 +1,10 @@
import type { ReactNode } from "react";
import { getTimelineTrackStyle, type TimelineTrackStyle } from "./timelineTheme";
export interface TrackVisualStyle extends TimelineTrackStyle {
icon: ReactNode;
}
const ICON_BASE = "/icons/timeline";
function TimelineIcon({ src }: { src: string }) {
return (
<img
src={src}
alt=""
width={12}
height={12}
style={{ filter: "brightness(0) invert(1)" }}
draggable={false}
/>
);
}
const IconCaptions = <TimelineIcon src={`${ICON_BASE}/captions.svg`} />;
const IconImage = <TimelineIcon src={`${ICON_BASE}/image.svg`} />;
const IconMusic = <TimelineIcon src={`${ICON_BASE}/music.svg`} />;
const IconText = <TimelineIcon src={`${ICON_BASE}/text.svg`} />;
const IconComposition = <TimelineIcon src={`${ICON_BASE}/composition.svg`} />;
const IconAudio = <TimelineIcon src={`${ICON_BASE}/audio.svg`} />;
const ICONS: Record<string, ReactNode> = {
video: IconImage,
audio: IconMusic,
img: IconImage,
div: IconComposition,
span: IconCaptions,
p: IconText,
h1: IconText,
section: IconComposition,
sfx: IconAudio,
};
export type TrackVisualStyle = TimelineTrackStyle;
export function getTrackStyle(tag: string): TrackVisualStyle {
// Defensive: callers may pass an empty/undefined tag; fall back to "div"
// (restores the #1679 null-guard that a restack had dropped).
const safeTag = tag || "div";
const trackStyle = getTimelineTrackStyle(safeTag);
const normalized = safeTag.toLowerCase();
const icon =
normalized.startsWith("h") && normalized.length === 2 && "123456".includes(normalized[1] ?? "")
? ICONS.h1
: (ICONS[normalized] ?? IconComposition);
return { ...trackStyle, icon };
return getTimelineTrackStyle(safeTag);
}
@@ -11,9 +11,9 @@ const TIMELINE_SCROLL_BUFFER = 20;
/* ── Tick generation ──────────────────────────────────────────────── */
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
const targetMajorPx = 128;
const targetMajorPx = 88;
return (
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
);
@@ -24,20 +24,14 @@ function getMajorTickInterval(duration: number, pixelsPerSecond?: number): numbe
}
function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
let interval = majorInterval / 2;
if (majorInterval >= 30) interval = majorInterval / 6;
else if (majorInterval >= 15) interval = majorInterval / 3;
else if (majorInterval >= 5) interval = majorInterval / 5;
else if (majorInterval >= 1) interval = majorInterval / 4;
if (
Number.isFinite(pixelsPerSecond) &&
(pixelsPerSecond ?? 0) > 0 &&
interval * (pixelsPerSecond ?? 0) < 20
(majorInterval / 2) * (pixelsPerSecond ?? 0) < 12
) {
return Math.max(0.25, majorInterval / 2);
return 0;
}
return Math.max(0.25, interval);
return majorInterval / 2;
}
export function generateTicks(
@@ -51,17 +45,13 @@ export function generateTicks(
const major: number[] = [];
const minor: number[] = [];
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
for (
let t = 0;
t <= duration + 0.001 && major.length + minor.length < maxTicks;
t += minorInterval
) {
for (let t = 0; t <= duration + 0.001 && major.length < maxTicks; t += majorInterval) {
const rounded = Math.round(t * 100) / 100;
const isMajor =
Math.abs(rounded % majorInterval) < 0.01 ||
Math.abs((rounded % majorInterval) - majorInterval) < 0.01;
if (isMajor) major.push(rounded);
else minor.push(rounded);
major.push(rounded);
if (minorInterval > 0 && major.length + minor.length < maxTicks) {
const midpoint = Math.round((t + minorInterval) * 100) / 100;
if (midpoint <= duration + 0.001) minor.push(midpoint);
}
}
return { major, minor };
}
@@ -69,6 +59,12 @@ export function generateTicks(
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
if (!Number.isFinite(time)) return "0:00";
const safeTime = Math.max(0, time);
if (majorInterval < 0.1) {
const totalHundredths = Math.round(safeTime * 100);
const wholeSeconds = Math.floor(totalHundredths / 100);
const hundredth = totalHundredths % 100;
return `${formatTime(wholeSeconds)}.${hundredth.toString().padStart(2, "0")}`;
}
if (majorInterval < 1) {
const totalTenths = Math.round(safeTime * 10);
const wholeSeconds = Math.floor(totalTenths / 10);
@@ -101,7 +97,7 @@ export function getTimelineScrollLeftForZoomTransition(
nextZoomMode: ZoomMode,
currentScrollLeft: number,
): number {
if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
if (nextZoomMode === "fit") return 0;
return currentScrollLeft;
}
@@ -0,0 +1,101 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const studioCss = readFileSync(new URL("../../styles/studio.css", import.meta.url), "utf8");
const timelineClipSource = readFileSync(new URL("./TimelineClip.tsx", import.meta.url), "utf8");
const playheadSource = readFileSync(new URL("./PlayheadIndicator.tsx", import.meta.url), "utf8");
const allowedTimelineTransitionProperties = [
"background-color",
"border-color",
"box-shadow",
"color",
"opacity",
];
function expectRule(css: string, selector: string): string {
const selectorStart = css.indexOf(`${selector} {`);
expect(selectorStart).toBeGreaterThanOrEqual(0);
const bodyStart = css.indexOf("{", selectorStart);
const bodyEnd = css.indexOf("}", bodyStart);
expect(bodyStart).toBeGreaterThanOrEqual(0);
expect(bodyEnd).toBeGreaterThan(bodyStart);
return css.slice(bodyStart + 1, bodyEnd).trim();
}
function expectDeclaration(ruleBody: string, property: string): string {
const declarationMatch = new RegExp(`${property}:\\s*([^;]+);`).exec(ruleBody);
expect(declarationMatch?.[1]).toBeDefined();
return declarationMatch?.[1].trim() ?? "";
}
function transitionProperties(transitionDeclaration: string): string[] {
const items: string[] = [];
let depth = 0;
let item = "";
for (const char of transitionDeclaration) {
if (char === "(") depth += 1;
if (char === ")") depth -= 1;
if (char === "," && depth === 0) {
items.push(item.trim());
item = "";
continue;
}
item += char;
}
if (item.trim().length > 0) items.push(item.trim());
return items.map((transition) => transition.split(/\s+/)[0]);
}
describe("timeline motion styles", () => {
it("keeps clip motion reduced-motion gated and layout safe", () => {
const mediaStart = studioCss.indexOf("@media (prefers-reduced-motion: no-preference)");
expect(mediaStart).toBeGreaterThanOrEqual(0);
const beforeMotionMedia = studioCss.slice(0, mediaStart);
const baseTimelineClipRule = expectRule(beforeMotionMedia, ".timeline-clip");
expect(baseTimelineClipRule).not.toContain("transition");
const motionMediaCss = studioCss.slice(mediaStart);
const timelineClipMotionRule = expectRule(motionMediaCss, ".timeline-clip");
const clipTransition = expectDeclaration(timelineClipMotionRule, "transition");
expect(transitionProperties(clipTransition)).toEqual(allowedTimelineTransitionProperties);
expect(clipTransition).not.toMatch(/\b(?:all|left|width|top|bottom|transform)\b/);
});
it("layers the active mint bloom through opacity instead of a gradient background swap", () => {
const baseTimelineClipRule = expectRule(studioCss, ".timeline-clip");
const activeTimelineClipRule = expectRule(studioCss, ".timeline-clip[data-active]");
const bloomOverlayRule = expectRule(studioCss, ".timeline-clip::before");
const activeBloomOverlayRule = expectRule(studioCss, ".timeline-clip[data-active]::before");
expect(baseTimelineClipRule).toContain("background-color: rgba(255, 255, 255, 0.055)");
expect(activeTimelineClipRule).not.toContain("background: linear-gradient");
expect(activeTimelineClipRule).toContain("border-color: rgba(60, 230, 172, 0.55)");
expect(activeTimelineClipRule).not.toContain("box-shadow");
expect(bloomOverlayRule).toContain("background: rgba(60, 230, 172, 0.2)");
expect(bloomOverlayRule).not.toContain("linear-gradient");
expect(bloomOverlayRule).toContain("opacity: 0");
expect(activeBloomOverlayRule).toContain("opacity: 1");
});
it("targets trim handle bars without changing drag geometry", () => {
const handleClassMatches = timelineClipSource.match(/className="timeline-clip__handle-bar"/g);
expect(handleClassMatches).toHaveLength(2);
expect(timelineClipSource).toContain('transform: isDragging ? "translateY(-1px)" : undefined');
expect(timelineClipSource).not.toContain("scale(");
});
it("keeps the playhead polish static, without transition-driven positioning", () => {
expect(playheadSource).toContain("boxShadow");
expect(playheadSource).toContain("rotate(45deg)");
expect(playheadSource).not.toContain("transition");
});
});
@@ -4,14 +4,31 @@ import {
getRenderedTimelineElement,
getTimelineTrackStyle,
} from "./timelineTheme";
import { getTrackStyle } from "./timelineIcons";
describe("getTimelineTrackStyle", () => {
it("reuses heading styles for heading tags", () => {
expect(getTimelineTrackStyle("h2").accent).toBe(getTimelineTrackStyle("h1").accent);
});
it("uses one neutral clip style for every timeline tag", () => {
const expectedStyle = {
clip: "rgba(255,255,255,0.055)",
clipActive: "rgba(60,230,172,0.16)",
accent: "#3CE6AC",
label: "rgba(255,255,255,0.5)",
};
it("falls back for unknown tags", () => {
expect(getTimelineTrackStyle("custom-tag").accent).toBe("#3CE6AC");
expect(getTimelineTrackStyle("video")).toEqual(expectedStyle);
expect(getTimelineTrackStyle("audio")).toEqual(expectedStyle);
expect(getTimelineTrackStyle("custom-tag")).toEqual(expectedStyle);
expect(getTimelineTrackStyle("video")).toEqual(getTimelineTrackStyle("audio"));
expect(getTimelineTrackStyle("video")).toEqual(getTimelineTrackStyle("custom-tag"));
});
});
describe("getTrackStyle", () => {
it("returns the timeline style only and preserves the empty tag fallback", () => {
const style = getTrackStyle("");
expect(style).toEqual(getTimelineTrackStyle("div"));
expect(Object.keys(style)).not.toContain("icon");
expect(Object.keys(style)).not.toContain("iconBackground");
});
});
@@ -4,7 +4,7 @@ export interface TimelineTrackStyle {
clip: string;
accent: string;
label: string;
iconBackground: string;
clipActive?: string;
}
export interface TimelineTheme {
@@ -36,25 +36,25 @@ export interface TimelineTheme {
}
const TRACK_STYLE: TimelineTrackStyle = {
clip: "#1c2028",
clip: "rgba(255,255,255,0.055)",
clipActive: "rgba(60,230,172,0.16)",
accent: "#3CE6AC",
label: "#dde1e8",
iconBackground: "rgba(255,255,255,0.06)",
label: "rgba(255,255,255,0.5)",
};
export const defaultTimelineTheme: TimelineTheme = {
shellBackground: "#0A0A0B",
shellBorder: "rgba(255,255,255,0.05)",
rulerBorder: "rgba(255,255,255,0.045)",
rowBackground: "#0A0A0B",
rowBorder: "rgba(255,255,255,0.05)",
gutterBackground: "#0A0A0B",
gutterBorder: "rgba(255,255,255,0.05)",
textPrimary: "#E8EDF5",
textSecondary: "#8391A8",
tickText: "rgba(131,145,168,0.92)",
tickMajor: "rgba(255,255,255,0.13)",
tickMinor: "rgba(255,255,255,0.08)",
rulerBorder: "rgba(255,255,255,0.16)",
rowBackground: "#0B0C0F",
rowBorder: "rgba(255,255,255,0.06)",
gutterBackground: "#0E0F12",
gutterBorder: "rgba(255,255,255,0.10)",
textPrimary: "rgba(255,255,255,0.92)",
textSecondary: "rgba(255,255,255,0.62)",
tickText: "rgba(255,255,255,0.34)",
tickMajor: "rgba(255,255,255,0.10)",
tickMinor: "rgba(255,255,255,0.06)",
clipBackground: "#141922",
clipBackgroundActive: "#181e28",
clipBorder: "rgba(255,255,255,0.10)",
@@ -67,7 +67,7 @@ export const defaultTimelineTheme: TimelineTheme = {
handleColor: "rgba(255,255,255,0.2)",
panelResizeSeam: "rgba(255,255,255,0.12)",
panelResizeActive: "rgba(255,255,255,0.24)",
clipRadius: "6px",
clipRadius: "8px",
};
export function getTimelineTrackStyle(_tag: string): TimelineTrackStyle {
@@ -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());
});
});
@@ -0,0 +1,125 @@
import { useCallback, useLayoutEffect, useRef } from "react";
import { liveTime } from "../store/playerStore";
import { useMountEffect } from "../../hooks/useMountEffect";
interface ActiveClipRecord {
id: string;
start: number;
end: number;
hidden: boolean;
element: HTMLElement;
}
interface UseTimelineActiveClipsInput {
scrollRef: React.RefObject<HTMLDivElement | null>;
currentTime: number;
clipStateVersion: string;
}
function readFiniteNumber(value: string | undefined): number | null {
if (value === undefined || value.trim() === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function readClipRecord(element: Element): ActiveClipRecord | null {
if (!(element instanceof HTMLElement)) return null;
const id = element.dataset.elId;
const start = readFiniteNumber(element.dataset.clipStart);
const end = readFiniteNumber(element.dataset.clipEnd);
const hidden = element.dataset.clipHidden === "true";
if (!id || start === null || end === null) return null;
return { id, start, end, hidden, element };
}
function collectTimelineClipRecords(container: HTMLElement): ActiveClipRecord[] {
const records: ActiveClipRecord[] = [];
for (const element of container.querySelectorAll('[data-clip="true"]')) {
const record = readClipRecord(element);
if (record) records.push(record);
}
return records;
}
function indexClipRecordsById(records: ActiveClipRecord[]): Map<string, ActiveClipRecord> {
const recordsById = new Map<string, ActiveClipRecord>();
for (const record of records) recordsById.set(record.id, record);
return recordsById;
}
function getActiveClipIds(records: ActiveClipRecord[], time: number): Set<string> {
const next = new Set<string>();
if (!Number.isFinite(time)) return next;
for (const record of records) {
if (record.hidden) continue;
if (time >= record.start && time <= record.end) next.add(record.id);
}
return next;
}
function setsMatch(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) return false;
for (const value of left) {
if (!right.has(value)) return false;
}
return true;
}
function applyActiveClipDiff(records: ActiveClipRecord[], previous: Set<string>, time: number) {
const next = getActiveClipIds(records, time);
const changed = !setsMatch(previous, next);
for (const record of records) {
const wasActive = previous.has(record.id);
const isActive = next.has(record.id);
if (wasActive === isActive) continue;
record.element.toggleAttribute("data-active", isActive);
}
previous.clear();
for (const id of next) previous.add(id);
return changed;
}
export function updateTimelineActiveClipClasses(
container: HTMLElement,
previous: Set<string>,
time: number,
) {
applyActiveClipDiff(collectTimelineClipRecords(container), previous, time);
}
export function useTimelineActiveClips({
scrollRef,
currentTime,
clipStateVersion,
}: UseTimelineActiveClipsInput) {
const recordsRef = useRef<ActiveClipRecord[]>([]);
const recordsByIdRef = useRef(new Map<string, ActiveClipRecord>());
const previousActiveIdsRef = useRef(new Set<string>());
const refreshRecords = useCallback(
(time: number) => {
const scroll = scrollRef.current;
if (!scroll) {
recordsRef.current = [];
recordsByIdRef.current.clear();
previousActiveIdsRef.current.clear();
return;
}
recordsRef.current = collectTimelineClipRecords(scroll);
recordsByIdRef.current = indexClipRecordsById(recordsRef.current);
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time);
},
[scrollRef],
);
useLayoutEffect(() => {
refreshRecords(currentTime);
}, [currentTime, clipStateVersion, refreshRecords]);
useMountEffect(() => {
const unsub = liveTime.subscribe((time) => {
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time);
});
return unsub;
});
}
@@ -94,6 +94,12 @@ export function useTimelinePlayhead({
syncPlayheadPosition(currentTime);
}, [currentTime, pps, syncPlayheadPosition]);
useLayoutEffect(() => {
const scroll = scrollRef.current;
if (!scroll || zoomMode !== "fit") return;
scroll.scrollLeft = 0;
}, [zoomMode, pps, scrollRef]);
useEffect(() => {
const scroll = scrollRef.current;
if (!scroll) {
@@ -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]);
}
@@ -6,6 +6,7 @@ describe("formatTime", () => {
expect(formatTime(0)).toBe("0:00");
});
// fallow-ignore-next-line code-duplication
it("formats seconds less than a minute", () => {
expect(formatTime(5)).toBe("0:05");
expect(formatTime(30)).toBe("0:30");
@@ -1,6 +1,10 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { parseTimelineFromDOM, createImplicitTimelineLayersFromDOM } from "./timelineDOM";
import {
createTimelineElementFromManifestClip,
parseTimelineFromDOM,
createImplicitTimelineLayersFromDOM,
} from "./timelineDOM";
function makeDoc(html: string): Document {
const d = document.implementation.createHTMLDocument();
@@ -55,6 +59,49 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
expect(elements.map((el) => el.tag)).toEqual(["img"]);
});
it("marks parsed timeline elements hidden when data-hidden is present", () => {
const doc = makeDoc(`
<div data-composition-id="root">
<div id="hero" class="clip" data-start="0" data-duration="5" data-hidden></div>
</div>
`);
const elements = parseTimelineFromDOM(doc, 10);
const hero = elements.find((el) => el.domId === "hero");
expect(hero?.hidden).toBe(true);
});
it("marks manifest timeline elements hidden when the host has data-hidden", () => {
const doc = makeDoc(`
<div data-composition-id="root">
<div id="hero" class="clip" data-start="0" data-duration="5" data-hidden></div>
</div>
`);
const hostEl = doc.getElementById("hero");
const element = createTimelineElementFromManifestClip({
clip: {
id: "hero",
label: "Hero",
kind: "element",
tagName: "div",
start: 0,
duration: 5,
track: 0,
compositionId: null,
parentCompositionId: null,
compositionSrc: null,
assetUrl: null,
},
fallbackIndex: 0,
doc,
hostEl,
});
expect(element.hidden).toBe(true);
});
});
describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
@@ -65,6 +65,7 @@ function resolveClipTag(clip: ClipManifestClip): string {
return clip.tagName || clip.kind || "div";
}
// fallow-ignore-next-line complexity
export function createTimelineElementFromManifestClip(params: {
clip: ClipManifestClip;
fallbackIndex: number;
@@ -120,6 +121,7 @@ export function createTimelineElementFromManifestClip(params: {
if (hostEl) {
applyMediaMetadataFromElement(entry, hostEl);
if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
const timelineRole = hostEl.getAttribute("data-timeline-role");
if (timelineRole) entry.timelineRole = timelineRole;
}
@@ -234,6 +236,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
const els: TimelineElement[] = [];
let trackCounter = 0;
// fallow-ignore-next-line complexity
nodes.forEach((node) => {
if (node === rootComp) return;
if (isTimelineIgnoredElement(node)) return;
@@ -256,6 +259,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
const trackStr = el.getAttribute("data-track-index");
const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
// fallow-ignore-next-line code-duplication
const compId = el.getAttribute("data-composition-id");
const selector = getTimelineElementSelector(el);
const sourceFile = getTimelineElementSourceFile(el);
@@ -308,6 +312,9 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
if (el.hasAttribute("data-timeline-locked")) {
entry.timelineLocked = true;
}
if (el.hasAttribute("data-hidden")) {
entry.hidden = true;
}
const timelineRole = el.getAttribute("data-timeline-role");
if (timelineRole) entry.timelineRole = timelineRole;
@@ -344,6 +344,7 @@ export function buildMissingCompositionElements(
const trackStr = el.getAttribute("data-track-index");
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
// fallow-ignore-next-line code-duplication
const compSrc =
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
const selector = getTimelineElementSelector(el);
@@ -49,6 +49,8 @@ export interface TimelineElement {
timingSource?: "authored" | "implicit";
/** Set by data-timeline-locked on the host element — disables move and trim in Studio. */
timelineLocked?: boolean;
/** Set by data-hidden on the host element — hides the clip in preview and render. */
hidden?: boolean;
/** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
timelineRole?: string;
/**
@@ -141,7 +143,9 @@ interface PlayerState {
setSelectedElementId: (id: string | null) => void;
updateElement: (
elementId: string,
updates: Partial<Pick<TimelineElement, "start" | "duration" | "track" | "playbackStart">>,
updates: Partial<
Pick<TimelineElement, "start" | "duration" | "track" | "playbackStart" | "hidden">
>,
) => void;
setZoomMode: (mode: ZoomMode) => void;
setManualZoomPercent: (percent: number) => void;
+108
View File
@@ -117,6 +117,114 @@ body {
height: 100dvh;
}
.timeline-clip {
background-color: rgba(255, 255, 255, 0.055);
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 8px;
}
.timeline-clip::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: rgba(60, 230, 172, 0.2);
opacity: 0;
pointer-events: none;
}
.timeline-clip[data-active] {
border-color: rgba(60, 230, 172, 0.55);
}
.timeline-clip[data-active]::before {
opacity: 1;
}
.timeline-clip.is-hovered {
background-color: rgba(255, 255, 255, 0.09);
}
.timeline-clip[data-active].is-hovered {
border-color: rgba(60, 230, 172, 0.75);
}
.timeline-clip.is-selected {
box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.85);
}
.timeline-clip[data-active].is-selected {
box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.85);
}
.timeline-clip.is-dragging {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.timeline-clip[data-active].is-selected.is-dragging {
box-shadow:
0 0 0 1.5px rgba(255, 255, 255, 0.85),
0 8px 24px rgba(0, 0, 0, 0.4);
}
.timeline-clip__label {
position: absolute;
top: 6px;
left: 12px;
right: 10px;
z-index: 6;
overflow: hidden;
color: rgba(255, 255, 255, 0.5);
font-size: 10px;
font-weight: 500;
line-height: 1;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-clip[data-active] .timeline-clip__label {
color: rgba(232, 255, 247, 0.95);
}
.timeline-clip__timecode {
position: absolute;
bottom: 6px;
left: 12px;
overflow: hidden;
color: rgba(255, 255, 255, 0.34);
font-family: "SF Mono", "Fira Code", monospace;
font-size: 9px;
font-variant-numeric: tabular-nums;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (prefers-reduced-motion: no-preference) {
.timeline-clip {
transition:
background-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 200ms cubic-bezier(0.22, 1, 0.36, 1),
color 200ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.timeline-clip::before {
transition: opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.timeline-clip__label {
transition: color 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.timeline-clip__handle-bar {
transition: opacity 120ms ease-out;
}
}
/* CodeMirror overrides */
.cm-editor {
height: 100%;
@@ -26,6 +26,15 @@ describe("applyPatchByTarget", () => {
);
});
it("removes a boolean data attribute by selector", () => {
const html = `<div class="headline clip" data-start="0" data-hidden></div>`;
const op: PatchOperation = { type: "attribute", property: "hidden", value: null };
expect(applyPatchByTarget(html, { selector: ".headline" }, op)).toBe(
`<div class="headline clip" data-start="0"></div>`,
);
});
it("updates inline z-index by selector when the clip has no DOM id", () => {
const html = `<div class="headline clip" style="position: absolute; opacity: 1" data-start="0"></div>`;
const op: PatchOperation = { type: "inline-style", property: "z-index", value: "3" };
@@ -366,6 +375,7 @@ describe("motion attribute round-trip via sourcePatcher", () => {
duration: 0.6,
ease: "power2.out",
from: { autoAlpha: 0, y: 32 },
// fallow-ignore-next-line code-duplication
to: { autoAlpha: 1, y: 0 },
};
@@ -420,6 +430,7 @@ describe("motion attribute round-trip via sourcePatcher", () => {
duration: 1,
ease: "none",
from: { opacity: 0 },
// fallow-ignore-next-line code-duplication
to: { opacity: 1 },
};
+6 -4
View File
@@ -323,8 +323,9 @@ function patchAttributeByTarget(
if (value === null) {
// Remove the attribute if present
if (!attrPattern.test(tag)) return html;
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`);
const boolAttrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
if (!boolAttrPattern.test(tag)) return html;
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
const newTag = tag.replace(removePattern, "");
return replaceTagAtMatch(html, match, newTag);
}
@@ -357,8 +358,9 @@ function patchAttribute(
const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
if (value === null) {
if (!attrPattern.test(tag)) return html;
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`);
const boolAttrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
if (!boolAttrPattern.test(tag)) return html;
const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}(?:=(["'])[^"']*\\1)?`);
const newTag = tag.replace(removePattern, "");
return html.replace(tag, newTag);
}