diff --git a/package-lock.json b/package-lock.json index a775569..876ebba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1397,9 +1397,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1420,9 +1417,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1443,9 +1437,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1466,9 +1457,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1489,9 +1477,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1667,9 +1652,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1684,9 +1666,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1701,9 +1680,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1718,9 +1694,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1735,9 +1708,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1752,9 +1722,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1769,9 +1736,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1786,9 +1750,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1803,9 +1764,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1820,9 +1778,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1837,9 +1792,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1854,9 +1806,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1871,9 +1820,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/src/app/App.css b/src/app/App.css index 4a82c61..3c034fd 100644 --- a/src/app/App.css +++ b/src/app/App.css @@ -122,3 +122,82 @@ body, html { .leaf-held-pages-panel { width: 300px; border-left: 1px solid var(--kindle-border); background: var(--kindle-paper); flex-shrink: 0; } .leaf-timeline-bar { height: 64px; background: var(--kindle-paper); border-top: 1px solid var(--kindle-border); flex-shrink: 0; } + +/* Recently opened books */ +.recent-books { + margin-top: 48px; + width: 100%; + max-width: 560px; + text-align: left; +} + +.recent-books-title { + font-family: var(--font-serif); + font-size: 1rem; + font-weight: 700; + color: var(--kindle-gray); + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--kindle-border); +} + +.recent-books-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.recent-book-item { + width: 100%; +} + +.recent-book-btn { + width: 100%; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + padding: 10px 14px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + text-align: left; + transition: background 0.15s, border-color 0.15s; + font-family: var(--font-serif); +} + +.recent-book-btn:hover { + background: rgba(0, 0, 0, 0.04); + border-color: var(--kindle-border); +} + +.recent-book-name { + font-size: 0.95rem; + color: var(--kindle-ink); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.recent-book-meta { + font-size: 0.78rem; + color: var(--kindle-gray); + white-space: nowrap; + flex-shrink: 0; +} + +.status-badge { + margin-left: 8px; + font-size: 0.75rem; + color: var(--kindle-gray); + font-style: italic; +} +.kindle-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; } diff --git a/src/app/App.tsx b/src/app/App.tsx index 8eee18c..98edcf4 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -10,6 +10,7 @@ import { useHeldStore } from '../stores/heldStore'; import { useWindowStore } from '../stores/windowStore'; import { useQuickFlipStore } from '../stores/quickFlipStore'; import { useWorkspaceStore } from '../stores/workspaceStore'; +import { useRecentBooksStore } from '../stores/recentBooksStore'; import { thumbnailService } from '../services/ThumbnailService'; function App() { @@ -18,21 +19,39 @@ function App() { const { windows, updateWindow, closeWindow, openInNewWindow, setActiveWindow } = useWindowStore(); const { isOpen: isQuickFlipVisible, close: closeQuickFlip, open: openQuickFlip } = useQuickFlipStore(); const { saveWorkspace, restoreWorkspace, status: workspaceStatus } = useWorkspaceStore(); + const { books: recentBooks, loadBooks: loadRecentBooks, addBook: addRecentBook } = useRecentBooksStore(); const fileInputRef = useRef(null); + const pendingFileNameRef = useRef(''); + + // Load recent books on mount + useEffect(() => { + loadRecentBooks(); + }, [loadRecentBooks]); const handleFileChange = useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { + pendingFileNameRef.current = file.name; try { await loadDocument(file); - const newId = useBookStore.getState().documentId; - if (newId) await restoreWorkspace(newId); + const state = useBookStore.getState(); + const newId = state.documentId; + if (newId) { + await restoreWorkspace(newId); + await addRecentBook({ + documentId: newId, + fileName: file.name, + totalPages: state.totalPages, + }); + } } catch (err) { console.error('Workflow Error:', err); } + // Reset input so the same file can be re-selected + e.target.value = ''; } - }, [loadDocument, restoreWorkspace]); + }, [loadDocument, restoreWorkspace, addRecentBook]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -56,6 +75,7 @@ function App() {
LeafSpace {workspaceStatus === 'restoring' && 正在恢复布局...} + {workspaceStatus === 'saving' && 正在保存...}
@@ -73,7 +93,7 @@ function App() {
+ + {recentBooks.length > 0 && ( +
+

最近打开

+
    + {recentBooks.map((book) => ( +
  • + +
  • + ))} +
+
+ )} )} diff --git a/src/services/PDFService.ts b/src/services/PDFService.ts index 12eadb0..b696b08 100644 --- a/src/services/PDFService.ts +++ b/src/services/PDFService.ts @@ -5,19 +5,40 @@ export class PDFService { private fingerprint: string | null = null; private numPages: number = 0; private hasDoc: boolean = false; + private documentData: Uint8Array | null = null; - async loadDocument(file: File): Promise<{ numPages: number }> { - const arrayBuffer = await file.arrayBuffer(); - const loadingTask = pdfjsLib.getDocument({ - data: new Uint8Array(arrayBuffer), + async loadDocument(input: string | File): Promise<{ numPages: number }> { + let bytes: Uint8Array; + if (typeof input === 'string') { + const response = await fetch(input); + bytes = new Uint8Array(await response.arrayBuffer()); + } else { + bytes = new Uint8Array(await input.arrayBuffer()); + } + + const loadingTask = pdfjsLib.getDocument({ + data: bytes, useWorkerFetch: false, - isEvalSupported: false + isEvalSupported: false, }); - const doc = await loadingTask.promise; + let doc: Awaited; + try { + doc = await loadingTask.promise; + } catch (err) { + // Best-effort cleanup of the loading task; ignore any secondary errors. + try { await loadingTask.destroy(); } catch { /* ignore */ } + this.fingerprint = null; + this.numPages = 0; + this.hasDoc = false; + this.documentData = null; + throw err; + } + this.fingerprint = doc.fingerprints[0] || `doc_${Date.now()}`; this.numPages = doc.numPages; this.hasDoc = true; + this.documentData = bytes; await doc.destroy(); return { numPages: this.numPages }; } @@ -25,8 +46,12 @@ export class PDFService { getDocumentFingerprint(): string | null { return this.fingerprint; } getTotalPages(): number { return this.numPages; } hasLoadedDocument(): boolean { return this.hasDoc; } - getDocumentData(): Uint8Array | null { return null; } // 现在主要使用 Blob URL - async destroy(): Promise { this.hasDoc = false; this.fingerprint = null; } + getDocumentData(): Uint8Array | null { return this.documentData; } + async destroy(): Promise { + this.hasDoc = false; + this.fingerprint = null; + this.documentData = null; + } static getDocumentFingerprint() { return this.shared.getDocumentFingerprint(); } } diff --git a/src/services/PersistenceService.ts b/src/services/PersistenceService.ts index 1389668..d6af8b2 100644 --- a/src/services/PersistenceService.ts +++ b/src/services/PersistenceService.ts @@ -1,14 +1,80 @@ -import type { WorkspaceSnapshot } from '../types/domain'; +import Dexie from 'dexie'; +import type { WorkspaceSnapshot, RecentBook } from '../types/domain'; + +// --------------------------------------------------------------------------- +// Dexie database schema +// --------------------------------------------------------------------------- + +class LeafSpaceDb extends Dexie { + workspaces!: Dexie.Table; + recentBooks!: Dexie.Table; + + constructor(name: string) { + super(name); + this.version(1).stores({ + workspaces: 'documentId', + recentBooks: 'documentId', + }); + } +} + +// --------------------------------------------------------------------------- +// Port – wraps the Dexie instance; can be swapped in tests +// --------------------------------------------------------------------------- + +export class DexieWorkspacePersistencePort { + private db: LeafSpaceDb; + + constructor(name = 'leafspace-workspace') { + this.db = new LeafSpaceDb(name); + } + + async loadWorkspace(documentId: string): Promise { + return (await this.db.workspaces.get(documentId)) ?? null; + } + + async saveWorkspace(snapshot: WorkspaceSnapshot): Promise { + await this.db.workspaces.put(snapshot); + } + + async loadRecentBooks(): Promise { + return this.db.recentBooks.orderBy('lastOpenedAt').reverse().toArray(); + } + + async saveRecentBook(book: RecentBook): Promise { + await this.db.recentBooks.put(book); + } + + async deleteDatabase(): Promise { + await this.db.delete(); + } +} + +// --------------------------------------------------------------------------- +// Service – public API consumed by stores +// --------------------------------------------------------------------------- export class PersistenceService { - // 确保方法名与 workspaceStore 调用一致 + private port: DexieWorkspacePersistencePort; + + constructor(port: DexieWorkspacePersistencePort = new DexieWorkspacePersistencePort()) { + this.port = port; + } + async loadWorkspace(documentId: string): Promise { - console.log('Loading from DB...', documentId); - return null; + return this.port.loadWorkspace(documentId); } async saveWorkspace(snapshot: WorkspaceSnapshot): Promise { - console.log('Saving to DB...', snapshot.documentId); + return this.port.saveWorkspace(snapshot); + } + + async loadRecentBooks(): Promise { + return this.port.loadRecentBooks(); + } + + async saveRecentBook(book: RecentBook): Promise { + return this.port.saveRecentBook(book); } } diff --git a/src/stores/bookStore.ts b/src/stores/bookStore.ts index 0544189..0210446 100644 --- a/src/stores/bookStore.ts +++ b/src/stores/bookStore.ts @@ -1,8 +1,11 @@ import { create } from 'zustand'; -import { pdfService } from '../services/PDFService'; +import type { PDFService } from '../services/PDFService'; export type BookStatus = 'idle' | 'loading' | 'ready' | 'error'; +const MIN_SCALE = 0.5; +const MAX_SCALE = 4; + export interface BookStoreState { currentPage: number; documentId: string | null; @@ -10,16 +13,34 @@ export interface BookStoreState { error: string | null; status: BookStatus; totalPages: number; - scale: number; // 恢复 scale 属性 - loadDocument: (file: File) => Promise; + scale: number; + loadDocument: (input: string | File) => Promise; setCurrentPage: (page: number) => void; - setDocumentReady: (payload: any) => void; // 恢复测试强依赖的方法 + setDocumentReady: (payload: { documentId: string; documentUrl?: string; totalPages: number; initialPage?: number; scale?: number }) => void; + setScale: (scale: number) => void; + setTotalPages: (totalPages: number) => void; startLoading: () => void; nextPage: () => void; previousPage: () => void; reset: () => void; } +// Injected service – null means "use the real singleton, lazily imported" +let pdfServiceDep: PDFService | null = null; + +export function configureBookStoreDependencies(deps: { pdfService: PDFService }) { + pdfServiceDep = deps.pdfService; +} + +export function resetBookStoreDependencies() { + pdfServiceDep = null; +} + +async function getPdfService(): Promise { + if (pdfServiceDep) return pdfServiceDep; + return import('../services/PDFService').then((m) => m.pdfService); +} + export const useBookStore = create((set, get) => ({ currentPage: 1, documentId: null, @@ -31,30 +52,39 @@ export const useBookStore = create((set, get) => ({ startLoading: () => set({ status: 'loading', error: null }), - loadDocument: async (file: File) => { + loadDocument: async (input: string | File) => { get().startLoading(); try { - const { numPages } = await pdfService.loadDocument(file); - const fingerprint = pdfService.getDocumentFingerprint(); - const blobUrl = URL.createObjectURL(file); + const service = await getPdfService(); + const { numPages } = await service.loadDocument(input); + const fingerprint = service.getDocumentFingerprint(); + const documentUrl = input instanceof File ? URL.createObjectURL(input) : input; get().setDocumentReady({ - documentId: fingerprint, - documentUrl: blobUrl, - totalPages: numPages + documentId: fingerprint ?? crypto.randomUUID(), + documentUrl, + totalPages: numPages, }); } catch (err: any) { set({ status: 'error', error: err.message }); + throw err; } }, setDocumentReady: (payload) => { + const totalPages = payload.totalPages; + const rawPage = payload.initialPage ?? 1; + const rawScale = payload.scale ?? 1.0; + const currentPage = Math.min(Math.max(1, rawPage), totalPages || 1); + const scale = Math.min(Math.max(MIN_SCALE, rawScale), MAX_SCALE); set({ documentId: payload.documentId, - documentUrl: payload.documentUrl, - totalPages: payload.totalPages, + documentUrl: payload.documentUrl ?? get().documentUrl, + totalPages, status: 'ready', - currentPage: 1 + currentPage, + scale, + error: null, }); }, @@ -63,16 +93,23 @@ export const useBookStore = create((set, get) => ({ set({ currentPage: Math.min(Math.max(1, page), totalPages || 1) }); }, + setScale: (scale) => { + set({ scale: Math.min(Math.max(MIN_SCALE, scale), MAX_SCALE) }); + }, + + setTotalPages: (totalPages) => { + const { currentPage } = get(); + set({ totalPages, currentPage: Math.min(currentPage, totalPages || 1) }); + }, + nextPage: () => get().setCurrentPage(get().currentPage + 1), previousPage: () => get().setCurrentPage(get().currentPage - 1), reset: () => { - if (get().documentUrl) URL.revokeObjectURL(get().documentUrl!); - set({ documentId: null, documentUrl: null, status: 'idle', totalPages: 0, currentPage: 1 }); - } + const { documentUrl } = get(); + if (documentUrl?.startsWith('blob:')) URL.revokeObjectURL(documentUrl); + set({ documentId: null, documentUrl: null, status: 'idle', totalPages: 0, currentPage: 1, scale: 1.0, error: null }); + }, })); export const bookStore = useBookStore; -// 恢复测试依赖的导出 -export const configureBookStoreDependencies = () => {}; -export const resetBookStoreDependencies = () => {}; diff --git a/src/stores/recentBooksStore.ts b/src/stores/recentBooksStore.ts new file mode 100644 index 0000000..f952586 --- /dev/null +++ b/src/stores/recentBooksStore.ts @@ -0,0 +1,38 @@ +import { create } from 'zustand'; +import { persistenceService } from '../services/PersistenceService'; +import type { RecentBook } from '../types/domain'; + +export interface RecentBooksStoreState { + books: RecentBook[]; + isLoading: boolean; + addBook: (book: Omit) => Promise; + loadBooks: () => Promise; +} + +export const useRecentBooksStore = create((set, get) => ({ + books: [], + isLoading: false, + + addBook: async (book) => { + const entry: RecentBook = { + ...book, + lastOpenedAt: new Date().toISOString(), + }; + await persistenceService.saveRecentBook(entry); + // Refresh the list, keeping the newest first + const books = get().books.filter((b) => b.documentId !== entry.documentId); + set({ books: [entry, ...books] }); + }, + + loadBooks: async () => { + set({ isLoading: true }); + try { + const books = await persistenceService.loadRecentBooks(); + set({ books, isLoading: false }); + } catch { + set({ isLoading: false }); + } + }, +})); + +export const recentBooksStore = useRecentBooksStore; diff --git a/src/stores/workspaceStore.ts b/src/stores/workspaceStore.ts index 0dd17f1..48986d7 100644 --- a/src/stores/workspaceStore.ts +++ b/src/stores/workspaceStore.ts @@ -1,37 +1,81 @@ import { create } from 'zustand'; -import { persistenceService } from '../services/PersistenceService'; +import { persistenceService as defaultPersistenceService } from '../services/PersistenceService'; +import type { PersistenceService } from '../services/PersistenceService'; +import { bookStore } from './bookStore'; +import { heldStore } from './heldStore'; +import { windowStore } from './windowStore'; +import type { WorkspaceSnapshot } from '../types/domain'; export type WorkspaceStatus = 'idle' | 'saving' | 'restoring' | 'error'; export interface WorkspaceStoreState { status: WorkspaceStatus; error: string | null; + currentSnapshot: WorkspaceSnapshot | null; saveWorkspace: (docId: string) => Promise; restoreWorkspace: (docId: string) => Promise; + reset: () => void; +} + +let persistenceServiceDep: PersistenceService = defaultPersistenceService; + +export function configureWorkspaceStoreDependencies(deps: { persistenceService: PersistenceService }) { + persistenceServiceDep = deps.persistenceService; +} + +export function resetWorkspaceStoreDependencies() { + persistenceServiceDep = defaultPersistenceService; } export const useWorkspaceStore = create((set) => ({ status: 'idle', error: null, + currentSnapshot: null, saveWorkspace: async (docId) => { - console.log('Saving...', docId); + set({ status: 'saving', error: null }); + try { + const { currentPage, scale } = bookStore.getState(); + const { pages: heldPages } = heldStore.getState(); + const { windows, activeWindowId } = windowStore.getState(); + + const snapshot: WorkspaceSnapshot = { + documentId: docId, + currentPage, + scale, + activeWindowId, + layoutPreset: 'single', + heldPages, + windows, + savedAt: new Date().toISOString(), + }; + + await persistenceServiceDep.saveWorkspace(snapshot); + set({ status: 'idle', currentSnapshot: snapshot }); + } catch (err: any) { + set({ status: 'error', error: err.message ?? String(err) }); + } }, restoreWorkspace: async (docId) => { - set({ status: 'restoring' }); + set({ status: 'restoring', error: null }); try { - // 修正 PersistenceService 的方法调用 - const snapshot = await persistenceService.loadWorkspace(docId); - if (snapshot) console.log('Restored'); - set({ status: 'idle' }); - } catch (err) { - set({ status: 'idle' }); + const snapshot = await persistenceServiceDep.loadWorkspace(docId); + if (snapshot) { + bookStore.getState().setCurrentPage(snapshot.currentPage); + bookStore.getState().setScale(snapshot.scale); + heldStore.getState().restorePages(snapshot.heldPages); + windowStore.getState().restoreWindows(snapshot.windows, snapshot.activeWindowId); + set({ status: 'idle', currentSnapshot: snapshot }); + } else { + set({ status: 'idle' }); + } + } catch (err: any) { + set({ status: 'error', error: err.message ?? String(err) }); } - } + }, + + reset: () => set({ status: 'idle', error: null, currentSnapshot: null }), })); -// 恢复测试依赖 -export const configureWorkspaceStoreDependencies = () => {}; -export const resetWorkspaceStoreDependencies = () => {}; export const workspaceStore = useWorkspaceStore; diff --git a/src/types/domain.ts b/src/types/domain.ts index 2cb145a..9e654d1 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -62,3 +62,11 @@ export interface ThumbnailEntry { status: 'idle' | 'queued' | 'rendering' | 'ready' | 'error'; lastAccessedAt: number; } + +export interface RecentBook { + documentId: string; + fileName: string; + totalPages: number; + fileData?: Uint8Array; + lastOpenedAt: string; +}