Files
readest/apps/readest-app/src/app/reader/hooks/useBooksManager.ts
T
Huang Xin 8c91ad411c fix(reader): open annotation deep link when a different book is open (#4887) (#4910)
An annotation deep link (readest://book/{hash}/annotation/{id}?cfi=...) for a
book that is not the one currently shown in the reader was ignored: the reader
stayed on the open book. It only worked from the library page.

Two causes, both in the reader-mounted path:

- useOpenAnnotationLink fell through to navigateToReader when the target book
  had no live view. router.push to the same /reader route does not re-run the
  reader's one-shot init effect, so it was a no-op and the book never changed.
  Route it through the in-place switch event (open-book-in-reader) carrying the
  cfi, mirroring useOpenBookLink.

- The "already open, jump in place" check scanned all viewStates, which keep
  stale entries for books switched away from (their views are detached from the
  DOM, never cleared on switch). Switching A -> B -> A matched the stale A view
  and called goTo on a dead view. Scope the check to the currently displayed
  bookKeys instead.

useBooksManager.openBookInReader now accepts an optional cfi and jumps to it
once the switched-in view is ready (marking it a preview so the saved position
is not overwritten).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:26:41 +02:00

134 lines
5.0 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { uniqueId } from '@/utils/misc';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { navigateToReader } from '@/utils/nav';
import { eventDispatcher } from '@/utils/event';
const useBooksManager = () => {
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig } = useEnv();
const { bookKeys } = useReaderStore();
const { setBookKeys, initViewState } = useReaderStore();
const { sideBarBookKey, setSideBarBookKey } = useSidebarStore();
const [shouldUpdateSearchParams, setShouldUpdateSearchParams] = useState(false);
const { setParallel } = useParallelViewStore();
useEffect(() => {
if (shouldUpdateSearchParams) {
const ids = bookKeys.map((key) => key.split('-')[0]!);
if (ids.length > 0) {
navigateToReader(router, ids, searchParams?.toString() || '', { scroll: false });
}
setShouldUpdateSearchParams(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKeys, shouldUpdateSearchParams]);
// Append a new book and sync with bookKeys and URL
const appendBook = (id: string, isPrimary: boolean, isParallel: boolean) => {
const newKey = `${id}-${uniqueId()}`;
initViewState(envConfig, id, newKey, isPrimary);
if (!bookKeys.includes(newKey)) {
const updatedKeys = [...bookKeys, newKey];
setBookKeys(updatedKeys);
}
if (isParallel) setParallel([sideBarBookKey!, newKey]);
setSideBarBookKey(newKey);
setShouldUpdateSearchParams(true);
};
// Jump the switched-in book to a deep-link cfi (#4887) once its view has
// finished initing. The freshly-opened FoliateView first lands on the saved
// reading position, so wait for `inited` and then goTo; mark it a preview so
// the saved position is not overwritten. The subscription cleans itself up on
// success or on load failure.
const goToCfiWhenReady = (bookKey: string, cfi: string) => {
const jump = () => {
const { getView, setPreviewMode } = useReaderStore.getState();
getView(bookKey)?.goTo(cfi);
setPreviewMode(bookKey, true);
};
const ready = (state: ReturnType<typeof useReaderStore.getState>) => {
const vs = state.viewStates[bookKey];
return { done: !!vs?.error || (!!vs?.inited && !!vs?.view), ok: !!vs?.inited && !!vs?.view };
};
const initial = ready(useReaderStore.getState());
if (initial.done) {
if (initial.ok) jump();
return;
}
const unsub = useReaderStore.subscribe((state) => {
const { done, ok } = ready(state);
if (!done) return;
unsub();
if (ok) jump();
});
};
// Open a book in-place when a widget/deep link targets a book while a reader
// is already mounted. REPLACE the open book(s) with the target one (single
// ids=<hash>) rather than appending: appending produced ids=a+b which, with
// the OS re-delivering the launch deep link, looped. The store update renders
// the new book immediately; closing the previous key follows the same path as
// dismissBook. An optional cfi (annotation deep link) is applied once ready.
const openBookInReader = (bookHash: string, cfi?: string) => {
const existing = bookKeys.find((key) => key.startsWith(bookHash));
if (existing) {
setSideBarBookKey(existing);
if (cfi) goToCfiWhenReady(existing, cfi);
return;
}
const newKey = `${bookHash}-${uniqueId()}`;
initViewState(envConfig, bookHash, newKey, true);
setBookKeys([newKey]);
setSideBarBookKey(newKey);
setShouldUpdateSearchParams(true);
if (cfi) goToCfiWhenReady(newKey, cfi);
};
// Stable ref so the listener calls the latest closure without re-subscribing.
const openBookRef = useRef(openBookInReader);
openBookRef.current = openBookInReader;
useEffect(() => {
const handle = (event: CustomEvent) => {
const { bookHash, cfi } = event.detail as { bookHash: string; cfi?: string };
openBookRef.current(bookHash, cfi);
};
eventDispatcher.on('open-book-in-reader', handle);
return () => eventDispatcher.off('open-book-in-reader', handle);
}, []);
// Close a book and sync with bookKeys and URL
const dismissBook = (bookKey: string) => {
const updatedKeys = bookKeys.filter((key) => key !== bookKey);
setBookKeys(updatedKeys);
setShouldUpdateSearchParams(true);
};
const getNextBookKey = (bookKey: string) => {
const index = bookKeys.indexOf(bookKey);
const nextIndex = (index + 1) % bookKeys.length;
return bookKeys[nextIndex]!;
};
const openParallelView = (id: string) => {
const sideBarBookId = sideBarBookKey?.split('-')[0];
appendBook(id, sideBarBookId != id, true);
};
return {
bookKeys,
appendBook,
dismissBook,
getNextBookKey,
openParallelView,
};
};
export default useBooksManager;