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
@@ -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) {