Files
readest/apps/readest-app/src/store/readerStore.ts
T
Huang Xin f8ad47a418 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>
2026-07-07 17:45:02 +02:00

561 lines
19 KiB
TypeScript

import { create } from 'zustand';
import {
BookContent,
BookConfig,
PageInfo,
BookProgress,
ViewSettings,
TimeInfo,
FIXED_LAYOUT_FORMATS,
} from '@/types/book';
import { Insets } from '@/types/misc';
import { EnvConfigType } from '@/services/environment';
import { FoliateView } from '@/types/view';
import { DocumentLoader, TOCItem } from '@/libs/document';
import {
isPseStreamFileName,
openPseStreamBook,
parsePseStreamFileName,
} from '@/services/opds/pseStream';
import { BOOK_NAV_VERSION, computeBookNav, hydrateBookNav, updateToc } from '@/services/nav';
import { formatTitle, getMetadataHash, getPrimaryLanguage } from '@/utils/book';
import { getBaseFilename } from '@/utils/path';
import { SUPPORTED_LANGNAMES } from '@/services/constants';
import { useSettingsStore } from './settingsStore';
import { BookData, useBookDataStore } from './bookDataStore';
import { useLibraryStore } from './libraryStore';
import { clearBookProgress, getBookProgress, setBookProgress } from './readerProgressStore';
import { uniqueId } from '@/utils/misc';
interface ViewState {
/* Unique key for each book view */
key: string;
view: FoliateView | null;
viewerKey: string;
isPrimary: boolean;
loading: boolean;
inited: boolean;
error: string | null;
/* `progress` moved to readerProgressStore — see that file's header for
rationale. Use `useBookProgress(key)` for reactive subscription or
`getBookProgress(key)` for one-shot reads. */
ribbonVisible: boolean;
ttsEnabled: boolean;
/* True while an Auto Scroll session (#4998) is engaged for this view;
session-only, never persisted. Drives the View menu checkmark. */
autoScrollEnabled: boolean;
syncing: boolean;
gridInsets: Insets | null;
/* True while the reader is showing a position requested by an external
deep link (e.g. ?cfi=...) that the user hasn't yet confirmed by reading.
Progress writers (auto-save, cloud sync, kosync) skip while this is true
so the user's actual last-read position isn't overwritten by a preview.
Cleared on the first user-initiated relocate (page turn / scroll). */
previewMode: boolean;
/* View settings for the view:
generally view settings have a hierarchy of global settings < book settings < view settings
view settings for primary view are saved to book config which is persisted to config file
omitting settings that are not changed from global settings */
viewSettings: ViewSettings | null;
}
interface ReaderStore {
viewStates: { [key: string]: ViewState };
bookKeys: string[];
hoveredBookKey: string | null;
setBookKeys: (keys: string[]) => void;
setHoveredBookKey: (key: string | null) => void;
setBookmarkRibbonVisibility: (key: string, visible: boolean) => void;
setTTSEnabled: (key: string, enabled: boolean) => void;
setAutoScrollEnabled: (key: string, enabled: boolean) => void;
setIsLoading: (key: string, loading: boolean) => void;
setIsSyncing: (key: string, syncing: boolean) => void;
setProgress: (
key: string,
location: string,
tocItem: TOCItem,
pageItem: BookProgress['pageItem'],
section: PageInfo,
pageinfo: PageInfo,
timeinfo: TimeInfo,
range: Range,
fraction: number,
) => void;
getProgress: (key: string) => BookProgress | null;
setView: (key: string, view: FoliateView) => void;
getView: (key: string | null) => FoliateView | null;
getViews: () => FoliateView[];
getViewsById: (id: string) => FoliateView[];
setViewSettings: (key: string, viewSettings: ViewSettings) => void;
getViewSettings: (key: string) => ViewSettings | null;
initViewState: (
envConfig: EnvConfigType,
id: string,
key: string,
isPrimary?: boolean,
reload?: boolean,
) => Promise<void>;
clearViewState: (key: string) => void;
getViewState: (key: string) => ViewState | null;
getGridInsets: (key: string) => Insets | null;
setGridInsets: (key: string, insets: Insets | null) => void;
setViewInited: (key: string, inited: boolean) => void;
setPreviewMode: (key: string, previewMode: boolean) => void;
recreateViewer: (envConfig: EnvConfigType, key: string) => void;
}
export const useReaderStore = create<ReaderStore>((set, get) => ({
viewStates: {},
bookKeys: [],
hoveredBookKey: null,
setBookKeys: (keys: string[]) => set({ bookKeys: keys }),
setHoveredBookKey: (key: string | null) => set({ hoveredBookKey: key }),
getView: (key: string | null) => (key && get().viewStates[key]?.view) || null,
setView: (key: string, view) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: { ...state.viewStates[key]!, view },
},
})),
getViews: () => Object.values(get().viewStates).map((state) => state.view!),
getViewsById: (id: string) => {
const { viewStates } = get();
return Object.values(viewStates)
.filter((state) => state.key && state.key.startsWith(id))
.map((state) => state.view!);
},
clearViewState: (key: string) => {
// Drop the per-book progress entry alongside the view state so the
// standalone progress store doesn't leak across opens/closes.
clearBookProgress(key);
set((state) => {
const viewStates = { ...state.viewStates };
delete viewStates[key];
return { viewStates };
});
},
getViewState: (key: string) => get().viewStates[key] || null,
initViewState: async (
envConfig: EnvConfigType,
id: string,
key: string,
isPrimary = true,
reload = false,
) => {
const booksData = useBookDataStore.getState().booksData;
const bookData = booksData[id];
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
key: '',
view: null,
viewerKey: '',
isPrimary: false,
loading: true,
inited: false,
error: null,
ribbonVisible: false,
ttsEnabled: false,
autoScrollEnabled: false,
syncing: false,
gridInsets: null,
previewMode: false,
viewSettings: null,
},
},
}));
try {
const appService = await envConfig.getAppService();
const { settings } = useSettingsStore.getState();
const { getBookByHash, library } = useLibraryStore.getState();
const book = getBookByHash(id);
if (!book) {
console.error(
`Book ${id} not found in library (size=${library.length}); likely the in-memory entry was dropped by a library reload.`,
);
throw new Error('Book not found');
}
const isPseStream = !!book.url && isPseStreamFileName(book.url);
let bookDoc = bookData?.bookDoc;
let file: File | null = bookData?.file ?? null;
if (!bookDoc || (!isPseStream && !file) || reload) {
console.log('Loading book', key);
if (isPseStream) {
const data = parsePseStreamFileName(book.url!);
const doc = await openPseStreamBook(data);
bookDoc = doc.book;
file = null;
} else {
const content = (await appService.loadBookContent(book)) as BookContent;
file = content.file;
let nativeFilePath: string | null = null;
try {
nativeFilePath = await appService.resolveNativeBookFilePath(book);
} catch (err) {
console.warn('resolveNativeBookFilePath failed', err);
}
const doc = await new DocumentLoader(file, {
nativeFilePath: nativeFilePath ?? undefined,
}).open();
bookDoc = doc.book;
}
}
const config = await appService.loadBookConfig(book, settings);
// Import annotations from third-party readers on first open
if (bookDoc.metadata.identifier) {
const { getAnnotationProviders } = await import('@/services/annotation');
for (const provider of getAnnotationProviders()) {
if (provider.isAvailable(appService)) {
const merged = await provider.importAnnotations(
appService,
bookDoc.metadata.identifier,
config,
);
if (merged !== config) {
Object.assign(config, merged);
await appService.saveBookConfig(book, config, settings);
}
}
}
}
// Filter out invalid booknotes
config.booknotes = config.booknotes?.filter((booknote) => booknote.cfi) ?? [];
// Load cached book navigation (TOC + section fragments) or compute and persist.
if (book.format === 'EPUB' && bookDoc.rendition?.layout !== 'pre-paginated') {
const cachedNav = await appService.loadBookNav(book);
if (cachedNav?.version === BOOK_NAV_VERSION && process.env.NODE_ENV === 'production') {
hydrateBookNav(bookDoc, cachedNav);
} else {
const freshNav = await computeBookNav(bookDoc);
hydrateBookNav(bookDoc, freshNav);
try {
await appService.saveBookNav(book, freshNav);
} catch (e) {
console.warn('Failed to persist book nav cache:', e);
}
}
}
await updateToc(
bookDoc,
config.viewSettings?.sortedTOC ?? false,
config.viewSettings?.convertChineseVariant ?? 'none',
);
if (!bookDoc.metadata.title && file) {
bookDoc.metadata.title = getBaseFilename(file.name);
}
book.sourceTitle = formatTitle(bookDoc.metadata.title);
// Correct language codes mistakenly set with language names
if (typeof bookDoc.metadata?.language === 'string') {
if (bookDoc.metadata.language in SUPPORTED_LANGNAMES) {
bookDoc.metadata.language = SUPPORTED_LANGNAMES[bookDoc.metadata.language]!;
}
}
// Set the book's language for formerly imported books, newly imported books have this field set
const primaryLanguage = getPrimaryLanguage(bookDoc.metadata.language);
book.primaryLanguage = book.primaryLanguage ?? primaryLanguage;
book.metadata = book.metadata ?? bookDoc.metadata;
// Update series info from metadata if available and not already set on the book
if (bookDoc.metadata.belongsTo?.series) {
const belongsTo = bookDoc.metadata.belongsTo.series;
const series = Array.isArray(belongsTo) ? belongsTo[0] : belongsTo;
if (series) {
book.metadata.series = book.metadata.series ?? formatTitle(series.name);
book.metadata.seriesIndex =
book.metadata.seriesIndex ?? parseFloat(series.position || '0');
book.metadata.seriesTotal =
book.metadata.seriesTotal ?? (series.total ? parseInt(series.total, 10) : undefined);
}
}
// TODO: uncomment this when we can ensure metaHash is correctly generated for all books
// book.metaHash = book.metaHash ?? getMetadataHash(bookDoc.metadata);
book.metaHash = getMetadataHash(bookDoc.metadata);
const isFixedLayout =
bookDoc.rendition?.layout === 'pre-paginated' || FIXED_LAYOUT_FORMATS.has(book.format);
const newBookData: BookData = { id, book, file, config, bookDoc, isFixedLayout };
useBookDataStore.setState((state) => ({
booksData: {
...state.booksData,
[id]: newBookData,
},
}));
const configViewSettings = config.viewSettings!;
const globalViewSettings = settings.globalViewSettings;
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key],
key,
view: null,
viewerKey: `${key}-${uniqueId()}`,
isPrimary,
loading: false,
inited: false,
error: null,
ribbonVisible: false,
ttsEnabled: false,
autoScrollEnabled: false,
syncing: false,
gridInsets: null,
previewMode: false,
viewSettings: { ...globalViewSettings, ...configViewSettings },
},
},
}));
} catch (error) {
console.error(error);
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key],
key: '',
view: null,
viewerKey: '',
isPrimary: false,
loading: false,
inited: false,
error: 'Failed to load book.',
ribbonVisible: false,
ttsEnabled: false,
autoScrollEnabled: false,
syncing: false,
gridInsets: null,
previewMode: false,
viewSettings: null,
},
},
}));
throw error;
}
},
getViewSettings: (key: string) => get().viewStates[key]?.viewSettings || null,
setViewSettings: (key: string, viewSettings: ViewSettings) => {
if (!key) return;
const id = key.split('-')[0]!;
const bookData = useBookDataStore.getState().booksData[id];
const viewState = get().viewStates[key];
if (!viewState || !bookData) return;
if (viewState.isPrimary) {
useBookDataStore.setState((state) => ({
booksData: {
...state.booksData,
[id]: {
...bookData,
config: {
...bookData.config,
updatedAt: Date.now(),
viewSettings,
},
},
},
}));
}
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
viewSettings,
},
},
}));
},
// Delegates to the standalone readerProgressStore so that progress reads
// do not subscribe the caller to readerStore. Most call sites need a
// one-shot read (event handlers, useEffect bodies). Components that
// genuinely depend on progress for rendering should subscribe via the
// `useBookProgress(key)` hook exported from readerProgressStore instead.
getProgress: (key: string) => getBookProgress(key),
setProgress: (
key: string,
location: string,
tocItem: TOCItem,
pageItem: BookProgress['pageItem'],
section: PageInfo,
pageinfo: PageInfo,
timeinfo: TimeInfo,
range: Range,
fraction: number,
) => {
const id = key.split('-')[0]!;
const bookData = useBookDataStore.getState().booksData[id];
const viewState = get().viewStates[key];
if (!viewState || !bookData) return;
const pageInfo = bookData.isFixedLayout ? section : pageinfo;
const progress: [number, number] = [pageInfo.current + 1, pageInfo.total];
const progressPercentage = Math.round((progress[0] / progress[1]) * 100);
// Lightweight library update — O(1) lookup, no array copy, no refreshGroups
const { getBookByHash, updateBookProgress } = useLibraryStore.getState();
const existingBook = getBookByHash(id);
if (existingBook) {
let newReadingStatus = existingBook.readingStatus;
if (existingBook.readingStatus === 'unread') {
newReadingStatus = undefined;
}
if (progressPercentage >= 100 && existingBook.readingStatus !== 'finished') {
newReadingStatus = 'finished';
}
updateBookProgress(id, progress, newReadingStatus);
}
// Only the primary view persists progress into the shared bookData
// config — secondary views in a parallel layout shouldn't overwrite
// it. Skip the bookDataStore write entirely when not primary to spare
// its subscribers a re-render.
if (viewState.isPrimary) {
useBookDataStore.setState((state) => {
const existing = state.booksData[id];
if (!existing) return state;
return {
booksData: {
...state.booksData,
[id]: {
...existing,
config: {
...existing.config,
progress,
location,
} as BookConfig,
},
},
};
});
}
// Write progress to the standalone store. This is the only setState on
// the hot swipe path that the previous implementation routed through
// the (much bigger) readerStore — the split here is the whole point of
// the refactor: components subscribing to `useReaderStore()` without a
// selector will no longer re-render per page turn.
setBookProgress(key, {
location,
sectionHref: tocItem?.href,
sectionLabel: tocItem?.label,
pageItem,
section,
pageinfo,
timeinfo,
fraction,
index: section.current,
range,
page: pageInfo.current + 1,
} as BookProgress);
},
setBookmarkRibbonVisibility: (key: string, visible: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
ribbonVisible: visible,
},
},
})),
setTTSEnabled: (key: string, enabled: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
ttsEnabled: enabled,
},
},
})),
setAutoScrollEnabled: (key: string, enabled: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
autoScrollEnabled: enabled,
},
},
})),
setIsLoading: (key: string, loading: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
loading,
},
},
})),
setIsSyncing: (key: string, syncing: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
syncing,
},
},
})),
getGridInsets: (key: string) =>
get().viewStates[key]?.gridInsets || { top: 0, right: 0, bottom: 0, left: 0 },
setGridInsets: (key: string, insets: Insets | null) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
gridInsets: insets,
},
},
})),
setViewInited: (key: string, inited: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
inited,
},
},
})),
setPreviewMode: (key: string, previewMode: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
previewMode,
},
},
})),
recreateViewer: (envConfig: EnvConfigType, key: string) => {
const id = key.split('-')[0]!;
get()
.initViewState(envConfig, id, key, true, true)
.then(() => {
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
viewerKey: `${key}-${uniqueId()}`,
},
},
}));
});
},
}));