Files
readest/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx
T
Huang Xin f1ae050768 fix(ui): refine reader side panels and their empty states (#4361)
* 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>
2026-05-30 08:29:41 +02:00

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;