f1ae050768
* docs(agent): add agent notes for cache, reading-ruler, foliate touch Add project-memory notes and index entries: - manage-cache-ios-layout: iOS container layout and what Manage Cache clears - reading-ruler-line-aware: line/column-aware reading ruler internals - foliate-touch-listener-capture-phase: capture-phase gesture suppression Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): pad sidebar and notebook for the device status bar (#4089) Top-anchored slide-in panels (sidebar, notebook) only applied status-bar top padding when isFullHeightInMobile was true. On a tablet/desktop (isMobile === false) that gate collapsed the padding to 0, so a visible system status bar overlapped the panel's top toolbar and made its icons inaccessible. Extract the inset math into getPanelTopInset() and gate it on (!isMobile || isFullHeightInMobile) so non-mobile panels clear the status bar like the reader header, while a partial-height mobile bottom sheet (which doesn't reach the top of the screen) stays flush. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reader): keep footer bar clear of the pinned sidebar On a mobile tablet in portrait, forceMobileLayout renders the footer bar with position: fixed, anchored to the viewport, so left-0 w-full spans the whole window and slides under a pinned sidebar — the progress / font / TTS controls end up obscured. Anchor the footer inside the book's grid cell (position: absolute) when the sidebar is pinned, mirroring the header bar. The flex layout already offsets the grid cell by the sidebar's real rendered width, which honors the sidebar's min-w-60 floor and 45% cap that a stored-width offset would miss. The slide-up panels are absolute within the footer container, so they shift and narrow with it and their animation is unchanged. The switch only happens when the sidebar is pinned, so phone (< 640px) and unpinned tablet-portrait class names stay identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ui): refine reader side panels and their empty states Closes #4089 Add a shared EmptyState component (large muted icon, title, and an optional hint or action) and use it for the empty annotations, bookmarks, and notes panels in the sidebar and notebook, replacing the ad-hoc "No … yet" placeholders. Polish the surrounding chrome: switch the bookmark toggler to the Ri icon set with responsive sizing, crop the HighlighterIcon viewBox to its artwork to remove the asymmetric bottom padding, and tune mobile sizing and spacing across the panel headers, tab navigation, and footer nav bar. Translate the new empty-state strings (No Notes, No Annotations, No Bookmarks, and their hints/action) across all 33 locales and drop the obsolete "No … yet" keys. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
464 lines
18 KiB
TypeScript
464 lines
18 KiB
TypeScript
import clsx from 'clsx';
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { RiQuillPenLine } from 'react-icons/ri';
|
|
|
|
import { useSettingsStore } from '@/store/settingsStore';
|
|
import { useBookDataStore } from '@/store/bookDataStore';
|
|
import { useReaderStore } from '@/store/readerStore';
|
|
import { useSidebarStore } from '@/store/sidebarStore';
|
|
import { useNotebookStore } from '@/store/notebookStore';
|
|
import { useAIChatStore } from '@/store/aiChatStore';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useThemeStore } from '@/store/themeStore';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useSwipeToDismiss } from '@/hooks/useSwipeToDismiss';
|
|
import { usePanelResize } from '@/hooks/usePanelResize';
|
|
import { TextSelection } from '@/utils/sel';
|
|
import { BookNote } from '@/types/book';
|
|
import { uniqueId } from '@/utils/misc';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import { getBookDirFromLanguage } from '@/utils/book';
|
|
import { getPanelTopInset } from '@/utils/insets';
|
|
import { Overlay } from '@/components/Overlay';
|
|
import { saveSysSettings } from '@/helpers/settings';
|
|
import { NOTE_PREFIX } from '@/types/view';
|
|
import useShortcuts from '@/hooks/useShortcuts';
|
|
import BooknoteItem from '../sidebar/BooknoteItem';
|
|
import AIAssistant from './AIAssistant';
|
|
import NotebookHeader from './Header';
|
|
import NoteEditor from './NoteEditor';
|
|
import SearchBar from './SearchBar';
|
|
import NotebookTabNavigation from './NotebookTabNavigation';
|
|
import EmptyState from '../EmptyState';
|
|
|
|
const MIN_NOTEBOOK_WIDTH = 0.15;
|
|
const MAX_NOTEBOOK_WIDTH = 0.45;
|
|
|
|
const Notebook: React.FC = ({}) => {
|
|
const _ = useTranslation();
|
|
const { envConfig, appService } = useEnv();
|
|
const { settings } = useSettingsStore();
|
|
const { updateAppTheme, safeAreaInsets, systemUIVisible, statusBarHeight } = useThemeStore();
|
|
const { sideBarBookKey } = useSidebarStore();
|
|
const { notebookWidth, isNotebookVisible, isNotebookPinned, notebookActiveTab } =
|
|
useNotebookStore();
|
|
const { notebookNewAnnotation, notebookEditAnnotation, setNotebookPin } = useNotebookStore();
|
|
const { getBookData, getConfig, saveConfig, updateBooknotes } = useBookDataStore();
|
|
const { getView, getProgress, getViewSettings } = useReaderStore();
|
|
const { getNotebookWidth, setNotebookWidth, setNotebookVisible, toggleNotebookPin } =
|
|
useNotebookStore();
|
|
const { setNotebookNewAnnotation, setNotebookEditAnnotation, setNotebookActiveTab } =
|
|
useNotebookStore();
|
|
const { activeConversationId } = useAIChatStore();
|
|
|
|
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false);
|
|
const [searchResults, setSearchResults] = useState<BookNote[] | null>(null);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const isMobile = window.innerWidth < 640;
|
|
const [isFullHeightInMobile, setIsFullHeightInMobile] = useState(isMobile);
|
|
|
|
const {
|
|
panelRef: notebookRef,
|
|
overlayRef,
|
|
panelHeight: notebookHeight,
|
|
handleVerticalDragStart,
|
|
} = useSwipeToDismiss(
|
|
() => {
|
|
setNotebookVisible(false);
|
|
setIsFullHeightInMobile(isMobile);
|
|
},
|
|
(data) => setIsFullHeightInMobile(data.clientY < 44),
|
|
);
|
|
|
|
const onNavigateEvent = async () => {
|
|
const { isNotebookPinned } = useNotebookStore.getState();
|
|
if (!isNotebookPinned) {
|
|
setNotebookVisible(false);
|
|
}
|
|
};
|
|
|
|
const handleHideNotebook = useCallback(() => {
|
|
if (!isNotebookPinned) {
|
|
setNotebookVisible(false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isNotebookPinned]);
|
|
|
|
useShortcuts({ onEscape: handleHideNotebook }, [handleHideNotebook]);
|
|
|
|
useEffect(() => {
|
|
if (isNotebookVisible) {
|
|
updateAppTheme('base-200');
|
|
overlayRef.current = document.querySelector('.overlay') as HTMLDivElement | null;
|
|
} else {
|
|
updateAppTheme('base-100');
|
|
overlayRef.current = null;
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isNotebookVisible]);
|
|
|
|
useEffect(() => {
|
|
setNotebookWidth(settings.globalReadSettings.notebookWidth);
|
|
setNotebookPin(settings.globalReadSettings.isNotebookPinned);
|
|
setNotebookVisible(settings.globalReadSettings.isNotebookPinned);
|
|
if (settings.globalReadSettings.notebookActiveTab) {
|
|
setNotebookActiveTab(settings.globalReadSettings.notebookActiveTab);
|
|
}
|
|
|
|
eventDispatcher.on('navigate', onNavigateEvent);
|
|
return () => {
|
|
eventDispatcher.off('navigate', onNavigateEvent);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isNotebookVisible || notebookNewAnnotation || notebookEditAnnotation) {
|
|
setIsSearchBarVisible(false);
|
|
setSearchResults(null);
|
|
setSearchTerm('');
|
|
}
|
|
}, [isNotebookVisible, notebookNewAnnotation, notebookEditAnnotation]);
|
|
|
|
const handleNotebookResize = (newWidth: string) => {
|
|
setNotebookWidth(newWidth);
|
|
settings.globalReadSettings.notebookWidth = newWidth;
|
|
};
|
|
|
|
const handleTogglePin = () => {
|
|
toggleNotebookPin();
|
|
const globalReadSettings = settings.globalReadSettings;
|
|
const newGlobalReadSettings = { ...globalReadSettings, isNotebookPinned: !isNotebookPinned };
|
|
saveSysSettings(envConfig, 'globalReadSettings', newGlobalReadSettings);
|
|
};
|
|
|
|
const handleTabChange = (tab: 'notes' | 'ai') => {
|
|
setNotebookActiveTab(tab);
|
|
const globalReadSettings = settings.globalReadSettings;
|
|
const newGlobalReadSettings = { ...globalReadSettings, notebookActiveTab: tab };
|
|
saveSysSettings(envConfig, 'globalReadSettings', newGlobalReadSettings);
|
|
};
|
|
|
|
const handleClickOverlay = () => {
|
|
setNotebookVisible(false);
|
|
setNotebookNewAnnotation(null);
|
|
setNotebookEditAnnotation(null);
|
|
};
|
|
|
|
const handleSaveNote = (selection: TextSelection, note: string) => {
|
|
if (!sideBarBookKey) return;
|
|
const view = getView(sideBarBookKey);
|
|
const config = getConfig(sideBarBookKey)!;
|
|
|
|
const cfi = view?.getCFI(selection.index, selection.range);
|
|
if (!cfi) return;
|
|
|
|
const { booknotes: annotations = [] } = config;
|
|
const annotation: BookNote = {
|
|
id: uniqueId(),
|
|
type: 'annotation',
|
|
cfi,
|
|
note,
|
|
page: selection.page,
|
|
text: selection.text,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
};
|
|
view?.addAnnotation({ ...annotation, value: `${NOTE_PREFIX}${annotation.cfi}` });
|
|
annotations.push(annotation);
|
|
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
|
|
if (updatedConfig) {
|
|
saveConfig(envConfig, sideBarBookKey, updatedConfig, settings);
|
|
}
|
|
setNotebookNewAnnotation(null);
|
|
};
|
|
|
|
const handleEditNote = (note: BookNote, isDelete: boolean) => {
|
|
if (!sideBarBookKey) return;
|
|
const view = getView(sideBarBookKey);
|
|
const config = getConfig(sideBarBookKey)!;
|
|
const progress = getProgress(sideBarBookKey)!;
|
|
const { booknotes: annotations = [] } = config;
|
|
const existingIndex = annotations.findIndex((item) => item.id === note.id);
|
|
if (existingIndex === -1) return;
|
|
if (isDelete) {
|
|
note.deletedAt = Date.now();
|
|
} else {
|
|
note.updatedAt = Date.now();
|
|
}
|
|
note.page = progress.page;
|
|
annotations[existingIndex] = note;
|
|
view?.addAnnotation({ ...note, value: `${NOTE_PREFIX}${note.cfi}` }, true);
|
|
const updatedConfig = updateBooknotes(sideBarBookKey, annotations);
|
|
if (updatedConfig) {
|
|
saveConfig(envConfig, sideBarBookKey, updatedConfig, settings);
|
|
}
|
|
setNotebookEditAnnotation(null);
|
|
};
|
|
|
|
const { handleResizeStart: handleDragStart, handleResizeKeyDown: handleDragKeyDown } =
|
|
usePanelResize({
|
|
side: 'end',
|
|
minWidth: MIN_NOTEBOOK_WIDTH,
|
|
maxWidth: MAX_NOTEBOOK_WIDTH,
|
|
getWidth: getNotebookWidth,
|
|
onResize: handleNotebookResize,
|
|
});
|
|
|
|
const config = getConfig(sideBarBookKey);
|
|
const { booknotes: allNotes = [] } = config || {};
|
|
const annotationNotes = allNotes
|
|
.filter((note) => note.type === 'annotation' && note.note && !note.deletedAt)
|
|
.sort((a, b) => b.createdAt - a.createdAt);
|
|
const excerptNotes = allNotes
|
|
.filter((note) => note.type === 'excerpt' && note.text && !note.deletedAt)
|
|
.sort((a, b) => a.createdAt - b.createdAt);
|
|
|
|
const handleToggleSearchBar = () => {
|
|
setIsSearchBarVisible((prev) => !prev);
|
|
if (isSearchBarVisible) {
|
|
setSearchResults(null);
|
|
setSearchTerm('');
|
|
}
|
|
};
|
|
|
|
const filteredAnnotationNotes = useMemo(
|
|
() =>
|
|
isSearchBarVisible && searchResults
|
|
? searchResults.filter((note) => note.type === 'annotation' && note.note && !note.deletedAt)
|
|
: annotationNotes,
|
|
[annotationNotes, searchResults, isSearchBarVisible],
|
|
);
|
|
|
|
const filteredExcerptNotes = useMemo(
|
|
() =>
|
|
isSearchBarVisible && searchResults
|
|
? searchResults.filter((note) => note.type === 'excerpt' && note.text && !note.deletedAt)
|
|
: excerptNotes,
|
|
[excerptNotes, searchResults, isSearchBarVisible],
|
|
);
|
|
|
|
if (!sideBarBookKey) return null;
|
|
|
|
const bookData = getBookData(sideBarBookKey);
|
|
const viewSettings = getViewSettings(sideBarBookKey);
|
|
if (!bookData || !bookData.bookDoc) {
|
|
return null;
|
|
}
|
|
const { bookDoc } = bookData;
|
|
const languageDir = getBookDirFromLanguage(bookDoc.metadata.language);
|
|
|
|
const hasSearchResults = filteredAnnotationNotes.length > 0 || filteredExcerptNotes.length > 0;
|
|
const hasAnyNotes = annotationNotes.length > 0 || excerptNotes.length > 0;
|
|
const isNotesTabEmpty =
|
|
!notebookNewAnnotation && !notebookEditAnnotation && !isSearchBarVisible && !hasAnyNotes;
|
|
|
|
return isNotebookVisible ? (
|
|
<>
|
|
{!isNotebookPinned && (
|
|
<Overlay
|
|
className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/50 sm:bg-black/20')}
|
|
onDismiss={handleClickOverlay}
|
|
/>
|
|
)}
|
|
<div
|
|
ref={notebookRef}
|
|
className={clsx(
|
|
'notebook-container right-0 flex min-w-60 select-none flex-col',
|
|
'full-height font-sans text-base font-normal transition-[padding-top] duration-300 sm:text-sm',
|
|
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
|
|
appService?.hasRoundedWindow && 'rounded-window-top-right rounded-window-bottom-right',
|
|
isNotebookPinned ? 'z-20' : 'z-[45] shadow-2xl',
|
|
!isNotebookPinned && viewSettings?.isEink && 'border-base-content border-s',
|
|
)}
|
|
role='group'
|
|
aria-label={_('Notebook')}
|
|
dir={viewSettings?.rtl && languageDir === 'rtl' ? 'rtl' : 'ltr'}
|
|
style={{
|
|
width: isMobile ? '100%' : `${notebookWidth}`,
|
|
maxWidth: isMobile ? '100%' : `${MAX_NOTEBOOK_WIDTH * 100}%`,
|
|
position: isMobile ? 'fixed' : isNotebookPinned ? 'relative' : 'absolute',
|
|
paddingTop: `${getPanelTopInset({
|
|
isMobile,
|
|
isFullHeightInMobile,
|
|
systemUIVisible,
|
|
statusBarHeight,
|
|
safeAreaInsets,
|
|
})}px`,
|
|
}}
|
|
>
|
|
<style jsx>{`
|
|
@media (max-width: 640px) {
|
|
.notebook-container {
|
|
border-top-left-radius: 16px;
|
|
border-top-right-radius: 16px;
|
|
}
|
|
.overlay {
|
|
transition: opacity 0.3s ease-in-out;
|
|
}
|
|
}
|
|
`}</style>
|
|
<div
|
|
className={clsx(
|
|
'drag-bar absolute -left-2 top-0 h-full w-0.5 cursor-col-resize bg-transparent p-2',
|
|
isMobile && 'hidden',
|
|
)}
|
|
role='slider'
|
|
tabIndex={0}
|
|
aria-label={_('Resize Notebook')}
|
|
aria-orientation='horizontal'
|
|
aria-valuenow={parseFloat(notebookWidth)}
|
|
onMouseDown={handleDragStart}
|
|
onTouchStart={handleDragStart}
|
|
onKeyDown={handleDragKeyDown}
|
|
/>
|
|
<div className='flex-shrink-0'>
|
|
{isMobile && (
|
|
<div
|
|
role='slider'
|
|
tabIndex={0}
|
|
aria-label={_('Resize Notebook')}
|
|
aria-orientation='vertical'
|
|
aria-valuenow={notebookHeight.current}
|
|
className='drag-handle flex h-6 max-h-6 min-h-6 w-full cursor-row-resize items-center justify-center'
|
|
onMouseDown={handleVerticalDragStart}
|
|
onTouchStart={handleVerticalDragStart}
|
|
>
|
|
<div className='bg-base-content/50 h-1 w-10 rounded-full'></div>
|
|
</div>
|
|
)}
|
|
<NotebookHeader
|
|
isPinned={isNotebookPinned}
|
|
isSearchBarVisible={isSearchBarVisible && notebookActiveTab === 'notes'}
|
|
handleClose={() => setNotebookVisible(false)}
|
|
handleTogglePin={handleTogglePin}
|
|
handleToggleSearchBar={handleToggleSearchBar}
|
|
showSearchButton={notebookActiveTab === 'notes'}
|
|
/>
|
|
{notebookActiveTab === 'notes' && (
|
|
<div
|
|
className={clsx('search-bar', {
|
|
'search-bar-visible': isSearchBarVisible,
|
|
})}
|
|
>
|
|
<SearchBar
|
|
isVisible={isSearchBarVisible}
|
|
bookKey={sideBarBookKey}
|
|
searchTerm={searchTerm}
|
|
onSearchResultChange={setSearchResults}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{notebookActiveTab === 'ai' ? (
|
|
<div className='flex min-h-0 flex-1 flex-col'>
|
|
<AIAssistant key={activeConversationId ?? 'new'} bookKey={sideBarBookKey} />
|
|
</div>
|
|
) : isNotesTabEmpty ? (
|
|
<div className='flex flex-grow items-center justify-center overflow-y-auto px-3'>
|
|
<EmptyState
|
|
Icon={RiQuillPenLine}
|
|
label={_('No Notes')}
|
|
hint={_('Capture an idea as you read')}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className='flex-grow overflow-y-auto px-3'>
|
|
{isSearchBarVisible && searchResults && !hasSearchResults && hasAnyNotes && (
|
|
<div className='flex h-32 items-center justify-center text-gray-500'>
|
|
<p className='font-size-sm text-center'>{_('No notes match your search')}</p>
|
|
</div>
|
|
)}
|
|
<div dir='ltr'>
|
|
{filteredExcerptNotes.length > 0 && (
|
|
<p className='content font-size-base'>
|
|
{_('Excerpts')}
|
|
{isSearchBarVisible && searchResults && (
|
|
<span className='font-size-xs ml-2 text-gray-500'>
|
|
({filteredExcerptNotes.length})
|
|
</span>
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<ul className=''>
|
|
{filteredExcerptNotes.map((item, index) => (
|
|
<li key={`${index}-${item.id}`} className='my-2'>
|
|
<div
|
|
role='button'
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
handleEditNote(item, true);
|
|
}
|
|
}}
|
|
className='booknote-item collapse-arrow border-base-300 bg-base-100 collapse border'
|
|
>
|
|
<div
|
|
className={clsx(
|
|
'collapse-title pe-8 text-sm font-medium',
|
|
'h-[2.5rem] min-h-[2.5rem] p-[0.6rem]',
|
|
)}
|
|
style={
|
|
{
|
|
'--top-override': '1.25rem',
|
|
'--end-override': '0.7rem',
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<p className='line-clamp-1'>{item.text || `Excerpt ${index + 1}`}</p>
|
|
</div>
|
|
<div className='collapse-content font-size-xs select-text px-3 pb-0'>
|
|
<p className='hyphens-auto text-justify'>{item.text}</p>
|
|
<div className='flex justify-end' dir='ltr'>
|
|
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/}
|
|
<div
|
|
className='font-size-xs cursor-pointer align-bottom text-red-500 hover:text-red-600'
|
|
onClick={handleEditNote.bind(null, item, true)}
|
|
aria-label={_('Delete')}
|
|
>
|
|
{_('Delete')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<div dir='ltr'>
|
|
{(notebookNewAnnotation || filteredAnnotationNotes.length > 0) && (
|
|
<p className='content font-size-base'>
|
|
{_('Notes')}
|
|
{isSearchBarVisible && searchResults && filteredAnnotationNotes.length > 0 && (
|
|
<span className='font-size-xs ml-2 text-gray-500'>
|
|
({filteredAnnotationNotes.length})
|
|
</span>
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{(notebookNewAnnotation || notebookEditAnnotation) && !isSearchBarVisible && (
|
|
<NoteEditor onSave={handleSaveNote} onEdit={(item) => handleEditNote(item, false)} />
|
|
)}
|
|
<ul>
|
|
{filteredAnnotationNotes.map((item, index) => (
|
|
<BooknoteItem key={`${index}-${item.cfi}`} bookKey={sideBarBookKey} item={item} />
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
<div
|
|
className='flex-shrink-0'
|
|
style={{
|
|
paddingBottom: `${(safeAreaInsets?.bottom || 0) / 2}px`,
|
|
}}
|
|
>
|
|
<NotebookTabNavigation activeTab={notebookActiveTab} onTabChange={handleTabChange} />
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : null;
|
|
};
|
|
|
|
export default Notebook;
|