# 基础功能 > 阅读器、侧栏、电子书转换、书签、快捷键等核心能力。安装、开发、构建与发布见 [开发构建.md](./开发构建.md);AI 与语音朗读见 [AI功能.md](./AI功能.md)、[语音朗读.md](./语音朗读.md)。 ## 侧栏文件列表:分类、排序与拖放 - **分类目录与筛选**:用户维护 **`fileCategoryCatalog`**(分类名与颜色表)、当前筛选 **`fileCategory`**(`__all__` / `__uncategorized__` / 具体分类名)、排序 **`fileSort`**(`FileSortMode`:文件名/路径/大小/阅读进度/最近阅读/添加时间等升序或降序)。 - **持久化**:上述与其它界面偏好一并写入 **`colorTxt.ui.settings`**(见 `cacheStore.PersistedSettingsData` 与 `useAppPersistence`)。 - **列表项字段**:`colorTxt.file.list` 中每条 `TxtFileItem` 除 `path` / `name` / `size` 外,可有 **`category`**(所属分类名)与 **`addedAt`**(加入列表时间,毫秒;旧数据由 `migrateTxtFileListAddedAt` 回填),用于展示与「添加时间」排序;分类与书籍元数据 **`colorTxt.file.meta`** 无关。 - **列表 UI**:`FileListPanel.vue` 使用 `useFileListCategorySort` 生成分类下拉项与计数、`useFileListSelection` 管编辑模式多选、`useFileListMenus` 管右键与分类浮层;`ReaderSidebar` 将事件上抛至 `App.vue` 的 `onSetFilesCategory` / `onApplyCategoryCatalog` 修改 `txtFiles` 与 catalog 并持久化。 - **编辑模式落盘时机**:`fileListEditing` 为 true 时,分类变更与目录编辑先写内存;退出编辑模式(`true -> false`)后统一 `persistFileListCache()`,减少编辑中频繁写入。 - **清空行为**:筛选为 `__all__` 时走 `confirmClearFileList`,筛选为具体分类时走 `confirmClearFileListCategory`,按钮文案与行为对应为「清空 / 清空分类」。 - **拖放**:见上文 **`useAppWindowBindings.ts`**:列表区域追加、其它区域打开首个支持文件。 ## 电子书解析与转换(`src/renderer/src/ebook`) 渲染进程在**打开电子书**时将其转为 UTF-8 的 Markdown 正文(`.md`),可选写出插图目录;路径判定与让出 UI 在 `ebook/` 根目录,**格式解析、目录注入与写出**在 `ebook/convert/`,与 `shared/ebookExtensions.ts` 中的扩展名列表、`shared/ebookConvertPaths.ts` 中的默认输出子目录名保持一致(主进程目录扫描、壳层打开路径判定依赖前者)。 ### 支持的格式与入口 | 扩展名 | 说明 | | ----------------- | ------------------------------------------------------------------------------------------------------------------ | | `.epub` | ZIP 容器,走 `convert/parseEpub.ts` | | `.mobi` / `.azw3` | 先尝试 `tryConvertZipAsEpub`(部分 AZW3 实为 ePub 封装);否则经 `convert/mobi/foliateMobi` 抽取后由 `convert/parseMobi.ts` 转产物 | | `.fb2` / `.fbz` | FB2 或 ZIP 内单 FB2,`convert/parseFb2.ts` | | `.pdf` | `pdfjs-dist` 文本层 + **`getOutline()` 书签目录**,`convert/parsePdf.ts` | | `.chm` | `convert/parseChm.ts`;底层块读取与 LZX 在 `convert/chm/chmArchive.ts`、`convert/chm/lzxDecode.ts` | `ebookFormat.ts` 提供 `isEbookFilePath`、`isMarkdownFilePath`、`isSupportedBookPath`(TXT、`.md` + 上述电子书扩展名)、输出用基名 `ebookSourceFileBaseForOutput`(含 Windows 非法字符净化 `sanitizeWindowsFilenameSegment`)。拖放 / 关联打开时 `useAppWindowBindings` 用 `isSupportedBookPath` 过滤;主进程 `ipcHandlers` 的目录枚举用 `EBOOK_DOT_EXTENSIONS` 与 `.txt`、`.md` 一并收集。 ### Markdown(`.md`) - **打开**:`resolvePhysicalTextForOpen` 对非电子书路径直接流式读盘(与 `.txt` 相同),`physicalReaderPath` 指向 `.md` 原文件。 - **章节**:仅识别 ATX 标题(`#` … `######`,行首最多 3 个空白);`markdownBlockContext` 在围栏代码块与 4 空格/TAB 缩进代码块内跳过 `#`;章节扫描基于**物理行**,避免「行首缩进」展示层误判;侧栏 `headingLevel` 每级缩进 10px;顶栏「章节匹配规则」对 `.md` 禁用。 - **插图(只读)**:`markdownImages` 扫描独占行 `![alt](url)`,`readerImageViewZones` 删源行并插 ViewZone;`https:` URL 直链,`img-src` CSP 含 `https:`;编辑模式不处理,保存仍写回 `.md` 原文。相对路径经 **`resolveMarkdownAssetAbsPath`** 按 `/` 分段 `joinFs`(路径段内可含 `[]` 等字符,勿整段 `join`)。 - 基于 [libmspack](https://github.com/kyz/libmspack)(GNU GPL)移植了一套 JavaScript 实现,以支持对 `.chm` 格式的解析 - 其他电子书格式的解析,主要参考 [foliate-js](https://github.com/johnfactotum/foliate-js)(MIT) ### 转换管线与输出布局 - **调度**:`convert/convertEbookToMarkdown.ts` 中 `convertBookBufferToArtifacts(absSource, buffer)` 按源路径后缀分派各 `parse*.ts`,得到 `EbookMarkdownArtifacts`(`convert/ebookTypes.ts`:`utf8` + 可选 `imageWrites`,每项含相对路径与 `ArrayBuffer`)。 - **写出**:`writeEbookConversionArtifacts` 将正文写入目标 `.md`,插图按 `relativePath` 写到与 `{basename}.md` **同目录**下;约定目录名为 **`{basename}.Images/`**(由 `imagesDirAbsBesideConvertedMd` 与相对路径前缀一致)。无插图时会 `removePath` 清理残留插图目录。 - **输出格式**:正文为 `{basename}.md`;块级图为独占行 `![…](rel)`,行内链为 `[…](#frag)`,锚点为 ``。 - **让出 UI**:`ebook/yieldToUi.ts` 用 `setTimeout(0)` 在长时间解析前后打断,便于底栏「转换中…」等状态刷新;`readBookAsArrayBuffer` 与 `ensureEbookMarkdown` 内多处调用。 ### 输出路径与缓存 - **目标 `.md` 路径(写入与严格缓存的参照)**: - `resolveConvertedMdOutputPaths`:基名为源文件名整段(经 `ebookSourceFileBaseForOutput` 净化,如 `abc.epub` → `abc.epub.md`)。 - `ebookConvertOutputDir`(`colorTxt.ui.settings`)**非空**时输出到该目录;**空字符串**表示与**源书同目录**。 - 新安装或尚无该键时,默认 **`app.getPath("userData")/ConvertedTxt`**(目录名见 `shared/ebookConvertPaths.ts`,preload `getDefaultEbookConvertOutputDir`;缓存文件扩展名为 `.md`)。 - **严格缓存命中**:`ensureEbookMarkdown` 在同时满足下列条件下直接复用、不再解析:`file.meta` 中 **`convertedMdPath` 与当前策略算出的目标路径一致**(与 `resolveConvertedMdOutputPaths` 逐路径规范化比较)、**`sourceMtimeMsAtConvert` 与当前源 `mtimeMs` 一致**,且对该路径 `stat` 仍为普通文件。 - **和解查找(路径无效统一处理)**: - **何时视为「路径无效」**:meta 中无 `convertedMdPath`(空或未写入),或「有路径但严格缓存未通过」——共用同一套和解逻辑。 - **何时执行和解**:仅当 **无记录路径**,或 **记录的源 mtime 与当前源 `mtimeMs` 一致(`mtimeStable`)** 时才和解,避免源书已更新仍复用旧的 `{basename}.md`。 - **实现**:**`findReconciledConvertedMd`** 对候选路径规范化去重后依次 `stat`,**第一个存在的普通文件**即复用结果。 - **候选顺序**:若 `mtimeStable` 且 meta 曾有非空路径,**优先该路径**(例如输出目录变更后旧文件仍留在原记录路径);然后 **当前设置的输出目录**(非空时)下的 `{basename}.md`;**源书同目录**下的 `{basename}.md`;**默认 `userData/ConvertedTxt`** 下同名文件。 - **无命中**:完整转换 `readBookAsArrayBuffer` → `convertBookBufferToArtifacts` → `writeEbookConversionArtifacts`(写出路径为当前策略下的 `convertedMdPath`)。 - **meta 写回与打开路径**: - `useAppFileSession.resolvePhysicalTextForOpen` 在 `ensureEbookMarkdown` 后调用 `setEbookConvertedMeta`,写入 `convertedMdPath` 与 `sourceMtimeMsAtConvert`,并 `persistFileMeta`。 - 流式管道使用的 `physicalPath` 为转换后的 `.md`;**逻辑上书路径**仍为源电子书路径;`currentFile`、会话、最近打开以**源书路径**为键。 ### Markdown 内链/锚点与阅读器衔接 转换输出在 `.md` 中使用标准 Markdown 扩展语法(非旧版 `<>` / `<>` / `<>` 特殊标记): - **``**:锚点(fragment 经 `EbookMarkdownFragmentRegistry` 去重,如 `f_1_2`、`fr_1_2`、`toc_1`)。 - **`[可见文案](#frag)`** / **`[![alt](icon)](#frag "title")`**:内链;`title` 属性为悬停提示(写入时取 `title` 或 `alt`);脚注 noteref 为跳转链 + 同行尾部回跳 `fr_*` span;脚注序号可写 **`[[1]](#m1 "1")`**。 - **独占行 `![alt](rel)`**:块级插图;阅读器删行后插 ViewZone。 **内链/外链解析(`markdown/markdownLinkShared.ts`)**: - 扫描基于 **marked `Lexer.lexInline`**(与 AI 助手 Markdown 规则一致),替代手写正则。 - 入口:`scanMdInternalLinkAt` / `scanMdExternalLinkAt` / `scanNextMdLinkAt` / `scanMdInternalLinksOnLine`;label 内嵌 `![alt](href)` 时补 **平衡 `]`** 与 marked 式 **`findClosingBracket(…, '()')`**,支持 href / 相对路径中含 `[]`、空格。 - 转换侧 **`ebookStemOnlyMdLinks`**、**`ebookTitleMatch`**、**`ebookSpineLineMatch`** 与阅读器 **`markdownInternalLinks`** 均复用上述扫描器。 **链接图标 vs 块级插图(转换分流):** | 结构 | 输出 | |------|------| | `` 内仅 `img` / `noteref` 上下文内小图 | 行内 `[![…](icon)](#frag)` | | `
` 内独立图、段落中无 `` 包裹的 `img` | 独占行 `![…](rel)` + ViewZone | **格式覆盖:** EPUB、MOBI/AZW3、FB2/FBZ 已对齐;CHM、PDF 亦输出 `.md`。**需重新转换**(或删缓存 `.md`)后阅读器才生效。 ### 嵌入目录与 ATX 章节注入 阅读器侧栏「章节」仅识别 ATX 标题(`#` … `######`,见 **`markdownChapter.ts`**)。转换阶段从各格式**嵌入目录**写入 ATX 与 ``,使章节列表尽量与源书 TOC 一致(不另写独立目录块)。 **写入形态**(`convert/ebookTocAnchorInjection.ts`): - `` 与 ATX 标题分两行;`applyLineMutations` 按行号降序应用,保证同索引先写标题、再 insert 锚点(锚点在上)。 - 节内已定位到标题行且纯文本与目录**完全一致**时:在该行 **replace** 升 ATX(保留行首已有 id span)。 - 节首 **fallback**(无精确匹配):插入**完整目录标题**(如 `# 卷一 周纪一`);节首为插图/正文时 `shouldReplaceLineWhenInjectingTocHeading` 为 false,**insert** 不覆盖原行。 **标题匹配(EPUB / MOBI / AZW3,共用 `convert/ebookSpineLineMatch.ts`)**: - `findTitleLineInSpineSection`:**仅精确匹配**——节内某行去 span / 内链后的纯文本须与目录标题完全相同。 - **已移除**「包含」匹配(`want.includes(plain)`)与按标点拆段的子串匹配,避免目录为 `卷一 周纪一`、正文为两行 `卷一` + `周纪一` 时误将 `卷一` 升为 `# 卷一`。 - `resolveTocInjectLineIdx` **不再**用 fragment 锚点行作为升 ATX 目标;无精确匹配即走节首 fallback。侧栏以目录原文为准,正文可能与标题重复,属可接受取舍。 - 搜索范围限定在**当前 spine 节**(`EpubSpineSectionRange`),按 `tocOrder` 与 `searchStartByStem` 顺序处理多项。 **各格式目录来源与注入入口**: | 格式 | 目录来源 | 注入 | | ---- | -------- | ---- | | EPUB | `nav` / NCX → `ebookEpubNav` → `flattenFoliateStyleTocTree` | `injectEpubTocAnchorsIntoLines` | | MOBI | foliate `book.toc`;缺失时 `buildMobiTocTreeFromNcx` | `injectFoliateMobiTocIntoLines`(`convert/parseMobi.ts`) | | AZW3 | 与 MOBI 相同:`convert/convertEbookToMarkdown` 先 `tryConvertZipAsEpub`,否则 **`convertMobiToArtifacts`**(KF8 / `convert/mobi/foliateMobi.js`) | | PDF | `pdfjs-dist` **`doc.getOutline()`**(与 foliate-js 一致,读 PDF Document Outline / 书签) | `injectPdfOutlineIntoLines`(`convert/parsePdf.ts`) | **EPUB 目录 href 映射**(`convert/ebookEpubNav.ts`): - `parseNavDocument` / `parseNcxDocument` 内 **`resolveUrl(navPath|ncxPath, href)`** 产出 **OPF 包根相对路径**(如 `Text/part0001.xhtml`),与 foliate-js 一致。 - 映射 spine 短键 **`epub-NNNN`** 时须相对 **`opfDir`**(OPF 所在 ZIP 目录,如 `OEBPS`)做 `resolveInZip`;**勿**再用 nav 文件所在目录(如 `OEBPS/Text`)二次 resolve,否则 `Text/…` 会错成 `Text/Text/…`、目录项全部无法注入(侧栏无章节)。 - spine 预扫描 **`zipPathToLinkStem`** 保证目录页可链接到尚未遍历的文档;跨章 stem-only 内链 **`[…](#epub-NNNN)`** 由 **`ebookStemOnlyMdLinks`** 后处理注入目标节 ``。 **PDF 专项**(`convert/parsePdf.ts`): - 每页正文拆为独立 `lines` 行(非整页压成一行),便于按行精确匹配。 - 页内优先**整行精确匹配**书签标题;同页多节辅以书签 dest 的 **Y 坐标**(`findPdfLineByDestY`,仅考虑短行)。 - **仅精确匹配**时对命中行 replace 升 ATX;**Y 定位**时 **insert** 标题,**不 replace**,避免吞掉正文行。 - 标题被 PDF 文本层拆成相邻两行时:拼接后精确匹配,升 ATX 并清空续行(如「…开把」+「簧」)。 - 无匹配则**跳过**该书签项,不在页内堆叠多个 fallback 假标题。 - 纯页锚点 `` 行不直接升 ATX(与 MOBI `filepos` 锚点行处理思路一致)。 **MOBI 历史注意点**:目录项须**标题匹配优先**于 fragment;纯 `filepos` / 锚点独占行在下一行升 ATX,避免 `#` 写在 span 同行。 底栏对源电子书提供 **「重新转换」**(`forceEbookConvert`);修改注入逻辑后须重新转换方更新已缓存 `.md`。 ### 黏性章节条(sticky scroll) Monaco `stickyScroll` + `chapterStickyScroll.ts` 的 DocumentSymbolProvider 在阅读区顶部显示当前章节大纲(多级目录时多层叠放)。**设置 → 阅读 → 启用粘性章节标题**(**`stickyChapterTitleEnabled`**,默认 **开启**,持久化于 **`colorTxt.ui.settings`**)控制是否显示;关闭后 Monaco `stickyScroll` 禁用,正文内章节标题行内装饰(`.chapterTitleLine`)不受影响。`ReaderMain` 在 **`streamLoading`** 期间亦强制关闭 sticky,避免加载旧文件时黏性标题残留。找书阅读器用 `AppModal` `v-if` 开关时会销毁/重建 `ReaderMain`:语言只需全局注册一次,但 DocumentSymbolProvider **须随实例重新注册**(卸载时 dispose),否则第二次打开粘性标题失效。 `ReaderMain.setChapters` 在更新章节装饰与文档符号后调用 **`scheduleStickyChapterScrollRefresh`**(内部 **`refreshStickyChapterScrollWidget`**:短暂关闭再开启 sticky;仅在 sticky 应为开启时执行),避免重新加载后黏性条仍用旧渲染、标题样式(颜色/字号)未与正文 `.chapterTitleLine` 同步的问题。 **程序性跳转与黏性条留白**(`reader/readerViewportAnchor.ts`): - 共用 **`computeScrollTopForLineAtViewportSlot`**:`revealLineNearTop` 后将目标行顶沿对齐视口「从上往下第 N 条字高带」。 - **章节列表**(**`jumpToChapter`**):**N = `headingLevel`**(1 级 → 第 1 条字高,4 级 → 第 4 条字高),避免深层目录跳转后标题被多层黏性条遮住。 - **书签列表**(**`jumpToBookmarkLine`**):固定 **N = 2**(**`READER_BOOKMARK_JUMP_SLOT_FROM_TOP`**),与单层黏性条时的历史留白一致;添加书签采样行亦对齐第 2 条字高带。 - **只读↔编辑 / 格式化恢复**:**N = 2**(**`READER_VIEWPORT_RESTORE_SLOT_FROM_TOP`**),与书签跳转相同,非章节层级动态计算。 `ReaderMain.vue` 载入 `.md` 后: - `applyEmbeddedImageAnchors`:`collectBlockMarkdownImageLines` → ViewZone。 - `applyMarkdownInternalLinks`:`stripMdInternalLinksFromText` 剥离 `` / 内链语法,安装侧车(`id → 物理行`、点击区间、行首链内 label)。 - 含 `iconRel` 的内链用 `.readerEbookLinkIcon` 与 `colortxt-local://` 背景图;图标路径经 **`resolveMarkdownAssetAbsPath`** 解析,加载失败时回退 **`.readerEbookLinkIcon--builtin-link`** 占位;纯文字链为 `.readerEbookInternalLink` 下划线。 - 与展示行↔物理行映射配合:`ebookDisplayLineToPhysical` / `ebookAnchorPhysicalToDisplay`(插图删行后映射须同步)。 - **大文件性能**:脚注极多时加载在侧车中批量处理;点击在 `editorHost` 捕获阶段统一命中;装饰仅注册视口 ± 约 80 行。 ### 目录与文件速查 | 文件 / 目录 | 职责 | | ------------------------------------------------------------------------------- | ---------------------------------- | | `ebook/ebookFormat.ts` / `ebook/ebookTitleMatch.ts` | 路径判定、目录标题匹配用纯文本 | | `ebook/pathUtils.ts` / `ebook/yieldToUi.ts` | 路径 join / dirname、解析让出 UI | | `ebook/convert/convertEbookToMarkdown.ts` | 调度解析、路径解析、缓存、写出产物 | | `ebook/convert/ebookTypes.ts` | 转换产物类型 | | `markdown/markdownLinkShared.ts` | marked 内/外链扫描、sidecar 类型(转换与阅读器共用) | | `markdown/markdownInternalLinks.ts` | MD 内链剥离、sidecar 安装与 Monaco 装饰 | | `markdown/markdownImages.ts` | 块级图扫描、`resolveMarkdownAssetAbsPath` | | `ebook/convert/ebookTocAnchorInjection.ts` 等 | 嵌入目录 → ATX / toc span 注入 | | `ebook/convert/ebookEpubNav.ts` / `ebook/convert/ebookMarkdownEmit.ts` | EPUB 目录解析、锚点与 MD 语法发射 | | `ebook/convert/ebookLinkIconHeuristics.ts` | 链接图标 vs 块级插图结构判定 | | `ebook/convert/parse*.ts` | 各格式实现 | | `ebook/convert/chm/` | CHM 归档与 LZX 解码 | | `ebook/convert/mobi/` | Foliate MOBI 引擎脚本与类型声明 | 新增格式时:在 `shared/ebookExtensions.ts` 增加扩展名;主进程 `isTxtOrEbookFileName` 与 `isSupportedShellOpenPath` 会自动跟随;在 `convertBookBufferToArtifacts` 与 `EBOOK_DOT_EXTENSIONS` 中补全分支与列表;若需新资源类型,扩展 `EbookMarkdownArtifacts.imageWrites` 或正文约定即可。 ## 全屏阅读与浮动 UI 全屏时顶栏、底栏、左侧章节/文件侧栏默认隐藏,靠屏幕边缘**感应区**呼出;移出对应面板区域后收起;在**阅读区所在 `.layout`** 上按下鼠标时也会一并收起已打开的浮动层(点在已展开侧栏内除外)。实现集中在 `src/renderer/src/composables/useAppReaderChrome.ts`,边缘像素与右侧滚动条「非唤起带」在 `src/renderer/src/constants/appUi.ts`(`FULLSCREEN_*_EDGE_PX`、`FULLSCREEN_RIGHT_SCROLLBAR_GUTTER_PX` 等)。 ### 统一交互模型 1. **`document` `mousemove`(由 `useAppWindowBindings` 注册)** 仅当**当前全屏**且**该浮动层尚未显示**时,根据指针是否进入对应边缘感应区决定是否唤起: - **顶栏**:`clientY` 不超过顶缘厚度,且不在右侧 gutter 内(避免误触 Monaco 固定滚动条一带)。 - **底栏**:`clientY` 不低于「视口高度 − 底缘厚度」,且不在右侧 gutter 内。 - **侧栏**:`clientX` 不超过左缘厚度。 一旦某层已显示,上述函数对该层**不再处理收起**(避免与 `mouseleave` 重复、抖动)。 2. **面板根节点 `mouseleave`(在 `App.vue` 模板中绑定)** 仅当 **`isFullscreenView`** 为真时,将对应 `showFullscreen*` 置为 `false`: - 顶栏:`appHeaderWrap` → `onFullscreenHeaderMouseLeave` - 底栏:`appFooterWrap` → `onFullscreenFooterMouseLeave` - 侧栏:`sidebarPaneWrap` → `onFullscreenSidebarMouseLeave` 浏览器只在指针离开**该元素及其子节点**时触发,与可见命中区域一致;子菜单若 **Teleport** 到 `body`,移入浮层会先触发顶栏 `mouseleave` 导致顶栏收起,属已知限制(可后续为浮层根单独白名单)。 3. **`.layout` `mousedown`(`App.vue`)** 全屏时先于 `useAppFullscreenReaderLayout` 的 `onLayoutMouseDown` 调用 `dismissFullscreenPanelsOnLayoutPointerDown`:将顶栏、底栏、侧栏的 `showFullscreen*` 一律置 `false`(已为 `false` 则无影响)。顶栏、底栏挂在 `.layout` 之外,能命中 `.layout` 的按下即表示未点在顶/底栏上。侧栏在 `.layout` 内:若侧栏处于展开态且事件目标落在侧栏根容器子树内(含沿 **ShadowRoot.host** 向上的判定,与正文区滚轮转发一致),则**不**收起,避免在侧栏里点选时误关。 4. **层间互斥** `canShowFullscreenPanel` 保证同一时刻只有一种浮动层可通过边缘被唤起(避免叠在一起)。 5. **退出全屏** 主进程广播非全屏或原生退出全屏时,`dismissFullscreenChromeForNativeExit` 会清空各 `showFullscreen*` 与全屏提示用的淡入淡出计时器,避免 UI 状态残留。 6. **顶栏与查找** Monaco 查找控件展开时,`updateFullscreenHeaderHover` 内若 `isFindWidgetRevealed()` 为真会强制收起顶栏,避免与查找条布局冲突。查找栏 tooltip、↑↓ 搜索历史与快捷键让出见 **「Monaco 查找栏」**。 7. **侧栏宽度** 非全屏时侧栏仍可拖拽改宽;全屏浮动侧栏宽度仍用同一 `sidebarWidth` 状态(`startResizeSidebar` / `endSidebarResize` 等未改)。 ### 顶栏 UI 全屏时 `AppHeader` 传入 `inFullscreen`;**「切换侧栏」** 图标按钮使用 `v-if="!inFullscreen"` 隐藏,避免与左缘感应侧栏重复。 #### 响应式收纳(`useAppHeaderLayout`) 窗口宽度由 **`window.innerWidth`** 监听;断点常量见 **`constants/appHeaderLayout.ts`**: | 条件 | 行为 | | ---- | ---- | | 宽度 **< 1030px** | **字体组**(`HeaderFontToolbar`:字体选择、字号、行高)从顶栏移入 **`MoreMenu`** 顶部 **`#toolbar`** 插槽 | | 宽度 **< 830px** | **格式组**(`HeaderFormatToolbar`:转换、压缩空行、行首缩进、高级换行、内容上色)一并收入 **`#toolbar`**(位于字体组下方,与菜单项之间用分隔线隔开) | 宽屏时两组仍直接渲染在顶栏中部工具区。窗口最小宽度 **650px**(**`windowBounds.ts`**),与上述断点配合使用。 **`FontPicker`** 下拉 **`Teleport` 到 `body`** 并带 **`data-header-float-panel`**;**`MoreMenu`** 在 `pointerdown` 时若命中该属性子树则不关闭,避免在「更多」内选字体时菜单被误关。 #### 定时滚动入口 顶栏 **语音朗读** 按钮左侧为 **定时滚动** 开关(`play.svg`);激活态高亮。与 **语音朗读** 互斥:定时滚动开启时禁用朗读入口,朗读进行中禁用定时滚动(`App.vue` **`onVoiceReadToggle`** 亦有防护)。行为与设置见 **「定时滚动」**。 ### 全屏正文宽度与两侧空白滚轮 - **设置块**:设置 → 阅读 → **「全屏阅读」**:含「全屏阅读区域宽度」与「全屏时在左下角显示系统时间」(`fullscreenShowSystemTime`,默认开启;`FullscreenSystemClock` 以 `h:mm` 显示于左下角,背景为 `--reader-bg`,文字为 50% 透明度的 `--reader-body-text`)。 - **宽度**:设置里的「全屏阅读区域宽度」对应 `fullscreenReaderWidthPercent`,由 `useAppFullscreenReaderLayout` 的 `fullscreenReaderPaneStyle` 在全屏时给 `readerPaneWrap` 设 `width` / `maxWidth`(百分比)与水平 `auto` 外边距,使正文区在 `.layout` 内水平居中;两侧露出与正文同背景的空白。 - **滚轮**: - 空白区不在 Monaco 视图 DOM 上,原生 wheel 不会进入编辑器。 - `App.vue` 在 **`.layout`** 上监听 **`@wheel`**,由 `useAppFullscreenReaderLayout.onLayoutWheel` 判断指针是否在 `readerPaneWrap` 矩形**之外**(左右空白);且事件与全屏侧栏无关时,调用 `ReaderMain` 的 **`delegateEditorWheelFromBrowserEvent(ev)`**。 - 内部对编辑器实例调用 **`delegateScrollFromMouseWheelEvent`**(`CodeEditorWidget` 运行时方法,未写入 `monaco` 的 `.d.ts`),与正文内滚轮走**同一条** Monaco 滚动逻辑。 - **`preventDefault` 顺序**:Monaco 在 `_onMouseWheel` 开头若发现 **`ev.defaultPrevented` 已为 true 会直接 return**,故 **`delegateEditorWheelFromBrowserEvent` 须在 `preventDefault` 之前调用**;委托完成后再对布局层 `preventDefault()`。侧栏内滚动通过 `composedPath` / `elementFromPoint` 与 Shadow DOM 向上判定排除,避免误劫持。 - **其它滚动**:键盘方向键、PageUp/PageDown 等仍由 `ReaderMain` 的 **`scrollByDeltaY` / `scrollByLineStep` / `scrollByPageStep`** 等驱动,与上述空白区 wheel 委托无关。 - **样式**:全屏时 Monaco 纵向滚动条、概览尺、**小地图**(编辑态开启时)通过 `appShell.css` 固定到视口最右侧:滚动条/概览尺 `right: 0`,小地图 `right: var(--txtr-fullscreen-scrollbar-size)`(默认 14px,与 Monaco 默认竖条宽度一致);须 **`left: auto`** 覆盖 Monaco 内联 `left`,避免小地图落在居中正文中间。与窄正文居中并存。 ## 阅读器字号与行高 实现集中在 `src/renderer/src/constants/appUi.ts` 与 `src/renderer/src/monaco/readerEditorOptions.ts`(`readerEditorLineHeight`)。 - **字号**:`minFontSize`~`maxFontSize`(整数 px),顶栏加减、快捷键与设置面板滑块共用同一状态。 - **行高倍数**:最小为 `minLineHeightMultiple`,步进 `lineHeightMultipleStep`(如 0.1)。 - **上限随字号变化**:Monaco 将编辑器 `lineHeight` 限制在约 `monacoMaxLineHeightPx`(150)像素量级;应用内行高由 `readerEditorLineHeight(字号, 倍数)` 得到(`Math.max(1, Math.round(字号 × 倍数))`)。 - **夹紧与持久化**:`maxLineHeightMultipleForFontSize(字号)` 得到该字号下允许的倍数上限;加载与设置「确定」时用 `clampLineHeightMultipleForFontSize` 将倍数夹到合法区间。 - **设置面板**:字号、行高均为滑块;行高滑块的上限随草稿字号变化;拖动字号若导致当前行高超限时,会自动下调行高草稿。 - **仅加大字号**(快捷键 / 顶栏):若当前行高倍数在新字号下超限,会自动下调倍数并写回阅读器与持久化。 ## 定时滚动 自 **2.8** 起支持按固定间隔自动向下滚动正文,便于解放双手阅读。 ### 功能与入口 | 项目 | 说明 | | ---- | ---- | | 顶栏 | **`AppHeader`**:**语音朗读** 左侧 **定时滚动** 按钮(`play.svg`);开启后按钮为激活态 | | 设置 | **设置 → 阅读 → 定时滚动**:**范围**(**一屏** / **一行**,**`RadioGroup`**)、**间隔(毫秒)**(**`NumericInput`**,默认 3000,夹紧 200~600000) | | 滚动实现 | **`useAppTimedScroll`** 以 `setInterval` 驱动;**一屏** → **`ReaderMain.scrollByPageStep(1)`**,**一行** → **`scrollByLineStep(1)`** | | 持久化 | **`colorTxt.ui.settings.timedScroll`**(`{ range, intervalMs }`);常量与 merge 见 **`constants/timedScroll.ts`**、`cacheStore.ts`;设置 **确定** 后写入;运行中修改间隔会重启计时器 | ### 开启 / 停止条件 - **可开启**:已打开文件、非 `loading`、非编辑模式、非朗读中、视口未到底、模型行数 > 0。 - **自动停止**:滚到文末(**`viewportAtBottom`**)、切换文件、进入编辑模式、开始加载、开启语音朗读。 - **互斥**:与 **语音朗读** 不能同时运行(顶栏互禁 + `useAppTimedScroll` / `onVoiceReadToggle` 双向防护)。 ## 番茄时钟 设置 → 阅读 → **「番茄时钟」**(`pomodoro` / `PomodoroSettings`,默认启用):阅读时长 25 分钟、短休息 5 分钟、长休息 15 分钟。启用后底栏最左侧显示启动按钮(`icons.history`);运行中为饼图倒计时,点击饼图可在「仅饼图」与「饼图 + `m:ss` + 暂停/继续 + 停止」间切换。阅读时长结束时全窗毛玻璃遮罩(`PomodoroBreakOverlay`)居中显示「休息一下」与休息倒计时;每完成 4 轮阅读进入长休息。实现见 `usePomodoroTimer.ts`。找书阅读器关闭时自动停止。 ## 底栏(`AppFooter`) 由 **`AppFooter.vue`** 渲染,数据与事件由 **`App.vue`** 注入。阅读进度与百分比文案仍来自 **`useAppReadingProgress`**(与其余展示口径一致)。左侧可挂番茄时钟控件(`PomodoroFooterControl`)。 ### 左侧路径 - **展示**:`footerPathCaption` — 普通书籍为 **`physicalReaderPath ?? currentFile`**;**电子书转换中**为源书路径(`ebookConversionSourcePath`)。 - **交互**:路径为链式按钮,点击打开 **`AppContextMenu`**(非直接打开资源管理器)。 - **菜单项**(均受「整体在窗口内」夹取;某条不可用时仍显示为 **disabled**): - **在文件管理器中显示**:与 **`revealCurrentFileInFolder`** 一致,目标路径为 **`physicalReaderPath ?? currentFile ?? ebookConversionSourcePath`**(无可用路径时 disabled)。 - **重新加载**:**`openFilePath(currentFile, { keepSidebarTab: true })`**;无 **`currentFile`**、**`loading`** 或 **`ebookParsing`** 时为 disabled。 - **重新转换**(仅源路径为电子书且当前会话已打开转换后的 `.md`):**`openFilePath(..., { forceEbookConvert: true, keepSidebarTab: true })`**,忽略缓存、强制重跑 `convertBookBufferToArtifacts`;**`warning`** 样式菜单项。 - **关闭文件**:**`closeCurrentFile`**(**danger** 样式);无 **`currentFile`** 时 disabled。 ### 右侧编码 - **展示**:当前探测/保存用编码标签(**`fileEncoding`**);打开文件时由主进程 **`detectTextEncoding.ts`** 自动探测(流式读与编辑载入共用),标签经 `encodingLabelForFooter` 显示(如 `UTF-8`、`GB2312`、其它 chardet 名大写)。 - **可点条件**:由 **`footerEncodingActionsEnabled`** 控制(需 **`physicalReaderPath`**、**`currentFile`**、非 **`loading`**、非 **`ebookParsing`** 且 **`writeTextFile` 可用**)。 - **菜单**:**保存为 UTF-8** / **保存为 GB2312** → **`saveReaderBufferWithIpcEncoding`**:`ReaderMain.getAllText()` → **`writeTextFile(physicalReaderPath, text, 编码)`** 覆盖落盘;成功后更新 **`fileEncoding`**、**`readerSaveEncoding`**,并 **`markReaderEditSaved`** / 清除编辑脏标记(与顶栏保存路径一致)。 ### 弹出定位与互斥 - **`AppContextMenu`** **`placement="aboveFooterMouseX"`**:以底栏 **`