diff --git a/build-linux.js b/build-linux.js index f79f7bf..348aa60 100644 --- a/build-linux.js +++ b/build-linux.js @@ -135,6 +135,10 @@ function appEntries() { const entries = [ collectFile(path.join(ROOT, 'main.js'), `${app}/main.js`), collectFile(path.join(ROOT, 'preload.js'), `${app}/preload.js`), + collectFile( + path.join(ROOT, 'manga-online-preload.js'), + `${app}/manga-online-preload.js` + ), ...collectDir(path.join(ROOT, 'src'), `${app}/src`, (e) => e.name.startsWith('_test')) ]; diff --git a/build-mac.js b/build-mac.js index 23ff41c..a562f47 100644 --- a/build-mac.js +++ b/build-mac.js @@ -273,6 +273,10 @@ async function build() { fs.mkdirSync(APP, { recursive: true }); fs.copyFileSync(path.join(ROOT, 'main.js'), path.join(APP, 'main.js')); fs.copyFileSync(path.join(ROOT, 'preload.js'), path.join(APP, 'preload.js')); + fs.copyFileSync( + path.join(ROOT, 'manga-online-preload.js'), + path.join(APP, 'manga-online-preload.js') + ); copyDir(path.join(ROOT, 'src'), path.join(APP, 'src'), (e) => e.name.startsWith('_test')); const iconDir = path.join(APP, 'icons', 'dist'); fs.mkdirSync(iconDir, { recursive: true }); diff --git a/build-portable.js b/build-portable.js index 45f321b..0bfe75a 100644 --- a/build-portable.js +++ b/build-portable.js @@ -165,6 +165,10 @@ async function build() { fs.mkdirSync(APP, { recursive: true }); fs.copyFileSync(path.join(ROOT, 'main.js'), path.join(APP, 'main.js')); fs.copyFileSync(path.join(ROOT, 'preload.js'), path.join(APP, 'preload.js')); + fs.copyFileSync( + path.join(ROOT, 'manga-online-preload.js'), + path.join(APP, 'manga-online-preload.js') + ); copyDir(path.join(ROOT, 'src'), path.join(APP, 'src'), (e) => e.name.startsWith('_test')); const iconDir = path.join(APP, 'icons', 'dist'); fs.mkdirSync(iconDir, { recursive: true }); diff --git a/build-release.js b/build-release.js index a28e71f..4201ab8 100644 --- a/build-release.js +++ b/build-release.js @@ -114,7 +114,12 @@ async function verifyWindowsZip(file) { const JSZip = require('jszip'); const zip = await JSZip.loadAsync(fs.readFileSync(file)); const names = Object.keys(zip.files).map((n) => n.replace(/^[^/]+\//, '')); - requireEntries(names, [`${PRODUCT}.exe`, 'resources/app/main.js', 'locales/zh-CN.pak']); + requireEntries(names, [ + `${PRODUCT}.exe`, + 'resources/app/main.js', + 'resources/app/manga-online-preload.js', + 'locales/zh-CN.pak' + ]); forbidEntries(names, [/^data\//, /(^|\/)_test\//]); } @@ -131,7 +136,13 @@ async function verifyDmg(file) { async function verifyLinuxTarball(file) { const entries = readTarEntries(zlib.gunzipSync(fs.readFileSync(file))); const names = entries.map((e) => e.name.replace(/^[^/]+\//, '')); - requireEntries(names, [PRODUCT, `${PRODUCT}.sh`, 'resources/app/main.js', 'locales/zh-CN.pak']); + requireEntries(names, [ + PRODUCT, + `${PRODUCT}.sh`, + 'resources/app/main.js', + 'resources/app/manga-online-preload.js', + 'locales/zh-CN.pak' + ]); forbidEntries(names, [/^data\//, /(^|\/)_test\//]); for (const required of [PRODUCT, `${PRODUCT}.sh`, 'chrome-sandbox']) { diff --git a/package.json b/package.json index 3397a38..6ee26ee 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "files": [ "main.js", "preload.js", + "manga-online-preload.js", "src/**/*", "icons/dist/*.ico", "icons/dist/dark/icon-32.png", diff --git a/src/_test/electron/startup.integration.js b/src/_test/electron/startup.integration.js index f086940..404489f 100644 --- a/src/_test/electron/startup.integration.js +++ b/src/_test/electron/startup.integration.js @@ -51,14 +51,14 @@ app.whenReady().then(async () => { const mangaDex = require(path.join(ROOT, 'src', 'sources', 'mangadex')); mangaDex.chapters = async () => ({ items: [ - { chapterId: 'online-c1', label: '在线第 1 话', pages: 1 }, - { chapterId: 'online-c2', label: '在线第 2 话', pages: 1 } + { chapterId: 'online-c1', label: '在线第 1 话', pages: 8 }, + { chapterId: 'online-c2', label: '在线第 2 话', pages: 8 } ], page: 1, maxPage: 1 }); mangaDex.chapterImageUrls = async () => ({ - urls: [imageUrl], + urls: Array(8).fill(imageUrl), mustReport: false, quality: 'dataSaver' }); @@ -125,6 +125,7 @@ app.whenReady().then(async () => { mangaId: 'online-manga', title: '在线阅读夹具', authors: ['测试作者'], + initialChapterId: 'online-c2', language: 'zh', quality: 'dataSaver' })`); @@ -137,30 +138,37 @@ app.whenReady().then(async () => { try { firstChapter = await waitUntil(() => onlineWindow.webContents.executeJavaScript( `document.querySelectorAll('.chapter-item').length === 2 - && document.querySelector('.manga-page img') - && document.querySelector('.manga-page img').naturalWidth > 0` + && document.getElementById('chapterTitle').textContent === '在线第 2 话' + && Array.from(document.querySelectorAll('.manga-page img')) + .filter((image) => image.naturalWidth > 0).length >= 4` )); } catch (error) { /* 由下面的诊断状态报告 */ } const onlineState = await onlineWindow.webContents.executeJavaScript(`(() => ({ status: document.getElementById('readerStatus').textContent, statusHidden: document.getElementById('readerStatus').classList.contains('hidden'), + chapterTitle: document.getElementById('chapterTitle').textContent, chapters: document.querySelectorAll('.chapter-item').length, pages: document.querySelectorAll('.manga-page').length, + scrollTop: document.getElementById('pageScroller').scrollTop, pageState: document.querySelector('.manga-page')?.dataset.state || '', pageText: document.querySelector('.manga-page')?.dataset.placeholder || '' }))()`); check('在线阅读器读取整部章节目录并通过主进程显示图片', !!firstChapter, JSON.stringify(onlineState)); - let secondChapter = false; + check('章节阅读入口会直接打开指定章节', + firstChapter && onlineState.chapterTitle === '在线第 2 话', JSON.stringify(onlineState)); + check('漫画长图异步展开时停留在章节第一页', + firstChapter && onlineState.scrollTop === 0, JSON.stringify(onlineState)); + let previousChapter = false; if (firstChapter) { - await onlineWindow.webContents.executeJavaScript(`document.getElementById('nextChapterBtn').click()`); - secondChapter = await waitUntil(() => onlineWindow.webContents.executeJavaScript(`(() => { + await onlineWindow.webContents.executeJavaScript(`document.getElementById('prevChapterBtn').click()`); + previousChapter = await waitUntil(() => onlineWindow.webContents.executeJavaScript(`(() => { const image = document.querySelector('.manga-page img'); - return document.getElementById('chapterTitle').textContent === '在线第 2 话' + return document.getElementById('chapterTitle').textContent === '在线第 1 话' && image && image.naturalWidth > 0; })()`)); } - check('在线阅读器可连续切换到下一章', secondChapter); + check('在线阅读器可连续切换到上一章', previousChapter); const afterOnline = await win.webContents.executeJavaScript( `window.api.library.list().then((result) => result.data.length)` ); diff --git a/src/_test/main.test.js b/src/_test/main.test.js index f355ba4..b4c1dfb 100644 --- a/src/_test/main.test.js +++ b/src/_test/main.test.js @@ -368,6 +368,25 @@ test('标准构建入口固定输出目录并保留便携数据', () => { assert.doesNotMatch(build, /\$\{PRODUCT\}-\$\{pkg\.version\}/); }); +test('所有构建与发布入口都包含在线漫画 preload', () => { + const root = path.join(__dirname, '..', '..'); + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const portable = fs.readFileSync(path.join(root, 'build-portable.js'), 'utf8'); + const mac = fs.readFileSync(path.join(root, 'build-mac.js'), 'utf8'); + const linux = fs.readFileSync(path.join(root, 'build-linux.js'), 'utf8'); + const release = fs.readFileSync(path.join(root, 'build-release.js'), 'utf8'); + + assert.ok(pkg.build.files.includes('manga-online-preload.js')); + for (const [name, source] of [['Windows', portable], ['macOS', mac], ['Linux', linux]]) { + assert.match(source, /manga-online-preload\.js/, `${name} 构建漏掉在线漫画 preload`); + } + assert.strictEqual( + (release.match(/resources\/app\/manga-online-preload\.js/g) || []).length, + 2, + 'Windows 与 Linux 发布件回读都必须检查在线漫画 preload' + ); +}); + function loadPathResolvers(platform, isPackaged) { return h.extractFns( mainFile, diff --git a/src/_test/manga-online.test.js b/src/_test/manga-online.test.js index 1444587..91b2a65 100644 --- a/src/_test/manga-online.test.js +++ b/src/_test/manga-online.test.js @@ -36,10 +36,12 @@ test('在线漫画会话与窗口 sender 绑定,并只授权目录里可用的 const created = sessions.create(11, source(), { mangaId: 'manga-1', title: '在线漫画', + initialChapterId: 'c1', language: 'zh-hk', quality: 'data' }); assert.strictEqual(created.title, '在线漫画'); + assert.strictEqual(created.initialChapterId, 'c1'); assert.strictEqual(created.quality, 'data'); await assert.rejects( Promise.resolve().then(() => sessions.meta(12, created.sessionId)), diff --git a/src/_test/ui.test.js b/src/_test/ui.test.js index 8520d7d..9c641c3 100644 --- a/src/_test/ui.test.js +++ b/src/_test/ui.test.js @@ -272,11 +272,14 @@ test('漫画源按分类显示,MangaDex 可筛选中文章节并设置图片 test('漫画详情提供不入库的整部在线阅读器,并通过独立 preload 懒加载图片', () => { const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'manga-online.html'), 'utf8'); const script = fs.readFileSync(path.join(__dirname, '..', 'ui', 'manga-online.js'), 'utf8'); + const mangaCss = fs.readFileSync(path.join(__dirname, '..', 'ui', 'manga-online.css'), 'utf8'); const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8'); const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'manga-online-preload.js'), 'utf8'); assert.match(browse, /id="onlineReadBtn">在线阅读整部漫画/); assert.match(browse, /window\.api\.sources\.readOnline\(state\.activeSourceId/); + assert.match(browse, /data-chapter-read=/); + assert.match(browse, /initialChapterId: chapter && chapter\.chapterId/); assert.doesNotMatch(browse.slice(browse.indexOf('async function readOnline'), browse.indexOf('async function loadChapters')), /library\.add/); assert.match(html, /Content-Security-Policy" content="default-src 'self'; img-src 'self' blob: data:/); assert.match(html, /id="chapterList"/); @@ -284,6 +287,8 @@ test('漫画详情提供不入库的整部在线阅读器,并通过独立 prel assert.match(script, /new IntersectionObserver/); assert.match(script, /URL\.createObjectURL\(new Blob/); assert.match(script, /ensureNextChapter/); + assert.match(script, /state\.meta\.initialChapterId/); + assert.match(mangaCss, /\.page-scroller\s*\{[^}]*overflow-anchor:\s*none/); assert.match(preload, /mangaOnline:image/); assert.doesNotMatch(preload, /library:|reader:bytes|shell:openPath/); }); diff --git a/src/manga-online/session.js b/src/manga-online/session.js index 0f184fd..c98464d 100644 --- a/src/manga-online/session.js +++ b/src/manga-online/session.js @@ -37,6 +37,7 @@ function create(ownerId, source, input) { .map((author) => cleanText(author, 120)) .filter(Boolean) .slice(0, 20), + initialChapterId: cleanText(input && input.initialChapterId, 500), language, quality, chapterPages: new Map(), @@ -64,6 +65,7 @@ function metaOf(session) { title: session.title, cover: session.cover, authors: session.authors.slice(), + initialChapterId: session.initialChapterId, language: session.language, quality: session.quality }; diff --git a/src/ui/manga-online.css b/src/ui/manga-online.css index a1d7ddb..70089c0 100644 --- a/src/ui/manga-online.css +++ b/src/ui/manga-online.css @@ -206,6 +206,7 @@ button, input { font: inherit; } min-width: 0; flex: 1; overflow: auto; + overflow-anchor: none; overscroll-behavior: contain; background: var(--bg); } diff --git a/src/ui/manga-online.js b/src/ui/manga-online.js index c8d807c..dae182c 100644 --- a/src/ui/manga-online.js +++ b/src/ui/manga-online.js @@ -281,7 +281,16 @@ async function init() { setStatus('该漫画暂无可在线阅读的章节', true); return; } - openChapter(0); + let initialIndex = state.meta.initialChapterId + ? state.chapters.findIndex((chapter) => chapter.chapterId === state.meta.initialChapterId) + : 0; + while (initialIndex < 0 && state.chapterPage < state.chapterMaxPage) { + if (!await loadChapterPage(state.chapterPage + 1)) return; + initialIndex = state.chapters.findIndex( + (chapter) => chapter.chapterId === state.meta.initialChapterId + ); + } + openChapter(initialIndex < 0 ? 0 : initialIndex); } $('tocToggleBtn').addEventListener('click', () => $('tocPane').classList.toggle('collapsed')); diff --git a/src/ui/views/browse.js b/src/ui/views/browse.js index 839235b..1d5610a 100644 --- a/src/ui/views/browse.js +++ b/src/ui/views/browse.js @@ -399,7 +399,7 @@ const Browse = (() => { $('addLibBtn').onclick = addToLibrary; const onlineReadBtn = $('onlineReadBtn'); - if (onlineReadBtn) onlineReadBtn.onclick = readOnline; + if (onlineReadBtn) onlineReadBtn.onclick = () => readOnline(); const urlLink = $('detailUrlLink'); if (urlLink) urlLink.onclick = (e) => { e.preventDefault(); window.api.openExternal(d.url); }; if (chapterBased) { @@ -416,9 +416,10 @@ const Browse = (() => { } } - async function readOnline() { - const button = $('onlineReadBtn'); + async function readOnline(chapter, chapterButton) { + const button = chapterButton || $('onlineReadBtn'); if (!button || !state.currentDetail) return; + const oldText = button.textContent; button.disabled = true; button.textContent = '正在打开…'; const [qualitySetting, languageSetting] = await Promise.all([ @@ -436,11 +437,14 @@ const Browse = (() => { title: state.currentDetail.title, cover: state.currentDetail.cover, authors: state.currentDetail.authors, + initialChapterId: chapter && chapter.chapterId, language, quality }); button.disabled = false; - button.textContent = result.ok ? '在线阅读器已打开' : '在线阅读整部漫画'; + button.textContent = result.ok + ? (chapter ? '已打开' : '在线阅读器已打开') + : oldText; button.title = result.ok ? '' : result.error || '在线阅读器打开失败'; } @@ -464,6 +468,7 @@ const Browse = (() => {