Add Tables of Content(TOC) in SideBar

This commit is contained in:
chrox
2024-10-22 22:23:29 +02:00
parent 51f783be21
commit 8ab712bc0a
14 changed files with 293 additions and 82 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Readest",
"version": "0.1.0",
"version": "0.2.0",
"identifier": "com.bilingify.digest",
"build": {
"frontendDist": "../out",
-9
View File
@@ -32,12 +32,3 @@ foliate-view {
height: 100vh;
border: none;
}
/* Hide scrollbar for Chrome, Safari and Opera */
::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE and Edge */
*{
-ms-overflow-style: none;
}
@@ -10,10 +10,14 @@ interface BookCardProps {
const BookCard: React.FC<BookCardProps> = ({ cover, title, author }) => {
return (
<div className='flex w-full items-center'>
<img src={cover} alt='Book cover' className='mr-4 w-[15%] rounded-sm object-cover' />
<div className='flex h-20 w-full items-center'>
<img
src={cover}
alt='Book cover'
className='mr-4 aspect-auto max-h-20 w-[15%] max-w-14 rounded-sm object-cover shadow-md'
/>
<div className='min-w-0 flex-1'>
<h4 className='w-[90%] truncate text-base font-semibold'>{title}</h4>
<h4 className='line-clamp-2 w-[90%] text-sm font-semibold'>{title}</h4>
<p className='truncate text-sm text-gray-600'>{formatAuthors(author)}</p>
</div>
<button
@@ -4,6 +4,7 @@ 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;
@@ -53,6 +54,7 @@ const getCSS = (spacing: number, justify: boolean, hyphenate: boolean) => `
export interface FoliateView extends HTMLElement {
open: (book: BookDoc) => Promise<void>;
init: (options: { lastLocation: string }) => void;
goTo: (href: string) => void;
goToFraction: (fraction: number) => void;
renderer: {
setStyles: (css: string) => void;
@@ -65,15 +67,18 @@ const FoliateViewer: React.FC<FoliateViewerProps> = ({ bookId, bookConfig, bookD
const viewRef = useRef<HTMLDivElement>(null);
const [view, setView] = useState<FoliateView | null>(null);
const isViewCreated = useRef(false);
const { setFoliateView } = useReaderStore();
useEffect(() => {
if (isViewCreated.current) return;
const openBook = async () => {
console.log('opening book');
await import('foliate-js/view.js');
const view = document.createElement('foliate-view') as FoliateView;
document.body.append(view);
viewRef.current?.appendChild(view);
setView(view);
setFoliateView(view);
await view.open(bookDoc);
if ('setStyles' in view.renderer) {
view.renderer.setStyles(getCSS(2.4, true, true));
@@ -13,55 +13,35 @@ import Spinner from '@/components/Spinner';
import FoliateViewer from './FoliateViewer';
import SideBar from './SideBar';
interface ReaderContentProps {}
const ReaderContent: React.FC<ReaderContentProps> = ({}) => {
const ReaderContent = () => {
const router = useRouter();
const searchParams = useSearchParams();
const id = searchParams.get('id');
const [bookDoc, setBookDoc] = React.useState<BookDoc>();
const { envConfig } = useEnv();
const { library, books, settings, fetchBook, setLibrary } = useReaderStore();
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 [sideBarWidth, setSideBarWidth] = useState(
settings.globalReadSettings.sideBarWidth ?? '20%',
settings.globalReadSettings.sideBarWidth ?? '25%',
);
const [isSideBarPinned, setIsSideBarPinned] = useState(
settings.globalReadSettings.isSideBarPinned ?? true,
);
const [isSideBarVisible, setSideBarVisibility] = useState(isSideBarPinned);
const [isClosingBook, setClosingBook] = useState(false);
const [isTopBarVisible, setTopBarVisibility] = useState(false);
React.useEffect(() => {
if (!id) {
return;
}
const { file, book, config } = bookState;
if (isClosingBook) {
if (book && config) {
book.lastUpdated = Date.now();
const bookIndex = library.findIndex((b) => b.hash === book.hash);
if (bookIndex !== -1) {
library[bookIndex] = book;
}
setLibrary(library);
envConfig.getAppService().then((appService) => {
config.lastUpdated = Date.now();
appService.saveBookConfig(book, config);
appService.saveLibraryBooks(library);
appService.saveSettings(settings);
});
}
return;
}
const { file } = bookState;
if (id && !file) {
fetchBook(envConfig, id);
}
if (!bookDoc && bookState.file) {
if (!bookDoc && file) {
const loadDocument = async () => {
if (file) {
const { book } = await new DocumentLoader(file).open();
@@ -70,7 +50,7 @@ const ReaderContent: React.FC<ReaderContentProps> = ({}) => {
};
loadDocument();
}
}, [isClosingBook, bookState.file, envConfig, fetchBook, id]);
}, [bookState.file, envConfig, fetchBook, id]);
const handleResize = (newWidth: string) => {
setSideBarWidth(newWidth);
@@ -86,20 +66,35 @@ const ReaderContent: React.FC<ReaderContentProps> = ({}) => {
};
const handleCloseBook = () => {
setClosingBook(true);
const { book, config } = bookState;
if (book && config) {
saveConfig(envConfig, book, config);
saveSettings(envConfig, settings);
}
router.back();
};
const topBarWidth = isSideBarPinned && isSideBarVisible ? `calc(100% - ${sideBarWidth})` : '100%';
if (!id || !bookDoc || !bookState.config || !bookState.book) {
return null;
return (
<div className={'flex-1 overflow-hidden'}>
{bookState.loading && <Spinner loading={bookState.loading} />}
{bookState.error && (
<div className='text-center'>
<h2 className='text-red-500'>{bookState.error}</h2>
</div>
)}
</div>
);
}
return (
<div className='flex h-screen overflow-hidden'>
<div className='flex h-screen'>
<SideBar
book={bookState.book}
tocData={bookDoc.toc}
currentHref={bookState.config.href ?? null}
width={sideBarWidth}
isVisible={isSideBarVisible}
isPinned={isSideBarPinned}
@@ -109,13 +104,7 @@ const ReaderContent: React.FC<ReaderContentProps> = ({}) => {
onSetVisibility={(visibility: boolean) => setSideBarVisibility(visibility)}
/>
<div className={`flex-1`}>
{bookState.loading && <Spinner loading={bookState.loading} />}
{bookState.error && (
<div className='text-center'>
<h2 className='text-red-500'>{bookState.error}</h2>
</div>
)}
<div className={'flex-1 overflow-hidden'}>
<div
className={`topbar absolute top-0 z-10 h-10 border-b ${
isTopBarVisible ? 'opacity-100' : 'opacity-0'
@@ -1,5 +1,5 @@
import { Book } from '@/types/book';
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import {
MdSearch,
MdOutlinePushPin,
@@ -10,10 +10,17 @@ import {
} from 'react-icons/md';
import { GiBookshelf } from 'react-icons/gi';
import { TOCItem } from '@/libs/document';
import BookCard from './BookCard';
import TOCView from './TOCView';
interface SideBarProps {
const MIN_SIDEBAR_WIDTH = 0.15;
const MAX_SIDEBAR_WIDTH = 0.45;
const SideBar: React.FC<{
book: Book;
tocData: TOCItem[];
currentHref: string | null;
width: string;
isVisible: boolean;
isPinned: boolean;
@@ -21,13 +28,10 @@ interface SideBarProps {
onTogglePin: () => void;
onResize: (newWidth: string) => void;
onGoToLibrary: () => void;
}
const MIN_SIDEBAR_WIDTH = '10em';
const MAX_SIDEBAR_WIDTH = '40em';
const SideBar: React.FC<SideBarProps> = ({
}> = ({
book,
tocData,
currentHref,
width,
isPinned,
isVisible,
@@ -37,7 +41,6 @@ const SideBar: React.FC<SideBarProps> = ({
onGoToLibrary,
}) => {
const [activeTab, setActiveTab] = useState('toc');
const sidebarRef = useRef<HTMLDivElement>(null);
useEffect(() => {}, [isPinned, isVisible]);
@@ -54,8 +57,8 @@ const SideBar: React.FC<SideBarProps> = ({
const handleMouseMove = (e: MouseEvent) => {
const newWidthPx = e.clientX;
const width = `${Math.round((newWidthPx / window.innerWidth) * 10000) / 100}%`;
const minWidthPx = parseFloat(MIN_SIDEBAR_WIDTH) * 16;
const maxWidthPx = parseFloat(MAX_SIDEBAR_WIDTH) * 16;
const minWidthPx = MIN_SIDEBAR_WIDTH * window.innerWidth;
const maxWidthPx = MAX_SIDEBAR_WIDTH * window.innerWidth;
if (newWidthPx >= minWidthPx && newWidthPx <= maxWidthPx) {
onResize(width);
}
@@ -72,13 +75,12 @@ const SideBar: React.FC<SideBarProps> = ({
className='sidebar-container z-20 h-full bg-gray-200'
style={{
width: `${width}`,
height: '100%',
minWidth: MIN_SIDEBAR_WIDTH,
maxWidth: MAX_SIDEBAR_WIDTH,
minWidth: `${MIN_SIDEBAR_WIDTH * 100}%`,
maxWidth: `${MAX_SIDEBAR_WIDTH * 100}%`,
position: isPinned ? 'relative' : 'absolute',
}}
>
<div ref={sidebarRef} className={'sidebar h-full'}>
<div className={'sidebar h-full'}>
<div className='flex h-10 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}>
@@ -97,10 +99,17 @@ const SideBar: React.FC<SideBarProps> = ({
</button>
</div>
</div>
<div className='border-b p-3 shadow-sm'>
<div className='border-b px-3 shadow-sm'>
<BookCard cover={book.coverImageUrl!} title={book.title} author={book.author} />
</div>
<div className='absolute bottom-0 flex w-full'>
<div className='sidebar-content overflow-y-auto'>
{activeTab === 'toc' && (
<TOCView toc={tocData} bookId={book.hash} currentHref={currentHref} />
)}
{activeTab === 'bookmarks' && <div>Bookmarks</div>}
{activeTab === 'annotations' && <div>Annotations</div>}
</div>
<div className='bottom-tab absolute bottom-0 flex w-full bg-gray-200'>
<button
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === 'toc' ? 'bg-gray-300' : ''}`}
onClick={() => setActiveTab('toc')}
@@ -120,11 +129,6 @@ const SideBar: React.FC<SideBarProps> = ({
<MdBookmarkBorder size={20} className='mx-auto' />
</button>
</div>
<div className='p-4'>
{activeTab === 'toc' && <div>Table of Contents</div>}
{activeTab === 'bookmarks' && <div>Bookmarks</div>}
{activeTab === 'annotations' && <div>Annotations</div>}
</div>
<div
className='drag-bar bg-base-300 absolute right-0 top-0 h-full w-0.5 cursor-col-resize'
onMouseDown={handleMouseDown}
@@ -0,0 +1,178 @@
import React, { useEffect, useRef, useState } from 'react';
import { TOCItem } from '@/libs/document';
import { useReaderStore } from '@/store/readerStore';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
const findParentPath = (toc: TOCItem[], href: string): TOCItem[] => {
for (const item of toc) {
if (item.href === href) {
return [item];
}
if (item.subitems) {
const path = findParentPath(item.subitems, href);
if (path.length) {
return [item, ...path];
}
}
}
return [];
};
const createExpanderIcon = (isExpanded: boolean) => {
return (
<svg
viewBox='0 0 8 10'
width='8'
height='10'
className={`transform transition-transform ${isExpanded ? 'rotate-90' : 'rotate-0'}`}
style={{ transformOrigin: 'center' }}
>
<polygon points='0 0, 8 5, 0 10' />
</svg>
);
};
const TOCItemView: React.FC<{
bookId: string;
item: TOCItem;
depth: number;
setCurrentHref: (href: string) => void;
currentHref: string | null;
expandedItems: string[];
}> = ({ bookId, item, depth, setCurrentHref, currentHref, expandedItems }) => {
const [isExpanded, setIsExpanded] = useState(expandedItems.includes(item.href || ''));
const { foliateView } = useReaderStore();
const handleToggleExpand = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setIsExpanded((prev) => !prev);
};
const handleClickItem = (event: React.MouseEvent) => {
event.preventDefault();
if (item.href) {
foliateView?.goTo(item.href);
setCurrentHref(item.href);
}
};
const isActive = currentHref === item.href;
useEffect(() => {
setIsExpanded(expandedItems.includes(item.href || ''));
}, [expandedItems, item.href]);
return (
<li className='w-full' style={{ paddingTop: '1px' }}>
<span
role='treeitem'
tabIndex={-1}
onClick={item.href ? handleClickItem : undefined}
style={{ paddingInlineStart: `${(depth + 1) * 12}px` }}
aria-expanded={isExpanded ? 'true' : 'false'}
aria-selected={isActive ? 'true' : 'false'}
data-href={item.href}
className={`flex w-full cursor-pointer items-center rounded-md py-2 ${
isActive ? 'bg-gray-300 hover:bg-gray-400' : 'hover:bg-gray-300'
}`}
>
{item.subitems && (
<span onClick={handleToggleExpand} className='inline-block cursor-pointer'>
{createExpanderIcon(isExpanded)}
</span>
)}
<span
className='ml-2 truncate text-ellipsis font-sans text-sm font-light'
style={{
maxWidth: 'calc(100% - 24px)',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{item.label}
</span>
</span>
{item.subitems && isExpanded && (
<ol role='group'>
{item.subitems.map((subitem) => (
<TOCItemView
bookId={bookId}
key={subitem.label}
item={subitem}
depth={depth + 1}
setCurrentHref={setCurrentHref}
currentHref={currentHref}
expandedItems={expandedItems}
/>
))}
</ol>
)}
</li>
);
};
const TOCView: React.FC<{
bookId: string;
toc: TOCItem[];
currentHref: string | null;
}> = ({ bookId, toc, currentHref: href }) => {
const [currentHref, setCurrentHref] = useState<string | null>(href);
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const { foliateView } = useReaderStore();
const tocRef = useRef<HTMLUListElement | null>(null);
const tocRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
const { tocItem } = detail;
if (tocItem?.href) {
setCurrentHref(tocItem.href);
}
};
useFoliateEvents(foliateView, bookId, { onRelocate: tocRelocateHandler });
const expandParents = (toc: TOCItem[], href: string) => {
const parentPath = findParentPath(toc, href).map((item) => item.href);
setExpandedItems(parentPath.filter(Boolean) as string[]);
};
useEffect(() => {
const currentItem = tocRef.current?.querySelector(`[data-href="${currentHref}"]`);
if (currentItem) {
const rect = currentItem.getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
if (!isVisible) {
(currentItem as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
}
(currentItem as HTMLElement).setAttribute('aria-current', 'page');
}
if (currentHref) {
expandParents(toc, currentHref);
}
}, [toc, currentHref]);
return (
<div className='relative'>
<div className='max-h-[calc(100vh-168px)] overflow-y-auto rounded border'>
<ul role='tree' ref={tocRef} className='overflow-y-auto px-2'>
{toc &&
toc.map((item) => (
<TOCItemView
bookId={bookId}
key={item.label}
item={item}
depth={0}
setCurrentHref={setCurrentHref}
currentHref={currentHref}
expandedItems={expandedItems}
/>
))}
</ul>
</div>
</div>
);
};
export default TOCView;
@@ -15,13 +15,14 @@ export const useFoliateEvents = (
const setProgress = useReaderStore((state) => state.setProgress);
const defaultLoadHandler = (event: Event) => {
console.log('load event:', event);
const detail = (event as CustomEvent).detail;
console.log('load event:', detail);
};
const defaultRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
// console.log('relocate:', detail);
setProgress(bookId, detail.fraction, detail.cfi, detail.location);
setProgress(bookId, detail.fraction, detail.cfi, detail.tocItem?.href, detail.location);
};
const onLoad = handlers?.onLoad || defaultLoadHandler;
+1 -1
View File
@@ -25,7 +25,7 @@ const ReaderPage = () => {
};
initLibrary();
}, [envConfig, setLibrary]);
}, [envConfig, setSettings, setLibrary]);
return (
settings.globalReadSettings && (
+7
View File
@@ -32,6 +32,12 @@ Map.groupBy ??= (iterable, callbackfn) => {
export type DocumentFile = File;
export interface TOCItem {
label: string;
href?: string;
subitems?: TOCItem[];
}
export interface BookDoc {
metadata: {
title: string;
@@ -39,6 +45,7 @@ export interface BookDoc {
editor?: string;
publisher?: string;
};
toc: Array<TOCItem>;
getCover(): Promise<Blob | null>;
}
+1 -1
View File
@@ -142,7 +142,7 @@ export abstract class BaseAppService implements AppService {
async loadBookContent(book: Book): Promise<BookContent> {
const fp = getFilename(book);
let file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open();
const file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open();
return { book, file, config: await this.loadBookConfig(book) };
}
+33 -4
View File
@@ -3,6 +3,7 @@ import { create } from 'zustand';
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';
interface BookState {
loading?: boolean;
@@ -10,28 +11,55 @@ interface BookState {
book?: Book | null;
file?: File | null;
config?: BookConfig | null;
fraction?: number;
}
interface ReaderStore {
library: Book[];
books: Record<string, BookState>;
settings: SystemSettings;
foliateView: FoliateView | null;
setLibrary: (books: Book[]) => void;
setSettings: (settings: SystemSettings) => void;
setProgress: (
id: string,
progress: number,
location: string,
href: string,
pageinfo: PageInfo,
) => void;
setFoliateView: (view: FoliateView | null) => void;
saveConfig: (envConfig: EnvConfigType, book: Book, config: BookConfig) => void;
saveSettings: (envConfig: EnvConfigType, settings: SystemSettings) => void;
fetchBook: (envConfig: EnvConfigType, id: string) => Promise<Book | null>;
setProgress: (id: string, progress: number, location: string, pageinfo: PageInfo) => void;
addBookmark: (id: string, bookmark: BookNote) => void;
}
export const useReaderStore = create<ReaderStore>((set) => ({
export const useReaderStore = create<ReaderStore>((set, get) => ({
library: [],
books: {},
settings: {} as SystemSettings,
foliateView: null,
setFoliateView: (view) => set({ foliateView: view }),
setLibrary: (books: Book[]) => set({ library: books }),
setSettings: (settings: SystemSettings) => set({ settings }),
saveConfig: async (envConfig: EnvConfigType, book: Book, config: BookConfig) => {
const appService = await envConfig.getAppService();
const { library } = get();
const bookIndex = library.findIndex((b) => b.hash === book.hash);
if (bookIndex !== -1) {
book.lastUpdated = Date.now();
library[bookIndex] = book;
}
set({ library });
config.lastUpdated = Date.now();
appService.saveBookConfig(book, config);
appService.saveLibraryBooks(library);
},
saveSettings: async (envConfig: EnvConfigType, settings: SystemSettings) => {
const appService = await envConfig.getAppService();
await appService.saveSettings(settings);
@@ -74,7 +102,7 @@ export const useReaderStore = create<ReaderStore>((set) => ({
}
},
setProgress: (id: string, progress: number, location: string, pageinfo: PageInfo) =>
setProgress: (id: string, progress: number, location: string, href: string, pageinfo: PageInfo) =>
set((state) => {
const book = state.books[id];
if (!book) return state;
@@ -86,6 +114,7 @@ export const useReaderStore = create<ReaderStore>((set) => ({
config: {
...book.config,
lastUpdated: Date.now(),
href,
progress,
location,
pageinfo,
+1
View File
@@ -33,6 +33,7 @@ export interface BookConfig {
lastUpdated: number;
progress?: number;
location?: string;
href?: string;
pageinfo?: PageInfo;
bookmarks?: BookNote[];
annotations?: BookNote[];
+5 -3
View File
@@ -32,6 +32,10 @@ interface LanguageMap {
[key: string]: string;
}
interface Contributor {
name: LanguageMap;
}
const formatLanguageMap = (x: string | LanguageMap): string => {
if (!x) return '';
if (typeof x === 'string') return x;
@@ -41,7 +45,7 @@ const formatLanguageMap = (x: string | LanguageMap): string => {
const listFormat = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' });
const formatContributors = (contributors: any) =>
export const formatAuthors = (contributors: string | Contributor | [string | Contributor]) =>
Array.isArray(contributors)
? listFormat.format(
contributors.map((contributor) =>
@@ -51,5 +55,3 @@ const formatContributors = (contributors: any) =>
: typeof contributors === 'string'
? contributors
: formatLanguageMap(contributors?.name);
export const formatAuthors = (authors: any) => formatContributors(authors);