feat(reader): Auto Scroll reading mode for scrolled flow (#4998) (#4999)

Teleprompter-style continuous scrolling toggled from the View menu
(Shift+A), available only in scrolled mode. A PacedScroller drives
whole-pixel forward steps at a constant, user-adjustable velocity;
the speed (25-500 percent, persisted as autoScrollSpeed) is tuned
from a floating control pill that also offers pause/resume and exit,
and fades away while scrolling to keep the mode immersive.

Tapping the page pauses and resumes instead of turning pages or
toggling the bars; manual wheel or drag input simply composes with
the paced scrolling. Escape or leaving scrolled mode ends the
session. When forward progress stalls the session hops to the next
section (single-section scroll mode) or stops with a toast at the
end of the book. Vertical-writing books scroll along the horizontal
axis with the sign convention foliate uses for scrolled offsets.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-08 00:45:02 +09:00
committed by GitHub
parent 17de9357dd
commit f8ad47a418
46 changed files with 926 additions and 33 deletions
@@ -0,0 +1,184 @@
'use client';
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { MdAdd, MdClose, MdPause, MdPlayArrow, MdRemove } from 'react-icons/md';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTranslation } from '@/hooks/useTranslation';
import { MAX_AUTO_SCROLL_SPEED, MIN_AUTO_SCROLL_SPEED } from '@/services/constants';
const INITIAL_SHOW_DURATION = 2500;
const HIDE_DELAY = 2000;
interface AutoScrollControlProps {
bookKey: string;
paused: boolean;
speed: number;
onTogglePause: () => void;
onAdjustSpeed: (dir: 1 | -1) => void;
onStop: () => void;
gridInsets: Insets;
}
// The Auto Scroll session pill (#4998): speed /+ around the current
// percentage, pause/resume, and exit. Fades away while scrolling so the mode
// stays immersive; any mouse movement or pausing (tap on the page) brings it
// back. Same floating-pill chassis as ParagraphBar.
const AutoScrollControl: React.FC<AutoScrollControlProps> = ({
bookKey,
paused,
speed,
onTogglePause,
onAdjustSpeed,
onStop,
gridInsets,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { hoveredBookKey } = useReaderStore();
const iconSize = useResponsiveSize(18);
const [isBarVisible, setIsBarVisible] = useState(true);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isHoveredRef = useRef(false);
const pausedRef = useRef(paused);
pausedRef.current = paused;
const clearHideTimer = useCallback(() => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
}, []);
const startHideTimer = useCallback(
(delay: number = HIDE_DELAY) => {
clearHideTimer();
hideTimerRef.current = setTimeout(() => {
if (!isHoveredRef.current && !pausedRef.current) {
setIsBarVisible(false);
}
}, delay);
},
[clearHideTimer],
);
useEffect(() => {
if (paused) {
clearHideTimer();
setIsBarVisible(true);
} else {
startHideTimer(INITIAL_SHOW_DURATION);
}
return clearHideTimer;
}, [paused, startHideTimer, clearHideTimer]);
useEffect(() => {
let lastMoveTime = 0;
const handleMouseMove = () => {
const now = Date.now();
if (now - lastMoveTime < 100) return;
lastMoveTime = now;
setIsBarVisible(true);
startHideTimer();
};
window.addEventListener('mousemove', handleMouseMove, { passive: true });
return () => window.removeEventListener('mousemove', handleMouseMove);
}, [startHideTimer]);
const isVisible = isBarVisible && hoveredBookKey !== bookKey;
const buttonClass = clsx(
'flex items-center justify-center rounded-full p-1.5',
'transition-all duration-200 ease-out',
'not-eink:hover:bg-base-200 active:scale-90',
);
return (
<div
className={clsx(
// `absolute` so the pill centers on the book's grid cell rather than
// the viewport: with a pinned sidebar (or a split view) the reading
// column is off the window center and the pill must stay under the
// text it controls.
'absolute bottom-6 left-1/2 z-50 -translate-x-1/2',
'transition-[opacity,filter,transform] duration-200 ease-out',
isVisible
? 'pointer-events-auto translate-y-0 scale-100 opacity-100 blur-0'
: 'pointer-events-none translate-y-4 scale-90 opacity-0 blur-sm',
)}
style={{
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0,
}}
onMouseEnter={() => {
isHoveredRef.current = true;
clearHideTimer();
setIsBarVisible(true);
}}
onMouseLeave={() => {
isHoveredRef.current = false;
startHideTimer();
}}
>
<div
className={clsx(
'text-base-content flex items-center gap-1 rounded-full px-3 py-1.5',
'not-eink:bg-base-300 eink-bordered',
'not-eink:border-base-content/10 not-eink:border',
'shadow-sm backdrop-blur-md',
)}
>
<button
onClick={() => onAdjustSpeed(-1)}
disabled={speed <= MIN_AUTO_SCROLL_SPEED}
className={clsx(buttonClass, speed <= MIN_AUTO_SCROLL_SPEED && 'opacity-30')}
title={_('Slower')}
aria-label={_('Slower')}
>
<MdRemove size={iconSize} />
</button>
<div className='flex min-w-[3.5rem] items-center justify-center'>
<span className='text-sm font-medium tabular-nums'>{speed}%</span>
</div>
<button
onClick={() => onAdjustSpeed(1)}
disabled={speed >= MAX_AUTO_SCROLL_SPEED}
className={clsx(buttonClass, speed >= MAX_AUTO_SCROLL_SPEED && 'opacity-30')}
title={_('Faster')}
aria-label={_('Faster')}
>
<MdAdd size={iconSize} />
</button>
<div className='bg-base-content/10 mx-1 h-4 w-px' />
<button
onClick={onTogglePause}
className={buttonClass}
title={paused ? _('Play') : _('Pause')}
aria-label={paused ? _('Play') : _('Pause')}
>
{paused ? <MdPlayArrow size={iconSize} /> : <MdPause size={iconSize} />}
</button>
<div className='bg-base-content/10 mx-1 h-4 w-px' />
<button
onClick={onStop}
className={buttonClass}
title={_('Exit Auto Scroll')}
aria-label={_('Exit Auto Scroll')}
>
<MdClose size={iconSize} />
</button>
</div>
</div>
);
};
export default AutoScrollControl;
@@ -79,8 +79,10 @@ import { eventDispatcher } from '@/utils/event';
import { isFontType } from '@/utils/font';
import { getScrollGapAttr } from '@/utils/webtoon';
import { useMiddleClickAutoscroll } from '../hooks/useMiddleClickAutoscroll';
import { useAutoScroll } from '../hooks/useAutoScroll';
import { ParagraphControl } from './paragraph';
import AutoscrollIndicator from './AutoscrollIndicator';
import AutoScrollControl from './AutoScrollControl';
import Spinner from '@/components/Spinner';
import KOSyncConflictResolver from './KOSyncResolver';
import ImageViewer from './ImageViewer';
@@ -517,6 +519,7 @@ const FoliateViewer: React.FC<{
const mouseHandlers = useMouseEvent(bookKey, handlePageFlip);
const touchHandlers = useTouchEvent(bookKey);
const autoscrollAnchor = useMiddleClickAutoscroll(bookKey, viewRef, containerRef);
const autoScroll = useAutoScroll(bookKey, viewRef);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [selectedTableHtml, setSelectedTableHtml] = useState<string | null>(null);
@@ -963,6 +966,17 @@ const FoliateViewer: React.FC<{
{...touchHandlers}
/>
{autoscrollAnchor && <AutoscrollIndicator anchor={autoscrollAnchor} />}
{autoScroll.active && (
<AutoScrollControl
bookKey={bookKey}
paused={autoScroll.paused}
speed={autoScroll.speed}
onTogglePause={autoScroll.togglePause}
onAdjustSpeed={autoScroll.adjustSpeed}
onStop={autoScroll.stop}
gridInsets={gridInsets}
/>
)}
<BrightnessOverlay visible={overlayVisible} level={overlayLevel} />
<ParagraphControl bookKey={bookKey} viewRef={viewRef} gridInsets={gridInsets} />
{((!docLoaded.current && loading) || viewState?.loading) && (
@@ -123,6 +123,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
eventDispatcher.dispatch('rsvp-start', { bookKey });
};
const toggleAutoScroll = () => {
setIsDropdownOpen?.(false);
eventDispatcher.dispatch('autoscroll-toggle', { bookKey });
};
const handleShare = () => {
setIsDropdownOpen?.(false);
if (!bookData?.book) return;
@@ -382,6 +387,14 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
onClick={toggleScrolledMode}
/>
<MenuItem
label={_('Auto Scroll')}
shortcut='Shift+A'
Icon={viewState?.autoScrollEnabled ? MdCheck : undefined}
onClick={toggleAutoScroll}
disabled={!isScrolledMode}
/>
<hr aria-hidden='true' className='border-base-300 my-1' />
<MenuItem
@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { FoliateView } from '@/types/view';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { saveViewSettings } from '@/helpers/settings';
import {
AUTO_SCROLL_BASE_PX_PER_SEC,
AUTO_SCROLL_SPEED_STEP,
MAX_AUTO_SCROLL_SPEED,
MIN_AUTO_SCROLL_SPEED,
} from '@/services/constants';
import { PacedScroller } from '../utils/autoscroller';
// A stalled forward tick must persist this long before the session reacts
// (section hop, or stop at the end of the book) — brief stalls can happen
// while an adjacent section is still loading.
const AUTO_SCROLL_STALL_MS = 800;
export interface AutoScrollState {
active: boolean;
paused: boolean;
speed: number;
togglePause: () => void;
adjustSpeed: (dir: 1 | -1) => void;
stop: () => void;
}
// Auto Scroll reading mode (#4998): teleprompter-style continuous scrolling in
// scrolled mode, toggled via the 'autoscroll-toggle' event (View menu, Shift+A).
// A tap on the page pauses/resumes instead of turning the page or toggling the
// bars; manual wheel/drag input simply composes with the paced scrolling since
// every frame is a relative containerPosition step. Escape or leaving scrolled
// mode stops the session; the session state is never persisted.
export const useAutoScroll = (
bookKey: string,
viewRef: React.RefObject<FoliateView | null>,
): AutoScrollState => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getViewSettings, setAutoScrollEnabled } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const [active, setActive] = useState(false);
const [paused, setPaused] = useState(false);
const [speed, setSpeed] = useState(viewSettings?.autoScrollSpeed ?? 100);
// Undoes the per-session window listeners; set on start.
const sessionCleanupRef = useRef<(() => void) | null>(null);
const stallStartRef = useRef<number | null>(null);
const scrollerRef = useRef<PacedScroller | null>(null);
if (!scrollerRef.current) {
scrollerRef.current = new PacedScroller({
scrollBy: (delta) => {
const view = viewRef.current;
const renderer = view?.renderer;
if (!renderer) return;
// Vertical-writing books scroll along the horizontal axis in scrolled
// mode, where reading forward means going more negative (foliate
// paginator negates scrolled-mode offsets for vertical writing).
const sign = renderer.scrollProp === 'scrollLeft' ? -1 : 1;
const before = renderer.containerPosition;
renderer.containerPosition = before + sign * delta;
if (renderer.containerPosition === before) {
// Clamped by the scroll bounds: at the end of the rendered content.
const now = Date.now();
if (stallStartRef.current === null) {
stallStartRef.current = now;
} else if (now - stallStartRef.current >= AUTO_SCROLL_STALL_MS) {
stallStartRef.current = now;
if (renderer.atEnd) {
scrollerRef.current?.stop();
eventDispatcher.dispatch('toast', { message: _('End of book'), type: 'info' });
} else {
// Single-section scroll mode parks at the section end; hop to
// the next section and keep going.
view?.next();
}
}
} else {
stallStartRef.current = null;
}
},
onStop: () => {
setActive(false);
setPaused(false);
setAutoScrollEnabled(bookKey, false);
sessionCleanupRef.current?.();
sessionCleanupRef.current = null;
},
});
}
const togglePause = () => {
const scroller = scrollerRef.current!;
if (!scroller.active) return;
if (scroller.paused) scroller.resume();
else scroller.pause();
setPaused(scroller.paused);
};
const adjustSpeed = (dir: 1 | -1) => {
const current = getViewSettings(bookKey)?.autoScrollSpeed ?? speed;
const next = Math.min(
MAX_AUTO_SCROLL_SPEED,
Math.max(MIN_AUTO_SCROLL_SPEED, current + dir * AUTO_SCROLL_SPEED_STEP),
);
setSpeed(next);
scrollerRef.current!.setVelocity((AUTO_SCROLL_BASE_PX_PER_SEC * next) / 100);
saveViewSettings(envConfig, bookKey, 'autoScrollSpeed', next, false, false);
};
const startSession = () => {
const scroller = scrollerRef.current!;
const renderer = viewRef.current?.renderer;
if (!renderer?.scrolled) return;
const speedNow = getViewSettings(bookKey)?.autoScrollSpeed ?? 100;
setSpeed(speedNow);
stallStartRef.current = null;
scroller.start((AUTO_SCROLL_BASE_PX_PER_SEC * speedNow) / 100);
setActive(true);
setPaused(false);
setAutoScrollEnabled(bookKey, true);
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') scroller.stop();
};
window.addEventListener('keydown', onKeydown, true);
sessionCleanupRef.current = () => {
window.removeEventListener('keydown', onKeydown, true);
};
};
useEffect(() => {
const scroller = scrollerRef.current!;
const onToggle = (event: CustomEvent) => {
if (event.detail?.bookKey !== bookKey) return;
if (scroller.active) scroller.stop();
else startSession();
};
// Escape typed inside the book iframe arrives as a forwarded message.
const onMessage = (msg: MessageEvent) => {
if (msg.data?.type === 'iframe-keydown' && msg.data.key === 'Escape') scroller.stop();
};
// A tap on the page pauses/resumes the session; consuming the click keeps
// it from also turning the page or toggling the header/footer bars.
const onSingleClick = () => {
if (!scroller.active) return false;
togglePause();
return true;
};
eventDispatcher.on('autoscroll-toggle', onToggle);
eventDispatcher.onSync('iframe-single-click', onSingleClick);
window.addEventListener('message', onMessage);
return () => {
eventDispatcher.off('autoscroll-toggle', onToggle);
eventDispatcher.offSync('iframe-single-click', onSingleClick);
window.removeEventListener('message', onMessage);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
// Auto Scroll only exists in scrolled mode; leaving it ends the session.
const scrolled = !!viewSettings?.scrolled;
useEffect(() => {
if (!scrolled) scrollerRef.current?.stop();
}, [scrolled]);
useEffect(() => {
return () => scrollerRef.current?.stop();
}, []);
return {
active,
paused,
speed,
togglePause,
adjustSpeed,
stop: () => scrollerRef.current?.stop(),
};
};
@@ -360,6 +360,14 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
eventDispatcher.dispatch('rsvp-start', { bookKey: sideBarBookKey });
};
const toggleAutoScroll = () => {
if (!sideBarBookKey) return;
// Auto Scroll only exists in scrolled mode (#4998); the View menu item is
// disabled outside it, so the shortcut silently no-ops there too.
if (!getViewSettings(sideBarBookKey)?.scrolled) return;
eventDispatcher.dispatch('autoscroll-toggle', { bookKey: sideBarBookKey });
};
const handlePinchZoom = (event: CustomEvent) => {
const zoomLevel = event.detail?.zoomLevel;
if (zoomLevel != null) {
@@ -393,6 +401,7 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
onToggleBookmark: toggleBookmark,
onToggleParagraphMode: toggleParagraphMode,
onStartRSVP: startRSVP,
onToggleAutoScroll: toggleAutoScroll,
onToggleToolbar: toggleToolbar,
onOpenFontLayoutSettings: () => setSettingsDialogOpen(true),
onShowSearchBar: showSearchBar,
@@ -108,3 +108,94 @@ export class Autoscroller {
this.#requestFrame();
}
}
// Frame gaps above this are treated as this long: rAF stops in background tabs,
// and scrolling through the whole gap on the resume frame would jump the text.
export const PACED_SCROLL_MAX_FRAME_MS = 100;
// Auto Scroll reading mode core (#4998): teleprompter-style scrolling at a
// constant caller-set velocity, with pause/resume. Emits whole-pixel forward
// deltas with fractional carry like Autoscroller; the caller owns the scroll
// direction sign. Pure logic with an injectable rAF/clock for tests.
export class PacedScroller {
#opts: AutoscrollerOptions;
#active = false;
#paused = false;
#velocity = 0;
#lastTime = 0;
#residual = 0;
#frameId: number | null = null;
constructor(opts: AutoscrollerOptions) {
this.#opts = opts;
}
get active() {
return this.#active;
}
get paused() {
return this.#paused;
}
start(velocity: number) {
this.stop();
this.#active = true;
this.#paused = false;
this.#velocity = velocity;
this.#residual = 0;
this.#resetClockAndRequestFrame();
}
setVelocity(velocity: number) {
this.#velocity = velocity;
}
pause() {
if (!this.#active || this.#paused) return;
this.#paused = true;
this.#cancelFrame();
}
resume() {
if (!this.#active || !this.#paused) return;
this.#paused = false;
this.#resetClockAndRequestFrame();
}
stop() {
if (!this.#active) return;
this.#active = false;
this.#paused = false;
this.#cancelFrame();
this.#opts.onStop?.();
}
#cancelFrame() {
if (this.#frameId !== null) {
(this.#opts.caf ?? cancelAnimationFrame)(this.#frameId);
this.#frameId = null;
}
}
#resetClockAndRequestFrame() {
this.#lastTime = (this.#opts.now ?? performance.now.bind(performance))();
this.#frameId = (this.#opts.raf ?? requestAnimationFrame)((time) => this.#tick(time));
}
#tick(time: number) {
this.#frameId = null;
if (!this.#active || this.#paused) return;
const dt = Math.min(Math.max(0, time - this.#lastTime), PACED_SCROLL_MAX_FRAME_MS);
this.#lastTime = time;
this.#residual += this.#velocity * (dt / 1000);
const whole = Math.trunc(this.#residual);
if (whole !== 0) {
this.#residual -= whole;
this.#opts.scrollBy(whole);
// scrollBy may stop the session (e.g. the book ended); don't re-arm then.
if (!this.#active || this.#paused) return;
}
this.#frameId = (this.#opts.raf ?? requestAnimationFrame)((t) => this.#tick(t));
}
}