mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-07 15:27:14 +00:00
feat(studio): resize every timeline layer at once from the toolbar or a layer's bottom edge
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user