diff --git a/.gitignore b/.gitignore index c8c1c21..65ff215 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ data # Large local TXT fixtures for resume E2E (not shipped) samples/TestFile.txt samples/*.local.txt +# Local comic/book fixtures for manual / optional nav tests +samples/*.zip +samples/*.cbz diff --git a/README.md b/README.md index 73a8cd7..e32d43d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Built with Electron, TypeScript, React, pdf.js, and epub.js. ### Opening & library - Open books via **File → Open**, toolbar **Open**, or **drag and drop** - **Comics:** `.zip` / `.cbz` (images inside) or an **image folder** (File → Open Folder) -- **Recent books** (up to 20) with Missing badge and Remove +- **Recent books** (up to 20) with Missing badge, Remove, and **Clear All History** - **Home (file list):** options toolbar is **hidden automatically**; it returns when a book is open (respects Pin) ### Reading modes @@ -51,7 +51,7 @@ Built with Electron, TypeScript, React, pdf.js, and epub.js. | Larger / smaller font | **Ctrl+Plus** / **Ctrl+-** | | Next / previous page | Arrows **→/←** (LTR) or **←/→** (RTL comics) | | First / last page | **Home** / **End** | -| Next / previous book | **PageDown** / **PageUp** (same-folder volume: filename number ±1; tries `.zip`/`.cbz`/`.pdf`/`.epub`/`.txt`) | +| Next / previous book | **PageDown** / **PageUp** (same folder, natural name order among `.zip`/`.cbz`/`.pdf`/`.epub`/`.txt`/images) | | Go to page | Digits in **Page**, then **Enter** or **Go** (focus returns to reader) | | Pan zoomed image/PDF | **Left-click drag** | | Single / two pages | **Ctrl+1** / **Ctrl+2** | diff --git a/electron/comicSession.ts b/electron/comicSession.ts index dfe65b2..0455ffa 100644 --- a/electron/comicSession.ts +++ b/electron/comicSession.ts @@ -1,40 +1,129 @@ import fs from 'node:fs'; import path from 'node:path'; -import JSZip from 'jszip'; +import yauzl from 'yauzl'; +import type { Entry, ZipFile } from 'yauzl'; import { filterAndSortImagePaths, isImageFile } from '../src/shared/comic'; export interface ComicSession { sourcePath: string; entries: string[]; kind: 'archive' | 'folder'; - zip?: JSZip; + zipfile?: ZipFile; + zipEntryByName?: Map; } let session: ComicSession | null = null; +/** yauzl allows only one openReadStream at a time per zipfile. */ +let zipReadChain: Promise = Promise.resolve(); export function clearComicSession(): void { + const zipfile = session?.zipfile; session = null; + zipReadChain = Promise.resolve(); + if (zipfile) { + try { + zipfile.close(); + } catch { + /* already closed */ + } + } } export function getComicSession(): ComicSession | null { return session; } -export async function openComicArchive(filePath: string): Promise { - const data = fs.readFileSync(filePath); - const zip = await JSZip.loadAsync(data); - const names: string[] = []; - zip.forEach((relativePath, file) => { - if (file.dir) return; - const base = relativePath.replace(/\\/g, '/'); - if (base.includes('__MACOSX/')) return; - if (isImageFile(base)) names.push(base); +function openZipFile(filePath: string): Promise { + return new Promise((resolve, reject) => { + yauzl.open(filePath, { lazyEntries: true, autoClose: false }, (error, zipfile) => { + if (error || !zipfile) { + reject(error ?? new Error('Failed to open zip archive.')); + return; + } + resolve(zipfile); + }); }); - const entries = filterAndSortImagePaths(names); - if (entries.length === 0) { +} + +function listImageEntries(zipfile: ZipFile): Promise { + return new Promise((resolve, reject) => { + const found: Entry[] = []; + zipfile.on('error', reject); + zipfile.on('entry', (entry: Entry) => { + const base = entry.fileName.replace(/\\/g, '/'); + if (!base.endsWith('/') && !base.includes('__MACOSX/') && isImageFile(base)) { + found.push(entry); + } + zipfile.readEntry(); + }); + zipfile.on('end', () => resolve(found)); + zipfile.readEntry(); + }); +} + +function readZipEntryBuffer(zipfile: ZipFile, entry: Entry): Promise { + return new Promise((resolve, reject) => { + zipfile.openReadStream(entry, (error, stream) => { + if (error || !stream) { + reject(error ?? new Error('Failed to read zip entry.')); + return; + } + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks))); + }); + }); +} + +/** + * Open a ZIP/CBZ without loading the whole file into memory. + * Only the central directory is scanned for image entry names; page bytes are read on demand. + */ +export async function openComicArchive(filePath: string): Promise { + const zipfile = await openZipFile(filePath); + let imageEntries: Entry[]; + try { + imageEntries = await listImageEntries(zipfile); + } catch (error) { + try { + zipfile.close(); + } catch { + /* ignore */ + } + throw error; + } + + const names = filterAndSortImagePaths(imageEntries.map((entry) => entry.fileName.replace(/\\/g, '/'))); + if (names.length === 0) { + try { + zipfile.close(); + } catch { + /* ignore */ + } throw new Error('No image files found in the archive.'); } - session = { sourcePath: filePath, entries, kind: 'archive', zip }; + + const byNorm = new Map(); + for (const entry of imageEntries) { + byNorm.set(entry.fileName.replace(/\\/g, '/'), entry); + } + const zipEntryByName = new Map(); + for (const name of names) { + const entry = byNorm.get(name); + if (entry) zipEntryByName.set(name, entry); + } + + session = { + sourcePath: filePath, + entries: names, + kind: 'archive', + zipfile, + zipEntryByName, + }; + zipReadChain = Promise.resolve(); return session; } @@ -54,20 +143,36 @@ export function openComicFolder(folderPath: string): ComicSession { return session; } +/** Open a single image file as a one-page comic (folder-order nav sibling). */ +export function openComicImageFile(filePath: string): ComicSession { + if (!isImageFile(filePath)) { + throw new Error('Not an image file.'); + } + session = { sourcePath: filePath, entries: [filePath], kind: 'folder' }; + return session; +} + export async function readComicPage(index: number): Promise { if (!session) throw new Error('No comic is open.'); - const entry = session.entries[index]; - if (!entry) throw new Error('Page out of range.'); + const entryName = session.entries[index]; + if (!entryName) throw new Error('Page out of range.'); if (session.kind === 'folder') { - const buf = fs.readFileSync(entry); + const buf = fs.readFileSync(entryName); return toArrayBuffer(buf); } - const file = session.zip?.file(entry); - if (!file) throw new Error('Missing page in archive.'); - const u8 = await file.async('uint8array'); - return toArrayBuffer(u8); + const zipfile = session.zipfile; + const entry = session.zipEntryByName?.get(entryName); + if (!zipfile || !entry) throw new Error('Missing page in archive.'); + + const read = zipReadChain.then(() => readZipEntryBuffer(zipfile, entry)); + zipReadChain = read.then( + () => undefined, + () => undefined, + ); + const buf = await read; + return toArrayBuffer(buf); } function toArrayBuffer(view: Uint8Array): ArrayBuffer { diff --git a/electron/main.ts b/electron/main.ts index 5b7fd5f..2a2aa36 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -10,7 +10,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { buildBookId } from '../src/shared/bookId'; import { detectFormat, getBookTitle, isSupportedBookFile } from '../src/shared/format'; -import { seriesSiblingBasenames } from '../src/shared/seriesSibling'; +import { isImageFile } from '../src/shared/comic'; +import { + isNavigableBookFileName, + resolveFolderSiblingBasename, +} from '../src/shared/seriesSibling'; import { GITHUB_LATEST_RELEASE_API, GITHUB_RELEASES_URL, @@ -22,6 +26,7 @@ import { clearComicSession, openComicArchive, openComicFolder, + openComicImageFile, readComicPage, } from './comicSession'; import { clearTxtSession, openTxtSession, readTxtPage } from './txtSession'; @@ -181,16 +186,18 @@ async function openBookFromPath(filePath: string): Promise { + return store.clearRecent(); + }); + ipcMain.handle('books:saveSettings', (_event, partial: Partial) => { return store.saveSettings(partial); }); diff --git a/electron/preload.ts b/electron/preload.ts index bf183cc..b5349ec 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -36,6 +36,7 @@ export interface ElectronApi { lastByteOffset?: number, ) => Promise; removeRecent: (idOrPath: string) => Promise; + clearRecent: () => Promise; saveSettings: (partial: Partial) => Promise; checkForUpdates: () => Promise; openReleasesPage: (url?: string) => Promise; @@ -63,6 +64,7 @@ const api: ElectronApi = { lastByteOffset, ), removeRecent: (idOrPath) => ipcRenderer.invoke('books:removeRecent', idOrPath), + clearRecent: () => ipcRenderer.invoke('books:clearRecent'), saveSettings: (partial) => ipcRenderer.invoke('books:saveSettings', partial), checkForUpdates: () => ipcRenderer.invoke('updates:check'), openReleasesPage: (url?: string) => ipcRenderer.invoke('updates:openReleases', url), diff --git a/electron/store.ts b/electron/store.ts index e02c2bd..651aa83 100644 --- a/electron/store.ts +++ b/electron/store.ts @@ -5,6 +5,7 @@ import { mergeSettings } from '../src/shared/settings'; import { markMissingBooks, removeRecentBook, + clearRecentBooks, updateRecentProgress, upsertRecentBook, } from '../src/shared/recent'; @@ -80,6 +81,12 @@ export class AppStore { return [...this.state.recentBooks]; } + clearRecent(): RecentBook[] { + this.state.recentBooks = clearRecentBooks(); + this.persist(); + return [...this.state.recentBooks]; + } + private load(): AppState { try { if (!fs.existsSync(this.filePath)) { diff --git a/package-lock.json b/package-lock.json index 22c85de..30ba485 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,26 +1,26 @@ { "name": "all-book-reader", - "version": "1.0.4", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "all-book-reader", - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "dependencies": { "epubjs": "^0.3.93", - "jszip": "^3.10.1", "pdfjs-dist": "^4.10.38", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "yauzl": "^3.4.0" }, "devDependencies": { "@eslint/js": "^9.17.0", - "@types/jszip": "^3.4.0", "@types/node": "^22.10.2", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@types/yauzl": "^3.4.0", "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.1.0", "electron": "^33.2.1", @@ -2525,16 +2525,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/jszip": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz", - "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "jszip": "*" - } - }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -2631,12 +2621,11 @@ "optional": true }, "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "@types/node": "*" } @@ -5442,6 +5431,28 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/extract-zip/node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/extract-zip/node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/extsprintf": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", @@ -7649,7 +7660,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -10297,14 +10307,15 @@ } }, "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", "license": "MIT", "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" } }, "node_modules/yocto-queue": { diff --git a/package.json b/package.json index 437b1a4..44b9e5c 100644 --- a/package.json +++ b/package.json @@ -19,17 +19,17 @@ }, "dependencies": { "epubjs": "^0.3.93", - "jszip": "^3.10.1", "pdfjs-dist": "^4.10.38", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "yauzl": "^3.4.0" }, "devDependencies": { "@eslint/js": "^9.17.0", - "@types/jszip": "^3.4.0", "@types/node": "^22.10.2", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@types/yauzl": "^3.4.0", "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.1.0", "electron": "^33.2.1", diff --git a/scripts/smoke-comic-open.mjs b/scripts/smoke-comic-open.mjs new file mode 100644 index 0000000..48e7689 --- /dev/null +++ b/scripts/smoke-comic-open.mjs @@ -0,0 +1,106 @@ +/** + * Local smoke: open each samples/*.zip via the same comicSession path as Electron main. + * Run: node scripts/smoke-comic-open.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const { + openComicArchive, + clearComicSession, + readComicPage, +} = require(path.join(root, 'dist-electron/electron/comicSession.js')); + +const samplesDir = path.join(root, 'samples'); +const zips = fs + .readdirSync(samplesDir) + .filter((name) => name.toLowerCase().endsWith('.zip')) + .sort((a, b) => a.localeCompare(b, 'ko')); + +if (zips.length === 0) { + console.error('No sample zips found in samples/'); + process.exit(1); +} + +const OPEN_BUDGET_MS = 500; +const FIRST_PAGE_BUDGET_MS = 2000; +const WARM10_BUDGET_MS = 15000; + +const results = []; + +for (const name of zips) { + const full = path.join(samplesDir, name); + const sizeMb = +(fs.statSync(full).size / (1024 * 1024)).toFixed(1); + + clearComicSession(); + const tOpen = Date.now(); + const session = await openComicArchive(full); + const openMs = Date.now() - tOpen; + + const tPage = Date.now(); + const page0 = await readComicPage(0); + const page0Ms = Date.now() - tPage; + + const warmCount = Math.min(10, session.entries.length); + const tWarm = Date.now(); + for (let i = 0; i < warmCount; i += 1) { + await readComicPage(i); + } + const warm10Ms = Date.now() - tWarm; + + clearComicSession(); + + const row = { + name, + sizeMb, + pages: session.entries.length, + openMs, + page0Ms, + page0Bytes: page0.byteLength, + warm10Ms, + openOk: openMs <= OPEN_BUDGET_MS, + page0Ok: page0Ms <= FIRST_PAGE_BUDGET_MS && page0.byteLength > 1000, + warmOk: warm10Ms <= WARM10_BUDGET_MS, + }; + results.push(row); + console.log(JSON.stringify(row)); +} + +// Simulate PageDown: open 05, then switch to 06(디카) measuring open+page0 +const vol05 = zips.find((n) => n.includes('05.zip')); +const vol06 = zips.find((n) => n.includes('06(')); +if (vol05 && vol06) { + clearComicSession(); + await openComicArchive(path.join(samplesDir, vol05)); + await readComicPage(0); + clearComicSession(); + const tSwitch = Date.now(); + const next = await openComicArchive(path.join(samplesDir, vol06)); + await readComicPage(0); + const switchMs = Date.now() - tSwitch; + clearComicSession(); + const switchRow = { + scenario: 'PageDown 05 -> 06(디카) open+page0', + switchMs, + pages: next.entries.length, + ok: switchMs <= OPEN_BUDGET_MS + FIRST_PAGE_BUDGET_MS, + }; + console.log(JSON.stringify(switchRow)); + results.push(switchRow); +} + +const failed = results.filter((r) => + 'openOk' in r ? !(r.openOk && r.page0Ok && r.warmOk) : r.ok === false, +); +console.log( + JSON.stringify({ + summary: failed.length === 0 ? 'PASS' : 'FAIL', + budgets: { OPEN_BUDGET_MS, FIRST_PAGE_BUDGET_MS, WARM10_BUDGET_MS }, + failed: failed.map((r) => r.name || r.scenario), + }), +); +process.exit(failed.length === 0 ? 0 : 1); diff --git a/src/App.tsx b/src/App.tsx index 630dbc5..9c2aaf3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { isSupportedBookFile } from './shared/format'; import { shouldShowUpdateBanner } from './shared/appUpdate'; import { arrowKeyPageDelta, + isImageFile, isReadingDirection, type ReadingDirection, } from './shared/comic'; @@ -115,10 +116,10 @@ export default function App() { const openPath = useCallback( async (filePath: string) => { - // Folders are allowed (comics); files must be a known extension. + // Folders are allowed (comics); files must be a known book or image extension. const looksLikeFile = /\.[^./\\]+$/.test(filePath); - if (looksLikeFile && !isSupportedBookFile(filePath)) { - setStatus('Only .txt, .pdf, .epub, .zip, and .cbz files are supported.'); + if (looksLikeFile && !isSupportedBookFile(filePath) && !isImageFile(filePath)) { + setStatus('Only .txt, .pdf, .epub, .zip, .cbz, and image files are supported.'); return; } await flushTxtProgress(); @@ -138,7 +139,7 @@ export default function App() { } const nextPath = await getApi().resolveSeriesSibling(book.path, delta); if (!nextPath) { - setStatus(delta > 0 ? 'No next volume found.' : 'No previous volume found.'); + setStatus(delta > 0 ? 'No next file found.' : 'No previous file found.'); return; } const result = await getApi().openPath(nextPath); @@ -432,7 +433,7 @@ export default function App() { if (!book) return; - // PageUp / PageDown: previous / next series volume (filename number ±1). + // PageUp / PageDown: previous / next file in the same folder (name order). if (event.key === 'PageDown') { event.preventDefault(); void openSeriesSibling(1); @@ -737,6 +738,18 @@ export default function App() { .removeRecent(item.id) .then(setRecentBooks); }} + onClearAll={() => { + if ( + !window.confirm( + 'Clear all recent books and saved reading positions? This cannot be undone.', + ) + ) { + return; + } + void getApi() + .clearRecent() + .then(setRecentBooks); + }} /> ) : (
@@ -793,6 +806,7 @@ export default function App() { )} {book.format === 'comic' && ( Promise; removeRecent: (idOrPath: string) => Promise; + clearRecent: () => Promise; saveSettings: (partial: Partial) => Promise; checkForUpdates: () => Promise; openReleasesPage: (url?: string) => Promise; diff --git a/src/comicPageCache.ts b/src/comicPageCache.ts index babca25..fce4acd 100644 --- a/src/comicPageCache.ts +++ b/src/comicPageCache.ts @@ -6,8 +6,14 @@ export interface ComicPageImage { naturalHeight: number; } -const cache = new Map(); -const inflight = new Map>(); +const cache = new Map(); +const inflight = new Map>(); +/** Bumped on clear so in-flight decodes from a previous book are discarded. */ +let cacheGeneration = 0; + +function pageKey(bookId: string, pageNumber: number): string { + return `${bookId}:${pageNumber}`; +} async function decodePageImage(pageNumber: number): Promise { const buffer = await getApi().readComicPage(pageNumber - 1); @@ -28,38 +34,46 @@ async function decodePageImage(pageNumber: number): Promise { } /** Load (or reuse) a decoded comic page. pageNumber is 1-based. */ -export function getComicPageImage(pageNumber: number): Promise { - const hit = cache.get(pageNumber); +export function getComicPageImage(bookId: string, pageNumber: number): Promise { + if (!bookId) return Promise.reject(new Error('Missing book id')); + const key = pageKey(bookId, pageNumber); + const hit = cache.get(key); if (hit) return Promise.resolve(hit); - let pending = inflight.get(pageNumber); + let pending = inflight.get(key); if (!pending) { + const gen = cacheGeneration; pending = decodePageImage(pageNumber) .then((image) => { - cache.set(pageNumber, image); - inflight.delete(pageNumber); + inflight.delete(key); + if (gen !== cacheGeneration) { + URL.revokeObjectURL(image.url); + throw new Error('Stale comic page decode'); + } + cache.set(key, image); return image; }) .catch((error) => { - inflight.delete(pageNumber); + inflight.delete(key); throw error; }); - inflight.set(pageNumber, pending); + inflight.set(key, pending); } return pending; } -/** Drop cached pages that are not in `keep` (1-based). */ -export function retainComicPages(keep: number[]): void { - const keepSet = new Set(keep); - for (const [pageNumber, image] of cache) { - if (keepSet.has(pageNumber)) continue; +/** Drop cached pages for `bookId` that are not in `keep` (1-based). Also drops other books. */ +export function retainComicPages(bookId: string, keep: number[]): void { + const keepSet = new Set(keep.map((pageNumber) => pageKey(bookId, pageNumber))); + for (const [key, image] of cache) { + if (keepSet.has(key)) continue; URL.revokeObjectURL(image.url); - cache.delete(pageNumber); + cache.delete(key); } } export function clearComicPageCache(): void { + cacheGeneration += 1; for (const image of cache.values()) { URL.revokeObjectURL(image.url); } diff --git a/src/components/ComicViewer.tsx b/src/components/ComicViewer.tsx index 9597a93..962ce5f 100644 --- a/src/components/ComicViewer.tsx +++ b/src/components/ComicViewer.tsx @@ -6,12 +6,13 @@ import { type ComicPageImage, } from '../comicPageCache'; import { comicImageDisplaySize, comicSpreadPages, type ReadingDirection } from '../shared/comic'; -import { comicPrefetchPages } from '../shared/comicPrefetch'; +import { comicInitialWarmPages, comicPrefetchPages } from '../shared/comicPrefetch'; import { measureReaderStage, stabilizeViewportSize } from '../readerViewport'; import { useReaderDragPan } from '../useReaderDragPan'; import type { FitMode, PageMode } from '../types'; interface ComicViewerProps { + bookId: string; page: number; totalPages: number; pageMode: PageMode; @@ -22,6 +23,7 @@ interface ComicViewerProps { } export function ComicViewer({ + bookId, page, totalPages, pageMode, @@ -63,6 +65,14 @@ export function ComicViewer({ return () => ro.disconnect(); }, [pageMode]); + // Drop previous book's decoded pages as soon as the book identity changes. + useEffect(() => { + clearComicPageCache(); + setLeftPage(null); + setRightPage(null); + return () => clearComicPageCache(); + }, [bookId]); + useEffect(() => { let cancelled = false; @@ -74,8 +84,8 @@ export function ComicViewer({ try { const [nextLeft, nextRight] = await Promise.all([ - pair.left != null ? getComicPageImage(pair.left) : Promise.resolve(null), - pair.right != null ? getComicPageImage(pair.right) : Promise.resolve(null), + pair.left != null ? getComicPageImage(bookId, pair.left) : Promise.resolve(null), + pair.right != null ? getComicPageImage(bookId, pair.right) : Promise.resolve(null), ]); if (cancelled) return; setLeftPage(nextLeft); @@ -88,9 +98,15 @@ export function ComicViewer({ return; } - const keep = comicPrefetchPages(page, totalPages, pageMode); - void Promise.all(keep.map((p) => getComicPageImage(p).catch(() => null))).then(() => { - if (!cancelled) retainComicPages(keep); + // Near pages for flipping + ~10-page warm window (after current page is on screen). + const keep = [ + ...new Set([ + ...comicPrefetchPages(page, totalPages, pageMode), + ...comicInitialWarmPages(page, totalPages, pageMode), + ]), + ].sort((a, b) => a - b); + void Promise.all(keep.map((p) => getComicPageImage(bookId, p).catch(() => null))).then(() => { + if (!cancelled) retainComicPages(bookId, keep); }); }; @@ -98,15 +114,11 @@ export function ComicViewer({ return () => { cancelled = true; }; - }, [page, pageMode, totalPages, readingDirection]); - - useEffect(() => { - return () => clearComicPageCache(); - }, []); + }, [bookId, page, pageMode, totalPages, readingDirection]); useEffect(() => { document.querySelector('.reader-stage')?.scrollTo({ top: 0, left: 0 }); - }, [zoom, page, pageMode]); + }, [bookId, zoom, page, pageMode]); const leftSize = leftPage && diff --git a/src/components/Home.tsx b/src/components/Home.tsx index b5889b4..17cdc6d 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -6,6 +6,7 @@ interface HomeProps { onOpenFolder: () => void; onOpenBook: (book: RecentBook) => void; onRemove: (book: RecentBook) => void; + onClearAll: () => void; } export function Home({ @@ -14,6 +15,7 @@ export function Home({ onOpenFolder, onOpenBook, onRemove, + onClearAll, }: HomeProps) { return (
@@ -32,6 +34,11 @@ export function Home({ + {books.length > 0 && ( + + )}
Open a file or drop a book, ZIP/CBZ, or folder here.
diff --git a/src/components/PagePreviewStrip.tsx b/src/components/PagePreviewStrip.tsx index 226e792..d1b3ba6 100644 --- a/src/components/PagePreviewStrip.tsx +++ b/src/components/PagePreviewStrip.tsx @@ -125,7 +125,12 @@ export function PagePreviewStrip({ for (let p = range.start; p <= range.end; p += 1) pages.push(p); const load = async () => { - for (const p of pages) { + // Prefer the current page(s) so the strip isn't blank while neighbors load. + const ordered = [ + ...[...activePages].filter((p) => p >= range.start && p <= range.end), + ...pages.filter((p) => !activePages.has(p)), + ]; + for (const p of ordered) { if (cancelled) return; if (thumbs[p]) continue; try { @@ -150,7 +155,7 @@ export function PagePreviewStrip({ }; // thumbs intentionally omitted to avoid reload loops // eslint-disable-next-line react-hooks/exhaustive-deps - }, [range.start, range.end, format, bookId, pdfDoc]); + }, [range.start, range.end, format, bookId, pdfDoc, activePages]); const selectFromClientY = (clientY: number) => { const el = scrollerRef.current; diff --git a/src/shared/comicPrefetch.test.ts b/src/shared/comicPrefetch.test.ts index d14af6b..8da6fbf 100644 --- a/src/shared/comicPrefetch.test.ts +++ b/src/shared/comicPrefetch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { comicPrefetchPages } from './comicPrefetch'; +import { comicInitialWarmPages, comicPrefetchPages } from './comicPrefetch'; describe('comicPrefetchPages', () => { it('prefetches neighbors in single-page mode', () => { @@ -14,3 +14,16 @@ describe('comicPrefetchPages', () => { expect(comicPrefetchPages(5, 5, 'two', 1)).toEqual([3, 4, 5]); }); }); + +describe('comicInitialWarmPages', () => { + it('warms about 10 pages ahead from the opening page', () => { + expect(comicInitialWarmPages(1, 95, 'single')).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + ]); + expect(comicInitialWarmPages(50, 55, 'single')).toEqual([50, 51, 52, 53, 54, 55]); + }); + + it('aligns two-page warm start to the spread', () => { + expect(comicInitialWarmPages(5, 12, 'two', 4)).toEqual([5, 6, 7, 8]); + }); +}); diff --git a/src/shared/comicPrefetch.ts b/src/shared/comicPrefetch.ts index 44d4524..19cdd72 100644 --- a/src/shared/comicPrefetch.ts +++ b/src/shared/comicPrefetch.ts @@ -4,6 +4,9 @@ import type { PageMode } from '../types'; /** How many page-steps ahead/behind to keep warm (single: pages, two: spreads). */ export const COMIC_PREFETCH_RADIUS = 2; +/** After opening a comic, warm this many pages ahead so PageDown feels instant. */ +export const COMIC_INITIAL_WARM_COUNT = 10; + /** * 1-based page numbers to keep loaded around the current view. * Includes the current page (and spread partner in two-page mode). @@ -30,3 +33,23 @@ export function comicPrefetchPages( } return [...pages].sort((a, b) => a - b); } + +/** + * 1-based pages to decode in the background right after open / book switch + * so the next several turns are already warm (does not block first paint). + */ +export function comicInitialWarmPages( + page: number, + totalPages: number, + pageMode: PageMode, + count: number = COMIC_INITIAL_WARM_COUNT, +): number[] { + if (totalPages < 1 || count < 1) return []; + const start = + pageMode === 'two' ? spreadStartPage(page) : clampPage(page, totalPages); + const pages: number[] = []; + for (let p = start; p <= totalPages && pages.length < count; p += 1) { + pages.push(p); + } + return pages; +} diff --git a/src/shared/recent.test.ts b/src/shared/recent.test.ts index be6bbc3..4150206 100644 --- a/src/shared/recent.test.ts +++ b/src/shared/recent.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + clearRecentBooks, markMissingBooks, removeRecentBook, updateRecentProgress, @@ -56,6 +57,24 @@ describe('recent', () => { expect(updated[0]?.lastByteOffset).toBe(9000); }); + it('clears all recent books so reopen starts at page 1', () => { + const list = [ + book({ id: 'a', path: 'C:\\a.pdf', lastPage: 9, lastByteOffset: 1000 }), + book({ id: 'b', path: 'C:\\b.pdf', lastPage: 3 }), + ]; + const cleared = clearRecentBooks(); + expect(cleared).toEqual([]); + const reopened = upsertRecentBook(cleared, { + id: 'a', + path: 'C:\\a.pdf', + format: 'pdf', + }); + expect(reopened).toHaveLength(1); + expect(reopened[0]?.lastPage).toBe(1); + expect(reopened[0]?.lastByteOffset).toBeUndefined(); + expect(list).toHaveLength(2); + }); + it('marks missing books', () => { const list = [ book({ id: 'a', path: 'C:\\a.pdf' }), diff --git a/src/shared/recent.ts b/src/shared/recent.ts index a8b05d2..e14fe4b 100644 --- a/src/shared/recent.ts +++ b/src/shared/recent.ts @@ -90,3 +90,8 @@ export function markMissingBooks( export function removeRecentBook(list: RecentBook[], idOrPath: string): RecentBook[] { return list.filter((book) => book.id !== idOrPath && book.path !== idOrPath); } + +/** Remove every recent book (and all saved reading positions). */ +export function clearRecentBooks(): RecentBook[] { + return []; +} diff --git a/src/shared/seriesSibling.test.ts b/src/shared/seriesSibling.test.ts index 8906a77..af11595 100644 --- a/src/shared/seriesSibling.test.ts +++ b/src/shared/seriesSibling.test.ts @@ -1,60 +1,136 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { parseSeriesName, seriesSiblingBasenames } from './seriesSibling'; +import { + isNavigableBookFileName, + resolveFolderSiblingBasename, +} from './seriesSibling'; -describe('parseSeriesName', () => { - it('parses trailing digits and extension', () => { - expect(parseSeriesName('C:\\Books\\열혈강호45.zip')).toEqual({ - dir: 'C:\\Books', - prefix: '열혈강호', - digits: '45', - number: 45, - extension: '.zip', - }); +describe('isNavigableBookFileName', () => { + it('accepts book and image extensions', () => { + expect(isNavigableBookFileName('a.pdf')).toBe(true); + expect(isNavigableBookFileName('a.epub')).toBe(true); + expect(isNavigableBookFileName('a.txt')).toBe(true); + expect(isNavigableBookFileName('a.zip')).toBe(true); + expect(isNavigableBookFileName('a.cbz')).toBe(true); + expect(isNavigableBookFileName('a.jpg')).toBe(true); + expect(isNavigableBookFileName('a.PNG')).toBe(true); + expect(isNavigableBookFileName('a.docx')).toBe(false); }); +}); + +describe('resolveFolderSiblingBasename', () => { + const yolang = [ + '용랑전 2부 - 중원요란전 01.zip', + '용랑전 2부 - 중원요란전 02.zip', + '용랑전 2부 - 중원요란전 03.zip', + '용랑전 2부 - 중원요란전 04.zip', + '용랑전 2부 - 중원요란전 05.zip', + '용랑전 2부 - 중원요란전 06(디카).zip', + '용랑전 2부 - 중원요란전 07.zip', + '용랑전 2부 - 중원요란전 08.zip', + '용랑전 2부 - 중원요란전 09(번역).zip', + ]; - it('preserves zero-padding width in digits', () => { - expect(parseSeriesName('/tmp/vol09.cbz')?.digits).toBe('09'); + it('walks irregular suffixes by folder name order', () => { + expect(resolveFolderSiblingBasename('용랑전 2부 - 중원요란전 05.zip', yolang, 1)).toBe( + '용랑전 2부 - 중원요란전 06(디카).zip', + ); + expect(resolveFolderSiblingBasename('용랑전 2부 - 중원요란전 06(디카).zip', yolang, 1)).toBe( + '용랑전 2부 - 중원요란전 07.zip', + ); + expect(resolveFolderSiblingBasename('용랑전 2부 - 중원요란전 09(번역).zip', yolang, -1)).toBe( + '용랑전 2부 - 중원요란전 08.zip', + ); }); - it('returns null when stem has no trailing digits', () => { - expect(parseSeriesName('C:\\Books\\manual.pdf')).toBeNull(); + it('handles ranges and 화 suffixes', () => { + const names = [ + '열혈강호 638.zip', + '열혈강호 639.zip', + '열혈강호 640-641.zip', + '열혈강호 642.zip', + '열혈강호 643.zip', + '열혈강호 644화.zip', + '열혈강호 645화.zip', + ]; + expect(resolveFolderSiblingBasename('열혈강호 639.zip', names, 1)).toBe( + '열혈강호 640-641.zip', + ); + expect(resolveFolderSiblingBasename('열혈강호 640-641.zip', names, 1)).toBe( + '열혈강호 642.zip', + ); + expect(resolveFolderSiblingBasename('열혈강호 643.zip', names, 1)).toBe( + '열혈강호 644화.zip', + ); }); -}); -describe('seriesSiblingBasenames', () => { - it('increments number and prefers current extension then fallbacks', () => { - expect(seriesSiblingBasenames('C:\\Books\\열혈강호45.zip', 1)).toEqual([ - '열혈강호46.zip', - '열혈강호46.cbz', - '열혈강호46.pdf', - '열혈강호46.epub', - '열혈강호46.txt', - ]); + it('handles spaced suffix after volume number', () => { + const names = [ + '하백의 신부 10.zip', + '하백의 신부 11 - 디카.zip', + '하백의 신부 12.zip', + ]; + expect(resolveFolderSiblingBasename('하백의 신부 10.zip', names, 1)).toBe( + '하백의 신부 11 - 디카.zip', + ); + expect(resolveFolderSiblingBasename('하백의 신부 11 - 디카.zip', names, 1)).toBe( + '하백의 신부 12.zip', + ); }); - it('can resolve zip to pdf via fallback list (same stem)', () => { - const names = seriesSiblingBasenames('/manga/열혈강호45.zip', 1); - expect(names).toContain('열혈강호46.pdf'); - expect(names?.[0]).toBe('열혈강호46.zip'); + it('returns null at ends or for invalid delta / missing current', () => { + const names = ['a01.zip', 'a02.zip']; + expect(resolveFolderSiblingBasename('a01.zip', names, 0)).toBeNull(); + expect(resolveFolderSiblingBasename('a01.zip', names, -1)).toBeNull(); + expect(resolveFolderSiblingBasename('a02.zip', names, 1)).toBeNull(); + expect(resolveFolderSiblingBasename('missing.zip', names, 1)).toBeNull(); }); - it('keeps zero-padding when width allows', () => { - expect(seriesSiblingBasenames('/tmp/vol09.cbz', 1)?.[0]).toBe('vol10.cbz'); - expect(seriesSiblingBasenames('/tmp/vol09.cbz', -1)?.[0]).toBe('vol08.cbz'); + it('matches current basename case-insensitively', () => { + expect(resolveFolderSiblingBasename('Vol01.ZIP', ['vol01.zip', 'vol02.zip'], 1)).toBe( + 'vol02.zip', + ); }); - it('grows digit width past pad when needed', () => { - expect(seriesSiblingBasenames('/tmp/vol99.zip', 1)?.[0]).toBe('vol100.zip'); + it('can move between different supported extensions by name order', () => { + const names = ['chapter1.pdf', 'chapter2.epub', 'chapter3.zip', 'cover.jpg']; + expect(resolveFolderSiblingBasename('chapter1.pdf', names, 1)).toBe('chapter2.epub'); + expect(resolveFolderSiblingBasename('chapter3.zip', names, 1)).toBe('cover.jpg'); }); - it('returns null for invalid delta or missing previous volume', () => { - expect(seriesSiblingBasenames('/tmp/vol01.zip', 0)).toBeNull(); - expect(seriesSiblingBasenames('/tmp/vol01.zip', -1)).toBeNull(); - expect(seriesSiblingBasenames('/tmp/manual.pdf', 1)).toBeNull(); + it('matches Unicode-normalized basenames (NFC vs NFD)', () => { + const names = [ + '용랑전 2부 - 중원요란전 05.zip', + '용랑전 2부 - 중원요란전 06(디카).zip', + ]; + expect( + resolveFolderSiblingBasename(names[0]!.normalize('NFD'), names, 1), + ).toBe(names[1]); }); +}); + +describe('samples folder navigation (optional)', () => { + it('walks real 용랑전 zips in samples/ by name order', () => { + const samplesDir = path.resolve(__dirname, '../../samples'); + if (!fs.existsSync(samplesDir)) return; + + const entries = fs + .readdirSync(samplesDir) + .filter((name) => fs.statSync(path.join(samplesDir, name)).isFile()) + .filter((name) => isNavigableBookFileName(name)); + + // Local-only fixtures (gitignored). Skip on CI when zips are absent. + const yolang = entries.filter((name) => name.includes('용랑전') && name.endsWith('.zip')); + if (yolang.length < 9) return; + + const vol05 = yolang.find((name) => name.includes('05.zip')); + const vol06 = yolang.find((name) => name.includes('06(')); + const vol07 = yolang.find((name) => name.includes('07.zip')); + expect(vol05 && vol06 && vol07).toBeTruthy(); - it('prefers current pdf extension before zip fallback', () => { - expect(seriesSiblingBasenames('/tmp/book12.pdf', 1)?.[0]).toBe('book13.pdf'); - expect(seriesSiblingBasenames('/tmp/book12.pdf', 1)?.[1]).toBe('book13.zip'); + expect(resolveFolderSiblingBasename(vol05!, entries, 1)).toBe(vol06); + expect(resolveFolderSiblingBasename(vol06!, entries, 1)).toBe(vol07); + expect(resolveFolderSiblingBasename(vol06!, entries, -1)).toBe(vol05); }); }); diff --git a/src/shared/seriesSibling.ts b/src/shared/seriesSibling.ts index 2d44048..1ceafa0 100644 --- a/src/shared/seriesSibling.ts +++ b/src/shared/seriesSibling.ts @@ -1,85 +1,34 @@ -import { getExtension } from './format'; +import { naturalCompare, isImageFile } from './comic'; +import { isSupportedBookFile } from './format'; -/** Fallback order after preferring the current file's extension. */ -export const SERIES_EXTENSION_PRIORITY = [ - '.zip', - '.cbz', - '.pdf', - '.epub', - '.txt', -] as const; - -export interface SeriesNameParts { - /** Directory containing the current book (normalized separators preserved from input). */ - dir: string; - /** Filename stem without trailing digits (may be empty). */ - prefix: string; - /** Trailing digit run as written (preserves zero-padding width). */ - digits: string; - number: number; - /** Current extension including dot, or '' for folders / extensionless paths. */ - extension: string; -} - -function splitDirBase(filePath: string): { dir: string; base: string } { - const normalized = filePath.replace(/\\/g, '/'); - const slash = normalized.lastIndexOf('/'); - if (slash < 0) return { dir: '', base: normalized }; - return { dir: filePath.slice(0, slash), base: normalized.slice(slash + 1) }; +/** Book formats plus standalone image files eligible for PageUp/PageDown folder nav. */ +export function isNavigableBookFileName(fileName: string): boolean { + return isSupportedBookFile(fileName) || isImageFile(fileName); } -function formatSeriesNumber(n: number, padWidth: number): string { - const raw = String(n); - return raw.length >= padWidth ? raw : raw.padStart(padWidth, '0'); -} - -/** Parse trailing volume digits from a book path (file or folder basename). */ -export function parseSeriesName(filePath: string): SeriesNameParts | null { - const { dir, base } = splitDirBase(filePath); - if (!base || base === '.' || base === '..') return null; - - const extension = getExtension(filePath); - const stem = extension ? base.slice(0, -extension.length) : base; - const match = /^(.*?)(\d+)$/.exec(stem); - if (!match) return null; - - const prefix = match[1] ?? ''; - const digits = match[2] ?? ''; - const number = Number(digits); - if (!Number.isFinite(number) || number < 1) return null; - - return { dir, prefix, digits, number, extension }; +function entryKey(name: string): string { + return name.normalize('NFC').toLowerCase(); } /** - * Candidate basenames for the series sibling at `number + delta`. - * Prefer the current extension, then {@link SERIES_EXTENSION_PRIORITY}. + * Next/previous basename in the same folder by natural name order. + * `dirEntries` should already be filtered to navigable files (or sibling folders). */ -export function seriesSiblingBasenames( - filePath: string, +export function resolveFolderSiblingBasename( + currentBase: string, + dirEntries: string[], delta: number, -): string[] | null { +): string | null { if (!Number.isFinite(delta) || delta === 0) return null; - const parsed = parseSeriesName(filePath); - if (!parsed) return null; - - const next = parsed.number + delta; - if (next < 1) return null; - - const numStr = formatSeriesNumber(next, parsed.digits.length); - const stem = `${parsed.prefix}${numStr}`; - - const ordered: string[] = []; - const seen = new Set(); - const pushExt = (ext: string) => { - const key = ext.toLowerCase(); - if (seen.has(key)) return; - seen.add(key); - ordered.push(`${stem}${ext}`); - }; + if (!currentBase || currentBase === '.' || currentBase === '..') return null; - if (parsed.extension) pushExt(parsed.extension.toLowerCase()); - for (const ext of SERIES_EXTENSION_PRIORITY) pushExt(ext); + const sorted = [...new Set(dirEntries.map((name) => name.normalize('NFC')))].sort( + naturalCompare, + ); + const currentKey = entryKey(currentBase); + const index = sorted.findIndex((name) => entryKey(name) === currentKey); + if (index < 0) return null; - return ordered; + const sibling = sorted[index + delta]; + return sibling ?? null; } diff --git a/src/styles.css b/src/styles.css index 69d26ec..8ea1544 100644 --- a/src/styles.css +++ b/src/styles.css @@ -146,6 +146,16 @@ input { background: var(--accent-soft); } +.home-actions button.danger { + border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); + color: var(--danger); +} + +.home-actions button.danger:hover { + border-color: var(--danger); + background: color-mix(in srgb, var(--danger) 12%, var(--bg-elevated)); +} + .toolbar button:disabled { opacity: 0.5; cursor: default;