diff --git a/.changeset/one-swipe-one-page.md b/.changeset/one-swipe-one-page.md new file mode 100644 index 0000000..706e9d7 --- /dev/null +++ b/.changeset/one-swipe-one-page.md @@ -0,0 +1,15 @@ +--- +'react-spring-lightbox': patch +--- + +One trackpad swipe now pages one image, instead of paging through many as its scroll slows down. + +- A trackpad sends wheel events for about a second after the fingers lift, as its scroll slows down. With 1.9.0, each of those events faster than 1.1px/ms paged. In jsdom, 10 recorded trackpad swipes from Safari and Chrome on macOS and Chrome on Windows each called `onNext` or `onPrev` 22 to 63 times. +- Now each wheel gesture pages once. A wheel gesture ends 140ms after its last wheel event. Fingers landing on the trackpad for another swipe stop the scroll, so once the scroll is under 0.2px/ms, the next swipe pages too, even before the first swipe's scroll ends. +- A mouse wheel spun quickly also pages once per spin. With 1.9.0, 6 recorded mouse wheel scrolls from Chrome, Edge, Firefox, and Safari paged 3 to 19 times each. +- Scrolling still pages only above 1.1px/ms, the first wheel event of a gesture still only measures its speed, and neither ctrl + wheel nor a zoomed-in image pages. +- Tested by replaying 20 recorded wheel streams from the [wheel-gestures](https://github.com/xiel/wheel-gestures) project with their timing, in jsdom and in Chromium. Each single swipe and mouse wheel scroll paged once, and a recorded double swipe paged twice. The Playwright wheel tests also pass in Firefox. A Mac trackpad was also tried by hand in the repository's demo app. A physical mouse and a Windows touchpad weren't tried by hand. + +Upgrade notes: + +- If a test sends several quick wheel events and expects the lightbox to page more than once, it now pages once. To page again, wait more than 140ms after the last wheel event, or slow the scroll to under 0.2px/ms first. diff --git a/e2e/paging.spec.ts b/e2e/paging.spec.ts index 5864ded..d67fd9b 100644 --- a/e2e/paging.spec.ts +++ b/e2e/paging.spec.ts @@ -281,7 +281,7 @@ test.describe('scrolling with a mouse wheel or trackpad', () => { expect(await shownImage(page)).toBe('Image 3'); }); - test('one trackpad swipe moves one image (#52)', async ({ page }) => { + test('one trackpad swipe moves one image', async ({ page }) => { await openHarness(page, 'index=0'); await page.mouse.move(640, 300); @@ -292,11 +292,58 @@ test.describe('scrolling with a mouse wheel or trackpad', () => { await settle(page); const { onNext } = await calls(page); - test.fail(); expect(onNext).toBe(1); }); }); +test.describe('swiping on a trackpad in Chromium', () => { + test.skip( + ({ browserName, isMobile }) => isMobile || browserName !== 'chromium', + 'Wheel events with timestamps through the DevTools protocol' + ); + + const fingers = (peak: number) => [0.1, 0.3, 0.6, 1].map((share) => Math.round(peak * share)); + + const momentum = (from: number) => { + const deltas: number[] = []; + for (let delta = from * 0.94; Math.abs(delta) >= 1; delta *= 0.94) { + deltas.push(Math.round(delta)); + } + return deltas; + }; + + const swipe = async (wheel: (point: Point, event: { deltaX: number }) => Promise, deltasX: number[]) => { + for (const deltaX of deltasX) { + await wheel([640, 300], { deltaX }); + } + }; + + test('one swipe moves one image, as it keeps scrolling after the fingers lift', async ({ page }) => { + await openHarness(page, 'index=0'); + const { wheel } = await chromiumInput(page); + + await swipe(wheel, [...fingers(-50), ...momentum(-50)]); + await settle(page); + + expect((await calls(page)).onNext).toBe(1); + expect(await shownImage(page)).toBe('Image 2'); + }); + + test('a second swipe moves another image, even before the first one stops scrolling', async ({ page }) => { + await openHarness(page, 'index=0'); + const { wheel } = await chromiumInput(page); + + await swipe(wheel, [...fingers(-50), ...momentum(-50).slice(0, 8)]); + // Fingers landing on the trackpad stop the scroll. The next wheel event moves 2px in 40ms. + await wheel([640, 300], { deltaX: -2, ms: 40 }); + await swipe(wheel, [...fingers(-50), ...momentum(-50)]); + await settle(page); + + expect((await calls(page)).onNext).toBe(2); + expect(await shownImage(page)).toBe('Image 3'); + }); +}); + test.describe('the keyboard', () => { test.skip(({ isMobile }) => isMobile, 'Keyboard input'); diff --git a/src/__tests__/devices.ts b/src/__tests__/devices.ts index 893d9a0..3d4b875 100644 --- a/src/__tests__/devices.ts +++ b/src/__tests__/devices.ts @@ -98,6 +98,12 @@ export const trackpad = { await advance(FRAME); } }, + swipe: async (target: Element, deltasX: number[]) => { + for (const deltaX of deltasX) { + wheel(target, { deltaX }); + await advance(FRAME); + } + }, }; type Finger = { id: number; point: Point }; diff --git a/src/__tests__/paging.test.tsx b/src/__tests__/paging.test.tsx index 830f533..5673274 100644 --- a/src/__tests__/paging.test.tsx +++ b/src/__tests__/paging.test.tsx @@ -40,6 +40,16 @@ const SHORT_LEFT: Point[] = [ [560, 300], ]; +const fingers = (peak: number) => [0.1, 0.3, 0.6, 1].map((share) => Math.round(peak * share)); + +const momentum = (from: number) => { + const deltas: number[] = []; + for (let delta = from * 0.94; Math.abs(delta) >= 1; delta *= 0.94) { + deltas.push(Math.round(delta)); + } + return deltas; +}; + let layout: ReturnType; beforeEach(() => { @@ -372,7 +382,7 @@ describe('scrolling with a mouse wheel or trackpad', () => { expect(shownImage()).toBe('Image 3'); }); - test.fails('one trackpad swipe pages one image (#52)', async () => { + test('one trackpad swipe pages one image', async () => { const props = lightboxProps({ currentIndex: START }); render(); @@ -380,6 +390,69 @@ describe('scrolling with a mouse wheel or trackpad', () => { expect(props.onNext).toHaveBeenCalledTimes(1); }); + + test('one trackpad swipe pages one image, as it keeps scrolling after the fingers lift', async () => { + const props = lightboxProps({ currentIndex: START }); + render(); + + await trackpad.swipe(pageOf(3), [...fingers(-50), ...momentum(-50)]); + await settle(); + + expect(props.onNext).toHaveBeenCalledOnce(); + }); + + test('a swipe on a Windows touchpad pages once, though Chrome sends an empty wheel event as the fingers lift', async () => { + const props = lightboxProps({ currentIndex: START }); + render(); + + await trackpad.swipe(pageOf(3), [...fingers(-50), 0, -50, ...momentum(-50)]); + await settle(); + + expect(props.onNext).toHaveBeenCalledOnce(); + }); + + test('a swipe that slows down and speeds up again, without stopping, pages once', async () => { + const props = lightboxProps({ currentIndex: START }); + render(); + + await trackpad.swipe(pageOf(3), [...fingers(-50), -20, -8, -8, -20, -35, -50, ...momentum(-50)]); + await settle(); + + expect(props.onNext).toHaveBeenCalledOnce(); + }); + + test.each([ + ['the same way', -50, 2, 0], + ['back', 50, 1, 1], + ])( + 'a second swipe %s pages again, even before the first one stops scrolling', + async (_, secondPeak, nextCalls, prevCalls) => { + const props = lightboxProps({ currentIndex: START }); + render(); + + await trackpad.swipe(pageOf(3), [...fingers(-50), ...momentum(-50).slice(0, 8)]); + // Fingers landing on the trackpad stop the scroll. The next wheel event moves 2px in 40ms. + await advance(40 - FRAME); + await trackpad.swipe(pageOf(3), [-2, ...fingers(secondPeak), ...momentum(secondPeak)]); + await settle(); + + expect(props.onNext).toHaveBeenCalledTimes(nextCalls); + expect(props.onPrev).toHaveBeenCalledTimes(prevCalls); + } + ); + + test('a mouse wheel spun quickly pages once', async () => { + const props = lightboxProps({ currentIndex: START }); + render(); + + for (let notch = 0; notch < 5; notch += 1) { + fireEvent.wheel(pageOf(3), { deltaY: 100 }); + await advance(50); + } + await settle(); + + expect(props.onPrev).toHaveBeenCalledOnce(); + }); }); describe('the keyboard', () => { diff --git a/src/components/ImageStage/components/ImagePager/index.tsx b/src/components/ImageStage/components/ImagePager/index.tsx index 764410e..6c434da 100644 --- a/src/components/ImageStage/components/ImagePager/index.tsx +++ b/src/components/ImageStage/components/ImagePager/index.tsx @@ -162,7 +162,13 @@ const ImagePager = ({ // Add small timeout buffer to prevent event handlers from firing in child Images setTimeout(() => setIsDragging(false), 100); }, - onWheel: ({ ctrlKey, delta: [xDelta, yDelta], last, velocity: [xVelocity, yVelocity] }) => { + onWheel: ({ + ctrlKey, + delta: [xDelta, yDelta], + last, + memo: canPage = true, + velocity: [xVelocity, yVelocity], + }) => { // react-use-gesture 9 reported one speed for both axes, and 0 for the // last wheel event, where @use-gesture repeats the one before it const velocity = last ? 0 : Math.hypot(xVelocity, yVelocity); @@ -176,6 +182,13 @@ const ImagePager = ({ setIsDragging(true); } + // A trackpad sends wheel events for about a second after the fingers lift, + // as its scroll slows down, so a swipe pages once. Fingers landing for another + // swipe stop the scroll, so the next swipe can page once the scroll nearly stops. + if (!canPage) { + return velocity < 0.2; + } + const draggedFastEnough = velocity > 1.1; // Handle next/prev image from valid drag @@ -188,6 +201,8 @@ const ImagePager = ({ onPrev(); } } + + return !draggedFastEnough; }, onWheelEnd: () => { springsApi.start((i) => getPagePositions(i));