Display multiple books in grid split

This commit is contained in:
chrox
2024-10-23 22:39:00 +02:00
parent 8ab712bc0a
commit 4263ab7fc4
9 changed files with 229 additions and 117 deletions
+9 -1
View File
@@ -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);
}
+1 -1
View File
@@ -25,7 +25,7 @@ const LibraryPage = () => {
setLibrary(await appService.loadLibraryBooks());
setLoading(false);
});
}, [envConfig, libraryBooks, setLibrary, getAppService]);
}, []);
const importBooks = async (files: [string | File]) => {
setLoading(true);
@@ -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<FoliateViewerProps> = ({ bookId, bookConfig, bookDoc }) => {
const FoliateViewer: React.FC<{
bookKey: string;
bookDoc: BookDoc;
bookConfig: BookConfig;
}> = ({ bookKey, bookDoc, bookConfig }) => {
const viewRef = useRef<HTMLDivElement>(null);
const [view, setView] = useState<FoliateView | null>(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<FoliateViewerProps> = ({ 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<FoliateViewerProps> = ({ bookId, bookConfig, bookD
isViewCreated.current = true;
}, [bookDoc]);
useFoliateEvents(view, bookId);
const handleTap = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const { clientX } = event;
const width = window.innerWidth;
@@ -110,7 +107,7 @@ const FoliateViewer: React.FC<FoliateViewerProps> = ({ bookId, bookConfig, bookD
}
};
return <div ref={viewRef} onClick={handleTap} />;
return <div className='foliate-viewer h-[100%] w-[100%]' ref={viewRef} onClick={handleTap} />;
};
export default FoliateViewer;
@@ -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<BookDoc>();
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<string>();
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 (
<div className={'flex-1 overflow-hidden'}>
{bookState.loading && <Spinner loading={bookState.loading} />}
{bookState.error && (
<div className={'flex-1'}>
<Spinner loading={true} />
{bookState?.error && (
<div className='text-center'>
<h2 className='text-red-500'>{bookState.error}</h2>
</div>
@@ -89,12 +108,12 @@ const ReaderContent = () => {
);
}
const gridWidth = isSideBarPinned && isSideBarVisible ? `calc(100% - ${sideBarWidth})` : '100%';
return (
<div className='flex h-screen'>
<SideBar
book={bookState.book}
tocData={bookDoc.toc}
currentHref={bookState.config.href ?? null}
bookKey={sideBarBookKey}
width={sideBarWidth}
isVisible={isSideBarVisible}
isPinned={isSideBarPinned}
@@ -104,25 +123,53 @@ const ReaderContent = () => {
onSetVisibility={(visibility: boolean) => setSideBarVisibility(visibility)}
/>
<div className={'flex-1 overflow-hidden'}>
<div
className={`topbar absolute top-0 z-10 h-10 border-b ${
isTopBarVisible ? 'opacity-100' : 'opacity-0'
} flex items-center px-4`}
style={{ width: topBarWidth }}
onMouseEnter={() => setTopBarVisibility(true)}
onMouseLeave={() => setTopBarVisibility(false)}
>
<div className='absolute left-4 flex h-full items-center p-2'>
<button onClick={() => setSideBarVisibility(!isSideBarVisible)}>
<BsLayoutSidebar size={16} />
</button>
</div>
<div className='absolute left-1/2 -translate-x-1/2 transform'>
<h2 className='text-center text-sm font-semibold'>{bookState.book.title}</h2>
</div>
</div>
<FoliateViewer bookId={id} bookDoc={bookDoc} bookConfig={bookState.config} />
<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;
return (
<div key={key} className='relative h-full w-full overflow-hidden'>
<div
className={`absolute top-0 z-10 flex h-10 w-full items-center px-4 ${
ids.length > 1 ? 'header-bar-anim' : 'header-bar'
} shadow-xs opacity-0 transition-opacity duration-300 hover:opacity-100`}
>
<div className='absolute inset-0 flex items-center justify-center'>
<h2 className='line-clamp-1 max-w-[90%] px-2 text-center text-xs font-semibold'>
{book?.title}
</h2>
</div>
<div className='absolute left-4 flex h-full items-center p-2'>
<button
onClick={() => {
if (!isSideBarVisible) {
setSideBarVisibility(true);
} else if (sideBarBookKey === key) {
setSideBarVisibility(false);
}
setSideBarBookKey(key);
}}
>
{sideBarBookKey == key && isSideBarVisible ? (
<VscLayoutSidebarLeft size={16} />
) : (
<VscLayoutSidebarLeftOff size={16} />
)}
</button>
</div>
</div>
<FoliateViewer bookKey={key} bookDoc={bookDoc!} bookConfig={config!} />
</div>
);
})}
</div>
</div>
);
@@ -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<BookState | null>(null);
const [currentHref, setCurrentHref] = useState<string | null>(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 && (
<div
@@ -103,8 +114,8 @@ const SideBar: React.FC<{
<BookCard cover={book.coverImageUrl!} title={book.title} author={book.author} />
</div>
<div className='sidebar-content overflow-y-auto'>
{activeTab === 'toc' && (
<TOCView toc={tocData} bookId={book.hash} currentHref={currentHref} />
{activeTab === 'toc' && bookDoc!.toc && (
<TOCView toc={bookDoc!.toc} bookKey={bookKey} currentHref={currentHref} />
)}
{activeTab === 'bookmarks' && <div>Bookmarks</div>}
{activeTab === 'annotations' && <div>Annotations</div>}
@@ -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 (
<svg
@@ -34,15 +37,15 @@ const createExpanderIcon = (isExpanded: boolean) => {
};
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<{
<ol role='group'>
{item.subitems.map((subitem) => (
<TOCItemView
bookId={bookId}
bookKey={bookKey}
key={subitem.label}
item={subitem}
depth={depth + 1}
@@ -114,13 +117,13 @@ const TOCItemView: React.FC<{
};
const TOCView: React.FC<{
bookId: string;
bookKey: string;
toc: TOCItem[];
currentHref: string | null;
}> = ({ bookId, toc, currentHref: href }) => {
}> = ({ bookKey, toc, currentHref: href }) => {
const [currentHref, setCurrentHref] = useState<string | null>(href);
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const { foliateView } = useReaderStore();
const { getFoliateView } = useReaderStore();
const tocRef = useRef<HTMLUListElement | null>(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) => (
<TOCItemView
bookId={bookId}
bookKey={bookKey}
key={item.label}
item={item}
depth={0}
@@ -8,8 +8,8 @@ type FoliateEventHandler = {
};
export const useFoliateEvents = (
bookKey: string,
view: FoliateView | null,
bookId: string,
handlers?: FoliateEventHandler,
) => {
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;
+6 -2
View File
@@ -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 && (
+55 -19
View File
@@ -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<string, BookState>;
settings: SystemSettings;
foliateView: FoliateView | null;
foliateViews: Record<string, FoliateView>;
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<Book | null>;
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<ReaderStore>((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<ReaderStore>((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<ReaderStore>((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<ReaderStore>((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,