新增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

@@ -3,6 +3,7 @@ import '../../utils/book/book_dao.dart';
import '../../utils/epub/reader_dao.dart';
import '../../utils/toast_util.dart';
import '../../models/data_models.dart';
import '../../widgets/fade_in_local_image.dart';
/// 选择关联书籍页面(带搜索功能)
class BookLinkPage extends StatefulWidget {
@@ -18,6 +19,7 @@ class _BookLinkPageState extends State<BookLinkPage> {
final BookDao _bookDao = BookDao();
final ReaderDao _readerDao = ReaderDao();
final TextEditingController _searchCtrl = TextEditingController();
final FocusNode _searchFocus = FocusNode();
List<Book> _allBooks = [];
List<Book> _filteredBooks = [];
@@ -33,6 +35,7 @@ class _BookLinkPageState extends State<BookLinkPage> {
@override
void dispose() {
_searchCtrl.dispose();
_searchFocus.dispose();
super.dispose();
}
@@ -88,52 +91,17 @@ class _BookLinkPageState extends State<BookLinkPage> {
body: Column(
children: [
// 搜索栏
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: TextField(
controller: _searchCtrl,
onChanged: _onSearch,
decoration: InputDecoration(
hintText: '搜索书名或作者...',
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)),
prefixIcon: Icon(Icons.search, size: 20, color: colors.onSurface.withValues(alpha: 0.4)),
suffixIcon: _query.isNotEmpty
? IconButton(
icon: Icon(Icons.close, size: 18, color: colors.onSurface.withValues(alpha: 0.4)),
onPressed: () {
_searchCtrl.clear();
_onSearch('');
},
)
: null,
filled: true,
fillColor: colors.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
style: const TextStyle(fontSize: 14),
),
),
_buildSearchBar(colors),
// 书籍列表
Expanded(
child: _isLoading
? Center(child: CircularProgressIndicator(color: colors.primary))
: _filteredBooks.isEmpty
? Center(
child: Text(
_query.isEmpty ? '暂无书籍' : '未找到匹配的书籍',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4)),
),
)
: ListView.separated(
? _buildEmptyState(colors)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
itemCount: _filteredBooks.length,
separatorBuilder: (_, __) =>
Divider(height: 0.5, indent: 72, color: colors.outlineVariant),
itemBuilder: (_, index) => _buildBookTile(_filteredBooks[index], colors),
itemBuilder: (_, index) => _buildBookCard(_filteredBooks[index], colors, index),
),
),
],
@@ -141,46 +109,217 @@ class _BookLinkPageState extends State<BookLinkPage> {
);
}
Widget _buildBookTile(Book book, ColorScheme colors) {
// ── 搜索栏 ──
Widget _buildSearchBar(ColorScheme colors) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Container(
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: TextField(
controller: _searchCtrl,
focusNode: _searchFocus,
onChanged: _onSearch,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(
hintText: '搜索书名或作者…',
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)),
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 14, right: 10),
child: Icon(Icons.search_rounded, size: 20, color: colors.onSurface.withValues(alpha: 0.35)),
),
prefixIconConstraints: const BoxConstraints(minWidth: 44, minHeight: 44),
suffixIcon: _query.isNotEmpty
? Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () {
_searchCtrl.clear();
_onSearch('');
},
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Icon(Icons.close_rounded, size: 14, color: colors.onSurface.withValues(alpha: 0.4)),
),
),
)
: null,
suffixIconConstraints: const BoxConstraints(minWidth: 32, minHeight: 32),
filled: true,
fillColor: colors.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.3), width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colors.primary.withValues(alpha: 0.5), width: 1.5),
),
),
),
),
);
}
// ── 空状态 ──
Widget _buildEmptyState(ColorScheme colors) {
return Center(
child: Padding(
padding: const EdgeInsets.all(40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
_query.isEmpty ? Icons.library_books_outlined : Icons.search_off_rounded,
size: 36,
color: colors.onSurface.withValues(alpha: 0.2),
),
),
const SizedBox(height: 16),
Text(
_query.isEmpty ? '暂无书籍' : '未找到匹配的书籍',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)),
),
],
),
),
);
}
// ── 书籍卡片 ──
Widget _buildBookCard(Book book, ColorScheme colors, int index) {
final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty;
return InkWell(
onTap: () => _selectBook(book),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(children: [
// 封面缩略图
Container(
width: 44, height: 62,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
clipBehavior: Clip.antiAlias,
child: hasCover
? Image.asset(book.coverPath!, fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(Icons.book_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2)))
: Icon(Icons.book_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2)),
),
const SizedBox(width: 14),
// 书名 + 作者
Expanded(
child: Column(
return Padding(
padding: EdgeInsets.only(bottom: index < _filteredBooks.length - 1 ? 10 : 0),
child: Material(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => _selectBook(book),
child: Container(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(book.title, maxLines: 2, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: colors.onSurface)),
if (book.authors.isNotEmpty) ...[
const SizedBox(height: 3),
Text(book.authors.join(', '), maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
],
// 封面
Container(
width: 48,
height: 68,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: hasCover
? FadeInLocalImage(
path: book.coverPath,
fit: BoxFit.cover,
)
: Center(
child: Icon(Icons.menu_book_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2)),
),
),
const SizedBox(width: 14),
// 书名 + 作者
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 2),
Text(
book.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.3),
),
if (book.authors.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
book.authors.join(', '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4)),
),
],
if (book.genres.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 4,
children: book.genres.take(2).map((g) => Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(4),
),
child: Text(
g,
style: TextStyle(fontSize: 10, color: colors.primary.withValues(alpha: 0.6)),
),
)).toList(),
),
],
],
),
),
const SizedBox(width: 8),
// 关联图标
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.link_rounded, size: 14, color: colors.primary),
const SizedBox(width: 4),
Text('关联', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.primary)),
],
),
),
],
),
),
Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)),
]),
),
),
);
}

View File

