generated from dellevin/template
修复翻页速度过快导致高亮/摘抄失效的问题
This commit is contained in:
@@ -801,7 +801,11 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
|
|||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => EpubHighlightsPage(bookId: widget.bookId, book: _book),
|
builder: (_) => EpubHighlightsPage(
|
||||||
|
bookId: widget.bookId,
|
||||||
|
book: _book,
|
||||||
|
onNavigateToHighlight: _navigateToHighlight,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
).then((_) => _refreshBook());
|
).then((_) => _refreshBook());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||||
import '../../utils/epub/reader_dao.dart';
|
import '../../utils/epub/reader_dao.dart';
|
||||||
import '../../utils/toast_util.dart';
|
import '../../utils/toast_util.dart';
|
||||||
import '../../utils/user_prefs.dart';
|
import '../../utils/user_prefs.dart';
|
||||||
import 'highlight_detail_sheet.dart';
|
import 'highlight_detail_sheet.dart';
|
||||||
|
import 'reader_screen.dart';
|
||||||
|
|
||||||
/// EPUB 句读(高亮)管理页面 —— 支持瀑布流/列表模式切换
|
/// EPUB 句读(高亮)管理页面 —— 支持瀑布流/列表模式切换
|
||||||
class EpubHighlightsPage extends StatefulWidget {
|
class EpubHighlightsPage extends StatefulWidget {
|
||||||
final String bookId;
|
final String bookId;
|
||||||
final Map<String, dynamic> book;
|
final Map<String, dynamic> book;
|
||||||
|
final void Function(Map<String, dynamic> highlight)? onNavigateToHighlight;
|
||||||
|
|
||||||
const EpubHighlightsPage({
|
const EpubHighlightsPage({
|
||||||
super.key,
|
super.key,
|
||||||
required this.bookId,
|
required this.bookId,
|
||||||
required this.book,
|
required this.book,
|
||||||
|
this.onNavigateToHighlight,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -245,9 +249,48 @@ class _EpubHighlightsPageState extends State<EpubHighlightsPage> {
|
|||||||
highlight: highlight,
|
highlight: highlight,
|
||||||
book: widget.book,
|
book: widget.book,
|
||||||
onDelete: () => _deleteHighlight(highlight['id'] as int),
|
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) {
|
String _formatDate(String isoString) {
|
||||||
try {
|
try {
|
||||||
final dt = DateTime.parse(isoString);
|
final dt = DateTime.parse(isoString);
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ class EpubSelectionToolbar extends StatelessWidget {
|
|||||||
/// 点击摘抄到笔记的回调
|
/// 点击摘抄到笔记的回调
|
||||||
final VoidCallback onExcerpt;
|
final VoidCallback onExcerpt;
|
||||||
|
|
||||||
|
/// 点击取消高亮按钮的回调
|
||||||
|
final VoidCallback? onRemoveHighlight;
|
||||||
|
|
||||||
|
/// 点击取消摘抄按钮的回调
|
||||||
|
final VoidCallback? onRemoveExcerpt;
|
||||||
|
|
||||||
/// 点击外部区域关闭
|
/// 点击外部区域关闭
|
||||||
final VoidCallback onDismiss;
|
final VoidCallback onDismiss;
|
||||||
|
|
||||||
@@ -24,6 +30,8 @@ class EpubSelectionToolbar extends StatelessWidget {
|
|||||||
required this.onCopy,
|
required this.onCopy,
|
||||||
required this.onHighlight,
|
required this.onHighlight,
|
||||||
required this.onExcerpt,
|
required this.onExcerpt,
|
||||||
|
this.onRemoveHighlight,
|
||||||
|
this.onRemoveExcerpt,
|
||||||
required this.onDismiss,
|
required this.onDismiss,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,7 +52,10 @@ class EpubSelectionToolbar extends StatelessWidget {
|
|||||||
: selectionBottom + margin;
|
: 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;
|
double left = selectionRect.center.dx - toolbarWidth / 2;
|
||||||
left = left.clamp(12.0, mediaQuery.size.width - toolbarWidth - 12.0);
|
left = left.clamp(12.0, mediaQuery.size.width - toolbarWidth - 12.0);
|
||||||
|
|
||||||
@@ -71,14 +82,6 @@ class EpubSelectionToolbar extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildButton(
|
|
||||||
context,
|
|
||||||
icon: Icons.highlight_outlined,
|
|
||||||
label: '高亮',
|
|
||||||
onTap: onHighlight,
|
|
||||||
color: colors.primary,
|
|
||||||
),
|
|
||||||
_divider(colors),
|
|
||||||
_buildButton(
|
_buildButton(
|
||||||
context,
|
context,
|
||||||
icon: Icons.copy_outlined,
|
icon: Icons.copy_outlined,
|
||||||
@@ -87,6 +90,32 @@ class EpubSelectionToolbar extends StatelessWidget {
|
|||||||
color: colors.primary,
|
color: colors.primary,
|
||||||
),
|
),
|
||||||
_divider(colors),
|
_divider(colors),
|
||||||
|
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(
|
_buildButton(
|
||||||
context,
|
context,
|
||||||
icon: Icons.edit_note_outlined,
|
icon: Icons.edit_note_outlined,
|
||||||
|
|||||||
@@ -178,6 +178,9 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
|
|||||||
currentPageInChapter: currentPageInChapter,
|
currentPageInChapter: currentPageInChapter,
|
||||||
totalPagesInChapter: totalPagesInChapter,
|
totalPagesInChapter: totalPagesInChapter,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 恢复新章节的高亮
|
||||||
|
await restoreHighlightsForCurrentSpine();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> previousSpineItemFirstPage() async {
|
Future<void> previousSpineItemFirstPage() async {
|
||||||
@@ -200,6 +203,9 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
|
|||||||
currentPageInChapter: currentPageInChapter,
|
currentPageInChapter: currentPageInChapter,
|
||||||
totalPagesInChapter: totalPagesInChapter,
|
totalPagesInChapter: totalPagesInChapter,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 恢复新章节的高亮
|
||||||
|
await restoreHighlightsForCurrentSpine();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> nextSpineItem() async {
|
Future<void> nextSpineItem() async {
|
||||||
@@ -222,6 +228,9 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
|
|||||||
currentPageInChapter: currentPageInChapter,
|
currentPageInChapter: currentPageInChapter,
|
||||||
totalPagesInChapter: totalPagesInChapter,
|
totalPagesInChapter: totalPagesInChapter,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 恢复新章节的高亮
|
||||||
|
await restoreHighlightsForCurrentSpine();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> navigateToTocItem(TocEntry item) async {
|
Future<void> navigateToTocItem(TocEntry item) async {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ mixin _TextSelectionMixin on State<ReaderScreen> {
|
|||||||
BookSession get bookSession;
|
BookSession get bookSession;
|
||||||
ReaderRendererController get rendererController;
|
ReaderRendererController get rendererController;
|
||||||
int get currentSpineItemIndex;
|
int get currentSpineItemIndex;
|
||||||
|
Completer<void>? get pageCountReadyCompleter;
|
||||||
|
|
||||||
// ─── 选中工具条状态 ──────────────────────────────────────────────
|
// ─── 选中工具条状态 ──────────────────────────────────────────────
|
||||||
String? _selectionText;
|
String? _selectionText;
|
||||||
@@ -23,6 +24,10 @@ mixin _TextSelectionMixin on State<ReaderScreen> {
|
|||||||
String? _pendingScrollXPath;
|
String? _pendingScrollXPath;
|
||||||
String? _pendingScrollText;
|
String? _pendingScrollText;
|
||||||
|
|
||||||
|
// 已有高亮/摘抄信息(选中文字时检测)
|
||||||
|
int? _existingHighlightId;
|
||||||
|
bool _existingIsExcerpt = false;
|
||||||
|
|
||||||
bool get isSelectionToolbarVisible =>
|
bool get isSelectionToolbarVisible =>
|
||||||
_selectionText != null && _selectionRect != null;
|
_selectionText != null && _selectionRect != null;
|
||||||
|
|
||||||
@@ -43,6 +48,8 @@ mixin _TextSelectionMixin on State<ReaderScreen> {
|
|||||||
_dismissSelectionToolbar();
|
_dismissSelectionToolbar();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 检测选中文本是否已有高亮/摘抄
|
||||||
|
_detectExistingHighlight(selectedText, currentSpineItemIndex);
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectionText = selectedText;
|
_selectionText = selectedText;
|
||||||
_selectionRect = rect;
|
_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 选区
|
/// 关闭工具条并清除 WebView 选区
|
||||||
void _dismissSelectionToolbar() {
|
void _dismissSelectionToolbar() {
|
||||||
if (!isSelectionToolbarVisible) return;
|
if (!isSelectionToolbarVisible) return;
|
||||||
@@ -64,6 +87,8 @@ mixin _TextSelectionMixin on State<ReaderScreen> {
|
|||||||
_startHandle = null;
|
_startHandle = null;
|
||||||
_endHandle = null;
|
_endHandle = null;
|
||||||
_selectionInfo = null;
|
_selectionInfo = null;
|
||||||
|
_existingHighlightId = null;
|
||||||
|
_existingIsExcerpt = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +261,60 @@ mixin _TextSelectionMixin on State<ReaderScreen> {
|
|||||||
_dismissSelectionToolbar();
|
_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 加载完成后调用:先跳转到高亮位置,再恢复高亮
|
/// spine 加载完成后调用:先跳转到高亮位置,再恢复高亮
|
||||||
@@ -250,14 +329,24 @@ mixin _TextSelectionMixin on State<ReaderScreen> {
|
|||||||
final text = _pendingScrollText ?? '';
|
final text = _pendingScrollText ?? '';
|
||||||
_pendingScrollXPath = null;
|
_pendingScrollXPath = null;
|
||||||
_pendingScrollText = null;
|
_pendingScrollText = null;
|
||||||
await Future.delayed(const Duration(milliseconds: 300));
|
|
||||||
var pageIndex = await controller.getPageIndexForXPath(xpath);
|
// 等待分栏布局完成(onPageCountReady 触发后)
|
||||||
debugPrint('[MN] scrollToXPath: $xpath → page $pageIndex');
|
final completer = pageCountReadyCompleter;
|
||||||
// XPath 找不到时用文本搜索回退
|
if (completer != null && !completer.isCompleted) {
|
||||||
if (pageIndex < 0 && text.isNotEmpty) {
|
await completer.future.timeout(const Duration(seconds: 5), onTimeout: () {});
|
||||||
debugPrint('[MN] XPath failed, trying text search...');
|
}
|
||||||
|
// 分栏完成后额外等待一帧确保布局稳定
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
// 用文本搜索定位(比 XPath 更可靠)
|
||||||
|
int pageIndex = -1;
|
||||||
|
if (text.isNotEmpty) {
|
||||||
pageIndex = await controller.getPageIndexForText(text);
|
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) {
|
if (pageIndex >= 0) {
|
||||||
await controller.jumpToPage(pageIndex);
|
await controller.jumpToPage(pageIndex);
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ class _ReaderScreenState extends State<ReaderScreen>
|
|||||||
bool styleDrawerOpen = false;
|
bool styleDrawerOpen = false;
|
||||||
AppLifecycleState? lastLifecycleState = AppLifecycleState.resumed;
|
AppLifecycleState? lastLifecycleState = AppLifecycleState.resumed;
|
||||||
|
|
||||||
|
// 等待分栏布局完成
|
||||||
|
Completer<void>? _pageCountReadyCompleter;
|
||||||
|
@override
|
||||||
|
Completer<void>? get pageCountReadyCompleter => _pageCountReadyCompleter;
|
||||||
|
|
||||||
// 书签
|
// 书签
|
||||||
final List<Map<String, dynamic>> _bookmarks = [];
|
final List<Map<String, dynamic>> _bookmarks = [];
|
||||||
|
|
||||||
@@ -531,9 +536,17 @@ class _ReaderScreenState extends State<ReaderScreen>
|
|||||||
final ratio = widget.initialSpineIndex != null
|
final ratio = widget.initialSpineIndex != null
|
||||||
? null
|
? null
|
||||||
: bookSession.initialScrollPosition;
|
: bookSession.initialScrollPosition;
|
||||||
|
// 如果有跳转目标,创建 Completer 等待分栏布局完成
|
||||||
|
if (widget.initialSpineIndex != null && (widget.scrollToXPath != null || widget.scrollToText != null)) {
|
||||||
|
_pageCountReadyCompleter = Completer<void>();
|
||||||
|
}
|
||||||
await loadCarousel(restoreScrollRatio: ratio);
|
await loadCarousel(restoreScrollRatio: ratio);
|
||||||
},
|
},
|
||||||
onPageCountReady: (totalPages) async {
|
onPageCountReady: (totalPages) async {
|
||||||
|
// 通知分栏布局已完成
|
||||||
|
if (_pageCountReadyCompleter != null && !_pageCountReadyCompleter!.isCompleted) {
|
||||||
|
_pageCountReadyCompleter!.complete();
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
totalPagesInChapter = totalPages;
|
totalPagesInChapter = totalPages;
|
||||||
if (currentPageInChapter >= totalPagesInChapter) {
|
if (currentPageInChapter >= totalPagesInChapter) {
|
||||||
@@ -696,6 +709,8 @@ class _ReaderScreenState extends State<ReaderScreen>
|
|||||||
onCopy: _onCopyButton,
|
onCopy: _onCopyButton,
|
||||||
onHighlight: _onHighlightButton,
|
onHighlight: _onHighlightButton,
|
||||||
onExcerpt: _onExcerptButton,
|
onExcerpt: _onExcerptButton,
|
||||||
|
onRemoveHighlight: _existingHighlightId != null && !_existingIsExcerpt ? _onRemoveHighlightButton : null,
|
||||||
|
onRemoveExcerpt: _existingHighlightId != null && _existingIsExcerpt ? _onRemoveExcerptButton : null,
|
||||||
onDismiss: _dismissSelectionToolbar,
|
onDismiss: _dismissSelectionToolbar,
|
||||||
),
|
),
|
||||||
// 选区手柄(起点和终点)
|
// 选区手柄(起点和终点)
|
||||||
|
|||||||
@@ -509,6 +509,8 @@ class _ReaderWebViewState extends State<ReaderWebView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 计算节点在分栏排版中的页码
|
/// 计算节点在分栏排版中的页码
|
||||||
|
/// 与 Lumina 的 calculatePageIndexOfAnchor 使用相同算法:
|
||||||
|
/// getBoundingClientRect + scrollOffset,配合 128px column-gap
|
||||||
function calcPageIndex(doc, node) {
|
function calcPageIndex(doc, node) {
|
||||||
if (!node) return -1;
|
if (!node) return -1;
|
||||||
var el = node.nodeType === 3 ? node.parentNode : node;
|
var el = node.nodeType === 3 ? node.parentNode : node;
|
||||||
@@ -516,17 +518,24 @@ class _ReaderWebViewState extends State<ReaderWebView> {
|
|||||||
if (!f) return -1;
|
if (!f) return -1;
|
||||||
var iframeWidth = f.clientWidth;
|
var iframeWidth = f.clientWidth;
|
||||||
var iframeHeight = f.clientHeight;
|
var iframeHeight = f.clientHeight;
|
||||||
// 获取元素相对于 iframe 内容的偏移
|
if (iframeWidth <= 0 || iframeHeight <= 0) return -1;
|
||||||
var rect = el.getBoundingClientRect();
|
var bodyRect = doc.body.getBoundingClientRect();
|
||||||
var scrollLeft = doc.documentElement.scrollLeft || doc.body.scrollLeft || 0;
|
var clientRects = el.getClientRects();
|
||||||
var scrollTop = doc.documentElement.scrollTop || doc.body.scrollTop || 0;
|
var rect = clientRects.length > 0 ? clientRects[0] : el.getBoundingClientRect();
|
||||||
// 水平分栏
|
if (!rect || rect.width === 0 && rect.height === 0) return -1;
|
||||||
var pageX = iframeWidth > 0 ? Math.floor((rect.left + scrollLeft) / iframeWidth) : 0;
|
// 判断是水平还是垂直分栏(与 Lumina 一致:direction===1 为垂直)
|
||||||
// 垂直分栏
|
var isVertical = doc.body.classList.contains('lumina-is-vertical');
|
||||||
var pageY = iframeHeight > 0 ? Math.floor((rect.top + scrollTop) / iframeHeight) : 0;
|
var page;
|
||||||
// 判断是水平还是垂直分栏:看 scrollWidth 是否大于 clientWidth
|
if (isVertical) {
|
||||||
var hasHorizontalPagination = doc.documentElement.scrollWidth > doc.documentElement.clientWidth + 10;
|
var offsetY = rect.top + doc.body.scrollTop - bodyRect.top + rect.height / 5 + 1;
|
||||||
return hasHorizontalPagination ? pageX : pageY;
|
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');
|
var mark = doc.createElement('mooknote-mark');
|
||||||
mark.setAttribute('data-hl-id', id);
|
mark.setAttribute('data-hl-id', id);
|
||||||
mark.setAttribute('data-hl-type', markType);
|
mark.setAttribute('data-hl-type', markType);
|
||||||
mark.style.backgroundColor = bg;
|
mark.style.setProperty('background-color', bg, 'important');
|
||||||
mark.style.color = 'inherit';
|
mark.style.setProperty('color', 'inherit', 'important');
|
||||||
mark.style.borderRadius = '2px';
|
mark.style.setProperty('border-radius', '2px', 'important');
|
||||||
try {
|
try {
|
||||||
range.surroundContents(mark);
|
range.surroundContents(mark);
|
||||||
return true;
|
return true;
|
||||||
@@ -631,9 +640,9 @@ class _ReaderWebViewState extends State<ReaderWebView> {
|
|||||||
var mk = doc.createElement('mooknote-mark');
|
var mk = doc.createElement('mooknote-mark');
|
||||||
mk.setAttribute('data-hl-id', id);
|
mk.setAttribute('data-hl-id', id);
|
||||||
mk.setAttribute('data-hl-type', markType);
|
mk.setAttribute('data-hl-type', markType);
|
||||||
mk.style.backgroundColor = bg;
|
mk.style.setProperty('background-color', bg, 'important');
|
||||||
mk.style.color = 'inherit';
|
mk.style.setProperty('color', 'inherit', 'important');
|
||||||
mk.style.borderRadius = '2px';
|
mk.style.setProperty('border-radius', '2px', 'important');
|
||||||
|
|
||||||
wrapNode.parentNode.insertBefore(mk, wrapNode);
|
wrapNode.parentNode.insertBefore(mk, wrapNode);
|
||||||
mk.appendChild(wrapNode);
|
mk.appendChild(wrapNode);
|
||||||
@@ -724,13 +733,17 @@ class _ReaderWebViewState extends State<ReaderWebView> {
|
|||||||
removeHighlight: function(id) {
|
removeHighlight: function(id) {
|
||||||
var doc = getFrameDoc();
|
var doc = getFrameDoc();
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
var mark = doc.querySelector('[data-hl-id="' + id + '"]');
|
var marks = doc.querySelectorAll('[data-hl-id="' + id + '"]');
|
||||||
if (mark && mark.tagName === 'MOOKNOTE-MARK') {
|
for (var i = 0; i < marks.length; i++) {
|
||||||
|
var mark = marks[i];
|
||||||
|
if (mark.tagName === 'MOOKNOTE-MARK') {
|
||||||
var parent = mark.parentNode;
|
var parent = mark.parentNode;
|
||||||
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
|
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
|
||||||
parent.removeChild(mark);
|
parent.removeChild(mark);
|
||||||
parent.normalize();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// 合并相邻文本节点
|
||||||
|
if (doc.body) doc.body.normalize();
|
||||||
},
|
},
|
||||||
|
|
||||||
clearAllHighlights: function() {
|
clearAllHighlights: function() {
|
||||||
|
|||||||
Reference in New Issue
Block a user