From a493fee4ed1af805ea63a6b7ff2e618b2e0f1b7a Mon Sep 17 00:00:00 2001 From: chrox Date: Mon, 14 Oct 2024 15:58:37 +0200 Subject: [PATCH] Save reading progress in book config --- apps/readest-app/src/app/page.tsx | 31 ++--- .../reader}/components/FoliateViewer.tsx | 38 ++++-- .../app/reader/components/ReaderContent.tsx | 82 ++++++++++++ .../src/app/reader/hooks/useFoliateEvents.ts | 41 ++++++ apps/readest-app/src/app/reader/page.tsx | 23 +++- .../src/components/ReaderContent.tsx | 62 --------- apps/readest-app/src/services/appService.ts | 12 +- apps/readest-app/src/services/environment.ts | 10 +- apps/readest-app/src/store/readerStore.ts | 123 ++++++++++++------ apps/readest-app/src/types/book.ts | 9 +- apps/readest-app/src/types/system.ts | 1 - apps/readest-app/src/utils/book.ts | 4 - 12 files changed, 272 insertions(+), 164 deletions(-) rename apps/readest-app/src/{ => app/reader}/components/FoliateViewer.tsx (72%) create mode 100644 apps/readest-app/src/app/reader/components/ReaderContent.tsx create mode 100644 apps/readest-app/src/app/reader/hooks/useFoliateEvents.ts delete mode 100644 apps/readest-app/src/components/ReaderContent.tsx diff --git a/apps/readest-app/src/app/page.tsx b/apps/readest-app/src/app/page.tsx index ce18b59b..fba8c799 100644 --- a/apps/readest-app/src/app/page.tsx +++ b/apps/readest-app/src/app/page.tsx @@ -3,46 +3,35 @@ import * as React from 'react'; import { useState, useRef } from 'react'; -import { Book } from '@/types/book'; import { useEnv } from '@/context/EnvContext'; +import { useReaderStore } from '@/store/readerStore'; import SearchBar from '@/components/SearchBar'; import Spinner from '@/components/Spinner'; import Bookshelf from '@/components/Bookshelf'; const LibraryPage = () => { - const { envConfig, getAppService, appService } = useEnv(); - const [libraryBooks, setLibraryBooks] = useState([]); - const [loading, setLoading] = useState(true); + const { envConfig, appService, getAppService } = useEnv(); + const { library: libraryBooks, setLibrary } = useReaderStore(); + const [loading, setLoading] = useState(true); const isInitiating = useRef(false); React.useEffect(() => { if (isInitiating.current) return; isInitiating.current = true; setLoading(true); - getAppService(envConfig).then((appService) => { - appService.loadSettings().then((settings) => { - console.log('Settings', settings); - appService - .loadLibraryBooks() - .then((libraryBooks) => { - setLibraryBooks(libraryBooks); - setLoading(false); - }) - .catch((err) => { - console.error(err); - setLoading(false); - appService.showMessage(`Failed to load library books: ${err}`, 'error'); - }); - }); + getAppService(envConfig).then(async (appService) => { + console.log('Loading library books...'); + setLibrary(await appService.loadLibraryBooks()); + setLoading(false); }); - }, [envConfig, getAppService]); + }, [envConfig, libraryBooks, setLibrary]); const importBooks = async (files: [string | File]) => { setLoading(true); for (const file of files) { await appService?.importBook(file, libraryBooks); - setLibraryBooks(libraryBooks); + setLibrary(libraryBooks); } appService?.saveLibraryBooks(libraryBooks); setLoading(false); diff --git a/apps/readest-app/src/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx similarity index 72% rename from apps/readest-app/src/components/FoliateViewer.tsx rename to apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 98115458..8ab09469 100644 --- a/apps/readest-app/src/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -1,10 +1,14 @@ 'use client'; -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { useFoliateEvents } from '../hooks/useFoliateEvents'; import { BookDoc } from '@/libs/document'; +import { BookConfig } from '@/types/book'; type FoliateViewerProps = { - book: BookDoc; + bookId: string; + bookConfig: BookConfig; + bookDoc: BookDoc; }; const getCSS = (spacing: number, justify: boolean, hyphenate: boolean) => ` @@ -46,8 +50,10 @@ const getCSS = (spacing: number, justify: boolean, hyphenate: boolean) => ` } `; -interface FoliateView extends HTMLElement { +export interface FoliateView extends HTMLElement { open: (book: BookDoc) => Promise; + init: (options: { lastLocation: string }) => void; + goToFraction: (fraction: number) => void; renderer: { setStyles: (css: string) => void; next: () => Promise; @@ -55,8 +61,9 @@ interface FoliateView extends HTMLElement { }; } -const FoliateViewer: React.FC = ({ book }) => { +const FoliateViewer: React.FC = ({ bookId, bookConfig, bookDoc }) => { const viewRef = useRef(null); + const [view, setView] = useState(null); const isViewCreated = useRef(false); useEffect(() => { @@ -66,18 +73,24 @@ const FoliateViewer: React.FC = ({ book }) => { const view = document.createElement('foliate-view') as FoliateView; document.body.append(view); viewRef.current?.appendChild(view); - - console.log('Open the book with foliate-view:', book); - await view.open(book); + setView(view); + await view.open(bookDoc); if ('setStyles' in view.renderer) { - view.renderer.setStyles(getCSS(1.4, true, true)); + view.renderer.setStyles(getCSS(2.4, true, true)); + } + const lastLocation = bookConfig.location; + if (lastLocation) { + view.init({ lastLocation }); + } else { + view.goToFraction(0); } - await view.renderer.next(); }; openBook(); isViewCreated.current = true; - }, [book]); + }, [bookDoc]); + + useFoliateEvents(view, bookId); const handleTap = (event: React.MouseEvent) => { const { clientX } = event; @@ -85,11 +98,10 @@ const FoliateViewer: React.FC = ({ book }) => { const leftThreshold = width * 0.5; const rightThreshold = width * 0.5; - const existingView = viewRef.current?.querySelector('foliate-view') as FoliateView; if (clientX < leftThreshold) { - existingView?.renderer?.prev(); + view?.renderer.prev(); } else if (clientX > rightThreshold) { - existingView?.renderer?.next(); + view?.renderer.next(); } }; diff --git a/apps/readest-app/src/app/reader/components/ReaderContent.tsx b/apps/readest-app/src/app/reader/components/ReaderContent.tsx new file mode 100644 index 00000000..6d19c23b --- /dev/null +++ b/apps/readest-app/src/app/reader/components/ReaderContent.tsx @@ -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 = ({ isClosingBook }) => { + const searchParams = useSearchParams(); + const id = searchParams.get('id'); + + const [bookDoc, setBookDoc] = React.useState(); + 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 ( +
+ {bookState.loading && } + {bookState.error && ( +
+

{bookState.error}

+
+ )} + +
+ ); +}; + +export default ReaderContent; diff --git a/apps/readest-app/src/app/reader/hooks/useFoliateEvents.ts b/apps/readest-app/src/app/reader/hooks/useFoliateEvents.ts new file mode 100644 index 00000000..c3f8c4f9 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useFoliateEvents.ts @@ -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]); +}; diff --git a/apps/readest-app/src/app/reader/page.tsx b/apps/readest-app/src/app/reader/page.tsx index 2911945f..84a38a88 100644 --- a/apps/readest-app/src/app/reader/page.tsx +++ b/apps/readest-app/src/app/reader/page.tsx @@ -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 (
{ /> - +
); diff --git a/apps/readest-app/src/components/ReaderContent.tsx b/apps/readest-app/src/components/ReaderContent.tsx deleted file mode 100644 index eb062d66..00000000 --- a/apps/readest-app/src/components/ReaderContent.tsx +++ /dev/null @@ -1,62 +0,0 @@ -'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'; - -const ReaderContent = () => { - const searchParams = useSearchParams(); - const id = searchParams.get('id'); - - const [bookDoc, setBookDoc] = React.useState(); - const { envConfig } = useEnv(); - const { books, fetchBook } = useReaderStore(); - const bookState = books[id!] || { loading: true, content: null, error: null }; - - React.useEffect(() => { - if (id && !bookState.content) { - envConfig.initAppService().then((appService) => { - fetchBook(appService, id).then((book) => { - if (book) { - book.lastUpdated = Date.now(); - appService.updateLibraryBook(book); - } - }); - }); - } - const loadDocument = async () => { - const content = bookState.content; - if (content) { - const { book } = await new DocumentLoader(content.file).open(); - setBookDoc(book); - } - }; - if (bookState.content) { - loadDocument(); - } - }, [bookState.content, envConfig, fetchBook, id]); - - if (!bookDoc) { - return null; - } - - return ( -
- {bookState.loading && } - {bookState.error && ( -
-

{bookState.error}

-
- )} - -
- ); -}; - -export default ReaderContent; diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 99d305a5..5705bf6e 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -189,15 +189,7 @@ export abstract class BaseAppService implements AppService { } async saveLibraryBooks(books: Book[]): Promise { - await this.fs.writeFile(getLibraryFilename(), 'Books', JSON.stringify(books)); - } - - async updateLibraryBook(book: Book): Promise { - const library = await this.loadLibraryBooks(); - const bookIndex = library.findIndex((b) => b.hash === book.hash); - if (bookIndex !== -1) { - library[bookIndex] = book; - } - await this.saveLibraryBooks(library); + const libraryBooks = books.map(({ coverImageUrl, ...rest }) => rest); + await this.fs.writeFile(getLibraryFilename(), 'Books', JSON.stringify(libraryBooks)); } } diff --git a/apps/readest-app/src/services/environment.ts b/apps/readest-app/src/services/environment.ts index b80ffba0..6ba138ae 100644 --- a/apps/readest-app/src/services/environment.ts +++ b/apps/readest-app/src/services/environment.ts @@ -1,14 +1,18 @@ import { AppService } from '@/types/system'; +let appService: AppService | null = null; + export interface EnvConfigType { initAppService: () => Promise; } const environmentConfig: EnvConfigType = { initAppService: async () => { - const { NativeAppService } = await import('@/services/nativeAppService'); - const appService = new NativeAppService(); - await appService.loadSettings(); + if (!appService) { + const { NativeAppService } = await import('@/services/nativeAppService'); + appService = new NativeAppService(); + await appService.loadSettings(); + } return appService; }, }; diff --git a/apps/readest-app/src/store/readerStore.ts b/apps/readest-app/src/store/readerStore.ts index bed5124c..e1de99ed 100644 --- a/apps/readest-app/src/store/readerStore.ts +++ b/apps/readest-app/src/store/readerStore.ts @@ -1,69 +1,106 @@ import { create } from 'zustand'; -import { BookNote, BookContent, Book } from '@/types/book'; +import { BookNote, BookContent, Book, BookConfig } from '@/types/book'; import { AppService } from '@/types/system'; interface BookState { loading?: boolean; - content?: BookContent | null; error?: string | null; - notes?: BookNote[]; + book?: Book | null; + file?: File | null; + config?: BookConfig | null; + fraction?: number; } interface ReaderStore { + library: Book[]; books: Record; + + setLibrary: (books: Book[]) => void; fetchBook: (appService: AppService, id: string) => Promise; - addNote: (id: string, note: BookNote) => void; + setProgress: (id: string, progress: number, location: string, pageinfo: {}) => void; + addBookmark: (id: string, bookmark: BookNote) => void; } -export const useReaderStore = create((set) => { - return { - books: {}, +export const useReaderStore = create((set) => ({ + library: [], + books: {}, + + setLibrary: (books: Book[]) => set({ library: books }), + + fetchBook: async (appService: AppService, id: string) => { + set((state) => ({ + books: { + ...state.books, + [id]: { loading: true, file: null, book: null, config: null, error: null, notes: [] }, + }, + })); + + try { + const library = await appService.loadLibraryBooks(); + const book = library.find((b) => b.hash === id); + if (!book) { + throw new Error('Book not found'); + } + const content = (await appService.loadBookContent(book)) as BookContent; + const { file, config } = content; - fetchBook: async (appService: AppService, id: string) => { set((state) => ({ books: { ...state.books, - [id]: { loading: true, content: null, error: null, notes: [] }, + [id]: { ...state.books[id], loading: false, book, file, config }, }, })); - - try { - const library = await appService.loadLibraryBooks(); - const book = library.find((b) => b.hash === id); - if (!book) { - throw new Error('Book not found'); - } - const content = (await appService.loadBookContent(book)) as BookContent; - - set((state) => ({ - books: { - ...state.books, - [id]: { ...state.books[id], loading: false, content }, - }, - })); - return book; - } catch (error) { - console.error(error); - set((state) => ({ - books: { - ...state.books, - [id]: { ...state.books[id], loading: false, error: 'Failed to load book.' }, - }, - })); - return null; - } - }, - - addNote: (id: string, note: BookNote) => + return book; + } catch (error) { + console.error(error); set((state) => ({ + books: { + ...state.books, + [id]: { ...state.books[id], loading: false, error: 'Failed to load book.' }, + }, + })); + return null; + } + }, + + setProgress: (id: string, progress: number, location: string, pageinfo: {}) => + set((state) => { + const book = state.books[id]; + if (!book) return state; + return { books: { ...state.books, [id]: { - ...state.books[id], - notes: [...state.books[id]!.notes!, note], + ...book, + config: { + ...book.config, + lastUpdated: Date.now(), + progress, + location, + pageinfo, + }, }, }, - })), - }; -}); + }; + }), + + addBookmark: (id: string, bookmark: BookNote) => + set((state) => { + const book = state.books[id]; + if (!book) return state; + return { + books: { + ...state.books, + [id]: { + ...book, + config: { + ...book.config, + lastUpdated: Date.now(), + bookmarks: [...(book.config?.bookmarks || []), bookmark], + }, + }, + }, + }; + }), +})); diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 540675e0..604a6a2d 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -25,10 +25,11 @@ export interface BookNote { export interface BookConfig { lastUpdated: number; - remoteProgress: number; - localProgress: number; - bookmarks: BookNote[]; - annotations: BookNote[]; + progress?: number; + location?: string; + pageinfo?: Record; + bookmarks?: BookNote[]; + annotations?: BookNote[]; removedNotesTimestamps?: Record; } diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index e8ead0f6..d2c7c5e5 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -31,6 +31,5 @@ export interface AppService { loadBookContent(book: Book): Promise; loadLibraryBooks(): Promise; saveLibraryBooks(books: Book[]): Promise; - updateLibraryBook(book: Book): Promise; getCoverImageUrl(book: Book): string; } diff --git a/apps/readest-app/src/utils/book.ts b/apps/readest-app/src/utils/book.ts index 409d400e..35622d31 100644 --- a/apps/readest-app/src/utils/book.ts +++ b/apps/readest-app/src/utils/book.ts @@ -26,8 +26,4 @@ export const getBaseFilename = (filename: string) => { }; export const INIT_BOOK_CONFIG: BookConfig = { lastUpdated: 0, - remoteProgress: 0, - localProgress: 0, - bookmarks: [], - annotations: [], };