feat(studio): resize every timeline layer at once from the toolbar or a layer's bottom edge

This commit is contained in:
Miguel Angel Simon Sierra
2026-10-03 14:55:42 -07:00
parent 2cfbe3d257
commit c175ea774d
23 changed files with 280 additions and 66 deletions
@@ -5,6 +5,7 @@ import {
Magnet,
MagnifyingGlassMinus,
MagnifyingGlassPlus,
Rows,
} from "@phosphor-icons/react";
import {
useEnableKeyframes,
@@ -20,6 +21,7 @@ import {
timelineSliderToZoomPercent,
} from "../player/components/timelineZoom";
import { useTimelineZoom } from "../player/components/useTimelineZoom";
import { TRACK_H_MAX, TRACK_H_MIN } from "../player/components/timelineLayout";
import { usePlayerStore, type TimelineElement } from "../player";
import { Tooltip } from "./ui";
import { AudioMetersIcon } from "./icons/AudioMetersIcon";
@@ -41,6 +43,10 @@ interface DomEditSessionSlice extends EnableKeyframesSession {
selectedGsapAnimations: GsapAnimation[];
}
// h-6 on a range input is the 24x24 WCAG 2.2 (2.5.8) target: the visible track stays 2px and the thumb 10px.
const RANGE_INPUT_CLASS =
"cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-text-0 [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing";
export interface TimelineToolbarProps {
domEditSession?: DomEditSessionSlice;
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
@@ -159,6 +165,8 @@ export const TimelineToolbar = memo(function TimelineToolbar({
const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
const setThumbnailMode = usePlayerStore((s) => s.setThumbnailMode);
const thumbnailsVisible = thumbnailMode === "adaptive";
const trackHeight = usePlayerStore((s) => s.trackHeight);
const setTrackHeight = usePlayerStore((s) => s.setTrackHeight);
const audioMetersVisible = useAudioMetersVisible((s) => s.visible);
const setAudioMetersVisible = useAudioMetersVisible((s) => s.setVisible);
const projectHasAudio = useProjectHasAudio();
@@ -485,6 +493,21 @@ export const TimelineToolbar = memo(function TimelineToolbar({
<Image size={16} aria-hidden="true" />
</button>
</Tooltip>
<Tooltip label="Layer height">
<span className="flex items-center text-neutral-400">
<Rows size={16} aria-hidden="true" className="ml-1" />
<input
type="range"
min={TRACK_H_MIN}
max={TRACK_H_MAX}
value={trackHeight}
aria-label="Layer height"
aria-valuetext={`${trackHeight} px`}
onChange={(e) => setTrackHeight(Number(e.target.value))}
className={`mx-1 h-6 w-[56px] ${RANGE_INPUT_CLASS}`}
/>
</span>
</Tooltip>
<Tooltip label="Fit timeline to width">
<button
type="button"
@@ -528,9 +551,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
timelineSliderToZoomPercent(Number(e.target.value), timelineFitPps),
);
}}
// h-6 on the input is the 24x24 WCAG 2.2 (2.5.8) target: the visible
// track stays 2px and the thumb 10px, only the pointer box grows.
className="mx-1 h-6 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-text-0 [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
className={`mx-1 h-6 w-[96px] ${RANGE_INPUT_CLASS}`}
/>
<Tooltip label="Zoom in">
<button
@@ -20,11 +20,12 @@ import { buildCompositionThumbnailUrl } from "../player/components/CompositionTh
import { compositionCardThumbnailUrl } from "../components/sidebar/CompositionsTab";
import { normalizeCompositionSrc } from "./useRenderClipContent";
import { useRenderClipContent } from "./useRenderClipContent";
import { TRACK_H, TRACK_H_THUMBNAILS_MIN } from "../player/components/timelineLayout";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
usePlayerStore.setState({ thumbnailMode: "hidden", elements: [] });
usePlayerStore.setState({ thumbnailMode: "hidden", elements: [], trackHeight: TRACK_H });
document.body.innerHTML = "";
});
@@ -265,6 +266,14 @@ describe("useRenderClipContent", () => {
}
});
it("drops a video's thumbnails on layers shorter than the threshold", () => {
const video = { id: "b", tag: "video", start: 0, duration: 4, track: 0, src: "b.mp4", hasAudio: false };
usePlayerStore.setState({ thumbnailMode: "adaptive", trackHeight: TRACK_H_THUMBNAILS_MIN });
expect(isValidElement(renderClipContent(video, null))).toBe(true);
usePlayerStore.setState({ trackHeight: TRACK_H_THUMBNAILS_MIN - 1 });
expect(renderClipContent(video, null)).toBeNull();
});
it("still shows the sound strip when thumbnails are off", () => {
const content = renderClipContent(
{
@@ -21,6 +21,7 @@ import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/
import { usePlayerStore } from "../player/store/playerStore";
import { thumbnailRevisionOf } from "../player/store/thumbnailSlice";
import { effectiveThumbnailMode } from "../player/lib/thumbnailPolicy";
import { TRACK_H_THUMBNAILS_MIN } from "../player/components/timelineLayout";
export function normalizeCompositionSrc(
compSrc: string,
@@ -125,7 +126,9 @@ export function useRenderClipContent({
}: UseRenderClipContentOptions) {
// Self-sourced so the adaptive policy gates thumbnail generation without App plumbing.
const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
const effectiveMode = effectiveThumbnailMode(thumbnailMode);
// Short layers keep just the name bar (and an audio clip's waveform).
const shortLayers = usePlayerStore((s) => s.trackHeight < TRACK_H_THUMBNAILS_MIN);
const effectiveMode = shortLayers ? "hidden" : effectiveThumbnailMode(thumbnailMode);
const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
const thumbnailRevisions = usePlayerStore((s) => s.thumbnailRevisions);
const elements = usePlayerStore((s) => s.elements);
+1 -1
View File
@@ -60,7 +60,7 @@ export {
} from "./player/components/TimelineParts";
export { TimelineProvider, useTimelineContext } from "./player/components/TimelineProvider";
export type { TimelineTheme } from "./player/components/timelineTheme";
export { TRACK_H } from "./player/components/timelineLayout";
export { TRACK_H, TRACK_H_MAX, TRACK_H_MIN } from "./player/components/timelineLayout";
export type { TimelineTrackPadding } from "./player/components/timelineLayout";
export { displayTrackOrder } from "./player/components/useTimelineTrackDerivations";
export { fadeHandleBoxes } from "./player/components/timelineClipFadeGeometry";
@@ -1,4 +1,4 @@
import { TRACK_H } from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
import { TrackClipCount } from "./TrackClipCount";
// Layer row (diamond, name, then the ∿ disclosure on the right edge) — the
@@ -82,12 +82,13 @@ export function LayerDisclosureRow({
/** Trailing controls that act on the LAYER (the visibility eye), not on a lane. */
children?: React.ReactNode;
}) {
const trackHeight = usePlayerStore((s) => s.trackHeight);
return (
<div
className="absolute left-0 top-0 flex items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
style={{
width: columnWidth,
height: TRACK_H,
height: trackHeight,
color: "var(--timeline-text-solid)",
background: gutterBackground,
}}
@@ -15,7 +15,7 @@ import { getTimelineLaneTop } from "./timelineLayout";
import { groupAutomationLanes, isCarveLane } from "./automationLaneData";
import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { UseAutomationLanesResult } from "./useAutomationLanes";
/** Which shared rows one clip draws into, and with which of its lanes. */
@@ -174,6 +174,7 @@ export function TimelineAutomationLaneSlot({
currentTime,
beatTimes,
}: TimelineAutomationLaneSlotProps) {
const trackHeight = usePlayerStore((s) => s.trackHeight);
// Broader than a raw-attribute check — a clip mid-edit (see the stale-
// selection test) has lanes in its live binding before the attribute commits.
const clips = elements.filter(isAudioOrVideoTimelineElement);
@@ -186,7 +187,7 @@ export function TimelineAutomationLaneSlot({
else rowsByClip.set(key, [{ lane: entry.lane, rowIndex }]);
}
});
const top = topOffset ?? getTimelineLaneTop(laneCount);
const top = topOffset ?? getTimelineLaneTop(laneCount, trackHeight);
return (
<>
{clips.map((element) => (
@@ -2,7 +2,7 @@ import { memo } from "react";
import { TimelineRulerPart } from "./TimelineRulerPart";
import { PlayheadIndicator } from "./PlayheadIndicator";
import { TimelinePlayheadLayer } from "./TimelinePlayheadLayer";
import { RULER_H, CLIP_Y, TRACK_H, PLAYHEAD_HEAD_W, getTimelineRowHeight } from "./timelineLayout";
import { RULER_H, CLIP_Y, PLAYHEAD_HEAD_W, getTimelineRowHeight } from "./timelineLayout";
import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
import { TimelineLanes } from "./TimelineLanes";
import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
@@ -30,15 +30,16 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
(insertLineRow ?? -1) >= props.trackOrder.length ||
(props.dropPreview?.insertRow == null && props.dropPreview != null && dropTrackIndex < 0);
// A host bottom pad shorter than a track leaves no lane to draw.
const showGhostLane = props.rowGeometry.padding.bottom >= TRACK_H;
const trackHeight = props.rowGeometry.trackHeight;
const showGhostLane = props.rowGeometry.padding.bottom >= trackHeight;
// Past the last committed track: a drag's new bottom-track preview row is the lane itself.
const ghostLaneRow = props.rowGeometry.getRowIndex(props.trackOrder.at(-1) ?? Number.NaN) + 1;
const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
// A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes
// A clip bar in an EXPANDED row still renders at one layer height (the property lanes
// occupy the rest of the row — see TimelineLanes' clipHeight), so the drag
// ghost and drop placeholder must clamp to it or they stretch to the full
// expanded row height and stop matching the clip being dragged.
const draggedClipHeight = Math.min(draggedRowHeight, TRACK_H) - CLIP_Y * 2;
const draggedClipHeight = Math.min(draggedRowHeight, trackHeight) - CLIP_Y * 2;
const beatDragging = props.beatDragging;
const { draggedElement, snapGuide, multiDragPreview } = props;
return (
@@ -92,7 +93,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
top: props.rowGeometry.getRowTop(ghostLaneRow) + CLIP_Y,
left: props.contentOrigin,
width: props.trackContentWidth,
height: TRACK_H - CLIP_Y * 2,
height: trackHeight - CLIP_Y * 2,
}}
>
{props.acceptsMediaDrop && "Drop media here"}
@@ -121,7 +122,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
top: props.rowGeometry.getRowTop(rowIndex) + 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,
height: trackHeight - CLIP_Y * 2,
background: loud ? "var(--timeline-accent-soft)" : "var(--timeline-accent-faint)",
borderRadius: 4,
zIndex: 25,
@@ -159,7 +160,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
top: props.rowGeometry.getRowTop(dropRowIndex) + CLIP_Y,
left: props.contentOrigin + props.dropPreview.start * props.pps,
width: DROP_PREVIEW_SECONDS * props.pps,
height: TRACK_H - CLIP_Y * 2,
height: trackHeight - CLIP_Y * 2,
border: "1px solid color-mix(in srgb, var(--timeline-accent) 55%, transparent)",
background: "color-mix(in srgb, var(--timeline-accent) 12%, transparent)",
borderRadius: 4,
@@ -1,5 +1,5 @@
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
import { TRACK_H } from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
import type { TimelineTheme } from "./timelineTheme";
import { TimelineFxButton } from "./TimelineFxButton";
import type { AuditionSpan } from "../../components/editor/useAuditionTransport.js";
@@ -102,6 +102,7 @@ export function TimelineGroupHeader({
theme,
showAudioEffects = true,
}: TimelineGroupHeaderProps) {
const trackHeight = usePlayerStore((s) => s.trackHeight);
return (
<div
role="rowheader"
@@ -109,7 +110,7 @@ export function TimelineGroupHeader({
className="sticky left-0 z-12 flex shrink-0 items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
style={{
width: columnWidth,
height: TRACK_H,
height: trackHeight,
color: "var(--timeline-text-solid)",
background: theme.gutterBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
@@ -12,7 +12,7 @@ import { groupAutomationLanes } from "./automationLaneData";
import { groupAutomationElement } from "./groupAutomationElement";
import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot";
import { TimelineGroupLaneLabels } from "./TimelineGroupLaneLabels";
import { LABEL_COL_W, TRACK_H } from "./timelineLayout";
import { LABEL_COL_W } from "./timelineLayout";
import type { UseAutomationLanesResult } from "./useAutomationLanes";
import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
@@ -98,6 +98,7 @@ export function TimelineGroupRow({
} = useTimelineEditContextOptional();
const domEditActions = useDomEditActionsContextOptional();
const revealAudioFx = usePlayerStore((state) => state.setRevealedAudioFxTarget);
const trackHeight = usePlayerStore((state) => state.trackHeight);
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean, ended = false) => {
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
if (!live) {
@@ -139,7 +140,7 @@ export function TimelineGroupRow({
rovingTargetId={rovingTargetId}
>
{/* Header and its lane labels in ONE sticky column — the shape
`TimelineTrackHeader` already uses: a fixed TRACK_H line box with the
`TimelineTrackHeader` already uses: a fixed one-layer line box with the
lane rows absolutely positioned beneath it, the whole thing pinned.
The labels used to be SIBLINGS of the header, so `absolute left-0`
resolved against the ROW, and the row is what scrolls horizontally —
@@ -186,7 +187,7 @@ export function TimelineGroupRow({
<TimelineGroupLaneLabels
groupElement={groupElement}
groupLabel={group.label}
top={TRACK_H}
top={trackHeight}
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
gutterBackground={theme.gutterBackground}
accentColor={GROUP_LANE_ACCENT}
@@ -212,7 +213,7 @@ export function TimelineGroupRow({
pps={pps}
// Below the strip, which sits directly under the header row.
laneCount={0}
topOffset={TRACK_H}
topOffset={trackHeight}
accentColor={GROUP_LANE_ACCENT}
currentTime={currentTime}
beatTimes={beatTimes}
@@ -18,7 +18,7 @@ import {
import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
import { clipTimingStart } from "../../hooks/gsapShared";
import { useTimelineClipCapabilities } from "./timelineReadOnly";
import { CLIP_Y, TRACK_H } from "./timelineLayout";
import { CLIP_Y } from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview";
import { useTimelineMultiDragActorWindows } from "./useTimelineMultiDragActorWindows";
@@ -209,7 +209,7 @@ export function TimelineLanes({
top: rowsVirtualized ? rowGeometry.getRowTop(row) : undefined,
marginLeft: contentOrigin,
width: trackContentWidth,
height: TRACK_H,
height: rowGeometry.trackHeight,
border: "1px dashed var(--timeline-accent)",
background: "color-mix(in srgb, var(--timeline-accent) 5%, transparent)",
pointerEvents: "none",
@@ -237,7 +237,7 @@ export function TimelineLanes({
// bar is capped for every clip on the row, not just the one whose
// property lanes are showing. Undefined means "fill the row", which is
// right only while it is collapsed and the row is nothing but bar.
const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined;
const clipBarHeight = rowExpanded ? rowGeometry.trackHeight - 2 * CLIP_Y : undefined;
const automationElements = els.map(getPreviewElement);
// Minted here because this is the only place that sees BOTH ends of
// the disclosure: the caret in the sticky header and the diamond lanes
@@ -306,6 +306,7 @@ export function TimelineLanes({
isTrackHidden={isTrackHidden}
isAudioTrack={isAudioTrack}
isGroupMember={groupMemberTracks.has(trackNum)}
row={row}
theme={theme}
showAudioEffects={showAudioEffects}
onToggleClipExpanded={() => {
@@ -8,6 +8,7 @@ import { toClipKeyframes } from "../../hooks/gsapShared";
import { synthesizeFlatTweenKeyframes } from "../../hooks/gsapTweenSynth";
import { TimelineDiamondLane, type TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
@@ -197,6 +198,7 @@ export function TimelinePropertyLanes({
onMoveKeyframe,
suppressClickRef,
}: TimelinePropertyLanesProps) {
const trackHeight = usePlayerStore((s) => s.trackHeight);
// Memoized: TimelineDiamondLane is React.memo'd, and rebuilding the lanes (and
// a fresh keyframesData literal per lane) on every render would re-render every
// diamond in every expanded clip on each playhead tick.
@@ -232,11 +234,11 @@ export function TimelinePropertyLanes({
data-property-group={group}
data-timeline-element-id={elementId}
data-timeline-property-lane=""
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
data-timeline-lane-top={getTimelineLaneTop(laneIndex, trackHeight)}
className="absolute"
style={{
left: clipLeftPx,
top: getTimelineLaneTop(laneIndex),
top: getTimelineLaneTop(laneIndex, trackHeight),
width: clipWidthPx,
height: LANE_H,
}}
@@ -18,7 +18,8 @@ import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationL
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { clipTimingStart } from "../../hooks/gsapShared";
import { LaneToggleButton, LayerDisclosureRow } from "./LayerDisclosureRow";
import { LABEL_COL_W, TRACK_H, getTimelineLaneTop } from "./timelineLayout";
import { LABEL_COL_W, getTimelineLaneTop } from "./timelineLayout";
import { TrackHeightGrip } from "./TrackHeightGrip";
import type { TimelineTheme } from "./timelineTheme";
import { trackDisplaySuffix } from "./timelineTrackDisplay";
import { AutomationLaneHeaderRow, PropertyGroupHeaderRow } from "./trackHeaderLabelRows";
@@ -71,6 +72,8 @@ interface TimelineTrackHeaderProps {
/** This track is a member of an audio group — indents the row under its header. */
isGroupMember?: boolean;
rovingTargetId?: string | null;
/** Display row; with it the header's bottom edge resizes every layer. */
row?: number;
theme: TimelineTheme;
showAudioEffects?: boolean;
onToggleClipExpanded: () => void;
@@ -106,6 +109,7 @@ export function TimelineTrackHeader({
onRemoveAutomationLane,
onSeek,
rovingTargetId = null,
row,
}: TimelineTrackHeaderProps) {
const clipPercentage = keyframeClip
? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
@@ -164,6 +168,7 @@ export function TimelineTrackHeader({
[groupAutomationRaw, groupFxChainRaw, groupOwner],
);
const revealAudioFx = usePlayerStore((s) => s.setRevealedAudioFxTarget);
const trackHeight = usePlayerStore((s) => s.trackHeight);
/**
* Which element's rack a lane's reveal opens, in the PANEL's id space.
*
@@ -289,9 +294,10 @@ export function TimelineTrackHeader({
: {}),
}}
>
{row !== undefined && <TrackHeightGrip row={row} />}
{!isKeyframeLayer ? (
<>
{/* The two lines own exactly TRACK_H, not the whole header.
{/* The two lines own exactly one layer height, not the whole header.
`justify-center` on the header itself centred them in its FULL
height — which grows by AUTOMATION_LANE_H per open lane — so
opening one pushed the name and its controls down THROUGH the lane
@@ -299,10 +305,10 @@ export function TimelineTrackHeader({
<div
className={
showTrackLabel
? "flex flex-col justify-center gap-0.5 px-1.5 text-[var(--timeline-handle)]"
: "flex flex-col items-center justify-center gap-0.5"
? "flex flex-col justify-center gap-0.5 overflow-hidden px-1.5 text-[var(--timeline-handle)]"
: "flex flex-col items-center justify-center gap-0.5 overflow-hidden"
}
style={{ height: TRACK_H }}
style={{ height: trackHeight }}
>
<PlainTrackHeader
trackNumber={trackNumber}
@@ -435,7 +441,7 @@ export function TimelineTrackHeader({
alsoAutomatedBy={
groupAutomatedTargets.has(row.key) ? (groupLabelForNote ?? groupOwner) : undefined
}
top={getTimelineLaneTop(lanes.length) + index * AUTOMATION_LANE_H}
top={getTimelineLaneTop(lanes.length, trackHeight) + index * AUTOMATION_LANE_H}
isLastLane={index === automationRows.length - 1}
gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)}
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
@@ -0,0 +1,51 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { usePlayerStore } from "../store/playerStore";
import { TRACK_H } from "./timelineLayout";
import { TrackHeightGrip } from "./TrackHeightGrip";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
usePlayerStore.setState({ trackHeight: TRACK_H });
document.body.innerHTML = "";
});
function renderGrip(row: number): HTMLElement {
const host = document.createElement("div");
document.body.append(host);
act(() => createRoot(host).render(<TrackHeightGrip row={row} />));
const grip = host.querySelector<HTMLElement>("[data-timeline-track-height-grip]")!;
grip.setPointerCapture = () => undefined;
return grip;
}
function pointer(target: HTMLElement, type: string, clientY: number) {
act(() => {
target.dispatchEvent(
new PointerEvent(type, { bubbles: true, button: 0, buttons: 1, pointerId: 1, clientY }),
);
});
}
describe("TrackHeightGrip", () => {
it("keeps the dragged edge under the pointer: the third layer's edge shares its travel three ways", () => {
const grip = renderGrip(2);
pointer(grip, "pointerdown", 100);
pointer(grip, "pointermove", 130);
expect(usePlayerStore.getState().trackHeight).toBe(TRACK_H + 10);
pointer(grip, "pointerup", 130);
pointer(grip, "pointermove", 400);
expect(usePlayerStore.getState().trackHeight).toBe(TRACK_H + 10);
});
it("puts every layer back to the default on a double click", () => {
usePlayerStore.setState({ trackHeight: 30 });
const grip = renderGrip(0);
act(() => grip.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })));
expect(usePlayerStore.getState().trackHeight).toBe(TRACK_H);
});
});
@@ -0,0 +1,43 @@
import { useRef, type PointerEvent } from "react";
import { usePlayerStore } from "../store/playerStore";
import { TRACK_H } from "./timelineLayout";
/**
* A layer's bottom edge. Dragging it resizes every layer at once, so the `row + 1` layers down to this edge
* each take a share of the pointer's travel and the edge stays under the pointer. Double click: the default.
*/
export function TrackHeightGrip({ row }: { row: number }) {
const trackHeight = usePlayerStore((s) => s.trackHeight);
const setTrackHeight = usePlayerStore((s) => s.setTrackHeight);
const drag = useRef<{ pointerId: number; y: number; height: number } | null>(null);
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { pointerId: event.pointerId, y: event.clientY, height: trackHeight };
};
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const start = drag.current;
if (!start || start.pointerId !== event.pointerId) return;
setTrackHeight(start.height + (event.clientY - start.y) / (row + 1));
};
const end = () => void (drag.current = null);
// The keyboard path is the toolbar's Layer height slider, so the grip stays out of the tab order.
return (
<div
aria-hidden="true"
data-timeline-track-height-grip
className="absolute inset-x-0 z-20 h-1.5 cursor-row-resize"
style={{ top: trackHeight - 3 }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={end}
onPointerCancel={end}
onDoubleClick={(event) => {
event.stopPropagation();
setTrackHeight(TRACK_H);
}}
/>
);
}
@@ -134,9 +134,10 @@ function dragRowAim(
let y = clientY - (ctx.scroll?.getBoundingClientRect().top ?? 0) + (ctx.scroll?.scrollTop ?? 0);
if (drag.started && drag.insertRow !== null) {
const top = geometry.getRowTop(drag.insertRow);
if (y >= top - INSERT_SEAM_PX && y <= top + TRACK_H + INSERT_SEAM_PX)
const trackHeight = geometry.trackHeight;
if (y >= top - INSERT_SEAM_PX && y <= top + trackHeight + INSERT_SEAM_PX)
return { rowFloat: drag.insertRow, insertRow: drag.insertRow };
if (y > top + TRACK_H) y -= TRACK_H;
if (y > top + trackHeight) y -= trackHeight;
}
const rowFloat = geometry.getRowFromY(y);
return { rowFloat, insertRow: physicalInsertRow(rowFloat, y, ctx, geometry) };
@@ -17,6 +17,7 @@ import {
trackHeights,
resolveTimelineAssetDrop,
getTimelineBeatEntries,
getTimelineLaneTop,
} from "./timelineLayout";
import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
import { resolveInsertRow } from "./timelineCollision";
@@ -295,6 +296,26 @@ describe("host track padding", () => {
});
});
describe("resized layers", () => {
const short = 30;
const geometry = createTimelineRowGeometry([0, 1], trackHeights([[], []], undefined, short), {}, short);
it("sizes every row, the ghost lane and the rows past the last from one layer height", () => {
expect(geometry.rowHeights).toEqual([short, short]);
expect(geometry.trackHeight).toBe(short);
expect(geometry.padding.bottom).toBe(short);
expect(geometry.canvasHeight).toBe(RULER_H + TRACKS_TOP_PAD + 3 * short);
expect(geometry.getRowTop(3)).toBe(RULER_H + TRACKS_TOP_PAD + 3 * short);
expect(Math.floor(geometry.getRowFromY(RULER_H + TRACKS_TOP_PAD + 2 * short + 1))).toBe(2);
});
it("puts an expanded clip's lanes under the resized bar", () => {
const expanded = trackHeights([[{ clipId: "a", laneCount: 2 }]], new Set(["a"]), short);
expect(expanded).toEqual([short + 2 * LANE_H]);
expect(getTimelineLaneTop(1, short)).toBe(short + LANE_H);
});
});
describe("default track pads", () => {
const rows = [TRACK_H, TRACK_H, TRACK_H];
const lastLaneBottom = RULER_H + 8 + 3 * TRACK_H;
@@ -5,7 +5,17 @@ import type { TimelineTimeRange } from "../lib/timelineClipIndex";
/* ── Layout constants ──────────────────────────────────────────────── */
export const GUTTER = 32;
export const LABEL_COL_W = 232;
/** The default layer height; the person can resize every layer at once between the MIN and MAX below. */
export const TRACK_H = 48;
export const TRACK_H_MIN = 24;
export const TRACK_H_MAX = 120;
/** Below this layer height clips show only their name bar, no thumbnails. */
export const TRACK_H_THUMBNAILS_MIN = 40;
export function clampTrackHeight(height: number): number {
if (!Number.isFinite(height)) return TRACK_H;
return Math.round(Math.min(TRACK_H_MAX, Math.max(TRACK_H_MIN, height)));
}
export const LANE_H = 28;
export const RULER_H = 24;
export const CLIP_Y = 3;
@@ -51,8 +61,8 @@ export function getTimelineBeatEntries(
.map((index) => ({ index, time: beatTimes[index]!, strength: beatStrengths?.[index] }));
}
export function getTimelineLaneTop(laneIndex: number): number {
return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
export function getTimelineLaneTop(laneIndex: number, trackHeight = TRACK_H): number {
return trackHeight + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
}
/**
* Default breathing room INSIDE the scroll area (CapCut-style). A host overrides
@@ -61,7 +71,8 @@ export function getTimelineLaneTop(laneIndex: number): number {
* - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track,
* just enough that the first clip isn't jammed under the ruler.
* - TRACKS_BOTTOM_PAD: one empty track below the last, drawn as a ghost lane;
* dropping a clip or file there creates a new bottom track.
* dropping a clip or file there creates a new bottom track. It follows the
* layer height when the person resizes layers.
*/
export const TRACKS_TOP_PAD = 8;
export const TRACKS_BOTTOM_PAD = TRACK_H;
@@ -98,6 +109,7 @@ type TimelineTrackHeightInput = readonly (readonly TimelineTrackHeightClip[])[];
export function trackHeights(
tracks: TimelineTrackHeightInput,
expandedClipIds?: ReadonlySet<string>,
trackHeight = TRACK_H,
): number[] {
return tracks.map((clips) => {
let laneCount = 0;
@@ -108,13 +120,15 @@ export function trackHeights(
automationLanes = Math.max(automationLanes, clip.automationLaneCount ?? 0);
}
return (
TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H + automationLanes * AUTOMATION_LANE_H
trackHeight +
Math.max(0, Math.trunc(laneCount)) * LANE_H +
automationLanes * AUTOMATION_LANE_H
);
});
}
function validRowHeight(height: number | undefined): number {
if (height === undefined || !Number.isFinite(height) || height <= 0) return TRACK_H;
function validRowHeight(height: number | undefined, trackHeight = TRACK_H): number {
if (height === undefined || !Number.isFinite(height) || height <= 0) return trackHeight;
return height;
}
@@ -129,6 +143,8 @@ export interface TimelineRowGeometry {
readonly rowOffsets: readonly number[];
readonly rowsHeight: number;
readonly padding: Readonly<Required<TimelineTrackPadding>>;
/** One collapsed layer: rows past the last, and every clip bar, take this height. */
readonly trackHeight: number;
readonly canvasHeight: number;
getRowIndex(rowKey: number): number;
getRowHeight(row: number): number;
@@ -150,10 +166,11 @@ export function createTimelineRowGeometry(
rowKeys: readonly number[],
rowHeights: readonly number[],
padding: TimelineTrackPadding = {},
trackHeight = TRACK_H,
): TimelineRowGeometry {
const topPad = validPad(padding.top, TRACKS_TOP_PAD);
const bottomPad = validPad(padding.bottom, TRACKS_BOTTOM_PAD);
const heights = Object.freeze(rowHeights.map(validRowHeight));
const bottomPad = validPad(padding.bottom, trackHeight);
const heights = Object.freeze(rowHeights.map((height) => validRowHeight(height, trackHeight)));
const keys = Object.freeze(
heights.map((_, row) => {
const key = rowKeys[row];
@@ -165,22 +182,22 @@ export function createTimelineRowGeometry(
Object.freeze(offsets);
const rowIndexByKey = new Map(keys.map((key, row) => [key, row]));
const getRowHeight = (row: number) => validRowHeight(heights[row]);
const getRowHeight = (row: number) => validRowHeight(heights[row], trackHeight);
const getRowOffset = (row: number) => {
if (heights.length === 0) return row * TRACK_H;
if (heights.length === 0) return row * trackHeight;
if (row <= 0) return row * getRowHeight(0);
if (row >= heights.length) {
return (offsets[heights.length] ?? 0) + (row - heights.length) * TRACK_H;
return (offsets[heights.length] ?? 0) + (row - heights.length) * trackHeight;
}
const wholeRow = Math.floor(row);
return (offsets[wholeRow] ?? 0) + (row - wholeRow) * getRowHeight(wholeRow);
};
const getRowFromY = (contentY: number) => {
const y = contentY - RULER_H - topPad;
if (heights.length === 0) return y / TRACK_H;
if (heights.length === 0) return y / trackHeight;
if (y < 0) return y / getRowHeight(0);
const rowsHeight = offsets[heights.length] ?? 0;
if (y >= rowsHeight) return heights.length + (y - rowsHeight) / TRACK_H;
if (y >= rowsHeight) return heights.length + (y - rowsHeight) / trackHeight;
// First boundary strictly greater than y. Unlike the old linear scan this
// stays logarithmic for large timelines and uses the precomputed offsets.
@@ -200,6 +217,7 @@ export function createTimelineRowGeometry(
rowOffsets: offsets,
rowsHeight: offsets.at(-1) ?? 0,
padding: Object.freeze({ top: topPad, bottom: bottomPad }),
trackHeight,
canvasHeight: RULER_H + topPad + (offsets.at(-1) ?? 0) + bottomPad,
getRowIndex: (rowKey) => rowIndexByKey.get(rowKey) ?? -1,
getRowHeight,
@@ -1,4 +1,4 @@
import { RULER_H, CLIP_Y, TRACK_H, type TimelineRowGeometry } from "./timelineLayout";
import { RULER_H, CLIP_Y, type TimelineRowGeometry } from "./timelineLayout";
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
import { queryTimelineClipIndex, type TimelineClipIndex } from "../lib/timelineClipIndex";
import type { TimelineElement } from "../store/playerStore";
@@ -79,7 +79,7 @@ export function getTimelineClipRect(
left: contentOrigin + clip.start * pps,
top: rowGeometry.getRowTop(row) + CLIP_Y,
width: Math.max(clip.duration * pps, MIN_CLIP_W),
height: TRACK_H - CLIP_Y * 2,
height: rowGeometry.trackHeight - CLIP_Y * 2,
};
}
@@ -8,7 +8,7 @@
* they are the seam.
*/
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
@@ -105,6 +105,7 @@ export function PropertyGroupHeaderRow({
onSeek?: (time: number) => void;
rovingTargetId: string | null;
}) {
const trackHeight = usePlayerStore((s) => s.trackHeight);
const elementId = expandedElement.key ?? expandedElement.id;
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
lane,
@@ -119,10 +120,10 @@ export function PropertyGroupHeaderRow({
data-timeline-element-id={elementId}
tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
data-property-group={lane.group}
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
data-timeline-lane-top={getTimelineLaneTop(laneIndex, trackHeight)}
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-text-2"
style={{
top: getTimelineLaneTop(laneIndex),
top: getTimelineLaneTop(laneIndex, trackHeight),
// The header column narrows to contentOrigin whenever that is under
// LABEL_COL_W; a lane row pinned to LABEL_COL_W then hangs its value
// readout over the canvas, on top of the clips it is labelling.
@@ -1,6 +1,6 @@
import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { RULER_H, TRACK_H, type TimelineRowGeometry } from "./timelineLayout";
import { RULER_H, type TimelineRowGeometry } from "./timelineLayout";
import {
isTimelineNavigationKey,
locateTimelineLogicalTarget,
@@ -88,8 +88,8 @@ export function scrollKeyboardInsertRow(
): void {
const top = geometry.getRowTop(row);
if (top < viewport.scrollTop + RULER_H) viewport.scrollTop = Math.max(0, top - RULER_H);
else if (top + TRACK_H > viewport.scrollTop + viewport.clientHeight)
viewport.scrollTop = top + TRACK_H - viewport.clientHeight;
else if (top + geometry.trackHeight > viewport.scrollTop + viewport.clientHeight)
viewport.scrollTop = top + geometry.trackHeight - viewport.clientHeight;
}
export function timelineKeyboardEventTarget(
@@ -8,7 +8,6 @@ import type { DraggedClipState } from "./timelineClipDragTypes";
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
import {
TRACK_H,
createTimelineRowGeometry,
type TimelineRowGeometry,
type TimelineTrackPadding,
@@ -140,23 +139,24 @@ function computeLaneCounts(
/** Group anchor rows have no elements of their own (`groupTimelineTracks`
* pushes them as `[anchorKey, []]`), so `trackHeights` — which only ever
* looks at a row's clips — always gives them TRACK_H. Override those
* specific rows post-hoc: TRACK_H while collapsed, plus the group's own
* looks at a row's clips — always gives them one layer height. Override those
* specific rows post-hoc: one layer while collapsed, plus the group's own
* automation rows once its `∿` is open. */
function applyGroupStripHeights(
tracks: readonly (readonly [number, readonly TimelineElement[]])[],
rowHeights: number[],
groups: readonly TimelineTrackGroupInfo[],
expandedLaneOwnerIds: ReadonlySet<string>,
trackHeight: number,
): number[] {
if (groups.length === 0) return rowHeights;
const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
return tracks.map(([track], index) => {
const group = groupByAnchor.get(track);
if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? TRACK_H;
if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? trackHeight;
// The group's own automation rows, which its `∿` discloses. A row sized
// without them clipped every lane it had just promised in the count.
return TRACK_H + groupOwnLaneCount(group) * AUTOMATION_LANE_H;
return trackHeight + groupOwnLaneCount(group) * AUTOMATION_LANE_H;
});
}
@@ -172,6 +172,7 @@ function useTimelineRowHeights(
const padBottom = trackPadding?.bottom;
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds);
const trackHeight = usePlayerStore((s) => s.trackHeight);
const { laneCounts, rowGeometry } = useMemo(() => {
const laneCounts = computeLaneCounts(tracks, gsapAnimations);
// Keyframe lanes follow only the active clip, so a track with several
@@ -203,9 +204,10 @@ function useTimelineRowHeights(
});
const rowHeights = applyGroupStripHeights(
tracks,
trackHeights(heightTracks, expandedClipIds),
trackHeights(heightTracks, expandedClipIds, trackHeight),
groups,
expandedLaneOwnerIds,
trackHeight,
);
return {
laneCounts,
@@ -213,9 +215,11 @@ function useTimelineRowHeights(
tracks.map(([track]) => track),
rowHeights,
{ top: padTop, bottom: padBottom },
trackHeight,
),
};
}, [
trackHeight,
padTop,
padBottom,
expandedClipIds,
@@ -285,7 +289,7 @@ function useDisplayRowHeights(
() =>
displayTrackOrder.map((track) => {
const row = rowGeometry.getRowIndex(track);
return row < 0 ? TRACK_H : rowGeometry.getRowHeight(row);
return row < 0 ? rowGeometry.trackHeight : rowGeometry.getRowHeight(row);
}),
[displayTrackOrder, rowGeometry],
);
@@ -335,8 +339,14 @@ export function useTimelineDisplayLayout(
const displayTrackOrder = useDisplayTrackOrder(draggedClip, trackOrder);
const displayRowHeights = useDisplayRowHeights(displayTrackOrder, rowGeometry);
const displayRowGeometry = useMemo(
() => createTimelineRowGeometry(displayTrackOrder, displayRowHeights, rowGeometry.padding),
[displayTrackOrder, displayRowHeights, rowGeometry.padding],
() =>
createTimelineRowGeometry(
displayTrackOrder,
displayRowHeights,
rowGeometry.padding,
rowGeometry.trackHeight,
),
[displayTrackOrder, displayRowHeights, rowGeometry.padding, rowGeometry.trackHeight],
);
let draggedRowKey: number | undefined;
if (draggedClip?.started) {
@@ -2,6 +2,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { usePlayerStore, liveTime, whenPreviewBooted, type TimelineElement } from "./playerStore";
import { thumbnailRevisionOf } from "./thumbnailSlice";
import { TRACK_H, TRACK_H_MAX, TRACK_H_MIN } from "../components/timelineLayout";
/** The playback/selection state `reset()` restores (persistent prefs asserted separately). */
function expectResettableDefaults(state: ReturnType<typeof usePlayerStore.getState>): void {
@@ -573,6 +574,21 @@ describe("usePlayerStore", () => {
});
});
describe("setTrackHeight", () => {
it("keeps every layer between the shortest and tallest height", () => {
const { setTrackHeight } = usePlayerStore.getState();
setTrackHeight(TRACK_H_MIN - 10);
expect(usePlayerStore.getState().trackHeight).toBe(TRACK_H_MIN);
setTrackHeight(TRACK_H_MAX + 10);
expect(usePlayerStore.getState().trackHeight).toBe(TRACK_H_MAX);
setTrackHeight(Number.NaN);
expect(usePlayerStore.getState().trackHeight).toBe(TRACK_H);
setTrackHeight(33.4);
expect(usePlayerStore.getState().trackHeight).toBe(33);
setTrackHeight(TRACK_H);
});
});
describe("setManualZoomPercent", () => {
it("updates the manual zoom percent", () => {
usePlayerStore.getState().setManualZoomPercent(200);
@@ -10,6 +10,7 @@ import {
type TimelineTimeDisplayMode,
} from "../../utils/studioUiPreferences";
import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
import { TRACK_H, clampTrackHeight } from "../components/timelineLayout";
import { createKeyframeSlice, type KeyframeSlice } from "./keyframeSlice";
import {
createAutomationSelectionSlice,
@@ -84,6 +85,8 @@ interface PlayerState extends PlayerStoreSlices {
/** Timeline zoom percent relative to the fit width when in manual mode */
manualZoomPercent: number;
userZoomCount: number;
/** Height of every collapsed timeline layer, in px; one value for all layers. */
trackHeight: number;
/**
* Bumped on every live z-index edit (handleDomZIndexReorderCommit apply AND
* rollback). Flashless z commits (skipReload) never reload the iframe or
@@ -147,6 +150,7 @@ interface PlayerState extends PlayerStoreSlices {
updateElement: (elementId: string, updates: TimelineElementPatch) => void;
setZoomMode: (mode: ZoomMode) => void;
setManualZoomPercent: (percent: number) => void;
setTrackHeight: (height: number) => void;
bumpZEditVersion: () => void;
setInPoint: (time: number | null) => void;
setOutPoint: (time: number | null) => void;
@@ -281,6 +285,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
zoomMode: "fit",
manualZoomPercent: 100,
userZoomCount: 0,
trackHeight: TRACK_H,
zEditVersion: 0,
timelinePps: 100,
timelineFitPps: 100,
@@ -492,6 +497,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
set((state) => ({
manualZoomPercent: clampTimelineZoomPercent(percent, state.timelineFitPps),
})),
setTrackHeight: (height) => set({ trackHeight: clampTrackHeight(height) }),
bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })),
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),