@@ -139,10 +139,18 @@ class BookSession {
_tocItemFallback.clear();
for (int i = 0; i < _spine.length; i++) {
_tocItemFallback.add(fallback);
final anchors = _spineToAnchorsMap[_spine[i].href] ?? [];
final spineHref = _spine[i].href;
final anchors = _spineToAnchorsMap[spineHref] ?? [];
if (anchors.isNotEmpty) {
final lastHref = '${_spine[i].href}#${anchors.last}';
final idx = _hrefToTocIndexMap[lastHref];
// 先查 spine href + anchor再回退到纯 spine href
final lastHref = '$spineHref#${anchors.last}';
final idx = _hrefToTocIndexMap[lastHref] ?? _hrefToTocIndexMap[spineHref];
if (idx != null) {
fallback = _flatToc[idx];
}
} else {
// 没有锚点,直接用 spine href 查
final idx = _hrefToTocIndexMap[spineHref];
if (idx != null) {
fallback = _flatToc[idx];
}
@@ -281,6 +289,38 @@ class BookSession {
return null;
}
/// 通过 spine 索引直接获取章节标题(不依赖 scroll anchors用文件名匹配
String getChapterTitleForSpine(int spineIndex) {
if (spineIndex < 0 || spineIndex >= _spine.length) return '';
final spineHref = _spine[spineIndex].href;
final spineFileName = spineHref.split('/').last.toLowerCase();
// 1. 精确匹配 href
for (final entry in _flatToc) {
final tocHref = entry.href.split('#')[0];
if (tocHref == spineHref) return entry.label;
}
// 2. 文件名匹配(防止路径前缀不一致)
for (final entry in _flatToc) {
final tocFileName = entry.href.split('#')[0].split('/').last.toLowerCase();
if (tocFileName == spineFileName && entry.label.isNotEmpty) {
return entry.label;
}
}
// 3. 回退到 _tocItemFallback
if (spineIndex < _tocItemFallback.length) {
final label = _tocItemFallback[spineIndex].label;
if (label.isNotEmpty && label != (bookData['title'] as String? ?? '')) {
return label;
}
}
return '';
}
/// Find spine index from a URL string (virtual epub:// or relative path).
int? findSpineIndexByUrl(String url) {
String path;

View File

@@ -1,16 +1,22 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../utils/epub/reader_dao.dart';
import '../../utils/epub/epub_parser.dart';
import '../../utils/epub/reader_models.dart';
import '../../utils/book/book_dao.dart';
import '../../utils/book/book_excerpt_dao.dart';
import '../../utils/toast_util.dart';
import '../../models/data_models.dart';
import '../book/book_detail_page.dart';
import '../book/book_excerpts_page.dart';
import 'book_link_page.dart';
import 'epub_edit_page.dart';
import 'epub_highlights_page.dart';
import 'highlight_detail_sheet.dart';
import 'reader_screen.dart';
/// EPUB 书籍详情页
@@ -37,6 +43,7 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
EpubBookInfo? _bookInfo;
bool _descriptionExpanded = false;
Future<List<BookExcerpt>>? _excerptsFuture;
Future<List<Map<String, dynamic>>>? _highlightsFuture;
@override
void initState() {
@@ -47,6 +54,7 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
if (linkedBookId.isNotEmpty) {
_excerptsFuture = BookExcerptDao().getExcerptsByBookId(linkedBookId);
}
_highlightsFuture = _dao.getHighlightsByBookId(widget.bookId);
}
Future<void> _loadBookInfo() async {
@@ -58,7 +66,18 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
Future<void> _refreshBook() async {
final updated = await _dao.getReaderBookById(widget.bookId);
if (mounted && updated != null) setState(() => _book = updated);
if (mounted && updated != null) {
final linkedBookId = updated['book_id'] as String? ?? '';
setState(() {
_book = updated;
_highlightsFuture = _dao.getHighlightsByBookId(widget.bookId);
if (linkedBookId.isNotEmpty) {
_excerptsFuture = BookExcerptDao().getExcerptsByBookId(linkedBookId);
} else {
_excerptsFuture = null;
}
});
}
}
void _navigateToReader() {
@@ -202,12 +221,20 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
child: _buildLinkedBookCard(colors),
),
// ── 句读(高亮)──
Divider(height: 0.5, thickness: 0.5, color: colors.outline),
_buildHighlightsSectionHeader(colors),
Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 24),
child: _buildHighlightsList(colors),
),
// ── 书籍摘抄(仅关联书籍时显示)──
if ((_book['book_id'] as String? ?? '').isNotEmpty) ...[
Divider(height: 0.5, thickness: 0.5, color: colors.outline),
_buildSectionHeader('书籍摘抄', colors),
_buildExcerptsSectionHeader(colors),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
padding: const EdgeInsets.fromLTRB(0, 0, 0, 24),
child: _buildExcerptsList(colors),
),
],
@@ -366,6 +393,7 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
@@ -382,71 +410,398 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
);
}
final excerpts = snapshot.data!;
final showCount = excerpts.length > 5 ? 5 : excerpts.length;
return Column(
children: [
for (int i = 0; i < showCount; i++)
Padding(
padding: EdgeInsets.only(bottom: i < showCount - 1 ? 8 : 0),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 3,
height: 16,
margin: const EdgeInsets.only(top: 2, right: 10),
decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(1.5),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
excerpts[i].content,
maxLines: 3,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.75), height: 1.6),
),
if (excerpts[i].chapter.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
excerpts[i].chapter,
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35)),
),
),
],
),
),
],
),
),
),
if (excerpts.length > 5)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'${excerpts.length} 条摘抄',
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35)),
),
),
],
return SizedBox(
height: 140,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: excerpts.length,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, i) => _buildExcerptCard(excerpts[i], colors),
),
);
},
);
}
Widget _buildExcerptCard(BookExcerpt excerpt, ColorScheme colors) {
final hasComment = excerpt.comment.isNotEmpty;
return GestureDetector(
onTap: () => _showExcerptDetail(excerpt, colors),
child: Container(
width: 160,
padding: const EdgeInsets.fromLTRB(14, 12, 14, 10),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部:图标 + 章节标签
Row(
children: [
Icon(Icons.menu_book_outlined, size: 14, color: colors.primary.withValues(alpha: 0.6)),
const Spacer(),
if (excerpt.chapter.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
),
child: Text(
excerpt.chapter,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 9, color: colors.primary.withValues(alpha: 0.7), fontWeight: FontWeight.w500),
),
),
],
),
const SizedBox(height: 8),
// 摘抄内容
Expanded(
child: Text(
excerpt.content,
maxLines: hasComment ? 3 : 4,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.8), height: 1.6),
),
),
// 底部:感悟标记 + 日期
Row(
children: [
if (hasComment) ...[
Icon(Icons.lightbulb_outline, size: 10, color: colors.primary.withValues(alpha: 0.4)),
const SizedBox(width: 3),
Text('有感悟', style: TextStyle(fontSize: 9, color: colors.primary.withValues(alpha: 0.4))),
],
const Spacer(),
Text(
_formatDate(excerpt.createdAt.toIso8601String()),
style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.3)),
),
],
),
],
),
),
);
}
// ── 句读(高亮)──
Widget _buildHighlightsSectionHeader(ColorScheme colors) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 8, 10),
child: Row(children: [
Container(width: 4, height: 14,
decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 8),
Text('句读', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
const Spacer(),
TextButton(
onPressed: _navigateToHighlightsPage,
style: TextButton.styleFrom(
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: 12),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('详情', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))),
Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.5)),
],
),
),
]),
);
}
// ── 书籍摘抄 ──
Widget _buildExcerptsSectionHeader(ColorScheme colors) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 8, 10),
child: Row(children: [
Container(width: 4, height: 14,
decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 8),
Text('书籍摘抄', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
const Spacer(),
TextButton(
onPressed: _navigateToExcerptsPage,
style: TextButton.styleFrom(
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: 12),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('详情', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))),
Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.5)),
],
),
),
]),
);
}
Widget _buildHighlightsList(ColorScheme colors) {
return FutureBuilder<List<Map<String, dynamic>>>(
future: _highlightsFuture,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox.shrink();
}
final highlights = snapshot.data!
.where((h) => h['color'] != 'excerpt')
.toList();
if (highlights.isEmpty) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.highlight_outlined, size: 18, color: colors.onSurface.withValues(alpha: 0.2)),
const SizedBox(width: 8),
Text('暂无句读', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))),
],
),
);
}
return SizedBox(
height: 140,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: highlights.length,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, i) => _buildHighlightCard(highlights[i], colors),
),
);
},
);
}
Widget _buildHighlightCard(Map<String, dynamic> highlight, ColorScheme colors) {
final content = highlight['content'] as String? ?? '';
final chapter = highlight['chapter'] as String? ?? '';
final chapterNum = int.tryParse(chapter);
final createdAt = highlight['created_at'] as String? ?? '';
return GestureDetector(
onTap: () => _showHighlightDetail(highlight, colors),
child: Container(
width: 160,
padding: const EdgeInsets.fromLTRB(14, 12, 14, 10),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部:引号图标 + 章节标签
Row(
children: [
Icon(Icons.format_quote, size: 14, color: const Color(0xFFFFC107)),
const Spacer(),
if (chapterNum != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFFFEB3B).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${chapterNum + 1}',
style: const TextStyle(fontSize: 9, color: Color(0xFF795548), fontWeight: FontWeight.w500),
),
),
],
),
const SizedBox(height: 8),
// 高亮文本
Expanded(
child: Text(
content,
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.8), height: 1.6),
),
),
// 底部:日期
if (createdAt.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_formatDate(createdAt),
style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.3)),
),
),
],
),
),
);
}
/// 摘抄详情弹窗
void _showExcerptDetail(BookExcerpt excerpt, ColorScheme colors) {
showExcerptDetailSheet(
context,
content: excerpt.content,
chapter: excerpt.chapter,
comment: excerpt.comment,
createdAt: excerpt.createdAt,
bookTitle: _book['title'] as String? ?? '',
onDelete: () => _deleteExcerpt(excerpt, colors),
);
}
/// 删除摘抄(同步删除蓝色高亮)
Future<void> _deleteExcerpt(BookExcerpt excerpt, ColorScheme colors) async {
final confirmed = showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除摘抄', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
content: Text('确定删除这条摘抄吗?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)))),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text('删除', style: TextStyle(color: colors.error))),
],
),
);
if (await confirmed != true) return;
// 同步删除蓝色高亮(用 books 表 ID 查找关联的 reader_book
final linkedBookId = _book['book_id'] as String? ?? '';
if (linkedBookId.isNotEmpty) {
final readerBook = await _dao.getReaderBookByBookId(linkedBookId);
if (readerBook != null) {
await _dao.deleteExcerptHighlightByContent(
readerBook['id'] as String,
excerpt.content,
);
}
}
// 删除摘抄记录
await BookExcerptDao().deleteExcerpt(excerpt.id);
if (mounted) {
setState(() {
_refreshBook();
});
ToastUtil.show(context, '已删除');
}
}
/// 句读详情弹窗(使用共享组件)
void _showHighlightDetail(Map<String, dynamic> highlight, ColorScheme colors) {
showHighlightDetailSheet(
context,
highlight: highlight,
book: _book,
onDelete: () => _deleteHighlight(highlight['id'] as int, colors),
);
}
Future<void> _deleteHighlight(int id, ColorScheme colors) async {
final confirmed = showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除句读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
content: Text('确定删除这条句读?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4))),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text('删除', style: TextStyle(color: colors.error)),
),
],
),
);
if (await confirmed != true) return;
await _dao.deleteHighlight(id);
if (mounted) {
setState(() {
_highlightsFuture = _dao.getHighlightsByBookId(widget.bookId);
});
}
}
String _formatDate(String isoString) {
try {
final dt = DateTime.parse(isoString);
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
} catch (_) {
return '';
}
}
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? ?? '';
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ReaderScreen(
bookId: _book['id'] as String,
filePath: _book['file_path'] as String,
title: _book['title'] as String? ?? '',
coverPath: _book['cover_path'] as String?,
bookData: _book,
initialSpineIndex: chapter,
scrollToXPath: xpath,
scrollToText: text,
),
),
).then((_) => _refreshBook());
}
/// 从 cfi JSON 中提取 startXPath
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;
}
}
void _navigateToHighlightsPage() {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => EpubHighlightsPage(bookId: widget.bookId, book: _book),
),
).then((_) => _refreshBook());
}
void _navigateToExcerptsPage() async {
final linkedBookId = _book['book_id'] as String? ?? '';
if (linkedBookId.isEmpty) return;
final book = await _bookDao.getBookById(linkedBookId);
if (!mounted || book == null) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BookExcerptsPage(book: book),
),
).then((_) => _refreshBook());
}
void _showLinkedBookActions(ColorScheme colors, String title) {
showModalBottomSheet(
context: context,

View File

@@ -0,0 +1,291 @@
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';
/// EPUB 句读(高亮)管理页面 —— 支持瀑布流/列表模式切换
class EpubHighlightsPage extends StatefulWidget {
final String bookId;
final Map<String, dynamic> book;
const EpubHighlightsPage({
super.key,
required this.bookId,
required this.book,
});
@override
State<EpubHighlightsPage> createState() => _EpubHighlightsPageState();
}
class _EpubHighlightsPageState extends State<EpubHighlightsPage> {
final ReaderDao _dao = ReaderDao();
List<Map<String, dynamic>> _highlights = [];
bool _isLoading = true;
bool _isListMode = UserPrefs().highlightsViewMode == 1;
@override
void initState() {
super.initState();
_loadHighlights();
}
Future<void> _loadHighlights() async {
setState(() => _isLoading = true);
try {
final all = await _dao.getHighlightsByBookId(widget.bookId);
setState(() {
_highlights = all.where((h) => h['color'] != 'excerpt').toList();
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
if (mounted) ToastUtil.show(context, '加载失败: $e');
}
}
void _toggleViewMode() {
setState(() => _isListMode = !_isListMode);
UserPrefs().setHighlightsViewMode(_isListMode ? 1 : 0);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
title: Text('句读', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
actions: [
IconButton(
icon: Icon(
_isListMode ? Icons.grid_view_rounded : Icons.view_agenda_outlined,
size: 22,
),
tooltip: _isListMode ? '瀑布流' : '列表',
onPressed: _toggleViewMode,
),
],
),
body: _isLoading
? Center(child: CircularProgressIndicator(color: colors.primary))
: _highlights.isEmpty
? _buildEmpty(colors)
: _isListMode
? _buildListView(colors)
: _buildMasonryView(colors),
);
}
// ─── 瀑布流模式 ───
Widget _buildMasonryView(ColorScheme colors) {
return MasonryGridView.count(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
itemCount: _highlights.length,
itemBuilder: (context, i) => _buildMasonryCard(_highlights[i], colors),
);
}
Widget _buildMasonryCard(Map<String, dynamic> highlight, ColorScheme colors) {
final content = highlight['content'] as String? ?? '';
final chapter = highlight['chapter'] as String? ?? '';
final chapterNum = int.tryParse(chapter);
final createdAt = highlight['created_at'] as String? ?? '';
return GestureDetector(
onTap: () => _showDetail(highlight, colors),
onLongPress: () => _showDeleteConfirm(highlight['id'] as int, colors),
child: Container(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 高亮文本
Text(
content,
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.85),
height: 1.65,
),
),
const SizedBox(height: 8),
// 底部信息行
Row(
children: [
if (chapterNum != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: const Color(0xFFFFEB3B).withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'${chapterNum + 1}',
style: const TextStyle(fontSize: 9, color: Color(0xFF795548), fontWeight: FontWeight.w500),
),
),
],
if (createdAt.isNotEmpty) ...[
const SizedBox(width: 6),
Text(
_formatDate(createdAt),
style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.3)),
),
],
],
),
],
),
),
);
}
// ─── 列表模式 ───
Widget _buildListView(ColorScheme colors) {
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
itemCount: _highlights.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, i) => _buildListCard(_highlights[i], colors),
);
}
Widget _buildListCard(Map<String, dynamic> highlight, ColorScheme colors) {
final content = highlight['content'] as String? ?? '';
final chapter = highlight['chapter'] as String? ?? '';
final chapterNum = int.tryParse(chapter);
final createdAt = highlight['created_at'] as String? ?? '';
return GestureDetector(
onTap: () => _showDetail(highlight, colors),
onLongPress: () => _showDeleteConfirm(highlight['id'] as int, colors),
child: Container(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 章节 + 日期
Row(
children: [
Icon(Icons.highlight_outlined, size: 13, color: const Color(0xFFFFC107)),
if (chapterNum != null) ...[
const SizedBox(width: 5),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFFFEB3B).withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'${chapterNum + 1}',
style: const TextStyle(fontSize: 10, color: Color(0xFF795548), fontWeight: FontWeight.w500),
),
),
],
const Spacer(),
if (createdAt.isNotEmpty)
Text(
_formatDateFull(createdAt),
style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.3)),
),
],
),
const SizedBox(height: 10),
// 高亮文本
Text(
content,
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.85),
height: 1.75,
),
),
],
),
),
);
}
// ─── 公共 ───
Widget _buildEmpty(ColorScheme colors) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.highlight_outlined, size: 48, color: colors.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 12),
Text('暂无句读', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35))),
],
),
);
}
void _showDetail(Map<String, dynamic> highlight, ColorScheme colors) {
showHighlightDetailSheet(
context,
highlight: highlight,
book: widget.book,
onDelete: () => _deleteHighlight(highlight['id'] as int),
);
}
String _formatDate(String isoString) {
try {
final dt = DateTime.parse(isoString);
return '${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
} catch (_) {
return '';
}
}
String _formatDateFull(String isoString) {
try {
final dt = DateTime.parse(isoString);
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
} catch (_) {
return '';
}
}
void _showDeleteConfirm(int id, ColorScheme colors) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除句读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
content: Text('确定删除这条句读?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)))),
TextButton(
onPressed: () { Navigator.pop(ctx); _deleteHighlight(id); },
child: Text('删除', style: TextStyle(color: colors.error)),
),
],
),
);
}
Future<void> _deleteHighlight(int id) async {
await _dao.deleteHighlight(id);
_loadHighlights();
if (mounted) ToastUtil.show(context, '已删除');
}
}

