Refactor ReaderContent and SideBar

This commit is contained in:
chrox
2024-10-31 17:13:33 +01:00
parent f5a233ba31
commit b7c8300de6
23 changed files with 616 additions and 414 deletions
@@ -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<BookGridProps> = ({ 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 (
<div
className='grid h-full'
style={{
width: gridWidth,
gridTemplateColumns: gridTemplate.columns,
gridTemplateRows: gridTemplate.rows,
}}
>
{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 (
<div key={bookKey} className='relative h-full w-full overflow-hidden'>
<HeaderBar
bookKey={bookKey}
bookTitle={book.title}
isHoveredAnim={bookStates.length > 2}
onCloseBook={onCloseBook}
/>
<FoliateViewer bookKey={bookKey} bookDoc={bookState.bookDoc!} bookConfig={config} />
{config.viewSettings?.scrolled ? null : (
<>
<SectionInfo chapter={chapter} />
<PageInfo bookFormat={book.format} section={section} pageinfo={pageinfo} />
</>
)}
<FooterBar bookKey={bookKey} progress={progress} isHoveredAnim={false} />
</div>
);
})}
</div>
);
};
export default BookGrid;
@@ -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<HTMLDivElement, MouseEvent>) => {
const { clientX } = event;
@@ -8,18 +8,10 @@ interface FooterBarProps {
bookKey: string;
progress: number | undefined;
isHoveredAnim: boolean;
hoveredBookKey: string;
setHoveredBookKey: (key: string) => void;
}
const FooterBar: React.FC<FooterBarProps> = ({
bookKey,
progress,
isHoveredAnim,
hoveredBookKey,
setHoveredBookKey,
}) => {
const { getFoliateView } = useReaderStore();
const FooterBar: React.FC<FooterBarProps> = ({ 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<FooterBarProps> = ({
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`,
)}
@@ -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<HeaderBarProps> = ({
bookKey,
bookTitle,
isHoveredAnim,
hoveredBookKey,
isSideBarVisible,
sideBarBookKey,
setSideBarVisibility,
setSideBarBookKey,
setHoveredBookKey,
onCloseBook,
}) => {
const headerRef = useRef<HTMLDivElement>(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<HeaderBarProps> = ({
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<HeaderBarProps> = ({
onMouseLeave={() => setHoveredBookKey('')}
>
<div className='sidebar-toggler mr-auto flex h-full items-center'>
<button onClick={toggleSideBar} className='p-2'>
{sideBarBookKey === bookKey && isSideBarVisible ? (
<VscLayoutSidebarLeft size={16} />
) : (
<VscLayoutSidebarLeftOff size={16} />
)}
</button>
<SidebarToggler
isSidebarVisible={isSideBarVisible}
isCurrentBook={sideBarBookKey === bookKey}
toggleSidebar={() => toggleSidebarForBook(bookKey)}
></SidebarToggler>
</div>
<div className='header-title flex flex-1 items-center justify-center'>
@@ -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<string>();
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 (
<div className={'hero hero-content min-h-screen'}>
<Spinner loading={true} />
@@ -188,66 +78,16 @@ const ReaderContent = () => {
);
}
const gridWidth = isSideBarPinned && isSideBarVisible ? `calc(100% - ${sideBarWidth})` : '100%';
return (
<div className='flex h-screen'>
<SideBar
bookKey={sideBarBookKey}
width={sideBarWidth}
isVisible={isSideBarVisible}
isPinned={isSideBarPinned}
onResize={handleSideBarResize}
onTogglePin={handleSideBarTogglePin}
width={settings.globalReadSettings.sideBarWidth}
isPinned={settings.globalReadSettings.isSideBarPinned}
onGoToLibrary={handleCloseBooks}
onOpenSplitView={openSplitView}
onSetVisibility={(visibility: boolean) => setSideBarVisibility(visibility)}
/>
<div
className='grid h-full'
style={{
width: gridWidth,
gridTemplateColumns: getGridTemplate().columns,
gridTemplateRows: getGridTemplate().rows,
}}
>
{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 (
<div key={key} className='relative h-full w-full overflow-hidden'>
<HeaderBar
bookKey={key}
bookTitle={book?.title}
isHoveredAnim={ids.length > 2}
hoveredBookKey={hoveredBookKey}
isSideBarVisible={isSideBarVisible}
sideBarBookKey={sideBarBookKey}
setSideBarVisibility={setSideBarVisibility}
setSideBarBookKey={setSideBarBookKey}
setHoveredBookKey={setHoveredBookKey}
onCloseBook={handleCloseBook}
/>
<FoliateViewer bookKey={key} bookDoc={bookDoc!} bookConfig={config!} />
{!scrolled && <SectionInfo chapter={chapter} />}
{!scrolled && (
<PageInfo bookFormat={book.format} section={section} pageinfo={pageinfo} />
)}
<FooterBar
bookKey={key}
progress={progress}
isHoveredAnim={false}
hoveredBookKey={hoveredBookKey}
setHoveredBookKey={setHoveredBookKey}
/>
</div>
);
})}
</div>
<BookGrid bookKeys={bookKeys} bookStates={bookStates} onCloseBook={handleCloseBook} />
</div>
);
};
@@ -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<BookState | null>(null);
const [currentHref, setCurrentHref] = useState<string | null>(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<HTMLDivElement>) => {
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 ? (
<div
className={clsx(
'sidebar-container bg-base-200 z-20 h-full select-none',
'rounded-window-top-left rounded-window-bottom-left',
!isPinned && 'shadow-2xl',
)}
style={{
width: `${width}`,
minWidth: `${MIN_SIDEBAR_WIDTH * 100}%`,
maxWidth: `${MAX_SIDEBAR_WIDTH * 100}%`,
position: isPinned ? 'relative' : 'absolute',
}}
>
<div className={'sidebar h-full'}>
<div className='sidebar-header flex h-11 items-center justify-between pl-1.5 pr-3'>
<div className='flex items-center'>
<button className='btn btn-ghost h-8 min-h-8 w-8 p-0' onClick={onGoToLibrary}>
<GiBookshelf size={20} className='fill-base-content' />
</button>
</div>
<div className='flex size-[50%] min-w-20 items-center justify-between'>
<button className='btn btn-ghost left-0 h-8 min-h-8 w-8 p-0'>
<CiSearch size={20} className='fill-base-content' />
</button>
<Dropdown
className='dropdown-bottom flex justify-center'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdOutlineMenu size={20} />}
>
<BookMenu openSplitView={onOpenSplitView} />
</Dropdown>
<button
onClick={onTogglePin}
className={`${isPinned ? 'bg-gray-300' : 'bg-base-300'} btn btn-ghost btn-circle right-0 h-6 min-h-6 w-6`}
>
{isPinned ? <MdPushPin size={14} /> : <MdOutlinePushPin size={14} />}
</button>
</div>
</div>
<div className='border-b px-3'>
<BookCard cover={book.coverImageUrl!} title={book.title} author={book.author} />
</div>
<div className='sidebar-content overflow-y-auto shadow-inner'>
{activeTab === 'toc' && bookDoc!.toc && (
<TOCView toc={bookDoc!.toc} bookKey={bookKey} currentHref={currentHref} />
)}
{activeTab === 'annotations' && <div>Annotations</div>}
{activeTab === 'bookmarks' && <div>Bookmarks</div>}
</div>
<div className='bottom-tab absolute bottom-0 flex w-full border-t'>
{['toc', 'annotations', 'bookmarks'].map((tab) => (
<button
key={tab}
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === tab ? 'bg-base-300' : ''}`}
onClick={() => setActiveTab(tab)}
>
{tab === 'toc' && <MdToc size={20} className='mx-auto' />}
{tab === 'annotations' && <MdEditNote size={20} className='mx-auto' />}
{tab === 'bookmarks' && <MdBookmarkBorder size={20} className='mx-auto' />}
</button>
))}
</div>
<div
className='drag-bar absolute right-0 top-0 h-full w-0.5 cursor-col-resize'
onMouseDown={handleMouseDown}
></div>
</div>
{!isPinned && (
<div
className='overlay fixed top-0 h-full bg-black/20'
style={{
left: width,
width: `calc(100% - ${width})`,
}}
onClick={() => handleClickOverlay()}
/>
)}
</div>
) : null;
};
export default SideBar;
@@ -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<SidebarTogglerProps> = ({
isSidebarVisible,
isCurrentBook,
toggleSidebar,
}) => (
<button onClick={toggleSidebar} className='p-2'>
{isCurrentBook && isSidebarVisible ? (
<VscLayoutSidebarLeft size={16} />
) : (
<VscLayoutSidebarLeftOff size={16} />
)}
</button>
);
export default SidebarToggler;
@@ -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 }) => (
<div className='sidebar-content overflow-y-auto shadow-inner'>
{activeTab === 'toc' && bookDoc.toc && (
<TOCView toc={bookDoc.toc} bookKey={sideBarBookKey} currentHref={currentHref} />
)}
{activeTab === 'annotations' && <div>Annotations</div>}
{activeTab === 'bookmarks' && <div>Bookmarks</div>}
</div>
);
export default SidebarContent;
@@ -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 }) => (
<div className='sidebar-header flex h-11 items-center justify-between pl-1.5 pr-3'>
<div className='flex items-center'>
<button className='btn btn-ghost h-8 min-h-8 w-8 p-0' onClick={onGoToLibrary}>
<GiBookshelf size={20} className='fill-base-content' />
</button>
</div>
<div className='flex size-[50%] min-w-20 items-center justify-between'>
<button className='btn btn-ghost left-0 h-8 min-h-8 w-8 p-0'>
<CiSearch size={20} className='fill-base-content' />
</button>
<Dropdown
className='dropdown-bottom flex justify-center'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<MdOutlineMenu size={20} />}
>
<BookMenu openSplitView={onOpenSplitView} />
</Dropdown>
<button
onClick={handleSideBarTogglePin}
className={`${isPinned ? 'bg-gray-300' : 'bg-base-300'} btn btn-ghost btn-circle right-0 h-6 min-h-6 w-6`}
>
{isPinned ? <MdPushPin size={14} /> : <MdOutlinePushPin size={14} />}
</button>
</div>
</div>
);
export default SidebarHeader;
@@ -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<BookState | null>(null);
const [currentHref, setCurrentHref] = useState<string | null>(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 ? (
<div
className={clsx(
'sidebar-container bg-base-200 z-20 h-full select-none',
'rounded-window-top-left rounded-window-bottom-left',
!isPinned && 'shadow-2xl',
)}
style={{
width: `${sideBarWidth}`,
minWidth: `${MIN_SIDEBAR_WIDTH * 100}%`,
maxWidth: `${MAX_SIDEBAR_WIDTH * 100}%`,
position: isPinned ? 'relative' : 'absolute',
}}
>
<SidebarHeader
isPinned={isPinned}
onGoToLibrary={onGoToLibrary}
onOpenSplitView={onOpenSplitView}
handleSideBarTogglePin={handleSideBarTogglePin}
/>
<div className='border-b px-3'>
<BookCard cover={book.coverImageUrl!} title={book.title} author={book.author} />
</div>
<SidebarContent
activeTab={activeTab}
bookDoc={bookDoc}
currentHref={currentHref}
sideBarBookKey={sideBarBookKey!}
/>
<TabNavigation activeTab={activeTab} onTabChange={setActiveTab} />
<div
className='drag-bar absolute right-0 top-0 h-full w-0.5 cursor-col-resize'
onMouseDown={handleMouseDown}
></div>
{!isPinned && (
<div
className='overlay fixed top-0 h-full bg-black/20'
style={{ left: sideBarWidth, width: `calc(100% - ${sideBarWidth})` }}
onClick={handleClickOverlay}
/>
)}
</div>
) : null;
};
export default SideBar;
@@ -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(() => {
@@ -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 }) => (
<div className='bottom-tab absolute bottom-0 flex w-full border-t'>
{['toc', 'annotations', 'bookmarks'].map((tab) => (
<button
key={tab}
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === tab ? 'bg-base-300' : ''}`}
onClick={() => onTabChange(tab)}
>
{tab === 'toc' && <MdToc size={20} className='mx-auto' />}
{tab === 'annotations' && <MdEditNote size={20} className='mx-auto' />}
{tab === 'bookmarks' && <MdBookmarkBorder size={20} className='mx-auto' />}
</button>
))}
</div>
);
export default TabNavigation;
@@ -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;
@@ -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<string[]>(() => {
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<string>();
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;
@@ -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<HTMLDivElement>) => {
e.preventDefault();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', () => {
document.removeEventListener('mousemove', handleMouseMove);
});
};
return { handleMouseDown };
};
export default useDragBar;
@@ -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]);
};
@@ -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;
+1 -1
View File
@@ -32,7 +32,7 @@ const ReaderPage = () => {
settings.globalReadSettings && (
<div className='min-h-screen select-none'>
<Suspense>
<ReaderContent />
<ReaderContent settings={settings} />
</Suspense>
</div>
)
+4 -4
View File
@@ -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 (
+55 -7
View File
@@ -19,9 +19,25 @@ export interface BookState {
interface ReaderStore {
library: Book[];
books: Record<string, BookState>;
settings: SystemSettings;
books: Record<string, BookState>;
foliateViews: Record<string, FoliateView>;
bookDocCache: Record<string, BookDoc>;
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<ReaderStore>((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<ReaderStore>((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<ReaderStore>((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<ReaderStore>((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<ReaderStore>((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: {
+15
View File
@@ -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;