diff --git a/apps/readest-app/src/app/reader/components/BookGrid.tsx b/apps/readest-app/src/app/reader/components/BookGrid.tsx new file mode 100644 index 00000000..0f141ce0 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/BookGrid.tsx @@ -0,0 +1,60 @@ +import React from 'react'; + +import { BookState, useReaderStore } from '@/store/readerStore'; +import HeaderBar from './HeaderBar'; +import FoliateViewer from './FoliateViewer'; +import PageInfo from './PageInfo'; +import FooterBar from './FooterBar'; +import SectionInfo from './SectionInfo'; +import getGridTemplate from '@/utils/grid'; + +interface BookGridProps { + bookKeys: string[]; + bookStates: BookState[]; + onCloseBook: (bookKey: string) => void; +} + +const BookGrid: React.FC = ({ bookKeys, bookStates, onCloseBook }) => { + const { isSideBarPinned, isSideBarVisible, sideBarWidth } = useReaderStore(); + const gridWidth = isSideBarPinned && isSideBarVisible ? `calc(100% - ${sideBarWidth})` : '100%'; + const gridTemplate = getGridTemplate(bookKeys.length, window.innerWidth / window.innerHeight); + + return ( +
+ {bookStates.map((bookState, index) => { + const bookKey = bookKeys[index]!; + const { book, config, bookDoc } = bookState; + if (!book || !config || !bookDoc) return null; + const { section, pageinfo, progress, chapter } = config; + + return ( +
+ 2} + onCloseBook={onCloseBook} + /> + + {config.viewSettings?.scrolled ? null : ( + <> + + + + )} + +
+ ); + })} +
+ ); +}; + +export default BookGrid; diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 9b336aea..83df9ab0 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -131,7 +131,7 @@ const FoliateViewer: React.FC<{ useEffect(() => { if (isViewCreated.current) return; const openBook = async () => { - console.log('opening book'); + console.log('Opening book', bookKey); await import('foliate-js/view.js'); const view = document.createElement('foliate-view') as FoliateView; document.body.append(view); @@ -162,7 +162,13 @@ const FoliateViewer: React.FC<{ openBook(); isViewCreated.current = true; - }, [bookDoc]); + + return () => { + console.log('Closing book', bookKey); + view?.close(); + view?.remove(); + }; + }, []); const handleTap = (event: React.MouseEvent) => { const { clientX } = event; diff --git a/apps/readest-app/src/app/reader/components/FooterBar.tsx b/apps/readest-app/src/app/reader/components/FooterBar.tsx index 9bfcc962..3a3d27af 100644 --- a/apps/readest-app/src/app/reader/components/FooterBar.tsx +++ b/apps/readest-app/src/app/reader/components/FooterBar.tsx @@ -8,18 +8,10 @@ interface FooterBarProps { bookKey: string; progress: number | undefined; isHoveredAnim: boolean; - hoveredBookKey: string; - setHoveredBookKey: (key: string) => void; } -const FooterBar: React.FC = ({ - bookKey, - progress, - isHoveredAnim, - hoveredBookKey, - setHoveredBookKey, -}) => { - const { getFoliateView } = useReaderStore(); +const FooterBar: React.FC = ({ bookKey, progress, isHoveredAnim }) => { + const { isSideBarVisible, hoveredBookKey, setHoveredBookKey, getFoliateView } = useReaderStore(); const handleProgressChange = (event: React.ChangeEvent) => { const newProgress = parseInt((event.target as HTMLInputElement).value, 10); @@ -42,6 +34,7 @@ const FooterBar: React.FC = ({ className={clsx( 'footer-bar absolute bottom-0 z-10 flex h-12 w-full items-center px-4', 'shadow-xs bg-base-100 rounded-window-bottom-right transition-opacity duration-300', + !isSideBarVisible && 'rounded-window-bottom-left', isHoveredAnim && 'hover-bar-anim', hoveredBookKey === bookKey ? `opacity-100` : `opacity-0`, )} diff --git a/apps/readest-app/src/app/reader/components/HeaderBar.tsx b/apps/readest-app/src/app/reader/components/HeaderBar.tsx index 5b27d816..da8221b6 100644 --- a/apps/readest-app/src/app/reader/components/HeaderBar.tsx +++ b/apps/readest-app/src/app/reader/components/HeaderBar.tsx @@ -1,22 +1,17 @@ -import React, { useRef, useState } from 'react'; import clsx from 'clsx'; -import { VscLayoutSidebarLeft, VscLayoutSidebarLeftOff } from 'react-icons/vsc'; +import React, { useRef, useState } from 'react'; import { PiDotsThreeVerticalBold } from 'react-icons/pi'; import WindowButtons from '@/components/WindowButtons'; import Dropdown from '@/components/Dropdown'; +import SidebarToggler from './SidebarToggler'; import ViewMenu from './ViewMenu'; +import { useReaderStore } from '@/store/readerStore'; interface HeaderBarProps { bookKey: string; bookTitle: string; isHoveredAnim: boolean; - hoveredBookKey: string; - isSideBarVisible: boolean; - sideBarBookKey: string | null; - setSideBarVisibility: (visibility: boolean) => void; - setSideBarBookKey: (key: string) => void; - setHoveredBookKey: (key: string) => void; onCloseBook: (bookKey: string) => void; } @@ -24,24 +19,20 @@ const HeaderBar: React.FC = ({ bookKey, bookTitle, isHoveredAnim, - hoveredBookKey, - isSideBarVisible, - sideBarBookKey, - setSideBarVisibility, - setSideBarBookKey, - setHoveredBookKey, onCloseBook, }) => { const headerRef = useRef(null); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const { hoveredBookKey, setHoveredBookKey, sideBarBookKey, setSideBarBookKey } = useReaderStore(); + const { isSideBarVisible, toggleSideBar } = useReaderStore(); - const toggleSideBar = () => { - if (!isSideBarVisible) { - setSideBarVisibility(true); - } else if (sideBarBookKey === bookKey) { - setSideBarVisibility(false); + const toggleSidebarForBook = (bookKey: string) => { + if (sideBarBookKey === bookKey) { + toggleSideBar(); + } else { + setSideBarBookKey(bookKey); + if (!isSideBarVisible) toggleSideBar(); } - setSideBarBookKey(bookKey); }; return ( @@ -50,6 +41,7 @@ const HeaderBar: React.FC = ({ className={clsx( `header-bar absolute top-0 z-10 flex h-11 w-full items-center px-4`, `shadow-xs bg-base-100 rounded-window-top-right transition-opacity duration-300`, + !isSideBarVisible && 'rounded-window-top-left', isHoveredAnim && 'hover-bar-anim', hoveredBookKey === bookKey || isDropdownOpen ? `opacity-100` : `opacity-0`, isDropdownOpen && 'header-bar-pinned', @@ -58,13 +50,11 @@ const HeaderBar: React.FC = ({ onMouseLeave={() => setHoveredBookKey('')} >
- + toggleSidebarForBook(bookKey)} + >
diff --git a/apps/readest-app/src/app/reader/components/ReaderContent.tsx b/apps/readest-app/src/app/reader/components/ReaderContent.tsx index d23990d1..6f672266 100644 --- a/apps/readest-app/src/app/reader/components/ReaderContent.tsx +++ b/apps/readest-app/src/app/reader/components/ReaderContent.tsx @@ -1,116 +1,28 @@ 'use client'; import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { useSearchParams, useRouter } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import { useEnv } from '@/context/EnvContext'; -import { useReaderStore, DEFAULT_BOOK_STATE } from '@/store/readerStore'; +import { DEFAULT_BOOK_STATE, useReaderStore } from '@/store/readerStore'; +import { SystemSettings } from '@/types/settings'; import Spinner from '@/components/Spinner'; -import FoliateViewer from './FoliateViewer'; -import SideBar from './SideBar'; -import PageInfo from './PageInfo'; -import HeaderBar from './HeaderBar'; -import FooterBar from './FooterBar'; -import SectionInfo from './SectionInfo'; -import useShortcuts from '@/hooks/useShortcuts'; +import SideBar from './sidebar/SideBar'; +import useBooks from '../hooks/useBooks'; +import BookGrid from './BookGrid'; +import useBookShortcuts from '../hooks/useBookShortcuts'; -const ReaderContent = () => { +const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) => { const router = useRouter(); - const searchParams = useSearchParams(); - const ids = (searchParams.get('ids') || '').split(','); - const { envConfig } = useEnv(); - const { books, settings } = useReaderStore(); - const { initBookState, closeBook, saveConfig, saveSettings, getFoliateView } = useReaderStore(); + const { bookKeys, dismissBook, getNextBookKey, openSplitView } = useBooks(); + const { sideBarBookKey, setSideBarBookKey } = useReaderStore(); - const [sideBarWidth, setSideBarWidth] = useState(settings.globalReadSettings.sideBarWidth); - const [isSideBarPinned, setIsSideBarPinned] = useState( - settings.globalReadSettings.isSideBarPinned, - ); - const [isSideBarVisible, setSideBarVisibility] = useState(isSideBarPinned); + const { books, getFoliateView, clearBookState, saveConfig, saveSettings } = useReaderStore(); + const bookStates = bookKeys.map((key) => books[key] || DEFAULT_BOOK_STATE); - 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)); - const [hoveredBookKey, setHoveredBookKey] = useState(''); - - useEffect(() => { - if (ids.length === 0) return; - const uniqueIds = new Set(); - console.log('fetching books', ids); - ids.forEach((id, index) => { - const isPrimary = !uniqueIds.has(id); - uniqueIds.add(id); - const key = getKey(id, index); - if (books[key]) return; - console.log('initing state', key); - initBookState(envConfig, id, key, isPrimary); - }); - }, [searchParams]); - - const getNextBookKey = (bookKey: string) => { - const bookKeys = ids.map((id, index) => getKey(id, index)); - const index = bookKeys.findIndex((key) => key === bookKey); - const nextIndex = (index + 1) % bookKeys.length; - return bookKeys[nextIndex]!; - }; - - const switchSidebar = () => { - setSideBarBookKey((prevKey: string) => { - return getNextBookKey(prevKey); - }); - }; - - const toggleSidebar = () => { - setSideBarVisibility((prev) => !prev); - }; - - const openSplitView = () => { - const params = new URLSearchParams(searchParams.toString()); - const sideBarBookId = sideBarBookKey.split('-')[0]; - const updatedIds = [...ids, sideBarBookId].join(','); - params.set('ids', updatedIds); - router.push(`?${params.toString()}`); - }; - - const goLeft = () => { - getFoliateView(sideBarBookKey)?.goLeft(); - }; - - const goRight = () => { - getFoliateView(sideBarBookKey)?.goRight(); - }; - - const reloadPage = () => { - window.location.reload(); - }; - - useShortcuts( - { - onOpenSplitView: openSplitView, - onSwitchSidebar: switchSidebar, - onToggleSidebar: toggleSidebar, - onReloadPage: reloadPage, - onGoLeft: goLeft, - onGoRight: goRight, - }, - [sideBarBookKey, searchParams], - ); - - const handleSideBarResize = (newWidth: string) => { - setSideBarWidth(newWidth); - settings.globalReadSettings.sideBarWidth = newWidth; - }; - - const handleSideBarTogglePin = () => { - if (isSideBarPinned && isSideBarVisible) { - setSideBarVisibility(false); - } - setIsSideBarPinned(!isSideBarPinned); - settings.globalReadSettings.isSideBarPinned = !isSideBarPinned; - }; + useBookShortcuts({ sideBarBookKey, bookKeys, openSplitView, getNextBookKey }); const saveConfigAndCloseBook = (bookKey: string) => { getFoliateView(bookKey)?.close(); @@ -121,7 +33,7 @@ const ReaderContent = () => { if (isPrimary && book && config) { saveConfig(envConfig, book, config, settings); } - closeBook(bookKey); + clearBookState(bookKey); }; const saveSettingsAndGoToLibrary = () => { @@ -130,8 +42,7 @@ const ReaderContent = () => { }; const handleCloseBooks = () => { - ids.forEach((id, index) => { - const key = getKey(id, index); + bookKeys.forEach((key) => { saveConfigAndCloseBook(key); }); saveSettingsAndGoToLibrary(); @@ -139,43 +50,22 @@ const ReaderContent = () => { const handleCloseBook = (bookKey: string) => { saveConfigAndCloseBook(bookKey); - setSideBarBookKey((prevKey: string) => { - return prevKey === bookKey ? getNextBookKey(prevKey) : prevKey; - }); - const newIds = ids.filter((id, index) => getKey(id, index) !== bookKey); - if (newIds.length > 0) { - router.push(`/reader?ids=${newIds.join(',')}`); - } else { + if (sideBarBookKey === bookKey) { + setSideBarBookKey(getNextBookKey(sideBarBookKey)); + } + dismissBook(bookKey); + if (bookKeys.filter((key) => key !== bookKey).length == 0) { saveSettingsAndGoToLibrary(); } }; - const getGridTemplate = () => { - const count = ids.length; - const aspectRatio = window.innerWidth / window.innerHeight; - - if (count <= 1) { - // One book, full screen - return { columns: '1fr', rows: '1fr' }; - } else if (count === 2) { - if (aspectRatio < 1) { - // portrait mode: horizontal split - return { columns: '1fr', rows: '1fr 1fr' }; - } else { - // landscape mode: vertical 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) { + if ( + bookStates.length !== bookKeys.length || + !bookState || + !bookState.book || + !bookState.bookDoc + ) { return (
@@ -188,66 +78,16 @@ const ReaderContent = () => { ); } - const gridWidth = isSideBarPinned && isSideBarVisible ? `calc(100% - ${sideBarWidth})` : '100%'; - return (
setSideBarVisibility(visibility)} /> -
- {bookStates.map((bookState, index) => { - const key = getKey(ids[index]!, index); - const { book, config, bookDoc } = bookState; - if (!book || !config || !bookDoc) return null; - const { section, pageinfo, progress, chapter } = config; - const scrolled = config.viewSettings!.scrolled; - return ( -
- 2} - hoveredBookKey={hoveredBookKey} - isSideBarVisible={isSideBarVisible} - sideBarBookKey={sideBarBookKey} - setSideBarVisibility={setSideBarVisibility} - setSideBarBookKey={setSideBarBookKey} - setHoveredBookKey={setHoveredBookKey} - onCloseBook={handleCloseBook} - /> - - {!scrolled && } - {!scrolled && ( - - )} - -
- ); - })} -
+
); }; diff --git a/apps/readest-app/src/app/reader/components/SideBar.tsx b/apps/readest-app/src/app/reader/components/SideBar.tsx deleted file mode 100644 index 59397c6e..00000000 --- a/apps/readest-app/src/app/reader/components/SideBar.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import clsx from 'clsx'; -import { - MdOutlinePushPin, - MdPushPin, - MdToc, - MdEditNote, - MdBookmarkBorder, - MdOutlineMenu, -} from 'react-icons/md'; -import { GiBookshelf } from 'react-icons/gi'; -import { CiSearch } from 'react-icons/ci'; - -import { BookState, DEFAULT_BOOK_STATE, useReaderStore } from '@/store/readerStore'; -import Dropdown from '@/components/Dropdown'; -import BookCard from './BookCard'; -import TOCView from './TOCView'; -import BookMenu from './BookMenu'; - -const MIN_SIDEBAR_WIDTH = 0.15; -const MAX_SIDEBAR_WIDTH = 0.45; - -const SideBar: React.FC<{ - bookKey: string; - width: string; - isVisible: boolean; - isPinned: boolean; - onSetVisibility: (visibility: boolean) => void; - onTogglePin: () => void; - onResize: (newWidth: string) => void; - onGoToLibrary: () => void; - onOpenSplitView: () => void; -}> = ({ - bookKey, - width, - isPinned, - isVisible, - onTogglePin, - onSetVisibility, - onResize, - onGoToLibrary, - onOpenSplitView, -}) => { - const [activeTab, setActiveTab] = useState('toc'); - const { books } = useReaderStore(); - const [bookState, setBookState] = useState(null); - const [currentHref, setCurrentHref] = useState(null); - - 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); - }; - - const handleMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - - const handleMouseMove = (e: MouseEvent) => { - const newWidthPx = e.clientX; - const width = `${Math.round((newWidthPx / window.innerWidth) * 10000) / 100}%`; - const minWidthPx = MIN_SIDEBAR_WIDTH * window.innerWidth; - const maxWidthPx = MAX_SIDEBAR_WIDTH * window.innerWidth; - if (newWidthPx >= minWidthPx && newWidthPx <= maxWidthPx) { - onResize(width); - } - }; - - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - - if (!bookState || !bookState.book || !bookState.bookDoc) { - return null; - } - - const { book, bookDoc } = bookState; - - return isVisible ? ( -
-
-
-
- -
-
- - } - > - - - -
-
-
- -
-
- {activeTab === 'toc' && bookDoc!.toc && ( - - )} - {activeTab === 'annotations' &&
Annotations
} - {activeTab === 'bookmarks' &&
Bookmarks
} -
-
- {['toc', 'annotations', 'bookmarks'].map((tab) => ( - - ))} -
-
-
- {!isPinned && ( -
handleClickOverlay()} - /> - )} -
- ) : null; -}; - -export default SideBar; diff --git a/apps/readest-app/src/app/reader/components/SidebarToggler.tsx b/apps/readest-app/src/app/reader/components/SidebarToggler.tsx new file mode 100644 index 00000000..63496da1 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/SidebarToggler.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { VscLayoutSidebarLeft, VscLayoutSidebarLeftOff } from 'react-icons/vsc'; + +interface SidebarTogglerProps { + isSidebarVisible: boolean; + isCurrentBook: boolean; + toggleSidebar: () => void; +} + +const SidebarToggler: React.FC = ({ + isSidebarVisible, + isCurrentBook, + toggleSidebar, +}) => ( + +); + +export default SidebarToggler; diff --git a/apps/readest-app/src/app/reader/components/BookCard.tsx b/apps/readest-app/src/app/reader/components/sidebar/BookCard.tsx similarity index 100% rename from apps/readest-app/src/app/reader/components/BookCard.tsx rename to apps/readest-app/src/app/reader/components/sidebar/BookCard.tsx diff --git a/apps/readest-app/src/app/reader/components/BookMenu.tsx b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx similarity index 100% rename from apps/readest-app/src/app/reader/components/BookMenu.tsx rename to apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx diff --git a/apps/readest-app/src/app/reader/components/sidebar/Content.tsx b/apps/readest-app/src/app/reader/components/sidebar/Content.tsx new file mode 100644 index 00000000..7213b18e --- /dev/null +++ b/apps/readest-app/src/app/reader/components/sidebar/Content.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import TOCView from './TOCView'; +import { BookDoc } from '@/libs/document'; + +const SidebarContent: React.FC<{ + activeTab: string; + bookDoc: BookDoc; + currentHref: string | null; + sideBarBookKey: string; +}> = ({ activeTab, bookDoc, currentHref, sideBarBookKey }) => ( +
+ {activeTab === 'toc' && bookDoc.toc && ( + + )} + {activeTab === 'annotations' &&
Annotations
} + {activeTab === 'bookmarks' &&
Bookmarks
} +
+); + +export default SidebarContent; diff --git a/apps/readest-app/src/app/reader/components/sidebar/Header.tsx b/apps/readest-app/src/app/reader/components/sidebar/Header.tsx new file mode 100644 index 00000000..9eee7184 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/sidebar/Header.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { GiBookshelf } from 'react-icons/gi'; +import { CiSearch } from 'react-icons/ci'; +import { MdOutlineMenu, MdOutlinePushPin, MdPushPin } from 'react-icons/md'; +import Dropdown from '@/components/Dropdown'; +import BookMenu from './BookMenu'; + +const SidebarHeader: React.FC<{ + isPinned: boolean; + onGoToLibrary: () => void; + onOpenSplitView: () => void; + handleSideBarTogglePin: () => void; +}> = ({ isPinned, onGoToLibrary, onOpenSplitView, handleSideBarTogglePin }) => ( +
+
+ +
+
+ + } + > + + + +
+
+); + +export default SidebarHeader; diff --git a/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx b/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx new file mode 100644 index 00000000..ddeb232e --- /dev/null +++ b/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx @@ -0,0 +1,98 @@ +import clsx from 'clsx'; +import React, { useState, useEffect } from 'react'; + +import { BookState, DEFAULT_BOOK_STATE, useReaderStore } from '@/store/readerStore'; +import SidebarHeader from './Header'; +import SidebarContent from './Content'; +import TabNavigation from './TabNavigation'; +import BookCard from './BookCard'; +import useSidebar from '../../hooks/useSidebar'; +import useDragBar from '../../hooks/useDragBar'; + +const MIN_SIDEBAR_WIDTH = 0.15; +const MAX_SIDEBAR_WIDTH = 0.45; + +const SideBar: React.FC<{ + width: string; + isPinned: boolean; + onGoToLibrary: () => void; + onOpenSplitView: () => void; +}> = ({ width, isPinned, onGoToLibrary, onOpenSplitView }) => { + const [activeTab, setActiveTab] = useState('toc'); + const { books } = useReaderStore(); + const [bookState, setBookState] = useState(null); + const [currentHref, setCurrentHref] = useState(null); + const { sideBarBookKey } = useReaderStore(); + const { + sideBarWidth, + isSideBarVisible, + handleSideBarResize, + handleSideBarTogglePin, + setSideBarVisibility, + } = useSidebar(width, isPinned); + const { handleMouseDown } = useDragBar(handleSideBarResize, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); + + useEffect(() => { + if (!books || !sideBarBookKey) return; + const bookState = books[sideBarBookKey] || DEFAULT_BOOK_STATE; + const { config } = bookState; + setBookState(bookState); + setCurrentHref(config?.href || null); + }, [books, sideBarBookKey]); + + const handleClickOverlay = () => { + setSideBarVisibility(false); + }; + + if (!sideBarBookKey || !bookState || !bookState.book || !bookState.bookDoc) { + return null; + } + + const { book, bookDoc } = bookState; + + return isSideBarVisible ? ( +
+ +
+ +
+ + +
+ {!isPinned && ( +
+ )} +
+ ) : null; +}; + +export default SideBar; diff --git a/apps/readest-app/src/app/reader/components/TOCView.tsx b/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx similarity index 98% rename from apps/readest-app/src/app/reader/components/TOCView.tsx rename to apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx index 5306f406..66c3c190 100644 --- a/apps/readest-app/src/app/reader/components/TOCView.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx @@ -3,7 +3,7 @@ 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'; +import { useFoliateEvents } from '../../hooks/useFoliateEvents'; const findParentPath = (toc: TOCItem[], href: string): TOCItem[] => { for (const item of toc) { @@ -61,8 +61,6 @@ const TOCItemView: React.FC<{ } }; - useEffect(() => {}, [currentHref]); - const isActive = currentHref === item.href; useEffect(() => { diff --git a/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx b/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx new file mode 100644 index 00000000..9c72bed2 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/sidebar/TabNavigation.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { MdToc, MdEditNote, MdBookmarkBorder } from 'react-icons/md'; + +const TabNavigation: React.FC<{ + activeTab: string; + onTabChange: (tab: string) => void; +}> = ({ activeTab, onTabChange }) => ( +
+ {['toc', 'annotations', 'bookmarks'].map((tab) => ( + + ))} +
+); + +export default TabNavigation; diff --git a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts new file mode 100644 index 00000000..07600c11 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts @@ -0,0 +1,49 @@ +import { useReaderStore } from '@/store/readerStore'; +import useShortcuts from '@/hooks/useShortcuts'; + +interface UseBookShortcutsProps { + sideBarBookKey: string | null; + bookKeys: string[]; + openSplitView: () => void; + getNextBookKey: (bookKey: string) => string; +} + +const useBookShortcuts = ({ + sideBarBookKey, + bookKeys, + openSplitView, + getNextBookKey, +}: UseBookShortcutsProps) => { + const { getFoliateView, setSideBarBookKey } = useReaderStore(); + const { toggleSideBar } = useReaderStore(); + + const switchSideBar = () => { + if (sideBarBookKey) setSideBarBookKey(getNextBookKey(sideBarBookKey)); + }; + + const goLeft = () => { + getFoliateView(sideBarBookKey)?.goLeft(); + }; + + const goRight = () => { + getFoliateView(sideBarBookKey)?.goRight(); + }; + + const reloadPage = () => { + window.location.reload(); + }; + + useShortcuts( + { + onOpenSplitView: openSplitView, + onSwitchSideBar: switchSideBar, + onToggleSideBar: toggleSideBar, + onReloadPage: reloadPage, + onGoLeft: goLeft, + onGoRight: goRight, + }, + [sideBarBookKey, bookKeys], + ); +}; + +export default useBookShortcuts; diff --git a/apps/readest-app/src/app/reader/hooks/useBooks.ts b/apps/readest-app/src/app/reader/hooks/useBooks.ts new file mode 100644 index 00000000..a1100346 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useBooks.ts @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEnv } from '@/context/EnvContext'; +import { useReaderStore } from '@/store/readerStore'; + +const uniqueId = () => Math.random().toString(36).substring(2, 9); + +const useBooks = () => { + const { envConfig } = useEnv(); + const searchParams = useSearchParams(); + const router = useRouter(); + const { books, initBookState, sideBarBookKey, setSideBarBookKey } = useReaderStore(); + + const initialIds = (searchParams.get('ids') || '').split(',').filter(Boolean); + const [bookKeys, setBookKeys] = useState(() => { + return initialIds.map((id) => `${id}-${uniqueId()}`); + }); + + const [shouldUpdateSearchParams, setShouldUpdateSearchParams] = useState(false); + useEffect(() => { + if (shouldUpdateSearchParams) { + const ids = bookKeys.map((key) => key.split('-')[0]).join(','); + if (ids) { + const params = new URLSearchParams(searchParams.toString()); + params.set('ids', ids); + router.replace(`?${params.toString()}`, { scroll: false }); + } + setShouldUpdateSearchParams(false); + } + }, [bookKeys, shouldUpdateSearchParams]); + + // 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); + setBookKeys((prevKeys) => { + if (!prevKeys.includes(newKey)) { + prevKeys.push(newKey); + } + return prevKeys; + }); + setSideBarBookKey(newKey); + setShouldUpdateSearchParams(true); + }; + + // Close a book and sync with bookKeys and URL + const dismissBook = (bookKey: string) => { + setBookKeys((prevKeys) => { + const updatedKeys = prevKeys.filter((key) => key !== bookKey); + return updatedKeys; + }); + setShouldUpdateSearchParams(true); + }; + + const getNextBookKey = (bookKey: string) => { + const index = bookKeys.findIndex((key) => key === bookKey); + const nextIndex = (index + 1) % bookKeys.length; + return bookKeys[nextIndex]!; + }; + + const openSplitView = () => { + const sideBarBookId = sideBarBookKey?.split('-')[0]; + if (sideBarBookId) appendBook(sideBarBookId, false); + }; + + // Initialize all book states on first load + useEffect(() => { + const uniqueIds = new Set(); + console.log('Initialize books', bookKeys); + bookKeys.forEach((key, index) => { + const id = key.split('-')[0]!; + const isPrimary = !uniqueIds.has(id); + uniqueIds.add(id); + if (!books[key]) { + initBookState(envConfig, id, key, isPrimary); + if (index === 0) setSideBarBookKey(key); + } + }); + }, []); + + return { + bookKeys, + appendBook, + dismissBook, + getNextBookKey, + openSplitView, + }; +}; + +export default useBooks; diff --git a/apps/readest-app/src/app/reader/hooks/useDragBar.ts b/apps/readest-app/src/app/reader/hooks/useDragBar.ts new file mode 100644 index 00000000..6d77af97 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useDragBar.ts @@ -0,0 +1,32 @@ +import { useCallback } from 'react'; + +const useDragBar = ( + handleSideBarResize: (width: string) => void, + minWidth: number, + maxWidth: number, +) => { + const handleMouseMove = useCallback( + (e: MouseEvent) => { + const newWidthPx = e.clientX; + const width = `${Math.round((newWidthPx / window.innerWidth) * 10000) / 100}%`; + const minWidthPx = minWidth * window.innerWidth; + const maxWidthPx = maxWidth * window.innerWidth; + if (newWidthPx >= minWidthPx && newWidthPx <= maxWidthPx) { + handleSideBarResize(width); + } + }, + [handleSideBarResize, minWidth, maxWidth], + ); + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', () => { + document.removeEventListener('mousemove', handleMouseMove); + }); + }; + + return { handleMouseDown }; +}; + +export default useDragBar; diff --git a/apps/readest-app/src/app/reader/hooks/useFoliateEvents.ts b/apps/readest-app/src/app/reader/hooks/useFoliateEvents.ts index 0d5cb368..bcb1c35d 100644 --- a/apps/readest-app/src/app/reader/hooks/useFoliateEvents.ts +++ b/apps/readest-app/src/app/reader/hooks/useFoliateEvents.ts @@ -20,5 +20,5 @@ export const useFoliateEvents = (view: FoliateView | null, handlers?: FoliateEve if (onLoad) view.removeEventListener('load', onLoad); if (onRelocate) view.removeEventListener('relocate', onRelocate); }; - }, [view, onLoad, onRelocate, handlers]); + }, [view, onLoad, onRelocate]); }; diff --git a/apps/readest-app/src/app/reader/hooks/useSidebar.ts b/apps/readest-app/src/app/reader/hooks/useSidebar.ts new file mode 100644 index 00000000..51167728 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useSidebar.ts @@ -0,0 +1,45 @@ +import { useReaderStore } from '@/store/readerStore'; +import { useEffect } from 'react'; + +const useSidebar = (initialWidth: string, isPinned: boolean) => { + const { settings } = useReaderStore(); + const { + sideBarWidth, + setSideBarWidth, + isSideBarVisible, + setSideBarVisibility, + toggleSideBar, + isSideBarPinned, + setSideBarPin, + toggleSideBarPin, + } = useReaderStore(); + + useEffect(() => { + setSideBarWidth(initialWidth); + setSideBarPin(isPinned); + setSideBarVisibility(isPinned); + }, []); + + const handleSideBarResize = (newWidth: string) => { + setSideBarWidth(newWidth); + settings.globalReadSettings.sideBarWidth = newWidth; + }; + + const handleSideBarTogglePin = () => { + toggleSideBarPin(); + settings.globalReadSettings.isSideBarPinned = !isSideBarPinned; + if (isSideBarPinned && isSideBarVisible) setSideBarVisibility(false); + }; + + return { + sideBarWidth, + isSideBarPinned, + isSideBarVisible, + handleSideBarResize, + handleSideBarTogglePin, + setSideBarVisibility, + toggleSideBar, + }; +}; + +export default useSidebar; diff --git a/apps/readest-app/src/app/reader/page.tsx b/apps/readest-app/src/app/reader/page.tsx index 9d21acea..1a32c8d0 100644 --- a/apps/readest-app/src/app/reader/page.tsx +++ b/apps/readest-app/src/app/reader/page.tsx @@ -32,7 +32,7 @@ const ReaderPage = () => { settings.globalReadSettings && (
- +
) diff --git a/apps/readest-app/src/hooks/useShortcuts.ts b/apps/readest-app/src/hooks/useShortcuts.ts index 48784dbe..8803415e 100644 --- a/apps/readest-app/src/hooks/useShortcuts.ts +++ b/apps/readest-app/src/hooks/useShortcuts.ts @@ -2,8 +2,8 @@ import { useEffect, useState } from 'react'; import { loadShortcuts, ShortcutConfig } from '../helpers/shortcuts'; interface KeyActionHandlers { - onSwitchSidebar?: () => void; - onToggleSidebar?: () => void; + onSwitchSideBar?: () => void; + onToggleSideBar?: () => void; onOpenSplitView?: () => void; onReloadPage?: () => void; onGoLeft?: () => void; @@ -63,7 +63,7 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency isShortcutMatch(shortcut, key, ctrlKey, altKey, metaKey, shiftKey), ) ) { - actions.onSwitchSidebar?.(); + actions.onSwitchSideBar?.(); return true; } if ( @@ -71,7 +71,7 @@ const useShortcuts = (actions: KeyActionHandlers, dependencies: React.Dependency isShortcutMatch(shortcut, key, ctrlKey, altKey, metaKey, shiftKey), ) ) { - actions.onToggleSidebar?.(); + actions.onToggleSideBar?.(); return true; } if ( diff --git a/apps/readest-app/src/store/readerStore.ts b/apps/readest-app/src/store/readerStore.ts index ef066757..fe563a01 100644 --- a/apps/readest-app/src/store/readerStore.ts +++ b/apps/readest-app/src/store/readerStore.ts @@ -19,9 +19,25 @@ export interface BookState { interface ReaderStore { library: Book[]; - books: Record; settings: SystemSettings; + + books: Record; foliateViews: Record; + bookDocCache: Record; + + hoveredBookKey: string | null; + sideBarBookKey: string | null; + setHoveredBookKey: (key: string) => void; + setSideBarBookKey: (key: string) => void; + + sideBarWidth: string; + isSideBarVisible: boolean; + isSideBarPinned: boolean; + setSideBarWidth: (width: string) => void; + toggleSideBar: () => void; + toggleSideBarPin: () => void; + setSideBarVisibility: (visible: boolean) => void; + setSideBarPin: (pinned: boolean) => void; setLibrary: (books: Book[]) => void; setSettings: (settings: SystemSettings) => void; @@ -36,7 +52,7 @@ interface ReaderStore { ) => void; setConfig: (key: string, config: BookConfig) => void; setFoliateView: (key: string, view: FoliateView) => void; - getFoliateView: (key: string) => FoliateView | null; + getFoliateView: (key: string | null) => FoliateView | null; deleteBook: (envConfig: EnvConfigType, book: Book) => void; saveConfig: ( @@ -48,7 +64,7 @@ interface ReaderStore { saveSettings: (envConfig: EnvConfigType, settings: SystemSettings) => void; initBookState: (envConfig: EnvConfigType, id: string, key: string, isPrimary?: boolean) => void; - closeBook: (key: string) => void; + clearBookState: (key: string) => void; addBookmark: (key: string, bookmark: BookNote) => void; } @@ -65,9 +81,25 @@ export const DEFAULT_BOOK_STATE = { export const useReaderStore = create((set, get) => ({ library: [], - books: {}, settings: {} as SystemSettings, + + books: {}, foliateViews: {}, + bookDocCache: {}, + + hoveredBookKey: null, + sideBarBookKey: null, + setHoveredBookKey: (key: string) => set({ hoveredBookKey: key }), + setSideBarBookKey: (key: string) => set({ sideBarBookKey: key }), + + sideBarWidth: '', + isSideBarVisible: false, + isSideBarPinned: false, + setSideBarWidth: (width: string) => set({ sideBarWidth: width }), + toggleSideBar: () => set((state) => ({ isSideBarVisible: !state.isSideBarVisible })), + toggleSideBarPin: () => set((state) => ({ isSideBarPinned: !state.isSideBarPinned })), + setSideBarVisibility: (visible: boolean) => set({ isSideBarVisible: visible }), + setSideBarPin: (pinned: boolean) => set({ isSideBarPinned: pinned }), setLibrary: (books: Book[]) => set({ library: books }), setSettings: (settings: SystemSettings) => set({ settings }), @@ -94,7 +126,7 @@ export const useReaderStore = create((set, get) => ({ setFoliateView: (key: string, view) => set((state) => ({ foliateViews: { ...state.foliateViews, [key]: view } })), - getFoliateView: (key: string) => get().foliateViews[key] || null, + getFoliateView: (key: string | null) => (key && get().foliateViews[key]) || null, deleteBook: async (envConfig: EnvConfigType, book: Book) => { const appService = await envConfig.getAppService(); @@ -130,7 +162,7 @@ export const useReaderStore = create((set, get) => ({ await appService.saveSettings(settings); }, - closeBook: (key: string) => { + clearBookState: (key: string) => { set((state) => { const books = { ...state.books }; delete books[key]; @@ -138,6 +170,8 @@ export const useReaderStore = create((set, get) => ({ }); }, initBookState: async (envConfig: EnvConfigType, id: string, key: string, isPrimary = true) => { + const cache = get().bookDocCache || {}; + set((state) => ({ books: { ...state.books, @@ -154,7 +188,21 @@ export const useReaderStore = create((set, get) => ({ } const content = (await appService.loadBookContent(book, settings)) as BookContent; const { file, config } = content; - const { book: bookDoc } = await new DocumentLoader(file).open(); + let bookDoc: BookDoc; + if (cache[id]) { + console.log('Using cached bookDoc for book', key); + bookDoc = cache[id]; + } else { + console.log('Loading book', key); + const { book: loadedBookDoc } = await new DocumentLoader(file).open(); + bookDoc = loadedBookDoc as BookDoc; + set((state) => ({ + bookDocCache: { + ...state.bookDocCache, + [id]: bookDoc, + }, + })); + } set((state) => ({ books: { diff --git a/apps/readest-app/src/utils/grid.ts b/apps/readest-app/src/utils/grid.ts new file mode 100644 index 00000000..a0664685 --- /dev/null +++ b/apps/readest-app/src/utils/grid.ts @@ -0,0 +1,15 @@ +const getGridTemplate = (count: number, aspectRatio: number) => { + if (count <= 1) { + return { columns: '1fr', rows: '1fr' }; + } else if (count === 2) { + return aspectRatio < 1 + ? { columns: '1fr', rows: '1fr 1fr' } + : { columns: '1fr 1fr', rows: '1fr' }; + } else if (count === 3 || count === 4) { + return { columns: '1fr 1fr', rows: '1fr 1fr' }; + } else { + return { columns: '1fr 1fr 1fr', rows: '1fr 1fr 1fr' }; + } +}; + +export default getGridTemplate;