电子书室优化
This commit is contained in:
@@ -7,6 +7,22 @@ import { buildBookReadPath, cacheBookReadRecord, cacheBookShelfItem } from '@/li
|
|||||||
import { deleteBookReadRecord, getAllBookReadRecords, getAllBookShelf } from '@/lib/book.db.client';
|
import { deleteBookReadRecord, getAllBookReadRecords, getAllBookShelf } from '@/lib/book.db.client';
|
||||||
import { BookReadRecord, BookShelfItem } from '@/lib/book.types';
|
import { BookReadRecord, BookShelfItem } from '@/lib/book.types';
|
||||||
|
|
||||||
|
|
||||||
|
function looksLikeInternalHref(value?: string) {
|
||||||
|
if (!value) return false;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return /\.(xhtml|html|htm|xml)(#.*)?$/.test(normalized) || /^nav/.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReadableChapterLabel(item: BookReadRecord) {
|
||||||
|
const candidates = [item.chapterTitle, item.locator.chapterTitle];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const text = (candidate || '').trim();
|
||||||
|
if (text && !looksLikeInternalHref(text)) return text;
|
||||||
|
}
|
||||||
|
return '定位已保存';
|
||||||
|
}
|
||||||
|
|
||||||
export default function BookHistoryPage() {
|
export default function BookHistoryPage() {
|
||||||
const [records, setRecords] = useState<Record<string, BookReadRecord>>({});
|
const [records, setRecords] = useState<Record<string, BookReadRecord>>({});
|
||||||
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
|
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
|
||||||
@@ -44,7 +60,7 @@ export default function BookHistoryPage() {
|
|||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='truncate font-medium'>{item.title}</div>
|
<div className='truncate font-medium'>{item.title}</div>
|
||||||
<div className='mt-1 text-sm text-gray-500'>{item.author || item.sourceName}</div>
|
<div className='mt-1 text-sm text-gray-500'>{item.author || item.sourceName}</div>
|
||||||
<div className='mt-1 text-xs text-gray-500'>已读 {Math.round(item.progressPercent || 0)}% · {item.chapterTitle || item.locator.chapterTitle || '定位已保存'}</div>
|
<div className='mt-1 text-xs text-gray-500'>已读 {Math.round(item.progressPercent || 0)}% · {getReadableChapterLabel(item)}</div>
|
||||||
<div className='mt-3 flex flex-wrap gap-2'>
|
<div className='mt-3 flex flex-wrap gap-2'>
|
||||||
{item.sourceId ? (
|
{item.sourceId ? (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
+167
-89
@@ -3,6 +3,7 @@
|
|||||||
import { BookOpen, List, Moon, Settings2, Sun } from 'lucide-react';
|
import { BookOpen, List, Moon, Settings2, Sun } from 'lucide-react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildBookCacheKey,
|
buildBookCacheKey,
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
} from '@/lib/book-cache.client';
|
} from '@/lib/book-cache.client';
|
||||||
import { cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
|
import { cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
|
||||||
import { saveBookReadRecord } from '@/lib/book.db.client';
|
import { saveBookReadRecord } from '@/lib/book.db.client';
|
||||||
import { BookReadManifest } from '@/lib/book.types';
|
import { BookReadManifest, BookReadRecord } from '@/lib/book.types';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -82,6 +83,7 @@ const DEFAULT_SETTINGS: ReaderSettings = {
|
|||||||
lineHeight: 1.7,
|
lineHeight: 1.7,
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
};
|
};
|
||||||
|
const SAVE_INTERVAL_MS = 10000;
|
||||||
|
|
||||||
const THEME_STYLES: Record<ReaderTheme, { bodyBg: string; bodyColor: string; panelBg: string }> = {
|
const THEME_STYLES: Record<ReaderTheme, { bodyBg: string; bodyColor: string; panelBg: string }> = {
|
||||||
light: { bodyBg: '#ffffff', bodyColor: '#111827', panelBg: '#ffffff' },
|
light: { bodyBg: '#ffffff', bodyColor: '#111827', panelBg: '#ffffff' },
|
||||||
@@ -141,6 +143,19 @@ function flattenToc(items: TocItem[]): TocItem[] {
|
|||||||
return items.flatMap((item) => [item, ...flattenToc(item.subitems || [])]);
|
return items.flatMap((item) => [item, ...flattenToc(item.subitems || [])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tocItemIsActive(item: TocItem, currentHref: string): boolean {
|
||||||
|
return isSameTocTarget(currentHref, item.href) || (item.subitems || []).some((subitem) => tocItemIsActive(subitem, currentHref));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTocLabelByHref(items: TocItem[], currentHref: string): string {
|
||||||
|
for (const item of items) {
|
||||||
|
if (isSameTocTarget(currentHref, item.href)) return item.label;
|
||||||
|
const nested = findTocLabelByHref(item.subitems || [], currentHref);
|
||||||
|
if (nested) return nested;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadBookWithProgress(
|
async function downloadBookWithProgress(
|
||||||
manifest: Pick<BookReadManifest, 'book' | 'format' | 'acquisitionHref'>,
|
manifest: Pick<BookReadManifest, 'book' | 'format' | 'acquisitionHref'>,
|
||||||
onProgress: (received: number, total: number | null) => void
|
onProgress: (received: number, total: number | null) => void
|
||||||
@@ -235,11 +250,14 @@ export default function BookReadPage() {
|
|||||||
const tocItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
const tocItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
const bookRef = useRef<EpubBookInstance | null>(null);
|
const bookRef = useRef<EpubBookInstance | null>(null);
|
||||||
const renditionRef = useRef<EpubRendition | null>(null);
|
const renditionRef = useRef<EpubRendition | null>(null);
|
||||||
const saveTimerRef = useRef<number | null>(null);
|
const pendingRecordRef = useRef<BookReadRecord | null>(null);
|
||||||
|
const pendingRecordDirtyRef = useRef(false);
|
||||||
|
const saveInFlightRef = useRef(false);
|
||||||
const lastLocationRef = useRef<EpubLocation | null>(null);
|
const lastLocationRef = useRef<EpubLocation | null>(null);
|
||||||
const lastProgressRef = useRef(0);
|
const lastProgressRef = useRef(0);
|
||||||
const lastChapterRef = useRef('');
|
const lastChapterRef = useRef('');
|
||||||
const locationsReadyRef = useRef(false);
|
const locationsReadyRef = useRef(false);
|
||||||
|
const tocItemsRef = useRef<TocItem[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSettings(loadReaderSettings());
|
setSettings(loadReaderSettings());
|
||||||
@@ -303,40 +321,65 @@ export default function BookReadPage() {
|
|||||||
.catch((err) => setError(err.message || '获取阅读信息失败'));
|
.catch((err) => setError(err.message || '获取阅读信息失败'));
|
||||||
}, [sourceId, bookId, cached]);
|
}, [sourceId, bookId, cached]);
|
||||||
|
|
||||||
const saveProgress = useMemo(() => {
|
const buildReadRecord = useCallback((location: EpubLocation, nextProgress = 0, chapterTitle?: string): BookReadRecord | null => {
|
||||||
return async (location: EpubLocation, nextProgress = 0, chapterTitle?: string) => {
|
if (!manifest) return null;
|
||||||
if (!manifest) return;
|
const locatorValue = location?.start?.cfi || location?.end?.cfi || '';
|
||||||
const locatorValue = location?.start?.cfi || location?.end?.cfi || '';
|
if (!locatorValue) return null;
|
||||||
if (!locatorValue) return;
|
return {
|
||||||
await saveBookReadRecord(manifest.book.sourceId, manifest.book.id, {
|
sourceId: manifest.book.sourceId,
|
||||||
sourceId: manifest.book.sourceId,
|
sourceName: manifest.book.sourceName,
|
||||||
sourceName: manifest.book.sourceName,
|
bookId: manifest.book.id,
|
||||||
bookId: manifest.book.id,
|
title: manifest.book.title,
|
||||||
title: manifest.book.title,
|
author: manifest.book.author,
|
||||||
author: manifest.book.author,
|
cover: manifest.book.cover,
|
||||||
cover: manifest.book.cover,
|
detailHref: manifest.book.detailHref,
|
||||||
detailHref: manifest.book.detailHref,
|
acquisitionHref: manifest.acquisitionHref,
|
||||||
acquisitionHref: manifest.acquisitionHref,
|
format: manifest.format,
|
||||||
format: manifest.format,
|
locator: {
|
||||||
locator: {
|
type: 'epub-cfi',
|
||||||
type: 'epub-cfi',
|
value: locatorValue,
|
||||||
value: locatorValue,
|
href: location?.start?.href,
|
||||||
href: location?.start?.href,
|
|
||||||
chapterTitle,
|
|
||||||
},
|
|
||||||
chapterTitle,
|
chapterTitle,
|
||||||
chapterHref: location?.start?.href,
|
},
|
||||||
progressPercent: nextProgress,
|
chapterTitle,
|
||||||
saveTime: Date.now(),
|
chapterHref: location?.start?.href,
|
||||||
});
|
progressPercent: nextProgress,
|
||||||
|
saveTime: Date.now(),
|
||||||
};
|
};
|
||||||
}, [manifest]);
|
}, [manifest]);
|
||||||
|
|
||||||
|
const queueReadRecord = useCallback((location: EpubLocation, nextProgress = 0, chapterTitle?: string) => {
|
||||||
|
const record = buildReadRecord(location, nextProgress, chapterTitle);
|
||||||
|
if (!record) return;
|
||||||
|
pendingRecordRef.current = record;
|
||||||
|
pendingRecordDirtyRef.current = true;
|
||||||
|
}, [buildReadRecord]);
|
||||||
|
|
||||||
|
const flushPendingReadRecord = useCallback(async () => {
|
||||||
|
const record = pendingRecordRef.current;
|
||||||
|
if (!record || !pendingRecordDirtyRef.current || saveInFlightRef.current) return;
|
||||||
|
|
||||||
|
saveInFlightRef.current = true;
|
||||||
|
try {
|
||||||
|
await saveBookReadRecord(record.sourceId, record.bookId, {
|
||||||
|
...record,
|
||||||
|
saveTime: Date.now(),
|
||||||
|
});
|
||||||
|
pendingRecordRef.current = { ...record, saveTime: Date.now() };
|
||||||
|
pendingRecordDirtyRef.current = false;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
saveInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const persistCurrentProgress = useCallback(() => {
|
const persistCurrentProgress = useCallback(() => {
|
||||||
if (lastLocationRef.current) {
|
if (lastLocationRef.current) {
|
||||||
void saveProgress(lastLocationRef.current, lastProgressRef.current, lastChapterRef.current);
|
queueReadRecord(lastLocationRef.current, lastProgressRef.current, lastChapterRef.current);
|
||||||
}
|
}
|
||||||
}, [saveProgress]);
|
void flushPendingReadRecord();
|
||||||
|
}, [queueReadRecord, flushPendingReadRecord]);
|
||||||
|
|
||||||
const applyReaderTheme = useCallback((nextSettings: ReaderSettings) => {
|
const applyReaderTheme = useCallback((nextSettings: ReaderSettings) => {
|
||||||
const rendition = renditionRef.current;
|
const rendition = renditionRef.current;
|
||||||
@@ -383,6 +426,9 @@ export default function BookReadPage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return;
|
if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
@@ -477,7 +523,8 @@ export default function BookReadPage() {
|
|||||||
window.setTimeout(() => setRestoredMessage(''), 3000);
|
window.setTimeout(() => setRestoredMessage(''), 3000);
|
||||||
}
|
}
|
||||||
lastLocationRef.current = location;
|
lastLocationRef.current = location;
|
||||||
const chapterTitle = location?.start?.displayed?.chapter || location?.start?.href || manifest.book.title;
|
const hrefLabel = location?.start?.href ? findTocLabelByHref(tocItemsRef.current, location.start.href) : '';
|
||||||
|
const chapterTitle = hrefLabel || location?.start?.displayed?.chapter || location?.start?.href || manifest.book.title;
|
||||||
const cfi = location?.start?.cfi || '';
|
const cfi = location?.start?.cfi || '';
|
||||||
const computedProgress = locationsReadyRef.current && cfi
|
const computedProgress = locationsReadyRef.current && cfi
|
||||||
? Math.max(0, Math.min(100, (book.locations?.percentageFromCfi?.(cfi) || 0) * 100))
|
? Math.max(0, Math.min(100, (book.locations?.percentageFromCfi?.(cfi) || 0) * 100))
|
||||||
@@ -488,10 +535,7 @@ export default function BookReadPage() {
|
|||||||
setCurrentHref(location?.start?.href || '');
|
setCurrentHref(location?.start?.href || '');
|
||||||
lastProgressRef.current = normalizedProgress;
|
lastProgressRef.current = normalizedProgress;
|
||||||
lastChapterRef.current = chapterTitle;
|
lastChapterRef.current = chapterTitle;
|
||||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
queueReadRecord(location, normalizedProgress, chapterTitle);
|
||||||
saveTimerRef.current = window.setTimeout(() => {
|
|
||||||
void saveProgress(location, normalizedProgress, chapterTitle);
|
|
||||||
}, locationsReadyRef.current ? 1500 : 3500);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
void navigateToTarget(restoreTarget).catch(() => {
|
void navigateToTarget(restoreTarget).catch(() => {
|
||||||
@@ -533,25 +577,37 @@ export default function BookReadPage() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
|
||||||
persistCurrentProgress();
|
persistCurrentProgress();
|
||||||
renditionRef.current?.destroy?.();
|
renditionRef.current?.destroy?.();
|
||||||
bookRef.current?.destroy?.();
|
bookRef.current?.destroy?.();
|
||||||
};
|
};
|
||||||
}, [manifest, settings, applyReaderTheme, persistCurrentProgress, saveProgress, navigateToTarget]);
|
}, [manifest, settings, applyReaderTheme, persistCurrentProgress, queueReadRecord, navigateToTarget]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleVisibility = () => {
|
const flushPendingReadRecordOnLeave = () => {
|
||||||
if (document.visibilityState === 'hidden') persistCurrentProgress();
|
if (!pendingRecordDirtyRef.current || saveInFlightRef.current) return;
|
||||||
|
persistCurrentProgress();
|
||||||
};
|
};
|
||||||
const handleUnload = () => persistCurrentProgress();
|
|
||||||
document.addEventListener('visibilitychange', handleVisibility);
|
const handleVisibilityChange = () => {
|
||||||
window.addEventListener('beforeunload', handleUnload);
|
if (document.visibilityState === 'hidden') {
|
||||||
|
flushPendingReadRecordOnLeave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
void flushPendingReadRecord();
|
||||||
|
}, SAVE_INTERVAL_MS);
|
||||||
|
window.addEventListener('pagehide', flushPendingReadRecordOnLeave);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibility);
|
window.clearInterval(intervalId);
|
||||||
window.removeEventListener('beforeunload', handleUnload);
|
window.removeEventListener('pagehide', flushPendingReadRecordOnLeave);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, [persistCurrentProgress]);
|
}, [flushPendingReadRecord, persistCurrentProgress]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -588,15 +644,8 @@ export default function BookReadPage() {
|
|||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!manifest) return;
|
tocItemsRef.current = tocItems;
|
||||||
window.dispatchEvent(new CustomEvent('books-read-update-header', {
|
}, [tocItems]);
|
||||||
detail: {
|
|
||||||
title: manifest.book.title,
|
|
||||||
subtitle: currentChapter || manifest.book.author || '分页阅读',
|
|
||||||
backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}, [manifest, currentChapter]);
|
|
||||||
|
|
||||||
const flatToc = useMemo(() => flattenToc(tocItems), [tocItems]);
|
const flatToc = useMemo(() => flattenToc(tocItems), [tocItems]);
|
||||||
const activeTocHref = useMemo(
|
const activeTocHref = useMemo(
|
||||||
@@ -604,6 +653,19 @@ export default function BookReadPage() {
|
|||||||
[flatToc, currentHref]
|
[flatToc, currentHref]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const currentTocLabel = useMemo(() => findTocLabelByHref(tocItems, currentHref), [tocItems, currentHref]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!manifest) return;
|
||||||
|
window.dispatchEvent(new CustomEvent('books-read-update-header', {
|
||||||
|
detail: {
|
||||||
|
title: manifest.book.title,
|
||||||
|
subtitle: currentTocLabel || currentChapter || manifest.book.author || '分页阅读',
|
||||||
|
backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [manifest, currentChapter, currentTocLabel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tocOpen || !activeTocHref) return;
|
if (!tocOpen || !activeTocHref) return;
|
||||||
const activeNode = tocItemRefs.current[activeTocHref];
|
const activeNode = tocItemRefs.current[activeTocHref];
|
||||||
@@ -611,6 +673,34 @@ export default function BookReadPage() {
|
|||||||
activeNode.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
activeNode.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
}, [tocOpen, activeTocHref]);
|
}, [tocOpen, activeTocHref]);
|
||||||
|
|
||||||
|
const renderTocItems = useCallback((items: TocItem[], depth = 0) => items.map((item) => {
|
||||||
|
const active = tocItemIsActive(item, currentHref);
|
||||||
|
const clickable = !!item.href;
|
||||||
|
return (
|
||||||
|
<div key={`${item.href || item.label}-${depth}`} className='space-y-2'>
|
||||||
|
<button
|
||||||
|
ref={(node) => {
|
||||||
|
if (item.href) tocItemRefs.current[item.href] = node;
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!clickable) return;
|
||||||
|
void navigateToTarget(item.href);
|
||||||
|
setTocOpen(false);
|
||||||
|
}}
|
||||||
|
disabled={!clickable}
|
||||||
|
className={`group relative block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${active ? 'bg-sky-600 text-white' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'} ${!clickable ? 'cursor-default opacity-80' : ''}`}
|
||||||
|
style={{ paddingLeft: `${16 + depth * 14}px` }}
|
||||||
|
>
|
||||||
|
<span className='block truncate'>{item.label}</span>
|
||||||
|
<div className='pointer-events-none absolute bottom-full left-1/2 z-[100] mb-2 -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-sm text-white opacity-0 invisible shadow-xl transition-all duration-200 ease-out group-hover:visible group-hover:opacity-100 dark:bg-gray-900 whitespace-nowrap'>
|
||||||
|
<div className='text-sm'>{item.label}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{item.subitems?.length ? renderTocItems(item.subitems, depth + 1) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}), [currentHref, navigateToTarget]);
|
||||||
|
|
||||||
const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes);
|
const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes);
|
||||||
|
|
||||||
if (error) return <div className='p-4 text-sm text-red-500'>{error}</div>;
|
if (error) return <div className='p-4 text-sm text-red-500'>{error}</div>;
|
||||||
@@ -664,47 +754,27 @@ export default function BookReadPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{tocOpen && (
|
{tocOpen && typeof document !== 'undefined' ? createPortal(
|
||||||
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}>
|
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}>
|
||||||
<div
|
<div
|
||||||
className='absolute right-0 top-0 h-[calc(100vh-3.5rem)] w-full max-w-sm overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
className='absolute right-0 top-0 h-screen w-[22rem] max-w-[88vw] overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className='p-4'>
|
<div className='p-4'>
|
||||||
<div className='mb-3 flex items-center justify-between'>
|
<div className='space-y-2' ref={tocScrollRef}>
|
||||||
<div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'><BookOpen className='h-4 w-4' />目录</div>
|
{tocItems.length === 0 ? (
|
||||||
<button onClick={() => setTocOpen(false)} className='text-xs text-gray-500'>关闭</button>
|
|
||||||
</div>
|
|
||||||
<div ref={tocScrollRef} className='space-y-2'>
|
|
||||||
{flatToc.length === 0 ? (
|
|
||||||
<div className='p-3 text-sm text-gray-500'>当前 EPUB 未提供目录</div>
|
<div className='p-3 text-sm text-gray-500'>当前 EPUB 未提供目录</div>
|
||||||
) : (
|
) : (
|
||||||
flatToc.map((item) => {
|
renderTocItems(tocItems)
|
||||||
const active = activeTocHref === item.href;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${item.href}-${item.label}`}
|
|
||||||
ref={(node) => {
|
|
||||||
tocItemRefs.current[item.href] = node;
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
void navigateToTarget(item.href);
|
|
||||||
setTocOpen(false);
|
|
||||||
}}
|
|
||||||
className={`block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${active ? 'bg-sky-600 text-white shadow-sm' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
)}
|
document.body
|
||||||
|
) : null}
|
||||||
|
|
||||||
{settingsOpen && (
|
{settingsOpen && typeof document !== 'undefined' ? createPortal(
|
||||||
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4' onClick={() => setSettingsOpen(false)}>
|
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4' onClick={() => setSettingsOpen(false)}>
|
||||||
<div
|
<div
|
||||||
className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
||||||
@@ -757,16 +827,24 @@ export default function BookReadPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
)}
|
document.body
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
|
||||||
{ready && !tocOpen && !settingsOpen ? (
|
{ready && !tocOpen && !settingsOpen ? (
|
||||||
<div className='absolute inset-0 z-10 grid grid-cols-3'>
|
<>
|
||||||
<button aria-label='上一页' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('left')} />
|
<button
|
||||||
<button aria-label='切换工具栏' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('center')} />
|
aria-label='上一页'
|
||||||
<button aria-label='下一页' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('right')} />
|
className='absolute inset-y-0 left-0 z-10 w-[28%] cursor-pointer bg-transparent'
|
||||||
</div>
|
onClick={() => handleReaderTap('left')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label='下一页'
|
||||||
|
className='absolute inset-y-0 right-0 z-10 w-[28%] cursor-pointer bg-transparent'
|
||||||
|
onClick={() => handleReaderTap('right')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div ref={viewerRef} className='h-full w-full' style={{ backgroundColor: THEME_STYLES[settings.theme].panelBg }} />
|
<div ref={viewerRef} className='h-full w-full' style={{ backgroundColor: THEME_STYLES[settings.theme].panelBg }} />
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
|
|||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='group relative'>
|
<div className='group relative'>
|
||||||
<div className='truncate text-sm font-semibold sm:text-base'>{meta.title}</div>
|
<div className='truncate text-sm font-semibold sm:text-base'>{meta.title}</div>
|
||||||
<div className='absolute left-1/2 top-full z-[100] mt-2 w-max max-w-[85vw] -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-center text-sm text-white opacity-0 invisible shadow-xl transition-all duration-200 ease-out pointer-events-none group-hover:visible group-hover:opacity-100 dark:bg-gray-900 sm:max-w-none sm:whitespace-nowrap'>
|
<div className='absolute left-1/2 top-full z-[100] mt-2 w-max max-w-[85vw] -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-center text-sm text-white opacity-0 invisible shadow-xl transition-all duration-200 ease-out pointer-events-none group-hover:visible group-hover:opacity-100 dark:bg-gray-900'>
|
||||||
<div className='break-words whitespace-normal sm:whitespace-nowrap'>{meta.title}</div>
|
<div className='max-w-[85vw] break-words whitespace-normal sm:max-w-none sm:whitespace-nowrap'>{meta.title}</div>
|
||||||
|
{meta.subtitle ? <div className='mt-1 max-w-[85vw] break-words whitespace-normal text-xs text-gray-300 sm:max-w-none sm:whitespace-nowrap'>{meta.subtitle}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>{meta.subtitle}</div>
|
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>{meta.subtitle}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user