修复翻页速度过快导致高亮/摘抄失效的问题

This commit is contained in:
DelLevin-Home
2026-07-05 02:17:34 +08:00
parent e2c15cef99
commit 1529c7d7cb
7 changed files with 249 additions and 47 deletions

View File

@@ -801,7 +801,11 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => EpubHighlightsPage(bookId: widget.bookId, book: _book),
builder: (_) => EpubHighlightsPage(
bookId: widget.bookId,
book: _book,
onNavigateToHighlight: _navigateToHighlight,
),
),
).then((_) => _refreshBook());
}

View File

@@ -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<String, dynamic> book;
final void Function(Map<String, dynamic> highlight)? onNavigateToHighlight;
const EpubHighlightsPage({
super.key,
required this.bookId,
required this.book,
this.onNavigateToHighlight,
});
@override
@@ -245,9 +249,48 @@ class _EpubHighlightsPageState extends State<EpubHighlightsPage> {
highlight: highlight,
book: widget.book,
onDelete: () => _deleteHighlight(highlight['id'] as int),
onNavigate: () => _navigateToHighlight(highlight),
);
}
void _navigateToHighlight(Map<String, dynamic> 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<String, dynamic>;
return decoded['startXPath'] as String?;
} catch (_) {
return null;
}
}
String _formatDate(String isoString) {
try {
final dt = DateTime.parse(isoString);

View File

@@ -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,
),
],
),
),

View File

@@ -178,6 +178,9 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
currentPageInChapter: currentPageInChapter,
totalPagesInChapter: totalPagesInChapter,
);
// 恢复新章节的高亮
await restoreHighlightsForCurrentSpine();
}
Future<void> previousSpineItemFirstPage() async {
@@ -200,6 +203,9 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
currentPageInChapter: currentPageInChapter,
totalPagesInChapter: totalPagesInChapter,
);
// 恢复新章节的高亮
await restoreHighlightsForCurrentSpine();
}
Future<void> nextSpineItem() async {
@@ -222,6 +228,9 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
currentPageInChapter: currentPageInChapter,
totalPagesInChapter: totalPagesInChapter,
);
// 恢复新章节的高亮
await restoreHighlightsForCurrentSpine();
}
Future<void> navigateToTocItem(TocEntry item) async {

View File

@@ -12,6 +12,7 @@ mixin _TextSelectionMixin on State<ReaderScreen> {
BookSession get bookSession;
ReaderRendererController get rendererController;
int get currentSpineItemIndex;
Completer<void>? get pageCountReadyCompleter;
// ─── 选中工具条状态 ──────────────────────────────────────────────
String? _selectionText;
@@ -23,6 +24,10 @@ mixin _TextSelectionMixin on State<ReaderScreen> {
String? _pendingScrollXPath;
String? _pendingScrollText;
// 已有高亮/摘抄信息(选中文字时检测)
int? _existingHighlightId;
bool _existingIsExcerpt = false;
bool get isSelectionToolbarVisible =>
_selectionText != null && _selectionRect != null;
@@ -43,6 +48,8 @@ mixin _TextSelectionMixin on State<ReaderScreen> {
_dismissSelectionToolbar();
return;
}
// 检测选中文本是否已有高亮/摘抄
_detectExistingHighlight(selectedText, currentSpineItemIndex);
setState(() {
_selectionText = selectedText;
_selectionRect = rect;
@@ -53,6 +60,22 @@ mixin _TextSelectionMixin on State<ReaderScreen> {
});
}
/// 检测选中文本是否已有高亮或摘抄
Future<void> _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<ReaderScreen> {
_startHandle = null;
_endHandle = null;
_selectionInfo = null;
_existingHighlightId = null;
_existingIsExcerpt = false;
});
}
@@ -236,6 +261,60 @@ mixin _TextSelectionMixin on State<ReaderScreen> {
_dismissSelectionToolbar();
}
// ─── 取消高亮/摘抄 ─────────────────────────────────────────────
/// 取消高亮:删除 DB 记录 + 移除 DOM 标注 → 关闭工具条
Future<void> _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<void> _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<ReaderScreen> {
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);

View File

@@ -144,6 +144,11 @@ class _ReaderScreenState extends State<ReaderScreen>
bool styleDrawerOpen = false;
AppLifecycleState? lastLifecycleState = AppLifecycleState.resumed;
// 等待分栏布局完成
Completer<void>? _pageCountReadyCompleter;
@override
Completer<void>? get pageCountReadyCompleter => _pageCountReadyCompleter;
// 书签
final List<Map<String, dynamic>> _bookmarks = [];
@@ -531,9 +536,17 @@ class _ReaderScreenState extends State<ReaderScreen>
final ratio = widget.initialSpineIndex != null
? null
: bookSession.initialScrollPosition;
// 如果有跳转目标,创建 Completer 等待分栏布局完成
if (widget.initialSpineIndex != null && (widget.scrollToXPath != null || widget.scrollToText != null)) {
_pageCountReadyCompleter = Completer<void>();
}
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<ReaderScreen>
onCopy: _onCopyButton,
onHighlight: _onHighlightButton,
onExcerpt: _onExcerptButton,
onRemoveHighlight: _existingHighlightId != null && !_existingIsExcerpt ? _onRemoveHighlightButton : null,
onRemoveExcerpt: _existingHighlightId != null && _existingIsExcerpt ? _onRemoveExcerptButton : null,
onDismiss: _dismissSelectionToolbar,
),
// 选区手柄(起点和终点)

View File

@@ -509,6 +509,8 @@ class _ReaderWebViewState extends State<ReaderWebView> {
}
/// 计算节点在分栏排版中的页码
/// 与 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<ReaderWebView> {
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<ReaderWebView> {
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<ReaderWebView> {
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<ReaderWebView> {
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() {