feat: add empty state hints and loading indicators for annotations, bookmarks, notes, font import, and Moon+ Reader import (#4338)

* feat: add empty state hints and loading indicators for annotations, bookmarks, notes, font import, and Moon+ Reader import

- BooknoteView: show 'No annotation yet' / 'No bookmark yet' when empty
- Notebook: show 'No note yet' when no notes/excerpts exist
- Annotator: add loading overlay with spinner during Moon+ Reader import
- mrexpt: yield to event loop every 5 entries to keep spinner animating
- CustomFonts: show in-place loading card during font import, spinner transitions to font name without layout jump

* fix(ui): respect e-ink overlay styling and drop dead className branch

- Annotator: use modal-box on the mrexpt import overlay so eink picks up
  the no-shadow + 1px border override automatically.
- CustomFonts: collapse importing-card clsx ternary whose branches were
  identical into a flat className.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
loveheaven
2026-05-29 00:37:04 +08:00
committed by GitHub
parent bb81d6270f
commit 3c134380b7
5 changed files with 205 additions and 93 deletions
@@ -125,6 +125,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [editingAnnotation, setEditingAnnotation] = useState<BookNote | null>(null);
const [externalDragPoint, setExternalDragPoint] = useState<Point | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [importingMrexpt, setImportingMrexpt] = useState(false);
// "Clear Annotations" confirm dialog. Hosted here (and not in BookMenu)
// because the menu unmounts the moment the user picks the entry, which
// would otherwise tear down the dialog state immediately.
@@ -1049,68 +1050,73 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
return;
}
let conversion;
setImportingMrexpt(true);
try {
conversion = await convertMrexptEntriesToBookNotes(entries, bookDoc, {
highlightStyle: settings.globalReadSettings.highlightStyle,
highlightColor:
settings.globalReadSettings.highlightStyles[settings.globalReadSettings.highlightStyle],
});
} catch (e) {
console.warn('Failed to convert mrexpt entries:', e);
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Failed to import annotations.'),
timeout: 3000,
});
return;
}
let conversion;
try {
conversion = await convertMrexptEntriesToBookNotes(entries, bookDoc, {
highlightStyle: settings.globalReadSettings.highlightStyle,
highlightColor:
settings.globalReadSettings.highlightStyles[settings.globalReadSettings.highlightStyle],
});
} catch (e) {
console.warn('Failed to convert mrexpt entries:', e);
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Failed to import annotations.'),
timeout: 3000,
});
return;
}
if (conversion.notes.length === 0) {
if (conversion.notes.length === 0) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('No annotations could be located in this book.'),
timeout: 2500,
});
return;
}
// Merge into the current book config, deduplicating by note id and
// preferring the latest updatedAt for any conflicting entries.
const config = getConfig(bookKey)!;
const { merged, applied, added, updated } = mergeImportedBookNotes(
config.booknotes ?? [],
conversion.notes,
);
const updatedConfig = updateBooknotes(bookKey, merged);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
// Apply imported (or resurrected) annotations to all live views so
// they appear immediately. We only re-draw the notes that actually
// changed in this round, otherwise duplicate addAnnotation calls
// can confuse the overlay layer.
const views = getViewsById(bookKey.split('-')[0]!);
for (const note of applied) {
try {
views.forEach((v) => v?.addAnnotation(note));
} catch (err) {
console.warn('Failed to add imported annotation', { note, err });
}
}
// A single result toast: the count if anything changed, otherwise a
// plain "nothing new" hint for a repeated import of the same file.
const imported = added + updated;
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('No annotations could be located in this book.'),
message:
imported > 0
? _('Imported {{count}} annotations', { count: imported })
: _('No new annotations to import'),
timeout: 2500,
});
return;
} finally {
setImportingMrexpt(false);
}
// Merge into the current book config, deduplicating by note id and
// preferring the latest updatedAt for any conflicting entries.
const config = getConfig(bookKey)!;
const { merged, applied, added, updated } = mergeImportedBookNotes(
config.booknotes ?? [],
conversion.notes,
);
const updatedConfig = updateBooknotes(bookKey, merged);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
// Apply imported (or resurrected) annotations to all live views so
// they appear immediately. We only re-draw the notes that actually
// changed in this round, otherwise duplicate addAnnotation calls
// can confuse the overlay layer.
const views = getViewsById(bookKey.split('-')[0]!);
for (const note of applied) {
try {
views.forEach((v) => v?.addAnnotation(note));
} catch (err) {
console.warn('Failed to add imported annotation', { note, err });
}
}
// A single result toast: the count if anything changed, otherwise a
// plain "nothing new" hint for a repeated import of the same file.
const imported = added + updated;
eventDispatcher.dispatch('toast', {
type: 'info',
message:
imported > 0
? _('Imported {{count}} annotations', { count: imported })
: _('No new annotations to import'),
timeout: 2500,
});
};
const handleExportMarkdown = async (event: CustomEvent) => {
@@ -1430,6 +1436,28 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
/>
</ModalPortal>
)}
{importingMrexpt && (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/30'>
<div className='modal-box bg-base-100 flex flex-col items-center gap-3 px-8 py-6 shadow-2xl'>
<svg className='text-primary h-8 w-8 animate-spin' viewBox='0 0 24 24' fill='none'>
<circle
className='opacity-25'
cx='12'
cy='12'
r='10'
stroke='currentColor'
strokeWidth='4'
/>
<path
className='opacity-75'
fill='currentColor'
d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z'
/>
</svg>
<p className='font-size-sm text-base-content'>{_('Importing annotations...')}</p>
</div>
</div>
)}
</div>
);
};
@@ -430,6 +430,14 @@ const Notebook: React.FC = ({}) => {
<BooknoteItem key={`${index}-${item.cfi}`} bookKey={sideBarBookKey} item={item} />
))}
</ul>
{!notebookNewAnnotation &&
!notebookEditAnnotation &&
!isSearchBarVisible &&
!hasAnyNotes && (
<div className='flex h-32 items-center justify-center text-gray-500'>
<p className='font-size-sm text-center'>{_('No note yet')}</p>
</div>
)}
</div>
)}
<div
@@ -7,6 +7,7 @@ import { findTocItemBS } from '@/services/nav';
import { findNearestCfi } from '@/utils/cfi';
import { TOCItem } from '@/libs/document';
import { BooknoteGroup, BookNoteType } from '@/types/book';
import { useTranslation } from '@/hooks/useTranslation';
import BooknoteItem from './BooknoteItem';
const BooknoteView: React.FC<{
@@ -14,6 +15,7 @@ const BooknoteView: React.FC<{
bookKey: string;
toc: TOCItem[];
}> = ({ type, bookKey, toc }) => {
const _ = useTranslation();
const { getConfig } = useBookDataStore();
const { getProgress } = useReaderStore();
const { setActiveBooknoteType, setBooknoteResults } = useSidebarStore();
@@ -61,24 +63,32 @@ const BooknoteView: React.FC<{
return (
<div className='rounded pt-2'>
<ul role='tree' className='px-2'>
{sortedGroups.map((group) => (
<li key={group.href} className='p-2'>
<h3 className='content font-size-base line-clamp-1 font-normal'>{group.label}</h3>
<ul>
{group.booknotes.map((item, index) => (
<BooknoteItem
key={`${index}-${item.cfi}`}
bookKey={bookKey}
item={item}
isNearest={item.cfi === nearestCfi}
onClick={handleBrowseBookNotes}
/>
))}
</ul>
</li>
))}
</ul>
{sortedGroups.length === 0 ? (
<div className='flex h-32 items-center justify-center text-gray-500'>
<p className='font-size-sm text-center'>
{type === 'annotation' ? _('No annotation yet') : _('No bookmark yet')}
</p>
</div>
) : (
<ul role='tree' className='px-2'>
{sortedGroups.map((group) => (
<li key={group.href} className='p-2'>
<h3 className='content font-size-base line-clamp-1 font-normal'>{group.label}</h3>
<ul>
{group.booknotes.map((item, index) => (
<BooknoteItem
key={`${index}-${item.cfi}`}
bookKey={bookKey}
item={item}
isNearest={item.cfi === nearestCfi}
onClick={handleBrowseBookNotes}
/>
))}
</ul>
</li>
))}
</ul>
)}
</div>
);
};
@@ -38,6 +38,9 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
const [isDeleteMode, setIsDeleteMode] = useState(false);
// null = idle, true = importing (spinner), { family } = done importing (show font name).
// The card stays mounted throughout — only its content changes.
const [importingFont, setImportingFont] = useState<true | { family: string } | null>(null);
const { selectFiles } = useFileSelector(appService, _);
@@ -50,28 +53,42 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
const handleImportFont = () => {
selectFiles({ type: 'fonts', multiple: true }).then(async (result) => {
if (result.error || result.files.length === 0) return;
for (const selectedFile of result.files) {
const fontInfo = await appService?.importFont(selectedFile.path || selectedFile.file);
if (!fontInfo) continue;
setImportingFont(true);
try {
for (const selectedFile of result.files) {
const fontInfo = await appService?.importFont(selectedFile.path || selectedFile.file);
if (!fontInfo) continue;
const customFont = addFont(fontInfo.path, {
name: fontInfo.name,
family: fontInfo.family,
style: fontInfo.style,
weight: fontInfo.weight,
variable: fontInfo.variable,
contentId: fontInfo.contentId,
bundleDir: fontInfo.bundleDir,
byteSize: fontInfo.byteSize,
});
console.log('Added custom font:', customFont);
if (customFont && !customFont.error) {
const loadedFont = await loadFont(envConfig, customFont.id);
mountCustomFont(document, loadedFont);
if (appService) void queueReplicaBinaryUpload('font', customFont, appService);
// Replace the spinner with the resolved font family name in-place,
// so the card stays at the same grid position without layout jump.
setImportingFont({ family: fontInfo.family });
const customFont = addFont(fontInfo.path, {
name: fontInfo.name,
family: fontInfo.family,
style: fontInfo.style,
weight: fontInfo.weight,
variable: fontInfo.variable,
contentId: fontInfo.contentId,
bundleDir: fontInfo.bundleDir,
byteSize: fontInfo.byteSize,
});
console.log('Added custom font:', customFont);
if (customFont && !customFont.error) {
const loadedFont = await loadFont(envConfig, customFont.id);
mountCustomFont(document, loadedFont);
if (appService) void queueReplicaBinaryUpload('font', customFont, appService);
}
}
saveCustomFonts(envConfig);
} finally {
// Keep the card visible — it now shows the font family name.
// availableFamilies will pick it up on next render and the
// importingFont card naturally becomes a regular font card.
// We clear importingFont after a tick so availableFamilies
// has a chance to include the new font first.
setTimeout(() => setImportingFont(null), 0);
}
saveCustomFonts(envConfig);
});
};
@@ -122,7 +139,15 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
.filter((font) => !font.deletedAt)
.sort((a, b) => (b.downloadedAt || 0) - (a.downloadedAt || 0));
const availableFamilies = getAvailableFamilies(availableFonts);
// Exclude the font that's currently shown by the importingFont card so
// we don't render two cards for the same font family.
const importingFamily =
importingFont && typeof importingFont === 'object' ? importingFont.family : null;
const visibleFonts = importingFamily
? availableFonts.filter((f) => (f.family || f.name) !== importingFamily)
: availableFonts;
const availableFamilies = getAvailableFamilies(visibleFonts);
return (
<div className='w-full'>
@@ -184,6 +209,40 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
<span className='line-clamp-1'>{_('Import Font')}</span>
</button>
{importingFont && (
<div className='card border-base-200 bg-base-100 h-12 border shadow-sm'>
<div className='card-body flex items-center justify-center p-2'>
{typeof importingFont === 'object' ? (
<div
style={{ fontFamily: `"${importingFont.family}", sans-serif`, fontWeight: 400 }}
className='text-base-content line-clamp-1 break-all'
>
{importingFont.family}
</div>
) : (
<div className='flex items-center gap-2 text-sm text-base-content/60'>
<svg className='h-4 w-4 animate-spin' viewBox='0 0 24 24' fill='none'>
<circle
className='opacity-25'
cx='12'
cy='12'
r='10'
stroke='currentColor'
strokeWidth='4'
/>
<path
className='opacity-75'
fill='currentColor'
d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z'
/>
</svg>
<span>{_('Importing...')}</span>
</div>
)}
</div>
</div>
)}
{availableFamilies.map((family) => (
<div
role='none'
@@ -309,7 +309,14 @@ export const convertMrexptEntriesToBookNotes = async (
}
};
// Yield to the event loop every N entries so the UI spinner stays alive
// during large imports. Without this, the synchronous DOM operations
// inside loadSectionDoc / findWordRange starve the renderer.
const YIELD_EVERY = 5;
for (let i = 0; i < unique.length; i++) {
if (i > 0 && i % YIELD_EVERY === 0) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
const entry = unique[i]!;
const createdAt = entry.timestamp || now + i;