mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
feat(studio): zoom the timeline to the selected clips (#5824)
* feat(studio): zoom the timeline to the selected clips Toolbar action and Z fit the selected clips to the visible width, with the range margin, and stay disabled until something is selected. * fix(studio): gate zoom-to-selection on a resolvable clip The scroll clamp already lives in the timeline zoom request. Drop the unused copy, and disable the button and Z unless the selection matches a timeline clip with duration. A collapsed timeline cancels zoomTimelineToRange instead of laying out a percent the view cannot show. * fix(studio): keep a zoom step when the timeline is collapsed A collapsed timeline has no track for a range zoom, so zoom in and out step the percent directly. Z is consumed only when that zoom actually starts, and zoom-to-selection no longer builds the span three times. * ci: retrigger checks after workflow approval The approved run was cancelled during checkout, so the required checks never executed.
This commit is contained in:
@@ -271,11 +271,18 @@ describe("TimelineToolbar Fit", () => {
|
||||
timelineFitPps: 10,
|
||||
timelinePps: 10,
|
||||
});
|
||||
return registerTimelineZoomViewport({ scroll, contentOrigin: 32, publishScroll: () => {} });
|
||||
return {
|
||||
scroll,
|
||||
unregister: registerTimelineZoomViewport({
|
||||
scroll,
|
||||
contentOrigin: 32,
|
||||
publishScroll: () => {},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it("moves the slider and its readout with a zoom while it is previewed", () => {
|
||||
const unregister = mountTimelineViewport();
|
||||
const { unregister } = mountTimelineViewport();
|
||||
const { host, root } = renderToolbar();
|
||||
const slider = host.querySelector<HTMLInputElement>('input[aria-label="Timeline zoom"]')!;
|
||||
const before = slider.value;
|
||||
@@ -296,7 +303,7 @@ describe("TimelineToolbar Fit", () => {
|
||||
});
|
||||
|
||||
it("keeps Fit when it is picked while a zoom waits to be laid out", () => {
|
||||
const unregister = mountTimelineViewport();
|
||||
const { unregister } = mountTimelineViewport();
|
||||
const { host, root } = renderToolbar();
|
||||
act(() =>
|
||||
host
|
||||
@@ -315,6 +322,113 @@ describe("TimelineToolbar Fit", () => {
|
||||
act(() => unregister());
|
||||
});
|
||||
|
||||
it("disables zoom-to-selection until a clip is selected", () => {
|
||||
usePlayerStore.setState({ selectedElementId: null, selectedElementIds: new Set() });
|
||||
const { host, root } = renderToolbar();
|
||||
const button = host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Select a clip to zoom the timeline"]',
|
||||
);
|
||||
expect(button?.disabled).toBe(true);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("disables zoom-to-selection when the selection is not a timeline clip", () => {
|
||||
usePlayerStore.setState({
|
||||
elements: [{ id: "a", key: "a", tag: "div", start: 0, duration: 4, track: 0 }],
|
||||
selectedElementId: "scene.html#child",
|
||||
selectedElementIds: new Set(["scene.html#child"]),
|
||||
});
|
||||
const { host, root } = renderToolbar();
|
||||
expect(
|
||||
host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Select a clip to zoom the timeline"]',
|
||||
)?.disabled,
|
||||
).toBe(true);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("disables zoom-to-selection when every selected clip has no duration", () => {
|
||||
usePlayerStore.setState({
|
||||
elements: [{ id: "a", key: "a", tag: "div", start: 2, duration: 0, track: 0 }],
|
||||
selectedElementId: "a",
|
||||
selectedElementIds: new Set(["a"]),
|
||||
});
|
||||
const { host, root } = renderToolbar();
|
||||
expect(
|
||||
host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Select a clip to zoom the timeline"]',
|
||||
)?.disabled,
|
||||
).toBe(true);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
function clickZoom(host: HTMLElement) {
|
||||
act(() => {
|
||||
host
|
||||
.querySelector('button[aria-label="Zoom to selected clips"]')
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
act(() => {
|
||||
for (let i = 0; i < 30; i++) vi.advanceTimersToNextFrame();
|
||||
});
|
||||
}
|
||||
|
||||
it("zooms the mounted timeline so the selected clips fill the width between the margins", () => {
|
||||
const { scroll, unregister } = mountTimelineViewport();
|
||||
usePlayerStore.setState({
|
||||
elements: [{ id: "a", key: "a", tag: "div", start: 10, duration: 20, track: 0 }],
|
||||
selectedElementId: "a",
|
||||
selectedElementIds: new Set(["a"]),
|
||||
});
|
||||
const { host, root } = renderToolbar();
|
||||
clickZoom(host);
|
||||
const pps = usePlayerStore.getState().timelinePps;
|
||||
expect(pps).toBeCloseTo(50);
|
||||
expect(32 + 10 * pps - scroll.scrollLeft).toBeCloseTo(56);
|
||||
expect(32 + 30 * pps - scroll.scrollLeft).toBeCloseTo(1056);
|
||||
act(() => root.unmount());
|
||||
act(() => unregister());
|
||||
usePlayerStore.setState({
|
||||
elements: [],
|
||||
selectedElementId: null,
|
||||
selectedElementIds: new Set(),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a clip at the start on screen without scrolling past zero", () => {
|
||||
const { scroll, unregister } = mountTimelineViewport();
|
||||
usePlayerStore.setState({
|
||||
elements: [{ id: "a", key: "a", tag: "div", start: 0, duration: 10, track: 0 }],
|
||||
selectedElementId: "a",
|
||||
selectedElementIds: new Set(["a"]),
|
||||
});
|
||||
const { host, root } = renderToolbar();
|
||||
clickZoom(host);
|
||||
expect(scroll.scrollLeft).toBe(0);
|
||||
const pps = usePlayerStore.getState().timelinePps;
|
||||
expect(32 + 10 * pps).toBeLessThanOrEqual(1080);
|
||||
act(() => root.unmount());
|
||||
act(() => unregister());
|
||||
});
|
||||
|
||||
it("keeps a clip at the end of the fit span inside the viewport", () => {
|
||||
const end = (1080 - 32 - 2) / 10;
|
||||
const { scroll, unregister } = mountTimelineViewport();
|
||||
usePlayerStore.setState({
|
||||
elements: [{ id: "a", key: "a", tag: "div", start: end - 10, duration: 10, track: 0 }],
|
||||
selectedElementId: "a",
|
||||
selectedElementIds: new Set(["a"]),
|
||||
});
|
||||
const { host, root } = renderToolbar();
|
||||
clickZoom(host);
|
||||
const pps = usePlayerStore.getState().timelinePps;
|
||||
expect(scroll.scrollLeft).toBeGreaterThan(0);
|
||||
expect(32 + (end - 10) * pps - scroll.scrollLeft).toBeGreaterThanOrEqual(0);
|
||||
expect(32 + end * pps - scroll.scrollLeft).toBeLessThanOrEqual(1080);
|
||||
act(() => root.unmount());
|
||||
act(() => unregister());
|
||||
});
|
||||
|
||||
it("shows Fit as a named icon and says whether fit is on", () => {
|
||||
const { host, root } = renderToolbar();
|
||||
const fit = () => host.querySelector('button[aria-label="Fit timeline to width"]');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useEffect, useRef, type ReactNode } from "react";
|
||||
import {
|
||||
ArrowsOutLineHorizontal,
|
||||
FrameCorners,
|
||||
Image,
|
||||
Magnet,
|
||||
MagnifyingGlassMinus,
|
||||
@@ -19,8 +20,10 @@ import {
|
||||
} from "../player/components/timelineZoom";
|
||||
import { useTimelineZoom } from "../player/components/useTimelineZoom";
|
||||
import {
|
||||
canZoomToSelectedClips,
|
||||
useShownTimelineZoomPercent,
|
||||
zoomTimelineStep,
|
||||
zoomTimelineToSelectedClips,
|
||||
} from "../player/components/timelineZoomInput";
|
||||
import { usePlayerStore, type TimelineElement } from "../player";
|
||||
import { Tooltip } from "./ui";
|
||||
@@ -37,7 +40,10 @@ import { LinkedSelectionToggle } from "./LinkedSelectionToggle";
|
||||
import { useProjectHasAudio } from "../utils/audioMeterMath";
|
||||
import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions";
|
||||
import { isTypingTarget } from "../utils/typingTarget";
|
||||
import { SPLIT_SHORTCUT_HINT } from "../player/components/studioShortcuts";
|
||||
import {
|
||||
SPLIT_SHORTCUT_HINT,
|
||||
ZOOM_SELECTION_SHORTCUT_HINT,
|
||||
} from "../player/components/studioShortcuts";
|
||||
import type { TimelineProps } from "../player/components/TimelineTypes";
|
||||
|
||||
interface DomEditSessionSlice extends EnableKeyframesSession {
|
||||
@@ -174,7 +180,13 @@ export const TimelineToolbar = memo(function TimelineToolbar({
|
||||
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
|
||||
// Subscribed, so the split button follows the selection, not only playhead ticks.
|
||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const canZoomSelection = canZoomToSelectedClips({
|
||||
elements,
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
});
|
||||
const timelineFitPps = usePlayerStore((s) => s.timelineFitPps);
|
||||
const { zoomMode, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
||||
const displayedTimelineZoomPercent = useShownTimelineZoomPercent();
|
||||
@@ -487,6 +499,25 @@ export const TimelineToolbar = memo(function TimelineToolbar({
|
||||
<Image size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={
|
||||
canZoomSelection
|
||||
? `Zoom to selected clips (${ZOOM_SELECTION_SHORTCUT_HINT})`
|
||||
: "Select a clip to zoom the timeline"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canZoomSelection}
|
||||
aria-label={
|
||||
canZoomSelection ? "Zoom to selected clips" : "Select a clip to zoom the timeline"
|
||||
}
|
||||
onClick={() => zoomTimelineToSelectedClips()}
|
||||
className={canZoomSelection ? flatIdle : flatDisabled}
|
||||
>
|
||||
<FrameCorners size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Fit timeline to width">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
||||
import { canSplitElement } from "../utils/timelineElementSplit";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
|
||||
import { zoomTimelineToSelectedClips } from "../player/components/timelineZoomInput";
|
||||
import { openAudioGainDialog } from "../player/components/audioGainDialogStore";
|
||||
import type { LinkShortcutCallbacks } from "./linkShortcuts";
|
||||
|
||||
@@ -156,6 +157,12 @@ export function dispatchModifierKey(
|
||||
* be asserted without standing up the whole hook. */
|
||||
export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
|
||||
if (ownsPlainKeys(event.target)) return;
|
||||
if (key === STUDIO_PLAIN_KEYS.zoomSelection && !event.shiftKey && !event.altKey) {
|
||||
if (!zoomTimelineToSelectedClips()) return;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STUDIO_PLAIN_KEYS.fullscreen && !event.shiftKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
if (document.fullscreenElement) void document.exitFullscreen();
|
||||
|
||||
@@ -7,6 +7,10 @@ import { clearAutomationClipboard, copyRange } from "../player/components/automa
|
||||
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
||||
import type { TimelineElement } from "../player/store/timelineElement";
|
||||
import { useAudioGainDialogStore } from "../player/components/audioGainDialogStore";
|
||||
import {
|
||||
cancelTimelineZoom,
|
||||
registerTimelineZoomViewport,
|
||||
} from "../player/components/timelineZoomInput";
|
||||
|
||||
/** Minimal valid fixture — TimelineElement only requires these five fields. */
|
||||
const bgmElement: TimelineElement = {
|
||||
@@ -500,6 +504,77 @@ describe("dispatchPlainKey — G opens Audio Gain", () => {
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("Z zooms when a clip is selected and does not take the key otherwise", () => {
|
||||
const scroll = document.createElement("div");
|
||||
Object.defineProperties(scroll, {
|
||||
clientWidth: { value: 1080 },
|
||||
scrollWidth: { value: 1080 },
|
||||
scrollLeft: { value: 0, writable: true },
|
||||
});
|
||||
const unregister = registerTimelineZoomViewport({
|
||||
scroll,
|
||||
contentOrigin: 32,
|
||||
publishScroll: () => {},
|
||||
});
|
||||
usePlayerStore.setState({
|
||||
elements: [bgmElement],
|
||||
selectedElementId: "bgm",
|
||||
selectedElementIds: new Set(["bgm"]),
|
||||
});
|
||||
const event = press("z");
|
||||
dispatchPlainKey(event, "z", callbacks());
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
cancelTimelineZoom();
|
||||
unregister();
|
||||
|
||||
const unmounted = press("z");
|
||||
dispatchPlainKey(unmounted, "z", callbacks());
|
||||
expect(unmounted.defaultPrevented).toBe(false);
|
||||
|
||||
const collapsedScroll = document.createElement("div");
|
||||
Object.defineProperties(collapsedScroll, {
|
||||
clientWidth: { value: 32 },
|
||||
scrollWidth: { value: 32 },
|
||||
scrollLeft: { value: 0, writable: true },
|
||||
});
|
||||
const unregisterCollapsed = registerTimelineZoomViewport({
|
||||
scroll: collapsedScroll,
|
||||
contentOrigin: 32,
|
||||
publishScroll: () => {},
|
||||
});
|
||||
const collapsed = press("z");
|
||||
dispatchPlainKey(collapsed, "z", callbacks());
|
||||
expect(collapsed.defaultPrevented).toBe(false);
|
||||
unregisterCollapsed();
|
||||
|
||||
usePlayerStore.setState({ selectedElementId: null, selectedElementIds: new Set() });
|
||||
const idle = press("z");
|
||||
dispatchPlainKey(idle, "z", callbacks());
|
||||
expect(idle.defaultPrevented).toBe(false);
|
||||
|
||||
const shifted = new KeyboardEvent("keydown", { key: "z", shiftKey: true, bubbles: true });
|
||||
dispatchPlainKey(shifted, "z", callbacks());
|
||||
expect(shifted.defaultPrevented).toBe(false);
|
||||
|
||||
usePlayerStore.setState({
|
||||
elements: [bgmElement],
|
||||
selectedElementId: "scene.html#child",
|
||||
selectedElementIds: new Set(["scene.html#child"]),
|
||||
});
|
||||
const child = press("z");
|
||||
dispatchPlainKey(child, "z", callbacks());
|
||||
expect(child.defaultPrevented).toBe(false);
|
||||
|
||||
usePlayerStore.setState({
|
||||
elements: [{ ...bgmElement, duration: 0 }],
|
||||
selectedElementId: "bgm",
|
||||
selectedElementIds: new Set(["bgm"]),
|
||||
});
|
||||
const zero = press("z");
|
||||
dispatchPlainKey(zero, "z", callbacks());
|
||||
expect(zero.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves G to the grid toggle when nothing selected has sound", () => {
|
||||
usePlayerStore.setState({ elements: [video], selectedElementId: "b-roll" });
|
||||
const event = press("g");
|
||||
|
||||
@@ -5,6 +5,7 @@ export const STUDIO_PLAIN_KEYS = {
|
||||
record: "r",
|
||||
addKeyframe: "k",
|
||||
audioGain: "g",
|
||||
zoomSelection: "z",
|
||||
} as const;
|
||||
|
||||
export interface ShortcutHint {
|
||||
@@ -20,6 +21,7 @@ export interface ShortcutSection {
|
||||
const hintKey = (key: string) => key.toUpperCase();
|
||||
|
||||
export const SPLIT_SHORTCUT_HINT = hintKey(STUDIO_PLAIN_KEYS.split);
|
||||
export const ZOOM_SELECTION_SHORTCUT_HINT = hintKey(STUDIO_PLAIN_KEYS.zoomSelection);
|
||||
|
||||
/** What PlayerControls' shortcuts panel lists unless an embedder passes its own sections. */
|
||||
export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
|
||||
@@ -54,6 +56,7 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
|
||||
{ key: "⌘V", label: "Paste element" },
|
||||
{ key: "⌘X", label: "Cut element" },
|
||||
{ key: SPLIT_SHORTCUT_HINT, label: "Split clip at playhead" },
|
||||
{ key: ZOOM_SELECTION_SHORTCUT_HINT, label: "Zoom timeline to selected clips" },
|
||||
{ key: hintKey(STUDIO_PLAIN_KEYS.audioGain), label: "Audio Gain for clips with sound" },
|
||||
{ key: "⇧Click", label: "Razor tool: split all tracks" },
|
||||
{ key: "[", label: "Select clips starting before the playhead" },
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
getTimelinePixelsPerSecond,
|
||||
getTimelineZoomPercent,
|
||||
MIN_TIMELINE_ZOOM_PERCENT,
|
||||
TIMELINE_RANGE_MARGIN_PX,
|
||||
timelineZoomPercentToSlider,
|
||||
timelineSliderToZoomPercent,
|
||||
zoomToSelectedClips,
|
||||
} from "./timelineZoom";
|
||||
|
||||
const FIT_PPS = 12;
|
||||
@@ -153,6 +155,52 @@ describe("computePinnedZoomPercent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const VIEW = { viewportWidth: 1080, contentOrigin: 32, fitPixelsPerSecond: 10 };
|
||||
|
||||
describe("zoomToSelectedClips", () => {
|
||||
const usable = VIEW.viewportWidth - VIEW.contentOrigin - TIMELINE_RANGE_MARGIN_PX * 2;
|
||||
|
||||
it("returns the span and the percent that fits it between the margins", () => {
|
||||
expect(zoomToSelectedClips({ ...VIEW, clips: [{ start: 10, end: 20 }] })).toEqual({
|
||||
start: 10,
|
||||
end: 20,
|
||||
zoomPercent: (usable / 10 / VIEW.fitPixelsPerSecond) * 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("unions several clips and skips a zero-length entry", () => {
|
||||
expect(
|
||||
zoomToSelectedClips({
|
||||
...VIEW,
|
||||
clips: [
|
||||
{ start: 28, end: 30 },
|
||||
{ start: 10, end: 12 },
|
||||
{ start: 0, end: 0 },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
start: 10,
|
||||
end: 30,
|
||||
zoomPercent: (usable / 20 / VIEW.fitPixelsPerSecond) * 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when nothing selectable is passed or the timeline is collapsed", () => {
|
||||
expect(zoomToSelectedClips({ ...VIEW, clips: [] })).toBeNull();
|
||||
expect(zoomToSelectedClips({ ...VIEW, clips: [{ start: 4, end: 4 }] })).toBeNull();
|
||||
expect(
|
||||
zoomToSelectedClips({
|
||||
...VIEW,
|
||||
viewportWidth: VIEW.contentOrigin,
|
||||
clips: [{ start: 0, end: 1 }],
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
zoomToSelectedClips({ ...VIEW, viewportWidth: 0, clips: [{ start: 0, end: 1 }] }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("timelineZoomPercentToSlider / timelineSliderToZoomPercent round-trip", () => {
|
||||
for (const percent of [10, 100, 500, 2000, MAX_ZOOM_PERCENT]) {
|
||||
it(`round-trips ${percent}% within ±1%`, () => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { ZoomMode } from "../store/playerStore";
|
||||
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
||||
|
||||
export const MIN_TIMELINE_ZOOM_PERCENT = 10;
|
||||
/** Inset so a range zoom does not sit on the viewport edge. Shared with the range box. */
|
||||
export const TIMELINE_RANGE_MARGIN_PX = 24;
|
||||
const MAX_TIMELINE_FRAME_WIDTH_PX = 48;
|
||||
// CapCut-strength steps: one button press / pinch gesture moves the zoom
|
||||
// meaningfully (user feedback, twice-doubled: 1.25×/0.8× + 0.0035 felt like
|
||||
@@ -119,3 +121,56 @@ export function timelineSliderToZoomPercent(slider: number, fitPixelsPerSecond:
|
||||
const logValue = LOG_MIN + (clampedSlider / 100) * (logMax - LOG_MIN);
|
||||
return clampTimelineZoomPercent(Math.exp(logValue), fitPixelsPerSecond);
|
||||
}
|
||||
|
||||
export interface ClipTimeSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface SelectedClipsZoom {
|
||||
start: number;
|
||||
end: number;
|
||||
zoomPercent: number;
|
||||
}
|
||||
|
||||
/** Union of clips with a positive duration. Zero-length entries are not a range. */
|
||||
export function positiveClipSpan(
|
||||
clips: readonly ClipTimeSpan[],
|
||||
): { start: number; end: number } | null {
|
||||
let start = Number.POSITIVE_INFINITY;
|
||||
let end = Number.NEGATIVE_INFINITY;
|
||||
for (const clip of clips) {
|
||||
if (!Number.isFinite(clip.start) || !Number.isFinite(clip.end) || !(clip.end > clip.start)) {
|
||||
continue;
|
||||
}
|
||||
if (clip.start < start) start = clip.start;
|
||||
if (clip.end > end) end = clip.end;
|
||||
}
|
||||
if (!Number.isFinite(start) || !(end > start)) return null;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function zoomToSelectedClips(input: {
|
||||
clips: readonly ClipTimeSpan[];
|
||||
viewportWidth: number;
|
||||
contentOrigin: number;
|
||||
fitPixelsPerSecond: number;
|
||||
marginPx?: number;
|
||||
}): SelectedClipsZoom | null {
|
||||
const margin = input.marginPx ?? TIMELINE_RANGE_MARGIN_PX;
|
||||
const span = positiveClipSpan(input.clips);
|
||||
const fitPps = input.fitPixelsPerSecond;
|
||||
const viewport = input.viewportWidth;
|
||||
const origin = input.contentOrigin;
|
||||
if (!span) return null;
|
||||
if (!Number.isFinite(fitPps) || !(fitPps > 0)) return null;
|
||||
// A collapsed timeline is no wider than its headers, so there is no track to fit.
|
||||
if (!Number.isFinite(viewport) || !Number.isFinite(origin) || !(viewport > origin)) return null;
|
||||
|
||||
const usable = Math.max(1, viewport - origin - margin * 2);
|
||||
const zoomPercent = clampTimelineZoomPercent(
|
||||
(usable / (span.end - span.start) / fitPps) * 100,
|
||||
fitPps,
|
||||
);
|
||||
return { start: span.start, end: span.end, zoomPercent };
|
||||
}
|
||||
|
||||
@@ -346,6 +346,20 @@ describe("zoomTimelineToRange", () => {
|
||||
abort.abort();
|
||||
await expect(result).resolves.toBe("cancelled");
|
||||
});
|
||||
|
||||
it("resolves cancelled when the timeline is no wider than its headers", async () => {
|
||||
const scroll = document.createElement("div");
|
||||
Object.defineProperties(scroll, {
|
||||
clientWidth: { value: 32 },
|
||||
scrollWidth: { value: 32 },
|
||||
scrollLeft: { value: 0, writable: true },
|
||||
});
|
||||
unregisterViewport = registerTimelineZoomViewport({ scroll, contentOrigin: 32, publishScroll });
|
||||
const result = zoomTimelineToRange(10, 20, { smooth: false });
|
||||
await expect(result).resolves.toBe("cancelled");
|
||||
expect(usePlayerStore.getState().zoomMode).toBe("fit");
|
||||
expect(usePlayerStore.getState().timelinePps).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentTimelineRange", () => {
|
||||
@@ -456,4 +470,17 @@ describe("zoomTimelineStep", () => {
|
||||
// The range's middle, 56 + 1000 / 2, is the playhead.
|
||||
expect(laidOutX(60)).toBeCloseTo(556);
|
||||
});
|
||||
|
||||
it("still doubles the scale when the timeline is no wider than its headers", () => {
|
||||
const scroll = document.createElement("div");
|
||||
Object.defineProperties(scroll, {
|
||||
clientWidth: { value: 32 },
|
||||
scrollWidth: { value: 32 },
|
||||
scrollLeft: { value: 0, writable: true },
|
||||
});
|
||||
unregisterViewport = registerTimelineZoomViewport({ scroll, contentOrigin: 32, publishScroll });
|
||||
zoomTimelineStep("in");
|
||||
vi.advanceTimersByTime(150);
|
||||
expect(usePlayerStore.getState().timelinePps).toBeCloseTo(20);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
getNextTimelineZoomPercent,
|
||||
getTimelinePixelsPerSecond,
|
||||
getTimelineZoomPercent,
|
||||
TIMELINE_RANGE_MARGIN_PX,
|
||||
positiveClipSpan,
|
||||
zoomToSelectedClips,
|
||||
type ClipTimeSpan,
|
||||
} from "./timelineZoom";
|
||||
|
||||
/** Where a zoom holds its place: `time` stays `x` px from the viewport's left edge. */
|
||||
@@ -41,7 +45,6 @@ interface ZoomPreview {
|
||||
byPerson: boolean;
|
||||
}
|
||||
|
||||
const RANGE_MARGIN_PX = 24;
|
||||
const SMOOTH_ZOOM_MS = 220;
|
||||
/** Past this the scaled-up preview turns soft. */
|
||||
const MAX_PREVIEW_SCALE = 4;
|
||||
@@ -329,8 +332,8 @@ const reducedMotion = () =>
|
||||
|
||||
/** The stretch a range zoom fills: from `x` px in, `width` px wide, inside the margins. */
|
||||
function rangeBox(view: TimelineZoomViewport): { x: number; width: number } {
|
||||
const x = view.contentOrigin + RANGE_MARGIN_PX;
|
||||
return { x, width: Math.max(1, view.scroll.clientWidth - x - RANGE_MARGIN_PX) };
|
||||
const x = view.contentOrigin + TIMELINE_RANGE_MARGIN_PX;
|
||||
return { x, width: Math.max(1, view.scroll.clientWidth - x - TIMELINE_RANGE_MARGIN_PX) };
|
||||
}
|
||||
|
||||
/** The seconds the timeline shows between its margins: what zoomTimelineToRange would fill. */
|
||||
@@ -364,16 +367,24 @@ function easeToRange(
|
||||
const view = viewport;
|
||||
const fitPps = usePlayerStore.getState().timelineFitPps;
|
||||
stopEase();
|
||||
if (!view || !(end > start) || !(fitPps > 0) || signal?.aborted)
|
||||
return Promise.resolve("cancelled");
|
||||
const zoom =
|
||||
view && !signal?.aborted
|
||||
? zoomToSelectedClips({
|
||||
clips: [{ start, end }],
|
||||
viewportWidth: view.scroll.clientWidth,
|
||||
contentOrigin: view.contentOrigin,
|
||||
fitPixelsPerSecond: fitPps,
|
||||
})
|
||||
: null;
|
||||
if (!view || !zoom) return Promise.resolve("cancelled");
|
||||
let resolveThis: (result: TimelineZoomResult) => void = () => {};
|
||||
const result = new Promise<TimelineZoomResult>((resolve) => (resolveThis = resolve));
|
||||
resolveEase = resolveThis;
|
||||
signal?.addEventListener("abort", () => resolveEase === resolveThis && stopEase(), {
|
||||
once: true,
|
||||
});
|
||||
const { x, width } = rangeBox(view);
|
||||
const toPercent = clampTimelineZoomPercent((width / (end - start) / fitPps) * 100, fitPps);
|
||||
const { x } = rangeBox(view);
|
||||
const toPercent = zoom.zoomPercent;
|
||||
const finish = () => {
|
||||
commitPreview();
|
||||
stopEase("done");
|
||||
@@ -447,7 +458,8 @@ export function zoomTimelineStep(direction: "in" | "out"): void {
|
||||
fitPps,
|
||||
);
|
||||
const view = viewport;
|
||||
if (!view || !(fitPps > 0)) {
|
||||
// A collapsed timeline has no track to fit a range into, so the step still changes the percent.
|
||||
if (!view || !(fitPps > 0) || !(view.scroll.clientWidth > view.contentOrigin)) {
|
||||
requestTimelineZoom(nextPercent);
|
||||
return;
|
||||
}
|
||||
@@ -458,3 +470,38 @@ export function zoomTimelineStep(direction: "in" | "out"): void {
|
||||
const start = at.time - (at.x - x) / nextPps;
|
||||
void easeToRange(start, start + width / nextPps, {}, true);
|
||||
}
|
||||
|
||||
function selectedClipSpans(state: {
|
||||
elements: readonly { key?: string | null; id: string; start: number; duration: number }[];
|
||||
selectedElementId: string | null;
|
||||
selectedElementIds: ReadonlySet<string>;
|
||||
}): ClipTimeSpan[] {
|
||||
const keys = new Set(state.selectedElementIds);
|
||||
if (state.selectedElementId) keys.add(state.selectedElementId);
|
||||
const clips: ClipTimeSpan[] = [];
|
||||
for (const el of state.elements) {
|
||||
if (!keys.has(el.key ?? el.id)) continue;
|
||||
clips.push({ start: el.start, end: el.start + el.duration });
|
||||
}
|
||||
return clips;
|
||||
}
|
||||
|
||||
/** Whether the selection matches a timeline clip the zoom can land on. */
|
||||
export function canZoomToSelectedClips(state: {
|
||||
elements: readonly { key?: string | null; id: string; start: number; duration: number }[];
|
||||
selectedElementId: string | null;
|
||||
selectedElementIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
return positiveClipSpan(selectedClipSpans(state)) !== null;
|
||||
}
|
||||
|
||||
export function zoomTimelineToSelectedClips(): boolean {
|
||||
const view = viewport;
|
||||
const state = usePlayerStore.getState();
|
||||
if (!view || !(state.timelineFitPps > 0) || !(view.scroll.clientWidth > view.contentOrigin))
|
||||
return false;
|
||||
const span = positiveClipSpan(selectedClipSpans(state));
|
||||
if (!span) return false;
|
||||
void zoomTimelineToRange(span.start, span.end);
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user