diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index 89e3b8bb2b..704000526e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -2,7 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, - type PointerEvent, type WheelEvent, + type PointerEvent, } from 'react' import type { TrajectoryTurnModel } from './layout.ts' import { @@ -117,6 +117,8 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ anchorClientX: number recordIndex: number | null } | null>(null) + const rootRef = useRef(null) + const trackRef = useRef(null) const [draft, setDraft] = useState(null) const [hover, setHover] = useState(null) const [viewport, setViewport] = useState(null) @@ -189,10 +191,42 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ : rangeFraction(draft, domainStart, domainDuration) const visibleRange = draftFraction ?? committed const activeRange = draft ?? range + useEffect(() => { + const root = rootRef.current + if (root === null) return + const onWheel = (event: globalThis.WheelEvent): void => { + event.preventDefault() + const track = trackRef.current + if (track === null || model === null) return + setAnimateViewport(false) + const rect = track.getBoundingClientRect() + const anchorFraction = + clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) + const nextDuration = Math.min( + fullDuration, + Math.max( + Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration), + domainDuration * Math.exp(event.deltaY * 0.0015), + ), + ) + if (nextDuration >= fullDuration * 0.999) { + setViewport(null) + return + } + const anchorTime = domainStart + anchorFraction * domainDuration + const nextStart = Math.min( + Math.max(anchorTime - anchorFraction * nextDuration, model.start), + model.end - nextDuration, + ) + setViewport({ start: nextStart, end: nextStart + nextDuration }) + } + root.addEventListener('wheel', onWheel, { passive: false }) + return () => { root.removeEventListener('wheel', onWheel) } + }, [domainDuration, domainStart, fullDuration, mode, model]) if (model === null) { return ( -
+
@@ -339,36 +373,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ setHover(null) } - const onWheel = (event: WheelEvent) => { - event.preventDefault() - setAnimateViewport(false) - const rect = event.currentTarget.getBoundingClientRect() - const anchorFraction = - clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) - const nextDuration = Math.min( - fullDuration, - Math.max( - Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration), - domainDuration * Math.exp(event.deltaY * 0.0015), - ), - ) - if (nextDuration >= fullDuration * 0.999) { - setViewport(null) - return - } - const anchorTime = domainStart + anchorFraction * domainDuration - const nextStart = Math.min( - Math.max(anchorTime - anchorFraction * nextDuration, model.start), - model.end - nextDuration, - ) - setViewport({ start: nextStart, end: nextStart + nextDuration }) - } - return ( -
+
{ event.preventDefault() setAnimateViewport(false) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 2d3cf02e8a..ff390d45ea 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -391,6 +391,28 @@ describe('timeline projection', () => { }], }] satisfies readonly TrajectoryTurnModel[] + it('cancels native scrolling across the timeline while zooming', () => { + render( + , + ) + const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ + x: 44, y: 0, left: 44, top: 0, right: 144, bottom: 50, width: 100, height: 50, + toJSON: () => ({}), + }) + + expect(fireEvent.wheel(plot, { clientX: 94, deltaY: -100 })).toBe(false) + expect(fireEvent.wheel(screen.getByText('Input'), { + clientX: 20, + deltaY: -100, + })).toBe(false) + }) + it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => { const onRangeChange = vi.fn() const view = render(