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:
Daniel McSheehy
2026-10-25 23:22:54 +00:00
committed by GitHub
parent c8876431e9
commit d787681d12
9 changed files with 419 additions and 12 deletions
@@ -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;
}