diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 3151f4a0b..19041d5e0 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect } from "vitest"; import { + formatTimelineTickLabel, generateTicks, getDefaultDroppedTrack, getTimelineCanvasHeight, resolveTimelineAssetDrop, getTimelinePlayheadLeft, + getTimelineScrollLeftForZoomAnchor, getTimelineScrollLeftForZoomTransition, shouldHandleTimelineDeleteKey, shouldAutoScrollTimeline, @@ -78,6 +80,20 @@ describe("generateTicks", () => { expect(major[0]).toBe(0); } }); + + it("uses denser major labels as timeline zoom increases", () => { + const fitTicks = generateTicks(180, 10); + const zoomedTicks = generateTicks(180, 48); + expect(fitTicks.major[1] - fitTicks.major[0]).toBe(15); + expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5); + expect(zoomedTicks.minor).toContain(1); + expect(zoomedTicks.minor).toContain(4); + }); + + it("keeps labels readable instead of placing one at every tiny tick", () => { + const { major } = generateTicks(180, 80); + expect(major[1] - major[0]).toBe(2); + }); }); describe("formatTime", () => { @@ -118,6 +134,20 @@ describe("formatTime", () => { }); }); +describe("formatTimelineTickLabel", () => { + it("uses minute-second labels for normal timeline intervals", () => { + expect(formatTimelineTickLabel(90, 180, 5)).toBe("1:30"); + }); + + it("uses hour labels for long timelines", () => { + expect(formatTimelineTickLabel(3661, 4000, 60)).toBe("1:01:01"); + }); + + it("shows subsecond labels when the major ruler interval is below one second", () => { + expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("0:01.5"); + }); +}); + describe("shouldAutoScrollTimeline", () => { it("never auto-scrolls in fit mode", () => { expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false); @@ -145,6 +175,47 @@ describe("getTimelineScrollLeftForZoomTransition", () => { }); }); +describe("getTimelineScrollLeftForZoomAnchor", () => { + it("preserves the time under the pointer when zooming in", () => { + expect( + getTimelineScrollLeftForZoomAnchor({ + pointerX: 300, + currentScrollLeft: 200, + gutter: 32, + currentPixelsPerSecond: 10, + nextPixelsPerSecond: 20, + duration: 120, + }), + ).toBe(668); + }); + + it("clamps negative scroll targets", () => { + expect( + getTimelineScrollLeftForZoomAnchor({ + pointerX: 300, + currentScrollLeft: 0, + gutter: 32, + currentPixelsPerSecond: 20, + nextPixelsPerSecond: 5, + duration: 120, + }), + ).toBe(0); + }); + + it("preserves current scroll when inputs are invalid", () => { + expect( + getTimelineScrollLeftForZoomAnchor({ + pointerX: 300, + currentScrollLeft: 120, + gutter: 32, + currentPixelsPerSecond: 0, + nextPixelsPerSecond: 20, + duration: 120, + }), + ).toBe(120); + }); +}); + describe("getTimelinePlayheadLeft", () => { it("converts time to a pixel offset from the gutter", () => { expect(getTimelinePlayheadLeft(4, 20)).toBe(112); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 7dba1e138..13f971ba4 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -26,7 +26,7 @@ import { type TimelineTrackStyle, type TimelineTheme, } from "./timelineTheme"; -import { getTimelinePixelsPerSecond } from "./timelineZoom"; +import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom"; import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop"; /* ── Layout ─────────────────────────────────────────────────────── */ @@ -88,16 +88,47 @@ function getStyle(tag: string): TrackVisualStyle { } /* ── Tick Generation ────────────────────────────────────────────── */ -export function generateTicks(duration: number): { major: number[]; minor: number[] } { +function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number { + const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]; + if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) { + const targetMajorPx = 128; + return ( + zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600 + ); + } + const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60]; + const target = duration / 6; + return durationIntervals.find((interval) => interval >= target) ?? 60; +} + +function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number { + let interval = majorInterval / 2; + if (majorInterval >= 30) interval = majorInterval / 6; + else if (majorInterval >= 15) interval = majorInterval / 3; + else if (majorInterval >= 5) interval = majorInterval / 5; + else if (majorInterval >= 1) interval = majorInterval / 4; + + if ( + Number.isFinite(pixelsPerSecond) && + (pixelsPerSecond ?? 0) > 0 && + interval * (pixelsPerSecond ?? 0) < 20 + ) { + return Math.max(0.25, majorInterval / 2); + } + return Math.max(0.25, interval); +} + +export function generateTicks( + duration: number, + pixelsPerSecond?: number, +): { major: number[]; minor: number[] } { if (duration <= 0 || !Number.isFinite(duration) || duration > 7200) return { major: [], minor: [] }; - const intervals = [0.5, 1, 2, 5, 10, 15, 30, 60]; - const target = duration / 6; - const majorInterval = intervals.find((i) => i >= target) ?? 60; - const minorInterval = Math.max(0.25, majorInterval / 2); + const majorInterval = getMajorTickInterval(duration, pixelsPerSecond); + const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond); const major: number[] = []; const minor: number[] = []; - const maxTicks = 500; // Safety cap to prevent infinite loop + const maxTicks = 2000; // Safety cap to prevent runaway tick generation for ( let t = 0; t <= duration + 0.001 && major.length + minor.length < maxTicks; @@ -113,6 +144,25 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe return { major, minor }; } +export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) { + if (!Number.isFinite(time)) return "0:00"; + const safeTime = Math.max(0, time); + if (majorInterval < 1) { + const totalTenths = Math.round(safeTime * 10); + const wholeSeconds = Math.floor(totalTenths / 10); + const tenth = totalTenths % 10; + return `${formatTime(wholeSeconds)}.${tenth}`; + } + if (duration >= 3600 || safeTime >= 3600) { + const totalSeconds = Math.floor(safeTime); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } + return formatTime(safeTime); +} + export function shouldAutoScrollTimeline( zoomMode: ZoomMode, scrollWidth: number, @@ -132,6 +182,31 @@ export function getTimelineScrollLeftForZoomTransition( return currentScrollLeft; } +export function getTimelineScrollLeftForZoomAnchor(input: { + pointerX: number; + currentScrollLeft: number; + gutter: number; + currentPixelsPerSecond: number; + nextPixelsPerSecond: number; + duration: number; +}): number { + const currentPps = Math.max(0, input.currentPixelsPerSecond); + const nextPps = Math.max(0, input.nextPixelsPerSecond); + if ( + !Number.isFinite(input.pointerX) || + !Number.isFinite(input.currentScrollLeft) || + !Number.isFinite(input.duration) || + input.duration <= 0 || + currentPps <= 0 || + nextPps <= 0 + ) { + return Math.max(0, input.currentScrollLeft); + } + const timelineX = Math.max(0, input.currentScrollLeft + input.pointerX - input.gutter); + const timeAtPointer = Math.max(0, Math.min(input.duration, timelineX / currentPps)); + return Math.max(0, input.gutter + timeAtPointer * nextPps - input.pointerX); +} + export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number { if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER; return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond); @@ -306,6 +381,8 @@ export const Timeline = memo(function Timeline({ const currentTime = usePlayerStore((s) => s.currentTime); const zoomMode = usePlayerStore((s) => s.zoomMode); const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent); + const setZoomMode = usePlayerStore((s) => s.setZoomMode); + const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent); const playheadRef = useRef(null); const containerRef = useRef(null); const scrollRef = useRef(null); @@ -435,7 +512,11 @@ export const Timeline = memo(function Timeline({ const trackContentWidth = Math.max(0, effectiveDuration * pps); const zoomModeRef = useRef(zoomMode); zoomModeRef.current = zoomMode; + const manualZoomPercentRef = useRef(manualZoomPercent); + manualZoomPercentRef.current = manualZoomPercent; const previousZoomModeRef = useRef(zoomMode); + const fitPpsRef = useRef(fitPps); + fitPpsRef.current = fitPps; const durationRef = useRef(effectiveDuration); durationRef.current = effectiveDuration; @@ -925,7 +1006,12 @@ export const Timeline = memo(function Timeline({ cancelAnimationFrame(dragScrollRaf.current); }, []); - const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]); + const { major, minor } = useMemo( + () => generateTicks(effectiveDuration, pps), + [effectiveDuration, pps], + ); + const majorTickInterval = + major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration; const getPreviewElement = useCallback( (element: TimelineElement): TimelineElement => { if (resizingClip?.element.id === element.id) { @@ -1011,6 +1097,57 @@ export const Timeline = memo(function Timeline({ [onAssetDrop, onFileDrop], ); + const handlePinchWheel = useCallback( + (e: WheelEvent) => { + if (!e.ctrlKey) return; + const scroll = scrollRef.current; + if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const rect = scroll.getBoundingClientRect(); + const pointerX = e.clientX - rect.left; + const nextZoomPercent = getPinchTimelineZoomPercent( + e.deltaY, + zoomModeRef.current, + manualZoomPercentRef.current, + ); + if (nextZoomPercent === manualZoomPercentRef.current && zoomModeRef.current === "manual") { + return; + } + + const nextPps = fitPpsRef.current * (nextZoomPercent / 100); + const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({ + pointerX, + currentScrollLeft: scroll.scrollLeft, + gutter: GUTTER, + currentPixelsPerSecond: ppsRef.current, + nextPixelsPerSecond: nextPps, + duration: durationRef.current, + }); + + setZoomMode("manual"); + setManualZoomPercent(nextZoomPercent); + requestAnimationFrame(() => { + const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth); + scroll.scrollLeft = Math.min(maxScrollLeft, nextScrollLeft); + }); + }, + [setManualZoomPercent, setZoomMode], + ); + + useEffect(() => { + const scroll = scrollRef.current; + if (!scroll) return; + scroll.addEventListener("wheel", handlePinchWheel, { passive: false, capture: true }); + return () => { + scroll.removeEventListener("wheel", handlePinchWheel, { capture: true }); + }; + }, [handlePinchWheel, timelineReady, elements.length]); + if (!timelineReady || elements.length === 0) { return (
- {formatTime(t)} + {formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
diff --git a/packages/studio/src/player/components/timelineZoom.test.ts b/packages/studio/src/player/components/timelineZoom.test.ts index bcd961639..3e4a35cac 100644 --- a/packages/studio/src/player/components/timelineZoom.test.ts +++ b/packages/studio/src/player/components/timelineZoom.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { clampTimelineZoomPercent, getNextTimelineZoomPercent, + getPinchTimelineZoomPercent, getTimelinePixelsPerSecond, getTimelineZoomPercent, MAX_TIMELINE_ZOOM_PERCENT, @@ -60,3 +61,23 @@ describe("getNextTimelineZoomPercent", () => { ); }); }); + +describe("getPinchTimelineZoomPercent", () => { + it("zooms in for upward pinch wheel deltas", () => { + expect(getPinchTimelineZoomPercent(-80, "fit", 100)).toBeGreaterThan(100); + }); + + it("zooms out for downward pinch wheel deltas", () => { + expect(getPinchTimelineZoomPercent(80, "manual", 200)).toBeLessThan(200); + }); + + it("keeps the current zoom for zero or invalid deltas", () => { + expect(getPinchTimelineZoomPercent(0, "manual", 180)).toBe(180); + expect(getPinchTimelineZoomPercent(Number.NaN, "manual", 180)).toBe(180); + }); + + it("clamps pinch zoom to the supported range", () => { + expect(getPinchTimelineZoomPercent(10000, "manual", 100)).toBe(MIN_TIMELINE_ZOOM_PERCENT); + expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT); + }); +}); diff --git a/packages/studio/src/player/components/timelineZoom.ts b/packages/studio/src/player/components/timelineZoom.ts index abc15ca2b..0ac4a92b2 100644 --- a/packages/studio/src/player/components/timelineZoom.ts +++ b/packages/studio/src/player/components/timelineZoom.ts @@ -4,6 +4,7 @@ export const MIN_TIMELINE_ZOOM_PERCENT = 10; export const MAX_TIMELINE_ZOOM_PERCENT = 2000; const ZOOM_OUT_FACTOR = 0.8; const ZOOM_IN_FACTOR = 1.25; +const PINCH_ZOOM_SENSITIVITY = 0.0035; export function clampTimelineZoomPercent(percent: number): number { if (!Number.isFinite(percent)) return 100; @@ -36,3 +37,13 @@ export function getNextTimelineZoomPercent( const next = direction === "in" ? current * ZOOM_IN_FACTOR : current * ZOOM_OUT_FACTOR; return clampTimelineZoomPercent(next); } + +export function getPinchTimelineZoomPercent( + deltaY: number, + zoomMode: ZoomMode, + manualZoomPercent: number, +): number { + const current = getTimelineZoomPercent(zoomMode, manualZoomPercent); + if (!Number.isFinite(deltaY) || deltaY === 0) return current; + return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY)); +}