From 191cc6e0113930866f91f1c68df20f4f506de79f Mon Sep 17 00:00:00 2001 From: meidlinger Date: Wed, 17 Jun 2026 16:01:04 +0800 Subject: [PATCH 1/4] =?UTF-8?q?Update=20plugin=20=E9=9A=90=E9=98=85?= =?UTF-8?q?=E7=9B=92=20v1.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat: 初始化ZTools插件项目 - feat: 实现隐阅盒阅读器插件 - chore(gitignore): add ignore rules - chore: 相关标识重命名 - chore: add changelog - fix: 修复书架导入书籍异常未捕获、完善本地存储数据兼容逻辑 - docs: 更新README - docs: 重写README - feat: 发布1.1.0版本,新增多项功能并修复多个bug - fix(窗口拖动): 修复阅读窗口拖动拉伸卡顿问题 - chore: bump plugin version to 1.1.1 - chore: 重命名部分称呼 - feat(setting): add plain cover option, remove epub cache storage - feat: 新增MOBI电子书格式支持 - feat: 迁移封面和章节缓存到ztools.db - feat: 新增主题模式切换功能,优化深色主题样式 - chore: release v1.3.0, update plugin info and changelog --- plugins/hushreader/CHANGELOG.md | 24 +- plugins/hushreader/README.md | 66 ++- plugins/hushreader/public/plugin.json | 4 +- plugins/hushreader/src/App.vue | 51 +- .../src/components/Bookshelf/BookCard.vue | 10 +- .../src/components/Bookshelf/Modal.vue | 2 +- .../src/components/Bookshelf/ThemeToggle.vue | 62 ++ .../src/components/Bookshelf/index.vue | 76 ++- .../src/components/Settings/index.vue | 4 +- plugins/hushreader/src/main.css | 46 ++ plugins/hushreader/src/main.ts | 9 + plugins/hushreader/src/stores/books.ts | 15 +- plugins/hushreader/src/stores/config.ts | 4 +- plugins/hushreader/src/utils/db.ts | 138 +++++ plugins/hushreader/src/utils/mobiParser.ts | 517 +++++++++++++++++ plugins/hushreader/ztools.api.md | 543 ++++++++++++++++++ 16 files changed, 1533 insertions(+), 38 deletions(-) create mode 100644 plugins/hushreader/src/components/Bookshelf/ThemeToggle.vue create mode 100644 plugins/hushreader/src/utils/db.ts create mode 100644 plugins/hushreader/src/utils/mobiParser.ts create mode 100644 plugins/hushreader/ztools.api.md diff --git a/plugins/hushreader/CHANGELOG.md b/plugins/hushreader/CHANGELOG.md index da173e00..5c6b4f27 100644 --- a/plugins/hushreader/CHANGELOG.md +++ b/plugins/hushreader/CHANGELOG.md @@ -1,7 +1,23 @@ # Changelog All notable changes to this project will be documented in this file. -## [1.2.0](https://github.com/ZToolsCenter/ZTools-plugins) - 2026-06-16 + +## [1.3.0](https://github.com/me1dlinger/hushreader/releases/tag/v1.3.0) - 2026-06-17 + +### Added +- **MOBI 格式支持**:新增 MOBI 电子书格式的解析和阅读功能 +- **MOBI 加密文件解密**:支持加密 MOBI 文件的解析和阅读 +- **主题模式切换**:新增"主题模式"切换 + +### Changed +- **封面和章节内容缓存迁移至 ztools.db 数据库**:封面和章节内容不再每次实时解析,改为存到 ztools.db 数据库,提升书架加载和打开书籍的速度 + - 封面图(coverImage/customCoverImage)存入数据库文档 `cover_{bookId}` / `custom_cover_{bookId}`,书架加载时从数据库恢复 + - 章节内容存入数据库文档 `chapters_{bookId}`,打开书籍时优先从数据库加载,文件修改后自动重新解析并更新缓存 + - 删除书籍时同步清理数据库中对应的封面和章节文档 + - 开启"显示纯色封面"时清除数据库中的封面数据 + - dbStorage 中仅保留轻量数据:书籍列表(不含封面)、阅读进度、配置 + +## [1.2.0](https://github.com/me1dlinger/hushreader/releases/tag/v1.2.0) - 2026-06-16 ### Fixed - **移除大体积缓存,改为实时解析**:EPUB 封面和章节内容不再持久化到 dbStorage,改为每次需要时从文件实时解析,彻底解决存储空间溢出导致书籍丢失的问题 @@ -13,12 +29,12 @@ All notable changes to this project will be documented in this file. ### Added - **显示纯色封面选项**:其他设置中新增"显示纯色封面"开关,开启后 EPUB 不再解析封面图片,所有书籍使用纯色背景封面,节省性能消耗 -## [1.1.1](https://github.com/ZToolsCenter/ZTools-plugins) - 2026-06-16 +## [1.1.1](https://github.com/me1dlinger/hushreader/releases/tag/v1.1.1) - 2026-06-16 ### Fixed - **窗口拖动/拉伸卡顿**:修复移动和拉伸阅读窗口时严重卡顿的问题——移动预览未做帧节流、预览期间重复调用 `setAlwaysOnTop`/`moveTop` 等重操作、提交时重复推送状态 -## [1.1.0](https://github.com/ZToolsCenter/ZTools-plugins) - 2026-06-16 +## [1.1.0](https://github.com/me1dlinger/hushreader/releases/tag/v1.1.0) - 2026-06-16 ### Fixed - **设置编辑触发主窗口隐藏**:修复在设置界面编辑背景颜色输入框或快捷键时,每次输入/删除字符都会触发沉浸式阅读窗口 `show()`,导致主窗口被推到后面的问题 @@ -42,7 +58,7 @@ All notable changes to this project will be documented in this file. - **百分比进度编辑跳转**:百分比进度模式下,左键点击进度组件进入编辑模式,输入 0-100 数字后按 Enter 或点击外部区域跳转到对应进度,支持 ArrowUp/Down 微调(Shift 步进 10),Escape 取消 - **书架"最近阅读"排序**:书架排序栏新增"最近阅读"选项,按最后阅读时间降序排列,未读过的书排在最后 -## [1.0.0](https://github.com/ZToolsCenter/ZTools-plugins) - 2026-06-16 +## [1.0.0](https://github.com/me1dlinger/hushreader/releases/tag/v1.0.0) - 2026-06-16 ### Added - **隐阅盒阅读器插件**:隐阅盒阅读器插件初版实现 diff --git a/plugins/hushreader/README.md b/plugins/hushreader/README.md index 786b3f8b..b7ca6d62 100644 --- a/plugins/hushreader/README.md +++ b/plugins/hushreader/README.md @@ -2,7 +2,7 @@ # 隐阅盒 · HushReader -适配 [ZTools](https://github.com/ZToolsCenter/ZTools) 的阅读插件,支持 TXT / EPUB 格式 +适配 [ZTools](https://github.com/ZToolsCenter/ZTools) 的阅读插件,支持 TXT / EPUB / MOBI 格式 @@ -94,7 +94,7 @@ npm run build # 构建生产版本
文本预处理 -解析 TXT / EPUB 时自动预处理: +解析 TXT / EPUB / MOBI 时自动预处理: - `\r\n` / `\r` → 统一为 `\n` - Tab / 全角空格 → 普通空格 @@ -104,6 +104,62 @@ npm run build # 构建生产版本
+
+MOBI 格式解析 + +### 加密检测(DRM) + +本应用**不支持**解密 DRM 加密的书籍,但会正确识别加密类型并返回用户友好的提示: + +| 加密类型 | 说明 | 提示信息 | +| -------- | ---- | -------- | +| `0` | 未加密 | 正常解析正文 | +| `1` | 旧版 Mobipocket(单 PID) | 提示使用旧版加密方案,正文受保护 | +| `2` | Mobipocket/Kindle DRM(多 PID) | 提示使用现代加密方案,正文受保护 | + +**即使书籍加密,仍可读取**:标题、作者、封面等元数据(这些存储在未加密的记录区域)。 + +### 压缩格式支持 + +| 压缩类型 | 值 | 支持情况 | +| -------- | --- | -------- | +| `COMPRESSION_NONE` | `1` | ✅ 支持,直接读取原始内容 | +| `COMPRESSION_PALMDOC` | `2` | ✅ 支持,实现了 PalmDOC 解压缩算法 | +| `COMPRESSION_HUFFCDIC` | `17480` | ❌ 暂不支持,返回明确提示 | + +### 封面提取 + +封面图片存储在独立的资源记录中(不受正文加密影响): + +1. 从 EXTH 记录 201 获取封面索引偏移 +2. 将偏移值与 MOBI 头的 `firstImageIndex` 相加得到实际记录索引 +3. 从对应 PDB 记录中提取图片数据 +4. 自动检测图片格式(JPEG / PNG / GIF),转换为 Base64 URL + +### 解析流程 + +``` +PDB 文件头 (78字节) → 读取总记录数 + ↓ +记录偏移表 → 获取每条记录的起始位置 + ↓ +Record 0 → PalmDOC 头 + MOBI 头 + EXTH 记录 + ↓ +元数据提取:标题(FullName)、作者(EXTH 100)、封面(EXTH 201) + ↓ +加密检测 → 若加密,返回元数据 + 提示信息 + ↓ +正文记录 → 按 firstNonBookIndex 或 recordCount 读取文本记录 + ↓ +解压缩 → PalmDOC 压缩格式解码 + ↓ +编码检测 → UTF-8 或 Windows-1252 解码 + ↓ +HTML/纯文本判断 → 分章解析或按 TXT 逻辑处理 +``` + +
+ ## 项目结构 ``` @@ -117,13 +173,17 @@ npm run build # 构建生产版本 ├── src/ │ ├── App.vue # 根组件 │ ├── main.ts # 应用入口 +│ ├── main.css # 应用样式 +│ ├── env.d.ts #环境变量类型定义 │ ├── stores/ │ │ ├── books.ts # 书籍数据 + 持久化 │ │ ├── config.ts # 配置数据 + 持久化 │ │ └── reader.ts # 阅读器状态 + 分页 │ ├── utils/ +│ │ ├── db.ts # 数据库操作工具 │ │ ├── txtParser.ts # TXT 解析 + 分页 + 预处理 -│ │ └── epubParser.ts # EPUB 解析 +│ │ ├── epubParser.ts # EPUB 解析 +│ │ └── mobiParser.ts # MOBI 解析 + 加密检测 + 封面提取 │ └── components/ │ ├── Bookshelf/ # 书架组件 │ │ ├── index.vue diff --git a/plugins/hushreader/public/plugin.json b/plugins/hushreader/public/plugin.json index e7178989..3b904295 100644 --- a/plugins/hushreader/public/plugin.json +++ b/plugins/hushreader/public/plugin.json @@ -2,9 +2,9 @@ "$schema": "node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json", "name": "hushreader", "title": "隐阅盒", - "description": "沉浸式阅读,注意身后", + "description": "隐阅盒,ZTools自己的摸鱼阅读,支持TXT/EPUB/MOBI格式,沉浸式阅读", "author": "meidlinger", - "version": "1.2.0", + "version": "1.3.0", "main": "index.html", "preload": "preload/services.js", "logo": "logo.png", diff --git a/plugins/hushreader/src/App.vue b/plugins/hushreader/src/App.vue index b1eb6757..0a703afb 100644 --- a/plugins/hushreader/src/App.vue +++ b/plugins/hushreader/src/App.vue @@ -1,11 +1,14 @@ + + + + diff --git a/plugins/hushreader/src/components/Bookshelf/index.vue b/plugins/hushreader/src/components/Bookshelf/index.vue index 2b66ba57..4c45bf18 100644 --- a/plugins/hushreader/src/components/Bookshelf/index.vue +++ b/plugins/hushreader/src/components/Bookshelf/index.vue @@ -5,11 +5,14 @@ import { useConfigStore } from '../../stores/config' import { useReaderStore } from '../../stores/reader' import { parseTxt } from '../../utils/txtParser' import { parseEpub } from '../../utils/epubParser' +import { parseMobi } from '../../utils/mobiParser' +import { saveCover, loadCover, removeCover, saveCustomCover, loadCustomCover, removeCustomCover, removeBookData } from '../../utils/db' import SettingsModal from '../Settings/index.vue' import ContextMenu from './ContextMenu.vue' import BookCard from './BookCard.vue' import Toast from './Toast.vue' import Modal from './Modal.vue' +import ThemeToggle from './ThemeToggle.vue' const props = defineProps<{ enterAction?: any }>() @@ -87,6 +90,15 @@ async function openChapterList(bookId: string) { const text = window.services?.readFile(book.filePath) ?? '' const chapters = parseTxt(text, configStore.config.other.chapterRegex || undefined) chapterListItems.value = chapters.map(c => ({ index: c.index, title: c.title })) + } else if (book.format === 'mobi') { + const content = window.services?.readFileBinary?.(book.filePath) + if (content) { + const blob = new Blob([content], { type: 'application/x-mobipocket-ebook' }) + const file = new File([blob], book.filePath.split(/[\\/]/).pop() ?? 'book.mobi') + const result = await parseMobi(file) + if (result.error) { toast(`加载章节失败:${result.error}`, 'error'); showChapterList.value = false; return } + chapterListItems.value = result.chapters.map(c => ({ index: c.index, title: c.title })) + } } else { const content = window.services?.readFileBinary?.(book.filePath) if (content) { @@ -214,7 +226,9 @@ function openCoverPicker(bookId: string) { if (!file) return const reader = new FileReader() reader.onload = () => { - bookStore.updateBook(bookId, { customCoverImage: reader.result as string }) + const data = reader.result as string + bookStore.updateBook(bookId, { customCoverImage: data }) + saveCustomCover(bookId, data).catch(() => {}) toast('封面已更新', 'success') } reader.readAsDataURL(file) @@ -226,12 +240,14 @@ async function repairCover(bookId: string) { const book = bookStore.books.find(b => b.id === bookId) if (!book || book.format !== 'epub' || configStore.config.other.plainTextCover) { bookStore.updateBook(bookId, { coverImage: undefined }) + removeCover(bookId).catch(() => {}) return } try { const content = window.services?.readFileBinary?.(book.filePath) if (!content) { bookStore.updateBook(bookId, { coverImage: undefined }) + removeCover(bookId).catch(() => {}) return } const blob = new Blob([content], { type: 'application/epub+zip' }) @@ -239,11 +255,14 @@ async function repairCover(bookId: string) { const result = await parseEpub(file) if (result.coverUrl) { bookStore.updateBook(bookId, { coverImage: result.coverUrl }) + saveCover(bookId, result.coverUrl).catch(() => {}) } else { bookStore.updateBook(bookId, { coverImage: undefined }) + removeCover(bookId).catch(() => {}) } } catch { bookStore.updateBook(bookId, { coverImage: undefined }) + removeCover(bookId).catch(() => {}) } } @@ -270,14 +289,15 @@ async function importBook(filePath: string) { const name = filePath.split(/[\\/]/).pop() ?? '' const isEpub = /\.epub$/i.test(name) const isTxt = /\.txt$/i.test(name) - if (!isEpub && !isTxt) { - toast('仅支持 EPUB 和 TXT 格式', 'error') + const isMobi = /\.mobi$/i.test(name) + if (!isEpub && !isTxt && !isMobi) { + toast('仅支持 EPUB、TXT 和 MOBI 格式', 'error') return } isLoading.value = true try { - let title = name.replace(/\.(epub|txt)$/i, '') + let title = name.replace(/\.(epub|txt|mobi)$/i, '') let author = '' let coverColor = randomCoverColor() let coverImage: string | undefined @@ -296,11 +316,27 @@ async function importBook(filePath: string) { } catch {} } + if (isMobi) { + try { + const content = window.services?.readFileBinary?.(filePath) + if (content) { + const blob = new Blob([content], { type: 'application/x-mobipocket-ebook' }) + const file = new File([blob], name) + const result = await parseMobi(file) + if (result.error) { toast(`MOBI解析失败:${result.error}`, 'error'); return } + title = result.title || title + author = result.author || '' + } + } catch (e: any) { + toast(`MOBI导入失败:${e.message}`, 'error'); return + } + } + const fileModifiedAt = window.services?.getFileModifiedTime?.(filePath) const book = bookStore.addBook({ title, author, - format: isEpub ? 'epub' : 'txt', + format: isEpub ? 'epub' : isMobi ? 'mobi' : 'txt', filePath, coverColor, coverImage, @@ -308,6 +344,7 @@ async function importBook(filePath: string) { }) if (book) { + if (coverImage) saveCover(book.id, coverImage).catch(() => {}) toast(`《${title}》已加入书架`, 'success') } else { toast('该书籍已在书架中', 'info') @@ -323,7 +360,7 @@ function handleAddBook() { const picker = window.ztools?.showOpenDialog({ title: '选择书籍', buttonLabel: '导入', - filters: [{ name: '书籍文件', extensions: ['epub', 'txt'] }], + filters: [{ name: '书籍文件', extensions: ['epub', 'txt', 'mobi'] }], properties: ['openFile'] }) @@ -380,6 +417,11 @@ async function resolveEpubCovers() { const epubBooks = bookStore.books.filter(b => b.format === 'epub' && !b.coverImage && !b.customCoverImage) for (const book of epubBooks) { try { + const cached = await loadCover(book.id) + if (cached) { + book.coverImage = cached + continue + } const content = window.services?.readFileBinary?.(book.filePath) if (!content) continue const blob = new Blob([content], { type: 'application/epub+zip' }) @@ -387,6 +429,7 @@ async function resolveEpubCovers() { const result = await parseEpub(file) if (result.coverUrl) { book.coverImage = result.coverUrl + saveCover(book.id, result.coverUrl).catch(() => {}) } } catch {} } @@ -396,12 +439,13 @@ watch(() => bookStore.books.length, () => { resolveEpubCovers() }, { immediate: true }) -watch(() => configStore.config.other.plainTextCover, (plain) => { +watch(() => configStore.config.other.plainTextCover, async (plain) => { if (plain) { - bookStore.books.forEach(b => { + for (const b of bookStore.books) { b.coverImage = undefined b.customCoverImage = undefined - }) + } + await Promise.allSettled(bookStore.books.map(b => removeBookData(b.id))) } else { resolveEpubCovers() } @@ -485,7 +529,7 @@ const cfg = computed(() => configStore.config)
添加本地书籍 - 支持 EPUB / TXT + 支持 EPUB / TXT / MOBI @@ -611,6 +655,11 @@ const cfg = computed(() => configStore.config) + + +
+ +
@@ -967,4 +1016,11 @@ const cfg = computed(() => configStore.config) color: var(--c-ink-inverse); } .btn-danger:hover { opacity: 0.85; } + +.theme-toggle-fab { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 100; +} diff --git a/plugins/hushreader/src/components/Settings/index.vue b/plugins/hushreader/src/components/Settings/index.vue index 971179fb..39baa2f2 100644 --- a/plugins/hushreader/src/components/Settings/index.vue +++ b/plugins/hushreader/src/components/Settings/index.vue @@ -500,7 +500,7 @@ function commitCapture(targetArr: string[]) { .modal-overlay { position: fixed; inset: 0; - background: rgba(28, 25, 23, 0.3); + background: var(--c-overlay-bg); backdrop-filter: blur(4px); display: flex; align-items: center; @@ -824,7 +824,7 @@ function commitCapture(targetArr: string[]) { .confirm-overlay { position: fixed; inset: 0; - background: rgba(28, 25, 23, 0.3); + background: var(--c-overlay-bg); backdrop-filter: blur(4px); display: flex; align-items: center; diff --git a/plugins/hushreader/src/main.css b/plugins/hushreader/src/main.css index 5ad47894..2e396dec 100644 --- a/plugins/hushreader/src/main.css +++ b/plugins/hushreader/src/main.css @@ -41,6 +41,52 @@ --ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); + + --c-overlay-bg: rgba(28, 25, 23, 0.3); + --c-cover-text: #ffffff; + --c-cover-text-muted: rgba(255, 255, 255, 0.6); + --c-progress-bg: rgba(0, 0, 0, 0.5); + --c-progress-bar: rgba(255, 255, 255, 0.12); + --c-progress-label: rgba(255, 255, 255, 0.9); +} + +:root[data-theme="dark"] { + --c-accent: #818cf8; + --c-accent-hover: #6366f1; + --c-accent-soft: #1e1b4b; + --c-accent-muted: #4338ca; + + --c-ink: #e7e5e4; + --c-ink-secondary: #a8a29e; + --c-ink-tertiary: #78716c; + --c-ink-inverse: #1c1917; + + --c-surface: #1c1917; + --c-surface-raised: #292524; + --c-surface-sunken: #292524; + --c-surface-overlay: #292524; + + --c-border: #44403c; + --c-border-strong: #57534e; + + --c-danger: #f87171; + --c-danger-soft: #450a0a; + --c-success: #4ade80; + --c-success-soft: #052e16; + --c-warning: #fbbf24; + + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.25), 0 2px 4px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.3), 0 4px 6px rgba(0, 0, 0, 0.2); + --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.35), 0 8px 10px rgba(0, 0, 0, 0.2); + + --c-overlay-bg: rgba(0, 0, 0, 0.5); + --c-cover-text: #e7e5e4; + --c-cover-text-muted: rgba(231, 229, 228, 0.6); + --c-progress-bg: rgba(0, 0, 0, 0.6); + --c-progress-bar: rgba(255, 255, 255, 0.15); + --c-progress-label: rgba(255, 255, 255, 0.9); } *, *::before, *::after { diff --git a/plugins/hushreader/src/main.ts b/plugins/hushreader/src/main.ts index 16d1dfc4..113ed0c8 100644 --- a/plugins/hushreader/src/main.ts +++ b/plugins/hushreader/src/main.ts @@ -6,3 +6,12 @@ import App from './App.vue' const app = createApp(App) app.use(createPinia()) app.mount('#app') + +const saved = localStorage.getItem('hushreader_config') +if (saved) { + try { + const cfg = JSON.parse(saved) + const theme = cfg?.other?.theme + if (theme) document.documentElement.setAttribute('data-theme', theme) + } catch {} +} diff --git a/plugins/hushreader/src/stores/books.ts b/plugins/hushreader/src/stores/books.ts index 96617b0e..423d9c77 100644 --- a/plugins/hushreader/src/stores/books.ts +++ b/plugins/hushreader/src/stores/books.ts @@ -1,11 +1,12 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' +import { removeBookData, loadAllCovers } from '../utils/db' export interface Book { id: string title: string author: string - format: 'epub' | 'txt' + format: 'epub' | 'txt' | 'mobi' filePath: string coverColor?: string coverImage?: string @@ -56,7 +57,7 @@ export const useBookStore = defineStore('books', () => { const searchQuery = ref('') const activeCategory = ref('全部') - function load() { + async function load() { try { const data = storageGet('hushreader_books') if (data) { @@ -72,6 +73,15 @@ export const useBookStore = defineStore('books', () => { delete b.customCoverImage }) books.value = parsed + const ids = parsed.map((b: any) => b.id as string).filter(Boolean) + if (ids.length) { + const covers = await loadAllCovers(ids) + for (const book of books.value) { + const c = covers[book.id] + if (c?.cover) book.coverImage = c.cover + if (c?.customCover) book.customCoverImage = c.customCover + } + } } } const cur = storageGet('hushreader_current') @@ -109,6 +119,7 @@ export const useBookStore = defineStore('books', () => { books.value = books.value.filter(b => b.id !== id) if (currentBookId.value === id) currentBookId.value = null save() + removeBookData(id).catch(() => {}) } function updateBook(id: string, updates: Partial) { diff --git a/plugins/hushreader/src/stores/config.ts b/plugins/hushreader/src/stores/config.ts index 88551e05..b789ea77 100644 --- a/plugins/hushreader/src/stores/config.ts +++ b/plugins/hushreader/src/stores/config.ts @@ -59,6 +59,7 @@ export interface OtherConfig { listMode: boolean chapterRegex: string customFonts: string[] + theme: 'light' | 'dark' } export interface ReaderConfig { @@ -112,7 +113,8 @@ const DEFAULT_CONFIG: ReaderConfig = { plainTextCover: false, listMode: false, chapterRegex: '', - customFonts: [] + customFonts: [], + theme: 'light' }, appearance: { fontSize: 16, diff --git a/plugins/hushreader/src/utils/db.ts b/plugins/hushreader/src/utils/db.ts new file mode 100644 index 00000000..183ff962 --- /dev/null +++ b/plugins/hushreader/src/utils/db.ts @@ -0,0 +1,138 @@ +const db = () => (window as any).ztools?.db +const dbp = () => (window as any).ztools?.db?.promises + +export async function saveCover(bookId: string, coverData: string) { + try { + const p = dbp() + if (!p) return + const existing = await p.get(`cover_${bookId}`).catch(() => null) + if (existing) { + await p.put({ ...existing, data: coverData }) + } else { + await p.put({ _id: `cover_${bookId}`, data: coverData }) + } + } catch (e) { + console.warn('[db] saveCover failed', e) + } +} + +export async function loadCover(bookId: string): Promise { + try { + const p = dbp() + if (!p) return null + const doc = await p.get(`cover_${bookId}`).catch(() => null) + return doc?.data ?? null + } catch { + return null + } +} + +export async function removeCover(bookId: string) { + try { + const p = dbp() + if (!p) return + const doc = await p.get(`cover_${bookId}`).catch(() => null) + if (doc) await p.remove(doc) + } catch (e) { + console.warn('[db] removeCover failed', e) + } +} + +export async function saveCustomCover(bookId: string, coverData: string) { + try { + const p = dbp() + if (!p) return + const existing = await p.get(`custom_cover_${bookId}`).catch(() => null) + if (existing) { + await p.put({ ...existing, data: coverData }) + } else { + await p.put({ _id: `custom_cover_${bookId}`, data: coverData }) + } + } catch (e) { + console.warn('[db] saveCustomCover failed', e) + } +} + +export async function loadCustomCover(bookId: string): Promise { + try { + const p = dbp() + if (!p) return null + const doc = await p.get(`custom_cover_${bookId}`).catch(() => null) + return doc?.data ?? null + } catch { + return null + } +} + +export async function removeCustomCover(bookId: string) { + try { + const p = dbp() + if (!p) return + const doc = await p.get(`custom_cover_${bookId}`).catch(() => null) + if (doc) await p.remove(doc) + } catch (e) { + console.warn('[db] removeCustomCover failed', e) + } +} + +export async function saveChapters(bookId: string, chapters: any[]) { + try { + const p = dbp() + if (!p) return + const existing = await p.get(`chapters_${bookId}`).catch(() => null) + if (existing) { + await p.put({ ...existing, data: chapters }) + } else { + await p.put({ _id: `chapters_${bookId}`, data: chapters }) + } + } catch (e) { + console.warn('[db] saveChapters failed', e) + } +} + +export async function loadChapters(bookId: string): Promise { + try { + const p = dbp() + if (!p) return null + const doc = await p.get(`chapters_${bookId}`).catch(() => null) + return Array.isArray(doc?.data) ? doc.data : null + } catch { + return null + } +} + +export async function removeChapters(bookId: string) { + try { + const p = dbp() + if (!p) return + const doc = await p.get(`chapters_${bookId}`).catch(() => null) + if (doc) await p.remove(doc) + } catch (e) { + console.warn('[db] removeChapters failed', e) + } +} + +export async function removeBookData(bookId: string) { + await Promise.allSettled([ + removeCover(bookId), + removeCustomCover(bookId), + removeChapters(bookId) + ]) +} + +export async function loadAllCovers(bookIds: string[]): Promise> { + const result: Record = {} + await Promise.allSettled( + bookIds.map(async (id) => { + const [cover, customCover] = await Promise.all([ + loadCover(id), + loadCustomCover(id) + ]) + const entry: { cover?: string; customCover?: string } = {} + if (cover) entry.cover = cover + if (customCover) entry.customCover = customCover + if (cover || customCover) result[id] = entry + }) + ) + return result +} diff --git a/plugins/hushreader/src/utils/mobiParser.ts b/plugins/hushreader/src/utils/mobiParser.ts new file mode 100644 index 00000000..c335f482 --- /dev/null +++ b/plugins/hushreader/src/utils/mobiParser.ts @@ -0,0 +1,517 @@ +import type { Chapter } from '../stores/reader' +import { preprocessText, parseTxt } from './txtParser' + +const PALMDB_HEADER_SIZE = 78 +const RECORD_INFO_SIZE = 8 +const MOBI_HEADER_MIN_SIZE = 16 + +const TEXT_ENCODING_UTF8 = 65001 +const TEXT_ENCODING_CP1252 = 1252 + +// PalmDOC/MOBI compression types +const COMPRESSION_NONE = 1 +const COMPRESSION_PALMDOC = 2 +const COMPRESSION_HUFFCDIC = 17480 + +// PalmDOC/MOBI encryption types (see MobileRead Wiki: "MOBI" -> "MOBI DRM") +const ENCRYPTION_NONE = 0 +const ENCRYPTION_OLD_MOBIPOCKET = 1 // single-PID scheme, PC1 stream cipher +const ENCRYPTION_MOBIPOCKET = 2 // multi-PID scheme, much stronger + +interface PalmDocHeader { + compression: number + textLength: number + recordCount: number + recordSize: number + encryptionType: number +} + +interface MobiHeader { + textEncoding: number + firstNonBookIndex: number + fullNameOffset: number + fullNameLength: number + language: number + hasEXTH: boolean + exthFlags: number + headerLength: number + firstImageIndex: number +} + +interface EXTHRecord { + type: number + raw: Uint8Array + text: string +} + +function readUint8(data: Uint8Array, offset: number): number { + return data[offset] +} + +function readUint16BE(data: Uint8Array, offset: number): number { + return (data[offset] << 8) | data[offset + 1] +} + +function readUint32BE(data: Uint8Array, offset: number): number { + return ((data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]) >>> 0 +} + +function readString(data: Uint8Array, offset: number, length: number): string { + let result = '' + for (let i = 0; i < length; i++) { + const ch = data[offset + i] + if (ch === 0) break + result += String.fromCharCode(ch) + } + return result +} + +/** + * The PDB file header (78 bytes) carries the TOTAL number of records in the + * whole file (text + image + EXTH-adjacent + index records, etc.) in its + * last 2 bytes. This is different from the PalmDOC header's "record count", + * which only counts the TEXT records and lives inside record 0's data. + */ +function readTotalPdbRecordCount(data: Uint8Array): number { + return readUint16BE(data, 76) +} + +function getPalmDBRecordOffsets(data: Uint8Array, totalRecordCount: number): number[] { + const offsets: number[] = [] + const base = PALMDB_HEADER_SIZE + for (let i = 0; i < totalRecordCount; i++) { + const recordOffset = base + i * RECORD_INFO_SIZE + if (recordOffset + 4 > data.length) break + offsets.push(readUint32BE(data, recordOffset)) + } + return offsets +} + +/** + * The PalmDOC header lives at the very start of record 0's data (NOT at a + * fixed absolute file offset) — `recordZeroOffset` must be `recordOffsets[0]`. + */ +function parsePalmDocHeader(data: Uint8Array, recordZeroOffset: number): PalmDocHeader { + return { + compression: readUint16BE(data, recordZeroOffset), + textLength: readUint32BE(data, recordZeroOffset + 4), + recordCount: readUint16BE(data, recordZeroOffset + 8), + recordSize: readUint16BE(data, recordZeroOffset + 10), + encryptionType: readUint16BE(data, recordZeroOffset + 12) + } +} + +/** + * `recordZeroOffset` is the start of record 0 (same base as parsePalmDocHeader). + * The 16-byte PalmDOC header comes first, then the MOBI header (whose 'MOBI' + * magic therefore starts at recordZeroOffset + 16, not at recordZeroOffset). + */ +function parseMobiHeader(data: Uint8Array, recordZeroOffset: number): MobiHeader | null { + const mobiStart = recordZeroOffset + 16 + + if (mobiStart + MOBI_HEADER_MIN_SIZE > data.length) return null + + const magic = readString(data, mobiStart, 4) + if (magic !== 'MOBI') return null + + const headerLength = readUint32BE(data, mobiStart + 4) + const textEncoding = readUint32BE(data, mobiStart + 12) + + let firstNonBookIndex = 0 + let fullNameOffset = 0 + let fullNameLength = 0 + let language = 0 + let hasEXTH = false + let exthFlags = 0 + let firstImageIndex = 0 + + if (recordZeroOffset + 84 <= data.length) { + firstNonBookIndex = readUint32BE(data, recordZeroOffset + 80) + } + if (recordZeroOffset + 88 <= data.length) { + fullNameOffset = readUint32BE(data, recordZeroOffset + 84) + } + if (recordZeroOffset + 92 <= data.length) { + fullNameLength = readUint32BE(data, recordZeroOffset + 88) + } + if (recordZeroOffset + 96 <= data.length) { + language = readUint32BE(data, recordZeroOffset + 92) + } + if (recordZeroOffset + 112 <= data.length) { + firstImageIndex = readUint32BE(data, recordZeroOffset + 108) + } + if (recordZeroOffset + 132 <= data.length) { + exthFlags = readUint32BE(data, recordZeroOffset + 128) + hasEXTH = (exthFlags & 0x40) !== 0 + } + + return { + textEncoding, + firstNonBookIndex, + fullNameOffset, + fullNameLength, + language, + hasEXTH, + exthFlags, + headerLength, + firstImageIndex + } +} + +function parseEXTHRecords(data: Uint8Array, offset: number): EXTHRecord[] { + const records: EXTHRecord[] = [] + if (offset + 12 > data.length) return records + + const magic = readString(data, offset, 4) + if (magic !== 'EXTH') return records + + const recordCount = readUint32BE(data, offset + 8) + + let pos = offset + 12 + for (let i = 0; i < recordCount && pos + 8 <= data.length; i++) { + const recordType = readUint32BE(data, pos) + const recordLength = readUint32BE(data, pos + 4) + if (recordLength < 8 || pos + recordLength > data.length) break + + const dataLength = recordLength - 8 + const raw = data.slice(pos + 8, pos + 8 + dataLength) + let text = '' + try { + text = new TextDecoder('utf-8', { fatal: false }).decode(raw) + } catch { + text = readString(data, pos + 8, dataLength) + } + + records.push({ type: recordType, raw, text }) + pos += recordLength + } + + return records +} + +function findExthRecord(records: EXTHRecord[], type: number): EXTHRecord | undefined { + return records.find(r => r.type === type) +} + +// Some EXTH records (cover offset, thumb offset, version numbers, ...) hold a +// raw big-endian integer rather than text — decoding them as UTF-8 text and +// then `parseInt`-ing the (often unprintable) result does not work. +function exthRawToUint(raw: Uint8Array): number { + let value = 0 + for (let i = 0; i < raw.length; i++) { + value = (value << 8) | raw[i] + } + return value >>> 0 +} + +function detectImageMime(bytes: Uint8Array): string | null { + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg' + if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png' + if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif' + return null +} + +/** + * Cover art is stored as a separate, un-encrypted resource record — EXTH 201 + * gives an index that's added to the MOBI header's "first image index" field + * to find the PDB record holding the cover JPEG/PNG/GIF bytes. Since this is + * outside the encrypted text stream, it works for protected books too. + */ +function extractCoverUrl( + data: Uint8Array, + recordOffsets: number[], + firstImageIndex: number, + exthRecords: EXTHRecord[] +): string | undefined { + try { + const coverRecord = findExthRecord(exthRecords, 201) + if (!coverRecord) return undefined + + const coverRecordIndex = firstImageIndex + exthRawToUint(coverRecord.raw) + const start = recordOffsets[coverRecordIndex] + const end = recordOffsets[coverRecordIndex + 1] ?? data.length + if (start === undefined || start >= data.length || end > data.length || start >= end) return undefined + + const imageBytes = data.slice(start, end) + const mime = detectImageMime(imageBytes) + if (!mime) return undefined + + const blob = new Blob([imageBytes], { type: mime }) + return URL.createObjectURL(blob) + } catch { + return undefined + } +} + +function decompressPalmDoc(compressed: Uint8Array): Uint8Array { + const output: number[] = [] + let i = 0 + + while (i < compressed.length) { + const byte = compressed[i++] + + if (byte === 0) { + output.push(0) + } else if (byte >= 1 && byte <= 8) { + for (let j = 0; j < byte && i < compressed.length; j++) { + output.push(compressed[i++]) + } + } else if (byte >= 9 && byte <= 0x7f) { + output.push(byte) + } else if (byte >= 0x80 && byte <= 0xbf) { + const next = compressed[i++] || 0 + const distance = (((byte << 8) | next) >> 3) & 0x7ff + const length = (next & 0x7) + 3 + + for (let j = 0; j < length; j++) { + const srcIdx = output.length - distance + if (srcIdx >= 0 && srcIdx < output.length) { + output.push(output[srcIdx]) + } else { + output.push(0x20) + } + } + } else if (byte >= 0xc0) { + output.push(0x20) + output.push(byte ^ 0x80) + } + } + + return new Uint8Array(output) +} + +function decodeMobiText(raw: Uint8Array, encoding: number): string { + if (encoding === TEXT_ENCODING_UTF8) { + return new TextDecoder('utf-8').decode(raw) + } + return new TextDecoder('windows-1252').decode(raw) +} + +function stripHtmlTags(html: string): string { + let text = html + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(/]*>/gi, '') + .replace(//gi, '') + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n') + .replace(/<\/div>/gi, '\n') + .replace(/<\/li>/gi, '\n') + .replace(/<\/h[1-6]>/gi, '\n') + .replace(/<\/blockquote>/gi, '\n') + .replace(/<\/tr>/gi, '\n') + .replace(//gi, '\n---\n') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/&#(\d+);/gi, (_, code) => String.fromCharCode(parseInt(code))) + .replace(/&#x([0-9a-fA-F]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))) + .replace(/<[^>]+>/g, '') + return text +} + +function splitChaptersFromHtml(html: string): { title: string; content: string }[] { + const chapterPattern = /<\s*(h[1-6]|body|mbp:pagebreak|div\s+class\s*=\s*["']chapter)/gi + const parts: { title: string; content: string }[] = [] + + const matches: { index: number; tag: string }[] = [] + let m: RegExpExecArray | null + while ((m = chapterPattern.exec(html)) !== null) { + matches.push({ index: m.index, tag: m[1].toLowerCase() }) + } + + if (matches.length === 0) { + const text = stripHtmlTags(html) + const cleaned = preprocessText(text) + if (cleaned.trim().length > 0) { + return [{ title: '全文', content: cleaned }] + } + return [] + } + + const segments: string[] = [] + let lastIdx = 0 + for (const match of matches) { + if (match.index > lastIdx) { + segments.push(html.slice(lastIdx, match.index)) + } + lastIdx = match.index + } + if (lastIdx < html.length) { + segments.push(html.slice(lastIdx)) + } + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] + const text = stripHtmlTags(seg) + const cleaned = preprocessText(text) + if (cleaned.trim().length < 10) continue + + const headingMatch = seg.match(/<\s*h[1-6][^>]*>([\s\S]*?)<\/\s*h[1-6]>/i) + const title = headingMatch + ? stripHtmlTags(headingMatch[1]).trim() + : `第 ${parts.length + 1} 章` + + parts.push({ title, content: cleaned }) + } + + return parts +} + +function encryptionAdvisory(type: number): string { + if (type === ENCRYPTION_OLD_MOBIPOCKET) { + return '该书使用旧版 Mobipocket(单 PID)DRM 加密,正文受版权保护,本应用无法解密阅读;已读取到的仅为标题、作者、封面等元数据。' + } + if (type === ENCRYPTION_MOBIPOCKET) { + return '该书使用 Mobipocket/Kindle DRM 加密,正文受版权保护,本应用无法解密阅读;已读取到的仅为标题、作者、封面等元数据。' + } + return '该书已加密,正文内容无法解析;已读取到的仅为标题、作者、封面等元数据。' +} + +export async function parseMobi(file: File): Promise<{ + title: string + author: string + chapters: Chapter[] + coverUrl?: string + encrypted?: boolean + error?: string +}> { + const buffer = await file.arrayBuffer() + const data = new Uint8Array(buffer) + + if (data.length < PALMDB_HEADER_SIZE + RECORD_INFO_SIZE) { + return { title: '', author: '', chapters: [], error: '文件太小,不是有效的MOBI文件' } + } + + // The PDB header's own record count (not the PalmDOC "text record count") + // is what's needed to enumerate every record (text + image + EXTH, etc.). + const totalPdbRecordCount = readTotalPdbRecordCount(data) + const recordOffsets = getPalmDBRecordOffsets(data, totalPdbRecordCount) + + if (recordOffsets.length === 0) { + return { title: '', author: '', chapters: [], error: '无法读取MOBI文件的记录索引' } + } + + const firstRecordOffset = recordOffsets[0] + const palmDocHeader = parsePalmDocHeader(data, firstRecordOffset) + const mobiHeader = parseMobiHeader(data, firstRecordOffset) + + // ---- Metadata: title / author / cover. None of this is encrypted, even + // ---- when the book's text content is DRM-protected, so it always works. ---- + let title = file.name.replace(/\.mobi$/i, '') + let author = '' + let exthRecords: EXTHRecord[] = [] + + if (mobiHeader) { + if (mobiHeader.fullNameLength > 0 && mobiHeader.fullNameOffset > 0) { + try { + const nameOffset = firstRecordOffset + mobiHeader.fullNameOffset + if (nameOffset + mobiHeader.fullNameLength <= data.length) { + const nameSlice = data.slice(nameOffset, nameOffset + mobiHeader.fullNameLength) + title = new TextDecoder('utf-8', { fatal: false }).decode(nameSlice).replace(/\0/g, '') || title + } + } catch { } + } + + if (mobiHeader.hasEXTH) { + const exthOffset = firstRecordOffset + 16 + mobiHeader.headerLength + exthRecords = parseEXTHRecords(data, exthOffset) + + const authorRecord = findExthRecord(exthRecords, 100) // EXTH 100 = author/creator + if (authorRecord && authorRecord.text.trim()) { + author = authorRecord.text.trim() + } + } + } + + const coverUrl = mobiHeader + ? extractCoverUrl(data, recordOffsets, mobiHeader.firstImageIndex, exthRecords) + : undefined + + // ---- Encrypted books: stop here. We deliberately do not attempt to + // ---- decrypt DRM-protected text — only the metadata above is returned. ---- + if (palmDocHeader.encryptionType !== ENCRYPTION_NONE) { + return { + title, + author, + chapters: [], + coverUrl, + encrypted: true, + error: encryptionAdvisory(palmDocHeader.encryptionType) + } + } + + if (palmDocHeader.compression === COMPRESSION_HUFFCDIC) { + return { + title, + author, + chapters: [], + coverUrl, + error: '该书使用 HUFF/CDIC 高压缩格式,当前暂不支持解析正文(这与加密无关,是另一种压缩方案)。' + } + } + + // ---- Unencrypted: parse the actual text content as before. ---- + const textRecordStart = 1 + const textRecordEnd = mobiHeader + ? Math.min(mobiHeader.firstNonBookIndex || recordOffsets.length, recordOffsets.length) + : Math.min(palmDocHeader.recordCount + 1, recordOffsets.length) + + const textChunks: Array> = [] + for (let i = textRecordStart; i < textRecordEnd; i++) { + const start = recordOffsets[i] + const end = recordOffsets[i + 1] ?? data.length + if (start === undefined || start >= data.length || end > data.length || start >= end) continue + + const recordSlice = data.slice(start, end) + let recordData = new Uint8Array(recordSlice.buffer as ArrayBuffer, recordSlice.byteOffset, recordSlice.byteLength) + + if (palmDocHeader.compression === COMPRESSION_PALMDOC) { + try { + const decompressed = decompressPalmDoc(recordData) + recordData = new Uint8Array(decompressed.buffer as ArrayBuffer) + } catch { + // If decompression fails, use raw data + } + } + + textChunks.push(recordData) + } + + const fullRaw = new Uint8Array(textChunks.reduce((sum, c) => sum + c.length, 0)) + let offset = 0 + for (const chunk of textChunks) { + fullRaw.set(chunk, offset) + offset += chunk.length + } + + const encoding = mobiHeader?.textEncoding || TEXT_ENCODING_CP1252 + const fullText = decodeMobiText(fullRaw, encoding) + + const isHtml = /]/i.test(fullText) + + let chapters: Chapter[] + + if (isHtml) { + const parts = splitChaptersFromHtml(fullText) + chapters = parts.map((p, i) => ({ + index: i, + title: p.title, + content: p.content + })) + } else { + chapters = parseTxt(fullText) + } + + if (chapters.length === 0) { + const cleaned = preprocessText(stripHtmlTags(fullText)) + if (cleaned.trim().length > 0) { + chapters = [{ index: 0, title: '全文', content: cleaned }] + } + } + + return { title, author, chapters, coverUrl } +} \ No newline at end of file diff --git a/plugins/hushreader/ztools.api.md b/plugins/hushreader/ztools.api.md new file mode 100644 index 00000000..d66b4c95 --- /dev/null +++ b/plugins/hushreader/ztools.api.md @@ -0,0 +1,543 @@ +# ZTools 插件 API 文档 +ZTools 为插件提供了一套丰富的 API,通过全局对象 `window.ztools` 暴露。 + +## 目录 +1. [基础 API](#基础-api) +2. [事件 API](#事件-api) +3. [搜索框 API](#搜索框-api) +4. [数据库 API](#数据库-api) +5. [dbStorage 简易键值存储](#dbstorage-api) +6. [动态 Feature API](#动态-feature-api) +7. [剪贴板 API](#剪贴板-api) +8. [文件操作 API](#文件操作-api) +9. [窗口 API](#窗口-api) +10. [显示器 API](#显示器-api) +11. [Shell API](#shell-api) +12. [其他通用 API](#其他-api) +13. [AI API](#ai-api) + +## 基础 API +### ztools.getAppName() +获取应用名称。 +- 返回: `string` - 应用名称,固定返回 `'ZTools'`。 + +### ztools.getPathForFile(file) +获取拖放文件的真实路径,用于处理用户拖放文件到插件界面场景(底层基于 Electron `webUtils.getPathForFile`)。 +- 参数: + - `file: File` - 拖放事件中的 File 对象 +- 返回: `string` - 文件本地路径 + +### ztools.isMacOs() / ztools.isMacOS() +检测当前是否为 macOS 系统。 +- 返回: `boolean` + +### ztools.isWindows() +检测当前是否为 Windows 系统。 +- 返回: `boolean` + +### ztools.isLinux() +检测当前是否为 Linux 系统。 +- 返回: `boolean` + +### ztools.getNativeId() +获取设备唯一标识符(32位字符串)。 +- 返回: `string` + +### ztools.getAppVersion() +获取应用版本号。 +- 返回: `string` + +### ztools.getWindowType() +获取当前窗口类型。 +- 返回: `string` + +### ztools.isDarkColors() +检测当前是否为深色主题。 +- 返回: `boolean` + +### ztools.isDev() +检查当前插件是否处于开发模式。 +- 返回: `boolean` + +### ztools.getWebContentsId() +获取当前 WebContents ID。 +- 返回: `number` + +### ztools.setExpendHeight(height) +设置插件视图高度。 +- 参数: + - `height: number` - 期望高度,单位像素 + +### ztools.showNotification(body) +显示系统通知。 +- 参数: + - `body: string` - 通知文本内容 + +### ztools.sendInputEvent(event) +发送模拟输入事件。 +- 参数: + - `event: MouseInputEvent | MouseWheelInputEvent | KeyboardInputEvent` + +#### 事件对象结构 +##### KeyboardInputEvent 键盘事件 +```ts +{ + type: 'keyDown' | 'keyUp' | 'char', + keyCode: string, + modifiers: string[] // 修饰键,如 ['shift', 'control'] +} +``` + +##### MouseInputEvent 鼠标事件 +```ts +{ + type: 'mouseDown' | 'mouseUp' | 'mouseEnter' | 'mouseLeave' | 'contextMenu' | 'mouseMove', + x: number, + y: number, + button: 'left' | 'middle' | 'right', + clickCount: number +} +``` + +##### MouseWheelInputEvent 滚轮事件 +```ts +{ + type: 'mouseWheel', + deltaX: number, + deltaY: number, + wheelTicksX: number, + wheelTicksY: number, + accelerationRatioX: number, + accelerationRatioY: number, + hasPreciseScrollingDeltas: boolean, + canScroll: boolean +} +``` + +### ztools.simulateKeyboardTap(key, ...modifiers) +模拟键盘按键。 +- 参数: + - `key: string` - 按键标识 + - `modifiers: string[]` 可选,修饰键数组 +- 返回: `boolean` - 是否执行成功 + +### ztools.showMainWindow() +显示主窗口。 +- 返回: `Promise` + +### ztools.hideMainWindow(isRestorePreWindow) +隐藏主窗口,同时隐藏主窗口内运行的插件。 +- 参数: + - `isRestorePreWindow?: boolean` 可选,是否将焦点切回上一个活动窗口,默认 `true` +- 返回: `Promise` + +### ztools.outPlugin(isKill) +退出插件应用,默认后台隐藏插件进程。 +- 参数: + - `isKill?: boolean` 可选,为 `true` 时强制杀死插件进程 +- 返回: `Promise` + +## 事件 API +### ztools.onPluginEnter(callback) +监听插件打开事件,用户启动插件时触发。 +- 参数: + - `callback: (param: LaunchParam) => void` + +#### LaunchParam 结构 +```ts +{ + payload: any, // 外部传入数据,如搜索框文本 + type: 'text' | 'regex' | 'over', + // text:普通文本匹配 + // regex:正则匹配 + // over:任意文本匹配 + code: string // Feature 触发时对应的功能码 +} +``` + +### ztools.onPluginOut(callback) +监听插件退出事件。 +- 参数: + - `callback: (isKill: boolean) => void` + - `isKill`:是否为强制杀死进程退出 + +### ztools.onPluginDetach(callback) +监听插件分离独立窗口事件,用户拖拽插件脱离主窗口时触发。 +- 参数: + - `callback: () => void` + +### ztools.onMainPush(callback, selectCallback) +注册主搜索栏推送结果,无需打开插件即可展示搜索列表。 +- 参数: + - `callback: (queryData: any) => object[]`:输入查询回调,返回结果数组 + - `selectCallback?: (selectData: any) => boolean`:选中结果回调,返回 `true` 自动进入插件 + +### ztools.onPluginReady(callback) +兼容旧版 API,功能等同于 `onPluginEnter`。 +- 参数: + - `callback: (param: LaunchParam) => void` + +## 搜索框 API +### ztools.setSubInput(onChange, placeholder, isFocus) +插件激活时配置主窗口子搜索框行为。 +- 参数: + - `onChange: (text: string) => void`:输入内容变更回调 + - `placeholder: string`:输入框占位提示文字 + - `isFocus?: boolean` 可选,自动聚焦输入框,默认 `true` + +### ztools.setSubInputValue(text) +设置子输入框内容。 +- 参数: + - `text: string` + +### ztools.subInputFocus() +主动聚焦子输入框。 +- 返回: `boolean` + +### ztools.subInputBlur() +子输入框失焦,焦点切回插件页面。 +- 返回: `boolean` + +### ztools.subInputSelect() +聚焦输入框并全选已有内容。 +- 返回: `boolean` + +### ztools.removeSubInput() +隐藏/移除子搜索框。 +- 返回: `Promise` + +## 数据库 API +插件拥有独立隔离存储空间(Bucket),以插件标识隔离数据。同步方法如下: + +### ztools.db.put(doc) +写入/更新文档,文档必须包含 `_id`。 +- 参数: + - `doc: object` +- 返回: `object` - 写入后的完整文档(含 `_id`、`_rev`) + +### ztools.db.get(id) +根据 ID 查询文档。 +- 参数: + - `id: string` +- 返回: `object | null`,不存在返回 `null` + +### ztools.db.remove(docOrId) +删除文档。 +- 参数: + - `docOrId: object | string`:完整文档对象(需 `_id/_rev`)或文档ID +- 返回: `object` - 删除结果 + +### ztools.db.bulkDocs(docs) +批量新增/更新文档。 +- 参数: + - `docs: object[]` +- 返回: `object[]` - 每条文档操作结果 + +### ztools.db.allDocs(key) +查询全部文档,支持 ID 前缀过滤。 +- 参数: + - `key?: string` 可选,文档ID前缀过滤 +- 返回: `object[]` + +### ztools.db.postAttachment(id, attachment, type) +为文档添加二进制附件。 +- 参数: + - `id: string` 文档ID + - `attachment: string | Buffer` base64字符串或二进制Buffer + - `type: string` MIME类型 +- 返回: `object` + +### ztools.db.getAttachment(id) +读取文档附件二进制内容。 +- 参数: + - `id: string` +- 返回: `Buffer` + +### ztools.db.getAttachmentType(id) +获取附件 MIME 类型。 +- 参数: + - `id: string` +- 返回: `string` + +### Promise 异步版本数据库 +所有同步方法均存在异步 Promise 实现,挂载于 `ztools.db.promises`,方法签名一致,返回 Promise: +```js +window.ztools.db.promises.put(doc) +window.ztools.db.promises.get(id) +window.ztools.db.promises.remove(docOrId) +window.ztools.db.promises.bulkDocs(docs) +window.ztools.db.promises.allDocs(key) +window.ztools.db.promises.postAttachment(id, attachment, type) +window.ztools.db.promises.getAttachment(id) +window.ztools.db.promises.getAttachmentType(id) +``` + +## dbStorage API +轻量化键值持久化存储,类似 localStorage,自动 JSON 序列化。 +### ztools.dbStorage.setItem(key, value) +- 参数: + - `key: string` + - `value: any` 任意可序列化数据 + +### ztools.dbStorage.getItem(key) +- 参数: + - `key: string` +- 返回: `any | null`,无数据返回 `null` + +### ztools.dbStorage.removeItem(key) +删除指定键。 +- 参数: + - `key: string` + +## 动态 Feature API +### ztools.getFeatures(codes) +获取动态注册功能项。 +- 参数: + - `codes?: string[]` 可选,指定功能码列表,不传返回全部 +- 返回: `object[]` Feature 数组 + +### ztools.setFeature(feature) +新增/更新动态 Feature。 +- 参数: + - `feature: object` +- 返回: `boolean` + +### ztools.removeFeature(code) +删除指定 Feature。 +- 参数: + - `code: string` Feature 唯一编码 +- 返回: `boolean` + +## 剪贴板 API +### ztools.clipboard.getHistory(page, pageSize, filter) +分页读取剪贴板历史记录。 +- 参数: + - `page: number` 页码,从 1 开始 + - `pageSize: number` 单页条数 + - `filter?: string` 过滤关键词 +- 返回: `Promise` + +### ztools.clipboard.search(keyword) +全文检索剪贴板历史。 +- 参数: + - `keyword: string` +- 返回: `Promise` + +### ztools.clipboard.delete(id) +删除单条剪贴板历史记录。 +- 参数: + - `id: string` 记录ID +- 返回: `Promise` + +### ztools.clipboard.clear(type) +清空剪贴板历史,支持类型过滤。 +- 参数: + - `type?: string` 可选,指定数据类型 +- 返回: `Promise` + +### ztools.clipboard.getStatus() +读取剪贴板模块运行状态。 +- 返回: `Promise` + +### ztools.clipboard.write(id, shouldPaste) +将历史记录重新写入系统剪贴板。 +- 参数: + - `id: string` 记录ID + - `shouldPaste?: boolean` 可选,写入后模拟粘贴,默认 `true` +- 返回: `Promise` + +### ztools.clipboard.writeContent(data, shouldPaste) +自定义内容写入剪贴板。 +- 参数: + ```ts + data: { + type: 'text' | 'image', + content: string // 文本或base64图片 + } + ``` + - `shouldPaste?: boolean` 可选,默认 `true` +- 返回: `Promise` + +### ztools.clipboard.updateConfig(config) +修改剪贴板模块全局配置。 +- 参数: + - `config: object` +- 返回: `Promise` + +### ztools.clipboard.onChange(callback) +监听剪贴板新增记录事件。 +- 参数: + - `callback: (item: object) => void` + +### ztools.copyText(text) +快速复制文本到剪贴板(同步)。 +- 参数: + - `text: string` +- 返回: `boolean` + +### ztools.copyImage(image) +复制图片到剪贴板。 +- 参数: + - `image: string` base64 DataURL 或本地文件路径 +- 返回: `boolean` + +### ztools.copyFile(filePath) +复制文件到剪贴板。 +- 参数: + - `filePath: string` 文件完整路径 +- 返回: `boolean` + +## 文件操作 API +### ztools.getPath(name) +获取系统标准目录路径。 +- 参数: + - `name: string` 目录标识,支持 `home` / `desktop` / `documents` 等 +- 返回: `string` + +### ztools.showSaveDialog(options) +弹出文件保存窗口(同步)。 +- 参数: + - `options: SaveDialogOptions` 配置同 Electron `showSaveDialogSync` +- 返回: `string | undefined` 用户选择路径,取消返回 `undefined` + +### ztools.showOpenDialog(options) +弹出文件选择窗口(同步)。 +- 参数: + - `options: OpenDialogOptions` 配置同 Electron `showOpenDialogSync` +- 返回: `string[] | undefined` 选中文件路径数组,取消返回 `undefined` + +### ztools.screenCapture(callback) +唤起系统截图工具,截图完成回调返回图片。 +- 参数: + - `callback: (image: string) => void` + - `image`:截图 base64 DataURL + +## 窗口 API +### ztools.createBrowserWindow(url, options, callback) +创建独立浏览器窗口。 +- 参数: + - `url: string` 页面加载地址 + - `options: object` 窗口配置,兼容 Electron BrowserWindow 参数 + - `callback?: () => void` 可选,窗口加载完成回调 +- 返回: `Proxy | null` 窗口代理对象,创建失败返回 `null` + +### ztools.sendToParent(channel, ...args) +向父窗口发送 IPC 消息。 +- 参数: + - `channel: string` 消息通道名 + - `args: any[]` 透传参数列表 + +## 显示器 API +### ztools.getPrimaryDisplay() +获取主显示器信息。 +- 返回: `object` + +### ztools.getAllDisplays() +获取全部显示器列表。 +- 返回: `object[]` + +### ztools.getCursorScreenPoint() +获取鼠标光标屏幕坐标。 +- 返回: `{ x: number, y: number }` + +### ztools.getDisplayNearestPoint(point) +根据坐标查找最近的显示器。 +- 参数: + - `point: { x: number, y: number }` +- 返回: `object` 显示器信息 + +### ztools.desktopCaptureSources(options) +获取屏幕/窗口捕获源列表(录屏截图使用)。 +- 参数: + - `options: object` +- 返回: `Promise` + +### ztools.dipToScreenPoint(point) +DIP 逻辑坐标 → 屏幕物理像素坐标。 +- 参数: + - `point: { x: number, y: number }` +- 返回: `{ x: number, y: number }` + +### ztools.screenToDipPoint(point) +屏幕物理像素坐标 → DIP 逻辑坐标。 +- 参数: + - `point: { x: number, y: number }` +- 返回: `{ x: number, y: number }` + +### ztools.dipToScreenRect(rect) +DIP 矩形区域 → 物理像素矩形。 +- 参数: + - `rect: { x: number, y: number, width: number, height: number }` +- 返回: `{ x: number, y: number, width: number, height: number }` + +## Shell API +### ztools.shellOpenExternal(url) +系统默认浏览器打开网页链接。 +- 参数: + - `url: string` +- 返回: `boolean` + +### ztools.shellOpenPath(fullPath) +使用系统默认程序打开文件/文件夹。 +- 参数: + - `fullPath: string` 完整本地路径 +- 返回: `boolean` + +### ztools.shellShowItemInFolder(fullPath) +文件管理器定位并选中目标文件。 +- 参数: + - `fullPath: string` 文件路径 +- 返回: `boolean` + +## 其他 API +### ztools.redirect(label, payload) +插件间跳转。 +- 参数: + - `label: string` 目标插件标识 + - `payload: any` 传递数据 +- 返回: `boolean` + +### ztools.http.setHeaders(headers) +全局设置 HTTP 请求公共请求头。 +- 参数: + - `headers: object` +- 返回: `boolean` + +### ztools.http.getHeaders() +读取当前全局请求头。 +- 返回: `object` + +### ztools.http.clearHeaders() +清空全局 HTTP 请求头配置。 +- 返回: `boolean` + +## AI API +### ztools.ai(option, streamCallback) +调用内置 AI 模型,支持流式输出与中断请求。 +- 参数: + - `option: object` AI 请求配置,必填 `prompt` 字段 + - `streamCallback?: (chunk: any) => void` 可选,传入则启用流式分段回调 +- 返回: `PromiseLike & { abort: () => void }` + - 非流式:await 直接获取完整返回结果 + - 流式:分段数据通过 streamCallback 推送,await 等待请求结束 + - `.abort()` 手动中断当前 AI 请求 + +#### 使用示例 +```js +// 非流式完整返回 +const result = await ztools.ai({ prompt: '你好' }) + +// 流式逐段接收 +const request = ztools.ai({ prompt: '你好' }, (chunk) => { + console.log('流式分片:', chunk) +}) +await request + +// 中途终止AI请求 +request.abort() +``` + +### ztools.allAiModels() +获取全部可用 AI 模型列表。 +- 返回: `Promise` +- 异常:接口获取失败抛出 Error \ No newline at end of file From d962f48f2e982d32d2a5f1fdd743e68a8e1566f4 Mon Sep 17 00:00:00 2001 From: meidlinger Date: Wed, 17 Jun 2026 17:51:32 +0800 Subject: [PATCH 2/4] =?UTF-8?q?Update=20plugin=20=E9=9A=90=E9=98=85?= =?UTF-8?q?=E7=9B=92=20v1.3.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat: 初始化ZTools插件项目 - feat: 实现隐阅盒阅读器插件 - chore(gitignore): add ignore rules - chore: 相关标识重命名 - chore: add changelog - fix: 修复书架导入书籍异常未捕获、完善本地存储数据兼容逻辑 - docs: 更新README - docs: 重写README - feat: 发布1.1.0版本,新增多项功能并修复多个bug - fix(窗口拖动): 修复阅读窗口拖动拉伸卡顿问题 - chore: bump plugin version to 1.1.1 - chore: 重命名部分称呼 - feat(setting): add plain cover option, remove epub cache storage - feat: 新增MOBI电子书格式支持 - feat: 迁移封面和章节缓存到ztools.db - feat: 新增主题模式切换功能,优化深色主题样式 - chore: release v1.3.0, update plugin info and changelog - style: 优化窗口进度显示 - chore: 发布v1.3.1版本,更新功能与修复问题 - chore: bump plugin version to 1.3.1 --- plugins/hushreader/CHANGELOG.md | 13 ++++- plugins/hushreader/public/hushreader.html | 55 ++++++++++--------- plugins/hushreader/public/plugin.json | 2 +- plugins/hushreader/src/App.vue | 10 +++- .../src/components/Settings/index.vue | 24 +++----- plugins/hushreader/src/stores/config.ts | 8 +-- 6 files changed, 60 insertions(+), 52 deletions(-) diff --git a/plugins/hushreader/CHANGELOG.md b/plugins/hushreader/CHANGELOG.md index 5c6b4f27..b914b30f 100644 --- a/plugins/hushreader/CHANGELOG.md +++ b/plugins/hushreader/CHANGELOG.md @@ -2,11 +2,22 @@ All notable changes to this project will be documented in this file. +## [1.3.1](https://github.com/me1dlinger/hushreader/releases/tag/v1.3.1) - 2026-06-17 + +### Added +- **窗口大小锁定开关**:功能设置中新增"窗口大小锁定"开关,开启后窗口不允许拉伸调整大小 + +### Fixed +- **窗口可拖动开关生效**:修复关闭"窗口可拖动"后窗口仍可拖动的问题,关闭后拖动区域 cursor 变为 default + +### Removed +- **固定行数分页模式**:移除分页模式中的"固定行数"选项,统一使用自适应模式 + + ## [1.3.0](https://github.com/me1dlinger/hushreader/releases/tag/v1.3.0) - 2026-06-17 ### Added - **MOBI 格式支持**:新增 MOBI 电子书格式的解析和阅读功能 -- **MOBI 加密文件解密**:支持加密 MOBI 文件的解析和阅读 - **主题模式切换**:新增"主题模式"切换 ### Changed diff --git a/plugins/hushreader/public/hushreader.html b/plugins/hushreader/public/hushreader.html index bf60a5f5..8522bf52 100644 --- a/plugins/hushreader/public/hushreader.html +++ b/plugins/hushreader/public/hushreader.html @@ -215,7 +215,6 @@ grid-template-rows: minmax(0, 1fr); min-width: 0; overflow: hidden; - cursor: grab; touch-action: none; } @@ -223,6 +222,18 @@ cursor: grabbing; } + body.no-move .hushreader-content { + cursor: default; + } + + body.no-move.is-moving .hushreader-content { + cursor: default; + } + + body.no-resize .hushreader-resize-zone { + cursor: default; + } + .hushreader-lines { display: grid; align-content: center; @@ -274,22 +285,21 @@ } .show-meta .hushreader-reader { - grid-template-rows: 18px minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); } .show-meta .hushreader-content { - grid-row: 2; + grid-row: 1; } .show-meta .hushreader-meta { - position: relative; + position: absolute; + bottom: 1px; + right: 32px; top: auto; - right: auto; - grid-row: 1; - grid-column: 1; - align-self: start; - justify-self: end; - margin: 2px 32px 0 10px; + left: auto; + z-index: 4; + margin: 0; opacity: 0.66; pointer-events: auto; cursor: default; @@ -359,20 +369,6 @@ background: rgba(255, 255, 255, 0.15); } - @media (max-height: 42px) { - .show-meta .hushreader-reader { - grid-template-rows: minmax(0, 1fr); - } - - .show-meta .hushreader-content { - grid-row: 1; - } - - .show-meta .hushreader-meta { - display: none; - } - } - .hushreader-reader { border: 0; background: var(--hushreader-bg-color); @@ -385,7 +381,7 @@ } .hushreader-meta { - background: rgba(0, 0, 0, 0.3); + background: transparent; color: var(--hushreader-text-color); } @@ -489,7 +485,7 @@ function normalizeResizeSize(width, height) { return { width: clamp(snap(width, 10), getResizeLimit("minWidth", 280), getResizeLimit("maxWidth", 1180)), - height: clamp(snap(height, 2), getResizeLimit("minHeight", 22), getResizeLimit("maxHeight", 160)) + height: clamp(snap(height, 2), getResizeLimit("minHeight", 22), getResizeLimit("maxHeight", 500)) } } @@ -728,6 +724,8 @@ settings.showHushreaderMeta ? "show-meta" : "", isAutoHidden && visible && hideMode === 'hide-all' ? "is-auto-hidden" : "", isAutoHidden && visible && hideMode === 'show-progress' ? "is-auto-hidden-show-progress" : "", + !settings.windowMovable ? "no-move" : "", + settings.windowSizeLocked ? "no-resize" : "", visible ? "" : "is-hidden" ] .filter(Boolean) @@ -886,6 +884,10 @@ document.addEventListener("pointerdown", (event) => { const handle = event.target.closest("[data-resize]") if (handle) { + const isSizeLocked = Boolean(latestSettings.windowSizeLocked) + + if (isSizeLocked) return + event.preventDefault() event.stopPropagation() @@ -908,6 +910,7 @@ const moveHandle = event.target.closest("[data-move]") if (!moveHandle || resizeState || event.button !== 0) return + if (!latestSettings.windowMovable) return event.preventDefault() event.stopPropagation() diff --git a/plugins/hushreader/public/plugin.json b/plugins/hushreader/public/plugin.json index 3b904295..f3822f18 100644 --- a/plugins/hushreader/public/plugin.json +++ b/plugins/hushreader/public/plugin.json @@ -4,7 +4,7 @@ "title": "隐阅盒", "description": "隐阅盒,ZTools自己的摸鱼阅读,支持TXT/EPUB/MOBI格式,沉浸式阅读", "author": "meidlinger", - "version": "1.3.0", + "version": "1.3.1", "main": "index.html", "preload": "preload/services.js", "logo": "logo.png", diff --git a/plugins/hushreader/src/App.vue b/plugins/hushreader/src/App.vue index 0a703afb..43c6bc93 100644 --- a/plugins/hushreader/src/App.vue +++ b/plugins/hushreader/src/App.vue @@ -36,7 +36,7 @@ type AppBrowserWindow = { const FISH_MIN_WIDTH = 280 const FISH_MAX_WIDTH = 1180 const FISH_MIN_HEIGHT = 22 -const FISH_MAX_HEIGHT = 160 +const FISH_MAX_HEIGHT = 500 const FISH_META_ROW_HEIGHT = 18 const FISH_SIDE_CONTROLS_WIDTH = 44 const FISH_CONTENT_PADDING = 20 @@ -213,7 +213,9 @@ function getHushreaderPayload(bounds = getHushreaderWindowBounds()) { bgColor: hushreaderCfg.value.bgColor, textColor: hushreaderCfg.value.textColor, autoFlipEnabled: hushreaderCfg.value.autoFlipEnabled, - fontFamily: hushreaderCfg.value.fontFamily + fontFamily: hushreaderCfg.value.fontFamily, + windowMovable: cfg.value.function.windowMovable, + windowSizeLocked: cfg.value.function.windowSizeLocked } } } @@ -607,7 +609,9 @@ watch( hushreaderCfg.value.bgColor, hushreaderCfg.value.textColor, hushreaderCfg.value.autoFlipEnabled, - hushreaderCfg.value.fontFamily + hushreaderCfg.value.fontFamily, + cfg.value.function.windowMovable, + cfg.value.function.windowSizeLocked ], () => { nextTick(() => { diff --git a/plugins/hushreader/src/components/Settings/index.vue b/plugins/hushreader/src/components/Settings/index.vue index 39baa2f2..a70b382c 100644 --- a/plugins/hushreader/src/components/Settings/index.vue +++ b/plugins/hushreader/src/components/Settings/index.vue @@ -235,7 +235,7 @@ function commitCapture(targetArr: string[]) {
- + {{ cfg.hushreader.hushreaderHeight }}px
@@ -394,25 +394,17 @@ function commitCapture(targetArr: string[]) {
- - -
- -
- -
- - -
+ +
- +
diff --git a/plugins/hushreader/src/stores/config.ts b/plugins/hushreader/src/stores/config.ts index b789ea77..a5c01c0c 100644 --- a/plugins/hushreader/src/stores/config.ts +++ b/plugins/hushreader/src/stores/config.ts @@ -39,8 +39,6 @@ export interface AppearanceConfig { } export interface FunctionConfig { - pageMode: 'adaptive' | 'fixed' - pageLines: number scrollWheelEnabled: boolean scrollWheelDelay: number nextPageKeys: string[] @@ -51,6 +49,7 @@ export interface FunctionConfig { autoFlipInterval: number mouseHideEnabled: boolean windowMovable: boolean + windowSizeLocked: boolean } export interface OtherConfig { @@ -95,8 +94,6 @@ const DEFAULT_CONFIG: ReaderConfig = { progressMode: 'percent' }, function: { - pageMode: 'adaptive', - pageLines: 5, scrollWheelEnabled: true, scrollWheelDelay: 300, nextPageKeys: ['ArrowRight', 'PageDown'], @@ -106,7 +103,8 @@ const DEFAULT_CONFIG: ReaderConfig = { autoFlipEnabled: false, autoFlipInterval: 5000, mouseHideEnabled: true, - windowMovable: true + windowMovable: true, + windowSizeLocked: false }, other: { configName: '配置', From 710167982cdaf73d2397f986f71182dd734b9f63 Mon Sep 17 00:00:00 2001 From: meidlinger Date: Wed, 17 Jun 2026 18:07:47 +0800 Subject: [PATCH 3/4] =?UTF-8?q?Update=20plugin=20=E9=9A=90=E9=98=85?= =?UTF-8?q?=E7=9B=92=20v1.3.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat: 初始化ZTools插件项目 - feat: 实现隐阅盒阅读器插件 - chore(gitignore): add ignore rules - chore: 相关标识重命名 - chore: add changelog - fix: 修复书架导入书籍异常未捕获、完善本地存储数据兼容逻辑 - docs: 更新README - docs: 重写README - feat: 发布1.1.0版本,新增多项功能并修复多个bug - fix(窗口拖动): 修复阅读窗口拖动拉伸卡顿问题 - chore: bump plugin version to 1.1.1 - chore: 重命名部分称呼 - feat(setting): add plain cover option, remove epub cache storage - feat: 新增MOBI电子书格式支持 - feat: 迁移封面和章节缓存到ztools.db - feat: 新增主题模式切换功能,优化深色主题样式 - chore: release v1.3.0, update plugin info and changelog - style: 优化窗口进度显示 - chore: 发布v1.3.1版本,更新功能与修复问题 - chore: bump plugin version to 1.3.1 - fix: 修复多个已知问题并优化部分功能 --- plugins/hushreader/CHANGELOG.md | 6 ++++++ .../src/components/Bookshelf/ThemeToggle.vue | 11 ++++------- plugins/hushreader/src/components/Bookshelf/index.vue | 3 ++- plugins/hushreader/src/utils/mobiParser.ts | 8 ++++++-- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/plugins/hushreader/CHANGELOG.md b/plugins/hushreader/CHANGELOG.md index b914b30f..9e4146cc 100644 --- a/plugins/hushreader/CHANGELOG.md +++ b/plugins/hushreader/CHANGELOG.md @@ -9,6 +9,12 @@ All notable changes to this project will be documented in this file. ### Fixed - **窗口可拖动开关生效**:修复关闭"窗口可拖动"后窗口仍可拖动的问题,关闭后拖动区域 cursor 变为 default +- **ThemeToggle 主题未随配置加载更新**:改用 `watch` + `immediate: true` 替代直接调用 `applyTheme()`,确保配置异步加载后主题也能正确应用 +- **纯色封面时误清章节缓存**:开启"显示纯色封面"时仅清除封面和自定义封面缓存,不再误删章节缓存 +- **MOBI 导入 coverUrl 未赋值**:MOBI 解析成功后将 coverUrl 赋值给 coverImage +- **MOBI recordOffsets 边界检查**:增加 `firstRecordOffset + 16 > data.length` 检查,防止格式损坏文件导致越界 +- **MOBI firstImageIndex 为 0xffffffff 时封面索引错误**:在 extractCoverUrl 中判断 firstImageIndex 无效值,避免错误计算封面记录索引 +- **HTML 实体解码 fromCharCode → fromCodePoint**:`&#xxx;` 和 `&#xHH;` 解码改用 `String.fromCodePoint`,parseInt 增加 radix 参数,支持 BMP 外字符 ### Removed - **固定行数分页模式**:移除分页模式中的"固定行数"选项,统一使用自适应模式 diff --git a/plugins/hushreader/src/components/Bookshelf/ThemeToggle.vue b/plugins/hushreader/src/components/Bookshelf/ThemeToggle.vue index fa26faec..65fca67e 100644 --- a/plugins/hushreader/src/components/Bookshelf/ThemeToggle.vue +++ b/plugins/hushreader/src/components/Bookshelf/ThemeToggle.vue @@ -1,5 +1,5 @@