From 4263ab7fc406c45a82f21449630859b171870b23 Mon Sep 17 00:00:00 2001 From: chrox Date: Wed, 23 Oct 2024 22:39:00 +0200 Subject: [PATCH] Display multiple books in grid split --- apps/readest-app/src/app/globals.css | 10 +- apps/readest-app/src/app/page.tsx | 2 +- .../app/reader/components/FoliateViewer.tsx | 23 ++- .../app/reader/components/ReaderContent.tsx | 161 +++++++++++------- .../src/app/reader/components/SideBar.tsx | 31 ++-- .../src/app/reader/components/TOCView.tsx | 33 ++-- .../src/app/reader/hooks/useFoliateEvents.ts | 4 +- apps/readest-app/src/app/reader/page.tsx | 8 +- apps/readest-app/src/store/readerStore.ts | 74 +++++--- 9 files changed, 229 insertions(+), 117 deletions(-) diff --git a/apps/readest-app/src/app/globals.css b/apps/readest-app/src/app/globals.css index 1341221b..61709ed6 100644 --- a/apps/readest-app/src/app/globals.css +++ b/apps/readest-app/src/app/globals.css @@ -29,6 +29,14 @@ body { foliate-view { display: block; width: 100%; - height: 100vh; + height: 100%; border: none; } + +.foliate-viewer { + transition: transform 0.3s ease; +} + +.header-bar-anim:hover + .foliate-viewer { + transform: scale(1.02); +} diff --git a/apps/readest-app/src/app/page.tsx b/apps/readest-app/src/app/page.tsx index 5f4943f4..dd1aaa03 100644 --- a/apps/readest-app/src/app/page.tsx +++ b/apps/readest-app/src/app/page.tsx @@ -25,7 +25,7 @@ const LibraryPage = () => { setLibrary(await appService.loadLibraryBooks()); setLoading(false); }); - }, [envConfig, libraryBooks, setLibrary, getAppService]); + }, []); const importBooks = async (files: [string | File]) => { setLoading(true); diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index d91b8617..298e4b7c 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -1,17 +1,9 @@ -'use client'; - import React, { useEffect, useRef, useState } from 'react'; import { useFoliateEvents } from '../hooks/useFoliateEvents'; import { BookDoc } from '@/libs/document'; import { BookConfig } from '@/types/book'; import { useReaderStore } from '@/store/readerStore'; -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 { @@ -63,12 +55,18 @@ export interface FoliateView extends HTMLElement { }; } -const FoliateViewer: React.FC = ({ bookId, bookConfig, bookDoc }) => { +const FoliateViewer: React.FC<{ + bookKey: string; + bookDoc: BookDoc; + bookConfig: BookConfig; +}> = ({ bookKey, bookDoc, bookConfig }) => { const viewRef = useRef(null); const [view, setView] = useState(null); const isViewCreated = useRef(false); const { setFoliateView } = useReaderStore(); + useFoliateEvents(bookKey, view); + useEffect(() => { if (isViewCreated.current) return; const openBook = async () => { @@ -78,7 +76,8 @@ const FoliateViewer: React.FC = ({ bookId, bookConfig, bookD document.body.append(view); viewRef.current?.appendChild(view); setView(view); - setFoliateView(view); + setFoliateView(bookKey, view); + await view.open(bookDoc); if ('setStyles' in view.renderer) { view.renderer.setStyles(getCSS(2.4, true, true)); @@ -95,8 +94,6 @@ const FoliateViewer: React.FC = ({ bookId, bookConfig, bookD isViewCreated.current = true; }, [bookDoc]); - useFoliateEvents(view, bookId); - const handleTap = (event: React.MouseEvent) => { const { clientX } = event; const width = window.innerWidth; @@ -110,7 +107,7 @@ const FoliateViewer: React.FC = ({ bookId, bookConfig, bookD } }; - return
; + return
; }; export default FoliateViewer; diff --git a/apps/readest-app/src/app/reader/components/ReaderContent.tsx b/apps/readest-app/src/app/reader/components/ReaderContent.tsx index 6749e263..d59880f5 100644 --- a/apps/readest-app/src/app/reader/components/ReaderContent.tsx +++ b/apps/readest-app/src/app/reader/components/ReaderContent.tsx @@ -4,10 +4,10 @@ import * as React from 'react'; import { useState } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { BsLayoutSidebar } from 'react-icons/bs'; +import { VscLayoutSidebarLeft, VscLayoutSidebarLeftOff } from 'react-icons/vsc'; import { useEnv } from '@/context/EnvContext'; -import { useReaderStore } from '@/store/readerStore'; -import { BookDoc, DocumentLoader } from '@/libs/document'; +import { useReaderStore, DEFAULT_BOOK_STATE } from '@/store/readerStore'; import Spinner from '@/components/Spinner'; import FoliateViewer from './FoliateViewer'; @@ -16,13 +16,10 @@ import SideBar from './SideBar'; const ReaderContent = () => { const router = useRouter(); const searchParams = useSearchParams(); - const id = searchParams.get('id'); + const ids = searchParams.getAll('id') || []; - const [bookDoc, setBookDoc] = React.useState(); const { envConfig } = useEnv(); - const { books, settings, fetchBook, saveConfig, saveSettings } = useReaderStore(); - const defaultBookState = { loading: true, error: null, file: null, book: null, config: null }; - const bookState = id ? books[id] || defaultBookState : defaultBookState; + const { books, settings, initBookState, saveConfig, saveSettings } = useReaderStore(); const [sideBarWidth, setSideBarWidth] = useState( settings.globalReadSettings.sideBarWidth ?? '25%', @@ -31,26 +28,23 @@ const ReaderContent = () => { settings.globalReadSettings.isSideBarPinned ?? true, ); const [isSideBarVisible, setSideBarVisibility] = useState(isSideBarPinned); - const [isTopBarVisible, setTopBarVisibility] = useState(false); + + const getKey = (id: string, index: number) => `${id}-${index}`; + const bookStates = ids.map((id, index) => books[getKey(id, index)] || DEFAULT_BOOK_STATE); + const [sideBarBookKey, setSideBarBookKey] = useState(getKey(ids[0] ?? '', 0)); React.useEffect(() => { - if (!id) { - return; - } - const { file } = bookState; - if (id && !file) { - fetchBook(envConfig, id); - } - if (!bookDoc && file) { - const loadDocument = async () => { - if (file) { - const { book } = await new DocumentLoader(file).open(); - setBookDoc(book); - } - }; - loadDocument(); - } - }, [bookState.file, envConfig, fetchBook, id]); + if (ids.length === 0) return; + const uniqueIds = new Set(); + ids.forEach((id, index) => { + const isPrimary = !uniqueIds.has(id); + uniqueIds.add(id); + const key = getKey(id, index); + if (books[key]) return; + console.log('fetching book', key); + initBookState(envConfig, id, key, isPrimary); + }); + }, [ids, settings]); const handleResize = (newWidth: string) => { setSideBarWidth(newWidth); @@ -66,21 +60,46 @@ const ReaderContent = () => { }; const handleCloseBook = () => { - const { book, config } = bookState; - if (book && config) { - saveConfig(envConfig, book, config); - saveSettings(envConfig, settings); - } + bookStates.forEach((bookState) => { + const { book, config, isPrimary } = bookState; + if (isPrimary && book && config) { + saveConfig(envConfig, book, config); + } + }); + saveSettings(envConfig, settings); router.back(); }; - const topBarWidth = isSideBarPinned && isSideBarVisible ? `calc(100% - ${sideBarWidth})` : '100%'; + const getGridTemplate = () => { + const count = ids.length; + const aspectRatio = window.innerWidth / window.innerHeight; - if (!id || !bookDoc || !bookState.config || !bookState.book) { + if (count === 1) { + // One book, full screen + return { columns: '1fr', rows: '1fr' }; + } else if (count === 2) { + if (aspectRatio < 1) { + // portrait mode: vertical split + return { columns: '1fr', rows: '1fr 1fr' }; + } else { + // landscape mode: horizontal split + return { columns: '1fr 1fr', rows: '1fr' }; + } + } else if (count === 3 || count === 4) { + // Three or four books, 2x2 grid + return { columns: '1fr 1fr', rows: '1fr 1fr' }; + } else { + // Five or more books, a 3x3 grid + return { columns: '1fr 1fr 1fr', rows: '1fr 1fr 1fr' }; + } + }; + + const bookState = bookStates[0]; + if (bookStates.length !== ids.length || !bookState || !bookState.book || !bookState.bookDoc) { return ( -
- {bookState.loading && } - {bookState.error && ( +
+ + {bookState?.error && (

{bookState.error}

@@ -89,12 +108,12 @@ const ReaderContent = () => { ); } + const gridWidth = isSideBarPinned && isSideBarVisible ? `calc(100% - ${sideBarWidth})` : '100%'; + return (
{ onSetVisibility={(visibility: boolean) => setSideBarVisibility(visibility)} /> -
-
setTopBarVisibility(true)} - onMouseLeave={() => setTopBarVisibility(false)} - > -
- -
-
-

{bookState.book.title}

-
-
- +
+ {bookStates.map((bookState, index) => { + const key = getKey(ids[index]!, index); + const { book, config, bookDoc } = bookState; + if (!book || !config || !bookDoc) return null; + return ( +
+
1 ? 'header-bar-anim' : 'header-bar' + } shadow-xs opacity-0 transition-opacity duration-300 hover:opacity-100`} + > +
+

+ {book?.title} +

+
+
+ +
+
+ +
+ ); + })}
); diff --git a/apps/readest-app/src/app/reader/components/SideBar.tsx b/apps/readest-app/src/app/reader/components/SideBar.tsx index 77c5411f..be423c31 100644 --- a/apps/readest-app/src/app/reader/components/SideBar.tsx +++ b/apps/readest-app/src/app/reader/components/SideBar.tsx @@ -10,17 +10,15 @@ import { } from 'react-icons/md'; import { GiBookshelf } from 'react-icons/gi'; -import { TOCItem } from '@/libs/document'; import BookCard from './BookCard'; import TOCView from './TOCView'; +import { BookState, DEFAULT_BOOK_STATE, useReaderStore } from '@/store/readerStore'; const MIN_SIDEBAR_WIDTH = 0.15; const MAX_SIDEBAR_WIDTH = 0.45; const SideBar: React.FC<{ - book: Book; - tocData: TOCItem[]; - currentHref: string | null; + bookKey: string; width: string; isVisible: boolean; isPinned: boolean; @@ -29,9 +27,7 @@ const SideBar: React.FC<{ onResize: (newWidth: string) => void; onGoToLibrary: () => void; }> = ({ - book, - tocData, - currentHref, + bookKey, width, isPinned, isVisible, @@ -41,8 +37,17 @@ const SideBar: React.FC<{ onGoToLibrary, }) => { const [activeTab, setActiveTab] = useState('toc'); + const { books } = useReaderStore(); + const [bookState, setBookState] = useState(null); + const [currentHref, setCurrentHref] = useState(null); - useEffect(() => {}, [isPinned, isVisible]); + useEffect(() => { + if (!books) return; + const bookState = books[bookKey] || DEFAULT_BOOK_STATE; + const { config } = bookState; + setBookState(bookState); + setCurrentHref(config?.href || null); + }, [bookKey]); const handleClickOverlay = () => { onSetVisibility(false); @@ -69,6 +74,12 @@ const SideBar: React.FC<{ document.removeEventListener('mouseup', handleMouseUp); }; + if (!bookState || !bookState.book || !bookState.bookDoc) { + return null; + } + + const { book, bookDoc } = bookState; + return ( isVisible && (
- {activeTab === 'toc' && ( - + {activeTab === 'toc' && bookDoc!.toc && ( + )} {activeTab === 'bookmarks' &&
Bookmarks
} {activeTab === 'annotations' &&
Annotations
} diff --git a/apps/readest-app/src/app/reader/components/TOCView.tsx b/apps/readest-app/src/app/reader/components/TOCView.tsx index fa220174..7c42002b 100644 --- a/apps/readest-app/src/app/reader/components/TOCView.tsx +++ b/apps/readest-app/src/app/reader/components/TOCView.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; +import { md5 } from 'js-md5'; import { TOCItem } from '@/libs/document'; import { useReaderStore } from '@/store/readerStore'; import { useFoliateEvents } from '../hooks/useFoliateEvents'; @@ -19,6 +20,8 @@ const findParentPath = (toc: TOCItem[], href: string): TOCItem[] => { return []; }; +const getHrefMd5 = (href: string) => md5(JSON.stringify(href)); + const createExpanderIcon = (isExpanded: boolean) => { return ( { }; const TOCItemView: React.FC<{ - bookId: string; + bookKey: string; item: TOCItem; depth: number; setCurrentHref: (href: string) => void; currentHref: string | null; expandedItems: string[]; -}> = ({ bookId, item, depth, setCurrentHref, currentHref, expandedItems }) => { +}> = ({ bookKey, item, depth, setCurrentHref, currentHref, expandedItems }) => { const [isExpanded, setIsExpanded] = useState(expandedItems.includes(item.href || '')); - const { foliateView } = useReaderStore(); + const { getFoliateView } = useReaderStore(); const handleToggleExpand = (event: React.MouseEvent) => { event.preventDefault(); @@ -53,7 +56,7 @@ const TOCItemView: React.FC<{ const handleClickItem = (event: React.MouseEvent) => { event.preventDefault(); if (item.href) { - foliateView?.goTo(item.href); + getFoliateView(bookKey)?.goTo(item.href); setCurrentHref(item.href); } }; @@ -73,7 +76,7 @@ const TOCItemView: React.FC<{ style={{ paddingInlineStart: `${(depth + 1) * 12}px` }} aria-expanded={isExpanded ? 'true' : 'false'} aria-selected={isActive ? 'true' : 'false'} - data-href={item.href} + data-href={item.href ? getHrefMd5(item.href) : undefined} className={`flex w-full cursor-pointer items-center rounded-md py-2 ${ isActive ? 'bg-gray-300 hover:bg-gray-400' : 'hover:bg-gray-300' }`} @@ -98,7 +101,7 @@ const TOCItemView: React.FC<{
    {item.subitems.map((subitem) => ( = ({ bookId, toc, currentHref: href }) => { +}> = ({ bookKey, toc, currentHref: href }) => { const [currentHref, setCurrentHref] = useState(href); const [expandedItems, setExpandedItems] = useState([]); - const { foliateView } = useReaderStore(); + const { getFoliateView } = useReaderStore(); const tocRef = useRef(null); const tocRelocateHandler = (event: Event) => { @@ -131,7 +134,12 @@ const TOCView: React.FC<{ } }; - useFoliateEvents(foliateView, bookId, { onRelocate: tocRelocateHandler }); + const foliateView = getFoliateView(bookKey); + useFoliateEvents(bookKey, foliateView, { onRelocate: tocRelocateHandler }); + + useEffect(() => { + setCurrentHref(href); + }, [href]); const expandParents = (toc: TOCItem[], href: string) => { const parentPath = findParentPath(toc, href).map((item) => item.href); @@ -139,7 +147,8 @@ const TOCView: React.FC<{ }; useEffect(() => { - const currentItem = tocRef.current?.querySelector(`[data-href="${currentHref}"]`); + const hrefMd5 = currentHref ? getHrefMd5(currentHref) : ''; + const currentItem = tocRef.current?.querySelector(`[data-href="${hrefMd5}"]`); if (currentItem) { const rect = currentItem.getBoundingClientRect(); const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight; @@ -160,7 +169,7 @@ const TOCView: React.FC<{ {toc && toc.map((item) => ( { const setProgress = useReaderStore((state) => state.setProgress); @@ -22,7 +22,7 @@ export const useFoliateEvents = ( const defaultRelocateHandler = (event: Event) => { const detail = (event as CustomEvent).detail; // console.log('relocate:', detail); - setProgress(bookId, detail.fraction, detail.cfi, detail.tocItem?.href, detail.location); + setProgress(bookKey, detail.fraction, detail.cfi, detail.tocItem?.href, detail.location); }; const onLoad = handlers?.onLoad || defaultLoadHandler; diff --git a/apps/readest-app/src/app/reader/page.tsx b/apps/readest-app/src/app/reader/page.tsx index 3376e460..60e17e8f 100644 --- a/apps/readest-app/src/app/reader/page.tsx +++ b/apps/readest-app/src/app/reader/page.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { useEffect, Suspense } from 'react'; +import { useEffect, Suspense, useRef } from 'react'; import { useEnv } from '@/context/EnvContext'; import { useReaderStore } from '@/store/readerStore'; @@ -12,8 +12,11 @@ import { DEFAULT_READSETTINGS } from '@/services/constants'; const ReaderPage = () => { const { envConfig } = useEnv(); const { settings, setLibrary, setSettings } = useReaderStore(); + const isInitiating = useRef(false); useEffect(() => { + if (isInitiating.current) return; + isInitiating.current = true; const initLibrary = async () => { const appService = await envConfig.getAppService(); const settings = await appService.loadSettings(); @@ -21,11 +24,12 @@ const ReaderPage = () => { settings.globalReadSettings = DEFAULT_READSETTINGS; } setSettings(settings); + console.log('initializing library in reader'); setLibrary(await appService.loadLibraryBooks()); }; initLibrary(); - }, [envConfig, setSettings, setLibrary]); + }, []); return ( settings.globalReadSettings && ( diff --git a/apps/readest-app/src/store/readerStore.ts b/apps/readest-app/src/store/readerStore.ts index 6c10e143..f0e679e2 100644 --- a/apps/readest-app/src/store/readerStore.ts +++ b/apps/readest-app/src/store/readerStore.ts @@ -4,49 +4,69 @@ import { BookNote, BookContent, Book, BookConfig, PageInfo } from '@/types/book' import { EnvConfigType } from '@/services/environment'; import { SystemSettings } from '@/types/settings'; import { FoliateView } from '@/app/reader/components/FoliateViewer'; +import { BookDoc, DocumentLoader } from '@/libs/document'; -interface BookState { +export interface BookState { + key: string; loading?: boolean; error?: string | null; book?: Book | null; file?: File | null; config?: BookConfig | null; + bookDoc?: BookDoc | null; + isPrimary?: boolean; } interface ReaderStore { library: Book[]; books: Record; settings: SystemSettings; - foliateView: FoliateView | null; + foliateViews: Record; setLibrary: (books: Book[]) => void; setSettings: (settings: SystemSettings) => void; setProgress: ( - id: string, + key: string, progress: number, location: string, href: string, pageinfo: PageInfo, ) => void; - setFoliateView: (view: FoliateView | null) => void; + setFoliateView: (key: string, view: FoliateView) => void; + getFoliateView: (key: string) => FoliateView | null; saveConfig: (envConfig: EnvConfigType, book: Book, config: BookConfig) => void; saveSettings: (envConfig: EnvConfigType, settings: SystemSettings) => void; - fetchBook: (envConfig: EnvConfigType, id: string) => Promise; - addBookmark: (id: string, bookmark: BookNote) => void; + initBookState: (envConfig: EnvConfigType, id: string, key: string, isPrimary?: boolean) => void; + addBookmark: (key: string, bookmark: BookNote) => void; } +export const DEFAULT_BOOK_STATE = { + key: '', + loading: true, + error: null, + file: null, + book: null, + config: null, + bookDoc: null, + isPrimary: true, +}; + export const useReaderStore = create((set, get) => ({ library: [], books: {}, settings: {} as SystemSettings, - foliateView: null, + foliateViews: {}, - setFoliateView: (view) => set({ foliateView: view }), setLibrary: (books: Book[]) => set({ library: books }), setSettings: (settings: SystemSettings) => set({ settings }), + setFoliateView: (key: string, view) => + set((state) => ({ foliateViews: { ...state.foliateViews, [key]: view } })), + + getFoliateView: (key: string) => get().foliateViews[key] || null, + saveConfig: async (envConfig: EnvConfigType, book: Book, config: BookConfig) => { const appService = await envConfig.getAppService(); const { library } = get(); @@ -65,11 +85,11 @@ export const useReaderStore = create((set, get) => ({ await appService.saveSettings(settings); }, - fetchBook: async (envConfig: EnvConfigType, id: string) => { + initBookState: async (envConfig: EnvConfigType, id: string, key: string, isPrimary = true) => { set((state) => ({ books: { ...state.books, - [id]: { loading: true, file: null, book: null, config: null, error: null, notes: [] }, + [key]: DEFAULT_BOOK_STATE, }, })); @@ -82,34 +102,50 @@ export const useReaderStore = create((set, get) => ({ } const content = (await appService.loadBookContent(book)) as BookContent; const { file, config } = content; + const { book: bookDoc } = await new DocumentLoader(file).open(); set((state) => ({ books: { ...state.books, - [id]: { ...state.books[id], loading: false, book, file, config }, + [key]: { + ...state.books[key], + loading: false, + key, + book, + file, + config, + bookDoc, + isPrimary, + }, }, })); - return book; + return content; } catch (error) { console.error(error); set((state) => ({ books: { ...state.books, - [id]: { ...state.books[id], loading: false, error: 'Failed to load book.' }, + [key]: { ...state.books[key], key: '', loading: false, error: 'Failed to load book.' }, }, })); return null; } }, - setProgress: (id: string, progress: number, location: string, href: string, pageinfo: PageInfo) => + setProgress: ( + key: string, + progress: number, + location: string, + href: string, + pageinfo: PageInfo, + ) => set((state) => { - const book = state.books[id]; + const book = state.books[key]; if (!book) return state; return { books: { ...state.books, - [id]: { + [key]: { ...book, config: { ...book.config, @@ -124,14 +160,14 @@ export const useReaderStore = create((set, get) => ({ }; }), - addBookmark: (id: string, bookmark: BookNote) => + addBookmark: (key: string, bookmark: BookNote) => set((state) => { - const book = state.books[id]; + const book = state.books[key]; if (!book) return state; return { books: { ...state.books, - [id]: { + [key]: { ...book, config: { ...book.config,