Skip to content

Commit 2abcdac

Browse files
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.
1 parent 55b595d commit 2abcdac

4 files changed

Lines changed: 59 additions & 16 deletions

File tree

‎packages/studio/src/hooks/appHotkeysDispatch.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,7 @@ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
99
import { canSplitElement } from "../utils/timelineElementSplit";
1010
import { trackStudioEvent } from "../utils/studioTelemetry";
1111
import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
12-
import {
13-
canZoomToSelectedClips,
14-
zoomTimelineToSelectedClips,
15-
} from "../player/components/timelineZoomInput";
12+
import { zoomTimelineToSelectedClips } from "../player/components/timelineZoomInput";
1613
import { openAudioGainDialog } from "../player/components/audioGainDialogStore";
1714
import type { LinkShortcutCallbacks } from "./linkShortcuts";
1815

@@ -161,9 +158,8 @@ export function dispatchModifierKey(
161158
export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void {
162159
if (ownsPlainKeys(event.target)) return;
163160
if (key === STUDIO_PLAIN_KEYS.zoomSelection && !event.shiftKey && !event.altKey) {
164-
if (!canZoomToSelectedClips(usePlayerStore.getState())) return;
161+
if (!zoomTimelineToSelectedClips()) return;
165162
event.preventDefault();
166-
zoomTimelineToSelectedClips();
167163
return;
168164
}
169165

‎packages/studio/src/hooks/useAppHotkeys.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { clearAutomationClipboard, copyRange } from "../player/components/automa
77
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
88
import type { TimelineElement } from "../player/store/timelineElement";
99
import { useAudioGainDialogStore } from "../player/components/audioGainDialogStore";
10+
import {
11+
cancelTimelineZoom,
12+
registerTimelineZoomViewport,
13+
} from "../player/components/timelineZoomInput";
1014

1115
/** Minimal valid fixture — TimelineElement only requires these five fields. */
1216
const bgmElement: TimelineElement = {
@@ -501,6 +505,17 @@ describe("dispatchPlainKey — G opens Audio Gain", () => {
501505
});
502506

503507
it("Z zooms when a clip is selected and does not take the key otherwise", () => {
508+
const scroll = document.createElement("div");
509+
Object.defineProperties(scroll, {
510+
clientWidth: { value: 1080 },
511+
scrollWidth: { value: 1080 },
512+
scrollLeft: { value: 0, writable: true },
513+
});
514+
const unregister = registerTimelineZoomViewport({
515+
scroll,
516+
contentOrigin: 32,
517+
publishScroll: () => {},
518+
});
504519
usePlayerStore.setState({
505520
elements: [bgmElement],
506521
selectedElementId: "bgm",
@@ -509,6 +524,28 @@ describe("dispatchPlainKey — G opens Audio Gain", () => {
509524
const event = press("z");
510525
dispatchPlainKey(event, "z", callbacks());
511526
expect(event.defaultPrevented).toBe(true);
527+
cancelTimelineZoom();
528+
unregister();
529+
530+
const unmounted = press("z");
531+
dispatchPlainKey(unmounted, "z", callbacks());
532+
expect(unmounted.defaultPrevented).toBe(false);
533+
534+
const collapsedScroll = document.createElement("div");
535+
Object.defineProperties(collapsedScroll, {
536+
clientWidth: { value: 32 },
537+
scrollWidth: { value: 32 },
538+
scrollLeft: { value: 0, writable: true },
539+
});
540+
const unregisterCollapsed = registerTimelineZoomViewport({
541+
scroll: collapsedScroll,
542+
contentOrigin: 32,
543+
publishScroll: () => {},
544+
});
545+
const collapsed = press("z");
546+
dispatchPlainKey(collapsed, "z", callbacks());
547+
expect(collapsed.defaultPrevented).toBe(false);
548+
unregisterCollapsed();
512549

513550
usePlayerStore.setState({ selectedElementId: null, selectedElementIds: new Set() });
514551
const idle = press("z");

‎packages/studio/src/player/components/timelineZoomInput.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,4 +470,17 @@ describe("zoomTimelineStep", () => {
470470
// The range's middle, 56 + 1000 / 2, is the playhead.
471471
expect(laidOutX(60)).toBeCloseTo(556);
472472
});
473+
474+
it("still doubles the scale when the timeline is no wider than its headers", () => {
475+
const scroll = document.createElement("div");
476+
Object.defineProperties(scroll, {
477+
clientWidth: { value: 32 },
478+
scrollWidth: { value: 32 },
479+
scrollLeft: { value: 0, writable: true },
480+
});
481+
unregisterViewport = registerTimelineZoomViewport({ scroll, contentOrigin: 32, publishScroll });
482+
zoomTimelineStep("in");
483+
vi.advanceTimersByTime(150);
484+
expect(usePlayerStore.getState().timelinePps).toBeCloseTo(20);
485+
});
473486
});

‎packages/studio/src/player/components/timelineZoomInput.ts‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,8 @@ export function zoomTimelineStep(direction: "in" | "out"): void {
458458
fitPps,
459459
);
460460
const view = viewport;
461-
if (!view || !(fitPps > 0)) {
461+
// A collapsed timeline has no track to fit a range into, so the step still changes the percent.
462+
if (!view || !(fitPps > 0) || !(view.scroll.clientWidth > view.contentOrigin)) {
462463
requestTimelineZoom(nextPercent);
463464
return;
464465
}
@@ -497,14 +498,10 @@ export function canZoomToSelectedClips(state: {
497498
export function zoomTimelineToSelectedClips(): boolean {
498499
const view = viewport;
499500
const state = usePlayerStore.getState();
500-
if (!view || !(state.timelineFitPps > 0) || !canZoomToSelectedClips(state)) return false;
501-
const zoom = zoomToSelectedClips({
502-
clips: selectedClipSpans(state),
503-
viewportWidth: view.scroll.clientWidth,
504-
contentOrigin: view.contentOrigin,
505-
fitPixelsPerSecond: state.timelineFitPps,
506-
});
507-
if (!zoom) return false;
508-
void zoomTimelineToRange(zoom.start, zoom.end);
501+
if (!view || !(state.timelineFitPps > 0) || !(view.scroll.clientWidth > view.contentOrigin))
502+
return false;
503+
const span = positiveClipSpan(selectedClipSpans(state));
504+
if (!span) return false;
505+
void zoomTimelineToRange(span.start, span.end);
509506
return true;
510507
}

0 commit comments

Comments
 (0)