mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user