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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-08 01:01:18 +09:00
committed by GitHub
parent f8ad47a418
commit a8d3411203
3 changed files with 110 additions and 1 deletions
@@ -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();
});
});
@@ -179,6 +179,11 @@ export const useCapturedTurn = (bookKey: string, viewRef: React.RefObject<Foliat
if (detail.phase === 'move') {
let state = dragRef.current;
if (!state) {
// Instant highlight engaged (still-hold on text) locks scrolling so
// the finger extends the highlight, not turns the page. The push
// paginator honors the same lock in its native swipe (paginator's
// #scrollLocked); mirror it here so slide/curl behaves identically.
if (currentView.renderer.scrollLocked) return false;
if (!viewSettings || viewSettings.disableSwipe) return false;
const style = getCapturedTurnStyle(viewSettings, isFixedLayout());
if (!style) return false;