Files
readest/apps/readest-app/src/store/readerStore.ts
T
Huang Xin fb37406b31 feat(annotations): preview mode for deep-link landings (#4019)
* feat(annotations): preview mode for deep-link landings

When the reader opens at a deep-link CFI (e.g. clicking an exported
highlight from Obsidian), the position should not be persisted as the
user's reading progress until they actually start reading. Otherwise
the deep-link visit overwrites their last-read position and propagates
that across all sync targets.

Adds a per-book `previewMode` flag in the reader store that:

- Is set to true in FoliateViewer when the URL's `?cfi=` overrides the
  saved last-position.
- Is cleared on the first user-initiated relocate (page turn / scroll),
  reusing the existing reason filter in `docRelocateHandler`.
- Gates the auto progress writers:
    - useProgressAutoSave — skip local config persist
    - useProgressSync     — skip auto-push and skip the remote-progress
                            view.goTo (so cloud pull doesn't yank the
                            user away from the previewed annotation)
    - useKOSync           — skip auto-push (manual pushes still respected)

Hardcover sync and Discord presence are unaffected: hardcover only
fires on explicit user button press, and Discord presence carries no
position information.

Also picks up the regenerated AndroidManifest.xml change from the
existing tauri.conf.json deep-link config (registers readest:// scheme
on Android so the smart landing page's intent:// launch resolves).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(annotations): jump in place when target book is already open

When an annotation deep link arrives while the user is already in the
reader (most common case on mobile App Links), navigateToReader was
pushing the same /reader path with a different cfi query param. The
reader's init useEffect has [] deps, so it doesn't re-run, and
FoliateViewer doesn't re-read the cfi — the view stayed put.

Detect a mounted view for the target book hash by walking
viewStates and matching the hash prefix on the bookKey. If found,
call view.goTo(cfi) directly and set previewMode so the existing
gates fire. Falls back to navigateToReader when no view is open.

Also adds a console.log on each parsed deep link to make this path
easier to debug from device logs in the future.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:37:50 +02:00

517 lines
16 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 { 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: BookProgress | null;
ribbonVisible: boolean;
ttsEnabled: 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;
setIsLoading: (key: string, loading: boolean) => void;
setIsSyncing: (key: string, syncing: boolean) => void;
setProgress: (
key: string,
location: string,
tocItem: TOCItem,
section: PageInfo,
pageinfo: PageInfo,
timeinfo: TimeInfo,
range: Range,
) => 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) => {
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,
progress: null,
ribbonVisible: false,
ttsEnabled: 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;
const doc = await new DocumentLoader(file).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');
}
}
// 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,
progress: null,
ribbonVisible: false,
ttsEnabled: 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.',
progress: null,
ribbonVisible: false,
ttsEnabled: 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,
},
},
}));
},
getProgress: (key: string) => get().viewStates[key]?.progress || null,
setProgress: (
key: string,
location: string,
tocItem: TOCItem,
section: PageInfo,
pageinfo: PageInfo,
timeinfo: TimeInfo,
range: Range,
) =>
set((state) => {
const id = key.split('-')[0]!;
const bookData = useBookDataStore.getState().booksData[id];
const viewState = state.viewStates[key];
if (!viewState || !bookData) return state;
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);
}
const oldConfig = bookData.config;
const newConfig = {
...bookData.config,
progress,
location,
} as BookConfig;
useBookDataStore.setState((state) => ({
booksData: {
...state.booksData,
[id]: {
...bookData,
config: viewState.isPrimary ? newConfig : oldConfig,
},
},
}));
return {
viewStates: {
...state.viewStates,
[key]: {
...viewState,
progress: {
...viewState.progress,
location,
sectionHref: tocItem?.href,
sectionLabel: tocItem?.label,
section,
pageinfo,
timeinfo,
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,
},
},
})),
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()}`,
},
},
}));
});
},
}));