Refactor ReaderStore

This commit is contained in:
chrox
2024-11-08 19:33:37 +01:00
parent f570eeb3de
commit d81629b72a
21 changed files with 263 additions and 255 deletions
@@ -1,25 +1,24 @@
import React from 'react';
import { BookState, useReaderStore } from '@/store/readerStore';
import { useReaderStore } from '@/store/readerStore';
import FoliateViewer from './FoliateViewer';
import getGridTemplate from '@/utils/grid';
import SectionInfo from './SectionInfo';
import HeaderBar from './HeaderBar';
import FooterBar from './FooterBar';
import PageInfo from './PageInfo';
import PageInfoView from './PageInfo';
import Ribbon from './Ribbon';
import SettingsDialog from './settings/SettingsDialog';
import Annotator from './annotator/Annotator';
interface BookGridProps {
bookKeys: string[];
bookStates: BookState[];
onCloseBook: (bookKey: string) => void;
}
const BookGrid: React.FC<BookGridProps> = ({ bookKeys, bookStates, onCloseBook }) => {
const { isSideBarPinned, isSideBarVisible, sideBarWidth, bookmarkRibbons } = useReaderStore();
const BookGrid: React.FC<BookGridProps> = ({ bookKeys, onCloseBook }) => {
const { isSideBarPinned, isSideBarVisible, sideBarWidth } = useReaderStore();
const { getConfig, getProgress, getBookData, getViewState } = useReaderStore();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useReaderStore();
const gridWidth = isSideBarPinned && isSideBarVisible ? `calc(100% - ${sideBarWidth})` : '100%';
const gridTemplate = getGridTemplate(bookKeys.length, window.innerWidth / window.innerHeight);
@@ -33,12 +32,15 @@ const BookGrid: React.FC<BookGridProps> = ({ bookKeys, bookStates, onCloseBook }
gridTemplateRows: gridTemplate.rows,
}}
>
{bookStates.map((bookState, index) => {
const bookKey = bookKeys[index]!;
const isBookmarked = bookmarkRibbons[bookKey];
const { book, config, progress, bookDoc } = bookState;
if (!book || !config || !progress || !bookDoc) return null;
const { section, pageinfo, tocLabel: chapter } = progress;
{bookKeys.map((bookKey) => {
const bookData = getBookData(bookKey);
const config = getConfig(bookKey);
const progress = getProgress(bookKey);
const { book, bookDoc } = bookData || {};
if (!book || !config || !bookDoc) return null;
const isBookmarked = getViewState(bookKey)?.ribbonVisible;
const { section, pageinfo, tocLabel: chapter } = progress || {};
const marginGap = `${config.viewSettings!.gapPercent}%`;
return (
@@ -51,18 +53,18 @@ const BookGrid: React.FC<BookGridProps> = ({ bookKeys, bookStates, onCloseBook }
<HeaderBar
bookKey={bookKey}
bookTitle={book.title}
isHoveredAnim={bookStates.length > 2}
isHoveredAnim={bookKeys.length > 2}
onCloseBook={onCloseBook}
onSetSettingsDialogOpen={setFontLayoutSettingsDialogOpen}
/>
<FoliateViewer bookKey={bookKey} bookDoc={bookState.bookDoc!} bookConfig={config} />
<FoliateViewer bookKey={bookKey} bookDoc={bookDoc} config={config} />
{config.viewSettings!.scrolled ? null : (
<>
<SectionInfo chapter={chapter} gapLeft={marginGap} />
<PageInfo
<PageInfoView
bookFormat={book.format}
section={section}
pageinfo={pageinfo}
section={section ?? null}
pageinfo={pageinfo ?? null}
gapRight={marginGap}
/>
</>
@@ -12,11 +12,10 @@ interface BookmarkTogglerProps {
const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
const { envConfig } = useEnv();
const { books, settings, saveConfig, updateBooknotes, setBookmarkRibbonVisibility } =
useReaderStore();
const bookState = books[bookKey]!;
const config = bookState.config!;
const progress = bookState.progress!;
const { settings, saveConfig, updateBooknotes } = useReaderStore();
const { getConfig, getProgress, setBookmarkRibbonVisibility } = useReaderStore();
const config = getConfig(bookKey)!;
const progress = getProgress(bookKey)!;
const [isBookmarked, setIsBookmarked] = useState(false);
@@ -56,7 +55,8 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
};
useEffect(() => {
const { location: cfi, booknotes = [] } = config;
const { booknotes = [] } = config || {};
const { location: cfi } = progress || {};
if (!cfi) return;
const start = CFI.collapse(cfi);
@@ -66,7 +66,7 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
.some((item) => CFI.compare(start, item.cfi) * CFI.compare(end, item.cfi) <= 0);
setIsBookmarked(locationBookmarked);
setBookmarkRibbonVisibility(bookKey, locationBookmarked);
}, [config]);
}, [config, progress]);
return (
<button onClick={toggleBookmark} className='p-2'>
@@ -41,13 +41,13 @@ const wrappedFoliateView = (originalView: FoliateView): FoliateView => {
const FoliateViewer: React.FC<{
bookKey: string;
bookDoc: BookDoc;
bookConfig: BookConfig;
}> = ({ bookKey, bookDoc, bookConfig }) => {
config: BookConfig;
}> = ({ bookKey, bookDoc, config }) => {
const viewRef = useRef<HTMLDivElement>(null);
const [view, setView] = useState<FoliateView | null>(null);
const [viewInited, setViewInited] = useState(false);
const isViewCreated = useRef(false);
const { setFoliateView } = useReaderStore();
const { setView: setFoliateView } = useReaderStore();
const setProgress = useReaderStore((state) => state.setProgress);
const progressRelocateHandler = (event: Event) => {
@@ -115,8 +115,8 @@ const FoliateViewer: React.FC<{
setFoliateView(bookKey, view);
await view.open(bookDoc);
const viewSettings = bookConfig.viewSettings!;
view.renderer.setStyles?.(getStyles(bookConfig));
const viewSettings = config.viewSettings!;
view.renderer.setStyles?.(getStyles(config));
const isScrolled = viewSettings.scrolled!;
const marginPx = viewSettings.marginPx!;
const gapPercent = viewSettings.gapPercent!;
@@ -130,7 +130,6 @@ const FoliateViewer: React.FC<{
} else {
view.renderer.removeAttribute('animated');
}
view.renderer.setAttribute('animated', animated ? 'animated' : '');
view.renderer.setAttribute('flow', isScrolled ? 'scrolled' : 'paginated');
view.renderer.setAttribute('margin', `${marginPx}px`);
view.renderer.setAttribute('gap', `${gapPercent}%`);
@@ -138,7 +137,7 @@ const FoliateViewer: React.FC<{
view.renderer.setAttribute('max-inline-size', `${maxInlineSize}px`);
view.renderer.setAttribute('max-block-size', `${maxBlockSize}px`);
const lastLocation = bookConfig.location;
const lastLocation = config.location;
if (lastLocation) {
await view.init({ lastLocation });
} else {
@@ -158,7 +157,7 @@ const FoliateViewer: React.FC<{
}, []);
const initAnnotations = () => {
const { booknotes = [] } = bookConfig;
const { booknotes = [] } = config;
const annotations = booknotes.filter((item) => item.type === 'annotation');
try {
Promise.all(annotations.map((annotation) => view?.addAnnotation(annotation)));
@@ -6,29 +6,27 @@ import { useReaderStore } from '@/store/readerStore';
interface FooterBarProps {
bookKey: string;
pageinfo?: { current: number; total: number } | undefined;
pageinfo: { current: number; total: number } | undefined;
isHoveredAnim: boolean;
}
const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim }) => {
const { isSideBarVisible, hoveredBookKey, setHoveredBookKey, getFoliateView } = useReaderStore();
const { isSideBarVisible, hoveredBookKey, setHoveredBookKey, getView } = useReaderStore();
const handleProgressChange = (event: React.ChangeEvent) => {
const newProgress = parseInt((event.target as HTMLInputElement).value, 10);
const foliateView = getFoliateView(bookKey);
foliateView?.goToFraction(newProgress / 100.0);
getView(bookKey)?.goToFraction(newProgress / 100.0);
};
const handleGoPrev = () => {
const foliateView = getFoliateView(bookKey);
foliateView?.goLeft();
getView(bookKey)?.goLeft();
};
const handleGoNext = () => {
const foliateView = getFoliateView(bookKey);
foliateView?.goRight();
getView(bookKey)?.goRight();
};
const progressFraction = pageinfo ? pageinfo.current / pageinfo.total : 0;
const pageinfoValid = pageinfo && pageinfo.total > 0 && pageinfo.current >= 0;
const progressFraction = pageinfoValid ? pageinfo.current / pageinfo.total : 0;
return (
<div
className={clsx(
@@ -45,14 +43,14 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
<RiArrowLeftWideLine size={20} />
</button>
<span className='mx-2 text-center text-sm text-black'>
{pageinfo ? `${Math.round(progressFraction * 100)}%` : ''}
{pageinfoValid ? `${Math.round(progressFraction * 100)}%` : ''}
</span>
<input
type='range'
className='mx-2 w-full'
min={0}
max={100}
value={pageinfo ? progressFraction * 100 : 0}
value={pageinfoValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
<button className='btn btn-ghost mx-2 h-8 min-h-8 w-8 p-0' onClick={handleGoNext}>
@@ -1,13 +1,14 @@
import { PageInfo } from '@/types/book';
import React from 'react';
interface PageInfoProps {
bookFormat: string;
section?: { current: number; total: number };
pageinfo?: { current: number; total: number };
section: PageInfo | null;
pageinfo: PageInfo | null;
gapRight: string;
}
const PageInfo: React.FC<PageInfoProps> = ({ bookFormat, section, pageinfo, gapRight }) => {
const PageInfoView: React.FC<PageInfoProps> = ({ bookFormat, section, pageinfo, gapRight }) => {
const pageInfo =
bookFormat === 'PDF'
? section
@@ -27,4 +28,4 @@ const PageInfo: React.FC<PageInfoProps> = ({ bookFormat, section, pageinfo, gapR
);
};
export default PageInfo;
export default PageInfoView;
@@ -4,7 +4,7 @@ import * as React from 'react';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { DEFAULT_BOOK_STATE, useReaderStore } from '@/store/readerStore';
import { useReaderStore } from '@/store/readerStore';
import { SystemSettings } from '@/types/settings';
import Spinner from '@/components/Spinner';
@@ -17,23 +17,22 @@ const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) =>
const router = useRouter();
const { envConfig } = useEnv();
const { bookKeys, dismissBook, getNextBookKey, openSplitView } = useBooks();
const { sideBarBookKey, setSideBarBookKey } = useReaderStore();
const { books, getFoliateView, clearBookState, saveConfig, saveSettings } = useReaderStore();
const bookStates = bookKeys.map((key) => books[key] || DEFAULT_BOOK_STATE);
const { sideBarBookKey, getConfig, setSideBarBookKey } = useReaderStore();
const { getView, getBookData, getViewState, clearViewState, saveConfig, saveSettings } =
useReaderStore();
useBookShortcuts({ sideBarBookKey, bookKeys, openSplitView, getNextBookKey });
const saveConfigAndCloseBook = (bookKey: string) => {
getFoliateView(bookKey)?.close();
getFoliateView(bookKey)?.remove();
const bookState = books[bookKey];
if (!bookState) return;
const { book, config, isPrimary } = bookState;
getView(bookKey)?.close();
getView(bookKey)?.remove();
const config = getConfig(bookKey);
const { book } = getBookData(bookKey) || {};
const { isPrimary } = getViewState(bookKey) || {};
if (isPrimary && book && config) {
saveConfig(envConfig, bookKey, config, settings);
}
clearBookState(bookKey);
clearViewState(bookKey);
};
const saveSettingsAndGoToLibrary = () => {
@@ -59,21 +58,12 @@ const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) =>
}
};
const bookState = bookStates[0];
if (
bookStates.length !== bookKeys.length ||
!bookState ||
!bookState.book ||
!bookState.bookDoc
) {
if (!bookKeys || bookKeys.length === 0) return null;
const bookData = getBookData(bookKeys[0]!);
if (!bookData || !bookData.book || !bookData.bookDoc) {
return (
<div className={'hero hero-content min-h-screen'}>
<Spinner loading={true} />
{bookState?.error && (
<div className='text-center'>
<h2 className='text-red-500'>{bookState.error}</h2>
</div>
)}
</div>
);
}
@@ -87,7 +77,7 @@ const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) =>
onOpenSplitView={openSplitView}
/>
<BookGrid bookKeys={bookKeys} bookStates={bookStates} onCloseBook={handleCloseBook} />
<BookGrid bookKeys={bookKeys} onCloseBook={handleCloseBook} />
</div>
);
};
@@ -18,9 +18,8 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
setIsDropdownOpen,
onSetSettingsDialogOpen,
}) => {
const { books, setConfig, getFoliateView } = useReaderStore();
const bookState = books[bookKey]!;
const config = bookState.config!;
const { getConfig, setConfig, getView } = useReaderStore();
const config = getConfig(bookKey)!;
const [isDarkMode, setDarkMode] = useState(config.viewSettings!.theme === 'dark');
const [isScrolledMode, setScrolledMode] = useState(config.viewSettings!.scrolled);
@@ -40,8 +39,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
};
useEffect(() => {
const view = getFoliateView(bookKey);
view?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
getView(bookKey)?.renderer.setAttribute('flow', isScrolledMode ? 'scrolled' : 'paginated');
config.viewSettings!.scrolled = isScrolledMode;
setConfig(bookKey, config);
}, [isScrolledMode]);
@@ -51,7 +49,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
}, [isInvertedColors]);
useEffect(() => {
const view = getFoliateView(bookKey);
const view = getView(bookKey);
if (!view) return;
// FIXME: zoom level is not working in paginated mode
if (config.viewSettings?.scrolled) {
@@ -25,12 +25,12 @@ interface TextSelection {
const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const { envConfig } = useEnv();
const { books, settings, saveConfig, updateBooknotes, getFoliateView } = useReaderStore();
const { settings, getConfig, saveConfig, getProgress, updateBooknotes, getView } =
useReaderStore();
const globalReadSettings = settings.globalReadSettings;
const bookState = books[bookKey]!;
const config = bookState.config!;
const progress = bookState.progress!;
const view = getFoliateView(bookKey);
const config = getConfig(bookKey)!;
const progress = getProgress(bookKey)!;
const view = getView(bookKey);
const [selection, setSelection] = useState<TextSelection | null>();
const [showPopup, setShowPopup] = useState(false);
@@ -90,7 +90,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSelection(selection as TextSelection);
};
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation }, [bookState]);
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation }, [config]);
const popupRef = useOutsideClick<HTMLDivElement>(() => {
setShowPopup(false);
@@ -36,11 +36,10 @@ const FontFace = ({ className, family, label, options, selected, onSelect }: Fon
);
const FontPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const { books, settings, setSettings, setConfig, getFoliateView } = useReaderStore();
const { isFontLayoutSettingsGlobal } = useReaderStore();
const bookState = books[bookKey]!;
const config = bookState.config!;
const view = getFoliateView(bookKey);
const { settings, isFontLayoutSettingsGlobal } = useReaderStore();
const { setSettings, getConfig, setConfig, getView } = useReaderStore();
const config = getConfig(bookKey)!;
const view = getView(bookKey);
const [defaultFontSize, setDefaultFontSize] = useState(config.viewSettings!.defaultFontSize!);
const [minFontSize, setMinFontSize] = useState(config.viewSettings!.minimumFontSize!);
@@ -5,11 +5,10 @@ import { ONE_COLUMN_MAX_INLINE_SIZE } from '@/services/constants';
import NumberInput from './NumberInput';
const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const { books, settings, setSettings, setConfig, getFoliateView } = useReaderStore();
const { isFontLayoutSettingsGlobal } = useReaderStore();
const bookState = books[bookKey]!;
const config = bookState.config!;
const view = getFoliateView(bookKey);
const { settings, isFontLayoutSettingsGlobal } = useReaderStore();
const { setSettings, getConfig, setConfig, getView } = useReaderStore();
const config = getConfig(bookKey)!;
const view = getView(bookKey);
const [lineHeight, setLineHeight] = useState(config.viewSettings!.lineHeight!);
const [fullJustification, setFullJustification] = useState(
@@ -7,10 +7,9 @@ const cssRegex =
/((?:\s*)([\w#.@*,:\-.:>+~\[\]\"=(),*\s]+)\s*{(?:[\s]*)((?:[A-Za-z\- \s]+[:]\s*['"0-9\w .,\/\()\-!#%]+;?)*)*\s*}(?:\s*))/gim;
const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const { books, settings, setSettings, setConfig, getFoliateView } = useReaderStore();
const { isFontLayoutSettingsGlobal } = useReaderStore();
const bookState = books[bookKey]!;
const config = bookState.config!;
const { settings, isFontLayoutSettingsGlobal } = useReaderStore();
const { setSettings, getConfig, setConfig, getView } = useReaderStore();
const config = getConfig(bookKey)!;
const [animated, setAnimated] = useState(config.viewSettings!.animated!);
const [userStylesheet, setUserStylesheet] = useState(config.viewSettings!.userStylesheet!);
@@ -44,7 +43,7 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalViewSettings.userStylesheet = formattedCSS;
setSettings(settings);
}
getFoliateView(bookKey)?.renderer.setStyles?.(getStyles(config));
getView(bookKey)?.renderer.setStyles?.(getStyles(config));
} catch (err) {
setError('Invalid CSS: Please check your input.');
console.log('CSS Error:', err);
@@ -64,9 +63,9 @@ const MiscPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSettings(settings);
}
if (animated) {
getFoliateView(bookKey)?.renderer.setAttribute('animated', '');
getView(bookKey)?.renderer.setAttribute('animated', '');
} else {
getFoliateView(bookKey)?.renderer.removeAttribute('animated');
getView(bookKey)?.renderer.removeAttribute('animated');
}
}, [animated]);
@@ -27,8 +27,8 @@ const BookMenu: React.FC<BookMenuProps> = ({ openSplitView, setIsDropdownOpen })
className='flex w-full items-center justify-between rounded-md p-2 hover:bg-gray-100'
onClick={handleOpenSplitView}
>
<span className='ml-2'>Split View</span>
<span className='text-sm text-gray-400'>Shift+S</span>
<span className='ml-2'>Parallel Read</span>
<span className='text-sm text-gray-400'>Shift+P</span>
</button>
<button
className='flex w-full items-center justify-between rounded-md p-2 hover:bg-gray-100'
@@ -20,13 +20,11 @@ interface BooknoteItemProps {
}
const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
const { getFoliateView } = useReaderStore();
const { books } = useReaderStore();
const { getProgress, getView } = useReaderStore();
const [isCurrentBookmark, setIsCurrentBookmark] = useState(false);
const { text, cfi } = item;
const bookState = books[bookKey]!;
const progress = bookState.progress!;
const progress = getProgress(bookKey)!;
useEffect(() => {
const { location } = progress;
@@ -37,7 +35,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
const handleClickItem = (event: React.MouseEvent) => {
event.preventDefault();
getFoliateView(bookKey)?.goTo(cfi);
getView(bookKey)?.goTo(cfi);
};
return (
@@ -74,9 +72,8 @@ const BooknoteView: React.FC<{
bookKey: string;
toc: TOCItem[];
}> = ({ type, bookKey, toc }) => {
const { books } = useReaderStore();
const bookState = books[bookKey]!;
const config = bookState.config!;
const { getConfig } = useReaderStore();
const config = getConfig(bookKey)!;
const { booknotes: allNotes = [] } = config;
const booknotes = allNotes.filter((note) => note.type === type);
@@ -1,7 +1,7 @@
import clsx from 'clsx';
import React, { useState, useEffect } from 'react';
import { BookState, DEFAULT_BOOK_STATE, useReaderStore } from '@/store/readerStore';
import { useReaderStore } from '@/store/readerStore';
import SidebarHeader from './Header';
import SidebarContent from './Content';
import TabNavigation from './TabNavigation';
@@ -18,9 +18,8 @@ const SideBar: React.FC<{
onGoToLibrary: () => void;
onOpenSplitView: () => void;
}> = ({ width, isPinned, onGoToLibrary, onOpenSplitView }) => {
const { getBookData, getProgress } = useReaderStore();
const [activeTab, setActiveTab] = useState('toc');
const { books } = useReaderStore();
const [bookState, setBookState] = useState<BookState | null>(null);
const [currentHref, setCurrentHref] = useState<string | null>(null);
const { sideBarBookKey } = useReaderStore();
const {
@@ -33,22 +32,22 @@ const SideBar: React.FC<{
const { handleMouseDown } = useDragBar(handleSideBarResize, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH);
useEffect(() => {
if (!books || !sideBarBookKey) return;
const bookState = books[sideBarBookKey] || DEFAULT_BOOK_STATE;
const { progress } = bookState;
setBookState(bookState);
if (!sideBarBookKey) return;
const progress = getProgress(sideBarBookKey);
setCurrentHref(progress?.tocHref || null);
}, [books, sideBarBookKey]);
}, [sideBarBookKey]);
const handleClickOverlay = () => {
setSideBarVisibility(false);
};
if (!sideBarBookKey || !bookState || !bookState.book || !bookState.bookDoc) {
if (!sideBarBookKey) return null;
const bookData = getBookData(sideBarBookKey);
if (!bookData || !bookData.book || !bookData.bookDoc) {
return null;
}
const { book, bookDoc } = bookState;
const { book, bookDoc } = bookData;
return isSideBarVisible ? (
<>
@@ -31,7 +31,7 @@ const TOCItemView: React.FC<{
expandedItems: string[];
}> = ({ bookKey, item, depth, setCurrentHref, currentHref, expandedItems }) => {
const [isExpanded, setIsExpanded] = useState(expandedItems.includes(item.href || ''));
const { getFoliateView } = useReaderStore();
const { getView } = useReaderStore();
const handleToggleExpand = (event: React.MouseEvent) => {
event.preventDefault();
@@ -42,7 +42,7 @@ const TOCItemView: React.FC<{
const handleClickItem = (event: React.MouseEvent) => {
event.preventDefault();
if (item.href) {
getFoliateView(bookKey)?.goTo(item.href);
getView(bookKey)?.goTo(item.href);
setCurrentHref(item.href);
}
};
@@ -109,7 +109,7 @@ const TOCView: React.FC<{
}> = ({ bookKey, toc, currentHref: href }) => {
const [currentHref, setCurrentHref] = useState<string | null>(href);
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const { getFoliateView } = useReaderStore();
const { getView } = useReaderStore();
const tocRef = useRef<HTMLUListElement | null>(null);
const tocRelocateHandler = (event: Event) => {
@@ -120,7 +120,7 @@ const TOCView: React.FC<{
}
};
useFoliateEvents(getFoliateView(bookKey), { onRelocate: tocRelocateHandler });
useFoliateEvents(getView(bookKey), { onRelocate: tocRelocateHandler });
useEffect(() => {
setCurrentHref(href);
@@ -14,7 +14,7 @@ const useBookShortcuts = ({
openSplitView,
getNextBookKey,
}: UseBookShortcutsProps) => {
const { getFoliateView, setSideBarBookKey } = useReaderStore();
const { getView, setSideBarBookKey } = useReaderStore();
const { toggleSideBar } = useReaderStore();
const switchSideBar = () => {
@@ -22,11 +22,11 @@ const useBookShortcuts = ({
};
const goLeft = () => {
getFoliateView(sideBarBookKey)?.goLeft();
getView(sideBarBookKey)?.goLeft();
};
const goRight = () => {
getFoliateView(sideBarBookKey)?.goRight();
getView(sideBarBookKey)?.goRight();
};
const reloadPage = () => {
@@ -9,7 +9,7 @@ const useBooks = () => {
const { envConfig } = useEnv();
const searchParams = useSearchParams();
const router = useRouter();
const { books, initBookState, sideBarBookKey, setSideBarBookKey } = useReaderStore();
const { sideBarBookKey, initViewState, getViewState, setSideBarBookKey } = useReaderStore();
const initialIds = (searchParams.get('ids') || '').split(',').filter(Boolean);
const [bookKeys, setBookKeys] = useState<string[]>(() => {
@@ -32,7 +32,7 @@ const useBooks = () => {
// Append a new book and sync with bookKeys and URL
const appendBook = (id: string, isPrimary: boolean) => {
const newKey = `${id}-${uniqueId()}`;
initBookState(envConfig, id, newKey, isPrimary);
initViewState(envConfig, id, newKey, isPrimary);
setBookKeys((prevKeys) => {
if (!prevKeys.includes(newKey)) {
prevKeys.push(newKey);
@@ -71,8 +71,8 @@ const useBooks = () => {
const id = key.split('-')[0]!;
const isPrimary = !uniqueIds.has(id);
uniqueIds.add(id);
if (!books[key]) {
initBookState(envConfig, id, key, isPrimary);
if (!getViewState(key)) {
initViewState(envConfig, id, key, isPrimary);
if (index === 0) setSideBarBookKey(key);
}
});
+1 -1
View File
@@ -10,7 +10,7 @@ export interface ShortcutConfig {
const DEFAULT_SHORTCUTS: ShortcutConfig = {
switchSidebar: ['ctrl+Tab', 'opt+Tab', 'alt+Tab'],
toggleSidebar: ['t'],
openSplitView: ['shift+s'],
openSplitView: ['shift+p'],
reloadPage: ['shift+r'],
goLeft: ['ArrowLeft', 'PageUp', 'h'],
goRight: ['ArrowRight', 'PageDown', 'l'],
@@ -190,6 +190,7 @@ export abstract class BaseAppService implements AppService {
async saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings) {
if (settings) {
config = JSON.parse(JSON.stringify(config));
const globalViewSettings = settings.globalViewSettings as ViewSettings;
const viewSettings = config.viewSettings as Partial<ViewSettings>;
config.viewSettings = Object.entries(viewSettings).reduce(
+136 -122
View File
@@ -5,27 +5,34 @@ import { EnvConfigType } from '@/services/environment';
import { SystemSettings } from '@/types/settings';
import { FoliateView } from '@/app/reader/components/FoliateViewer';
import { BookDoc, DocumentLoader, TOCItem } from '@/libs/document';
import { updateTocID } from '@/utils/toc';
export interface BookState {
export interface BookData {
/* Persistent data shared with different views of the same book */
id: string;
book: Book | null;
file: File | null;
config: BookConfig | null;
bookDoc: BookDoc | null;
}
export interface ViewState {
/* Unique key for each book view */
key: string;
loading?: boolean;
error?: string | null;
book?: Book | null;
file?: File | null;
config?: BookConfig | null;
progress?: BookProgress | null;
bookDoc?: BookDoc | null;
isPrimary?: boolean;
view: FoliateView | null;
isPrimary: boolean;
loading: boolean;
error: string | null;
progress: BookProgress | null;
ribbonVisible: boolean;
}
interface ReaderStore {
library: Book[];
settings: SystemSettings;
books: Record<string, BookState>;
foliateViews: Record<string, FoliateView>;
bookDocCache: Record<string, BookDoc>;
bookmarkRibbons: Record<string, boolean>;
booksData: { [id: string]: BookData };
viewStates: { [key: string]: ViewState };
hoveredBookKey: string | null;
sideBarBookKey: string | null;
@@ -58,9 +65,11 @@ interface ReaderStore {
pageinfo: PageInfo,
range: Range,
) => void;
getProgress: (key: string) => BookProgress | null;
setConfig: (key: string, config: BookConfig) => void;
setFoliateView: (key: string, view: FoliateView) => void;
getFoliateView: (key: string | null) => FoliateView | null;
getConfig: (key: string) => BookConfig | null;
setView: (key: string, view: FoliateView) => void;
getView: (key: string | null) => FoliateView | null;
deleteBook: (envConfig: EnvConfigType, book: Book) => void;
saveConfig: (
@@ -70,32 +79,19 @@ interface ReaderStore {
settings: SystemSettings,
) => void;
saveSettings: (envConfig: EnvConfigType, settings: SystemSettings) => void;
initBookState: (envConfig: EnvConfigType, id: string, key: string, isPrimary?: boolean) => void;
clearBookState: (key: string) => void;
initViewState: (envConfig: EnvConfigType, id: string, key: string, isPrimary?: boolean) => void;
clearViewState: (key: string) => void;
getBookData: (key: string) => BookData | null;
getViewState: (key: string) => ViewState | null;
updateBooknotes: (key: string, booknotes: BookNote[]) => BookConfig | undefined;
}
export const DEFAULT_BOOK_STATE = {
key: '',
loading: true,
error: null,
file: null,
book: null,
config: null,
progress: null,
bookDoc: null,
isPrimary: true,
};
export const useReaderStore = create<ReaderStore>((set, get) => ({
library: [],
settings: {} as SystemSettings,
books: {},
foliateViews: {},
bookDocCache: {},
bookmarkRibbons: {},
booksData: {},
viewStates: {},
hoveredBookKey: null,
sideBarBookKey: null,
@@ -120,15 +116,15 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
setSettings: (settings: SystemSettings) => set({ settings }),
setConfig: (key: string, config: BookConfig) => {
set((state) => {
const book = state.books[key];
if (!book) return state;
const bookData = state.booksData[key];
if (!bookData) return state;
return {
books: {
...state.books,
booksData: {
...state.booksData,
[key]: {
...book,
...bookData,
config: {
...book.config,
...bookData.config,
...config,
lastUpdated: Date.now(),
},
@@ -138,10 +134,17 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
});
},
setFoliateView: (key: string, view) =>
set((state) => ({ foliateViews: { ...state.foliateViews, [key]: view } })),
getConfig: (key: string) => {
const id = key.split('-')[0]!;
return get().booksData[id]?.config || null;
},
getFoliateView: (key: string | null) => (key && get().foliateViews[key]) || null,
setView: (key: string, view) =>
set((state) => ({
viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, view } },
})),
getView: (key: string | null) => (key && get().viewStates[key]?.view) || null,
deleteBook: async (envConfig: EnvConfigType, book: Book) => {
const appService = await envConfig.getAppService();
@@ -177,89 +180,89 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
await appService.saveSettings(settings);
},
clearBookState: (key: string) => {
clearViewState: (key: string) => {
set((state) => {
const books = { ...state.books };
delete books[key];
return { books };
const viewStates = { ...state.viewStates };
delete viewStates[key];
return { viewStates };
});
},
initBookState: async (envConfig: EnvConfigType, id: string, key: string, isPrimary = true) => {
const cache = get().bookDocCache || {};
initViewState: async (envConfig: EnvConfigType, id: string, key: string, isPrimary = true) => {
const bookData = get().booksData[id];
set((state) => ({
books: {
...state.books,
[key]: DEFAULT_BOOK_STATE,
viewStates: {
...state.viewStates,
[key]: {
key: '',
view: null,
isPrimary: false,
loading: true,
error: null,
progress: null,
ribbonVisible: false,
},
},
}));
try {
const appService = await envConfig.getAppService();
const { library, settings } = get();
const book = library.find((b) => b.hash === id);
if (!book) {
throw new Error('Book not found');
}
const content = (await appService.loadBookContent(book, settings)) as BookContent;
const { file, config } = content;
let bookDoc: BookDoc;
if (cache[id]) {
console.log('Using cached bookDoc for book', key);
bookDoc = cache[id];
} else {
if (!bookData) {
const appService = await envConfig.getAppService();
const { library, settings } = get();
const book = library.find((b) => b.hash === id);
if (!book) {
throw new Error('Book not found');
}
const content = (await appService.loadBookContent(book, settings)) as BookContent;
const { file, config } = content;
console.log('Loading book', key);
const { book: loadedBookDoc } = await new DocumentLoader(file).open();
bookDoc = loadedBookDoc as BookDoc;
const updateTocID = (items: TOCItem[], index = 0): number => {
items.forEach((item) => {
if (item.id === undefined) {
item.id = index++;
}
if (item.subitems) {
index = updateTocID(item.subitems, index);
}
});
return index;
};
const bookDoc = loadedBookDoc as BookDoc;
updateTocID(bookDoc.toc);
set((state) => ({
bookDocCache: {
...state.bookDocCache,
[id]: bookDoc,
booksData: {
...state.booksData,
[id]: { id, book, file, config, bookDoc },
},
}));
}
set((state) => ({
books: {
...state.books,
viewStates: {
...state.viewStates,
[key]: {
...state.books[key],
loading: false,
...state.viewStates[key],
key,
book,
file,
config,
progress: {} as BookProgress,
bookDoc,
view: null,
isPrimary,
loading: false,
error: null,
progress: null,
ribbonVisible: false,
},
},
}));
return content;
} catch (error) {
console.error(error);
set((state) => ({
books: {
...state.books,
[key]: { ...state.books[key], key: '', loading: false, error: 'Failed to load book.' },
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key],
key: '',
view: null,
isPrimary: false,
loading: false,
error: 'Failed to load book.',
progress: null,
ribbonVisible: false,
},
},
}));
return null;
}
},
getBookData: (key: string) => {
const id = key.split('-')[0]!;
return get().booksData[id] || null;
},
getViewState: (key: string) => get().viewStates[key] || null,
setProgress: (
key: string,
location: string,
@@ -269,26 +272,31 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
range: Range,
) =>
set((state) => {
const book = state.books[key];
if (!book) return state;
const id = key.split('-')[0]!;
const bookData = state.booksData[id];
const viewState = state.viewStates[key];
if (!viewState || !bookData) return state;
const oldConfig = bookData.config;
const newConfig = {
...bookData.config,
lastUpdated: Date.now(),
progress: [pageinfo.current, pageinfo.total] as [number, number],
location,
};
return {
books: {
...state.books,
booksData: {
...state.booksData,
[id]: {
...bookData,
config: viewState.isPrimary ? newConfig : oldConfig,
},
},
viewStates: {
...state.viewStates,
[key]: {
...book,
config: {
...book.config,
lastUpdated: Date.now(),
href: tocItem?.href,
chapter: tocItem?.label,
progress: [pageinfo.current, pageinfo.total],
location,
section,
pageinfo,
},
...viewState,
progress: {
...book.progress,
progress: [pageinfo.current, pageinfo.total],
...viewState.progress,
location,
tocHref: tocItem?.href,
tocLabel: tocItem?.label,
@@ -302,18 +310,24 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
};
}),
getProgress: (key: string) => get().viewStates[key]?.progress || null,
setBookmarkRibbonVisibility: (key: string, visible: boolean) =>
set((state) => ({
bookmarkRibbons: {
...state.bookmarkRibbons,
[key]: visible,
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
ribbonVisible: visible,
},
},
})),
updateBooknotes: (key: string, booknotes: BookNote[]) => {
let updatedConfig: BookConfig | undefined;
set((state) => {
const book = state.books[key];
const id = key.split('-')[0]!;
const book = state.booksData[id];
if (!book) return state;
updatedConfig = {
...book.config,
@@ -321,9 +335,9 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
booknotes,
};
return {
books: {
...state.books,
[key]: {
booksData: {
...state.booksData,
[id]: {
...book,
config: {
...book.config,
+12
View File
@@ -14,3 +14,15 @@ export const findParentPath = (toc: TOCItem[], href: string): TOCItem[] => {
}
return [];
};
export const updateTocID = (items: TOCItem[], index = 0): number => {
items.forEach((item) => {
if (item.id === undefined) {
item.id = index++;
}
if (item.subitems) {
index = updateTocID(item.subitems, index);
}
});
return index;
};