From 3c134380b70c3156bc5c714c4ace4e4a87468c36 Mon Sep 17 00:00:00 2001 From: loveheaven Date: Fri, 29 May 2026 00:37:04 +0800 Subject: [PATCH] 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) --------- Co-authored-by: Huang Xin Co-authored-by: Claude Opus 4.7 (1M context) --- .../reader/components/annotator/Annotator.tsx | 138 +++++++++++------- .../reader/components/notebook/Notebook.tsx | 8 + .../components/sidebar/BooknoteView.tsx | 46 +++--- .../src/components/settings/CustomFonts.tsx | 99 ++++++++++--- .../services/annotation/providers/mrexpt.ts | 7 + 5 files changed, 205 insertions(+), 93 deletions(-) diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index 83952b72..c78a11bf 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -125,6 +125,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const [editingAnnotation, setEditingAnnotation] = useState(null); const [externalDragPoint, setExternalDragPoint] = useState(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 }) => { /> )} + {importingMrexpt && ( +
+
+ + + + +

{_('Importing annotations...')}

+
+
+ )} ); }; diff --git a/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx b/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx index a2aa7314..9b1f97bf 100644 --- a/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx +++ b/apps/readest-app/src/app/reader/components/notebook/Notebook.tsx @@ -430,6 +430,14 @@ const Notebook: React.FC = ({}) => { ))} + {!notebookNewAnnotation && + !notebookEditAnnotation && + !isSearchBarVisible && + !hasAnyNotes && ( +
+

{_('No note yet')}

+
+ )} )}
= ({ type, bookKey, toc }) => { + const _ = useTranslation(); const { getConfig } = useBookDataStore(); const { getProgress } = useReaderStore(); const { setActiveBooknoteType, setBooknoteResults } = useSidebarStore(); @@ -61,24 +63,32 @@ const BooknoteView: React.FC<{ return (
-
    - {sortedGroups.map((group) => ( -
  • -

    {group.label}

    -
      - {group.booknotes.map((item, index) => ( - - ))} -
    -
  • - ))} -
+ {sortedGroups.length === 0 ? ( +
+

+ {type === 'annotation' ? _('No annotation yet') : _('No bookmark yet')} +

+
+ ) : ( +
    + {sortedGroups.map((group) => ( +
  • +

    {group.label}

    +
      + {group.booknotes.map((item, index) => ( + + ))} +
    +
  • + ))} +
+ )}
); }; diff --git a/apps/readest-app/src/components/settings/CustomFonts.tsx b/apps/readest-app/src/components/settings/CustomFonts.tsx index de1bb3e3..dfe94ca2 100644 --- a/apps/readest-app/src/components/settings/CustomFonts.tsx +++ b/apps/readest-app/src/components/settings/CustomFonts.tsx @@ -38,6 +38,9 @@ const CustomFonts: React.FC = ({ 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(null); const { selectFiles } = useFileSelector(appService, _); @@ -50,28 +53,42 @@ const CustomFonts: React.FC = ({ 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 = ({ 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 (
@@ -184,6 +209,40 @@ const CustomFonts: React.FC = ({ bookKey, onBack }) => { {_('Import Font')} + {importingFont && ( +
+
+ {typeof importingFont === 'object' ? ( +
+ {importingFont.family} +
+ ) : ( +
+ + + + + {_('Importing...')} +
+ )} +
+
+ )} + {availableFamilies.map((family) => (
0 && i % YIELD_EVERY === 0) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } const entry = unique[i]!; const createdAt = entry.timestamp || now + i;