Save reading progress in book config

This commit is contained in:
chrox
2024-10-14 15:58:37 +02:00
parent 3e78f867e5
commit a493fee4ed
12 changed files with 272 additions and 164 deletions
@@ -0,0 +1,111 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { BookDoc } from '@/libs/document';
import { BookConfig } from '@/types/book';
type FoliateViewerProps = {
bookId: string;
bookConfig: BookConfig;
bookDoc: BookDoc;
};
const getCSS = (spacing: number, justify: boolean, hyphenate: boolean) => `
@namespace epub "http://www.idpf.org/2007/ops";
html {
color-scheme: light dark;
}
/* https://github.com/whatwg/html/issues/5426 */
@media (prefers-color-scheme: dark) {
a:link {
color: lightblue;
}
}
p, li, blockquote, dd {
line-height: ${spacing};
text-align: ${justify ? 'justify' : 'start'};
-webkit-hyphens: ${hyphenate ? 'auto' : 'manual'};
hyphens: ${hyphenate ? 'auto' : 'manual'};
-webkit-hyphenate-limit-before: 3;
-webkit-hyphenate-limit-after: 2;
-webkit-hyphenate-limit-lines: 2;
hanging-punctuation: allow-end last;
widows: 2;
}
/* prevent the above from overriding the align attribute */
[align="left"] { text-align: left; }
[align="right"] { text-align: right; }
[align="center"] { text-align: center; }
[align="justify"] { text-align: justify; }
pre {
white-space: pre-wrap !important;
}
aside[epub|type~="endnote"],
aside[epub|type~="footnote"],
aside[epub|type~="note"],
aside[epub|type~="rearnote"] {
display: none;
}
`;
export interface FoliateView extends HTMLElement {
open: (book: BookDoc) => Promise<void>;
init: (options: { lastLocation: string }) => void;
goToFraction: (fraction: number) => void;
renderer: {
setStyles: (css: string) => void;
next: () => Promise<void>;
prev: () => Promise<void>;
};
}
const FoliateViewer: React.FC<FoliateViewerProps> = ({ bookId, bookConfig, bookDoc }) => {
const viewRef = useRef<HTMLDivElement>(null);
const [view, setView] = useState<FoliateView | null>(null);
const isViewCreated = useRef(false);
useEffect(() => {
if (isViewCreated.current) return;
const openBook = async () => {
await import('foliate-js/view.js');
const view = document.createElement('foliate-view') as FoliateView;
document.body.append(view);
viewRef.current?.appendChild(view);
setView(view);
await view.open(bookDoc);
if ('setStyles' in view.renderer) {
view.renderer.setStyles(getCSS(2.4, true, true));
}
const lastLocation = bookConfig.location;
if (lastLocation) {
view.init({ lastLocation });
} else {
view.goToFraction(0);
}
};
openBook();
isViewCreated.current = true;
}, [bookDoc]);
useFoliateEvents(view, bookId);
const handleTap = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const { clientX } = event;
const width = window.innerWidth;
const leftThreshold = width * 0.5;
const rightThreshold = width * 0.5;
if (clientX < leftThreshold) {
view?.renderer.prev();
} else if (clientX > rightThreshold) {
view?.renderer.next();
}
};
return <div ref={viewRef} onClick={handleTap} />;
};
export default FoliateViewer;
@@ -0,0 +1,82 @@
'use client';
import React from 'react';
import { useSearchParams } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { BookDoc, DocumentLoader } from '@/libs/document';
import Spinner from '@/components/Spinner';
import FoliateViewer from './FoliateViewer';
interface ReaderContentProps {
isClosingBook: boolean;
}
const ReaderContent: React.FC<ReaderContentProps> = ({ isClosingBook }) => {
const searchParams = useSearchParams();
const id = searchParams.get('id');
const [bookDoc, setBookDoc] = React.useState<BookDoc>();
const { envConfig } = useEnv();
const { library, books, fetchBook, setLibrary } = useReaderStore();
const defaultBookState = { loading: true, error: null, file: null, book: null, config: null };
const bookState = id ? books[id] || defaultBookState : defaultBookState;
React.useEffect(() => {
if (!id) {
return;
}
const { file, book, config } = bookState;
if (isClosingBook) {
if (book && config) {
book.lastUpdated = Date.now();
const bookIndex = library.findIndex((b) => b.hash === book.hash);
if (bookIndex !== -1) {
library[bookIndex] = book;
}
setLibrary(library);
envConfig.initAppService().then((appService) => {
config.lastUpdated = Date.now();
appService.saveBookConfig(book, config);
appService.saveLibraryBooks(library);
});
}
return;
}
if (id && !file) {
envConfig.initAppService().then((appService) => {
fetchBook(appService, id);
});
}
if (!bookDoc && bookState.file) {
const loadDocument = async () => {
if (file) {
const { book } = await new DocumentLoader(file).open();
setBookDoc(book);
}
};
loadDocument();
}
return;
}, [isClosingBook, bookState.file, envConfig, fetchBook, id]);
if (!id || !bookDoc || !bookState.config) {
return null;
}
return (
<div>
{bookState.loading && <Spinner loading={bookState.loading} />}
{bookState.error && (
<div className='text-center'>
<h2 className='text-red-500'>{bookState.error}</h2>
</div>
)}
<FoliateViewer bookId={id} bookDoc={bookDoc} bookConfig={bookState.config} />
</div>
);
};
export default ReaderContent;
@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { FoliateView } from '@/app/reader/components/FoliateViewer';
import { useReaderStore } from '@/store/readerStore';
type FoliateEventHandler = {
onLoad?: (event: Event) => void;
onRelocate?: (event: Event) => void;
};
export const useFoliateEvents = (
view: FoliateView | null,
bookId: string,
handlers?: FoliateEventHandler,
) => {
const setProgress = useReaderStore((state) => state.setProgress);
const defaultLoadHandler = (event: Event) => {
console.log('load event:', event);
};
const defaultRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
// console.log('relocate:', detail);
setProgress(bookId, detail.fraction, detail.cfi, detail.location);
};
const onLoad = handlers?.onLoad || defaultLoadHandler;
const onRelocate = handlers?.onRelocate || defaultRelocateHandler;
useEffect(() => {
if (!view) return;
view.addEventListener('load', onLoad);
view.addEventListener('relocate', onRelocate);
return () => {
view.removeEventListener('load', onLoad);
view.removeEventListener('relocate', onRelocate);
};
}, [view, handlers]);
};
+20 -3
View File
@@ -1,18 +1,26 @@
'use client';
import * as React from 'react';
import { useState, Suspense } from 'react';
import { useState, useEffect, Suspense } from 'react';
import { useRouter } from 'next/navigation';
import ReaderContent from '@/components/ReaderContent';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import NavBar from '@/components/NavBar';
import ReaderContent from './components/ReaderContent';
const ReaderPage = () => {
const router = useRouter();
const [isNavBarVisible, setIsNavBarVisible] = useState(false);
const [isClosingBook, setIsClosingBook] = useState(false);
const { envConfig } = useEnv();
const { setLibrary } = useReaderStore();
const handleBack = () => {
console.log('Back to bookshelf');
setIsClosingBook(true);
router.back();
};
@@ -20,6 +28,15 @@ const ReaderPage = () => {
setIsNavBarVisible((pre) => !pre);
};
useEffect(() => {
const fetchLibrary = async () => {
const appService = await envConfig.initAppService();
setLibrary(await appService.loadLibraryBooks());
};
fetchLibrary();
}, [setLibrary]);
return (
<div className='min-h-screen bg-gray-100'>
<div
@@ -28,7 +45,7 @@ const ReaderPage = () => {
/>
<NavBar onBack={handleBack} isVisible={isNavBarVisible} />
<Suspense>
<ReaderContent />
<ReaderContent isClosingBook={isClosingBook} />
</Suspense>
</div>
);