新增epub书摘,高亮文字,适配平板

This commit is contained in:
DelLevin-Home
2026-07-02 05:22:21 +08:00
parent a0a146854b
commit a7e4845e46
27 changed files with 3712 additions and 505 deletions

View File

@@ -24,6 +24,9 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
// === Cross-mixin: _ThemeMixin ===
EpubTheme getEpubTheme();
// === Cross-mixin: _TextSelectionMixin ===
Future<void> restoreHighlightsForCurrentSpine();
List<String> getAnchorsForSpine(String spinePath) {
return bookSession.getAnchorsForSpine(spinePath);
}
@@ -109,6 +112,9 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
setState(() {
isWebViewLoading = false;
});
// 恢复当前 spine 的高亮(跨 mixin: _TextSelectionMixin
await restoreHighlightsForCurrentSpine();
}
Future<void> preloadNextOf(int currentIndex) async {

View File

@@ -0,0 +1,315 @@
part of '../reader_screen.dart';
/// 文本选中和高亮功能的 mixin
///
/// 提供:
/// - 选中文字后弹出工具条(高亮/复制/摘抄)
/// - 高亮持久化到 [book_annotations] 表
/// - spine 加载后恢复已保存的高亮
mixin _TextSelectionMixin on State<ReaderScreen> {
// === Borrowed state (provided by _ReaderScreenState fields) ===
ReaderDao get _readerDao;
BookSession get bookSession;
ReaderRendererController get rendererController;
int get currentSpineItemIndex;
// ─── 选中工具条状态 ──────────────────────────────────────────────
String? _selectionText;
Rect? _selectionRect;
int? _selectionSpineIndex;
Offset? _startHandle;
Offset? _endHandle;
Map<String, dynamic>? _selectionInfo;
String? _pendingScrollXPath;
String? _pendingScrollText;
bool get isSelectionToolbarVisible =>
_selectionText != null && _selectionRect != null;
ReaderWebViewController? get _webViewControllerMixin =>
rendererController.webViewController;
/// 文本选中回调 —— 由 ReaderWebView 触发
void handleTextSelection(
String selectedText,
Rect rect,
int spineIndex,
double scrollRatio,
Offset? startHandle,
Offset? endHandle,
Map<String, dynamic>? selectionInfo,
) {
if (selectedText.isEmpty) {
_dismissSelectionToolbar();
return;
}
setState(() {
_selectionText = selectedText;
_selectionRect = rect;
_selectionSpineIndex = currentSpineItemIndex;
_startHandle = startHandle;
_endHandle = endHandle;
_selectionInfo = selectionInfo;
});
}
/// 关闭工具条并清除 WebView 选区
void _dismissSelectionToolbar() {
if (!isSelectionToolbarVisible) return;
_webViewControllerMixin?.clearSelection();
setState(() {
_selectionText = null;
_selectionRect = null;
_selectionSpineIndex = null;
_startHandle = null;
_endHandle = null;
_selectionInfo = null;
});
}
// ─── 手柄拖动 ───────────────────────────────────────────────────
/// 拖动起点手柄
void onDragStartHandle(DragUpdateDetails details) {
final globalPos = details.globalPosition;
_webViewControllerMixin?.extendSelection(globalPos.dx, globalPos.dy, true);
}
/// 拖动终点手柄
void onDragEndHandle(DragUpdateDetails details) {
final globalPos = details.globalPosition;
_webViewControllerMixin?.extendSelection(globalPos.dx, globalPos.dy, false);
}
// ─── 工具条按钮处理 ──────────────────────────────────────────────
/// 高亮:获取选区 DOM 信息 → 保存到 DB → 应用 <mark> → 关闭工具条
Future<void> _onHighlightButton() async {
final text = _selectionText;
final spineIndex = _selectionSpineIndex;
final info = _selectionInfo;
if (text == null || spineIndex == null || info == null) {
ToastUtil.show(context, '选区信息已失效,请重新选择');
_dismissSelectionToolbar();
return;
}
final controller = _webViewControllerMixin;
if (controller == null) {
ToastUtil.show(context, '阅读器未就绪');
return;
}
// 重复检测:相同 content + chapter 已有黄色高亮
final existing = await _readerDao.getHighlightsByBookId(widget.bookId);
final dup = existing.any((h) =>
h['chapter'] == spineIndex.toString() &&
h['content'] == text &&
h['color'] != 'excerpt');
if (dup) {
if (!mounted) return;
ToastUtil.show(context, '该内容已高亮');
_dismissSelectionToolbar();
return;
}
final now = DateTime.now().toIso8601String();
final highlight = <String, dynamic>{
'book_id': widget.bookId,
'content': text,
'cfi': jsonEncode({
'startXPath': info['startXPath'],
'startOffset': info['startOffset'],
'endXPath': info['endXPath'],
'endOffset': info['endOffset'],
}),
'chapter': spineIndex.toString(),
'type': 'highlight',
'color': 'FFEB3B',
'reader_note': '',
'created_at': now,
'updated_at': now,
};
final id = await _readerDao.saveHighlight(highlight);
// 先清除浏览器选区,防止 splitText 时活跃选区干扰 DOM 渲染(精排版书籍尤其明显)
await controller.clearSelection();
await Future.delayed(const Duration(milliseconds: 50));
await controller.applyHighlight(info, id.toString(), color: 'highlight', text: text);
if (!mounted) return;
ToastUtil.show(context, '已高亮');
_dismissSelectionToolbar();
}
/// 复制:复制到剪贴板 → 关闭工具条
Future<void> _onCopyButton() async {
final text = _selectionText;
if (text == null) return;
await copyTextToClipboard(text);
if (!mounted) return;
ToastUtil.show(context, '已复制');
_dismissSelectionToolbar();
}
/// 摘抄:保存到 book_excerpts 表 + 用蓝色高亮标注 → 关闭工具条
Future<void> _onExcerptButton() async {
final text = _selectionText;
final spineIndex = _selectionSpineIndex;
final info = _selectionInfo;
if (text == null || info == null) {
ToastUtil.show(context, '选区信息已失效,请重新选择');
_dismissSelectionToolbar();
return;
}
final linkedBookId = bookSession.book['book_id'] as String? ?? '';
if (linkedBookId.isEmpty) {
ToastUtil.show(context, '请先在 EPUB 详情页关联书籍后再摘抄');
_dismissSelectionToolbar();
return;
}
// 重复检测:相同 content + chapter 已有摘抄
final existing = await _readerDao.getHighlightsByBookId(widget.bookId);
final dup = existing.any((h) =>
h['chapter'] == (spineIndex ?? 0).toString() &&
h['content'] == text &&
h['color'] == 'excerpt');
if (dup) {
if (!mounted) return;
ToastUtil.show(context, '该内容已摘抄');
_dismissSelectionToolbar();
return;
}
final now = DateTime.now().toIso8601String();
// 获取章节标题(用于摘抄显示)
final chapterTitle = bookSession.getChapterTitleForSpine(currentSpineItemIndex);
final displayChapter = chapterTitle.isNotEmpty
? chapterTitle
: '${(spineIndex ?? 0) + 1}';
// 1. 保存到 book_excerpts 表chapter 用章节标题)
await DatabaseHelper.instance.database.then((db) async {
await db.insert('book_excerpts', {
'id': 'excerpt_${DateTime.now().millisecondsSinceEpoch}',
'book_id': linkedBookId,
'chapter': displayChapter,
'content': text,
'comment': '',
'is_deleted': 0,
'created_at': now,
'updated_at': now,
});
});
// 2. 同时保存蓝色高亮标注到 book_annotations 表chapter 用 spine 索引,用于恢复高亮)
final controller = _webViewControllerMixin;
if (controller != null) {
final highlight = <String, dynamic>{
'book_id': widget.bookId,
'content': text,
'cfi': jsonEncode({
'startXPath': info['startXPath'],
'startOffset': info['startOffset'],
'endXPath': info['endXPath'],
'endOffset': info['endOffset'],
}),
'chapter': (spineIndex ?? 0).toString(),
'type': 'highlight',
'color': 'excerpt',
'reader_note': '',
'created_at': now,
'updated_at': now,
};
final id = await _readerDao.saveHighlight(highlight);
// 先清除浏览器选区,防止 splitText 时活跃选区干扰 DOM 渲染
await controller.clearSelection();
await Future.delayed(const Duration(milliseconds: 50));
await controller.applyHighlight(info, id.toString(), color: 'excerpt', text: text);
}
if (!mounted) return;
ToastUtil.show(context, '已保存到摘抄');
_dismissSelectionToolbar();
}
// ─── 高亮恢复 ───────────────────────────────────────────────────
/// spine 加载完成后调用:先跳转到高亮位置,再恢复高亮
Future<void> restoreHighlightsForCurrentSpine() async {
final controller = _webViewControllerMixin;
if (controller == null) return;
if (currentSpineItemIndex < 0) return;
// 1. 先跳转到高亮所在位置在应用高亮之前XPath 还指向原始文本节点)
if (_pendingScrollXPath != null) {
final xpath = _pendingScrollXPath!;
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...');
pageIndex = await controller.getPageIndexForText(text);
debugPrint('[MN] text search → page $pageIndex');
}
if (pageIndex >= 0) {
await controller.jumpToPage(pageIndex);
debugPrint('[MN] jumped to page $pageIndex');
}
}
// 2. 查询当前 spine 应有的高亮
final highlights =
await _readerDao.getHighlightsByBookId(widget.bookId);
final spineHighlights = highlights.where((h) {
final chapter = h['chapter'] as String? ?? '';
return chapter == currentSpineItemIndex.toString();
}).toList();
// 3. 先清除 DOM 中所有旧高亮,再从 DB 重新应用
// 这样外部删除摘抄/高亮后,返回阅读器时 DOM 能同步更新
await controller.clearAllHighlights();
if (spineHighlights.isEmpty) return;
final jsHighlights = <Map<String, dynamic>>[];
for (final h in spineHighlights) {
final cfi = h['cfi'] as String? ?? '';
if (cfi.isEmpty) continue;
try {
final decoded = jsonDecode(cfi) as Map<String, dynamic>;
jsHighlights.add({
'id': (h['id'] ?? '').toString(),
'color': h['color'] == 'excerpt' ? 'excerpt' : 'highlight',
'text': h['content'] as String? ?? '',
'info': {
'startXPath': decoded['startXPath'] ?? '',
'startOffset': decoded['startOffset'] ?? 0,
'endXPath': decoded['endXPath'] ?? '',
'endOffset': decoded['endOffset'] ?? 0,
},
});
} catch (_) {}
}
if (jsHighlights.isEmpty) return;
debugPrint('[MN] restoreHighlights: ${jsHighlights.length} highlights for spine $currentSpineItemIndex');
// 重试 3 次,确保 iframe DOM 就绪
for (int attempt = 0; attempt < 3; attempt++) {
await Future.delayed(const Duration(milliseconds: 300));
if (!mounted) return;
final applied = await controller.applyHighlights(jsHighlights);
debugPrint('[MN] restoreHighlights: attempt=$attempt applied=$applied/${jsHighlights.length}');
if (applied >= jsHighlights.length) break;
}
}
}