View File

@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// 文本选中后弹出的工具条
class EpubSelectionToolbar extends StatelessWidget {
/// 选中文字的屏幕坐标区域
final Rect selectionRect;
/// 点击复制按钮的回调
final VoidCallback onCopy;
/// 点击高亮按钮的回调
final VoidCallback onHighlight;
/// 点击摘抄到笔记的回调
final VoidCallback onExcerpt;
/// 点击外部区域关闭
final VoidCallback onDismiss;
const EpubSelectionToolbar({
super.key,
required this.selectionRect,
required this.onCopy,
required this.onHighlight,
required this.onExcerpt,
required this.onDismiss,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
// 计算工具条位置:优先在选区上方,空间不足时放下方
final mediaQuery = MediaQuery.of(context);
final selectionTop = selectionRect.top;
final selectionBottom = selectionRect.bottom;
const toolbarHeight = 44.0;
const margin = 8.0;
final showAbove = selectionTop > toolbarHeight + margin + 80;
final top = showAbove
? selectionTop - toolbarHeight - margin
: selectionBottom + margin;
// 水平居中于选区,但不超出屏幕
const toolbarWidth = 220.0;
double left = selectionRect.center.dx - toolbarWidth / 2;
left = left.clamp(12.0, mediaQuery.size.width - toolbarWidth - 12.0);
return Stack(
children: [
// 全屏遮罩,点击关闭
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onDismiss,
child: const SizedBox.expand(),
),
),
Positioned(
left: left,
top: top < 0 ? margin : top,
child: Material(
elevation: 6,
borderRadius: BorderRadius.circular(10),
color: colors.surface,
surfaceTintColor: colors.surfaceTint,
child: SizedBox(
height: toolbarHeight,
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,
label: '复制',
onTap: onCopy,
color: colors.primary,
),
_divider(colors),
_buildButton(
context,
icon: Icons.edit_note_outlined,
label: '摘抄',
onTap: onExcerpt,
color: colors.primary,
),
],
),
),
),
),
],
);
}
Widget _divider(ColorScheme colors) {
return Container(
width: 0.5,
height: 22,
color: colors.outlineVariant,
);
}
Widget _buildButton(
BuildContext context, {
required IconData icon,
required String label,
required VoidCallback onTap,
required Color color,
}) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 14,
color: colors.onSurface,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
}
/// 复制文字到剪贴板的辅助函数
Future<void> copyTextToClipboard(String text) async {
await Clipboard.setData(ClipboardData(text: text));
}

View File

