From a8d341120310571ec52b9ae5a2eba7cdd3bcde15 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 8 Jul 2026 01:01:18 +0900 Subject: [PATCH] fix(reader): gate captured slide/curl turn on scrollLocked like push (#5000) Instant Highlight engages after a 300ms still-hold on text and locks scrolling (renderer.scrollLocked) so the finger extends the highlight instead of turning the page. The push paginator honors that lock in its native swipe, but slide and page curl run through the app-side captured turn: applyPageTurnAttributes sets no-swipe, so the native swipe bows out and the captured-turn touch interceptor drives the turn instead. That interceptor started a page turn on any horizontal swipe without checking the lock, so a hold-then-swipe paginated with the slide/curl effect instead of extending the highlight. Gate the interceptor on renderer.scrollLocked before it begins a drag, mirroring the native swipe. Bump the foliate-js submodule (readest/foliate-js#51) to expose scrollLocked via a getter (it was write-only) and add a regression test. Co-authored-by: Claude Opus 4.8 (1M context) --- .../hooks/useCapturedTurn-scrollLock.test.ts | 104 ++++++++++++++++++ .../src/app/reader/hooks/useCapturedTurn.ts | 5 + packages/foliate-js | 2 +- 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 apps/readest-app/src/__tests__/hooks/useCapturedTurn-scrollLock.test.ts diff --git a/apps/readest-app/src/__tests__/hooks/useCapturedTurn-scrollLock.test.ts b/apps/readest-app/src/__tests__/hooks/useCapturedTurn-scrollLock.test.ts new file mode 100644 index 00000000..d3998eaa --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useCapturedTurn-scrollLock.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, renderHook } from '@testing-library/react'; +import type { FoliateView } from '@/types/view'; +import type { ViewSettings } from '@/types/book'; +import type { TouchDetail } from '@/app/reader/hooks/useTouchInterceptor'; + +// The captured page-turn (slide/curl) swipe is handled by an app-side touch +// interceptor because `no-swipe` disables the paginator's own swipe. Push mode +// stays on the paginator's native swipe, which bows out while `scrollLocked` +// is set (instant highlight engaged). This test pins the captured turn to the +// same gate so a hold-then-swipe extends the highlight instead of paginating. +const h = vi.hoisted(() => ({ + controller: { + turn: vi.fn(async () => {}), + beginDrag: vi.fn(async () => true), + moveDrag: vi.fn(), + endDrag: vi.fn(async () => {}), + dispose: vi.fn(), + }, + renderer: { + scrollLocked: false, + atEnd: false, + atStart: false, + hasAttribute: () => false, + setAttribute: () => {}, + removeAttribute: () => {}, + }, + viewSettings: { + pageTurnStyle: 'curl', + animated: true, + scrolled: false, + disableSwipe: false, + isEink: false, + rtl: false, + } as ViewSettings, +})); + +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ getViewSettings: () => h.viewSettings }), +})); +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ getBookData: () => ({ isFixedLayout: false }) }), +})); +vi.mock('@/utils/bridge', () => ({ captureWebviewRegion: vi.fn() })); +vi.mock('@/utils/viewTransition', () => ({ detectViewTransitionGroup: () => false })); +vi.mock('@/app/reader/utils/capturedTurn', () => ({ + CapturedPageTurn: class { + constructor() { + Object.assign(this, h.controller); + } + }, +})); + +import { useCapturedTurn } from '@/app/reader/hooks/useCapturedTurn'; +import { dispatchTouchInterceptors } from '@/app/reader/hooks/useTouchInterceptor'; + +const makeView = () => + ({ renderer: h.renderer, prev: vi.fn(), next: vi.fn() }) as unknown as FoliateView; + +const detail = (phase: TouchDetail['phase'], deltaX = 0, deltaY = 0): TouchDetail => ({ + phase, + touch: { screenX: 0, screenY: 0 }, + touchStart: { screenX: 0, screenY: 0 }, + deltaX, + deltaY, + deltaT: 16, +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM', 'tauri'); + h.renderer.scrollLocked = false; + h.renderer.atEnd = false; + h.renderer.atStart = false; +}); + +afterEach(() => { + vi.unstubAllEnvs(); + cleanup(); +}); + +describe('useCapturedTurn scroll-lock gate', () => { + test('a horizontal swipe starts the captured turn when scroll is not locked', () => { + renderHook(() => useCapturedTurn('book-1', { current: makeView() })); + + dispatchTouchInterceptors('book-1', detail('start')); + const consumed = dispatchTouchInterceptors('book-1', detail('move', -60, 3)); + + expect(consumed).toBe(true); + expect(h.controller.beginDrag).toHaveBeenCalled(); + }); + + test('scroll lock (instant highlight engaged) leaves the swipe to the highlight', () => { + renderHook(() => useCapturedTurn('book-1', { current: makeView() })); + + // Instant highlight has engaged after the still-hold: it locks scrolling. + h.renderer.scrollLocked = true; + dispatchTouchInterceptors('book-1', detail('start')); + const consumed = dispatchTouchInterceptors('book-1', detail('move', -60, 3)); + + expect(consumed).toBe(false); + expect(h.controller.beginDrag).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/app/reader/hooks/useCapturedTurn.ts b/apps/readest-app/src/app/reader/hooks/useCapturedTurn.ts index 07685e68..d08f2fa2 100644 --- a/apps/readest-app/src/app/reader/hooks/useCapturedTurn.ts +++ b/apps/readest-app/src/app/reader/hooks/useCapturedTurn.ts @@ -179,6 +179,11 @@ export const useCapturedTurn = (bookKey: string, viewRef: React.RefObject