From 1529c7d7cb53ca78acdf9fae4ec42d36610aa19a Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sun, 5 Jul 2026 02:17:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BF=BB=E9=A1=B5=E9=80=9F?= =?UTF-8?q?=E5=BA=A6=E8=BF=87=E5=BF=AB=E5=AF=BC=E8=87=B4=E9=AB=98=E4=BA=AE?= =?UTF-8?q?/=E6=91=98=E6=8A=84=E5=A4=B1=E6=95=88=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/epub_reader/epub_detail_page.dart | 6 +- .../epub_reader/epub_highlights_page.dart | 43 ++++++++ .../epub_reader/epub_selection_toolbar.dart | 61 ++++++++--- .../mixins/spine_navigation_mixin.dart | 9 ++ .../mixins/text_selection_mixin.dart | 103 ++++++++++++++++-- lib/pages/epub_reader/reader_screen.dart | 15 +++ lib/pages/epub_reader/reader_webview.dart | 59 ++++++---- 7 files changed, 249 insertions(+), 47 deletions(-) diff --git a/lib/pages/epub_reader/epub_detail_page.dart b/lib/pages/epub_reader/epub_detail_page.dart index ecf4b66..77e762e 100644 --- a/lib/pages/epub_reader/epub_detail_page.dart +++ b/lib/pages/epub_reader/epub_detail_page.dart @@ -801,7 +801,11 @@ class _EpubDetailPageState extends State { Navigator.push( context, MaterialPageRoute( - builder: (_) => EpubHighlightsPage(bookId: widget.bookId, book: _book), + builder: (_) => EpubHighlightsPage( + bookId: widget.bookId, + book: _book, + onNavigateToHighlight: _navigateToHighlight, + ), ), ).then((_) => _refreshBook()); } diff --git a/lib/pages/epub_reader/epub_highlights_page.dart b/lib/pages/epub_reader/epub_highlights_page.dart index 63aa2fb..52d484f 100644 --- a/lib/pages/epub_reader/epub_highlights_page.dart +++ b/lib/pages/epub_reader/epub_highlights_page.dart @@ -1,19 +1,23 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import '../../utils/epub/reader_dao.dart'; import '../../utils/toast_util.dart'; import '../../utils/user_prefs.dart'; import 'highlight_detail_sheet.dart'; +import 'reader_screen.dart'; /// EPUB 句读(高亮)管理页面 —— 支持瀑布流/列表模式切换 class EpubHighlightsPage extends StatefulWidget { final String bookId; final Map book; + final void Function(Map highlight)? onNavigateToHighlight; const EpubHighlightsPage({ super.key, required this.bookId, required this.book, + this.onNavigateToHighlight, }); @override @@ -245,9 +249,48 @@ class _EpubHighlightsPageState extends State { highlight: highlight, book: widget.book, onDelete: () => _deleteHighlight(highlight['id'] as int), + onNavigate: () => _navigateToHighlight(highlight), ); } + void _navigateToHighlight(Map highlight) { + final chapter = int.tryParse(highlight['chapter'] as String? ?? '') ?? 0; + final xpath = _extractStartXPath(highlight['cfi'] as String? ?? ''); + final text = highlight['content'] as String? ?? ''; + + if (widget.onNavigateToHighlight != null) { + widget.onNavigateToHighlight!(highlight); + return; + } + + // 默认行为:先关闭当前页面,再打开阅读器跳转 + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ReaderScreen( + bookId: widget.book['id'] as String, + filePath: widget.book['file_path'] as String, + title: widget.book['title'] as String? ?? '', + bookData: widget.book, + initialSpineIndex: chapter, + scrollToXPath: xpath, + scrollToText: text, + ), + ), + ); + } + + String? _extractStartXPath(String cfi) { + if (cfi.isEmpty) return null; + try { + final decoded = jsonDecode(cfi) as Map; + return decoded['startXPath'] as String?; + } catch (_) { + return null; + } + } + String _formatDate(String isoString) { try { final dt = DateTime.parse(isoString); diff --git a/lib/pages/epub_reader/epub_selection_toolbar.dart b/lib/pages/epub_reader/epub_selection_toolbar.dart index 50b6698..4833cf2 100644 --- a/lib/pages/epub_reader/epub_selection_toolbar.dart +++ b/lib/pages/epub_reader/epub_selection_toolbar.dart @@ -15,6 +15,12 @@ class EpubSelectionToolbar extends StatelessWidget { /// 点击摘抄到笔记的回调 final VoidCallback onExcerpt; + /// 点击取消高亮按钮的回调 + final VoidCallback? onRemoveHighlight; + + /// 点击取消摘抄按钮的回调 + final VoidCallback? onRemoveExcerpt; + /// 点击外部区域关闭 final VoidCallback onDismiss; @@ -24,6 +30,8 @@ class EpubSelectionToolbar extends StatelessWidget { required this.onCopy, required this.onHighlight, required this.onExcerpt, + this.onRemoveHighlight, + this.onRemoveExcerpt, required this.onDismiss, }); @@ -44,7 +52,10 @@ class EpubSelectionToolbar extends StatelessWidget { : selectionBottom + margin; // 水平居中于选区,但不超出屏幕 - const toolbarWidth = 220.0; + final showRemoveHL = onRemoveHighlight != null; + final showRemoveExcerpt = onRemoveExcerpt != null; + final buttonCount = 2 + (showRemoveHL || !showRemoveExcerpt ? 1 : 0) + (showRemoveExcerpt ? 1 : 0); + final toolbarWidth = buttonCount * 72.0; double left = selectionRect.center.dx - toolbarWidth / 2; left = left.clamp(12.0, mediaQuery.size.width - toolbarWidth - 12.0); @@ -71,14 +82,6 @@ class EpubSelectionToolbar extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildButton( - context, - icon: Icons.highlight_outlined, - label: '高亮', - onTap: onHighlight, - color: colors.primary, - ), - _divider(colors), _buildButton( context, icon: Icons.copy_outlined, @@ -87,13 +90,39 @@ class EpubSelectionToolbar extends StatelessWidget { color: colors.primary, ), _divider(colors), - _buildButton( - context, - icon: Icons.edit_note_outlined, - label: '摘抄', - onTap: onExcerpt, - color: colors.primary, - ), + if (showRemoveHL) + _buildButton( + context, + icon: Icons.highlight_off_outlined, + label: '取消高亮', + onTap: onRemoveHighlight!, + color: colors.error, + ) + else + _buildButton( + context, + icon: Icons.highlight_outlined, + label: '高亮', + onTap: onHighlight, + color: colors.primary, + ), + _divider(colors), + if (showRemoveExcerpt) + _buildButton( + context, + icon: Icons.edit_off_outlined, + label: '取消摘抄', + onTap: onRemoveExcerpt!, + color: colors.error, + ) + else + _buildButton( + context, + icon: Icons.edit_note_outlined, + label: '摘抄', + onTap: onExcerpt, + color: colors.primary, + ), ], ), ), diff --git a/lib/pages/epub_reader/mixins/spine_navigation_mixin.dart b/lib/pages/epub_reader/mixins/spine_navigation_mixin.dart index 025bf05..157d1b1 100644 --- a/lib/pages/epub_reader/mixins/spine_navigation_mixin.dart +++ b/lib/pages/epub_reader/mixins/spine_navigation_mixin.dart @@ -178,6 +178,9 @@ mixin _SpineNavigationMixin on State { currentPageInChapter: currentPageInChapter, totalPagesInChapter: totalPagesInChapter, ); + + // 恢复新章节的高亮 + await restoreHighlightsForCurrentSpine(); } Future previousSpineItemFirstPage() async { @@ -200,6 +203,9 @@ mixin _SpineNavigationMixin on State { currentPageInChapter: currentPageInChapter, totalPagesInChapter: totalPagesInChapter, ); + + // 恢复新章节的高亮 + await restoreHighlightsForCurrentSpine(); } Future nextSpineItem() async { @@ -222,6 +228,9 @@ mixin _SpineNavigationMixin on State { currentPageInChapter: currentPageInChapter, totalPagesInChapter: totalPagesInChapter, ); + + // 恢复新章节的高亮 + await restoreHighlightsForCurrentSpine(); } Future navigateToTocItem(TocEntry item) async { diff --git a/lib/pages/epub_reader/mixins/text_selection_mixin.dart b/lib/pages/epub_reader/mixins/text_selection_mixin.dart index afff700..2096869 100644 --- a/lib/pages/epub_reader/mixins/text_selection_mixin.dart +++ b/lib/pages/epub_reader/mixins/text_selection_mixin.dart @@ -12,6 +12,7 @@ mixin _TextSelectionMixin on State { BookSession get bookSession; ReaderRendererController get rendererController; int get currentSpineItemIndex; + Completer? get pageCountReadyCompleter; // ─── 选中工具条状态 ────────────────────────────────────────────── String? _selectionText; @@ -23,6 +24,10 @@ mixin _TextSelectionMixin on State { String? _pendingScrollXPath; String? _pendingScrollText; + // 已有高亮/摘抄信息(选中文字时检测) + int? _existingHighlightId; + bool _existingIsExcerpt = false; + bool get isSelectionToolbarVisible => _selectionText != null && _selectionRect != null; @@ -43,6 +48,8 @@ mixin _TextSelectionMixin on State { _dismissSelectionToolbar(); return; } + // 检测选中文本是否已有高亮/摘抄 + _detectExistingHighlight(selectedText, currentSpineItemIndex); setState(() { _selectionText = selectedText; _selectionRect = rect; @@ -53,6 +60,22 @@ mixin _TextSelectionMixin on State { }); } + /// 检测选中文本是否已有高亮或摘抄 + Future _detectExistingHighlight(String text, int spineIndex) async { + final existing = await _readerDao.getHighlightsByBookId(widget.bookId); + final match = existing.where((h) => + h['chapter'] == spineIndex.toString() && + h['content'] == text).toList(); + if (match.isEmpty) { + _existingHighlightId = null; + _existingIsExcerpt = false; + } else { + final h = match.first; + _existingHighlightId = h['id'] as int?; + _existingIsExcerpt = h['color'] == 'excerpt'; + } + } + /// 关闭工具条并清除 WebView 选区 void _dismissSelectionToolbar() { if (!isSelectionToolbarVisible) return; @@ -64,6 +87,8 @@ mixin _TextSelectionMixin on State { _startHandle = null; _endHandle = null; _selectionInfo = null; + _existingHighlightId = null; + _existingIsExcerpt = false; }); } @@ -236,6 +261,60 @@ mixin _TextSelectionMixin on State { _dismissSelectionToolbar(); } + // ─── 取消高亮/摘抄 ───────────────────────────────────────────── + + /// 取消高亮:删除 DB 记录 + 移除 DOM 标注 → 关闭工具条 + Future _onRemoveHighlightButton() async { + final id = _existingHighlightId; + if (id == null) return; + final controller = _webViewControllerMixin; + await _readerDao.deleteHighlight(id); + if (controller != null) { + // 先清除浏览器选区,防止活跃选区干扰 DOM 操作 + await controller.clearSelection(); + await Future.delayed(const Duration(milliseconds: 50)); + await controller.removeHighlight(id.toString()); + } + if (!mounted) return; + ToastUtil.show(context, '已取消高亮'); + _dismissSelectionToolbar(); + } + + /// 取消摘抄:删除 book_excerpts 记录 + 删除蓝色标注 → 关闭工具条 + Future _onRemoveExcerptButton() async { + final text = _selectionText; + final id = _existingHighlightId; + if (text == null || id == null) return; + + final linkedBookId = bookSession.book['book_id'] as String? ?? ''; + + // 1. 删除 book_excerpts 中的摘抄记录 + if (linkedBookId.isNotEmpty) { + final db = await DatabaseHelper.instance.database; + await db.delete( + 'book_excerpts', + where: 'book_id = ? AND content = ?', + whereArgs: [linkedBookId, text], + ); + } + + // 2. 删除 book_annotations 中的蓝色标注 + await _readerDao.deleteHighlight(id); + + // 3. 移除 DOM 中的高亮标记 + final controller = _webViewControllerMixin; + if (controller != null) { + // 先清除浏览器选区,防止活跃选区干扰 DOM 操作 + await controller.clearSelection(); + await Future.delayed(const Duration(milliseconds: 50)); + await controller.removeHighlight(id.toString()); + } + + if (!mounted) return; + ToastUtil.show(context, '已取消摘抄'); + _dismissSelectionToolbar(); + } + // ─── 高亮恢复 ─────────────────────────────────────────────────── /// spine 加载完成后调用:先跳转到高亮位置,再恢复高亮 @@ -250,14 +329,24 @@ mixin _TextSelectionMixin on State { final text = _pendingScrollText ?? ''; _pendingScrollXPath = null; _pendingScrollText = null; - await Future.delayed(const Duration(milliseconds: 300)); - var pageIndex = await controller.getPageIndexForXPath(xpath); - debugPrint('[MN] scrollToXPath: $xpath → page $pageIndex'); - // XPath 找不到时用文本搜索回退 - if (pageIndex < 0 && text.isNotEmpty) { - debugPrint('[MN] XPath failed, trying text search...'); + + // 等待分栏布局完成(onPageCountReady 触发后) + final completer = pageCountReadyCompleter; + if (completer != null && !completer.isCompleted) { + await completer.future.timeout(const Duration(seconds: 5), onTimeout: () {}); + } + // 分栏完成后额外等待一帧确保布局稳定 + await Future.delayed(const Duration(milliseconds: 100)); + + // 用文本搜索定位(比 XPath 更可靠) + int pageIndex = -1; + if (text.isNotEmpty) { pageIndex = await controller.getPageIndexForText(text); - debugPrint('[MN] text search → page $pageIndex'); + debugPrint('[MN] scrollToText: page $pageIndex'); + } + if (pageIndex < 0 && xpath.isNotEmpty) { + pageIndex = await controller.getPageIndexForXPath(xpath); + debugPrint('[MN] scrollToXPath: $xpath → page $pageIndex'); } if (pageIndex >= 0) { await controller.jumpToPage(pageIndex); diff --git a/lib/pages/epub_reader/reader_screen.dart b/lib/pages/epub_reader/reader_screen.dart index 9441d86..b171c4f 100644 --- a/lib/pages/epub_reader/reader_screen.dart +++ b/lib/pages/epub_reader/reader_screen.dart @@ -144,6 +144,11 @@ class _ReaderScreenState extends State bool styleDrawerOpen = false; AppLifecycleState? lastLifecycleState = AppLifecycleState.resumed; + // 等待分栏布局完成 + Completer? _pageCountReadyCompleter; + @override + Completer? get pageCountReadyCompleter => _pageCountReadyCompleter; + // 书签 final List> _bookmarks = []; @@ -531,9 +536,17 @@ class _ReaderScreenState extends State final ratio = widget.initialSpineIndex != null ? null : bookSession.initialScrollPosition; + // 如果有跳转目标,创建 Completer 等待分栏布局完成 + if (widget.initialSpineIndex != null && (widget.scrollToXPath != null || widget.scrollToText != null)) { + _pageCountReadyCompleter = Completer(); + } await loadCarousel(restoreScrollRatio: ratio); }, onPageCountReady: (totalPages) async { + // 通知分栏布局已完成 + if (_pageCountReadyCompleter != null && !_pageCountReadyCompleter!.isCompleted) { + _pageCountReadyCompleter!.complete(); + } setState(() { totalPagesInChapter = totalPages; if (currentPageInChapter >= totalPagesInChapter) { @@ -696,6 +709,8 @@ class _ReaderScreenState extends State onCopy: _onCopyButton, onHighlight: _onHighlightButton, onExcerpt: _onExcerptButton, + onRemoveHighlight: _existingHighlightId != null && !_existingIsExcerpt ? _onRemoveHighlightButton : null, + onRemoveExcerpt: _existingHighlightId != null && _existingIsExcerpt ? _onRemoveExcerptButton : null, onDismiss: _dismissSelectionToolbar, ), // 选区手柄(起点和终点) diff --git a/lib/pages/epub_reader/reader_webview.dart b/lib/pages/epub_reader/reader_webview.dart index 47754bf..cbc9868 100644 --- a/lib/pages/epub_reader/reader_webview.dart +++ b/lib/pages/epub_reader/reader_webview.dart @@ -509,6 +509,8 @@ class _ReaderWebViewState extends State { } /// 计算节点在分栏排版中的页码 + /// 与 Lumina 的 calculatePageIndexOfAnchor 使用相同算法: + /// getBoundingClientRect + scrollOffset,配合 128px column-gap function calcPageIndex(doc, node) { if (!node) return -1; var el = node.nodeType === 3 ? node.parentNode : node; @@ -516,17 +518,24 @@ class _ReaderWebViewState extends State { if (!f) return -1; var iframeWidth = f.clientWidth; var iframeHeight = f.clientHeight; - // 获取元素相对于 iframe 内容的偏移 - var rect = el.getBoundingClientRect(); - var scrollLeft = doc.documentElement.scrollLeft || doc.body.scrollLeft || 0; - var scrollTop = doc.documentElement.scrollTop || doc.body.scrollTop || 0; - // 水平分栏 - var pageX = iframeWidth > 0 ? Math.floor((rect.left + scrollLeft) / iframeWidth) : 0; - // 垂直分栏 - var pageY = iframeHeight > 0 ? Math.floor((rect.top + scrollTop) / iframeHeight) : 0; - // 判断是水平还是垂直分栏:看 scrollWidth 是否大于 clientWidth - var hasHorizontalPagination = doc.documentElement.scrollWidth > doc.documentElement.clientWidth + 10; - return hasHorizontalPagination ? pageX : pageY; + if (iframeWidth <= 0 || iframeHeight <= 0) return -1; + var bodyRect = doc.body.getBoundingClientRect(); + var clientRects = el.getClientRects(); + var rect = clientRects.length > 0 ? clientRects[0] : el.getBoundingClientRect(); + if (!rect || rect.width === 0 && rect.height === 0) return -1; + // 判断是水平还是垂直分栏(与 Lumina 一致:direction===1 为垂直) + var isVertical = doc.body.classList.contains('lumina-is-vertical'); + var page; + if (isVertical) { + var offsetY = rect.top + doc.body.scrollTop - bodyRect.top + rect.height / 5 + 1; + page = Math.floor((offsetY + 128) / (iframeHeight + 128)); + console.log('[MN] calcPage V: rect.top=' + rect.top + ' scrollTop=' + doc.body.scrollTop + ' bodyRect.top=' + bodyRect.top + ' offsetY=' + offsetY + ' iframeH=' + iframeHeight + ' page=' + page); + } else { + var offsetX = rect.left + doc.body.scrollLeft - bodyRect.left + rect.width / 5 + 1; + page = Math.floor((offsetX + 128) / (iframeWidth + 128)); + console.log('[MN] calcPage H: rect.left=' + rect.left + ' scrollLeft=' + doc.body.scrollLeft + ' bodyRect.left=' + bodyRect.left + ' offsetX=' + offsetX + ' iframeW=' + iframeWidth + ' page=' + page); + } + return page; } /// 文本搜索回退:遍历所有文本节点,拼接后搜索目标文本,定位并高亮 @@ -606,9 +615,9 @@ class _ReaderWebViewState extends State { var mark = doc.createElement('mooknote-mark'); mark.setAttribute('data-hl-id', id); mark.setAttribute('data-hl-type', markType); - mark.style.backgroundColor = bg; - mark.style.color = 'inherit'; - mark.style.borderRadius = '2px'; + mark.style.setProperty('background-color', bg, 'important'); + mark.style.setProperty('color', 'inherit', 'important'); + mark.style.setProperty('border-radius', '2px', 'important'); try { range.surroundContents(mark); return true; @@ -631,9 +640,9 @@ class _ReaderWebViewState extends State { var mk = doc.createElement('mooknote-mark'); mk.setAttribute('data-hl-id', id); mk.setAttribute('data-hl-type', markType); - mk.style.backgroundColor = bg; - mk.style.color = 'inherit'; - mk.style.borderRadius = '2px'; + mk.style.setProperty('background-color', bg, 'important'); + mk.style.setProperty('color', 'inherit', 'important'); + mk.style.setProperty('border-radius', '2px', 'important'); wrapNode.parentNode.insertBefore(mk, wrapNode); mk.appendChild(wrapNode); @@ -724,13 +733,17 @@ class _ReaderWebViewState extends State { removeHighlight: function(id) { var doc = getFrameDoc(); if (!doc) return; - var mark = doc.querySelector('[data-hl-id="' + id + '"]'); - if (mark && mark.tagName === 'MOOKNOTE-MARK') { - var parent = mark.parentNode; - while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); - parent.removeChild(mark); - parent.normalize(); + var marks = doc.querySelectorAll('[data-hl-id="' + id + '"]'); + for (var i = 0; i < marks.length; i++) { + var mark = marks[i]; + if (mark.tagName === 'MOOKNOTE-MARK') { + var parent = mark.parentNode; + while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); + parent.removeChild(mark); + } } + // 合并相邻文本节点 + if (doc.body) doc.body.normalize(); }, clearAllHighlights: function() {