├── .browserslistrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yaml │ └── feature_request.yaml └── workflows │ ├── docs.yaml │ ├── pages-deployment.yaml │ └── release.yml ├── .gitignore ├── .percy.yml ├── .postcssrc.cjs ├── .storybook ├── global.css ├── main.ts ├── preview.ts └── test-runner.ts ├── .stylelintrc.js ├── .vscode ├── extensions.json ├── i18n-ally-custom-framework.yml └── settings.json ├── .xo-config.cjs ├── ComicRead-AdGuard.user.js ├── ComicRead.user.js ├── LICENSE ├── README.md ├── docs ├── .other │ ├── CHANGELOG.md │ ├── Dev.md │ ├── LatestChange.md │ ├── dmzj.md │ └── other.md ├── .vitepress │ ├── config.mts │ ├── imgSize.ts │ └── theme │ │ ├── custom.css │ │ └── index.js ├── index.md ├── public │ ├── ehentai例图.png │ ├── eh快捷收藏-列表页.webp │ ├── eh快捷收藏-详情页.webp │ ├── eh悬浮标签列表.webp │ ├── eh标签染色.webp │ ├── eh标签检查.webp │ ├── favicon.ico │ ├── 判断左右页位置例图.png │ ├── 并排卷轴模式示例.webp │ ├── 百合会入口.jpg │ ├── 百合会记录阅读进度功能.jpg │ ├── 百合姬简介页例图.png │ ├── 翻译功能示例.webp │ ├── 翻页分镜例图1.png │ ├── 翻页分镜例图2.png │ └── 页面填充示例.webp ├── 判断左右页位置.md ├── 功能 │ ├── PWA.md │ ├── 卷轴模式.md │ └── 页面填充.md ├── 无法解决的问题.md ├── 本地部署翻译.md └── 设置项说明.md ├── locales ├── en.json ├── hu.json ├── ru.json ├── ta.json └── zh.json ├── package.json ├── pnpm-lock.yaml ├── release.mjs ├── rollup.config.ts ├── src ├── components │ ├── DownloadButton.tsx │ ├── Fab │ │ ├── index.module.css │ │ └── index.tsx │ ├── IconButton │ │ ├── index.module.css │ │ └── index.tsx │ ├── Manga │ │ ├── actions │ │ │ ├── abreastScroll.ts │ │ │ ├── helper.ts │ │ │ ├── hotkeys.ts │ │ │ ├── image.ts │ │ │ ├── imageLoad.ts │ │ │ ├── imageRecognition.ts │ │ │ ├── imageSize.ts │ │ │ ├── imageType.ts │ │ │ ├── index.ts │ │ │ ├── memo │ │ │ │ ├── common.ts │ │ │ │ ├── index.ts │ │ │ │ └── observer.ts │ │ │ ├── operate.ts │ │ │ ├── pointer.ts │ │ │ ├── readProgress.ts │ │ │ ├── renderPage.ts │ │ │ ├── scroll.ts │ │ │ ├── scrollModeDrag.ts │ │ │ ├── scrollbar.ts │ │ │ ├── show.ts │ │ │ ├── switch.ts │ │ │ ├── translation │ │ │ │ ├── cotrans.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ └── selfhosted.ts │ │ │ ├── turnPage.ts │ │ │ └── zoom.ts │ │ ├── components │ │ │ ├── ComicImg.module.css │ │ │ ├── ComicImg.tsx │ │ │ ├── ComicImgFlow.tsx │ │ │ ├── EmptyTip.tsx │ │ │ ├── EndPage.module.css │ │ │ ├── EndPage.tsx │ │ │ ├── Scrollbar.module.css │ │ │ ├── Scrollbar.tsx │ │ │ ├── ScrollbarPageStatus.tsx │ │ │ ├── Setting.module.css │ │ │ ├── SettingHotkeys.module.css │ │ │ ├── SettingHotkeys.tsx │ │ │ ├── SettingPanel.tsx │ │ │ ├── SettingTranslation.tsx │ │ │ ├── SettingsItem.tsx │ │ │ ├── SettingsItemNumber.tsx │ │ │ ├── SettingsItemSelect.tsx │ │ │ ├── SettingsItemSwitch.tsx │ │ │ ├── SettingsShowItem.tsx │ │ │ ├── Toolbar.module.css │ │ │ ├── Toolbar.tsx │ │ │ ├── TouchArea.module.css │ │ │ └── TouchArea.tsx │ │ ├── defaultButtonList.tsx │ │ ├── defaultSettingList.tsx │ │ ├── handleComicData.test.ts │ │ ├── handleComicData.ts │ │ ├── helper.ts │ │ ├── hooks │ │ │ ├── useCssVar.ts │ │ │ ├── useDoubleClick.ts │ │ │ ├── useHiddenMouse.ts │ │ │ ├── useInit.ts │ │ │ └── useStyle.ts │ │ ├── index.module.css │ │ ├── index.tsx │ │ └── store │ │ │ ├── image.ts │ │ │ ├── index.ts │ │ │ ├── option.ts │ │ │ ├── other.ts │ │ │ ├── prop.ts │ │ │ └── show.ts │ ├── NumberInput.tsx │ ├── RangeInput.tsx │ └── Toast │ │ ├── ToastItem.tsx │ │ ├── Toaster.tsx │ │ ├── index.module.css │ │ ├── index.ts │ │ ├── store.tsx │ │ └── toast.tsx ├── dev.ts ├── helper │ ├── components.ts │ ├── faviconProgress.ts │ ├── helper.test.ts │ ├── i18n.ts │ ├── index.test.ts │ ├── index.ts │ ├── languages.ts │ ├── logger.ts │ ├── other.ts │ ├── solidJs.ts │ ├── useCache.test.ts │ ├── useCache.ts │ ├── useDrag.ts │ ├── useStore.ts │ └── useStyle.ts ├── index.html ├── index.ts ├── pwa │ ├── index.html │ ├── index.tsx │ ├── public │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── libarchive.js │ │ │ ├── libarchive.js │ │ │ ├── libarchive.wasm │ │ │ └── worker-bundle.js │ │ ├── libunrar │ │ │ ├── Promise.min.js │ │ │ ├── libunrar.js │ │ │ ├── libunrar.js.mem │ │ │ ├── rpc.js │ │ │ └── worker.js │ │ ├── mstile-144x144.png │ │ ├── mstile-150x150.png │ │ ├── mstile-310x150.png │ │ ├── mstile-310x310.png │ │ ├── mstile-70x70.png │ │ └── safari-pinned-tab.svg │ ├── src │ │ ├── DownloadButton.tsx │ │ ├── handleButtonList.tsx │ │ ├── handleDrag.ts │ │ ├── helper.ts │ │ ├── index.module.css │ │ ├── index.tsx │ │ ├── option.ts │ │ ├── store.ts │ │ └── unzip │ │ │ ├── fflate.ts │ │ │ ├── index.ts │ │ │ ├── libarchive.ts │ │ │ └── libunrar.ts │ └── vite.config.mts ├── request.ts ├── rollup-plugin │ ├── ehRules.ts │ ├── index.ts │ ├── metaHeader.ts │ ├── rollup-solid-svg.ts │ ├── siteUrl.ts │ └── vite.ts ├── site │ ├── copymanga.tsx │ ├── dmzj.tsx │ ├── dmzj_phone.tsx │ ├── dmzj_www.tsx │ ├── ehentai │ │ ├── associateNhentai.tsx │ │ ├── colorizeTag.ts │ │ ├── floatTagList.tsx │ │ ├── hotkeys.ts │ │ ├── index.tsx │ │ ├── myTags.ts │ │ ├── other.ts │ │ ├── quickFavorite.tsx │ │ ├── quickRating.tsx │ │ ├── quickTagDefine.tsx │ │ ├── sortTags.ts │ │ └── tagLint.tsx │ ├── jm.tsx │ ├── kemono.tsx │ ├── nhentai.tsx │ ├── yamibo.tsx │ └── yurifans.tsx ├── stories │ ├── Manga.stories.tsx │ ├── helper.ts │ ├── public │ │ ├── 方便的陪跑友 │ │ │ ├── 00.webp │ │ │ ├── 01.webp │ │ │ ├── 02.webp │ │ │ ├── 03.webp │ │ │ ├── 04.webp │ │ │ ├── 05.webp │ │ │ ├── 06.webp │ │ │ ├── 07.webp │ │ │ ├── 08.webp │ │ │ ├── 09.webp │ │ │ ├── 10.webp │ │ │ ├── 11.webp │ │ │ └── 12.webp │ │ ├── 杂 │ │ │ ├── 二维码 │ │ │ │ ├── 1.webp │ │ │ │ ├── 2.webp │ │ │ │ ├── 3.webp │ │ │ │ ├── 4.webp │ │ │ │ ├── 广告1.webp │ │ │ │ ├── 广告2.webp │ │ │ │ ├── 广告3.webp │ │ │ │ └── 广告4.webp │ │ │ ├── 复杂纹路背景.webp │ │ │ ├── 渐变背景.webp │ │ │ └── 纯黑背景.webp │ │ ├── 若爱在眼前 │ │ │ ├── 00.webp │ │ │ ├── 01.webp │ │ │ ├── 02.webp │ │ │ ├── 03.webp │ │ │ ├── 04.webp │ │ │ ├── 05.webp │ │ │ ├── 06.webp │ │ │ ├── 07.webp │ │ │ ├── 08.webp │ │ │ ├── 09.webp │ │ │ ├── 10.webp │ │ │ ├── 11.webp │ │ │ ├── 12.webp │ │ │ ├── 13.webp │ │ │ ├── 14.webp │ │ │ ├── 15.webp │ │ │ ├── 16.webp │ │ │ ├── 17.webp │ │ │ ├── 18.webp │ │ │ ├── 19.webp │ │ │ ├── 20.webp │ │ │ ├── 21.webp │ │ │ ├── 22.webp │ │ │ ├── 23.webp │ │ │ ├── 24.webp │ │ │ ├── 25.webp │ │ │ ├── 26.webp │ │ │ ├── 27.webp │ │ │ ├── 28.webp │ │ │ ├── 29.webp │ │ │ ├── 30.webp │ │ │ ├── 31.webp │ │ │ ├── 32.webp │ │ │ ├── 33.webp │ │ │ ├── 34.webp │ │ │ ├── 35.webp │ │ │ └── 36.webp │ │ ├── 透过百合SM能否连结两人的身心呢? │ │ │ ├── 00.webp │ │ │ ├── 01.webp │ │ │ ├── 02.webp │ │ │ ├── 03.webp │ │ │ ├── 04.webp │ │ │ ├── 05.webp │ │ │ ├── 06.webp │ │ │ ├── 07.webp │ │ │ ├── 08.webp │ │ │ ├── 09.webp │ │ │ ├── 10.webp │ │ │ ├── 11.webp │ │ │ ├── 12.webp │ │ │ ├── 13.webp │ │ │ ├── 14.webp │ │ │ ├── 15.webp │ │ │ ├── 16.webp │ │ │ └── 17.webp │ │ └── 饮茶之时、女仆之梦 │ │ │ ├── 00.webp │ │ │ ├── 01.webp │ │ │ ├── 02.webp │ │ │ ├── 03.webp │ │ │ ├── 04.webp │ │ │ ├── 05.webp │ │ │ ├── 06.webp │ │ │ ├── 07.webp │ │ │ ├── 08.webp │ │ │ ├── 09.webp │ │ │ ├── 10.webp │ │ │ ├── 11.webp │ │ │ ├── 12.webp │ │ │ ├── 13.webp │ │ │ ├── 14.webp │ │ │ ├── 15.webp │ │ │ ├── 16.webp │ │ │ ├── 17.webp │ │ │ ├── 18.webp │ │ │ ├── 19.webp │ │ │ ├── 20.webp │ │ │ ├── 21.webp │ │ │ ├── 22.webp │ │ │ ├── 23.webp │ │ │ ├── 24.webp │ │ │ ├── 25.webp │ │ │ ├── 26.webp │ │ │ ├── 27.webp │ │ │ ├── 28.webp │ │ │ ├── 29.webp │ │ │ └── 30.webp │ ├── recognition.stories.tsx │ ├── settings.stories.tsx │ └── show.stories.tsx ├── types │ ├── index.d.ts │ ├── tampermonkey.d.ts │ └── vite-env.d.ts ├── userscript │ ├── detectAd.ts │ ├── dmzjApi.ts │ ├── dmzjDecrypt.ts │ ├── ehTagRules │ │ ├── index.ts │ │ └── zh.json5 │ ├── import.ts │ ├── main │ │ ├── index.ts │ │ ├── migration.ts │ │ ├── universal.ts │ │ ├── useInit.tsx │ │ ├── useSiteOptions.ts │ │ ├── useSpeedDial.tsx │ │ └── version.tsx │ ├── otherSite │ │ ├── eleSelector.ts │ │ ├── index.tsx │ │ ├── switchChapter.ts │ │ └── triggerLazyLoad.ts │ └── useComponents │ │ ├── Fab.tsx │ │ └── Manga.tsx └── worker │ ├── ImageRecognition │ ├── background.ts │ ├── blankMargin.ts │ ├── colorArea.ts │ ├── index.ts │ ├── otsu.ts │ ├── pHash.ts │ └── workHelper.ts │ ├── detectAd │ ├── index.ts │ └── workHelper.ts │ └── helper.ts ├── tsconfig.json └── vite.config.mts /.browserslistrc: -------------------------------------------------------------------------------- 1 | last 5 Chrome versions 2 | last 5 Firefox versions 3 | last 5 Safari versions 4 | last 5 iOS versions 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 问题反馈 3 | description: Bug report 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: 在提交 issue 之前,请先确认 8 | options: 9 | - label: 脚本已更新至最新版本 10 | required: true 11 | 12 | - type: input 13 | attributes: 14 | label: 使用的油猴扩展是? 15 | description: 例如 Violentmonkey(暴力猴)、Tampermonkey(篡改猴) 16 | validations: 17 | required: true 18 | 19 | - type: input 20 | attributes: 21 | label: 使用的浏览器是? 22 | description: 如果浏览器未更新至最新版本,请附上具体的版本号 23 | validations: 24 | required: true 25 | 26 | - type: input 27 | attributes: 28 | label: 问题会在哪个网页上出现? 29 | description: 除非在所有站点上都会出现该问题,否则请提供一个具体的网址 30 | placeholder: 例:https://exhentai.org/g/2795726/81cbaaa96e/ 31 | validations: 32 | required: true 33 | 34 | - type: textarea 35 | attributes: 36 | label: 问题是? 37 | description: 除非问题能够一目了然,否则请具体描述操作流程以便复现 38 | validations: 39 | required: true 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 功能建议 3 | description: feature request 4 | body: 5 | - type: textarea 6 | id: feature-request 7 | attributes: 8 | label: 具体描述 9 | description: 请详细描述需要改进或者添加的功能 10 | validations: 11 | required: true 12 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: updateDocsPage 2 | 3 | on: 4 | push: 5 | paths: 6 | - "**.md" 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | deployments: write 14 | name: Deploy to Cloudflare Pages 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Install pnpm 20 | uses: pnpm/action-setup@v4 21 | id: pnpm-install 22 | with: 23 | version: 9 24 | run_install: false 25 | 26 | - name: Install Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: 20 30 | cache: "pnpm" 31 | 32 | - name: Install dependencies 33 | run: pnpm install --no-frozen-lockfile 34 | 35 | - name: Build 36 | run: pnpm run docs:build 37 | 38 | - name: Publish 39 | uses: cloudflare/pages-action@1 40 | with: 41 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 42 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 43 | projectName: "comic-read-docs" 44 | directory: "docs/.vitepress/dist" 45 | branch: main 46 | -------------------------------------------------------------------------------- /.github/workflows/pages-deployment.yaml: -------------------------------------------------------------------------------- 1 | name: updatePage 2 | 3 | on: [push] 4 | 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | deployments: write 11 | name: Deploy to Cloudflare Pages 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Install pnpm 17 | uses: pnpm/action-setup@v4 18 | id: pnpm-install 19 | with: 20 | version: 9 21 | run_install: false 22 | 23 | - name: Install Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | cache: "pnpm" 28 | 29 | - name: Install dependencies 30 | run: pnpm install --no-frozen-lockfile 31 | 32 | - name: Build 33 | run: pnpm run pwa:build 34 | 35 | - name: Publish 36 | uses: cloudflare/pages-action@1 37 | with: 38 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 39 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 40 | projectName: "comic-read" 41 | directory: "src/pwa/dist" 42 | branch: main 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v*.*.* 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | ref: "master" 17 | - name: Release 18 | uses: softprops/action-gh-release@v1 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | body_path: docs/.other/LatestChange.md 22 | files: | 23 | ComicRead.user.js 24 | ComicRead-AdGuard.user.js 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | dev-dist 3 | node_modules 4 | cache 5 | rollup.config-*.mjs 6 | storybook-static 7 | *storybook.log 8 | .env 9 | -------------------------------------------------------------------------------- /.percy.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | percy: 3 | defer-uploads: true 4 | snapshot: 5 | widths: 6 | - 384 7 | - 1920 8 | minHeight: 1080 9 | -------------------------------------------------------------------------------- /.postcssrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': {} || null, 4 | 'postcss-nesting': {} || null, 5 | autoprefixer: {} || null, 6 | cssnano: {} || null, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /.storybook/global.css: -------------------------------------------------------------------------------- 1 | root, 2 | html, 3 | body, 4 | #storybook-root { 5 | overflow: hidden; 6 | width: 100%; 7 | height: 100%; 8 | } 9 | 10 | .blur { 11 | clip-path: border-box; 12 | filter: blur(2em); 13 | } 14 | 15 | .blur:hover { 16 | filter: unset; 17 | } 18 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "storybook-solidjs-vite"; 2 | 3 | const config: StorybookConfig = { 4 | stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], 5 | staticDirs: ['../src/stories/public'], 6 | framework: "storybook-solidjs-vite", 7 | addons: [ 8 | "@storybook/addon-essentials", 9 | "@storybook/addon-interactions", 10 | 'storybook-dark-mode', 11 | ], 12 | core: { 13 | disableTelemetry: true, 14 | disableWhatsNewNotifications: true, 15 | enableCrashReports: false, 16 | }, 17 | }; 18 | export default config; 19 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import { type Preview } from 'storybook-solidjs'; 2 | import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport'; 3 | 4 | import './global.css'; 5 | import 'normalize.css'; 6 | 7 | const preview: Preview = { 8 | parameters: { 9 | viewport: { viewports: INITIAL_VIEWPORTS }, 10 | controls: { disableSaveFromUI: true }, 11 | }, 12 | }; 13 | 14 | export default preview; 15 | -------------------------------------------------------------------------------- /.storybook/test-runner.ts: -------------------------------------------------------------------------------- 1 | import { waitForPageReady, type TestRunnerConfig } from '@storybook/test-runner'; 2 | import percySnapshot from '@percy/playwright'; 3 | 4 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 5 | 6 | const config: TestRunnerConfig = { 7 | async postVisit(page, context) { 8 | await waitForPageReady(page); 9 | 10 | await page.setViewportSize({ width: 1920, height: 1080 }); 11 | await sleep(1000); 12 | // 虽然 TS 报错,但确实是有生效的 13 | await percySnapshot(page, context.id, { width: 1920 } as any); 14 | 15 | await page.setViewportSize({ width: 768, height: 1080 }); 16 | await sleep(1000); 17 | await percySnapshot(page, context.id, { width: 768 } as any); 18 | }, 19 | }; 20 | 21 | export default config; 22 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignoreFiles: ['**/node_modules/**/*.css', '**/dist/**/*.css'], 3 | extends: [ 4 | 'stylelint-config-standard', 5 | 'stylelint-prettier/recommended', 6 | 'stylelint-config-clean-order', 7 | ], 8 | plugins: ['stylelint-order', 'stylelint-high-performance-animation'], 9 | rules: { 10 | // 允许 css 变量使用任意命名方式 11 | 'custom-property-pattern': null, 12 | // 允许任意类型的命名方式 13 | 'selector-class-pattern': null, 14 | 'keyframes-name-pattern': null, 15 | // 允许重复的 css 动画帧 16 | 'keyframe-block-no-duplicate-selectors': null, 17 | 18 | // 防止使用低性能的动画和过度属性 19 | 'plugin/no-low-performance-animation-properties': true, 20 | 21 | 'import-notation': 'string', 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "lokalise.i18n-ally" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/i18n-ally-custom-framework.yml: -------------------------------------------------------------------------------- 1 | # .vscode/i18n-ally-custom-framework.yml 2 | 3 | # An array of strings which contain Language Ids defined by VS Code 4 | # You can check available language ids here: https://code.visualstudio.com/docs/languages/identifiers 5 | languageIds: 6 | - javascript 7 | - typescript 8 | - javascriptreact 9 | - typescriptreact 10 | 11 | # An array of RegExes to find the key usage. **The key should be captured in the first match group**. 12 | # You should unescape RegEx strings in order to fit in the YAML file 13 | # To help with this, you can use https://www.freeformatter.com/json-escape.html 14 | usageMatchRegex: 15 | # The following example shows how to detect `t("your.i18n.keys")` 16 | # the `{key}` will be placed by a proper keypath matching regex, 17 | # you can ignore it and use your own matching rules as well 18 | - "[^\\w\\d]t\\(\\s*['\"`]({key})['\"`]" 19 | - "[^\\w\\d]setStatu\\(\\s*['\"`]({key})['\"`]" 20 | - "[^\\w\\d]extractI18n\\(\\s*['\"`]({key})['\"`]" 21 | - "'((c|f|m|x|p|o):.+?)'" 22 | 23 | # A RegEx to set a custom scope range. This scope will be used as a prefix when detecting keys 24 | # and works like how the i18next framework identifies the namespace scope from the 25 | # useTranslation() hook. 26 | # You should unescape RegEx strings in order to fit in the YAML file 27 | # To help with this, you can use https://www.freeformatter.com/json-escape.html 28 | # scopeRangeRegex: "useTranslation\\(\\s*\\[?\\s*['\"`](.*?)['\"`]" 29 | 30 | # An array of strings containing refactor templates. 31 | # The "$1" will be replaced by the keypath specified. 32 | # Optional: uncomment the following two lines to use 33 | 34 | refactorTemplates: 35 | - t('$1') 36 | 37 | # If set to true, only enables this custom framework (will disable all built-in frameworks) 38 | monopoly: true 39 | -------------------------------------------------------------------------------- /docs/.other/Dev.md: -------------------------------------------------------------------------------- 1 | ## TODO 2 | 3 | ## 暂不考虑实现的功能 4 | 5 | - 切白边 6 | 除非在切掉白边后禁止恢复回来,否则必须存储两套长宽、长宽类型、图片 url、图片数据,并在所有相关的地方根据当前显示哪个来判断用哪套来计算。 7 | 双倍占内存的同时还会提高代码的复杂度,而且感觉也没什么人需要。 8 | 9 | - 自动调整首页填充 10 | 在漫画页数大于10时,检查漫画前5页,找出相似的图片(封面图)、空白图片,统计这类「非正片」的漫画页的页数。为奇数时开启首页填充,否则关闭。 11 | 有重复封面的漫画不算多,而且在识别空白图片时也很难分辨出「前记」和「留白超大的漫画页」(https://exhentai.org/g/3057496/76f42fcfa9/) 12 | 准确度低、可用场景少。 13 | 14 | ## 油猴扩展 API 15 | 16 | - https://violentmonkey.github.io/api/gm/ 17 | - https://www.tampermonkey.net/documentation.php?locale=zh 18 | - https://adguard.com/kb/zh-CN/general/userscripts/#supported-gm-functions 19 | 20 | ## 参考 21 | 22 | - https://github.dev/keiyoushi/extensions-source 23 | 24 | ## 调试 25 | 26 | ```bash 27 | pnpm dev 28 | ``` 29 | 30 | 然后将 `dist/dev.user.js` 的代码添加到油猴扩展里去就行了,之后每次修改完代码后只要刷新页面就能运行最新的代码,只要没有修改到 @resource 或 @grant 都不用更新油猴扩展上的代码。 31 | 32 | ## 支持新站点 33 | 34 | > 首先到 `src\index.tsx` 里参考其他网站增加站点对应的 url 和 `// #站点代码文件名` 的注释,再到 `src\site` 里创建 `站点代码文件名.tsx` 的文件,之后再开始编写里面的代码 35 | 36 | 先在站点漫画页的网页控制台执行下列代码找出网页内的自定义全局变量 37 | 38 | ```js 39 | const iframe = document.createElement("iframe", { url: "about:blank" }); 40 | iframe.style.display = "none"; 41 | document.body.appendChild(iframe); 42 | 43 | Object.fromEntries( 44 |   Object.entries(window) 45 |     .filter(([x]) => !Reflect.has(iframe.contentWindow, x)) 46 | ) 47 | ``` 48 | 49 | 手动检视一遍看能不能通过变量直接获取所有图片的链接,如果可以就参考 [manhuagui.ts](../src/site/manhuagui.tsx) 的代码,否则参考 [mangabz.ts](../src/site/mangabz.tsx) 的代码 50 | 51 | > !!!复制代码后一定要记得修改传给 useInit 的站点名 52 | 53 | 一般的代码逻辑流程是这样的 54 | 55 | 1. 通过页面变量或 url 的判断,跳过漫画页以外的页面 56 | 2. 使用 `useInit` 函数进行初始化,参数名为网站名,将会作为保存读取配置时的 id 57 | 3. 如果有上下一话的按钮,就通过 `setManga` 修改 onNext、onPrev 两个参数。注意如果按钮存在但无法点击的话,应该传递空值或直接不传 58 | 4. 向 `init` 函数传一个返回所有图片链接的函数 59 | 60 | ## 循环 debugger 的应对方式 61 | 62 | 如果有判断条件的话,可以直接在触发断点时通过修改变量来避免触发,甚至直接关掉循环。 63 | 64 | ```js 65 | // 使用 setInterval 来循环的话,直接取消所有 setInterval 66 | let id = setInterval(() => {}, 0); 67 | while (id--) clearInterval(id); 68 | ``` 69 | 70 | --- 71 | 72 | ## 动态导入外部库 73 | 74 | `src\helper\import.ts` 75 | 创建一个自定义的 require 函数放在脚本开头,再让 rollup 导出 cjs 模块规范的代码,就能直接在脚本里使用 cjs、umd 模块了。 76 | 不过因为有些 cjs 会使用 node 环境特有的变量、在模块里再 require() 其他模块(这种情况下也需要将其依赖模块在 @resource 中声明),所以尽量还是选择 umd 的代码。 77 | 78 | 另外为了尽量减少在无关页面浪费时间,components、helper 下的代码会被打包视为外部库 `'main'` 来使用,如果只需要其中一段代码则通过 `helper/XXX` 来导入即可。 79 | 80 | ## pnpm dev 81 | 82 | 这个命令总共会做三件事 83 | 84 | 1. 打包代码到 dist 85 | 2. 创建 dist 的文件服务器,用于在浏览器获取最新的脚本代码 86 | 3. 使用 vite 加载 src\components\display.tsx 以便单独测试组件 87 | -------------------------------------------------------------------------------- /docs/.other/LatestChange.md: -------------------------------------------------------------------------------- 1 | ## [11.10.0](https://github.com/hymbz/ComicReadScript/compare/v11.9.4...v11.10.0) (2025-04-17) 2 | 3 | ### Features 4 | 5 | * :sparkles: 增加自动全屏选项 ([8a91594](https://github.com/hymbz/ComicReadScript/commit/8a915945124754119819c33131f640433b6f2bb4)), closes [#237](https://github.com/hymbz/ComicReadScript/issues/237) 6 | 7 | ### Bug Fixes 8 | 9 | * :bug: 修复退出时未关闭全屏模式的 bug ([eafa2aa](https://github.com/hymbz/ComicReadScript/commit/eafa2aa26cd8f3344280d0251071802ed438f1cb)), closes [#236](https://github.com/hymbz/ComicReadScript/issues/236) 10 | -------------------------------------------------------------------------------- /docs/.other/dmzj.md: -------------------------------------------------------------------------------- 1 | 动漫之家的隐藏漫画有以下几种情况: 2 | 3 | 1. 在目录页提示「因版权国家法规……」。所有 api 都能拿到目录数据,可以直接重新生成目录,但旧APi只能获取到连载版本的章节 4 | 5 | 2. 目录页被删,但PC端漫画页还在。那就可以通过谷歌搜索找到进入 6 | 7 | 3. 目录页被删,但手机端漫画页还在,但提示漫画不存在。 8 | 那就只能通过 api 获取数据了,但因为 api 需要漫画 id,而 dmzj 的手机端 url 有两种,只有「https://m.dmzj.com/info/45163.html」格式的 url 才能拿到 id 正常调用接口获取数据。在以前可以通过 搜索找到,但现在已经上不去了。如果能搜到 id 的话,还是能通过手动构建 url 来进入 9 | 10 | 11 | > 5 级用户可以看所有隐藏漫画(被买的有版权的漫画除外),经抓包测试只需要在指定 api 加上?uid=(5 级用户的 uid) 即可获取章节列表和图片列表,无需直接登录,cookie 也不用 12 | 13 | ## 例子 14 | 15 | - 旧 api 就能拿到数据 16 | https://manhua.dmzj.com/yanquan 17 | - v4Api才能拿到数据 18 | https://manhua.dmzj.com/sexmigongzaiwojiadixiachuxianlehcishudengyudengjid 19 | 20 | ## API 21 | 22 | - https://api.dmzj.com/dynamic/comicinfo/50654.json 23 | - https://v4api.idmzj.com/comic/detail/51944?uid=2665531&disable_level=1 24 | 25 | ## 参考 26 | 27 | - https://github.com/xiaoyaocz/flutter_dmzj/blob/ecbe73eb435624022ae5a77156c5d3e0c06809cc/lib/requests/api.dart 28 | - https://github.com/erinacio/tachiyomi-extensions/blob/548be91cccb8f248342e2e7762c2c3d4b2d02036/src/zh/dmzj/src/eu/kanade/tachiyomi/extension/zh/dmzj/Dmzj.kt 29 | - https://greasyfork.org/zh-CN/scripts/466729-动漫之家解除屏蔽 30 | -------------------------------------------------------------------------------- /docs/.other/other.md: -------------------------------------------------------------------------------- 1 | 动图使用 ScreenToGif 录制,fps 24 就够了,导出格式为 Webp-高质量,手动调整质量到不会出现奇怪的色带 2 | 使用 ScreenToGif 选择录制区域时,先通过选择窗口框定大致范围,再用 ctrl、shift 配合方向键进行微调 3 | 浏览器窗口大小可以用 [sizer4](http://www.brianapps.net/sizer4/) 调整 4 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress'; 2 | 3 | import { imgSize } from './imgSize'; 4 | 5 | 6 | export default defineConfig({ 7 | lang: 'zh-CN', 8 | title: 'ComicRead Script', 9 | description: 'ComicRead Script Docs', 10 | head: [['link', { rel: 'icon', href: '/favicon.ico' }]], 11 | markdown: { config: (md) => md.use(imgSize) }, 12 | themeConfig: { 13 | nav: [ 14 | { 15 | text: 'Greasy Fork', 16 | link: 'https://sleazyfork.org/zh-CN/scripts/374903', 17 | }, 18 | { text: 'PWA', link: 'https://comic-read.pages.dev' }, 19 | ], 20 | 21 | outline: { level: 'deep' }, 22 | 23 | sidebar: [ 24 | { text: '简介', link: '/index' }, 25 | { text: '设置项说明', link: '/设置项说明' }, 26 | { text: '判断漫画左右页位置是否正确', link: '/判断左右页位置' }, 27 | 28 | { 29 | text: '功能', 30 | items: [ 31 | { text: '页面填充', link: '/功能/页面填充' }, 32 | { text: '卷轴模式', link: '/功能/卷轴模式' }, 33 | { text: 'PWA', link: '/功能/PWA' }, 34 | ], 35 | }, 36 | 37 | { text: '最简单的本地部署翻译服务流程', link: '/本地部署翻译' }, 38 | { text: '无法解决的问题', link: '/无法解决的问题' }, 39 | ], 40 | 41 | socialLinks: [ 42 | { icon: 'github', link: 'https://github.com/hymbz/ComicReadScript' }, 43 | ], 44 | 45 | docFooter: { prev: false, next: false }, 46 | 47 | externalLinkIcon: true, 48 | }, 49 | }); 50 | -------------------------------------------------------------------------------- /docs/.vitepress/imgSize.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import fetch from "sync-fetch"; 3 | import { imageMeta } from "image-meta"; 4 | import { MarkdownRenderer } from "vitepress"; 5 | 6 | const getImg = (url: string) => { 7 | if (url.startsWith("https://comic-read-docs.pages.dev/")) { 8 | const name = decodeURI(url.split("https://comic-read-docs.pages.dev/")[1]); 9 | return fs.readFileSync(`docs/public/${name}`); 10 | } 11 | 12 | if (url.startsWith("http")) { 13 | const res = fetch(url); 14 | return res.buffer(); 15 | } 16 | 17 | return fs.readFileSync(url); 18 | }; 19 | 20 | export const imgSize: Parameters[0] = (md) => { 21 | const defaultImageRenderer = md.renderer.rules.image!; 22 | 23 | md.renderer.rules.image = function (tokens, idx, options, env, self) { 24 | const token = tokens[idx]; 25 | token.attrSet("loading", "lazy"); 26 | 27 | try { 28 | const imgData = getImg(token.attrGet("src")!); 29 | const { width, height } = imageMeta(imgData); 30 | if (width) token.attrSet("width", `${width}px`); 31 | if (height) token.attrSet("height", `${height}px`); 32 | } catch (error) { 33 | debugger; 34 | console.error(error); 35 | } 36 | 37 | return defaultImageRenderer(tokens, idx, options, env, self); 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/custom.css: -------------------------------------------------------------------------------- 1 | .vp-doc h2:first-child { 2 | margin-top: 0; 3 | padding-top: 0; 4 | border-top: unset; 5 | } 6 | 7 | a > img { 8 | display: inline; 9 | margin: 0 0.1em; 10 | } 11 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.js: -------------------------------------------------------------------------------- 1 | import DefaultTheme from 'vitepress/theme' 2 | import './custom.css' 3 | 4 | export default DefaultTheme 5 | -------------------------------------------------------------------------------- /docs/public/ehentai例图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/ehentai例图.png -------------------------------------------------------------------------------- /docs/public/eh快捷收藏-列表页.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/eh快捷收藏-列表页.webp -------------------------------------------------------------------------------- /docs/public/eh快捷收藏-详情页.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/eh快捷收藏-详情页.webp -------------------------------------------------------------------------------- /docs/public/eh悬浮标签列表.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/eh悬浮标签列表.webp -------------------------------------------------------------------------------- /docs/public/eh标签染色.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/eh标签染色.webp -------------------------------------------------------------------------------- /docs/public/eh标签检查.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/eh标签检查.webp -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/判断左右页位置例图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/判断左右页位置例图.png -------------------------------------------------------------------------------- /docs/public/并排卷轴模式示例.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/并排卷轴模式示例.webp -------------------------------------------------------------------------------- /docs/public/百合会入口.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/百合会入口.jpg -------------------------------------------------------------------------------- /docs/public/百合会记录阅读进度功能.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/百合会记录阅读进度功能.jpg -------------------------------------------------------------------------------- /docs/public/百合姬简介页例图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/百合姬简介页例图.png -------------------------------------------------------------------------------- /docs/public/翻译功能示例.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/翻译功能示例.webp -------------------------------------------------------------------------------- /docs/public/翻页分镜例图1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/翻页分镜例图1.png -------------------------------------------------------------------------------- /docs/public/翻页分镜例图2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/翻页分镜例图2.png -------------------------------------------------------------------------------- /docs/public/页面填充示例.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hymbz/ComicReadScript/28b80a3c4fad1b8e44c1a43cd8a301893dd0039c/docs/public/页面填充示例.webp -------------------------------------------------------------------------------- /docs/判断左右页位置.md: -------------------------------------------------------------------------------- 1 | # 如何判断漫画左右页位置是否正确? 2 | 3 | 按照可靠度排序如下 4 | 5 | ### 1. 页数 6 | 7 | 如果能看到页数的话,直接将漫画页数调整到 **左奇右偶** 即可 8 | 9 | ### 2. 中缝 10 | 11 | 如果漫画的图源和汉化组都没有剪裁掉漫画四周的白边的话,在漫画顺序正确的情况下中间会有明显的空白区域,也就是书的中缝 12 | 13 | ### 3. 页边 14 | 15 | 有些漫画会在页边放有注释、广告之类的东西,这个只会出现在左右两边,是不会放在中缝上的 16 | 17 | ### 4. 画格 18 | 19 | 因为中缝的存在,如果画格有开口,那这个开口在大部分情况下都是对着页边的 20 | 21 | ::: info 举例 22 | ![判断左右页位置例图](https://comic-read-docs.pages.dev/判断左右页位置例图.png) 23 | *[柚原もけ] 安达与岛村 02* 24 | ::: 25 | 26 | ### 5. 经验 27 | 28 | 一些杂志——比如百合姬——上刊载的漫画会在第一页的右页放剧情梗概和角色介绍,如果汉化组有汉化这页,那这页本身就算是填充页了,不需要开「页面填充」,但如果之后汉化组不放这页了,那就肯定得开启了。还有类似的情况是,有些汉化组会将漫画第一页放上自己汉化组的 logo 作为第一页,无遮挡的原第一页放到第二页去。 29 | 30 | ::: info 举例 31 | ![百合姬简介页例图](https://comic-read-docs.pages.dev/百合姬简介页例图.png) 32 | *[岩見樹代子] 因为今天女友不在 02* 33 | ::: 34 | 35 | ### 6. 感觉 36 | 37 | 一些作者在创作时就会考虑到翻页这一动作,会特意调整页面的位置来利用翻页制造悬念、冲击或转折,让读者在翻开下一页时产生惊喜或震撼,也会用来切换场景或时间。所以漫画看多了就能在左右顺序出错时感觉到违和感,在看的时候感觉好像这页应该在翻页后再出现会更好,不过这个因为是纯凭感觉所以并不能百分百肯定。 38 | 39 | ::: info 举例 40 | ![翻页分镜例图1](https://comic-read-docs.pages.dev/翻页分镜例图1.png) 41 | ![翻页分镜例图2](https://comic-read-docs.pages.dev/翻页分镜例图2.png) 42 | *[金子ある] 二人同居与三块蛋糕* 43 | ::: 44 | -------------------------------------------------------------------------------- /docs/功能/PWA.md: -------------------------------------------------------------------------------- 1 | ### 快速下载 2 | 3 | 除了在页面里直接粘贴、点击输入 URL 按钮手动输入外,还可以通过直接跳转至 `https://comic-read.pages.dev/?url=<压缩包链接>` 来直接开始下载。 4 | 5 | 可以搭配浏览器的右键搜索功能来减少操作。 6 | -------------------------------------------------------------------------------- /docs/功能/卷轴模式.md: -------------------------------------------------------------------------------- 1 | 因为我很少用卷轴模式,所以对此并没有太多相关优化,只希望尽可能在使用体验上和正常浏览网页一致。 2 | 3 | 需要注意的几点是: 4 | 5 | - `方向键`、`空格`、`PageUp/PageDown` 等原生用于滚动的按键,即使绑定了快捷键在卷轴模式下也不会生效,而是会正常的触发滚动操作 6 | - `向上/向下翻页`的快捷键在卷轴模式下将表现为:滚动页面高度的 80% 距离 7 | - 为了避免和其他插件发生冲突,无法支持其他的滚动插件 8 | 9 | ## 设置项说明 10 | 11 | 切换到卷轴模式后设置里会多出几个卷轴模式专属的设置项: 12 | 13 | - `卷轴图片缩放`:这个缩放类似于浏览器的页面缩放,将同时缩放所有图片,和放大功能的缩放无关 14 | - `快捷滚动`:正常使用滚动条时需要鼠标点击按下后才会触发滚动,开启此设置后只要鼠标移上滚动条就会立刻触发 15 | -------------------------------------------------------------------------------- /docs/功能/页面填充.md: -------------------------------------------------------------------------------- 1 | ## 功能介绍 2 | 3 | 因为日漫一般奇数页在左,偶数页在右,所以正常只要在第一张图片前加一个填充页,使其被顶到左页上即可使左右页的位置正确。 4 | 5 | ::: info 举例 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
图1填充页
图3图2
16 | ::: 17 | 18 | 但实际总会有意外——汉化组删掉了单行本中间的空白页、将跨页图分成了左图全图右图三张图、作者在第一页的右页写了序所以也被放了进来等——导致左右页顺序乱掉,为此有了「页面填充」功能,通过手动增删填充页来将左右页顺序调整正确。 19 | 20 | ::: info 举例 21 | ![页面填充示例](https://comic-read-docs.pages.dev/页面填充示例.webp) 22 | 23 | 以这个示例动图为例,就需要在第一页用一个填充页来对齐跨页彩图,但在跨页彩图后因为删掉了百合姬简介页,所以在看完跨页后得再切换一下「页面填充」调整回来 24 | ::: 25 | 26 | 如果图片流中没有出现跨页大图,那「页面填充」的影响范围就是整个图片流。如果出现了一张跨页大图,以跨页大图为分割点,「页面填充」的影响范围将被分为两个。以此类推,图片流中出现的跨页大图将整个图片流分割为多块独立的流。通过侧边栏的页面填充按钮可以查看和修改当前流的「页面填充」的状态。 27 | 28 | ::: info 举例 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
图2图1
图4图3
图5(跨页图)
图7图6
46 | 47 |

在第一页和第二页切换「页面填充」只会在第一页增加填充页,下面跨页之后的左右顺序不受影响

48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 |
图1填充页
图3图2
空白页图4
图5(跨页图)
图7图6
70 | ::: 71 | 72 | ## 自动调整 73 | 74 | 为了保证左右页位置正确,目前脚本有以下机制: 75 | 76 | 1. 默认开启首页填充 77 | 1. 因为在除结尾外的位置出现了跨页图的话,那张跨页图大概率是页序的「正确答案」,所以如果这张跨页导致了上面一页缺页,就说明在这之前的填充有误,应该据此调整之前的填充(排除结尾是防止被结尾汉化组图误导) 78 | -------------------------------------------------------------------------------- /docs/无法解决的问题.md: -------------------------------------------------------------------------------- 1 | ### 卷轴模式下兼容 `vimium` 的滚动 2 | 3 | `vimium` 的滚动分两种,一种是直接滚动整个网页,一种是通过 `document.activeElement` 滚动当前激活的元素。 4 | 5 | 脚本为了减小对原网页的影响+简易模式设计问题,只能使用弹窗的形式,所以前者 PASS。 6 | 7 | 而为了避免被原网页的样式影响,以及被 `Dark Reader` 侵入修改掉样式,只能将所有元素放到关闭的 ShadowDom 里。这就又 PASS 掉了后者。 8 | 9 | 因此,实在是没办法兼容 `vimium`,因为相同的原因,应该也不支持其他滚动插件。并且因为相关快捷键被占用的缘故,不装 `vimium` 可以正常使用 w/s 键触发翻页快捷键的滚动,装了以后就只能按了个寂寞。虽然我自己对此也非常难受但目前也找不到办法,只能硬忍着了。 10 | 11 | ### 在 ios 的 Safari 上支持`严格CSP`的网站 12 | 13 | Safari 上无法绕过 CSP(参见 [issues](https://github.com/quoid/userscripts/issues/294)),因此没办法在网站上运行油猴脚本。 14 | 15 | ### 与 EhSyringe 共用时的性能问题 16 | 17 | 问题根源是 core-js 的 array.push 垫片,导致了在页数上千后网页会有明显卡顿感。解决办法是临时禁用 EhSyringe。 18 | -------------------------------------------------------------------------------- /docs/设置项说明.md: -------------------------------------------------------------------------------- 1 | ## 翻页 2 | 3 | ### 显示图片加载状态 4 | 5 | 进度条上会通过色块来显示对应图片的不同状态 6 | 7 | - - 加载中 8 | - - 等待加载 9 | - - 加载或翻译出错 10 | - - 空图占位中 11 | - - 等待翻译 12 | - - 翻译中或翻译完成 13 | 14 | ### 位置 15 | 16 | 除了左侧因为会和工具栏冲突外,滚动条可以通过设置移动到上下右侧。 17 | 18 | 默认会根据以下条件自动选择位置 19 | 20 | 1. 如果当前漫画中有多张连续的跨页宽图,则移动到底部。避免被漫画图片干扰看不清 21 | 1. 如果当前显示窗口过小(比如在手机上使用),则移动到顶部。避免在翻页时误触 22 | 1. 除此以外,默认位于右侧 23 | 24 | ## 显示 25 | 26 | ### 禁止图片自动放大 27 | 28 | 脚本默认会将小于显示窗口的图片缩放到能填满窗口,但有些图片在缩放后可能反倒会变模糊,因此有了这个设置用于禁止此行为。 29 | 30 | ## 翻译 31 | 32 | ### 翻译服务 33 | 34 | 因为使用本地部署的 manga-image-translator 时,根据部署配置不同,可选的翻译服务也不同,所以在选择`翻译服务器`为`本地部署`时,脚本需要发起请求来获取可用的翻译服务。 35 | 36 | 如果`翻译服务`菜单项为空,可以先再点开几次菜单,每次点开都会再次发起一个新请求。如果还不行就说明脚本无法连接到部署的 manga-image-translator,一般都是因为`自定义服务器 URL`没配置好导致的。 37 | 38 | ## 其他 39 | 40 | ### 预加载页数 41 | 42 | 预加载页数设为`x`,脚本会默认按照如下优先级加载指定页上的图片: 43 | 44 | 1. 当前显示页 45 | 1. 当前显示页后`x`页 46 | 1. 当前显示页前`x ÷ 2`页 47 | -------------------------------------------------------------------------------- /release.mjs: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url'; 2 | import path from 'node:path'; 3 | import { readFileSync } from 'node:fs'; 4 | 5 | import shell from 'shelljs'; 6 | import release from 'release-it'; 7 | 8 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 | 10 | const exec = (...commands) => { 11 | const res = shell.exec(commands.join(' && '), { 12 | silent: false, 13 | fatal: true, 14 | }); 15 | if (res.code !== 0) shell.exit(1); 16 | return res; 17 | }; 18 | 19 | (async () => { 20 | if (process.argv.slice(2).includes('push')) { 21 | const { version } = JSON.parse(readFileSync('./package.json')); 22 | 23 | // 打包代码 24 | exec('pnpm build'); 25 | 26 | // 将打包出来的脚本文件复制到根目录上 27 | shell.cp( 28 | '-f', 29 | path.join(__dirname, './dist/index.js'), 30 | path.join(__dirname, './ComicRead.user.js'), 31 | ); 32 | shell.cp( 33 | '-f', 34 | path.join(__dirname, './dist/adguard.js'), 35 | path.join(__dirname, './ComicRead-AdGuard.user.js'), 36 | ); 37 | 38 | // 提交上传更改 39 | exec( 40 | 'git add .', 41 | `git commit -m "chore: :bookmark: Release ${version}"`, 42 | `git tag --annotate v${version} --message="Release ${version}"`, 43 | 'git push --follow-tags', 44 | ); 45 | return; 46 | } 47 | 48 | // 测试 49 | exec('pnpm test run'); 50 | exec('pnpm check'); 51 | 52 | // 使用 release-it 更新版本,并获得更新日志 53 | const { changelog } = await release({ 54 | ci: true, 55 | git: { 56 | requireCommits: true, 57 | commit: false, 58 | tag: false, 59 | push: false, 60 | }, 61 | plugins: { 62 | '@release-it/conventional-changelog': { 63 | preset: 'conventionalcommits', 64 | infile: 'docs/.other/CHANGELOG.md', 65 | }, 66 | }, 67 | }); 68 | 69 | // 将最新的更改日志写入 LatestChange.md 70 | shell.echo(changelog).to('docs/.other/LatestChange.md'); 71 | })(); 72 | -------------------------------------------------------------------------------- /src/components/IconButton/index.module.css: -------------------------------------------------------------------------------- 1 | .iconButtonItem { 2 | position: relative; 3 | display: flex; 4 | align-items: center; 5 | } 6 | 7 | .iconButton { 8 | cursor: pointer; 9 | 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | 14 | width: 1.5em; 15 | height: 1.5em; 16 | margin: 0.1em; 17 | padding: 0; 18 | 19 | font-size: 1.5em; 20 | color: var(--text, white); 21 | 22 | background-color: transparent; 23 | border-style: none; 24 | border-radius: 9999px; 25 | outline: none; 26 | 27 | &:focus, 28 | &:hover { 29 | background-color: var(--hover-bg-color, #fff3); 30 | } 31 | 32 | &.enabled:not(.disable) { 33 | color: var(--text-bg, #121212); 34 | background-color: var(--text, white); 35 | 36 | &:focus, 37 | &:hover { 38 | background-color: var(--hover-bg-color-enable, #fffa); 39 | } 40 | } 41 | 42 | &.disable { 43 | cursor: not-allowed; 44 | opacity: 0.5; 45 | background-color: unset; 46 | } 47 | 48 | & > svg { 49 | width: 1em; 50 | } 51 | } 52 | 53 | /* 默认悬浮框样式 */ 54 | .iconButtonPopper { 55 | pointer-events: none; 56 | user-select: none; 57 | 58 | position: absolute; 59 | top: 50%; 60 | transform: translateY(-50%); 61 | 62 | display: flex; 63 | align-items: center; 64 | 65 | padding: 0.4em 0.5em; 66 | 67 | font-size: 0.8em; 68 | color: white; 69 | white-space: nowrap; 70 | 71 | opacity: 0; 72 | background-color: #303030; 73 | border-radius: 0.3em; 74 | 75 | &[data-placement="right"] { 76 | left: calc(100% + 1.5em); 77 | 78 | &::before { 79 | right: calc(100% + 0.5em); 80 | border-right-color: var(--switch-bg, #6e6e6e); 81 | border-right-width: 0.5em; 82 | } 83 | } 84 | 85 | &[data-placement="left"] { 86 | right: calc(100% + 1.5em); 87 | 88 | &::before { 89 | left: calc(100% + 0.5em); 90 | border-left-color: var(--switch-bg, #6e6e6e); 91 | border-left-width: 0.5em; 92 | } 93 | } 94 | } 95 | 96 | /* 工具栏按钮的悬浮框的箭头 */ 97 | .iconButtonPopper::before { 98 | pointer-events: none; 99 | content: ""; 100 | 101 | position: absolute; 102 | 103 | background-color: transparent; 104 | border-color: transparent; 105 | border-style: solid; 106 | border-width: 0.4em; 107 | 108 | transition: opacity 150ms; 109 | } 110 | 111 | /* 控制悬浮框的显示 */ 112 | .iconButtonItem:is(:hover, :focus, [data-show="true"]) .iconButtonPopper { 113 | opacity: 1; 114 | } 115 | 116 | .hidden { 117 | display: none; 118 | } 119 | -------------------------------------------------------------------------------- /src/components/IconButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, type JSX, mergeProps } from 'solid-js'; 2 | import { useStyle } from 'helper'; 3 | 4 | import classes, { css as style } from './index.module.css'; 5 | 6 | interface IconButtonProps { 7 | /** 文字提示 */ 8 | tip?: string; 9 | /** 是否显示文字提示 */ 10 | showTip?: boolean; 11 | /** 文字提示位置 */ 12 | placement?: 'left' | 'right'; 13 | /** 是否隐藏 */ 14 | hidden?: boolean; 15 | /** 是否启用 */ 16 | enabled?: boolean; 17 | /** 是否禁用 */ 18 | disable?: boolean; 19 | /** 自定义悬浮显示内容 */ 20 | popper?: JSX.Element; 21 | 22 | popperClassName?: string | boolean; 23 | children?: JSX.Element; 24 | onClick?: EventHandler['on:click']; 25 | } 26 | 27 | /** 图标按钮 */ 28 | export const IconButton: Component = (_props) => { 29 | const props = mergeProps({ placement: 'right' }, _props); 30 | let buttonRef!: HTMLButtonElement; 31 | const handleClick: EventHandler['on:click'] = (e) => { 32 | if (props.disable) return; 33 | (props.onClick as JSX.EventHandler)?.(e); 34 | // 在每次点击后取消焦点 35 | buttonRef?.blur(); 36 | }; 37 | 38 | return ( 39 |
useStyle(style, ref)} 41 | class={classes.iconButtonItem} 42 | data-show={props.showTip} 43 | > 44 | 59 | 60 | {props.popper || props.tip ? ( 61 |
65 | {props.popper || props.tip} 66 |
67 | ) : null} 68 |
69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /src/components/Manga/actions/hotkeys.ts: -------------------------------------------------------------------------------- 1 | import { createSignal } from 'solid-js'; 2 | import { createRootMemo } from 'helper'; 3 | 4 | import { store } from '../store'; 5 | 6 | export const [defaultHotkeys, setDefaultHotkeys] = createSignal< 7 | Record 8 | >({ 9 | scroll_up: ['w', 'Shift + w', 'ArrowUp'], 10 | scroll_down: ['s', 'Shift + s', 'ArrowDown', ' '], 11 | scroll_left: ['a', 'Shift + a', ',', 'ArrowLeft'], 12 | scroll_right: ['d', 'Shift + d', '.', 'ArrowRight'], 13 | page_up: ['PageUp'], 14 | page_down: [' ', 'PageDown'], 15 | jump_to_home: ['Home'], 16 | jump_to_end: ['End'], 17 | exit: ['Escape'], 18 | switch_page_fill: ['/', 'm', 'z'], 19 | switch_scroll_mode: [], 20 | switch_grid_mode: [], 21 | switch_single_double_page_mode: [], 22 | switch_dir: [], 23 | switch_auto_enlarge: [], 24 | translate_current_page: [], 25 | translate_all: [], 26 | translate_to_end: [], 27 | }); 28 | 29 | /** 快捷键配置 */ 30 | export const hotkeysMap = createRootMemo(() => 31 | Object.fromEntries( 32 | Object.entries(store.hotkeys).flatMap(([name, key]) => 33 | key.map((k) => [k, name]), 34 | ), 35 | ), 36 | ); 37 | -------------------------------------------------------------------------------- /src/components/Manga/actions/image.ts: -------------------------------------------------------------------------------- 1 | import { isEqual, throttle, createEffectOn } from 'helper'; 2 | 3 | import { handleComicData } from '../handleComicData'; 4 | import { setState, type State } from '../store'; 5 | 6 | import { activeImgIndex, isOnePageMode, pageNum } from './memo/common'; 7 | 8 | /** 重新计算图片排列 */ 9 | export const updatePageData = (state: State) => { 10 | const lastActiveImgIndex = activeImgIndex(); 11 | 12 | let newPageList: PageList = []; 13 | newPageList = isOnePageMode() 14 | ? state.imgList.map((_, i) => [i]) 15 | : handleComicData( 16 | state.imgList.map((url) => state.imgMap[url]), 17 | state.fillEffect, 18 | state.option.imgRecognition.pageFill, 19 | ); 20 | if (isEqual(state.pageList, newPageList)) return; 21 | state.pageList = newPageList; 22 | 23 | // 在图片排列改变后自动跳转回原先显示图片所在的页数 24 | if (lastActiveImgIndex !== activeImgIndex()) { 25 | const newActivePageIndex = state.pageList.findIndex((page) => 26 | page.includes(lastActiveImgIndex), 27 | ); 28 | if (newActivePageIndex !== -1) state.activePageIndex = newActivePageIndex; 29 | } 30 | }; 31 | updatePageData.throttle = throttle(() => setState(updatePageData), 100); 32 | 33 | /** 34 | * 将处理图片的相关变量恢复到初始状态 35 | * 36 | * 必须按照以下顺序调用 37 | * 1. 修改 imgList 38 | * 2. resetImgState 39 | * 3. updatePageData 40 | */ 41 | export const resetImgState = (state: State) => { 42 | // 如果用户没有手动修改过首页填充,才将其恢复初始 43 | if (typeof state.fillEffect['-1'] === 'boolean') 44 | state.fillEffect['-1'] = 45 | state.option.firstPageFill && state.imgList.length > 3; 46 | }; 47 | 48 | createEffectOn([pageNum, isOnePageMode], () => setState(updatePageData)); 49 | -------------------------------------------------------------------------------- /src/components/Manga/actions/imageRecognition.ts: -------------------------------------------------------------------------------- 1 | import { unwrap } from 'solid-js/store'; 2 | import * as Comlink from 'comlink'; 3 | import * as worker from 'worker/ImageRecognition'; 4 | import { type MainFn } from 'worker/ImageRecognition'; 5 | import { log, throttle } from 'helper'; 6 | import { showCanvas, showColorArea, showGrayList } from 'worker/helper'; 7 | 8 | import { _setState, setState, store } from '../store'; 9 | 10 | import { updatePageData } from './image'; 11 | 12 | const getImageData = (img: HTMLImageElement) => { 13 | const { naturalWidth: width, naturalHeight: height } = img; 14 | const canvas = new OffscreenCanvas(width, height); 15 | const ctx = canvas.getContext('2d', { willReadFrequently: true })!; 16 | ctx.drawImage(img, 0, 0); 17 | return ctx.getImageData(0, 0, width, height); 18 | }; 19 | 20 | export const handleImgRecognition = (img: HTMLImageElement, url: string) => { 21 | const { data, width, height } = getImageData(img); 22 | 23 | return worker.handleImg( 24 | Comlink.transfer(data, [data.buffer]), 25 | width, 26 | height, 27 | url, 28 | unwrap(store.option.imgRecognition), 29 | ); 30 | }; 31 | 32 | const mainFn = { 33 | log, 34 | updatePageData: throttle(() => setState(updatePageData), 1000), 35 | setImg: (url, key, val) => 36 | Reflect.has(store.imgMap, url) && _setState('imgMap', url, key, val), 37 | } as MainFn; 38 | if (isDevMode) 39 | Object.assign(mainFn, { showCanvas, showColorArea, showGrayList }); 40 | worker.setMainFn(Comlink.proxy(mainFn), Object.keys(mainFn)); 41 | -------------------------------------------------------------------------------- /src/components/Manga/actions/imageType.ts: -------------------------------------------------------------------------------- 1 | import { createRootEffect } from 'helper'; 2 | 3 | import { type State, setState, store } from '../store'; 4 | 5 | import { updatePageData } from './image'; 6 | import { placeholderSize } from './memo'; 7 | 8 | const isWideType = (type: ComicImg['type']) => 9 | type === 'wide' || type === 'long'; 10 | 11 | // https://www.figma.com/design/h0x2ZHVh3P3bCbnszonRqk/漫画双页阅读比例图 12 | // https://github.com/hymbz/ComicReadScript/issues/174#issuecomment-2252114640 13 | // 用于判断图片类型的比例 14 | const 单页比例 = 1920 / 2 / 1080; 15 | const 横幅比例 = 1920 / 1080; 16 | const 条漫比例 = 1920 / 2 / 1080 / 2; 17 | 18 | /** 根据比例判断图片类型 */ 19 | const getImgType = (img: { width: number; height: number }) => { 20 | const imgRatio = img.width / img.height; 21 | if (imgRatio <= 单页比例) return imgRatio < 条漫比例 ? 'vertical' : ''; 22 | return imgRatio > 横幅比例 ? 'long' : 'wide'; 23 | }; 24 | 25 | /** 更新图片类型。返回是否修改了图片类型 */ 26 | export const updateImgType = (state: State, draftImg: ComicImg) => { 27 | const { type } = draftImg; 28 | if (!draftImg.width || !draftImg.height) return false; 29 | draftImg.type = getImgType(draftImg as Required); 30 | 31 | if (isWideType(type) !== isWideType(draftImg.type)) updatePageData.throttle(); 32 | 33 | return (type ?? state.defaultImgType) !== draftImg.type; 34 | }; 35 | 36 | /** 是否自动开启过卷轴模式 */ 37 | let autoScrollMode = false; 38 | 39 | createRootEffect((prevIsWide) => { 40 | if (store.rootSize.width === 0 || store.rootSize.height === 0) return; 41 | 42 | const defaultImgType = getImgType(placeholderSize()); 43 | if (defaultImgType === store.defaultImgType) return prevIsWide; 44 | 45 | const isWide = isWideType(defaultImgType); 46 | 47 | setState((state) => { 48 | state.defaultImgType = defaultImgType; 49 | 50 | // 连续出现多张长图后,自动开启卷轴模式 51 | if ( 52 | defaultImgType === 'vertical' && 53 | !autoScrollMode && 54 | !state.option.scrollMode.enabled 55 | ) { 56 | state.option.scrollMode.enabled = true; 57 | autoScrollMode = true; 58 | return; 59 | } 60 | 61 | if (isWide !== prevIsWide) updatePageData(state); 62 | }); 63 | 64 | return isWide; 65 | }, false); 66 | -------------------------------------------------------------------------------- /src/components/Manga/actions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './helper'; 2 | export * from './hotkeys'; 3 | export * from './image'; 4 | export * from './memo'; 5 | export * from './imageSize'; 6 | export * from './operate'; 7 | export * from './pointer'; 8 | export * from './scrollbar'; 9 | export * from './show'; 10 | export * from './switch'; 11 | export * from './turnPage'; 12 | export * from './zoom'; 13 | export * from './scrollModeDrag'; 14 | export * from './abreastScroll'; 15 | export * from './scroll'; 16 | export * from './imageLoad'; 17 | export * from './imageType'; 18 | export * from './renderPage'; 19 | export * from './translation'; 20 | export * from './translation/selfhosted'; 21 | export * from './readProgress'; 22 | export * from '../handleComicData'; 23 | -------------------------------------------------------------------------------- /src/components/Manga/actions/memo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './observer'; 2 | export * from './common'; 3 | -------------------------------------------------------------------------------- /src/components/Manga/actions/memo/observer.ts: -------------------------------------------------------------------------------- 1 | import { createSignal } from 'solid-js'; 2 | import { inRange, createEffectOn, createRootMemo } from 'helper'; 3 | 4 | import { store, setState, _setState } from '../../store'; 5 | import { resetImgState, updatePageData } from '../image'; 6 | 7 | import { isAbreastMode } from './common'; 8 | 9 | /** 记录每张图片所在的页面 */ 10 | export const imgPageMap = createRootMemo(() => { 11 | const map: Record = {}; 12 | for (let i = 0; i < store.pageList.length; i++) { 13 | for (const imgIndex of store.pageList[i]) 14 | if (imgIndex !== -1) map[imgIndex] = i; 15 | } 16 | return map; 17 | }); 18 | 19 | const [_scrollTop, setScrollTop] = createSignal(0); 20 | /** 卷轴模式下的滚动距离 */ 21 | export const scrollModTop = _scrollTop; 22 | /** 滚动距离 */ 23 | export const scrollTop = createRootMemo(() => 24 | isAbreastMode() ? store.page.offset.x.px : scrollModTop(), 25 | ); 26 | export const bindScrollTop = (dom: HTMLElement) => { 27 | dom.addEventListener('scroll', () => setScrollTop(dom.scrollTop), { 28 | passive: true, 29 | }); 30 | }; 31 | 32 | // 自动切换黑暗模式 33 | const darkModeQuery = matchMedia('(prefers-color-scheme: dark)'); 34 | const autoSwitchDarkMode = (query: MediaQueryList | MediaQueryListEvent) => { 35 | if (!store.option.autoDarkMode) return; 36 | if (query.matches === store.option.darkMode) return; 37 | _setState('option', 'darkMode', query.matches); 38 | }; 39 | darkModeQuery.addEventListener('change', autoSwitchDarkMode); 40 | autoSwitchDarkMode(darkModeQuery); 41 | createEffectOn( 42 | () => store.option.autoDarkMode, 43 | () => autoSwitchDarkMode(darkModeQuery), 44 | ); 45 | 46 | // 窗口宽度小于800像素时,标记为移动端 47 | createEffectOn( 48 | () => store.rootSize.width, 49 | (width) => { 50 | const isMobile = inRange(1, width, 800); 51 | if (isMobile === store.isMobile) return; 52 | setState((state) => { 53 | state.isMobile = isMobile; 54 | resetImgState(state); 55 | updatePageData(state); 56 | }); 57 | }, 58 | ); 59 | -------------------------------------------------------------------------------- /src/components/Manga/actions/readProgress.ts: -------------------------------------------------------------------------------- 1 | import { promisifyRequest, throttle, useCache } from 'helper'; 2 | import { unwrap } from 'solid-js/store'; 3 | 4 | import { _setState, store, type State } from '../store'; 5 | import type { FillEffect } from '../store/image'; 6 | 7 | import { activeImgIndex, imgList } from './memo'; 8 | import { jumpToImg, scrollViewImg } from './scroll'; 9 | import { updateImgSize } from './imageSize'; 10 | import { updatePageData } from './image'; 11 | 12 | type Progress = { 13 | id: string; 14 | time: number; 15 | index: number; 16 | imgSize: Record; 17 | fillEffect: FillEffect; 18 | }; 19 | 20 | let cache = undefined as unknown as Awaited< 21 | ReturnType> 22 | >; 23 | 24 | const initCache = async () => { 25 | cache ||= await useCache({ progress: 'id' }, 'ReadProgress'); 26 | }; 27 | 28 | let lastIndex = -1; 29 | /** 保存阅读进度 */ 30 | export const saveReadProgress = throttle(async () => { 31 | await initCache(); 32 | 33 | const index = activeImgIndex(); 34 | if (index === lastIndex) return; 35 | lastIndex = index; 36 | 37 | if ( 38 | // 只保存 50 页以上漫画的进度 39 | store.imgList.length < 50 || 40 | // 翻到最后几页时不保存 41 | index >= store.imgList.length - 5 42 | ) 43 | return await cache.del('progress', location.pathname); 44 | 45 | const imgSize: Record = {}; 46 | for (const [i, img] of imgList().entries()) 47 | if (img.width && img.height) imgSize[i] = [img.width, img.height]; 48 | 49 | await cache.set('progress', { 50 | id: location.pathname, 51 | time: Date.now(), 52 | index, 53 | imgSize, 54 | fillEffect: unwrap(store.fillEffect), 55 | }); 56 | }, 1000); 57 | 58 | /** 恢复阅读进度 */ 59 | export const resumeReadProgress = async (state: State) => { 60 | await initCache(); 61 | const progress = await cache.get('progress', location.pathname); 62 | if (!progress) return; 63 | 64 | // 目前卷轴模式下无法避免因图片加载导致的抖动, 65 | // 为了避免在恢复阅读进度时出现问题,只能将图片显示相关的数据也存着用于恢复 66 | let i = state.imgList.length; 67 | while (i--) { 68 | const imgSize = progress.imgSize[i]; 69 | if (imgSize) updateImgSize(state.imgList[i], ...imgSize); 70 | } 71 | state.fillEffect = progress.fillEffect; 72 | updatePageData(state); 73 | if (state.option.scrollMode.enabled) 74 | setTimeout(scrollViewImg, 500, progress.index); 75 | else jumpToImg(progress.index); 76 | 77 | // 清除过时的进度 78 | const nowTime = Date.now(); 79 | cache.each('progress', async (data, cursor) => { 80 | if (nowTime - data.time < 1000 * 60 * 60 * 24 * 29) return; 81 | await promisifyRequest(cursor.delete()); 82 | }); 83 | }; 84 | -------------------------------------------------------------------------------- /src/components/Manga/actions/scrollModeDrag.ts: -------------------------------------------------------------------------------- 1 | import type { UseDrag } from 'helper'; 2 | 3 | import { refs, store } from '../store'; 4 | 5 | import { scrollTop } from './memo'; 6 | import { abreastScrollFill, setAbreastScrollFill } from './abreastScroll'; 7 | import { scrollTo } from './scroll'; 8 | import { saveReadProgress } from './readProgress'; 9 | 10 | /** 摩擦系数 */ 11 | const FRICTION_COEFF = 0.96; 12 | 13 | let lastTop = 0; 14 | let dy = 0; 15 | let lastLeft = 0; 16 | let dx = 0; 17 | let animationId: number | null = null; 18 | let lastTime: DOMHighResTimeStamp = 0; 19 | 20 | /** 逐帧计算速率 */ 21 | const calcVelocity = () => { 22 | const nowTop = store.option.scrollMode.abreastMode 23 | ? abreastScrollFill() 24 | : scrollTop(); 25 | dy = nowTop - lastTop; 26 | lastTop = nowTop; 27 | dx = store.page.offset.x.px - lastLeft; 28 | lastLeft = store.page.offset.x.px; 29 | animationId = requestAnimationFrame(calcVelocity); 30 | }; 31 | 32 | /** 逐帧计算惯性滑动 */ 33 | const handleSlide = (timestamp: DOMHighResTimeStamp) => { 34 | // 当速率足够小时停止计算动画 35 | if (Math.abs(dx) + Math.abs(dy) < 1) { 36 | animationId = null; 37 | return; 38 | } 39 | 40 | // 确保每16毫秒才减少一次速率,防止在高刷新率显示器上衰减过快 41 | if (timestamp - lastTime > 16) { 42 | dy *= FRICTION_COEFF; 43 | dx *= FRICTION_COEFF; 44 | lastTime = timestamp; 45 | } 46 | 47 | if (store.option.scrollMode.abreastMode) { 48 | scrollTo(scrollTop() + dx); 49 | setAbreastScrollFill(abreastScrollFill() + dy); 50 | } else scrollTo(scrollTop() + dy); 51 | animationId = requestAnimationFrame(handleSlide); 52 | }; 53 | 54 | let initTop = 0; 55 | let initLeft = 0; 56 | let initAbreastScrollFill = 0; 57 | 58 | export const handleScrollModeDrag: UseDrag = ( 59 | { type, xy: [x, y], initial: [ix, iy] }, 60 | e, 61 | ) => { 62 | if (!store.option.scrollMode.abreastMode && e.pointerType !== 'mouse') return; 63 | switch (type) { 64 | case 'down': { 65 | if (animationId) cancelAnimationFrame(animationId); 66 | initTop = refs.mangaBox.scrollTop; 67 | initLeft = store.page.offset.x.px * (store.option.dir === 'rtl' ? 1 : -1); 68 | initAbreastScrollFill = abreastScrollFill(); 69 | requestAnimationFrame(calcVelocity); 70 | return; 71 | } 72 | 73 | case 'move': { 74 | if (store.option.scrollMode.abreastMode) { 75 | const _dx = x - ix; 76 | const _dy = y - iy; 77 | 78 | scrollTo((initLeft + _dx) * (store.option.dir === 'rtl' ? 1 : -1)); 79 | setAbreastScrollFill(initAbreastScrollFill + _dy); 80 | } else scrollTo(initTop + iy - y); 81 | return; 82 | } 83 | 84 | case 'up': { 85 | if (animationId) cancelAnimationFrame(animationId); 86 | animationId = requestAnimationFrame(handleSlide); 87 | saveReadProgress(); 88 | } 89 | } 90 | }; 91 | -------------------------------------------------------------------------------- /src/components/Manga/actions/show.ts: -------------------------------------------------------------------------------- 1 | import { t, inRange, throttle, createEffectOn } from 'helper'; 2 | 3 | import { type State, _setState, setState, store } from '../store'; 4 | 5 | import { getImg, resetUI } from './helper'; 6 | import { activePage } from './memo'; 7 | import { updateShowRange } from './renderPage'; 8 | 9 | /** 将页面移回原位 */ 10 | export const resetPage = (state: State, animation = false) => { 11 | updateShowRange(state); 12 | state.page.offset.x.pct = 0; 13 | state.page.offset.y.pct = 0; 14 | 15 | if (state.option.scrollMode.enabled) { 16 | state.page.anima = ''; 17 | return; 18 | } 19 | 20 | let i = -1; 21 | if ( 22 | inRange(state.renderRange[0], state.activePageIndex, state.renderRange[1]) 23 | ) 24 | i = state.activePageIndex - state.renderRange[0]; 25 | if (store.page.vertical) state.page.offset.y.pct = i === -1 ? 0 : -i; 26 | else state.page.offset.x.pct = i === -1 ? 0 : i; 27 | 28 | state.page.anima = animation ? 'page' : ''; 29 | }; 30 | 31 | /** 获取指定图片的提示文本 */ 32 | export const getImgTip = (i: number) => { 33 | if (i === -1) return t('other.fill_page'); 34 | const img = getImg(i); 35 | 36 | // 如果图片未加载完毕则在其 index 后增加显示当前加载状态 37 | if (img.loadType !== 'loaded') 38 | return `${i + 1} (${t(`img_status.${img.loadType}`)})`; 39 | 40 | if ( 41 | img.translationType && 42 | img.translationType !== 'hide' && 43 | img.translationMessage 44 | ) 45 | return `${i + 1}:${img.translationMessage}`; 46 | 47 | return `${i + 1}`; 48 | }; 49 | 50 | /** 获取指定页面的提示文本 */ 51 | export const getPageTip = (pageIndex: number): string => { 52 | const page = store.pageList[pageIndex]; 53 | if (!page) return 'null'; 54 | const pageIndexText = page.map((index) => getImgTip(index)) as 55 | | [string] 56 | | [string, string]; 57 | if (pageIndexText.length === 1) return pageIndexText[0]; 58 | if (store.option.dir === 'rtl') pageIndexText.reverse(); 59 | return pageIndexText.join(' | '); 60 | }; 61 | 62 | createEffectOn( 63 | () => store.activePageIndex, 64 | () => store.show.endPage && _setState('show', 'endPage', undefined), 65 | { defer: true }, 66 | ); 67 | 68 | createEffectOn( 69 | activePage, 70 | throttle(() => store.isDragMode || setState(resetPage)), 71 | ); 72 | 73 | // 在关闭工具栏的同时关掉滚动条的强制显示 74 | createEffectOn( 75 | () => store.show.toolbar, 76 | () => 77 | store.show.scrollbar && 78 | !store.show.toolbar && 79 | _setState('show', 'scrollbar', false), 80 | { defer: true }, 81 | ); 82 | 83 | // 在切换网格模式后关掉 滚动条和工具栏 的强制显示 84 | createEffectOn( 85 | () => store.gridMode, 86 | () => setState(resetUI), 87 | { defer: true }, 88 | ); 89 | -------------------------------------------------------------------------------- /src/components/Manga/actions/translation/helper.ts: -------------------------------------------------------------------------------- 1 | import { t } from 'helper'; 2 | 3 | import { _setState, store } from '../../store'; 4 | 5 | export type TaskState = { 6 | state: 'saved' | 'finished' | 'error' | 'error-lang'; 7 | finished: boolean; 8 | waiting: number; 9 | }; 10 | 11 | export const setMessage = (url: string, msg: string) => 12 | _setState('imgMap', url, 'translationMessage', msg); 13 | 14 | const sizeDict = { '1024': 'S', '1536': 'M', '2048': 'L', '2560': 'X' }; 15 | 16 | export const createFormData = ( 17 | imgBlob: Blob, 18 | type: 'selfhosted' | 'cotrans' | 'selfhosted-old', 19 | ) => { 20 | const formData = new FormData(); 21 | const { options } = store.option.translation; 22 | const file = new File([imgBlob], `image.${imgBlob.type.split('/').at(-1)}`, { 23 | type: imgBlob.type, 24 | }); 25 | 26 | if (type === 'selfhosted') { 27 | formData.append('image', file); 28 | formData.append('config', JSON.stringify(options)); 29 | } else { 30 | formData.append('file', file); 31 | formData.append('mime', file.type); 32 | formData.append('size', sizeDict[options.detector.detection_size]); 33 | formData.append('detector', options.detector.detector); 34 | formData.append('direction', options.render.direction); 35 | formData.append('translator', options.translator.translator); 36 | formData.append( 37 | type === 'cotrans' ? 'target_language' : 'target_lang', 38 | options.translator.target_lang, 39 | ); 40 | formData.append('retry', `${store.option.translation.forceRetry}`); 41 | } 42 | return formData; 43 | }; 44 | 45 | /** 将站点列表转为选择器中的选项 */ 46 | export const createOptions = (list: string[]) => 47 | list.map( 48 | (name) => 49 | [name, t(`translation.translator.${name}`) || name] as [string, string], 50 | ); 51 | -------------------------------------------------------------------------------- /src/components/Manga/components/EmptyTip.tsx: -------------------------------------------------------------------------------- 1 | import { onAutoMount } from 'helper'; 2 | import { type Component } from 'solid-js'; 3 | 4 | export const EmptyTip: Component = () => { 5 | let ref!: HTMLHeadingElement; 6 | 7 | onAutoMount(() => { 8 | let timeoutId = 0; 9 | const observer = new IntersectionObserver( 10 | ([{ isIntersecting }]) => { 11 | if (!isIntersecting) return; 12 | timeoutId = window.setTimeout(() => { 13 | ref?.style.removeProperty('opacity'); 14 | timeoutId = 0; 15 | }, 2000); 16 | }, 17 | { threshold: 1 }, 18 | ); 19 | observer.observe(ref); 20 | 21 | return () => { 22 | observer.disconnect(); 23 | if (timeoutId) clearTimeout(timeoutId); 24 | }; 25 | }); 26 | 27 | return

; 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/Manga/components/SettingHotkeys.module.css: -------------------------------------------------------------------------------- 1 | .hotkeys { 2 | position: relative; 3 | z-index: 1; 4 | 5 | display: flex; 6 | flex-grow: 1; 7 | flex-wrap: wrap; 8 | align-items: center; 9 | 10 | padding: 0.2em; 11 | padding-top: 2em; 12 | 13 | font-size: 0.9em; 14 | color: var(--text); 15 | 16 | border-bottom: 1px solid var(--secondary-bg); 17 | 18 | & + & { 19 | margin-top: 0.5em; 20 | } 21 | 22 | &:last-child { 23 | border-bottom: none; 24 | } 25 | } 26 | 27 | .hotkeysItem { 28 | cursor: pointer; 29 | 30 | display: flex; 31 | align-items: center; 32 | 33 | box-sizing: content-box; 34 | height: 1em; 35 | margin: 0.3em; 36 | padding: 0.2em 1.2em; 37 | 38 | font-family: serif; 39 | 40 | border-radius: 0.3em; 41 | outline: 1px solid; 42 | outline-color: var(--secondary-bg); 43 | 44 | & > svg { 45 | display: none; 46 | 47 | height: 1em; 48 | margin-left: 0.4em; 49 | 50 | color: var(--page-bg); 51 | 52 | opacity: 0.5; 53 | background-color: var(--text); 54 | border-radius: 1em; 55 | 56 | &:hover { 57 | opacity: 0.9; 58 | } 59 | } 60 | 61 | &:hover { 62 | padding: 0.2em 0.5em; 63 | 64 | & > svg { 65 | display: unset; 66 | } 67 | } 68 | 69 | &:focus, 70 | &:focus-visible { 71 | outline: var(--text) solid 2px; 72 | } 73 | } 74 | 75 | .hotkeysHeader { 76 | position: absolute; 77 | top: 0; 78 | left: 0; 79 | 80 | display: flex; 81 | align-items: center; 82 | 83 | box-sizing: border-box; 84 | width: 100%; 85 | padding: 0 0.5em; 86 | 87 | & > p { 88 | line-height: 1em; 89 | text-align: start; 90 | overflow-wrap: anywhere; 91 | white-space: pre-wrap; 92 | 93 | background-color: var(--page-bg); 94 | } 95 | 96 | & > div[title] { 97 | cursor: pointer; 98 | 99 | transform: scale(0); 100 | 101 | display: flex; 102 | 103 | background-color: var(--page-bg); 104 | 105 | transition: transform 100ms; 106 | 107 | & > svg { 108 | width: 1.6em; 109 | } 110 | } 111 | } 112 | 113 | .hotkeys:hover div[title] { 114 | transform: scale(1); 115 | } 116 | -------------------------------------------------------------------------------- /src/components/Manga/components/SettingPanel.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, For, createSignal } from 'solid-js'; 2 | import { lang, createEffectOn } from 'helper'; 3 | 4 | import { defaultSettingList } from '../defaultSettingList'; 5 | import { refs, store } from '../store'; 6 | import { stopPropagation } from '../helper'; 7 | import { bindRef } from '../actions'; 8 | import classes from '../index.module.css'; 9 | 10 | /** 菜单面板 */ 11 | export const SettingPanel: Component = () => ( 12 |
17 | refs.settingPanel.scrollHeight > refs.settingPanel.clientHeight && 18 | e.stopPropagation() 19 | } 20 | onScroll={stopPropagation} 21 | on:click={stopPropagation} 22 | > 23 | 24 | {([name, SettingItem, initShow], i) => { 25 | const [show, setShwo] = createSignal(Boolean(initShow)); 26 | 27 | if (typeof initShow === 'function') 28 | createEffectOn(initShow, (val) => setShwo(Boolean(val))); 29 | 30 | return ( 31 | <> 32 | {i() ?
: null} 33 |
34 |
setShwo((prev) => !prev)} 37 | > 38 | {name} 39 | {show() ? null : ' …'} 40 |
41 |
42 | 43 |
44 |
45 | 46 | ); 47 | }} 48 |
49 |
50 | ); 51 | -------------------------------------------------------------------------------- /src/components/Manga/components/SettingsItem.tsx: -------------------------------------------------------------------------------- 1 | import type { Component, JSX } from 'solid-js'; 2 | 3 | import classes from '../index.module.css'; 4 | 5 | export interface SettingsItemProps { 6 | name: string; 7 | children?: JSX.Element | JSX.Element[]; 8 | 9 | class?: string; 10 | classList?: ClassList; 11 | style?: JSX.CSSProperties; 12 | } 13 | 14 | /** 设置菜单项 */ 15 | export const SettingsItem: Component = (props) => ( 16 |
28 |
{props.name}
29 | {props.children} 30 |
31 | ); 32 | -------------------------------------------------------------------------------- /src/components/Manga/components/SettingsItemNumber.tsx: -------------------------------------------------------------------------------- 1 | import { type Component } from 'solid-js'; 2 | 3 | import { NumberInput, type NumberInputProps } from '../../NumberInput'; 4 | 5 | import { SettingsItem, type SettingsItemProps } from './SettingsItem'; 6 | 7 | /** 数值输入框菜单项 */ 8 | export const SettingsItemNumber: Component< 9 | SettingsItemProps & NumberInputProps 10 | > = (props) => ( 11 | 16 |
17 | 18 |
19 |
20 | ); 21 | -------------------------------------------------------------------------------- /src/components/Manga/components/SettingsItemSelect.tsx: -------------------------------------------------------------------------------- 1 | import { For, createEffect } from 'solid-js'; 2 | 3 | import classes from '../index.module.css'; 4 | 5 | import { SettingsItem, type SettingsItemProps } from './SettingsItem'; 6 | 7 | export interface SettingsItemSelectProps 8 | extends SettingsItemProps { 9 | options: Array<[string, string] | [string]>; 10 | value: T; 11 | onChange: (val: T) => void; 12 | onClick?: () => void; 13 | } 14 | 15 | /** 选择器式菜单项 */ 16 | export const SettingsItemSelect = ( 17 | props: SettingsItemSelectProps, 18 | ) => { 19 | let ref!: HTMLSelectElement; 20 | 21 | createEffect(() => { 22 | ref.value = props.options?.some(([val]) => val === props.value) 23 | ? props.value 24 | : ''; 25 | }); 26 | 27 | return ( 28 | 33 | 43 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/Manga/components/SettingsItemSwitch.tsx: -------------------------------------------------------------------------------- 1 | import type { Component } from 'solid-js'; 2 | 3 | import classes from '../index.module.css'; 4 | 5 | import { SettingsItem, type SettingsItemProps } from './SettingsItem'; 6 | 7 | export interface SettingsItemSwitchProps extends SettingsItemProps { 8 | value: boolean; 9 | onChange: (val: boolean) => void; 10 | } 11 | 12 | /** 开关式菜单项 */ 13 | export const SettingsItemSwitch: Component = ( 14 | props, 15 | ) => { 16 | const handleClick = () => props.onChange(!props.value); 17 | 18 | return ( 19 | 24 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/Manga/components/SettingsShowItem.tsx: -------------------------------------------------------------------------------- 1 | import type { Component, JSX } from 'solid-js'; 2 | 3 | import classes from '../index.module.css'; 4 | 5 | /** 带有动画过渡的切换显示设置项 */ 6 | export const SettingsShowItem: Component<{ 7 | when: boolean; 8 | children: JSX.Element | JSX.Element[]; 9 | }> = (props) => ( 10 |
14 |
{props.children}
15 |
16 | ); 17 | -------------------------------------------------------------------------------- /src/components/Manga/components/Toolbar.module.css: -------------------------------------------------------------------------------- 1 | .toolbar { 2 | position: fixed; 3 | z-index: 9; 4 | top: 0; 5 | 6 | display: flex; 7 | align-items: center; 8 | justify-content: flex-start; 9 | 10 | height: 100%; 11 | } 12 | 13 | /* 工具栏面板 */ 14 | .toolbarPanel { 15 | position: relative; 16 | transform: translateX(-100%); 17 | 18 | display: flex; 19 | flex-direction: column; 20 | 21 | padding: 0.5em; 22 | 23 | transition: transform 200ms; 24 | 25 | & > hr { 26 | height: 1em; 27 | margin: 0; 28 | border: none; 29 | visibility: hidden; 30 | } 31 | } 32 | 33 | :is(.toolbar[data-show], .toolbar:hover) .toolbarPanel { 34 | transform: none; 35 | } 36 | 37 | .toolbar[data-close] .toolbarPanel { 38 | transform: translateX(-100%); 39 | visibility: hidden; 40 | } 41 | 42 | .toolbarBg { 43 | position: absolute; 44 | top: 0; 45 | right: 0; 46 | 47 | width: 100%; 48 | height: 100%; 49 | border-top-right-radius: 1em; 50 | border-bottom-right-radius: 1em; 51 | 52 | background-color: var(--page-bg); 53 | filter: opacity(0.8); 54 | } 55 | 56 | /* 移动端优化 */ 57 | .root[data-mobile] { 58 | /* 调大样式 */ 59 | & .toolbar { 60 | font-size: 1.3em; 61 | } 62 | 63 | /* 只能通过点击中心来唤出工具栏,防止误触 */ 64 | & .toolbar:not([data-show]) { 65 | pointer-events: none; 66 | } 67 | 68 | /* 减少背景的透明度,方便辨识 */ 69 | & .toolbarBg { 70 | filter: opacity(0.8); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/components/Manga/components/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, For } from 'solid-js'; 2 | import { boolDataVal, createEffectOn } from 'helper'; 3 | 4 | import { defaultButtonList } from '../defaultButtonList'; 5 | import { store } from '../store'; 6 | import classes from '../index.module.css'; 7 | import { focus } from '../actions'; 8 | 9 | /** 左侧工具栏 */ 10 | export const Toolbar: Component = () => { 11 | createEffectOn( 12 | () => store.show.toolbar, 13 | (show) => show || focus(), 14 | ); 15 | 16 | return ( 17 |