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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user