mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): wire expanded keyframe timeline lanes
This commit is contained in:
@@ -395,6 +395,7 @@ export function StudioApp() {
|
||||
designPanelActive,
|
||||
inspectorPanelActive,
|
||||
inspectorButtonActive,
|
||||
shouldShowMotionPath,
|
||||
shouldShowSelectedDomBounds,
|
||||
} = useInspectorState(
|
||||
panelLayout.rightPanelTab,
|
||||
@@ -552,6 +553,7 @@ export function StudioApp() {
|
||||
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
|
||||
setCompIdToSrc={setCompIdToSrc}
|
||||
setCompositionLoading={setCompositionLoading}
|
||||
shouldShowMotionPath={shouldShowMotionPath}
|
||||
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
||||
isGestureRecording={gestureState === "recording"}
|
||||
recordingState={gestureState}
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
|
||||
) => Promise<void> | void;
|
||||
setCompIdToSrc: (map: Map<string, string>) => void;
|
||||
setCompositionLoading: (loading: boolean) => void;
|
||||
shouldShowMotionPath: boolean;
|
||||
shouldShowSelectedDomBounds: boolean;
|
||||
blockPreview?: BlockPreviewInfo | null;
|
||||
isGestureRecording?: boolean;
|
||||
@@ -90,6 +91,7 @@ export function EditorShell({
|
||||
handleRazorSplitAll,
|
||||
setCompIdToSrc,
|
||||
setCompositionLoading,
|
||||
shouldShowMotionPath,
|
||||
shouldShowSelectedDomBounds,
|
||||
isGestureRecording,
|
||||
recordingState,
|
||||
@@ -149,6 +151,7 @@ export function EditorShell({
|
||||
onDeleteElement={handleTimelineElementDelete}
|
||||
previewOverlay={
|
||||
<PreviewOverlays
|
||||
shouldShowMotionPath={shouldShowMotionPath}
|
||||
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
||||
blockPreview={blockPreview}
|
||||
isGestureRecording={isGestureRecording}
|
||||
|
||||
@@ -520,12 +520,12 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
||||
<KeyframeDiamondContextMenu
|
||||
state={kfMenu}
|
||||
onClose={() => setKfMenu(null)}
|
||||
onDelete={(_elId, target) =>
|
||||
animId && handleGsapRemoveKeyframe(animId, target.percentage)
|
||||
onDelete={(_elId, keyframe) =>
|
||||
animId && handleGsapRemoveKeyframe(animId, keyframe.percentage)
|
||||
}
|
||||
onDeleteAll={() => animId && handleGsapRemoveAllKeyframes(animId)}
|
||||
onMoveToPlayhead={(_element, target) =>
|
||||
animId && handleGsapMoveKeyframeToPlayhead(animId, target.percentage)
|
||||
onMoveToPlayhead={(_element, keyframe) =>
|
||||
animId && handleGsapMoveKeyframeToPlayhead(animId, keyframe.percentage)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { GestureRecordingState } from "../editor/GestureRecordControl";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface PreviewOverlaysProps {
|
||||
shouldShowMotionPath: boolean;
|
||||
shouldShowSelectedDomBounds: boolean;
|
||||
blockPreview?: BlockPreviewInfo | null;
|
||||
isGestureRecording?: boolean;
|
||||
@@ -132,6 +133,7 @@ export function resolveZIndexEntries(
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function PreviewOverlays({
|
||||
shouldShowMotionPath,
|
||||
shouldShowSelectedDomBounds,
|
||||
blockPreview,
|
||||
isGestureRecording,
|
||||
@@ -274,7 +276,7 @@ export function PreviewOverlays({
|
||||
{STUDIO_KEYFRAMES_ENABLED && (
|
||||
<MotionPathOverlay
|
||||
iframeRef={previewIframeRef}
|
||||
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
||||
selection={shouldShowMotionPath ? domEditSelection : null}
|
||||
compositionSize={compositionDimensions}
|
||||
isPlaying={isPlaying}
|
||||
/>
|
||||
|
||||
@@ -19,10 +19,10 @@ export interface KeyframeDiamondContextMenuState {
|
||||
interface KeyframeDiamondContextMenuProps {
|
||||
state: KeyframeDiamondContextMenuState;
|
||||
onClose: () => void;
|
||||
onDelete: (elementId: string, target: TimelineKeyframeTarget) => void;
|
||||
onDelete: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
||||
onDeleteAll: (element: TimelineElement) => void;
|
||||
/** Retime the keyframe to the current playhead, preserving its value + ease. */
|
||||
onMoveToPlayhead?: (element: TimelineElement, target: TimelineKeyframeTarget) => void;
|
||||
onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
||||
}
|
||||
|
||||
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
|
||||
@@ -33,11 +33,9 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
||||
onMoveToPlayhead,
|
||||
}: KeyframeDiamondContextMenuProps) {
|
||||
const menuRef = useContextMenuDismiss(onClose);
|
||||
// One target object for every action: passing the identity as loose positional
|
||||
// arguments let an adapter forward the percentage alone, which drops the menu
|
||||
// back to first-match-by-percentage and picks the wrong animation whenever two
|
||||
// collide at the same percentage.
|
||||
const target: TimelineKeyframeTarget = {
|
||||
// The clicked diamond's identity, built once: the menu's two mutating entries
|
||||
// both act on it, and they must not disagree about which keyframe was clicked.
|
||||
const keyframe: TimelineKeyframeTarget = {
|
||||
percentage: state.percentage,
|
||||
tweenPercentage: state.tweenPercentage,
|
||||
propertyGroup: state.propertyGroup,
|
||||
@@ -64,7 +62,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
||||
// Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
|
||||
// and returns the tween-% for the mutation. Passing tween-% here would
|
||||
// miss the lookup on any tween whose window is shorter than the clip.
|
||||
onMoveToPlayhead(state.element, target);
|
||||
onMoveToPlayhead(state.element, keyframe);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
@@ -77,7 +75,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
|
||||
onClick={() => {
|
||||
onDelete(state.elementId, target);
|
||||
onDelete(state.elementId, keyframe);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -19,8 +19,11 @@ import {
|
||||
shouldAutoScrollTimeline,
|
||||
} from "./Timeline";
|
||||
import {
|
||||
CLIP_Y,
|
||||
FIT_ZOOM_HEADROOM,
|
||||
GUTTER,
|
||||
LABEL_COL_W,
|
||||
LANE_H,
|
||||
MIN_TIMELINE_EXTENT_S,
|
||||
PLAYHEAD_HEAD_W,
|
||||
RULER_H,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
TRACKS_LEFT_PAD,
|
||||
getTimelineDisplayContentWidth,
|
||||
getTimelineFitPps,
|
||||
getTimelineLaneTop,
|
||||
} from "./timelineLayout";
|
||||
import { formatTime } from "../lib/time";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
@@ -40,52 +44,216 @@ afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) {
|
||||
const clip = host.querySelector<HTMLElement>(`[data-el-id="${clipId}"]`);
|
||||
if (!clip) throw new Error(`Missing timeline clip ${clipId}`);
|
||||
const trackContent = clip.parentElement;
|
||||
if (!trackContent) throw new Error(`Missing content row for ${clipId}`);
|
||||
const trackHeader = trackContent.previousElementSibling;
|
||||
if (!(trackHeader instanceof HTMLElement)) throw new Error(`Missing track header for ${clipId}`);
|
||||
const rulerTickLabel = Array.from(host.querySelectorAll("span")).find(
|
||||
(node) => node.textContent === tickLabel,
|
||||
);
|
||||
const rulerTick = rulerTickLabel?.parentElement;
|
||||
if (!rulerTick) throw new Error(`Missing ruler tick ${tickLabel}`);
|
||||
const ruler = rulerTick.parentElement;
|
||||
if (!ruler) throw new Error("Missing timeline ruler");
|
||||
const rulerOrigin = ruler.previousElementSibling;
|
||||
if (!(rulerOrigin instanceof HTMLElement)) throw new Error("Missing timeline ruler origin");
|
||||
const playhead = Array.from(host.querySelectorAll<HTMLElement>("div")).find(
|
||||
(node) => node.style.zIndex === "100",
|
||||
);
|
||||
if (!playhead) throw new Error("Missing timeline playhead");
|
||||
return { clip, trackHeader, rulerTick, rulerOrigin, playhead };
|
||||
}
|
||||
|
||||
function renderTimelineGeometry(clipId: string) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(React.createElement(Timeline));
|
||||
});
|
||||
return { host, root, ...getHorizontalGeometry(host, clipId, "00:10") };
|
||||
}
|
||||
|
||||
function createSizedTimelineHost(width: number): HTMLDivElement {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
Object.defineProperty(host, "clientWidth", { configurable: true, value: width });
|
||||
return host;
|
||||
}
|
||||
|
||||
function expectTrackExpansion(
|
||||
row: HTMLElement | null | undefined,
|
||||
expandedClipIds: string[],
|
||||
height: number,
|
||||
) {
|
||||
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(expandedClipIds));
|
||||
expect(row?.style.height).toBe(`${height}px`);
|
||||
}
|
||||
|
||||
function renderBasicTimeline() {
|
||||
const host = createSizedTimelineHost(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));
|
||||
});
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
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);
|
||||
Object.defineProperty(host, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 640,
|
||||
});
|
||||
|
||||
it("keeps all-collapsed horizontal positions at the 32px gutter", () => {
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
duration: 11,
|
||||
timelineReady: true,
|
||||
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
|
||||
currentTime: 10,
|
||||
zoomMode: "manual",
|
||||
manualZoomPercent: 100,
|
||||
elements: [{ id: "clip-1", tag: "div", start: 10, duration: 1, track: 0 }],
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
const { root, clip, trackHeader, rulerTick, rulerOrigin, playhead } =
|
||||
renderTimelineGeometry("clip-1");
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
root.render(React.createElement(Timeline));
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(trackHeader.style.width).toBe("32px");
|
||||
expect(clip.style.left).toBe("1000px");
|
||||
expect(clip.style.height).toBe("");
|
||||
expect(clip.style.bottom).toBe(`${CLIP_Y}px`);
|
||||
expect(rulerOrigin.style.width).toBe("32px");
|
||||
expect(rulerTick.style.left).toBe("999.5px");
|
||||
expect(playhead.style.left).toBe(`${1032 - PLAYHEAD_HEAD_W / 2}px`);
|
||||
expect(playhead.style.width).toBe(`${PLAYHEAD_HEAD_W}px`);
|
||||
expect(
|
||||
resolveTimelineAssetDrop(
|
||||
{
|
||||
rectLeft: 100,
|
||||
rectTop: 0,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
contentOrigin: GUTTER,
|
||||
pixelsPerSecond: 100,
|
||||
duration: 60,
|
||||
trackOrder: [0],
|
||||
},
|
||||
1132,
|
||||
100,
|
||||
).start,
|
||||
).toBe(10);
|
||||
expect(getTimelineFitPps(640, 11, GUTTER)).toBe(10.1);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
it("reserves the label column and keeps expanded keyframes aligned with ruler time", () => {
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
duration: 20,
|
||||
timelineReady: true,
|
||||
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
|
||||
currentTime: 10,
|
||||
zoomMode: "manual",
|
||||
manualZoomPercent: 100,
|
||||
selectedElementId: "clip-1",
|
||||
expandedClipIds: new Set(["clip-1"]),
|
||||
elements: [
|
||||
{ id: "clip-1", label: "Hero card", tag: "div", start: 0, duration: 20, track: 0 },
|
||||
{ id: "clip-2", label: "Outro", tag: "div", start: 2, duration: 1, track: 1 },
|
||||
],
|
||||
gsapAnimations: new Map([
|
||||
[
|
||||
"clip-1",
|
||||
[
|
||||
{
|
||||
id: "position-tween",
|
||||
targetSelector: "#clip-1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 20,
|
||||
properties: {},
|
||||
propertyGroup: "position",
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [{ percentage: 50, properties: { x: 100 } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(React.createElement(Timeline));
|
||||
});
|
||||
const { host, root, clip, trackHeader, rulerTick, rulerOrigin, playhead } =
|
||||
renderTimelineGeometry("clip-1");
|
||||
const { trackHeader: collapsedHeader } = getHorizontalGeometry(host, "clip-2", "00:10");
|
||||
const diamond = host.querySelector<HTMLElement>(
|
||||
'[data-keyframe-group="position"][data-keyframe-percentage="50"]',
|
||||
);
|
||||
if (!diamond) throw new Error("Missing expanded position keyframe");
|
||||
const propertyLane = diamond.closest<HTMLElement>("[data-timeline-property-lane]");
|
||||
if (!propertyLane) throw new Error("Missing flat position property lane");
|
||||
const headerLane = trackHeader.querySelector<HTMLElement>('[data-property-group="position"]');
|
||||
if (!headerLane) throw new Error("Missing position property header");
|
||||
// Absolute x rebuilds from the content origin (the ruler-origin spacer),
|
||||
// which now insets a GUTTER past the LABEL_COL_W label column so a 0%
|
||||
// diamond has room to its left. The content row reaches that same origin via
|
||||
// header (LABEL_COL_W) + its gutter margin, so ruler tick and diamond still
|
||||
// coincide on the shared time x.
|
||||
const contentOrigin = Number.parseFloat(rulerOrigin.style.width);
|
||||
const rulerX = contentOrigin + Number.parseFloat(rulerTick.style.left) + 0.5;
|
||||
const diamondX =
|
||||
contentOrigin +
|
||||
Number.parseFloat(propertyLane.style.left) +
|
||||
Number.parseFloat(diamond.style.left) +
|
||||
Number.parseFloat(diamond.style.width) / 2;
|
||||
|
||||
expect(clip.contains(propertyLane)).toBe(false);
|
||||
expect(clip.style.height).toBe(`${TRACK_H - 2 * CLIP_Y}px`);
|
||||
expect(clip.style.bottom).toBe("");
|
||||
expect(propertyLane.style.top).toBe(`${getTimelineLaneTop(0)}px`);
|
||||
expect(propertyLane.style.top).toBe(headerLane.style.top);
|
||||
expect(propertyLane.style.background).toBe("");
|
||||
expect(propertyLane.style.border).toBe("");
|
||||
expect(propertyLane.style.borderRadius).toBe("");
|
||||
expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`);
|
||||
expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`);
|
||||
expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`);
|
||||
expect(diamondX).toBe(rulerX);
|
||||
expect(rulerX).toBe(LABEL_COL_W + GUTTER + 1000);
|
||||
expect(collapsedHeader.textContent).toContain("Outro");
|
||||
expect(getTimelineFitPps(640, 20, LABEL_COL_W + GUTTER)).toBeCloseTo(
|
||||
(640 - (LABEL_COL_W + GUTTER) - 2) / MIN_TIMELINE_EXTENT_S,
|
||||
);
|
||||
expect(
|
||||
resolveTimelineAssetDrop(
|
||||
{
|
||||
rectLeft: 100,
|
||||
rectTop: 0,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
contentOrigin: LABEL_COL_W + GUTTER,
|
||||
pixelsPerSecond: 100,
|
||||
duration: 60,
|
||||
trackOrder: [0],
|
||||
},
|
||||
100 + LABEL_COL_W + GUTTER + 1000,
|
||||
100,
|
||||
).start,
|
||||
).toBe(10);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders the public Timeline export without TimelineEditProvider", () => {
|
||||
const { root } = renderBasicTimeline();
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders the gutter without legacy icons or hue dots", () => {
|
||||
const { host, root } = renderBasicTimeline();
|
||||
|
||||
const hueDot = Array.from(host.querySelectorAll("div")).find(
|
||||
(node) =>
|
||||
@@ -99,14 +267,8 @@ describe("Timeline provider boundary", () => {
|
||||
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,
|
||||
});
|
||||
const host = createSizedTimelineHost(640);
|
||||
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
@@ -153,8 +315,8 @@ describe("Timeline provider boundary", () => {
|
||||
});
|
||||
|
||||
const row = button.parentElement?.parentElement;
|
||||
// Row children: [sticky gutter, TRACKS_LEFT_PAD spacer, time-mapped content].
|
||||
const trackContent = row?.children.item(2);
|
||||
// Row children: [TimelineTrackHeader (sticky column), time-mapped content].
|
||||
const trackContent = row?.children.item(1);
|
||||
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
|
||||
expect(trackContent).toBeInstanceOf(HTMLElement);
|
||||
if (!(trackContent instanceof HTMLElement)) {
|
||||
@@ -165,14 +327,49 @@ describe("Timeline provider boundary", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("opens the keyframe context menu without seeking to that keyframe", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
Object.defineProperty(host, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 720,
|
||||
it("splits all tracks once when shift-clicking the timeline with the razor", () => {
|
||||
const host = createSizedTimelineHost(640);
|
||||
usePlayerStore.setState({
|
||||
activeTool: "razor",
|
||||
duration: 4,
|
||||
timelineReady: true,
|
||||
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }],
|
||||
});
|
||||
|
||||
const onRazorSplitAll = vi.fn();
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
TimelineEditProvider,
|
||||
{ value: { onRazorSplitAll } },
|
||||
React.createElement(Timeline),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const viewport = host.querySelector('[aria-label="Timeline"]')?.firstElementChild;
|
||||
expect(viewport).toBeInstanceOf(HTMLElement);
|
||||
act(() => {
|
||||
viewport?.dispatchEvent(
|
||||
new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
clientX: 240,
|
||||
shiftKey: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onRazorSplitAll).toHaveBeenCalledTimes(1);
|
||||
expect(onRazorSplitAll).toHaveBeenCalledWith(expect.any(Number));
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("opens the keyframe context menu without seeking to that keyframe", () => {
|
||||
const host = createSizedTimelineHost(720);
|
||||
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
timelineReady: true,
|
||||
@@ -215,14 +412,101 @@ describe("Timeline provider boundary", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("marks every clip in selectedElementIds as selected", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
Object.defineProperty(host, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 720,
|
||||
it("shows a disclosure only for grouped keyframes and toggles the track height", () => {
|
||||
const host = createSizedTimelineHost(720);
|
||||
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
timelineReady: true,
|
||||
elements: [
|
||||
{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 },
|
||||
{ id: "clip-2", tag: "div", start: 2, duration: 2, track: 1 },
|
||||
],
|
||||
keyframeCache: new Map([
|
||||
[
|
||||
"clip-1",
|
||||
{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 }, propertyGroup: "position" },
|
||||
{ percentage: 50, properties: { x: 100 }, propertyGroup: "position" },
|
||||
{ percentage: 100, properties: { opacity: 0 }, propertyGroup: "visual" },
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
gsapAnimations: new Map([
|
||||
[
|
||||
"clip-1",
|
||||
[
|
||||
{
|
||||
id: "clip-1-position",
|
||||
targetSelector: "#clip-1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 2,
|
||||
properties: {},
|
||||
propertyGroup: "position",
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clip-1-visual",
|
||||
targetSelector: "#clip-1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 2,
|
||||
properties: {},
|
||||
propertyGroup: "visual",
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [{ percentage: 100, properties: { opacity: 0 } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(React.createElement(Timeline));
|
||||
});
|
||||
|
||||
// Keyframed clip-1 is expanded by default (AE/Figma default); its disclosure
|
||||
// lives in the left column. clip-2 has no keyframes so it never shows one.
|
||||
const collapseButton = host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Collapse clip-1 keyframes"]',
|
||||
);
|
||||
expect(collapseButton).not.toBeNull();
|
||||
expect(host.querySelector('button[aria-label="Expand clip-2 keyframes"]')).toBeNull();
|
||||
expect(host.querySelector('button[aria-label="Collapse clip-2 keyframes"]')).toBeNull();
|
||||
|
||||
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-1"]');
|
||||
const row = clip?.parentElement?.parentElement;
|
||||
expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H);
|
||||
|
||||
// Collapsing sticks (does not bounce back open via auto-expand).
|
||||
act(() => collapseButton?.click());
|
||||
expectTrackExpansion(row, [], TRACK_H);
|
||||
|
||||
const expandButton = host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Expand clip-1 keyframes"]',
|
||||
);
|
||||
expect(expandButton).not.toBeNull();
|
||||
act(() => expandButton?.click());
|
||||
expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("marks every clip in selectedElementIds as selected", () => {
|
||||
const host = createSizedTimelineHost(720);
|
||||
|
||||
usePlayerStore.setState({
|
||||
duration: 6,
|
||||
timelineReady: true,
|
||||
@@ -461,23 +745,29 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
|
||||
it("computes fit pps against the 60s floor for short compositions", () => {
|
||||
// A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width.
|
||||
// (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.)
|
||||
const pps = getTimelineFitPps(viewport, 10);
|
||||
expect(pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S);
|
||||
expect(10 * pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / 6);
|
||||
const pps = getTimelineFitPps(viewport, 10, GUTTER + TRACKS_LEFT_PAD);
|
||||
expect(pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S);
|
||||
expect(10 * pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / 6);
|
||||
});
|
||||
|
||||
it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => {
|
||||
expect(getTimelineFitPps(viewport, 60)).toBeCloseTo(
|
||||
(viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (60 * FIT_ZOOM_HEADROOM),
|
||||
expect(getTimelineFitPps(viewport, 60, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo(
|
||||
(viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / (60 * FIT_ZOOM_HEADROOM),
|
||||
);
|
||||
expect(getTimelineFitPps(viewport, 120)).toBeCloseTo(
|
||||
(viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (120 * FIT_ZOOM_HEADROOM),
|
||||
expect(getTimelineFitPps(viewport, 120, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo(
|
||||
(viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / (120 * FIT_ZOOM_HEADROOM),
|
||||
);
|
||||
});
|
||||
|
||||
it("subtracts the expanded keyframe label column before fitting headroom", () => {
|
||||
expect(getTimelineFitPps(viewport, 120, LABEL_COL_W)).toBeCloseTo(
|
||||
(viewport - LABEL_COL_W - 2) / (120 * FIT_ZOOM_HEADROOM),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves CapCut-style trailing headroom: the comp ends at 1/1.2 of the usable width", () => {
|
||||
const usable = viewport - GUTTER - TRACKS_LEFT_PAD - 2;
|
||||
const pps = getTimelineFitPps(viewport, 120);
|
||||
const usable = viewport - (GUTTER + TRACKS_LEFT_PAD) - 2;
|
||||
const pps = getTimelineFitPps(viewport, 120, GUTTER + TRACKS_LEFT_PAD);
|
||||
// Composition content occupies usable/1.2 px; the remaining ~17% is empty
|
||||
// droppable ruler/lane surface past the end.
|
||||
expect(120 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM);
|
||||
@@ -485,17 +775,17 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
|
||||
});
|
||||
|
||||
it("falls back to 100 pps before the viewport is measured", () => {
|
||||
expect(getTimelineFitPps(0, 10)).toBe(100);
|
||||
expect(getTimelineFitPps(GUTTER + TRACKS_LEFT_PAD, 10)).toBe(100);
|
||||
expect(getTimelineFitPps(Number.NaN, 10)).toBe(100);
|
||||
expect(getTimelineFitPps(0, 10, GUTTER)).toBe(100);
|
||||
expect(getTimelineFitPps(GUTTER, 10, GUTTER)).toBe(100);
|
||||
expect(getTimelineFitPps(Number.NaN, 10, GUTTER)).toBe(100);
|
||||
});
|
||||
|
||||
it("uses the floor for zero/invalid durations", () => {
|
||||
expect(getTimelineFitPps(viewport, 0)).toBeCloseTo(
|
||||
(viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S,
|
||||
expect(getTimelineFitPps(viewport, 0, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo(
|
||||
(viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S,
|
||||
);
|
||||
expect(getTimelineFitPps(viewport, Number.NaN)).toBeCloseTo(
|
||||
(viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S,
|
||||
expect(getTimelineFitPps(viewport, Number.NaN, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo(
|
||||
(viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -504,14 +794,24 @@ describe("getTimelineDisplayContentWidth", () => {
|
||||
it("always spans at least MIN_TIMELINE_EXTENT_S seconds of content", () => {
|
||||
// 10s of content at 20 pps = 200px; the floor keeps 60s (1200px) rendered.
|
||||
expect(
|
||||
getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 400, pps: 20 }),
|
||||
getTimelineDisplayContentWidth({
|
||||
trackContentWidth: 200,
|
||||
viewportWidth: 400,
|
||||
contentOrigin: GUTTER,
|
||||
pps: 20,
|
||||
}),
|
||||
).toBe(MIN_TIMELINE_EXTENT_S * 20);
|
||||
});
|
||||
|
||||
it("still fills the viewport when that is larger than the 60s floor", () => {
|
||||
expect(
|
||||
getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 2000, pps: 5 }),
|
||||
).toBe(2000 - GUTTER - TRACKS_LEFT_PAD - 2);
|
||||
getTimelineDisplayContentWidth({
|
||||
trackContentWidth: 200,
|
||||
viewportWidth: 2000,
|
||||
contentOrigin: GUTTER + TRACKS_LEFT_PAD,
|
||||
pps: 5,
|
||||
}),
|
||||
).toBe(2000 - (GUTTER + TRACKS_LEFT_PAD) - 2);
|
||||
});
|
||||
|
||||
it("tracks a drag ghost past every other bound (drag-to-extend)", () => {
|
||||
@@ -519,6 +819,7 @@ describe("getTimelineDisplayContentWidth", () => {
|
||||
getTimelineDisplayContentWidth({
|
||||
trackContentWidth: 500,
|
||||
viewportWidth: 400,
|
||||
contentOrigin: GUTTER,
|
||||
pps: 5,
|
||||
dragGhostEndPx: 5000,
|
||||
}),
|
||||
@@ -530,6 +831,7 @@ describe("getTimelineDisplayContentWidth", () => {
|
||||
getTimelineDisplayContentWidth({
|
||||
trackContentWidth: 500,
|
||||
viewportWidth: 400,
|
||||
contentOrigin: GUTTER,
|
||||
pps: 5,
|
||||
resizeGhostEndPx: 4200,
|
||||
}),
|
||||
@@ -538,7 +840,12 @@ describe("getTimelineDisplayContentWidth", () => {
|
||||
|
||||
it("keeps long content authoritative", () => {
|
||||
expect(
|
||||
getTimelineDisplayContentWidth({ trackContentWidth: 9000, viewportWidth: 400, pps: 50 }),
|
||||
getTimelineDisplayContentWidth({
|
||||
trackContentWidth: 9000,
|
||||
viewportWidth: 400,
|
||||
contentOrigin: GUTTER,
|
||||
pps: 50,
|
||||
}),
|
||||
).toBe(9000);
|
||||
});
|
||||
});
|
||||
@@ -565,7 +872,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
|
||||
getTimelineScrollLeftForZoomAnchor({
|
||||
pointerX: 300,
|
||||
currentScrollLeft: 200,
|
||||
gutter: 32,
|
||||
contentOrigin: GUTTER,
|
||||
currentPixelsPerSecond: 10,
|
||||
nextPixelsPerSecond: 20,
|
||||
duration: 120,
|
||||
@@ -578,7 +885,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
|
||||
getTimelineScrollLeftForZoomAnchor({
|
||||
pointerX: 300,
|
||||
currentScrollLeft: 0,
|
||||
gutter: 32,
|
||||
contentOrigin: GUTTER,
|
||||
currentPixelsPerSecond: 20,
|
||||
nextPixelsPerSecond: 5,
|
||||
duration: 120,
|
||||
@@ -591,7 +898,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
|
||||
getTimelineScrollLeftForZoomAnchor({
|
||||
pointerX: 300,
|
||||
currentScrollLeft: 120,
|
||||
gutter: 32,
|
||||
contentOrigin: GUTTER,
|
||||
currentPixelsPerSecond: 0,
|
||||
nextPixelsPerSecond: 20,
|
||||
duration: 120,
|
||||
@@ -601,28 +908,47 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
|
||||
});
|
||||
|
||||
describe("getTimelinePlayheadLeft", () => {
|
||||
it("offsets the wrapper by half the head width so the line CENTER = GUTTER + TRACKS_LEFT_PAD + t*pps", () => {
|
||||
it("offsets the wrapper by half the head width so the line CENTER = contentOrigin + t*pps", () => {
|
||||
// Wrapper left + PLAYHEAD_HEAD_W/2 (where the 1px line is centered) must
|
||||
// equal GUTTER + TRACKS_LEFT_PAD + t*pps at any zoom.
|
||||
expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe(
|
||||
// equal contentOrigin + t*pps at any zoom, for both the padded default
|
||||
// origin and the plain gutter origin.
|
||||
expect(getTimelinePlayheadLeft(4, 20, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe(
|
||||
GUTTER + TRACKS_LEFT_PAD + 4 * 20,
|
||||
);
|
||||
expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe(
|
||||
expect(getTimelinePlayheadLeft(10, 7.5, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe(
|
||||
GUTTER + TRACKS_LEFT_PAD + 75,
|
||||
);
|
||||
expect(getTimelinePlayheadLeft(4, 20, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 4 * 20);
|
||||
expect(getTimelinePlayheadLeft(10, 7.5, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 75);
|
||||
});
|
||||
|
||||
it("uses the expanded keyframe label column as the playhead origin", () => {
|
||||
expect(getTimelinePlayheadLeft(4, 20, LABEL_COL_W) + PLAYHEAD_HEAD_W / 2).toBe(
|
||||
LABEL_COL_W + 4 * 20,
|
||||
);
|
||||
});
|
||||
|
||||
it("centers the line exactly on the left pad's end (the 00:00 tick) at t = 0", () => {
|
||||
expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + TRACKS_LEFT_PAD);
|
||||
expect(getTimelinePlayheadLeft(0, 20, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe(
|
||||
GUTTER + TRACKS_LEFT_PAD,
|
||||
);
|
||||
});
|
||||
|
||||
it("centers the line exactly on the gutter (the 00:00 tick) at t = 0", () => {
|
||||
expect(getTimelinePlayheadLeft(0, 20, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER);
|
||||
});
|
||||
|
||||
it("guards invalid input", () => {
|
||||
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(
|
||||
expect(getTimelinePlayheadLeft(Number.NaN, 20, GUTTER + TRACKS_LEFT_PAD)).toBe(
|
||||
GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2,
|
||||
);
|
||||
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(
|
||||
expect(getTimelinePlayheadLeft(4, Number.NaN, GUTTER + TRACKS_LEFT_PAD)).toBe(
|
||||
GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2,
|
||||
);
|
||||
expect(getTimelinePlayheadLeft(Number.NaN, 20, GUTTER)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
|
||||
expect(getTimelinePlayheadLeft(4, Number.NaN, LABEL_COL_W)).toBe(
|
||||
LABEL_COL_W - PLAYHEAD_HEAD_W / 2,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -686,14 +1012,15 @@ describe("resolveTimelineAssetDrop", () => {
|
||||
rectTop: 200,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
contentOrigin: GUTTER,
|
||||
pixelsPerSecond: 100,
|
||||
duration: 10,
|
||||
trackHeight: 72,
|
||||
trackOrder: [0, 3, 7],
|
||||
},
|
||||
480, // rectLeft(100) + GUTTER + TRACKS_LEFT_PAD + 3s*100pps
|
||||
// clientY updated for TRACKS_TOP_PAD=72: rectTop(200) + RULER_H(24) +
|
||||
// TRACKS_TOP_PAD(72) + TRACK_H(48) + TRACK_H/2(24) = 368 → row 1 → track 3.
|
||||
432, // rectLeft(100) + GUTTER(32) + 3s*100pps (contentOrigin = GUTTER)
|
||||
// clientY: rectTop(200) + RULER_H(24) + TRACKS_TOP_PAD(72) + TRACK_H(48)
|
||||
// + TRACK_H/2(24) = 368 → row 1 → track 3.
|
||||
368,
|
||||
),
|
||||
).toEqual({ start: 3, track: 3 });
|
||||
@@ -707,12 +1034,13 @@ describe("resolveTimelineAssetDrop", () => {
|
||||
rectTop: 200,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
contentOrigin: GUTTER,
|
||||
pixelsPerSecond: 100,
|
||||
duration: 10,
|
||||
trackHeight: 72,
|
||||
trackOrder: [0, 3, 7],
|
||||
},
|
||||
250 + TRACKS_LEFT_PAD,
|
||||
250, // rectLeft(100) + GUTTER(32) + 1.18s*100pps (contentOrigin = GUTTER)
|
||||
600,
|
||||
),
|
||||
).toEqual({ start: 1.18, track: 8 });
|
||||
|
||||
@@ -9,7 +9,6 @@ import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
||||
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
||||
import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
||||
import { getTrackStyle } from "./timelineIcons";
|
||||
import { useTimelineZoom } from "./useTimelineZoom";
|
||||
import { useTimelineAssetDrop } from "./timelineDragDrop";
|
||||
import { TimelineEmptyState } from "./TimelineEmptyState";
|
||||
@@ -17,19 +16,27 @@ import { TimelineCanvas } from "./TimelineCanvas";
|
||||
import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
||||
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||
import { TimelineOverlays } from "./TimelineOverlays";
|
||||
import { animationContributesLane } from "./TimelinePropertyLanes";
|
||||
import { useTimelineEditPinning } from "./useTimelineEditPinning";
|
||||
import { useTimelineStackingSync } from "./useTimelineStackingSync";
|
||||
import { useTimelineGeometry } from "./useTimelineGeometry";
|
||||
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
||||
import { GUTTER, TRACKS_LEFT_PAD, generateTicks, getTimelineCanvasHeight } from "./timelineLayout";
|
||||
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
|
||||
import { GUTTER, LABEL_COL_W, generateTicks } from "./timelineLayout";
|
||||
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
|
||||
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
||||
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
||||
import type { TimelineProps } from "./TimelineTypes";
|
||||
import {
|
||||
getTrackStyle,
|
||||
useTimelineDisplayLayout,
|
||||
useTimelineTrackLayout,
|
||||
} from "./useTimelineTrackLayout";
|
||||
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
|
||||
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
||||
import { useTrackGapMenu } from "./useTrackGapMenu";
|
||||
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
||||
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
|
||||
import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext";
|
||||
import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
|
||||
|
||||
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
||||
export {
|
||||
@@ -106,6 +113,22 @@ export const Timeline = memo(function Timeline({
|
||||
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
||||
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
|
||||
// Label mode = comp has keyframed clips (not just when expanded): keeps the layer
|
||||
// disclosure + property column visible and reserves a GUTTER before 0s (Figma).
|
||||
const hasKeyframedClips = useMemo(
|
||||
() =>
|
||||
Array.from(gsapAnimations.values()).some((list) =>
|
||||
// Same lane-contribution predicate the layout uses: real keyframes OR a
|
||||
// synthesizable flat tween. Checking animation.keyframes alone left a
|
||||
// flat-tween-only comp without its reserved label column.
|
||||
list.some((animation) => animationContributesLane(animation)),
|
||||
),
|
||||
[gsapAnimations],
|
||||
);
|
||||
const labelMode = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips;
|
||||
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER;
|
||||
const contentGutter = labelMode ? GUTTER : 0;
|
||||
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
||||
const currentTime = usePlayerStore((s) => s.currentTime);
|
||||
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
||||
@@ -117,7 +140,6 @@ export const Timeline = memo(function Timeline({
|
||||
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const [shiftHeld, setShiftHeld] = useState(false);
|
||||
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
|
||||
|
||||
useMountEffect(() => {
|
||||
const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown");
|
||||
@@ -154,9 +176,10 @@ export const Timeline = memo(function Timeline({
|
||||
return Number.isFinite(result) ? result : safeDur;
|
||||
}, [rawElements, duration]);
|
||||
|
||||
const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
|
||||
const trackOrderRef = useRef(trackOrder);
|
||||
trackOrderRef.current = trackOrder;
|
||||
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
|
||||
useAutoExpandKeyframedClips(gsapAnimations);
|
||||
const { tracks, trackStyles, trackOrder, trackOrderRef, laneCounts, rowHeights, rowHeightsRef } =
|
||||
useTimelineTrackLayout(expandedElements, gsapAnimations, selectedElementId, selectedElementIds);
|
||||
const expandedElementsRef = useRef(expandedElements);
|
||||
expandedElementsRef.current = expandedElements;
|
||||
|
||||
@@ -223,6 +246,7 @@ export const Timeline = memo(function Timeline({
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
onMoveElement: pinnedOnMoveElement,
|
||||
onMoveElements: pinnedOnMoveElements,
|
||||
onResizeElement: pinnedOnResizeElement,
|
||||
@@ -241,27 +265,22 @@ export const Timeline = memo(function Timeline({
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
contentOrigin,
|
||||
onFileDrop: pinnedOnFileDrop,
|
||||
onAssetDrop: pinnedOnAssetDrop,
|
||||
onBlockDrop: pinnedOnBlockDrop,
|
||||
onCompositionDrop: pinnedOnCompositionDrop,
|
||||
});
|
||||
|
||||
const displayTrackOrder = useMemo(() => {
|
||||
if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
|
||||
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
||||
}, [draggedClip, trackOrder]);
|
||||
|
||||
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
||||
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights);
|
||||
const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
|
||||
timelineReady,
|
||||
expandedElements.length,
|
||||
totalH,
|
||||
displayLayout.totalH,
|
||||
]);
|
||||
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
|
||||
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
|
||||
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
|
||||
|
||||
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
|
||||
useTimelineKeyframeHandlers({
|
||||
expandedElements,
|
||||
@@ -302,6 +321,7 @@ export const Timeline = memo(function Timeline({
|
||||
isDragging,
|
||||
scrollRef,
|
||||
lastScrollLeftRef,
|
||||
contentOrigin,
|
||||
});
|
||||
|
||||
const laneGapStrips = useTimelineGapHighlights({
|
||||
@@ -334,12 +354,21 @@ export const Timeline = memo(function Timeline({
|
||||
setZoomMode,
|
||||
setManualZoomPercent,
|
||||
onSeek,
|
||||
contentOrigin,
|
||||
});
|
||||
useTimelineActiveClips({
|
||||
scrollRef,
|
||||
currentTime,
|
||||
clipStateVersion,
|
||||
});
|
||||
const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } =
|
||||
useTimelineRazorInteraction({
|
||||
active: activeTool === "razor",
|
||||
scrollRef,
|
||||
contentOrigin,
|
||||
pixelsPerSecond: pps,
|
||||
onSplitAll: onRazorSplitAll,
|
||||
});
|
||||
|
||||
const {
|
||||
rangeSelection,
|
||||
@@ -363,7 +392,9 @@ export const Timeline = memo(function Timeline({
|
||||
setShowPopover,
|
||||
elementsRef: expandedElementsRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
onSelectElement,
|
||||
contentOrigin,
|
||||
});
|
||||
setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
|
||||
|
||||
@@ -422,13 +453,8 @@ export const Timeline = memo(function Timeline({
|
||||
ref={setContainerRef}
|
||||
aria-label="Timeline"
|
||||
className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
||||
onMouseMove={(e) => {
|
||||
if (activeTool === "razor" && scrollRef.current) {
|
||||
const rect = scrollRef.current.getBoundingClientRect();
|
||||
setRazorGuideX(e.clientX - rect.left + scrollRef.current.scrollLeft);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setRazorGuideX(null)}
|
||||
onMouseMove={updateRazorGuide}
|
||||
onMouseLeave={clearRazorGuide}
|
||||
style={{
|
||||
touchAction: "pan-x pan-y",
|
||||
background: theme.shellBackground,
|
||||
@@ -449,14 +475,7 @@ export const Timeline = memo(function Timeline({
|
||||
// Let interactive controls (keyframe nav/toggle, caret, inputs) handle
|
||||
// their own clicks — scrubbing here would preventDefault and eat them.
|
||||
if (e.target instanceof Element && e.target.closest("button, input, select, a")) return;
|
||||
if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) {
|
||||
const rect = scrollRef.current.getBoundingClientRect();
|
||||
const x =
|
||||
e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
||||
const splitTime = Math.max(0, x / pps);
|
||||
onRazorSplitAll?.(splitTime);
|
||||
return;
|
||||
}
|
||||
if (splitAllAtPointer(e)) return;
|
||||
handlePointerDown(e);
|
||||
}}
|
||||
onPointerMove={handlePointerMove}
|
||||
@@ -467,18 +486,22 @@ export const Timeline = memo(function Timeline({
|
||||
major={major}
|
||||
minor={minor}
|
||||
pps={pps}
|
||||
contentOrigin={contentOrigin}
|
||||
contentGutter={contentGutter}
|
||||
trackContentWidth={displayContentWidth}
|
||||
totalH={totalH}
|
||||
totalH={displayLayout.totalH}
|
||||
effectiveDuration={effectiveDuration}
|
||||
majorTickInterval={majorTickInterval}
|
||||
rangeSelection={rangeSelection}
|
||||
marqueeRect={marqueeRect}
|
||||
laneGapStrips={laneGapStrips}
|
||||
theme={theme}
|
||||
displayTrackOrder={displayTrackOrder}
|
||||
displayTrackOrder={displayLayout.displayTrackOrder}
|
||||
rowHeights={displayLayout.displayRowHeights}
|
||||
trackOrder={trackOrder}
|
||||
tracks={tracks}
|
||||
trackStyles={trackStyles}
|
||||
laneCounts={laneCounts}
|
||||
selectedElementId={selectedElementId}
|
||||
selectedElementIds={selectedElementIds}
|
||||
hoveredClip={hoveredClip}
|
||||
@@ -504,11 +527,13 @@ export const Timeline = memo(function Timeline({
|
||||
getPreviewElement={getPreviewElement}
|
||||
getTrackStyle={getTrackStyle}
|
||||
keyframeCache={keyframeCache}
|
||||
gsapAnimations={gsapAnimations}
|
||||
selectedKeyframes={selectedKeyframes}
|
||||
currentTime={currentTime}
|
||||
onSeek={onSeek}
|
||||
beatAnalysis={adjustedBeatAnalysis}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
onSelectSegment={onSelectSegment}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
onShiftClickKeyframe={onShiftClickKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
onContextMenuKeyframe={onContextMenuKeyframe}
|
||||
@@ -525,16 +550,7 @@ export const Timeline = memo(function Timeline({
|
||||
openGapMenu({ x: e.clientX, y: e.clientY, track, time });
|
||||
}}
|
||||
/>
|
||||
{activeTool === "razor" && razorGuideX !== null && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 pointer-events-none z-10"
|
||||
style={{
|
||||
left: razorGuideX,
|
||||
width: 1,
|
||||
background: "rgba(239,68,68,0.7)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeTool === "razor" && razorGuideX !== null && <TimelineRazorGuide x={razorGuideX} />}
|
||||
</div>
|
||||
<TimelineOverlays
|
||||
theme={theme}
|
||||
|
||||
@@ -4,16 +4,15 @@ import { PlayheadIndicator } from "./PlayheadIndicator";
|
||||
import { getTimelineEditCapabilities, type TimelineRangeSelection } from "./timelineEditing";
|
||||
import { getRenderedTimelineElement } from "./timelineTheme";
|
||||
import {
|
||||
GUTTER,
|
||||
TRACK_H,
|
||||
RULER_H,
|
||||
CLIP_Y,
|
||||
TRACKS_TOP_PAD,
|
||||
TRACKS_BOTTOM_PAD,
|
||||
TRACKS_LEFT_PAD,
|
||||
TRACK_H,
|
||||
PLAYHEAD_HEAD_W,
|
||||
getTimelinePlayheadLeft,
|
||||
getTimelineRowTop,
|
||||
getTimelineRowHeight,
|
||||
} from "./timelineLayout";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import type { ResizingClipState } from "./useTimelineClipDrag";
|
||||
@@ -45,8 +44,17 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps {
|
||||
|
||||
export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) {
|
||||
const { draggedClip, scrollRef, selectedElementIds, displayTrackOrder } = props;
|
||||
const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } =
|
||||
useTimelineEditContextOptional();
|
||||
const draggedRowIndex =
|
||||
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
|
||||
const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
|
||||
const {
|
||||
onResizeElement,
|
||||
onMoveElement,
|
||||
onToggleTrackHidden,
|
||||
onTogglePropertyGroupKeyframe,
|
||||
onRazorSplit,
|
||||
onRazorSplitAll,
|
||||
} = useTimelineEditContextOptional();
|
||||
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
||||
// Scroll a clip into view when the sidebar (asset card) requests a reveal.
|
||||
useTimelineRevealClip(scrollRef);
|
||||
@@ -63,8 +71,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
// The drag ghost follows the cursor freely (both axes) — CapCut-style. The
|
||||
// "magnetic" affordance is a highlight on the destination lane (draggedRowIndex),
|
||||
// which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it.
|
||||
const draggedRowIndex =
|
||||
draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
|
||||
// Live multi-selection drag: while a selected clip is dragged, ALL selected
|
||||
// clips move together as one rigid formation. The GRABBED clip is the free
|
||||
// ghost below; its co-selected "passengers" slide by the SAME group-clamped
|
||||
@@ -101,7 +107,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: props.totalH, width: GUTTER + TRACKS_LEFT_PAD + props.trackContentWidth }}
|
||||
style={{ height: props.totalH, width: props.contentOrigin + props.trackContentWidth }}
|
||||
>
|
||||
<TimelineRuler
|
||||
major={props.major}
|
||||
@@ -113,6 +119,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
majorTickInterval={props.majorTickInterval}
|
||||
theme={props.theme}
|
||||
beatAnalysis={props.beatAnalysis}
|
||||
contentOrigin={props.contentOrigin}
|
||||
/>
|
||||
|
||||
{/* Breathing room between the sticky ruler and the first track lane — the
|
||||
@@ -124,6 +131,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
draggedElement={draggedElement}
|
||||
multiDragPreview={multiDragPreview}
|
||||
onToggleTrackHidden={onToggleTrackHidden}
|
||||
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
||||
onResizeElement={onResizeElement}
|
||||
onMoveElement={onMoveElement}
|
||||
onRazorSplit={onRazorSplit}
|
||||
@@ -148,8 +156,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
top: getTimelineRowTop(rowIndex) + CLIP_Y,
|
||||
left: GUTTER + TRACKS_LEFT_PAD + gap.start * props.pps,
|
||||
top: getTimelineRowTop(rowIndex, props.rowHeights) + CLIP_Y,
|
||||
left: props.contentOrigin + gap.start * props.pps,
|
||||
width: Math.max((gap.end - gap.start) * props.pps, 2),
|
||||
height: TRACK_H - CLIP_Y * 2,
|
||||
background: loud ? "rgba(60,230,172,0.18)" : "rgba(60,230,172,0.055)",
|
||||
@@ -166,10 +174,10 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
top: getTimelineRowTop(draggedRowIndex) + CLIP_Y,
|
||||
left: GUTTER + TRACKS_LEFT_PAD + draggedClip.previewStart * props.pps,
|
||||
top: getTimelineRowTop(draggedRowIndex, props.rowHeights) + CLIP_Y,
|
||||
left: props.contentOrigin + draggedClip.previewStart * props.pps,
|
||||
width: Math.max(draggedClip.element.duration * props.pps, 4),
|
||||
height: TRACK_H - CLIP_Y * 2,
|
||||
height: draggedRowHeight - CLIP_Y * 2,
|
||||
border: "1px solid rgba(60,230,172,0.55)",
|
||||
background: "rgba(60,230,172,0.12)",
|
||||
borderRadius: 4,
|
||||
@@ -184,8 +192,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
top: getTimelineRowTop(draggedClip.insertRow) - 0.5,
|
||||
left: GUTTER + TRACKS_LEFT_PAD,
|
||||
top: getTimelineRowTop(draggedClip.insertRow, props.rowHeights) - 0.5,
|
||||
left: props.contentOrigin,
|
||||
width: props.trackContentWidth,
|
||||
height: 1,
|
||||
background: "#3CE6AC",
|
||||
@@ -200,7 +208,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: GUTTER + TRACKS_LEFT_PAD + draggedClip.snapTime * props.pps,
|
||||
left: props.contentOrigin + draggedClip.snapTime * props.pps,
|
||||
top: RULER_H,
|
||||
bottom: 0,
|
||||
width: 1,
|
||||
@@ -222,7 +230,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
top: activeDraggedPosition.top,
|
||||
left: activeDraggedPosition.left,
|
||||
width: Math.max(activeDraggedElement.duration * props.pps, 4),
|
||||
height: TRACK_H - CLIP_Y * 2,
|
||||
height: draggedRowHeight - CLIP_Y * 2,
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
@@ -280,8 +288,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left:
|
||||
GUTTER +
|
||||
TRACKS_LEFT_PAD +
|
||||
props.contentOrigin +
|
||||
Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps,
|
||||
width: Math.abs(props.rangeSelection.end - props.rangeSelection.start) * props.pps,
|
||||
top: RULER_H,
|
||||
@@ -297,13 +304,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
||||
{/* Playhead — hidden while dragging a beat so its guideline doesn't
|
||||
track the scrub and clutter the beat being moved. Explicit width +
|
||||
the half-head offset baked into getTimelinePlayheadLeft keep the
|
||||
inner 1px line's CENTER exactly on GUTTER + t * pps (the ruler
|
||||
inner 1px line's CENTER exactly on contentOrigin + t * pps (the ruler
|
||||
ticks' center), instead of relying on shrink-wrap sizing. */}
|
||||
<div
|
||||
ref={props.playheadRef}
|
||||
className="absolute top-0 bottom-0 pointer-events-none"
|
||||
style={{
|
||||
left: `${getTimelinePlayheadLeft(0, 0)}px`,
|
||||
left: `${getTimelinePlayheadLeft(0, 0, props.contentOrigin)}px`,
|
||||
width: PLAYHEAD_HEAD_W,
|
||||
zIndex: 100,
|
||||
display: beatDragging ? "none" : undefined,
|
||||
|
||||
@@ -8,6 +8,7 @@ interface TimelineClipProps {
|
||||
el: TimelineElement;
|
||||
pps: number;
|
||||
clipY: number;
|
||||
clipHeight?: number;
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
isDragging?: boolean;
|
||||
@@ -30,6 +31,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
el,
|
||||
pps,
|
||||
clipY,
|
||||
clipHeight,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isDragging = false,
|
||||
@@ -71,7 +73,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
left: leftPx,
|
||||
width: widthPx,
|
||||
top: clipY,
|
||||
bottom: clipY,
|
||||
...(clipHeight === undefined ? { bottom: clipY } : { height: clipHeight }),
|
||||
borderRadius: theme.clipRadius,
|
||||
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
|
||||
// Regular cursor over clips (CapCut-style, user preference) — no grab hand.
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { getTimelineEditCapabilities } from "./timelineEditing";
|
||||
import { CLIP_Y, TRACK_H } from "./timelineLayout";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
|
||||
interface TimelineDragGhostProps {
|
||||
element: TimelineElement;
|
||||
position: { left: number; top: number };
|
||||
pps: number;
|
||||
selectedElementId: string | null;
|
||||
hasCustomContent: boolean;
|
||||
theme: TimelineTheme;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function TimelineDragGhost({
|
||||
element,
|
||||
position,
|
||||
pps,
|
||||
selectedElementId,
|
||||
hasCustomContent,
|
||||
theme,
|
||||
children,
|
||||
}: TimelineDragGhostProps) {
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: Math.max(element.duration * pps, 4),
|
||||
height: TRACK_H - CLIP_Y * 2,
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
<TimelineClip
|
||||
el={{ ...element, start: 0 }}
|
||||
pps={pps}
|
||||
clipY={0}
|
||||
isSelected={selectedElementId === (element.key ?? element.id)}
|
||||
isHovered={false}
|
||||
isDragging={true}
|
||||
hasCustomContent={hasCustomContent}
|
||||
capabilities={getTimelineEditCapabilities(element)}
|
||||
theme={theme}
|
||||
isComposition={!!element.compositionSrc}
|
||||
onHoverStart={() => {}}
|
||||
onHoverEnd={() => {}}
|
||||
onResizeStart={() => {}}
|
||||
onClick={() => {}}
|
||||
onDoubleClick={() => {}}
|
||||
>
|
||||
{children}
|
||||
</TimelineClip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
||||
import { TimelineClip } from "./TimelineClip";
|
||||
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
||||
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
||||
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
|
||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
||||
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import { GUTTER, TRACK_H, TRACKS_LEFT_PAD, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
|
||||
import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout";
|
||||
import {
|
||||
usePlayerStore,
|
||||
type TimelineElement,
|
||||
@@ -22,9 +25,9 @@ import {
|
||||
import type { TrackVisualStyle } from "./timelineIcons";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
||||
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
|
||||
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
||||
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
||||
import { Music } from "../../icons/SystemIcons";
|
||||
import { renderClipChildren } from "./timelineClipChildren";
|
||||
|
||||
/**
|
||||
@@ -35,12 +38,16 @@ import { renderClipChildren } from "./timelineClipChildren";
|
||||
*/
|
||||
export interface TimelineLaneBaseProps {
|
||||
pps: number;
|
||||
contentOrigin: number;
|
||||
contentGutter: number;
|
||||
trackContentWidth: number;
|
||||
theme: TimelineTheme;
|
||||
displayTrackOrder: number[];
|
||||
rowHeights: readonly number[];
|
||||
trackOrder: number[];
|
||||
tracks: [number, TimelineElement[]][];
|
||||
trackStyles: Map<number, TrackVisualStyle>;
|
||||
laneCounts: ReadonlyMap<string, number>;
|
||||
selectedElementId: string | null;
|
||||
selectedElementIds: Set<string>;
|
||||
hoveredClip: string | null;
|
||||
@@ -70,21 +77,25 @@ export interface TimelineLaneBaseProps {
|
||||
getPreviewElement: (element: TimelineElement) => TimelineElement;
|
||||
getTrackStyle: (tag: string) => TrackVisualStyle;
|
||||
keyframeCache?: Map<string, KeyframeCacheEntry>;
|
||||
gsapAnimations: Map<string, GsapAnimation[]>;
|
||||
selectedKeyframes: Set<string>;
|
||||
currentTime: number;
|
||||
onClickKeyframe?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
|
||||
onShiftClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
||||
/** Click on the segment BETWEEN two diamonds: selects it to edit its ease. */
|
||||
onSelectSegment?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
|
||||
onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void;
|
||||
onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void;
|
||||
onContextMenuKeyframe?: (
|
||||
e: React.MouseEvent,
|
||||
elementId: string,
|
||||
keyframe: TimelineKeyframeTarget,
|
||||
target: TimelineKeyframeTarget,
|
||||
) => void;
|
||||
onMoveKeyframe?: (
|
||||
elementId: string,
|
||||
keyframe: TimelineKeyframeTarget,
|
||||
toClipPercentage: number,
|
||||
propertyGroup?: string,
|
||||
tweenPercentage?: number,
|
||||
animationId?: string,
|
||||
) => Promise<boolean>;
|
||||
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
||||
/**
|
||||
@@ -101,6 +112,7 @@ interface TimelineLanesProps extends TimelineLaneBaseProps {
|
||||
draggedElement: TimelineElement | null;
|
||||
multiDragPreview: MultiDragPreviewInput | null;
|
||||
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
||||
onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
||||
onResizeElement: TimelineEditCallbacks["onResizeElement"];
|
||||
onMoveElement: TimelineEditCallbacks["onMoveElement"];
|
||||
onRazorSplit: TimelineEditCallbacks["onRazorSplit"];
|
||||
@@ -109,12 +121,16 @@ interface TimelineLanesProps extends TimelineLaneBaseProps {
|
||||
|
||||
export function TimelineLanes({
|
||||
pps,
|
||||
contentOrigin,
|
||||
contentGutter,
|
||||
trackContentWidth,
|
||||
theme,
|
||||
displayTrackOrder,
|
||||
rowHeights,
|
||||
trackOrder,
|
||||
tracks,
|
||||
trackStyles,
|
||||
laneCounts,
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
hoveredClip,
|
||||
@@ -139,22 +155,32 @@ export function TimelineLanes({
|
||||
getPreviewElement,
|
||||
getTrackStyle,
|
||||
keyframeCache,
|
||||
gsapAnimations,
|
||||
selectedKeyframes,
|
||||
currentTime,
|
||||
onSeek,
|
||||
onSelectSegment,
|
||||
onClickKeyframe,
|
||||
onShiftClickKeyframe,
|
||||
onSelectSegment,
|
||||
onContextMenuKeyframe,
|
||||
onMoveKeyframe,
|
||||
onContextMenuClip,
|
||||
onContextMenuLane,
|
||||
beatAnalysis,
|
||||
onToggleTrackHidden,
|
||||
onTogglePropertyGroupKeyframe,
|
||||
onResizeElement,
|
||||
onMoveElement,
|
||||
onRazorSplit,
|
||||
onRazorSplitAll,
|
||||
}: TimelineLanesProps) {
|
||||
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
||||
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
|
||||
const toggleClipExpandedTracked = (key: string) => {
|
||||
const willExpand = !expandedClipIds.has(key);
|
||||
trackStudioKeyframeLaneExpand({ expanded: willExpand });
|
||||
toggleClipExpanded(key);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{
|
||||
@@ -164,7 +190,8 @@ export function TimelineLanes({
|
||||
// bounded and virtualization's complexity isn't worth it. TODO: revisit and swap
|
||||
// in a virtualizer if editorial workflows ever push very high clip counts.
|
||||
// fallow-ignore-next-line complexity
|
||||
displayTrackOrder.map((trackNum) => {
|
||||
displayTrackOrder.map((trackNum, row) => {
|
||||
const rowHeight = getTimelineRowHeight(row, rowHeights);
|
||||
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
|
||||
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
||||
const isPendingTrack =
|
||||
@@ -181,58 +208,55 @@ export function TimelineLanes({
|
||||
: els.some(isMusicTrack));
|
||||
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
|
||||
const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
|
||||
// The one keyframed element this track shows lanes for (selected, else
|
||||
// most lanes). A track can hold several elements; scoping to one keeps
|
||||
// their keyframes from cramming into a single row.
|
||||
const keyframeClip = STUDIO_KEYFRAMES_ENABLED
|
||||
? resolveTrackKeyframeClip(els, laneCounts, selectedElementId, selectedElementIds)
|
||||
: null;
|
||||
const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
|
||||
const keyframeClipExpanded =
|
||||
keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
|
||||
return (
|
||||
<div key={trackNum} className="relative flex" style={{ height: TRACK_H }}>
|
||||
<div
|
||||
className="sticky left-0 z-[12] flex-shrink-0 flex flex-col items-center justify-center gap-0.5"
|
||||
style={{
|
||||
width: GUTTER,
|
||||
background: theme.gutterBackground,
|
||||
borderRight: `1px solid ${theme.gutterBorder}`,
|
||||
borderBottom: `1px solid ${theme.rowBorder}`,
|
||||
<div
|
||||
key={trackNum}
|
||||
className="relative flex"
|
||||
style={{
|
||||
height: rowHeight,
|
||||
background: rowBackground,
|
||||
borderBottom: `1px solid ${theme.rowBorder}`,
|
||||
}}
|
||||
>
|
||||
<TimelineTrackHeader
|
||||
trackNumber={trackNum}
|
||||
trackLabel={els[0]?.label ?? els[0]?.domId ?? els[0]?.id ?? `Track ${trackNum}`}
|
||||
contentOrigin={contentOrigin}
|
||||
keyframeClip={keyframeClip}
|
||||
clipCount={els.length}
|
||||
isExpanded={keyframeClipExpanded}
|
||||
animations={keyframeClipKey ? (gsapAnimations.get(keyframeClipKey) ?? []) : []}
|
||||
currentTime={currentTime}
|
||||
isTrackHidden={isTrackHidden}
|
||||
isAudioTrack={isAudioTrack}
|
||||
isActive={
|
||||
keyframeClipKey != null &&
|
||||
(selectedElementId === keyframeClipKey || selectedElementIds.has(keyframeClipKey))
|
||||
}
|
||||
isHovered={keyframeClipKey != null && hoveredClip === keyframeClipKey}
|
||||
theme={theme}
|
||||
onToggleClipExpanded={() => {
|
||||
if (keyframeClipKey) {
|
||||
toggleClipExpandedTracked(keyframeClipKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isAudioTrack && (
|
||||
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
|
||||
)}
|
||||
<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>
|
||||
{/* Left breathing pad — empty lane surface before t=0, scrolling
|
||||
with the content (the horizontal TRACKS_TOP_PAD). Sits OUTSIDE
|
||||
the time-mapped content div so clip/beat/menu math stays
|
||||
content-relative (clip left = t·pps). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0"
|
||||
style={{ width: TRACKS_LEFT_PAD }}
|
||||
onToggleTrackHidden={onToggleTrackHidden}
|
||||
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: trackContentWidth,
|
||||
background: rowBackground,
|
||||
borderBottom: `1px solid ${theme.rowBorder}`,
|
||||
marginLeft: contentGutter, // room for a 0% diamond left of t=0
|
||||
opacity: isTrackHidden ? 0.35 : 1,
|
||||
transition: "opacity 120ms ease",
|
||||
}}
|
||||
@@ -290,6 +314,13 @@ export function TimelineLanes({
|
||||
els.map((el) => {
|
||||
const clipStyle = getTrackStyle(el.tag);
|
||||
const elementKey = el.key ?? el.id;
|
||||
// Only the track's active keyframe clip shows expanded lanes;
|
||||
// other clips (incl. siblings on a shared track) show compact
|
||||
// diamonds on their own bar instead.
|
||||
const showsLanes =
|
||||
STUDIO_KEYFRAMES_ENABLED &&
|
||||
elementKey === keyframeClipKey &&
|
||||
keyframeClipExpanded;
|
||||
const capabilities = getTimelineEditCapabilities(el);
|
||||
const isSelected =
|
||||
selectedElementId === elementKey || selectedElementIds.has(elementKey);
|
||||
@@ -323,6 +354,7 @@ export function TimelineLanes({
|
||||
el={previewElement}
|
||||
pps={pps}
|
||||
clipY={CLIP_Y}
|
||||
clipHeight={showsLanes ? TRACK_H - 2 * CLIP_Y : undefined}
|
||||
isSelected={isSelected}
|
||||
isHovered={hoveredClip === clipKey}
|
||||
isDragging={false}
|
||||
@@ -465,35 +497,70 @@ export function TimelineLanes({
|
||||
renderClipContent,
|
||||
renderClipOverlay,
|
||||
)}
|
||||
{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={(_elId, keyframe) =>
|
||||
onClickKeyframe?.(previewElement, keyframe)
|
||||
}
|
||||
onShiftClickKeyframe={onShiftClickKeyframe}
|
||||
onSelectSegment={onSelectSegment}
|
||||
onContextMenuKeyframe={onContextMenuKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>
|
||||
)}
|
||||
{STUDIO_KEYFRAMES_ENABLED &&
|
||||
!showsLanes &&
|
||||
keyframeCache?.get(elementKey) && (
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={keyframeCache.get(elementKey)!}
|
||||
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
||||
clipHeightPx={rowHeight - 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={(_elId, target) =>
|
||||
onClickKeyframe?.(previewElement, target)
|
||||
}
|
||||
onShiftClickKeyframe={onShiftClickKeyframe}
|
||||
onContextMenuKeyframe={onContextMenuKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
onSelectSegment={onSelectSegment}
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>
|
||||
)}
|
||||
</TimelineClip>
|
||||
);
|
||||
if (!isPassenger) return clip;
|
||||
const propertyLanes = showsLanes && (
|
||||
<TimelinePropertyLanes
|
||||
key={`${clipKey}-property-lanes`}
|
||||
animations={gsapAnimations.get(elementKey) ?? []}
|
||||
clipStart={previewElement.start}
|
||||
clipDuration={previewElement.duration}
|
||||
clipLeftPx={previewElement.start * pps}
|
||||
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
||||
accentColor={clipStyle.accent}
|
||||
isSelected={isSelected}
|
||||
currentPercentage={
|
||||
previewElement.duration > 0
|
||||
? ((currentTime - previewElement.start) / previewElement.duration) * 100
|
||||
: 0
|
||||
}
|
||||
elementId={elementKey}
|
||||
selectedKeyframes={selectedKeyframes}
|
||||
onSelectSegment={(target) => onSelectSegment?.(elementKey, target)}
|
||||
onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)}
|
||||
onShiftClickKeyframe={(target) =>
|
||||
onShiftClickKeyframe?.(elementKey, target)
|
||||
}
|
||||
onContextMenuKeyframe={(e, target) =>
|
||||
onContextMenuKeyframe?.(e, elementKey, target)
|
||||
}
|
||||
onMoveKeyframe={(target, toClipPercentage) =>
|
||||
onMoveKeyframe?.(elementKey, target, toClipPercentage) ??
|
||||
Promise.resolve(false)
|
||||
}
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>
|
||||
);
|
||||
if (!isPassenger) return [clip, propertyLanes];
|
||||
return (
|
||||
<div
|
||||
key={clipKey}
|
||||
@@ -506,6 +573,7 @@ export function TimelineLanes({
|
||||
}}
|
||||
>
|
||||
{clip}
|
||||
{propertyLanes}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -102,12 +102,10 @@ export function TimelineOverlays({
|
||||
<KeyframeDiamondContextMenu
|
||||
state={kfContextMenu}
|
||||
onClose={() => setKfContextMenu(null)}
|
||||
onDelete={(elId, target) => onDeleteKeyframe?.(elId, target)}
|
||||
onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)}
|
||||
onDelete={(elId, keyframe) => onDeleteKeyframe?.(elId, keyframe)}
|
||||
onDeleteAll={(element) => onDeleteAllKeyframes?.(element)}
|
||||
onMoveToPlayhead={
|
||||
onMoveKeyframeToPlayhead
|
||||
? (element, target) => onMoveKeyframeToPlayhead(element, target)
|
||||
: undefined
|
||||
onMoveKeyframeToPlayhead ? (...args) => onMoveKeyframeToPlayhead(...args) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useState, type MouseEvent, type PointerEvent, type RefObject } from "react";
|
||||
import { getTimelineContentXFromClient } from "./timelineLayout";
|
||||
|
||||
interface TimelineRazorInteractionOptions {
|
||||
active: boolean;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
contentOrigin: number;
|
||||
pixelsPerSecond: number;
|
||||
onSplitAll?: (time: number) => void;
|
||||
}
|
||||
|
||||
export function useTimelineRazorInteraction({
|
||||
active,
|
||||
scrollRef,
|
||||
contentOrigin,
|
||||
pixelsPerSecond,
|
||||
onSplitAll,
|
||||
}: TimelineRazorInteractionOptions) {
|
||||
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
|
||||
|
||||
const updateRazorGuide = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>) => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!active || !scroll) return;
|
||||
const rect = scroll.getBoundingClientRect();
|
||||
setRazorGuideX(event.clientX - rect.left + scroll.scrollLeft);
|
||||
},
|
||||
[active, scrollRef],
|
||||
);
|
||||
|
||||
const clearRazorGuide = useCallback(() => setRazorGuideX(null), []);
|
||||
|
||||
const splitAllAtPointer = useCallback(
|
||||
(event: PointerEvent<HTMLDivElement>) => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!active || !event.shiftKey || event.button !== 0 || !scroll) return false;
|
||||
const rect = scroll.getBoundingClientRect();
|
||||
const x = getTimelineContentXFromClient({
|
||||
clientX: event.clientX,
|
||||
rectLeft: rect.left,
|
||||
scrollLeft: scroll.scrollLeft,
|
||||
contentOrigin,
|
||||
});
|
||||
onSplitAll?.(Math.max(0, x / pixelsPerSecond));
|
||||
return true;
|
||||
},
|
||||
[active, contentOrigin, onSplitAll, pixelsPerSecond, scrollRef],
|
||||
);
|
||||
|
||||
return { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer };
|
||||
}
|
||||
|
||||
export function TimelineRazorGuide({ x }: { x: number }) {
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 pointer-events-none z-10"
|
||||
style={{ left: x, width: 1, background: "rgba(239,68,68,0.7)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo } from "react";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import { GUTTER, RULER_H, TRACKS_LEFT_PAD, formatTimelineTickLabel } from "./timelineLayout";
|
||||
import { RULER_H, formatTimelineTickLabel } from "./timelineLayout";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { secondsToFrame } from "../lib/time";
|
||||
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
||||
@@ -15,6 +15,7 @@ interface TimelineRulerProps {
|
||||
majorTickInterval: number;
|
||||
theme: TimelineTheme;
|
||||
beatAnalysis?: MusicBeatAnalysis | null;
|
||||
contentOrigin: number;
|
||||
}
|
||||
|
||||
export const TimelineRuler = memo(function TimelineRuler({
|
||||
@@ -27,6 +28,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
majorTickInterval,
|
||||
theme,
|
||||
beatAnalysis,
|
||||
contentOrigin,
|
||||
}: TimelineRulerProps) {
|
||||
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
||||
const beatTimes = beatAnalysis?.beatTimes ?? [];
|
||||
@@ -45,7 +47,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
the ruler's own small ticks mark intervals (no full-height lines). */}
|
||||
<svg
|
||||
className="absolute pointer-events-none"
|
||||
style={{ left: GUTTER + TRACKS_LEFT_PAD, width: trackContentWidth, zIndex: 0 }}
|
||||
style={{ left: contentOrigin, width: trackContentWidth, zIndex: 0 }}
|
||||
height={totalH}
|
||||
>
|
||||
{showBeats &&
|
||||
@@ -69,35 +71,23 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
</svg>
|
||||
|
||||
{/* Ruler — sticky so the timestamps stay visible while the tracks scroll
|
||||
vertically. Opaque background (plus the gutter corner block) so clips
|
||||
vertically. Opaque background (plus the label-column corner block) so clips
|
||||
scrolling underneath don't bleed through; z-index sits above the track
|
||||
rows and drag overlays but below the playhead (z 100). */}
|
||||
<div
|
||||
className="sticky top-0 flex"
|
||||
style={{
|
||||
height: RULER_H,
|
||||
width: GUTTER + TRACKS_LEFT_PAD + trackContentWidth,
|
||||
zIndex: 70,
|
||||
}}
|
||||
style={{ height: RULER_H, width: contentOrigin + trackContentWidth, zIndex: 70 }}
|
||||
>
|
||||
<div
|
||||
className="sticky left-0 z-[12] flex-shrink-0"
|
||||
style={{
|
||||
width: GUTTER,
|
||||
// Ruler corner uses the panel surface — same as the ruler strip
|
||||
// itself, and NO right border: the ruler band stays completely
|
||||
// clean until 00:00 (the header-boundary line belongs to the track
|
||||
// rows below, not the ruler).
|
||||
width: contentOrigin,
|
||||
// Ruler corner uses the panel surface — same as the ruler strip itself.
|
||||
background: theme.shellBackground,
|
||||
}}
|
||||
/>
|
||||
{/* Left breathing pad — scrolls with the content, so 00:00 starts a
|
||||
beat right of the gutter (see TRACKS_LEFT_PAD). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0"
|
||||
style={{ width: TRACKS_LEFT_PAD, background: theme.shellBackground }}
|
||||
/>
|
||||
{/* Breathing pad before 00:00 is folded into contentOrigin (see
|
||||
Timeline.tsx: GUTTER + TRACKS_LEFT_PAD), so no separate pad div. */}
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
@@ -110,7 +100,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
||||
>
|
||||
{/* Each 1px tick line is shifted -0.5px so its CENTER sits exactly on
|
||||
t * pps — matching the playhead line, which is also centered on
|
||||
GUTTER + t * pps (see getTimelinePlayheadLeft). Without the shift
|
||||
contentOrigin + t * pps (see getTimelinePlayheadLeft). Without the shift
|
||||
a tick spans [x, x+1) and its center is half a pixel right. */}
|
||||
{minor.map((t) => (
|
||||
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
TIMELINE_COMPOSITION_MIME,
|
||||
} from "../../utils/timelineCompositionDrop";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
|
||||
import { resolveTimelineAssetDrop } from "./timelineLayout";
|
||||
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
||||
|
||||
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
||||
@@ -13,6 +13,8 @@ interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
||||
ppsRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
trackOrderRef: RefObject<number[]>;
|
||||
rowHeightsRef: RefObject<readonly number[]>;
|
||||
contentOrigin: number;
|
||||
}
|
||||
|
||||
type TimelinePlacement = { start: number; track: number };
|
||||
@@ -54,6 +56,8 @@ export function useTimelineAssetDrop({
|
||||
ppsRef,
|
||||
durationRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
contentOrigin,
|
||||
onFileDrop,
|
||||
onAssetDrop,
|
||||
onBlockDrop,
|
||||
@@ -85,10 +89,11 @@ export function useTimelineAssetDrop({
|
||||
rectTop: rect?.top ?? 0,
|
||||
scrollLeft: scroll?.scrollLeft ?? 0,
|
||||
scrollTop: scroll?.scrollTop ?? 0,
|
||||
contentOrigin,
|
||||
pixelsPerSecond: ppsRef.current,
|
||||
duration: durationRef.current,
|
||||
clampStartToDuration: !usePointerStart,
|
||||
trackHeight: TRACK_H,
|
||||
rowHeights: rowHeightsRef.current,
|
||||
trackOrder: trackOrderRef.current,
|
||||
},
|
||||
clientX,
|
||||
@@ -99,7 +104,7 @@ export function useTimelineAssetDrop({
|
||||
track: pointer.track,
|
||||
};
|
||||
},
|
||||
[scrollRef, ppsRef, durationRef, trackOrderRef],
|
||||
[scrollRef, ppsRef, durationRef, trackOrderRef, rowHeightsRef, contentOrigin],
|
||||
);
|
||||
|
||||
const handleAssetDrop = useCallback(
|
||||
|
||||
@@ -154,15 +154,16 @@ describe("track-area breathing pad y-math", () => {
|
||||
rectTop: 0,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
contentOrigin: GUTTER,
|
||||
pixelsPerSecond: 100,
|
||||
duration: 60,
|
||||
trackHeight: TRACK_H,
|
||||
rowHeights: trackHeights(3),
|
||||
trackOrder: [0, 1, 2],
|
||||
};
|
||||
|
||||
it("drops onto lane 0 when the pointer is in the middle of the first lane", () => {
|
||||
const clientY = getTimelineRowTop(0) + TRACK_H / 2;
|
||||
const clientX = GUTTER + TRACKS_LEFT_PAD + 100; // t = 1s
|
||||
const clientX = GUTTER + 100; // t = 1s (contentOrigin = GUTTER)
|
||||
const { start, track } = resolveTimelineAssetDrop(base, clientX, clientY);
|
||||
expect(track).toBe(0);
|
||||
expect(start).toBe(1);
|
||||
@@ -179,6 +180,13 @@ describe("track-area breathing pad y-math", () => {
|
||||
const { track } = resolveTimelineAssetDrop(base, GUTTER, clientY);
|
||||
expect(track).toBe(3); // max(trackOrder)+1
|
||||
});
|
||||
|
||||
it("keeps a drop in an expanded lane region on that track", () => {
|
||||
const rowHeights = [TRACK_H + 2 * LANE_H, TRACK_H, TRACK_H];
|
||||
const clientY = getTimelineRowTop(0, rowHeights) + TRACK_H + LANE_H;
|
||||
const { track } = resolveTimelineAssetDrop({ ...base, rowHeights }, GUTTER, clientY);
|
||||
expect(track).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,6 +196,7 @@ describe("getTimelineScrubTime", () => {
|
||||
clientX,
|
||||
viewportLeft: 0,
|
||||
scrollLeft: 0,
|
||||
contentOrigin: GUTTER + TRACKS_LEFT_PAD,
|
||||
pixelsPerSecond: 100,
|
||||
duration,
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ export const CLIP_HANDLE_W = 18;
|
||||
export function getTimelineLaneTop(laneIndex: number): number {
|
||||
return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed-row characterization value for the new-track INSERT band. Runtime
|
||||
* hit-testing uses getTimelineInsertBoundaryBand with the concrete row height.
|
||||
@@ -43,7 +42,7 @@ export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5);
|
||||
*/
|
||||
export const TRACKS_LEFT_PAD = 48;
|
||||
|
||||
interface TimelineTrackHeightClip {
|
||||
export interface TimelineTrackHeightClip {
|
||||
clipId: string;
|
||||
laneCount: number;
|
||||
}
|
||||
@@ -307,12 +306,16 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
|
||||
* remaining ruler runs to 1:00.
|
||||
* Manual zoom multiplies this base, so the floor only anchors the default.
|
||||
*/
|
||||
export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
|
||||
export function getTimelineFitPps(
|
||||
viewportWidth: number,
|
||||
effectiveDuration: number,
|
||||
contentOrigin: number,
|
||||
): number {
|
||||
const safeDuration =
|
||||
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
|
||||
const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
|
||||
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER + TRACKS_LEFT_PAD) return 100;
|
||||
return (viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2) / span;
|
||||
if (!Number.isFinite(viewportWidth) || viewportWidth <= contentOrigin) return 100;
|
||||
return (viewportWidth - contentOrigin - 2) / span;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,6 +328,7 @@ export function getTimelineFitPps(viewportWidth: number, effectiveDuration: numb
|
||||
export function getTimelineDisplayContentWidth(input: {
|
||||
trackContentWidth: number;
|
||||
viewportWidth: number;
|
||||
contentOrigin: number;
|
||||
pps: number;
|
||||
dragGhostEndPx?: number;
|
||||
resizeGhostEndPx?: number;
|
||||
@@ -332,7 +336,7 @@ export function getTimelineDisplayContentWidth(input: {
|
||||
const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
|
||||
return Math.max(
|
||||
input.trackContentWidth,
|
||||
input.viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2,
|
||||
input.viewportWidth - input.contentOrigin - 2,
|
||||
input.dragGhostEndPx ?? 0,
|
||||
input.resizeGhostEndPx ?? 0,
|
||||
MIN_TIMELINE_EXTENT_S * safePps,
|
||||
@@ -340,6 +344,15 @@ export function getTimelineDisplayContentWidth(input: {
|
||||
}
|
||||
|
||||
/* ── Scroll / zoom helpers ────────────────────────────────────────── */
|
||||
export function getTimelineContentXFromClient(input: {
|
||||
clientX: number;
|
||||
rectLeft: number;
|
||||
scrollLeft: number;
|
||||
contentOrigin: number;
|
||||
}): number {
|
||||
return input.clientX - input.rectLeft + input.scrollLeft - input.contentOrigin;
|
||||
}
|
||||
|
||||
export function shouldAutoScrollTimeline(
|
||||
zoomMode: ZoomMode,
|
||||
scrollWidth: number,
|
||||
@@ -362,7 +375,7 @@ export function getTimelineScrollLeftForZoomTransition(
|
||||
export function getTimelineScrollLeftForZoomAnchor(input: {
|
||||
pointerX: number;
|
||||
currentScrollLeft: number;
|
||||
gutter: number;
|
||||
contentOrigin: number;
|
||||
currentPixelsPerSecond: number;
|
||||
nextPixelsPerSecond: number;
|
||||
duration: number;
|
||||
@@ -379,9 +392,17 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
|
||||
) {
|
||||
return Math.max(0, input.currentScrollLeft);
|
||||
}
|
||||
const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter);
|
||||
const timelineX = Math.max(
|
||||
0,
|
||||
getTimelineContentXFromClient({
|
||||
clientX: input.pointerX,
|
||||
rectLeft: 0,
|
||||
scrollLeft: input.currentScrollLeft,
|
||||
contentOrigin: input.contentOrigin,
|
||||
}),
|
||||
);
|
||||
const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps));
|
||||
return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX);
|
||||
return Math.max(0, input.contentOrigin + timeAtPointer * nextPps - input.pointerX);
|
||||
}
|
||||
|
||||
/* ── Playhead / canvas ────────────────────────────────────────────── */
|
||||
@@ -390,33 +411,32 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
|
||||
* width, which the wrapper shrink-wraps to). The 1px vertical line inside
|
||||
* PlayheadIndicator is centered at 50% of this wrapper, so the wrapper must be
|
||||
* shifted LEFT by half this width for the line's center to land exactly on
|
||||
* `GUTTER + time * pps` — see {@link getTimelinePlayheadLeft}.
|
||||
* `contentOrigin + time * pps` — see {@link getTimelinePlayheadLeft}.
|
||||
*/
|
||||
export const PLAYHEAD_HEAD_W = 9;
|
||||
|
||||
/**
|
||||
* The `left` for the playhead WRAPPER such that the vertical line's CENTER
|
||||
* sits exactly on `GUTTER + time * pps` (the same x the ruler ticks center
|
||||
* sits exactly on `contentOrigin + time * pps` (the same x the ruler ticks center
|
||||
* on) at every zoom level. Without the half-head offset the line sat
|
||||
* `PLAYHEAD_HEAD_W / 2` px to the right of its ruler tick.
|
||||
*/
|
||||
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
|
||||
export function getTimelinePlayheadLeft(
|
||||
time: number,
|
||||
pixelsPerSecond: number,
|
||||
contentOrigin: number,
|
||||
): number {
|
||||
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
|
||||
return GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2;
|
||||
return contentOrigin - PLAYHEAD_HEAD_W / 2;
|
||||
}
|
||||
return (
|
||||
GUTTER +
|
||||
TRACKS_LEFT_PAD +
|
||||
Math.max(0, time) * Math.max(0, pixelsPerSecond) -
|
||||
PLAYHEAD_HEAD_W / 2
|
||||
);
|
||||
return contentOrigin + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of {@link getTimelinePlayheadLeft}: the scrub time under a viewport
|
||||
* clientX. Clamped to [0, duration], NOT rejected — the scrub surface starts
|
||||
* `GUTTER + TRACKS_LEFT_PAD` px right of the viewport edge, so any pointer left
|
||||
* of t=0 maps to a negative offset. Callers used to bail on that instead of
|
||||
* `contentOrigin` px right of the viewport edge, so any pointer left of t=0
|
||||
* maps to a negative offset. Callers used to bail on that instead of
|
||||
* clamping, which made the last 80px of the drag to zero silently do nothing:
|
||||
* the playhead stuck wherever the last in-range sample landed, and only a very
|
||||
* slow drag that happened to sample inside the sliver before the origin reached
|
||||
@@ -426,12 +446,18 @@ export function getTimelineScrubTime(input: {
|
||||
clientX: number;
|
||||
viewportLeft: number;
|
||||
scrollLeft: number;
|
||||
contentOrigin: number;
|
||||
pixelsPerSecond: number;
|
||||
duration: number;
|
||||
}): number {
|
||||
const { clientX, viewportLeft, scrollLeft, pixelsPerSecond, duration } = input;
|
||||
const { clientX, viewportLeft, scrollLeft, contentOrigin, pixelsPerSecond, duration } = input;
|
||||
if (!(pixelsPerSecond > 0) || !Number.isFinite(duration)) return 0;
|
||||
const x = clientX - viewportLeft + scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
||||
const x = getTimelineContentXFromClient({
|
||||
clientX,
|
||||
rectLeft: viewportLeft,
|
||||
scrollLeft,
|
||||
contentOrigin,
|
||||
});
|
||||
return Math.max(0, Math.min(duration, x / pixelsPerSecond));
|
||||
}
|
||||
|
||||
@@ -500,16 +526,22 @@ export function resolveTimelineAssetDrop(
|
||||
rectTop: number;
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
contentOrigin: number;
|
||||
pixelsPerSecond: number;
|
||||
duration: number;
|
||||
clampStartToDuration?: boolean;
|
||||
trackHeight: number;
|
||||
rowHeights?: readonly number[];
|
||||
trackOrder: number[];
|
||||
},
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): { start: number; track: number } {
|
||||
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
||||
const x = getTimelineContentXFromClient({
|
||||
clientX,
|
||||
rectLeft: input.rectLeft,
|
||||
scrollLeft: input.scrollLeft,
|
||||
contentOrigin: input.contentOrigin,
|
||||
});
|
||||
const contentY = clientY - input.rectTop + input.scrollTop;
|
||||
const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
|
||||
const start = Math.max(
|
||||
@@ -519,7 +551,7 @@ export function resolveTimelineAssetDrop(
|
||||
// Row from the shared row→y inverse so the top pad is honoured; a drop in the
|
||||
// pad above the first lane floors to row 0, a drop in the bottom pad rounds
|
||||
// past the last lane (getDefaultDroppedTrack then appends a new track).
|
||||
const rowIndex = Math.floor(getTimelineRowFromY(contentY));
|
||||
const rowIndex = Math.floor(getTimelineRowFromY(contentY, input.rowHeights));
|
||||
return {
|
||||
start,
|
||||
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
GUTTER,
|
||||
RULER_H,
|
||||
CLIP_Y,
|
||||
TRACKS_LEFT_PAD,
|
||||
getTimelineRowHeight,
|
||||
getTimelineRowTop,
|
||||
} from "./timelineLayout";
|
||||
import { RULER_H, CLIP_Y, getTimelineRowHeight, getTimelineRowTop } from "./timelineLayout";
|
||||
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
|
||||
|
||||
/** Pointer must travel at least this far (either axis) before a pointerdown on
|
||||
@@ -76,7 +69,7 @@ export function getTimelineClipRect(
|
||||
clip: Pick<MarqueeClipInput, "start" | "duration" | "track">,
|
||||
trackOrder: number[],
|
||||
pps: number,
|
||||
contentOrigin: number = GUTTER + TRACKS_LEFT_PAD,
|
||||
contentOrigin: number,
|
||||
rowHeights: readonly number[] = [],
|
||||
): Rect | null {
|
||||
const row = trackOrder.indexOf(clip.track);
|
||||
@@ -105,7 +98,7 @@ export function computeMarqueeSelection(input: {
|
||||
clips: MarqueeClipInput[];
|
||||
trackOrder: number[];
|
||||
pps: number;
|
||||
contentOrigin?: number;
|
||||
contentOrigin: number;
|
||||
marquee: Rect;
|
||||
baseSelection?: Iterable<string>;
|
||||
rowHeights?: readonly number[];
|
||||
|
||||
@@ -21,6 +21,7 @@ interface UseTimelineGeometryInput {
|
||||
isDragging: RefObject<boolean>;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
lastScrollLeftRef: RefObject<number>;
|
||||
contentOrigin: number;
|
||||
}
|
||||
|
||||
// Derive the timeline's horizontal geometry from the viewport, zoom, and any live
|
||||
@@ -40,10 +41,11 @@ export function useTimelineGeometry({
|
||||
isDragging,
|
||||
scrollRef,
|
||||
lastScrollLeftRef,
|
||||
contentOrigin,
|
||||
}: UseTimelineGeometryInput) {
|
||||
// Fit pps maps at least MIN_TIMELINE_EXTENT_S onto the viewport, so short
|
||||
// comps show a 60s ruler with usable empty space (see getTimelineFitPps).
|
||||
const fitPps = getTimelineFitPps(viewportWidth, effectiveDuration);
|
||||
const fitPps = getTimelineFitPps(viewportWidth, effectiveDuration, contentOrigin);
|
||||
const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
|
||||
ppsRef.current = pps;
|
||||
const trackContentWidth = Math.max(0, effectiveDuration * pps);
|
||||
@@ -70,6 +72,7 @@ export function useTimelineGeometry({
|
||||
const displayContentWidth = getTimelineDisplayContentWidth({
|
||||
trackContentWidth,
|
||||
viewportWidth,
|
||||
contentOrigin,
|
||||
pps,
|
||||
dragGhostEndPx,
|
||||
resizeGhostEndPx,
|
||||
|
||||
@@ -3,8 +3,6 @@ import { liveTime, type ZoomMode } from "../store/playerStore";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { getPinchTimelineZoomPercent } from "./timelineZoom";
|
||||
import {
|
||||
GUTTER,
|
||||
TRACKS_LEFT_PAD,
|
||||
getTimelinePlayheadLeft,
|
||||
getTimelineScrubTime,
|
||||
getTimelineScrollLeftForZoomTransition,
|
||||
@@ -32,6 +30,7 @@ interface UseTimelinePlayheadInput {
|
||||
setZoomMode: (mode: ZoomMode) => void;
|
||||
setManualZoomPercent: (percent: number) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
contentOrigin: number;
|
||||
}
|
||||
|
||||
export function useTimelinePlayhead({
|
||||
@@ -53,6 +52,7 @@ export function useTimelinePlayhead({
|
||||
setZoomMode,
|
||||
setManualZoomPercent,
|
||||
onSeek,
|
||||
contentOrigin,
|
||||
}: UseTimelinePlayheadInput) {
|
||||
const dragScrollRaf = useRef(0);
|
||||
const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
|
||||
@@ -61,6 +61,8 @@ export function useTimelinePlayhead({
|
||||
// anchors at the cursor instead, so it opts out via `skipCenterAnchorRef`.
|
||||
const previousAnchorPpsRef = useRef(pps);
|
||||
const skipCenterAnchorRef = useRef(false);
|
||||
const contentOriginRef = useRef(contentOrigin);
|
||||
contentOriginRef.current = contentOrigin;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const scroll = scrollRef.current;
|
||||
@@ -75,21 +77,21 @@ export function useTimelinePlayhead({
|
||||
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
|
||||
pointerX: scroll.clientWidth / 2,
|
||||
currentScrollLeft: scroll.scrollLeft,
|
||||
gutter: GUTTER + TRACKS_LEFT_PAD,
|
||||
contentOrigin,
|
||||
currentPixelsPerSecond: prevPps,
|
||||
nextPixelsPerSecond: pps,
|
||||
duration: durationRef.current,
|
||||
});
|
||||
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
||||
scroll.scrollLeft = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft));
|
||||
}, [pps, scrollRef, durationRef]);
|
||||
}, [pps, scrollRef, durationRef, contentOrigin]);
|
||||
|
||||
const syncPlayheadPosition = useCallback(
|
||||
(time: number) => {
|
||||
if (!playheadRef.current || durationRef.current <= 0) return;
|
||||
playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current)}px`;
|
||||
playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current, contentOrigin)}px`;
|
||||
},
|
||||
[playheadRef, durationRef, ppsRef],
|
||||
[playheadRef, durationRef, ppsRef, contentOrigin],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -121,7 +123,7 @@ export function useTimelinePlayhead({
|
||||
if (!playheadRef.current || durationRef.current <= 0) return;
|
||||
// Playback deliberately does NOT scroll the viewport to chase the playhead —
|
||||
// the user's scroll position is theirs; the playhead may run off-screen.
|
||||
playheadRef.current.style.left = `${getTimelinePlayheadLeft(t, ppsRef.current)}px`;
|
||||
playheadRef.current.style.left = `${getTimelinePlayheadLeft(t, ppsRef.current, contentOriginRef.current)}px`;
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
@@ -135,13 +137,14 @@ export function useTimelinePlayhead({
|
||||
clientX,
|
||||
viewportLeft: rect.left,
|
||||
scrollLeft: el.scrollLeft,
|
||||
contentOrigin,
|
||||
pixelsPerSecond: pps,
|
||||
duration: effectiveDuration,
|
||||
});
|
||||
liveTime.notify(time);
|
||||
onSeek?.(time);
|
||||
},
|
||||
[scrollRef, effectiveDuration, pps, onSeek],
|
||||
[scrollRef, effectiveDuration, pps, onSeek, contentOrigin],
|
||||
);
|
||||
|
||||
const autoScrollDuringDrag = useCallback(
|
||||
@@ -191,7 +194,7 @@ export function useTimelinePlayhead({
|
||||
const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
|
||||
pointerX: e.clientX - rect.left,
|
||||
currentScrollLeft: scroll.scrollLeft,
|
||||
gutter: GUTTER + TRACKS_LEFT_PAD,
|
||||
contentOrigin,
|
||||
currentPixelsPerSecond: ppsRef.current,
|
||||
nextPixelsPerSecond: nextPps,
|
||||
duration: durationRef.current,
|
||||
@@ -214,6 +217,7 @@ export function useTimelinePlayhead({
|
||||
manualZoomPercentRef,
|
||||
setManualZoomPercent,
|
||||
setZoomMode,
|
||||
contentOrigin,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "./timelineEditing";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { liveTime, usePlayerStore } from "../store/playerStore";
|
||||
import { GUTTER, TRACKS_LEFT_PAD, getTimelineScrubTime } from "./timelineLayout";
|
||||
import { getTimelineScrubTime } from "./timelineLayout";
|
||||
import {
|
||||
computeMarqueeSelection,
|
||||
getMarqueeRect,
|
||||
@@ -30,7 +30,9 @@ interface UseTimelineRangeSelectionInput {
|
||||
setShowPopover: (v: boolean) => void;
|
||||
elementsRef: React.RefObject<TimelineElement[]>;
|
||||
trackOrderRef: React.RefObject<number[]>;
|
||||
rowHeightsRef: React.RefObject<readonly number[]>;
|
||||
onSelectElement?: (element: TimelineElement | null) => void;
|
||||
contentOrigin: number;
|
||||
}
|
||||
|
||||
interface MarqueeDragState {
|
||||
@@ -72,12 +74,16 @@ function commitMarqueeSelection(
|
||||
marquee: MarqueeDragState,
|
||||
elements: TimelineElement[],
|
||||
trackOrder: number[],
|
||||
rowHeights: readonly number[],
|
||||
pps: number,
|
||||
contentOrigin: number,
|
||||
): void {
|
||||
const { ids, primaryId } = computeMarqueeSelection({
|
||||
clips: toMarqueeClips(elements),
|
||||
trackOrder,
|
||||
rowHeights,
|
||||
pps,
|
||||
contentOrigin,
|
||||
marquee: rect,
|
||||
baseSelection: additive ? marquee.baseIds : undefined,
|
||||
});
|
||||
@@ -101,7 +107,9 @@ export function useTimelineRangeSelection({
|
||||
setShowPopover,
|
||||
elementsRef,
|
||||
trackOrderRef,
|
||||
rowHeightsRef,
|
||||
onSelectElement,
|
||||
contentOrigin,
|
||||
}: UseTimelineRangeSelectionInput) {
|
||||
const isRangeSelecting = useRef(false);
|
||||
const rangeAnchorTime = useRef(0);
|
||||
@@ -168,10 +176,12 @@ export function useTimelineRangeSelection({
|
||||
marquee,
|
||||
elementsRef.current ?? [],
|
||||
trackOrderRef.current ?? [],
|
||||
rowHeightsRef.current,
|
||||
ppsRef.current,
|
||||
contentOrigin,
|
||||
);
|
||||
},
|
||||
[toContentPoint, elementsRef, trackOrderRef, ppsRef],
|
||||
[toContentPoint, elementsRef, trackOrderRef, rowHeightsRef, ppsRef, contentOrigin],
|
||||
);
|
||||
|
||||
const stopMarqueeAutoScroll = useCallback(() => {
|
||||
@@ -228,14 +238,13 @@ export function useTimelineRangeSelection({
|
||||
setShowPopover(false);
|
||||
const rect = scrollRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const x =
|
||||
e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER - TRACKS_LEFT_PAD;
|
||||
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - contentOrigin;
|
||||
const time = Math.max(0, x / pps);
|
||||
rangeAnchorTime.current = time;
|
||||
setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
|
||||
}
|
||||
},
|
||||
[scrollRef, pps, setShowPopover],
|
||||
[scrollRef, pps, setShowPopover, contentOrigin],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
@@ -291,6 +300,7 @@ export function useTimelineRangeSelection({
|
||||
clientX,
|
||||
viewportLeft: rect.left,
|
||||
scrollLeft: el.scrollLeft,
|
||||
contentOrigin,
|
||||
pixelsPerSecond: pps,
|
||||
duration: el.scrollWidth / pps,
|
||||
}),
|
||||
@@ -306,7 +316,7 @@ export function useTimelineRangeSelection({
|
||||
});
|
||||
}
|
||||
},
|
||||
[scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging],
|
||||
[scrollRef, pps, seekFromX, autoScrollDuringDrag, isDragging, contentOrigin],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
@@ -314,8 +324,7 @@ export function useTimelineRangeSelection({
|
||||
if (isRangeSelecting.current) {
|
||||
const rect = scrollRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const x =
|
||||
e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER - TRACKS_LEFT_PAD;
|
||||
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - contentOrigin;
|
||||
setRangeSelection((prev) =>
|
||||
prev
|
||||
? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }
|
||||
@@ -335,7 +344,15 @@ export function useTimelineRangeSelection({
|
||||
if (!isDragging.current) return;
|
||||
updateScrubDrag(e.clientX);
|
||||
},
|
||||
[pps, scrollRef, isDragging, applyMarqueeAtClient, syncMarqueeAutoScroll, updateScrubDrag],
|
||||
[
|
||||
pps,
|
||||
scrollRef,
|
||||
isDragging,
|
||||
applyMarqueeAtClient,
|
||||
syncMarqueeAutoScroll,
|
||||
updateScrubDrag,
|
||||
contentOrigin,
|
||||
],
|
||||
);
|
||||
|
||||
// Release of a shift time-range gesture: keep a real range (or a shift-click
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { LANE_H, TRACK_H } from "./timelineLayout";
|
||||
import { useTimelineTrackLayout } from "./useTimelineTrackLayout";
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
describe("useTimelineTrackLayout", () => {
|
||||
it("counts a flat tween lane and reserves its expanded row height", () => {
|
||||
const elements: TimelineElement[] = [
|
||||
{ id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
|
||||
];
|
||||
const animations = new Map<string, GsapAnimation[]>([
|
||||
[
|
||||
"clip-1",
|
||||
[
|
||||
{
|
||||
id: "position-tween",
|
||||
targetSelector: "#clip-1",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 1,
|
||||
properties: { x: 420 },
|
||||
propertyGroup: "position",
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
|
||||
|
||||
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
|
||||
function Probe() {
|
||||
layout = useTimelineTrackLayout(elements, animations, null, new Set());
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
act(() => root.render(React.createElement(Probe)));
|
||||
|
||||
expect(layout?.laneCounts.get("clip-1")).toBe(1);
|
||||
expect(layout?.rowHeights).toEqual([TRACK_H + LANE_H]);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { animationContributesLane } from "./TimelinePropertyLanes";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
||||
import {
|
||||
TRACK_H,
|
||||
getTimelineCanvasHeight,
|
||||
getTimelineRowHeight,
|
||||
trackHeights,
|
||||
type TimelineTrackHeightClip,
|
||||
} from "./timelineLayout";
|
||||
|
||||
export { getTrackStyle } from "./timelineIcons";
|
||||
|
||||
/**
|
||||
* The single keyframed element whose property lanes a track shows when expanded.
|
||||
* A track can hold several elements (same z-index is common), but keyframes are
|
||||
* per-element, so we scope to ONE active element — the selected one if it's on
|
||||
* this track, otherwise the element with the most lanes. Selecting a clip is how
|
||||
* you switch which element you're keyframing. Returns null when no element on the
|
||||
* track has keyframes.
|
||||
*/
|
||||
export function resolveTrackKeyframeClip(
|
||||
elements: readonly TimelineElement[],
|
||||
laneCounts: ReadonlyMap<string, number>,
|
||||
selectedElementId: string | null,
|
||||
selectedElementIds: ReadonlySet<string>,
|
||||
): TimelineElement | null {
|
||||
const keyframed = elements.filter(
|
||||
(element) => (laneCounts.get(element.key ?? element.id) ?? 0) >= 1,
|
||||
);
|
||||
if (keyframed.length === 0) return null;
|
||||
const selected = keyframed.find((element) => {
|
||||
const key = element.key ?? element.id;
|
||||
return key === selectedElementId || selectedElementIds.has(key);
|
||||
});
|
||||
if (selected) return selected;
|
||||
return [...keyframed].sort(
|
||||
(a, b) => (laneCounts.get(b.key ?? b.id) ?? 0) - (laneCounts.get(a.key ?? a.id) ?? 0),
|
||||
)[0]!;
|
||||
}
|
||||
|
||||
/** Lanes per clip: the count of distinct property groups whose tween contributes
|
||||
* a lane (real keyframes or a synthesizable flat tween). */
|
||||
function computeLaneCounts(
|
||||
tracks: [number, TimelineElement[]][],
|
||||
gsapAnimations: Map<string, GsapAnimation[]>,
|
||||
): Map<string, number> {
|
||||
const laneCounts = new Map<string, number>();
|
||||
for (const [, elements] of tracks) {
|
||||
for (const element of elements) {
|
||||
const clipId = element.key ?? element.id;
|
||||
const propertyGroups = new Set<string>();
|
||||
for (const animation of gsapAnimations.get(clipId) ?? []) {
|
||||
if (animation.propertyGroup && animationContributesLane(animation)) {
|
||||
propertyGroups.add(animation.propertyGroup);
|
||||
}
|
||||
}
|
||||
laneCounts.set(clipId, propertyGroups.size);
|
||||
}
|
||||
}
|
||||
return laneCounts;
|
||||
}
|
||||
|
||||
function useTimelineRowHeights(
|
||||
tracks: [number, TimelineElement[]][],
|
||||
gsapAnimations: Map<string, GsapAnimation[]>,
|
||||
selectedElementId: string | null,
|
||||
selectedElementIds: ReadonlySet<string>,
|
||||
) {
|
||||
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
||||
const { laneCounts, rowHeights } = useMemo(() => {
|
||||
const laneCounts = computeLaneCounts(tracks, gsapAnimations);
|
||||
// Row height follows only the active keyframe clip, so a track with several
|
||||
// keyframed elements never reserves empty lanes for the ones not shown.
|
||||
const heightTracks: TimelineTrackHeightClip[][] = tracks.map(([, elements]) => {
|
||||
const active = resolveTrackKeyframeClip(
|
||||
elements,
|
||||
laneCounts,
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
);
|
||||
if (!active) return [];
|
||||
const clipId = active.key ?? active.id;
|
||||
return [{ clipId, laneCount: laneCounts.get(clipId) ?? 0 }];
|
||||
});
|
||||
return {
|
||||
laneCounts,
|
||||
rowHeights: trackHeights(
|
||||
heightTracks,
|
||||
STUDIO_KEYFRAMES_ENABLED ? expandedClipIds : undefined,
|
||||
),
|
||||
};
|
||||
}, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]);
|
||||
const rowHeightsRef = useRef<readonly number[]>(rowHeights);
|
||||
rowHeightsRef.current = rowHeights;
|
||||
return { laneCounts, rowHeights, rowHeightsRef };
|
||||
}
|
||||
|
||||
export function useTimelineTrackLayout(
|
||||
expandedElements: TimelineElement[],
|
||||
gsapAnimations: Map<string, GsapAnimation[]>,
|
||||
selectedElementId: string | null,
|
||||
selectedElementIds: ReadonlySet<string>,
|
||||
) {
|
||||
const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
|
||||
const trackOrderRef = useRef(trackOrder);
|
||||
trackOrderRef.current = trackOrder;
|
||||
const { laneCounts, rowHeights, rowHeightsRef } = useTimelineRowHeights(
|
||||
tracks,
|
||||
gsapAnimations,
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
);
|
||||
|
||||
return {
|
||||
tracks,
|
||||
trackStyles,
|
||||
trackOrder,
|
||||
trackOrderRef,
|
||||
laneCounts,
|
||||
rowHeights,
|
||||
rowHeightsRef,
|
||||
};
|
||||
}
|
||||
|
||||
function useDisplayRowHeights(
|
||||
displayTrackOrder: readonly number[],
|
||||
trackOrder: readonly number[],
|
||||
rowHeights: readonly number[],
|
||||
) {
|
||||
return useMemo(
|
||||
() =>
|
||||
displayTrackOrder.map((track) => {
|
||||
const row = trackOrder.indexOf(track);
|
||||
return row < 0 ? TRACK_H : getTimelineRowHeight(row, rowHeights);
|
||||
}),
|
||||
[displayTrackOrder, trackOrder, rowHeights],
|
||||
);
|
||||
}
|
||||
|
||||
function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
|
||||
return useMemo(() => {
|
||||
if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
|
||||
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
||||
}, [draggedClip, trackOrder]);
|
||||
}
|
||||
|
||||
export function useTimelineDisplayLayout(
|
||||
draggedClip: DraggedClipState | null,
|
||||
trackOrder: number[],
|
||||
rowHeights: readonly number[],
|
||||
) {
|
||||
const displayTrackOrder = useDisplayTrackOrder(draggedClip, trackOrder);
|
||||
const displayRowHeights = useDisplayRowHeights(displayTrackOrder, trackOrder, rowHeights);
|
||||
const totalH = getTimelineCanvasHeight(displayRowHeights);
|
||||
return { displayTrackOrder, displayRowHeights, totalH };
|
||||
}
|
||||
Reference in New Issue
Block a user