diff --git a/app/renderer.js b/app/renderer.js index db17db6..0b86d9f 100644 --- a/app/renderer.js +++ b/app/renderer.js @@ -133,6 +133,8 @@ let pdfZoom = 1.15; let comicEntries = []; let comicArchive = null; let comicPageNumber = 0; +let epubState = null; +let localLibraryGeneration = 0; let selectedGameToken = null; const activeObjectUrls = new Set(); @@ -168,6 +170,7 @@ const hideLibraryViews = () => { }; const resetLocalLibrary = () => { + localLibraryGeneration += 1; videoPlayer.pause(); videoPlayer.removeAttribute('src'); audioPlayer.pause(); @@ -176,6 +179,8 @@ const resetLocalLibrary = () => { pdfDocument = null; comicArchive = null; comicEntries = []; + epubState = null; + epubDocument.replaceChildren(); readerMode = 'empty'; hideLibraryViews(); playerPlaceholder.hidden = false; @@ -227,6 +232,7 @@ const sanitizeBookDocument = (bookDocument) => { }; const openEpub = async (file) => { + const generation = localLibraryGeneration; const archive = await JSZip.loadAsync(await file.arrayBuffer()); const containerEntry = archive.file(/META-INF\/container\.xml$/i)[0]; if (!containerEntry) throw new Error('EPUB container metadata is missing.'); @@ -245,20 +251,54 @@ const openEpub = async (file) => { .map((item) => manifest.get(item.getAttribute('idref'))) .filter(Boolean) .map((chapter) => packageDirectory + chapter); - const firstChapter = archive.file(chapters[0]); - if (!firstChapter) throw new Error('EPUB chapter content is missing.'); - const chapterDocument = sanitizeBookDocument( - parser.parseFromString(await firstChapter.async('string'), 'text/html') - ); - epubDocument.replaceChildren( - ...[...chapterDocument.body.childNodes].map((node) => document.importNode(node, true)) - ); + if (!chapters.length) throw new Error('EPUB chapter content is missing.'); + if (generation !== localLibraryGeneration) return; + const state = { archive, chapters, chapterNumber: 0, loading: false }; + epubState = state; readerMode = 'epub'; + await renderEpubChapter(0); + if (epubState !== state || readerMode !== 'epub') return; readerToolbar.hidden = false; epubViewerShell.hidden = false; - pageStatus.textContent = `Chapter 1 of ${Math.max(chapters.length, 1)}`; +}; + +const renderEpubChapter = async (chapterNumber) => { + const state = epubState; + if (!state || state.loading || chapterNumber < 0 || chapterNumber >= state.chapters.length) return; + state.loading = true; previousPageButton.disabled = true; - nextPageButton.disabled = chapters.length <= 1; + nextPageButton.disabled = true; + try { + const chapter = state.archive.file(state.chapters[chapterNumber]); + if (!chapter) throw new Error('EPUB chapter content is missing.'); + const source = await chapter.async('string'); + if (epubState !== state || readerMode !== 'epub') return; + const chapterDocument = sanitizeBookDocument(new DOMParser().parseFromString(source, 'text/html')); + epubDocument.replaceChildren( + ...[...chapterDocument.body.childNodes].map((node) => document.importNode(node, true)) + ); + state.chapterNumber = chapterNumber; + epubViewerShell.scrollTop = 0; + pageStatus.textContent = `Chapter ${chapterNumber + 1} of ${state.chapters.length}`; + } finally { + state.loading = false; + if (epubState === state && readerMode === 'epub') { + previousPageButton.disabled = state.chapterNumber <= 0; + nextPageButton.disabled = state.chapterNumber >= state.chapters.length - 1; + } + } +}; + +const navigateEpubChapter = async (direction) => { + const state = epubState; + if (!state) return; + try { + await renderEpubChapter(state.chapterNumber + direction); + } catch (error) { + if (epubState === state && readerMode === 'epub') { + mediaDetails.textContent = error.message || 'Could not open this EPUB chapter.'; + } + } }; const naturalNameSort = (left, right) => left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' }); @@ -289,8 +329,11 @@ const openComic = async (file) => { const openLocalFile = async (file) => { if (!file) return; + localLibraryGeneration += 1; hideLibraryViews(); releaseObjectUrls(); + epubState = null; + epubDocument.replaceChildren(); clearMediaButton.hidden = false; mediaName.textContent = file.name; mediaDetails.textContent = `${file.type || 'Local file'} ยท ${formatFileSize(file.size)}`; @@ -368,6 +411,8 @@ previousPageButton.addEventListener('click', () => { } else if (readerMode === 'comic' && comicPageNumber > 0) { comicPageNumber -= 1; void renderComicPage(); + } else if (readerMode === 'epub') { + void navigateEpubChapter(-1); } }); @@ -378,6 +423,8 @@ nextPageButton.addEventListener('click', () => { } else if (readerMode === 'comic' && comicPageNumber < comicEntries.length - 1) { comicPageNumber += 1; void renderComicPage(); + } else if (readerMode === 'epub') { + void navigateEpubChapter(1); } }); diff --git a/tests/smoke-library.cjs b/tests/smoke-library.cjs index 0e03ca8..bc77858 100644 --- a/tests/smoke-library.cjs +++ b/tests/smoke-library.cjs @@ -1,3 +1,4 @@ +const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); @@ -121,6 +122,86 @@ const run = async () => { }; })()`); + assert.equal(epub.status, 'Chapter 1 of 1'); + assert.equal(epub.scripts + epub.eventAttributes + epub.remoteImages, 0); + const epubNavigation = await evaluate(`(async () => { + const zip = new JSZip(); + zip.file('mimetype', 'application/epub+zip'); + zip.file('META-INF/container.xml', ''); + // Manifest order deliberately differs from the spine's reading order. + zip.file('OEBPS/content.opf', ''); + zip.file('OEBPS/first.xhtml', '

First chapter

'); + zip.file('OEBPS/second.xhtml', '

Second chapter

Safe text

'); + zip.file('OEBPS/third.xhtml', '

Third chapter

'); + const makeBook = async (name) => new File([await zip.generateAsync({ type: 'blob', mimeType: 'application/epub+zip' })], name, { type: 'application/epub+zip' }); + const book = await makeBook('three-chapters.epub'); + const snapshot = () => ({ heading: epubDocument.querySelector('h1')?.textContent, status: pageStatus.textContent, previous: previousPageButton.disabled, next: nextPageButton.disabled }); + const clickAndWait = async (button, status) => { + button.click(); + const deadline = Date.now() + 2000; + while (pageStatus.textContent !== status) { + if (Date.now() > deadline) throw new Error('EPUB navigation did not reach ' + status); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }; + await openLocalFile(book); + const first = snapshot(); + previousPageButton.click(); + const firstBoundary = snapshot(); + await clickAndWait(nextPageButton, 'Chapter 2 of 3'); + const second = { ...snapshot(), unsafe: epubDocument.querySelectorAll('script, [onclick], img[src^="http"]').length }; + await clickAndWait(nextPageButton, 'Chapter 3 of 3'); + const last = snapshot(); + nextPageButton.click(); + const lastBoundary = snapshot(); + await clickAndWait(previousPageButton, 'Chapter 2 of 3'); + const backwards = snapshot(); + await openLocalFile(book); + const reopened = snapshot(); + // Repeated clicks during decompression must not skip a chapter. + nextPageButton.click(); + nextPageButton.click(); + const deadline = Date.now() + 2000; + while (pageStatus.textContent !== 'Chapter 2 of 3') { + if (Date.now() > deadline) throw new Error('Rapid EPUB navigation did not finish.'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + const rapid = snapshot(); + resetLocalLibrary(); + const cleared = { mode: readerMode, text: epubDocument.textContent, toolbarHidden: readerToolbar.hidden }; + // A missing later chapter retains the last successfully rendered chapter. + zip.remove('OEBPS/second.xhtml'); + await openLocalFile(await makeBook('missing-chapter.epub')); + nextPageButton.click(); + await new Promise((resolve) => setTimeout(resolve, 25)); + const missing = { ...snapshot(), error: mediaDetails.textContent }; + await openLocalFile(book); + nextPageButton.click(); + resetLocalLibrary(); + await new Promise((resolve) => setTimeout(resolve, 25)); + const stale = { mode: readerMode, text: epubDocument.textContent, toolbarHidden: readerToolbar.hidden }; + const opening = openLocalFile(book); + resetLocalLibrary(); + await opening; + const staleOpen = { mode: readerMode, text: epubDocument.textContent, toolbarHidden: readerToolbar.hidden }; + return { first, firstBoundary, second, last, lastBoundary, backwards, reopened, rapid, cleared, missing, stale, staleOpen }; + })()`); + const firstChapter = { heading: 'First chapter', status: 'Chapter 1 of 3', previous: true, next: false }; + const secondChapter = { heading: 'Second chapter', status: 'Chapter 2 of 3', previous: false, next: false }; + const lastChapter = { heading: 'Third chapter', status: 'Chapter 3 of 3', previous: false, next: true }; + assert.deepEqual(epubNavigation.first, firstChapter); + assert.deepEqual(epubNavigation.firstBoundary, firstChapter); + assert.deepEqual(epubNavigation.second, { ...secondChapter, unsafe: 0 }); + assert.deepEqual(epubNavigation.last, lastChapter); + assert.deepEqual(epubNavigation.lastBoundary, lastChapter); + assert.deepEqual(epubNavigation.backwards, secondChapter); + assert.deepEqual(epubNavigation.reopened, firstChapter); + assert.deepEqual(epubNavigation.rapid, secondChapter); + assert.deepEqual(epubNavigation.cleared, { mode: 'empty', text: '', toolbarHidden: true }); + assert.deepEqual(epubNavigation.missing, { ...firstChapter, error: 'EPUB chapter content is missing.' }); + assert.deepEqual(epubNavigation.stale, { mode: 'empty', text: '', toolbarHidden: true }); + assert.deepEqual(epubNavigation.staleOpen, { mode: 'empty', text: '', toolbarHidden: true }); + const comic = await evaluate(`(async () => { const zip = new JSZip(); const pixel = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; @@ -162,7 +243,7 @@ const run = async () => { fs.writeFileSync(path.join(artifactDirectory, 'library-smoke.png'), Buffer.from(screenshot.data, 'base64')); } socket.close(); - process.stdout.write(`${JSON.stringify({ shell, onboarding, epub, comic, pdf, gameBoundary, savedGameBoundary }, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ shell, onboarding, epub, epubNavigation, comic, pdf, gameBoundary, savedGameBoundary }, null, 2)}\n`); }; run().catch((error) => {