@@ -0,0 +1,404 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../utils/toast_util.dart';
/// 句读详情弹窗 —— 类似分享卡片的样式
/// 从 epub_detail_page 和 epub_highlights_page 共用
void showHighlightDetailSheet(
BuildContext context, {
required Map<String, dynamic> highlight,
required Map<String, dynamic> book,
required VoidCallback onDelete,
VoidCallback? onNavigate,
}) {
final colors = Theme.of(context).colorScheme;
final content = highlight['content'] as String? ?? '';
final chapter = highlight['chapter'] as String? ?? '';
final chapterNum = int.tryParse(chapter);
final createdAt = highlight['created_at'] as String? ?? '';
final bookTitle = book['title'] as String? ?? '';
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 24),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.15),
blurRadius: 30,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 顶部装饰条
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 12),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 书名 + 章节标题行
Row(
children: [
Icon(Icons.book_outlined, size: 16, color: const Color(0xFFFFC107)),
const SizedBox(width: 6),
Expanded(
child: Text(
bookTitle,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colors.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (chapterNum != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFFFEB3B).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${chapterNum + 1}',
style: const TextStyle(fontSize: 11, color: Color(0xFF795548), fontWeight: FontWeight.w500),
),
),
],
),
const SizedBox(height: 20),
// 引号装饰 + 高亮文本
Stack(
children: [
Positioned(
left: -4,
top: -8,
child: Text(
'"',
style: TextStyle(
fontSize: 48,
color: const Color(0xFFFFEB3B).withValues(alpha: 0.4),
fontWeight: FontWeight.bold,
height: 1,
),
),
),
Padding(
padding: const EdgeInsets.only(left: 20),
child: SelectableText(
content,
style: TextStyle(
fontSize: 15,
color: colors.onSurface.withValues(alpha: 0.85),
height: 1.8,
),
),
),
],
),
const SizedBox(height: 16),
// 日期
if (createdAt.isNotEmpty)
Row(
children: [
Icon(Icons.access_time, size: 12, color: colors.onSurface.withValues(alpha: 0.3)),
const SizedBox(width: 4),
Text(
_formatDate(createdAt),
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35)),
),
],
),
const SizedBox(height: 24),
// 分隔线
Container(height: 0.5, color: colors.outlineVariant),
const SizedBox(height: 16),
// 操作按钮
Row(
children: [
_buildActionButton(
colors,
icon: Icons.content_copy_outlined,
label: '复制',
onTap: () {
Clipboard.setData(ClipboardData(text: content));
Navigator.pop(ctx);
ToastUtil.show(context, '已复制');
},
),
if (onNavigate != null)
_buildActionButton(
colors,
icon: Icons.menu_book_outlined,
label: '跳转',
onTap: () {
Navigator.pop(ctx);
onNavigate();
},
),
_buildActionButton(
colors,
icon: Icons.delete_outline,
label: '删除',
onTap: () {
Navigator.pop(ctx);
onDelete();
},
isDanger: true,
),
],
),
],
),
),
],
),
);
},
);
}
Widget _buildActionButton(
ColorScheme colors, {
required IconData icon,
required String label,
required VoidCallback onTap,
bool isDanger = false,
}) {
final color = isDanger ? colors.error : colors.onSurface.withValues(alpha: 0.6);
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 13, color: color, fontWeight: FontWeight.w500)),
],
),
),
),
);
}
String _formatDate(String isoString) {
try {
final dt = DateTime.parse(isoString);
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
} catch (_) {
return '';
}
}
String _formatDateFromDateTime(DateTime dt) {
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
}
/// 摘抄详情弹窗 —— 和句读弹窗风格一致
void showExcerptDetailSheet(
BuildContext context, {
required String content,
required String chapter,
required String comment,
required DateTime createdAt,
required String bookTitle,
required VoidCallback onDelete,
}) {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 24),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.15),
blurRadius: 30,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 顶部装饰条
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 12),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2),
),
),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 书名 + 章节
Row(
children: [
Icon(Icons.book_outlined, size: 16, color: const Color(0xFF2196F3)),
const SizedBox(width: 6),
Expanded(
child: Text(
bookTitle,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (chapter.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF2196F3).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Text(
chapter,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, color: Color(0xFF1565C0), fontWeight: FontWeight.w500),
),
),
],
),
const SizedBox(height: 20),
// 引号 + 摘抄内容
Stack(
children: [
Positioned(
left: -4,
top: -8,
child: Text(
'"',
style: TextStyle(
fontSize: 48,
color: const Color(0xFF2196F3).withValues(alpha: 0.3),
fontWeight: FontWeight.bold,
height: 1,
),
),
),
Padding(
padding: const EdgeInsets.only(left: 20),
child: SelectableText(
content,
style: TextStyle(
fontSize: 15,
color: colors.onSurface.withValues(alpha: 0.85),
height: 1.8,
),
),
),
],
),
// 感悟
if (comment.isNotEmpty) ...[
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border(
left: BorderSide(color: colors.primary.withValues(alpha: 0.4), width: 2),
),
),
child: Text(
comment,
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.6),
height: 1.6,
),
),
),
],
const SizedBox(height: 16),
// 日期
Row(
children: [
Icon(Icons.access_time, size: 12, color: colors.onSurface.withValues(alpha: 0.3)),
const SizedBox(width: 4),
Text(
_formatDateFromDateTime(createdAt),
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35)),
),
],
),
const SizedBox(height: 24),
// 分隔线
Container(height: 0.5, color: colors.outlineVariant),
const SizedBox(height: 16),
// 操作按钮
Row(
children: [
_buildActionButton(
colors,
icon: Icons.content_copy_outlined,
label: '复制',
onTap: () {
Clipboard.setData(ClipboardData(text: content));
Navigator.pop(ctx);
ToastUtil.show(context, '已复制');
},
),
_buildActionButton(
colors,
icon: Icons.delete_outline,
label: '删除',
onTap: () {
Navigator.pop(ctx);
onDelete();
},
isDanger: true,
),
],
),
],
),
),
),
],
),
);
},
);
}

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;
}
}
}

View File

@@ -158,6 +158,7 @@ class ReaderRenderer extends StatefulWidget {
final Function(String innerHtml, Rect rect, String baseUrl) onFootnoteTap;
final Function(String url) onLinkTap;
final bool Function(String url) shouldHandleLinkTap;
final Function(String selectedText, Rect rect, int spineIndex, double scrollRatio, Offset? startHandle, Offset? endHandle, Map<String, dynamic>? selectionInfo)? onTextSelection;
final bool shouldShowWebView;
final EpubTheme initializeTheme;
final String statusBarLeftContent;
@@ -186,6 +187,7 @@ class ReaderRenderer extends StatefulWidget {
required this.onFootnoteTap,
required this.onLinkTap,
required this.shouldHandleLinkTap,
this.onTextSelection,
required this.shouldShowWebView,
required this.initializeTheme,
required this.statusBarLeftContent,
@@ -360,6 +362,11 @@ class _ReaderRendererState extends State<ReaderRenderer>
details.localPosition.dx,
details.localPosition.dy,
);
// 编程式选中最接近长按位置的文字(触发 onTextSelection 回调)
await _webViewController.selectWordAt(
details.localPosition.dx,
details.localPosition.dy,
);
}
@override
@@ -495,6 +502,7 @@ class _ReaderRendererState extends State<ReaderRenderer>
onFootnoteTap: widget.onFootnoteTap,
onLinkTap: widget.onLinkTap,
shouldHandleLinkTap: widget.shouldHandleLinkTap,
onTextSelection: widget.onTextSelection,
),
shouldShowWebView: widget.shouldShowWebView,
coverRelativePath: widget.bookSession.book['cover_path'] as String?,

View File

