Various enhancements (#58)
* Add H and L keyboard shortcuts for back and forward navigation, consistent with Vimium * Fallback bookmark text to page number if current page has no text, closes #51 * Add delete and edit buttons for highlights and booknotes in the sidebar, closes #57 * Each book now has its own sidebar default tab
This commit is contained in:
@@ -10,6 +10,7 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { BookNote } from '@/types/book';
|
||||
import { uniqueId } from '@/utils/misc';
|
||||
import Button from '@/components/Button';
|
||||
import { getCurrentPage } from '@/utils/book';
|
||||
|
||||
interface BookmarkTogglerProps {
|
||||
bookKey: string;
|
||||
@@ -19,10 +20,11 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
|
||||
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
|
||||
const { getProgress, setBookmarkRibbonVisibility } = useReaderStore();
|
||||
const config = getConfig(bookKey)!;
|
||||
const progress = getProgress(bookKey)!;
|
||||
const bookData = getBookData(bookKey)!;
|
||||
|
||||
const [isBookmarked, setIsBookmarked] = useState(false);
|
||||
|
||||
@@ -38,7 +40,7 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
||||
id: uniqueId(),
|
||||
type: 'bookmark',
|
||||
cfi,
|
||||
text: truncatedText,
|
||||
text: truncatedText ? truncatedText : `${getCurrentPage(bookData.book!, progress)}`,
|
||||
note: '',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
@@ -49,6 +51,7 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
|
||||
if (existingBookmark) {
|
||||
existingBookmark.deletedAt = null;
|
||||
existingBookmark.updatedAt = Date.now();
|
||||
existingBookmark.text = bookmark.text;
|
||||
} else {
|
||||
bookmarks.push(bookmark);
|
||||
}
|
||||
|
||||
@@ -191,12 +191,7 @@ const Notebook: React.FC = ({}) => {
|
||||
)}
|
||||
<ul className=''>
|
||||
{annotationNotes.map((item, index) => (
|
||||
<BooknoteItem
|
||||
key={`${index}-${item.cfi}`}
|
||||
bookKey={sideBarBookKey}
|
||||
item={item}
|
||||
editable={true}
|
||||
/>
|
||||
<BooknoteItem key={`${index}-${item.cfi}`} bookKey={sideBarBookKey} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -13,15 +13,14 @@ import useScrollToItem from '../../hooks/useScrollToItem';
|
||||
interface BooknoteItemProps {
|
||||
bookKey: string;
|
||||
item: BookNote;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, editable = false }) => {
|
||||
const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
|
||||
const { getProgress, getView } = useReaderStore();
|
||||
const { getProgress, getView, getViewsById } = useReaderStore();
|
||||
const { setNotebookEditAnnotation, setNotebookVisible } = useNotebookStore();
|
||||
|
||||
const { text, cfi, note } = item;
|
||||
@@ -44,6 +43,8 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, editable = f
|
||||
booknotes.forEach((item) => {
|
||||
if (item.id === note.id) {
|
||||
item.deletedAt = Date.now();
|
||||
const views = getViewsById(bookKey.split('-')[0]!);
|
||||
views.forEach((view) => view?.addAnnotation(item, true));
|
||||
}
|
||||
});
|
||||
const updatedConfig = updateBooknotes(bookKey, booknotes);
|
||||
@@ -53,6 +54,7 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, editable = f
|
||||
};
|
||||
|
||||
const editNote = (note: BookNote) => {
|
||||
setNotebookVisible(true);
|
||||
setNotebookEditAnnotation(note);
|
||||
};
|
||||
|
||||
@@ -60,15 +62,15 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, editable = f
|
||||
<li
|
||||
ref={viewRef}
|
||||
className={clsx(
|
||||
'border-base-300 my-2 cursor-pointer rounded-lg p-2 text-sm',
|
||||
editable && 'collapse-arrow collapse',
|
||||
isCurrent ? 'bg-base-300/85 hover:bg-base-300' : 'hover:bg-base-200 bg-base-100',
|
||||
'border-base-300 group relative my-2 cursor-pointer rounded-lg p-2 text-sm',
|
||||
isCurrent ? 'bg-base-300/85 hover:bg-base-300' : 'hover:bg-base-300/55 bg-base-100',
|
||||
'transition-all duration-300 ease-in-out',
|
||||
)}
|
||||
tabIndex={0}
|
||||
onClick={handleClickItem}
|
||||
>
|
||||
<div
|
||||
className={clsx('collapse-title min-h-4 p-0', editable && 'pr-4')}
|
||||
className={clsx('min-h-4 p-0 transition-all duration-300 ease-in-out')}
|
||||
style={
|
||||
{
|
||||
'--top-override': '0.7rem',
|
||||
@@ -98,40 +100,42 @@ const BooknoteItem: React.FC<BooknoteItemProps> = ({ bookKey, item, editable = f
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{editable && (
|
||||
<div
|
||||
className={clsx('collapse-content invisible !p-0 text-xs')}
|
||||
style={
|
||||
{
|
||||
'--bottom-override': 0,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='flex justify-end space-x-3'>
|
||||
{item.note && (
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost settings-content hover:bg-transparent',
|
||||
'flex h-4 min-h-4 items-end p-0',
|
||||
)}
|
||||
onClick={editNote.bind(null, item)}
|
||||
>
|
||||
<div className='align-bottom text-blue-400'>{_('Edit')}</div>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'max-h-0 overflow-hidden p-0 text-xs',
|
||||
'transition-[max-height] duration-300 ease-in-out',
|
||||
'group-hover:max-h-8 group-hover:overflow-visible',
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--bottom-override': 0,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='flex justify-end space-x-3 p-2'>
|
||||
{item.note && (
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost settings-content hover:bg-transparent',
|
||||
'flex h-4 min-h-4 items-end p-0',
|
||||
)}
|
||||
onClick={deleteNote.bind(null, item)}
|
||||
onClick={editNote.bind(null, item)}
|
||||
>
|
||||
<div className='align-bottom text-red-400'>{_('Delete')}</div>
|
||||
<div className='align-bottom text-blue-400'>{_('Edit')}</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-ghost settings-content hover:bg-transparent',
|
||||
'flex h-4 min-h-4 items-end p-0',
|
||||
)}
|
||||
onClick={deleteNote.bind(null, item)}
|
||||
>
|
||||
<div className='align-bottom text-red-400'>{_('Delete')}</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ const BooknoteView: React.FC<{
|
||||
|
||||
const booknoteGroups: { [href: string]: BooknoteGroup } = {};
|
||||
for (const booknote of booknotes) {
|
||||
const tocItem = findTocItemBS(toc, booknote.cfi);
|
||||
const tocItem = findTocItemBS(toc ?? [], booknote.cfi);
|
||||
const href = tocItem?.href || '';
|
||||
const label = tocItem?.label || '';
|
||||
const id = tocItem?.id || 0;
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import TOCView from './TOCView';
|
||||
import BooknoteView from './BooknoteView';
|
||||
import TabNavigation from './TabNavigation';
|
||||
|
||||
const SidebarContent: React.FC<{
|
||||
activeTab: string;
|
||||
bookDoc: BookDoc;
|
||||
sideBarBookKey: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
}> = ({ activeTab, bookDoc, sideBarBookKey, onTabChange }) => {
|
||||
}> = ({ bookDoc, sideBarBookKey }) => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { getConfig, setConfig } = useBookDataStore();
|
||||
const config = getConfig(sideBarBookKey);
|
||||
const [activeTab, setActiveTab] = useState(config?.viewSettings?.sideBarTab || 'toc');
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
@@ -39,6 +41,20 @@ const SidebarContent: React.FC<{
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sideBarBookKey) return;
|
||||
const config = getConfig(sideBarBookKey!)!;
|
||||
setActiveTab(config.viewSettings!.sideBarTab!);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sideBarBookKey]);
|
||||
|
||||
const handleTabChange = (tab: string) => {
|
||||
setActiveTab(tab);
|
||||
const config = getConfig(sideBarBookKey!)!;
|
||||
config.viewSettings!.sideBarTab = tab;
|
||||
setConfig(sideBarBookKey!, config);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -52,15 +68,15 @@ const SidebarContent: React.FC<{
|
||||
<TOCView toc={bookDoc.toc} bookKey={sideBarBookKey} />
|
||||
)}
|
||||
{activeTab === 'annotations' && (
|
||||
<BooknoteView type='annotation' toc={bookDoc.toc} bookKey={sideBarBookKey} />
|
||||
<BooknoteView type='annotation' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
|
||||
)}
|
||||
{activeTab === 'bookmarks' && (
|
||||
<BooknoteView type='bookmark' toc={bookDoc.toc} bookKey={sideBarBookKey} />
|
||||
<BooknoteView type='bookmark' toc={bookDoc.toc ?? []} bookKey={sideBarBookKey} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
<TabNavigation activeTab={activeTab} onTabChange={onTabChange} />
|
||||
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
@@ -23,15 +22,13 @@ const MAX_SIDEBAR_WIDTH = 0.45;
|
||||
const SideBar: React.FC<{
|
||||
onGoToLibrary: () => void;
|
||||
}> = ({ onGoToLibrary }) => {
|
||||
const { envConfig } = useEnv();
|
||||
const { settings, saveSettings } = useSettingsStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { getView } = useReaderStore();
|
||||
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<BookSearchResult[] | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTab, setActiveTab] = useState(settings.globalReadSettings.sideBarTab);
|
||||
const {
|
||||
sideBarWidth,
|
||||
isSideBarPinned,
|
||||
@@ -70,12 +67,6 @@ const SideBar: React.FC<{
|
||||
setSideBarVisible(false);
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: string) => {
|
||||
setActiveTab(tab);
|
||||
settings.globalReadSettings.sideBarTab = tab;
|
||||
saveSettings(envConfig, settings);
|
||||
};
|
||||
|
||||
const handleToggleSearchBar = () => {
|
||||
setIsSearchBarVisible((prev) => !prev);
|
||||
if (isSearchBarVisible) {
|
||||
@@ -144,12 +135,7 @@ const SideBar: React.FC<{
|
||||
onSelectResult={handleSearchResultClick}
|
||||
/>
|
||||
) : (
|
||||
<SidebarContent
|
||||
activeTab={activeTab}
|
||||
bookDoc={bookDoc}
|
||||
sideBarBookKey={sideBarBookKey!}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
<SidebarContent bookDoc={bookDoc} sideBarBookKey={sideBarBookKey!} />
|
||||
)}
|
||||
<div
|
||||
className='drag-bar absolute right-0 top-0 h-full w-0.5 cursor-col-resize'
|
||||
|
||||
@@ -51,6 +51,14 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
|
||||
getView(sideBarBookKey)?.next(distance);
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
getView(sideBarBookKey)?.history.back();
|
||||
};
|
||||
|
||||
const goForward = () => {
|
||||
getView(sideBarBookKey)?.history.forward();
|
||||
};
|
||||
|
||||
const reloadPage = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
@@ -67,6 +75,8 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
|
||||
onGoRight: goRight,
|
||||
onGoPrev: goPrev,
|
||||
onGoNext: goNext,
|
||||
onGoBack: goBack,
|
||||
onGoForward: goForward,
|
||||
},
|
||||
[sideBarBookKey, bookKeys],
|
||||
);
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface ShortcutConfig {
|
||||
onGoRight: string[];
|
||||
onGoNext: string[];
|
||||
onGoPrev: string[];
|
||||
onGoBack: string[];
|
||||
onGoForward: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_SHORTCUTS: ShortcutConfig = {
|
||||
@@ -24,6 +26,8 @@ const DEFAULT_SHORTCUTS: ShortcutConfig = {
|
||||
onGoRight: ['ArrowRight', 'PageDown', 'l'],
|
||||
onGoNext: ['ArrowDown', 'j'],
|
||||
onGoPrev: ['ArrowUp', 'k'],
|
||||
onGoBack: ['shift+ArrowLeft', 'shift+h'],
|
||||
onGoForward: ['shift+ArrowRight', 'shift+l'],
|
||||
};
|
||||
|
||||
// Load shortcuts from localStorage or fallback to defaults
|
||||
|
||||
@@ -57,8 +57,8 @@ export interface BookDoc {
|
||||
editor?: string;
|
||||
publisher?: string;
|
||||
};
|
||||
toc: Array<TOCItem>;
|
||||
sections: Array<SectionItem>;
|
||||
toc?: Array<TOCItem>;
|
||||
sections?: Array<SectionItem>;
|
||||
splitTOCHref(href: string): Array<string | number>;
|
||||
getCover(): Promise<Blob | null>;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DEFAULT_BOOK_LAYOUT,
|
||||
DEFAULT_BOOK_STYLE,
|
||||
DEFAULT_BOOK_FONT,
|
||||
DEFAULT_VIEW_CONFIG,
|
||||
DEFAULT_READSETTINGS,
|
||||
SYSTEM_SETTINGS_VERSION,
|
||||
DEFAULT_BOOK_SEARCH_CONFIG,
|
||||
@@ -65,6 +66,7 @@ export abstract class BaseAppService implements AppService {
|
||||
...DEFAULT_BOOK_LAYOUT,
|
||||
...DEFAULT_BOOK_STYLE,
|
||||
...DEFAULT_BOOK_FONT,
|
||||
...DEFAULT_VIEW_CONFIG,
|
||||
...settings.globalViewSettings,
|
||||
};
|
||||
} catch {
|
||||
@@ -80,6 +82,7 @@ export abstract class BaseAppService implements AppService {
|
||||
...DEFAULT_BOOK_LAYOUT,
|
||||
...DEFAULT_BOOK_STYLE,
|
||||
...DEFAULT_BOOK_FONT,
|
||||
...DEFAULT_VIEW_CONFIG,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -220,7 +223,7 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
|
||||
async fetchBookDetails(book: Book, settings: SystemSettings) {
|
||||
const { file } = await this.loadBookContent(book, settings) as BookContent;
|
||||
const { file } = (await this.loadBookContent(book, settings)) as BookContent;
|
||||
const bookDoc = (await new DocumentLoader(file).open()).book as BookDoc;
|
||||
return bookDoc.metadata;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookFont, BookLayout, BookSearchConfig, BookStyle } from '@/types/book';
|
||||
import { BookFont, BookLayout, BookSearchConfig, BookStyle, ViewConfig } from '@/types/book';
|
||||
import { ReadSettings } from '@/types/settings';
|
||||
|
||||
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
|
||||
@@ -10,7 +10,6 @@ export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).j
|
||||
export const DEFAULT_READSETTINGS: ReadSettings = {
|
||||
sideBarWidth: '25%',
|
||||
isSideBarPinned: true,
|
||||
sideBarTab: 'toc',
|
||||
notebookWidth: '25%',
|
||||
isNotebookPinned: false,
|
||||
autohideCursor: true,
|
||||
@@ -57,6 +56,10 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
|
||||
userStylesheet: '',
|
||||
};
|
||||
|
||||
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
|
||||
sideBarTab: 'toc',
|
||||
};
|
||||
|
||||
export const DEFAULT_BOOK_SEARCH_CONFIG: BookSearchConfig = {
|
||||
scope: 'book',
|
||||
matchCase: false,
|
||||
|
||||
@@ -114,7 +114,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
console.log('Loading book', key);
|
||||
const { book: loadedBookDoc } = await new DocumentLoader(file).open();
|
||||
const bookDoc = loadedBookDoc as BookDoc;
|
||||
if (bookDoc.toc?.length > 0 && bookDoc.sections?.length > 0) {
|
||||
if (bookDoc.toc?.length && bookDoc.sections?.length) {
|
||||
updateTocID(bookDoc.toc);
|
||||
const sections = bookDoc.sections.reduce((map: Record<string, SectionItem>, section) => {
|
||||
map[section.id] = section;
|
||||
|
||||
@@ -75,7 +75,11 @@ export interface BookFont {
|
||||
fontWeight: number;
|
||||
}
|
||||
|
||||
export interface ViewSettings extends BookLayout, BookStyle, BookFont {}
|
||||
export interface ViewConfig {
|
||||
sideBarTab: string;
|
||||
}
|
||||
|
||||
export interface ViewSettings extends BookLayout, BookStyle, BookFont, ViewConfig {}
|
||||
|
||||
export interface BookProgress {
|
||||
location: string;
|
||||
|
||||
@@ -5,7 +5,6 @@ export type ThemeType = 'light' | 'dark' | 'auto';
|
||||
export interface ReadSettings {
|
||||
sideBarWidth: string;
|
||||
isSideBarPinned: boolean;
|
||||
sideBarTab: string;
|
||||
notebookWidth: string;
|
||||
isNotebookPinned: boolean;
|
||||
autohideCursor: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EXTS } from '@/libs/document';
|
||||
import { Book, BookConfig } from '@/types/book';
|
||||
import { Book, BookConfig, BookProgress } from '@/types/book';
|
||||
import { getUserLang, makeSafeFilename } from './misc';
|
||||
|
||||
export const getDir = (book: Book) => {
|
||||
@@ -98,3 +98,15 @@ export const formatSubject = (subject: string | string[] | undefined) => {
|
||||
if (!subject) return '';
|
||||
return Array.isArray(subject) ? subject.join(', ') : subject;
|
||||
};
|
||||
|
||||
export const getCurrentPage = (book: Book, progress: BookProgress) => {
|
||||
const bookFormat = book.format;
|
||||
const { section, pageinfo } = progress;
|
||||
return bookFormat === 'PDF'
|
||||
? section
|
||||
? section.current + 1
|
||||
: 0
|
||||
: pageinfo
|
||||
? pageinfo.current + 1
|
||||
: 0;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user