@@ -1,9 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../utils/database_helper.dart';
import '../../utils/epub/epub_theme.dart';
import '../../utils/epub/epub_webview_handler.dart';
import '../../utils/epub/epub_stream_service.dart';
@@ -14,11 +15,14 @@ import '../../utils/epub/reader_dao.dart';
import '../../utils/toast_util.dart';
import 'book_session.dart';
import 'reader_renderer.dart';
import 'reader_webview.dart';
import 'control_panel.dart';
import 'toc_drawer.dart';
import 'image_viewer.dart';
import 'footnote_popup.dart';
import 'search_sheet.dart';
import 'epub_selection_toolbar.dart';
import 'selection_handles.dart';
part 'mixins/spine_navigation_mixin.dart';
part 'mixins/page_navigation_mixin.dart';
@@ -27,6 +31,7 @@ part 'mixins/theme_mixin.dart';
part 'mixins/link_handling_mixin.dart';
part 'mixins/image_viewer_mixin.dart';
part 'mixins/footnote_mixin.dart';
part 'mixins/text_selection_mixin.dart';
/// Reads EPUB directly from compressed file without extraction.
class ReaderScreen extends StatefulWidget {
@@ -35,6 +40,9 @@ class ReaderScreen extends StatefulWidget {
final String title;
final String? coverPath;
final Map<String, dynamic>? bookData;
final int? initialSpineIndex;
final String? scrollToXPath;
final String? scrollToText;
const ReaderScreen({
super.key,
@@ -43,6 +51,9 @@ class ReaderScreen extends StatefulWidget {
required this.title,
this.coverPath,
this.bookData,
this.initialSpineIndex,
this.scrollToXPath,
this.scrollToText,
});
@override
@@ -58,7 +69,8 @@ class _ReaderScreenState extends State<ReaderScreen>
_ThemeMixin,
_LinkHandlingMixin,
_ImageViewerMixin,
_FootnoteMixin {
_FootnoteMixin,
_TextSelectionMixin {
@override
late final EpubWebViewHandler webViewHandler;
@@ -137,6 +149,7 @@ class _ReaderScreenState extends State<ReaderScreen>
// Services
final EpubStreamService _streamService = EpubStreamService();
@override
final ReaderDao _readerDao = ReaderDao();
final EpubParser _epubParser = EpubParser();
@@ -169,6 +182,8 @@ class _ReaderScreenState extends State<ReaderScreen>
// Load settings first, then book
ReaderSettings.load().then((settings) {
readerSettings = settings;
_pendingScrollXPath = widget.scrollToXPath;
_pendingScrollText = widget.scrollToText;
if (mounted) {
setupVolumeControl();
_loadBook();
@@ -288,7 +303,7 @@ class _ReaderScreenState extends State<ReaderScreen>
if (mounted) {
setState(() {
currentSpineItemIndex = bookSession.initialChapterIndex;
currentSpineItemIndex = widget.initialSpineIndex ?? bookSession.initialChapterIndex;
});
updateProgressDebounced();
_loadBookmarks();
@@ -512,7 +527,10 @@ class _ReaderScreenState extends State<ReaderScreen>
onPerformPageTurn: handlePageTurn,
onToggleControls: toggleControls,
onInitialized: () async {
final ratio = bookSession.initialScrollPosition;
// 从详情页跳转时不恢复滚动位置
final ratio = widget.initialSpineIndex != null
? null
: bookSession.initialScrollPosition;
await loadCarousel(restoreScrollRatio: ratio);
},
onPageCountReady: (totalPages) async {
@@ -536,6 +554,7 @@ class _ReaderScreenState extends State<ReaderScreen>
onFootnoteTap: handleFootnoteTap,
onLinkTap: handleLinkTap,
shouldHandleLinkTap: shouldHandleLinkTap,
onTextSelection: handleTextSelection,
shouldShowWebView: shouldShowWebView,
initializeTheme: epubTheme,
statusBarLeftContent: activateTocTitle,
@@ -670,6 +689,23 @@ class _ReaderScreenState extends State<ReaderScreen>
),
),
),
// 文本选中工具条
if (isSelectionToolbarVisible)
EpubSelectionToolbar(
selectionRect: _selectionRect!,
onCopy: _onCopyButton,
onHighlight: _onHighlightButton,
onExcerpt: _onExcerptButton,
onDismiss: _dismissSelectionToolbar,
),
// 选区手柄(起点和终点)
if (isSelectionToolbarVisible)
SelectionHandles(
startPosition: _startHandle,
endPosition: _endHandle,
onDragStart: onDragStartHandle,
onDragEnd: onDragEndHandle,
),
],
),
),

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
@@ -59,6 +60,11 @@ class ReaderWebViewController {
await _webViewState?._checkTapElementAt(x, y);
}
/// 获取当前 iframe 中用户选中的文字(长按后调用)
Future<String?> getTextSelection() async {
return await _webViewState?._getTextSelection();
}
Future<ui.Image?> takeScreenshot() async {
return await _webViewState?._takeScreenshot();
}
@@ -82,10 +88,121 @@ class ReaderWebViewController {
Future<dynamic> runJavaScriptReturningResult(String js) async {
return await _webViewState?._controller?.evaluateJavascript(source: js);
}
/// 获取当前 iframe 中选区的详细信息(用于高亮持久化)
Future<Map<String, dynamic>?> getSelectionInfo() async {
final result = await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.getSelectionInfo()',
);
if (result == null) return null;
if (result is Map) return Map<String, dynamic>.from(result);
return null;
}
/// 应用单个高亮到当前 iframe
Future<bool> applyHighlight(Map<String, dynamic> info, String id, {String color = 'highlight', String text = ''}) async {
final infoJson = jsonEncode(info);
final textArg = text.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n');
final result = await _webViewState?._controller?.evaluateJavascript(
source:
"window.__mooknoteHL && window.__mooknoteHL.applyHighlight($infoJson, \"$id\", \"$color\", '$textArg')",
);
return result == true;
}
/// 批量应用高亮(用于 spine 加载后恢复)
Future<int> applyHighlights(List<Map<String, dynamic>> highlights) async {
if (highlights.isEmpty) return 0;
final list = jsonEncode(highlights.map((h) {
// 优先使用嵌套的 info 对象(来自 restoreHighlightsForCurrentSpine
final rawInfo = h['info'];
Map<String, dynamic> info;
if (rawInfo is Map) {
info = Map<String, dynamic>.from(rawInfo);
} else {
// 兼容扁平字段
info = {
'startXPath': h['start_x_path'] ?? h['startXPath'] ?? '',
'startOffset': h['start_offset'] ?? h['startOffset'] ?? 0,
'endXPath': h['end_x_path'] ?? h['endXPath'] ?? '',
'endOffset': h['end_offset'] ?? h['endOffset'] ?? 0,
};
}
return {
'info': info,
'id': (h['id'] ?? '').toString(),
'color': h['color'] == 'excerpt' ? 'excerpt' : 'highlight',
'text': h['text'] ?? h['content'] ?? '',
};
}).toList());
final result = await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.applyHighlights($list)',
);
if (result is int) return result;
if (result is num) return result.toInt();
return 0;
}
/// 移除指定 id 的高亮
Future<void> removeHighlight(String id) async {
await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.removeHighlight("$id")',
);
}
/// 移除当前文档中所有高亮标记
Future<void> clearAllHighlights() async {
await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.clearAllHighlights()',
);
}
/// 清除当前选区
Future<void> clearSelection() async {
await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.clearSelection()',
);
}
/// 编程式选中长按位置的文字(用于 Flutter 长按手势触发文本选择)
Future<void> selectWordAt(double x, double y) async {
await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.selectWordAt($x, $y)',
);
}
/// 拖动手柄时扩展选区
Future<void> extendSelection(double x, double y, bool isStart) async {
await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.extendSelection($x, $y, ${isStart ? 'true' : 'false'})',
);
}
/// 根据 XPath 获取元素所在的页码(用于跳转到高亮位置)
Future<int> getPageIndexForXPath(String xpath) async {
final result = await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.getPageIndexForXPath("$xpath")',
);
if (result is int) return result;
if (result is num) return result.toInt();
return -1;
}
/// 根据文本内容获取所在页码XPath 失败时的回退方案)
Future<int> getPageIndexForText(String text) async {
if (text.isEmpty) return -1;
final escaped = text.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n');
final result = await _webViewState?._controller?.evaluateJavascript(
source: "window.__mooknoteHL && window.__mooknoteHL.getPageIndexForText('$escaped')",
);
if (result is int) return result;
if (result is num) return result.toInt();
return -1;
}
}
final InAppWebViewSettings defaultSettings = InAppWebViewSettings(
disableContextMenu: true,
disableContextMenu: false,
disableLongPressContextMenuOnLinks: true,
selectionGranularity: SelectionGranularity.CHARACTER,
transparentBackground: true,
@@ -98,7 +215,7 @@ final InAppWebViewSettings defaultSettings = InAppWebViewSettings(
disableHorizontalScroll: true,
disableVerticalScroll: true,
supportZoom: false,
useHybridComposition: false,
useHybridComposition: true,
resourceCustomSchemes: [EpubWebViewHandler.virtualScheme],
verticalScrollBarEnabled: false,
horizontalScrollBarEnabled: false,
@@ -116,6 +233,7 @@ class ReaderWebViewCallbacks {
final Function(String innerHtml, Rect rect, String baseUrl) onFootnoteTap;
final Function(String url) onLinkTap;
final bool Function(String url) shouldHandleLinkTap;
final Function(String selectedText, Rect rect, int spineIndex, double scrollRatio, Offset? startHandle, Offset? endHandle, Map<String, dynamic>? selectionInfo)? onTextSelection;
const ReaderWebViewCallbacks({
required this.onInitialized,
@@ -127,6 +245,7 @@ class ReaderWebViewCallbacks {
required this.onFootnoteTap,
required this.onLinkTap,
required this.shouldHandleLinkTap,
this.onTextSelection,
});
}
@@ -184,6 +303,16 @@ class _ReaderWebViewState extends State<ReaderWebView> {
widget.controller._attachState(this);
}
@override
void dispose() {
widget.controller._attachState(null);
_bridge.detach();
_headlessWebView?.dispose();
_headlessWebView = null;
_controller = null;
super.dispose();
}
@override
void didUpdateWidget(covariant ReaderWebView oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -246,6 +375,21 @@ class _ReaderWebViewState extends State<ReaderWebView> {
Future<void> _checkTapElementAt(double x, double y) =>
_api.checkTapElementAt(x, y);
/// 获取当前 iframe 中用户选中的文字
Future<String?> _getTextSelection() async {
if (_controller == null) return null;
final result = await _controller!.evaluateJavascript(source: '''
(function(){
var f = document.getElementById('frame-curr');
if (!f || !f.contentWindow) return '';
var sel = f.contentWindow.getSelection();
return sel ? sel.toString() : '';
})()
''');
if (result == null) return null;
return result.toString();
}
InAppWebViewInitialData _generateInitialData(double width, double height) {
return InAppWebViewInitialData(
data: generateSkeletonHtml(
@@ -302,6 +446,642 @@ class _ReaderWebViewState extends State<ReaderWebView> {
}
void _onLoadStop(InAppWebViewController controller, WebUri? url) {
// 注入高亮辅助函数库 + CSS + 文本选中检测(一次性注入,定时重新应用到新 contentDocument
controller.evaluateJavascript(source: r'''
(function(){
if (window.__mooknoteHL) {
console.log('[MN] already injected, re-running setup');
setupSelectionListener();
return;
}
console.log('[MN] injecting highlight + selection JS');
function getFrameDoc() {
var f = document.getElementById('frame-curr');
return f && f.contentDocument ? f.contentDocument : null;
}
function getFrameWin() {
var f = document.getElementById('frame-curr');
return f && f.contentWindow ? f.contentWindow : null;
}
function getXPath(node, root) {
if (node.nodeType === 3) {
var parent = node.parentNode;
var textIndex = 0;
for (var i = 0; i < parent.childNodes.length; i++) {
if (parent.childNodes[i] === node) break;
if (parent.childNodes[i].nodeType === 3) textIndex++;
}
return getXPath(parent, root) + '/text()[' + (textIndex + 1) + ']';
}
if (node.nodeType !== 1) return '';
if (node === root) return '';
var parts = [];
var current = node;
while (current && current.nodeType === 1 && current !== root) {
var parent = current.parentNode;
if (!parent) break;
var index = 1;
var siblings = parent.childNodes;
for (var i = 0; i < siblings.length; i++) {
var s = siblings[i];
if (s.nodeType === 1 && s.tagName === current.tagName) {
if (s === current) break;
index++;
}
}
parts.unshift(current.tagName.toLowerCase() + '[' + index + ']');
current = parent;
}
return '/' + parts.join('/');
}
function getNodeByXPath(doc, xpath) {
if (!doc.evaluate) return null;
try {
var result = doc.evaluate(xpath, doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
return result.singleNodeValue;
} catch (e) {
return null;
}
}
/// 计算节点在分栏排版中的页码
function calcPageIndex(doc, node) {
if (!node) return -1;
var el = node.nodeType === 3 ? node.parentNode : node;
var f = document.getElementById('frame-curr');
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;
}
/// 文本搜索回退:遍历所有文本节点,拼接后搜索目标文本,定位并高亮
function applyHighlightByTextSearch(doc, text, id, color) {
if (!text || text.length === 0) return false;
try {
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
acceptNode: function(node) {
if (node.textContent.trim().length > 0) return NodeFilter.FILTER_ACCEPT;
return NodeFilter.FILTER_SKIP;
}
}, null);
var nodes = [];
var n;
while (n = walker.nextNode()) nodes.push(n);
if (nodes.length === 0) return false;
var fullText = '';
var ranges = [];
for (var i = 0; i < nodes.length; i++) {
var c = nodes[i].textContent;
ranges.push({ node: nodes[i], start: fullText.length, len: c.length });
fullText += c;
}
var idx = fullText.indexOf(text);
if (idx === -1) return false;
var endIdx = idx + text.length;
var startInfo = null, endInfo = null;
for (var i = 0; i < ranges.length; i++) {
var r = ranges[i];
if (!startInfo && idx >= r.start && idx < r.start + r.len) {
startInfo = { node: r.node, offset: idx - r.start };
}
if (endIdx > r.start && endIdx <= r.start + r.len) {
endInfo = { node: r.node, offset: endIdx - r.start };
break;
}
}
if (!startInfo || !endInfo) return false;
var range = doc.createRange();
range.setStart(startInfo.node, startInfo.offset);
range.setEnd(endInfo.node, endInfo.offset);
return wrapRangeMultiNode(doc, range, id, color);
} catch (e) {
console.log('[MN] applyHighlightByTextSearch error: ' + e);
return false;
}
}
/// 多节点包裹:遍历 range 内所有文本节点,逐个包裹在 <mooknote-mark> 中
/// 保留段落结构,不用 extractContents
function wrapRangeMultiNode(doc, range, id, color) {
var bg = color === 'excerpt' ? 'rgba(33, 150, 243, 0.35)' : 'rgba(255, 235, 59, 0.5)';
var markType = color === 'excerpt' ? 'excerpt' : 'highlight';
var ancestor = range.commonAncestorContainer;
var walkerRoot = ancestor.nodeType === 3 ? ancestor.parentNode : ancestor;
var walker = doc.createTreeWalker(walkerRoot, NodeFilter.SHOW_TEXT, null);
var nodesToWrap = [];
var n;
while (n = walker.nextNode()) {
if (range.intersectsNode(n)) {
var s = 0, e = n.length;
if (n === range.startContainer) s = range.startOffset;
if (n === range.endContainer) e = range.endOffset;
if (n === range.startContainer && n === range.endContainer) { s = range.startOffset; e = range.endOffset; }
if (s < e) nodesToWrap.push({ node: n, start: s, end: e });
}
}
if (nodesToWrap.length === 0) {
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';
try {
range.surroundContents(mark);
return true;
} catch (e) {
return false;
}
}
// 倒序包裹(防止 splitText 影响后面的节点索引)
for (var i = nodesToWrap.length - 1; i >= 0; i--) {
var item = nodesToWrap[i];
var tn = item.node;
var start = item.start;
var end = item.end;
var wrapNode = tn;
if (start > 0) wrapNode = tn.splitText(start);
if (end - start < wrapNode.length) wrapNode.splitText(end - start);
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';
wrapNode.parentNode.insertBefore(mk, wrapNode);
mk.appendChild(wrapNode);
}
return true;
}
window.__mooknoteHL = {
getSelectionInfo: function() {
var win = getFrameWin();
if (!win) return null;
var sel = win.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
var range = sel.getRangeAt(0);
var doc = getFrameDoc();
if (!doc) return null;
return {
text: sel.toString(),
startXPath: getXPath(range.startContainer, doc),
startOffset: range.startOffset,
endXPath: getXPath(range.endContainer, doc),
endOffset: range.endOffset
};
},
applyHighlight: function(info, id, color, text) {
var doc = getFrameDoc();
if (!doc || !info) {
return false;
}
var startNode = getNodeByXPath(doc, info.startXPath);
var endNode = getNodeByXPath(doc, info.endXPath);
if (!startNode || !endNode) {
if (text && text.length > 0) {
return applyHighlightByTextSearch(doc, text, id, color);
}
return false;
}
try {
var so = Math.min(info.startOffset, startNode.length || 0);
var eo = Math.min(info.endOffset, endNode.length || 0);
var range = doc.createRange();
range.setStart(startNode, so);
range.setEnd(endNode, eo);
return wrapRangeMultiNode(doc, range, id, color);
} catch (e) {
if (text) return applyHighlightByTextSearch(doc, text, id, color);
return false;
}
},
applyHighlights: function(list) {
if (!list || list.length === 0) return 0;
// 按文档位置倒序排序(从末尾往前应用),避免前面的高亮分裂文本节点影响后面的 XPath
// 用数字排序而非字符串排序(/p[10] 应排在 /p[2] 之后)
function docOrder(item) {
var xp = (item && item.info && item.info.startXPath) || '';
// 提取所有 [N] 中的数字,组成数组用于比较
var nums = [];
var re = /\[(\d+)\]/g;
var m;
while ((m = re.exec(xp)) !== null) nums.push(parseInt(m[1], 10));
return nums;
}
function compareNums(a, b) {
for (var i = 0; i < Math.max(a.length, b.length); i++) {
var av = a[i] || 0;
var bv = b[i] || 0;
if (av !== bv) return av - bv;
}
return 0;
}
var sorted = list.slice().sort(function(a, b) {
return compareNums(docOrder(b), docOrder(a)); // 倒序
});
var applied = 0;
for (var i = 0; i < sorted.length; i++) {
if (this.applyHighlight(sorted[i].info, sorted[i].id, sorted[i].color, sorted[i].text)) applied++;
}
return applied;
},
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();
}
},
clearAllHighlights: function() {
var doc = getFrameDoc();
if (!doc) return;
var marks = doc.querySelectorAll('mooknote-mark[data-hl-id]');
for (var i = 0; i < marks.length; i++) {
var mark = marks[i];
var parent = mark.parentNode;
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
parent.removeChild(mark);
}
// normalize 合并相邻文本节点
if (doc.body) doc.body.normalize();
},
clearSelection: function() {
var win = getFrameWin();
if (!win) return;
var sel = win.getSelection();
if (sel) sel.removeAllRanges();
},
/// 根据 XPath 获取元素所在页码(用于跳转到高亮位置)
getPageIndexForXPath: function(xpath) {
var doc = getFrameDoc();
if (!doc || !xpath) return -1;
var node = getNodeByXPath(doc, xpath);
if (!node) return -1;
return calcPageIndex(doc, node);
},
/// 根据文本内容获取所在页码XPath 失败时的回退)
getPageIndexForText: function(text) {
var doc = getFrameDoc();
if (!doc || !text) return -1;
// 遍历所有文本节点,找到包含目标文本的节点
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, null, null);
var n;
while (n = walker.nextNode()) {
if (n.textContent.indexOf(text) !== -1) {
return calcPageIndex(doc, n);
}
}
// 去掉空白再试
var cleanText = text.replace(/\s+/g, '');
walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, null, null);
while (n = walker.nextNode()) {
if (n.textContent.replace(/\s+/g, '').indexOf(cleanText) !== -1) {
return calcPageIndex(doc, n);
}
}
return -1;
},
/// 编程式选中最接近 (x,y) 的文字(单词/句子),并触发 onTextSelection 回调
selectWordAt: function(x, y) {
var f = document.getElementById('frame-curr');
if (!f || !f.contentDocument || !f.contentWindow) {
console.log('[MN] selectWordAt: no frame');
return false;
}
var doc = f.contentDocument;
var win = f.contentWindow;
var iframeRect = f.getBoundingClientRect();
var localX = x - iframeRect.left;
var localY = y - iframeRect.top;
// 用 caretRangeFromPoint 找到该位置的文本节点
var range = null;
if (doc.caretRangeFromPoint) {
range = doc.caretRangeFromPoint(localX, localY);
} else if (doc.caretPositionFromPoint) {
var pos = doc.caretPositionFromPoint(localX, localY);
if (pos) {
range = doc.createRange();
range.setStart(pos.offsetNode, pos.offset);
range.setEnd(pos.offsetNode, pos.offset);
}
}
if (!range || !range.startContainer || range.startContainer.nodeType !== 3) {
console.log('[MN] selectWordAt: no text node at (' + x + ',' + y + ')');
return false;
}
var node = range.startContainer;
var offset = range.startOffset;
var text = node.textContent;
// 向前找到词/句边界(支持中文和英文)
var start = offset;
while (start > 0) {
var ch = text[start - 1];
// 遇到空格、标点、换行就停
if (/[\s。、""''()【】《》\n\r]/.test(ch)) break;
start--;
}
// 向后找到词/句边界
var end = offset;
while (end < text.length) {
var ch = text[end];
if (/[\s。、""''()【】《》\n\r]/.test(ch)) break;
end++;
}
// 如果只选中了一个字,尝试扩展到整个句子(按句号分割)
if (end - start <= 1) {
start = 0;
end = text.length;
}
var selRange = doc.createRange();
selRange.setStart(node, start);
selRange.setEnd(node, end);
// 应用选区
var sel = win.getSelection();
sel.removeAllRanges();
sel.addRange(selRange);
var selectedText = sel.toString();
console.log('[MN] selectWordAt: selected "' + selectedText.substring(0, 30) + '"');
var selRect = selRange.getBoundingClientRect();
var rect = [iframeRect.left + selRect.left, iframeRect.top + selRect.top, selRect.width, selRect.height];
var handles = getHandlePositions(f, selRange, iframeRect);
var info = {
startXPath: getXPath(selRange.startContainer, doc),
startOffset: selRange.startOffset,
endXPath: getXPath(selRange.endContainer, doc),
endOffset: selRange.endOffset
};
// 触发 onTextSelection 回调
window.flutter_inappwebview.callHandler('onTextSelection',
selectedText,
rect,
0,
0,
handles.start,
handles.end,
info
);
return true;
},
/// 拖动手柄时扩展选区:将起点或终点移动到 (x, y) 处的文字位置
extendSelection: function(x, y, isStart) {
var f = document.getElementById('frame-curr');
if (!f || !f.contentDocument || !f.contentWindow) return false;
var doc = f.contentDocument;
var win = f.contentWindow;
var iframeRect = f.getBoundingClientRect();
var localX = x - iframeRect.left;
var localY = y - iframeRect.top;
var pos = null;
if (doc.caretRangeFromPoint) {
var r = doc.caretRangeFromPoint(localX, localY);
if (r) pos = { node: r.startContainer, offset: r.startOffset };
} else if (doc.caretPositionFromPoint) {
var p = doc.caretPositionFromPoint(localX, localY);
if (p) pos = { node: p.offsetNode, offset: p.offset };
}
if (!pos) return false;
var sel = win.getSelection();
if (!sel || sel.rangeCount === 0) return false;
var currentRange = sel.getRangeAt(0);
var newRange = doc.createRange();
if (isStart) {
// 拖动起点手柄:新起点 = pos终点 = 原终点
newRange.setStart(pos.node, pos.offset);
newRange.setEnd(currentRange.endContainer, currentRange.endOffset);
} else {
// 拖动终点手柄:起点 = 原起点,新终点 = pos
newRange.setStart(currentRange.startContainer, currentRange.startOffset);
newRange.setEnd(pos.node, pos.offset);
}
// 确保起点在终点之前
if (newRange.collapsed || currentRange.startContainer === newRange.endContainer && newRange.startOffset > newRange.endOffset) {
// 反向了,交换
if (isStart) {
newRange.setStart(currentRange.endContainer, currentRange.endOffset);
newRange.setEnd(pos.node, pos.offset);
} else {
newRange.setStart(pos.node, pos.offset);
newRange.setEnd(currentRange.startContainer, currentRange.startOffset);
}
}
sel.removeAllRanges();
sel.addRange(newRange);
var text = sel.toString();
var selRect = newRange.getBoundingClientRect();
var rect = [iframeRect.left + selRect.left, iframeRect.top + selRect.top, selRect.width, selRect.height];
var handles = getHandlePositions(f, newRange, iframeRect);
var info = {
startXPath: getXPath(newRange.startContainer, doc),
startOffset: newRange.startOffset,
endXPath: getXPath(newRange.endContainer, doc),
endOffset: newRange.endOffset
};
window.flutter_inappwebview.callHandler('onTextSelection',
text,
rect,
0,
0,
handles.start,
handles.end,
info
);
return true;
}
};
// ─── 文本选中检测 ────────────────────────────────────
var lastText = '';
var debounceTimer = null;
function checkSelection() {
var f = document.getElementById('frame-curr');
if (!f || !f.contentWindow || !f.contentDocument) {
console.log('[MN] checkSelection: no frame');
return;
}
var win = f.contentWindow;
var doc = f.contentDocument;
var sel = win.getSelection();
if (!sel) {
console.log('[MN] checkSelection: no getSelection');
return;
}
if (sel.isCollapsed || sel.rangeCount === 0) {
lastText = '';
return;
}
var text = sel.toString();
if (!text || text === lastText) return;
lastText = text;
console.log('[MN] selection detected: ' + text.substring(0, 30));
var range = sel.getRangeAt(0);
var selRect = range.getBoundingClientRect();
var iframeRect = f.getBoundingClientRect();
var left = iframeRect.left + selRect.left;
var top = iframeRect.top + selRect.top;
var scrollRatio = 0;
if (doc.documentElement) {
var sh = doc.documentElement.scrollHeight - doc.documentElement.clientHeight;
if (sh > 0) scrollRatio = doc.documentElement.scrollTop / sh;
}
var handles = getHandlePositions(f, range, iframeRect);
var info = getSelectionInfoInternal();
window.flutter_inappwebview.callHandler('onTextSelection',
text,
[left, top, selRect.width, selRect.height],
0,
scrollRatio,
handles.start,
handles.end,
info
);
}
/// 获取选区 DOM 信息XPath + offset用于高亮持久化
function getSelectionInfoInternal() {
var win = getFrameWin();
if (!win) return null;
var sel = win.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
var range = sel.getRangeAt(0);
var doc = getFrameDoc();
if (!doc) return null;
return {
startXPath: getXPath(range.startContainer, doc),
startOffset: range.startOffset,
endXPath: getXPath(range.endContainer, doc),
endOffset: range.endOffset
};
}
/// 获取选区起点和终点的屏幕坐标
function getHandlePositions(f, range, iframeRect) {
var rects = range.getClientRects();
if (rects.length === 0) {
var r = range.getBoundingClientRect();
rects = [r];
}
var firstRect = rects[0];
var lastRect = rects[rects.length - 1];
// 起点手柄:第一行的左下角(手柄线覆盖文字行,圆点在上方)
var startX = iframeRect.left + firstRect.left;
var startY = iframeRect.top + firstRect.bottom;
// 终点手柄:最后一行的右上角(手柄线覆盖文字行,圆点在下方)
var endX = iframeRect.left + lastRect.right;
var endY = iframeRect.top + lastRect.top;
return { start: [startX, startY], end: [endX, endY] };
}
// 每次 iframe 内容变化后重新注入 CSS + 事件监听
function setupSelectionListener() {
var f = document.getElementById('frame-curr');
if (!f || !f.contentDocument) return;
var doc = f.contentDocument;
// 1. 重新注入 user-select CSSloadFrame 会替换 contentDocumentCSS 会丢失)
var s = doc.getElementById('_ts_css');
if (!s) {
s = doc.createElement('style');
s.id = '_ts_css';
doc.head.appendChild(s);
s.textContent = 'html,body,p,span,div,a,h1,h2,h3,h4,h5,h6,li,td,th,blockquote,figcaption{-webkit-user-select:text!important;-moz-user-select:text!important;user-select:text!important;-webkit-touch-callout:default!important}';
console.log('[MN] CSS injected into new contentDocument');
}
// 2. 附加事件监听器
if (doc.__mnSelListener) return;
doc.__mnSelListener = true;
console.log('[MN] attaching selection listeners');
doc.addEventListener('selectionchange', function() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(checkSelection, 250);
});
doc.addEventListener('touchend', function() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(checkSelection, 350);
}, true);
doc.addEventListener('mouseup', function() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(checkSelection, 350);
}, true);
}
// 定时重新注入 CSS + 重新附加监听器iframe 内容会在翻页/切换章节时变化)
setInterval(setupSelectionListener, 500);
setupSelectionListener();
console.log('[MN] injection complete');
})();
''');
widget.callbacks.onInitialized();
}
@@ -329,6 +1109,9 @@ class _ReaderWebViewState extends State<ReaderWebView> {
shouldOverrideUrlLoading: _shouldOverrideUrlLoading,
onWebViewCreated: _onWebViewCreated,
onLoadStop: _onLoadStop,
onConsoleMessage: (controller, consoleMessage) {
debugPrint('[EPUB-JS] ${consoleMessage.message}');
},
)
: Container(color: _currentTheme.surfaceColor),
),
@@ -497,6 +1280,65 @@ class _ReaderWebViewState extends State<ReaderWebView> {
}
},
);
// ─── 文本选择(划线功能) ────────────────
controller.addJavaScriptHandler(
handlerName: 'onTextSelection',
callback: (args) async {
debugPrint('[MN] onTextSelection handler called, args: ${args.length}');
if (args.isEmpty) return;
final selectedText = args[0] as String? ?? '';
final rectList = args[1] as List?;
final spineIndex = (args[2] as num?)?.toInt() ?? 0;
final scrollRatio = (args[3] as num?)?.toDouble() ?? 0.0;
Offset? startHandle;
Offset? endHandle;
Map<String, dynamic>? selectionInfo;
if (args.length >= 6) {
final startList = args[4] as List?;
final endList = args[5] as List?;
if (startList != null && startList.length >= 2) {
startHandle = Offset(
(startList[0] as num).toDouble(),
(startList[1] as num).toDouble(),
);
}
if (endList != null && endList.length >= 2) {
endHandle = Offset(
(endList[0] as num).toDouble(),
(endList[1] as num).toDouble(),
);
}
}
if (args.length >= 7) {
final rawInfo = args[6];
if (rawInfo is Map) {
selectionInfo = Map<String, dynamic>.from(rawInfo);
}
}
debugPrint('[MN] selectedText="$selectedText" info=$selectionInfo');
Rect? rect;
if (rectList != null && rectList.length >= 4) {
rect = Rect.fromLTWH(
(rectList[0] as num).toDouble(),
(rectList[1] as num).toDouble(),
(rectList[2] as num).toDouble(),
(rectList[3] as num).toDouble(),
);
}
if (selectedText.isNotEmpty && rect != null) {
widget.callbacks.onTextSelection?.call(
selectedText,
rect,
spineIndex,
scrollRatio,
startHandle,
endHandle,
selectionInfo,
);
}
},
);
}
Future<ui.Image?> _takeScreenshot() async {

View File

@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
/// 选区手柄(起点和终点各一个),可拖动调整选区范围
///
/// 样式:光标竖线 + 大圆点
/// - 起点手柄:圆点在上,竖线向下连接到选区起点
/// - 终点手柄:竖线从选区终点向下延伸,圆点在下
class SelectionHandles extends StatelessWidget {
final Offset? startPosition;
final Offset? endPosition;
final Function(DragUpdateDetails) onDragStart;
final Function(DragUpdateDetails) onDragEnd;
static const double _circleSize = 12.0;
static const double _lineHeight = 20.0;
static const double _lineWidth = 2.0;
static const Color _handleColor = Color(0xFF4A90D9);
const SelectionHandles({
super.key,
required this.startPosition,
required this.endPosition,
required this.onDragStart,
required this.onDragEnd,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
if (startPosition != null) _buildStartHandle(startPosition!),
if (endPosition != null) _buildEndHandle(endPosition!),
],
);
}
/// 起点手柄:圆点在上 + 竖线向下
/// startPosition 在文字行底部,手柄线覆盖文字行,圆点在文字上方
Widget _buildStartHandle(Offset pos) {
final totalHeight = _circleSize + _lineHeight;
return Positioned(
left: pos.dx - _circleSize / 2,
top: pos.dy - totalHeight,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onPanUpdate: onDragStart,
child: SizedBox(
width: _circleSize,
height: totalHeight,
child: Column(
children: [
_buildCircle(),
_buildLine(),
],
),
),
),
);
}
/// 终点手柄:竖线向下 + 圆点在下
/// endPosition 在文字行顶部,手柄线覆盖文字行,圆点在文字下方
Widget _buildEndHandle(Offset pos) {
final totalHeight = _lineHeight + _circleSize;
return Positioned(
left: pos.dx - _circleSize / 2,
top: pos.dy,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onPanUpdate: onDragEnd,
child: SizedBox(
width: _circleSize,
height: totalHeight,
child: Column(
children: [
_buildLine(),
_buildCircle(),
],
),
),
),
);
}
Widget _buildCircle() {
return Container(
width: _circleSize,
height: _circleSize,
decoration: BoxDecoration(
color: _handleColor,
shape: BoxShape.circle,
boxShadow: const [
BoxShadow(
color: Color(0x33000000),
blurRadius: 3,
offset: Offset(0, 1),
),
],
),
);
}
Widget _buildLine() {
return Container(
width: _lineWidth,
height: _lineHeight,
color: _handleColor,
);
}
}

View File

@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// 浮动工具栏 — 参考微信阅读划线界面
class SelectionOverlay extends StatefulWidget {
final String selectedText;
final Rect position; // WebView 坐标系内的位置
final VoidCallback onCopy;
final VoidCallback onHighlight;
final VoidCallback? onExcerpt; // null = 不显示书摘按钮
final ColorScheme colorScheme;
final bool isDark;
const SelectionOverlay({
super.key,
required this.selectedText,
required this.position,
required this.onCopy,
required this.onHighlight,
this.onExcerpt,
required this.colorScheme,
required this.isDark,
});
@override
State<SelectionOverlay> createState() => _SelectionOverlayState();
}
class _SelectionOverlayState extends State<SelectionOverlay> {
static const int _visibleCount = 6;
void _copyToClipboard() async {
await Clipboard.setData(ClipboardData(text: widget.selectedText));
if (mounted) Navigator.of(context).pop();
widget.onCopy();
}
void _onItemTap(int index) {
switch (index) {
case 0:
_copyToClipboard();
break;
case 1:
Navigator.of(context).pop();
widget.onHighlight();
break;
case 2:
Navigator.of(context).pop();
break;
case 3:
if (widget.onExcerpt != null) {
Navigator.of(context).pop();
widget.onExcerpt!();
}
break;
case 4:
Navigator.of(context).pop();
break;
case 5:
Navigator.of(context).pop();
break;
}
}
@override
Widget build(BuildContext context) {
final bg = widget.isDark ? const Color(0xFF2C2C2C) : Colors.grey.shade900;
return Positioned(
left: clampDouble(widget.position.left, 8, MediaQuery.of(context).size.width - _visibleCount * 70),
top: clampDouble(widget.position.top - 48, 0, widget.position.top),
child: GestureDetector(
onTapUp: (_) => Navigator.of(context).pop(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 12, offset: const Offset(0, 4)),
],
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
_ToolbarItem(icon: Icons.copy, label: '复制', onTap: () => _onItemTap(0), color: widget.colorScheme.primary),
const SizedBox(width: 12),
_ToolbarItem(icon: Icons.format_underlined, label: '划线', onTap: () => _onItemTap(1), color: widget.colorScheme.primary),
const SizedBox(width: 12),
_ToolbarItem(icon: Icons.edit_note_outlined, label: '写想法', onTap: () => _onItemTap(2), color: widget.colorScheme.primary),
if (widget.onExcerpt != null) ...[
const SizedBox(width: 12),
Container(width: 1, height: 28, color: Colors.white30),
const SizedBox(width: 12),
_ToolbarItem(icon: Icons.bookmark_border, label: '书摘', onTap: () => _onItemTap(3), color: widget.colorScheme.primary),
],
const SizedBox(width: 12),
_ToolbarItem(icon: Icons.search, label: 'AI 问书', onTap: () => _onItemTap(4), color: widget.colorScheme.primary),
const SizedBox(width: 12),
_ToolbarItem(icon: Icons.headphones, label: '听当前', onTap: () => _onItemTap(5), color: widget.colorScheme.primary),
]),
),
),
);
}
}
class _ToolbarItem extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
final Color color;
const _ToolbarItem({required this.icon, required this.label, required this.onTap, required this.color});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 22, color: color),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white)),
]),
);
}
}
double clampDouble(double value, double min, double max) {
if (value < min) return min;
if (value > max) return max;
return value;
}