import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import '../../main.dart'; import '../../services/epub/epub_theme.dart'; import 'book_session.dart'; import '../../services/epub/epub_webview_handler.dart'; import '../../services/epub/reader_scripts.dart'; import '../../services/epub/web/webview_bridge.dart'; import '../../services/epub/web/reader_api.dart'; /// Controller for ReaderWebView that provides methods to control the WebView class ReaderWebViewController { _ReaderWebViewState? _webViewState; bool get isAttached => _webViewState != null; void _attachState(_ReaderWebViewState? state) { _webViewState = state; } // JavaScript wrapper methods Future jumpToLastPageOfFrame(String frame) async { return await _webViewState?._jumpToLastPageOfFrame(frame); } Future cycleFrames(String direction) async { return await _webViewState?._cycleFrames(direction); } Future jumpToPageFor(String frame, int pageIndex) async { return await _webViewState?._jumpToPageFor(frame, pageIndex); } Future loadFrame( String frame, String url, String anchors, String properties, ) async { return await _webViewState?._loadFrame(frame, url, anchors, properties); } Future loadFrameSrcdoc( String frame, String htmlContent, String baseUrl, String anchors, String properties, ) async { return await _webViewState?._loadFrameSrcdoc(frame, htmlContent, baseUrl, anchors, properties); } Future jumpToPage(int pageIndex) async { await _webViewState?._jumpToPage(pageIndex); } Future restoreScrollPosition(double ratio) async { await _webViewState?._restoreScrollPosition(ratio); } Future checkLongPressElementAt(double x, double y) async { await _webViewState?._checkLongPressElementAt(x, y); } Future checkTapElementAt(double x, double y) async { await _webViewState?._checkTapElementAt(x, y); } /// 获取当前 iframe 中用户选中的文字(长按后调用) Future getTextSelection() async { return await _webViewState?._getTextSelection(); } Future takeScreenshot() async { return await _webViewState?._takeScreenshot(); } Future waitForRender() async { await _webViewState?._waitForRender(); } Future updateTheme(EpubTheme theme) async { await _webViewState?._updateTheme(theme); } Future waitForEvent(int token, [int timeoutMs = 10000]) async { await _webViewState?._bridge.waitForEvent(token, timeoutMs); } Future waitForEvents(List tokens, [int timeoutMs = 10000]) async { await _webViewState?._bridge.waitForEvents(tokens, timeoutMs); } Future runJavaScriptReturningResult(String js) async { return await _webViewState?._controller?.evaluateJavascript(source: js); } /// 获取当前 iframe 中选区的详细信息(用于高亮持久化) Future?> 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.from(result); return null; } /// 应用单个高亮到当前 iframe Future applyHighlight(Map 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 applyHighlights(List> highlights) async { if (highlights.isEmpty) return 0; final list = jsonEncode(highlights.map((h) { // 优先使用嵌套的 info 对象(来自 restoreHighlightsForCurrentSpine) final rawInfo = h['info']; Map info; if (rawInfo is Map) { info = Map.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 removeHighlight(String id) async { await _webViewState?._controller?.evaluateJavascript( source: 'window.__mooknoteHL && window.__mooknoteHL.removeHighlight("$id")', ); } /// 移除当前文档中所有高亮标记 Future clearAllHighlights() async { await _webViewState?._controller?.evaluateJavascript( source: 'window.__mooknoteHL && window.__mooknoteHL.clearAllHighlights()', ); } /// 清除当前选区 Future clearSelection() async { await _webViewState?._controller?.evaluateJavascript( source: 'window.__mooknoteHL && window.__mooknoteHL.clearSelection()', ); } /// 编程式选中长按位置的文字(用于 Flutter 长按手势触发文本选择) Future selectWordAt(double x, double y) async { await _webViewState?._controller?.evaluateJavascript( source: 'window.__mooknoteHL && window.__mooknoteHL.selectWordAt($x, $y)', ); } /// 拖动手柄时扩展选区 Future 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 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 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: false, disableLongPressContextMenuOnLinks: true, selectionGranularity: SelectionGranularity.CHARACTER, transparentBackground: true, allowFileAccessFromFileURLs: true, allowUniversalAccessFromFileURLs: true, useShouldInterceptRequest: true, useOnLoadResource: false, useShouldOverrideUrlLoading: true, javaScriptEnabled: true, disableHorizontalScroll: true, disableVerticalScroll: true, supportZoom: false, useHybridComposition: true, resourceCustomSchemes: [EpubWebViewHandler.virtualScheme], verticalScrollBarEnabled: false, horizontalScrollBarEnabled: false, overScrollMode: OverScrollMode.NEVER, ); /// Callbacks for WebView events class ReaderWebViewCallbacks { final Function() onInitialized; final Function(int totalPages) onPageCountReady; final Function(int pageIndex) onPageChanged; final Function(List anchors) onScrollAnchors; final Function(String imageUrl, Rect rect) onImageLongPress; final Function(double x, double y) onTap; 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? selectionInfo)? onTextSelection; const ReaderWebViewCallbacks({ required this.onInitialized, required this.onPageCountReady, required this.onPageChanged, required this.onScrollAnchors, required this.onImageLongPress, required this.onTap, required this.onFootnoteTap, required this.onLinkTap, required this.shouldHandleLinkTap, this.onTextSelection, }); } /// WebView widget for reading EPUB content class ReaderWebView extends StatefulWidget { final BookSession bookSession; final EpubWebViewHandler webViewHandler; final String fileHash; final ReaderWebViewCallbacks callbacks; final EpubTheme initializeTheme; final bool isLoading; final ReaderWebViewController controller; final VoidCallback? onWebViewCreated; final bool shouldShowWebView; final String? coverRelativePath; final int direction; const ReaderWebView({ super.key, required this.bookSession, required this.webViewHandler, required this.fileHash, required this.callbacks, required this.initializeTheme, required this.isLoading, required this.controller, this.onWebViewCreated, required this.shouldShowWebView, this.coverRelativePath, required this.direction, }); @override State createState() => _ReaderWebViewState(); } class _ReaderWebViewState extends State { final GlobalKey _repaintKey = GlobalKey(); InAppWebViewController? _controller; HeadlessInAppWebView? _headlessWebView; bool _isHeadlessInitialized = false; bool _isSubsequentLoad = false; late EpubTheme _currentTheme; final WebViewBridge _bridge = WebViewBridge(); late final ReaderApi _api = ReaderApi(_bridge); @override void initState() { super.initState(); _currentTheme = widget.initializeTheme; 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); if (!oldWidget.isLoading && widget.isLoading) { setState(() { _isSubsequentLoad = true; }); } } void _initHeadlessWebViewIfNeeded(double width, double height) { if (_isHeadlessInitialized) return; _headlessWebView = HeadlessInAppWebView( webViewEnvironment: windowsWebViewEnvironment, initialData: _generateInitialData(width, height), initialSettings: defaultSettings, shouldInterceptRequest: _shouldInterceptRequest, onLoadResourceWithCustomScheme: _onLoadResourceWithCustomScheme, shouldOverrideUrlLoading: _shouldOverrideUrlLoading, onWebViewCreated: _onWebViewCreated, onLoadStop: _onLoadStop, ); _headlessWebView?.run(); _isHeadlessInitialized = true; } Future _waitForWebviewRender() async { if (_controller == null) return; await _api.waitForRender(); } Future _waitForRender() async { await _waitForWebviewRender(); } Future _jumpToLastPageOfFrame(String frame) => _api.jumpToLastPageOfFrame(frame); Future _cycleFrames(String direction) => _api.cycleFrames(direction); Future _jumpToPageFor(String frame, int pageIndex) => _api.jumpToPageFor(frame, pageIndex); Future _loadFrame( String frame, String url, String anchors, String properties, ) => _api.loadFrame(frame, url, anchors, properties); Future _loadFrameSrcdoc( String frame, String htmlContent, String baseUrl, String anchors, String properties, ) => _api.loadFrameSrcdoc(frame, htmlContent, baseUrl, anchors, properties); Future _jumpToPage(int pageIndex) => _api.jumpToPage(pageIndex); Future _restoreScrollPosition(double ratio) => _api.restoreScrollPosition(ratio); Future _checkLongPressElementAt(double x, double y) => _api.checkLongPressElementAt(x, y); Future _checkTapElementAt(double x, double y) => _api.checkTapElementAt(x, y); /// 获取当前 iframe 中用户选中的文字 Future _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( width, height, _currentTheme, widget.direction, ), baseUrl: WebUri(EpubWebViewHandler.getBaseUrl()), ); } Future _shouldInterceptRequest( InAppWebViewController controller, WebResourceRequest request, ) async { return await widget.webViewHandler.handleRequest( epubPath: widget.bookSession.book['file_path'] as String, fileHash: widget.fileHash, requestUrl: request.url, ); } Future _onLoadResourceWithCustomScheme( InAppWebViewController controller, WebResourceRequest request, ) async { return await widget.webViewHandler.handleRequestWithCustomScheme( epubPath: widget.bookSession.book['file_path'] as String, fileHash: widget.fileHash, requestUrl: request.url, ); } Future _shouldOverrideUrlLoading( InAppWebViewController controller, NavigationAction navigationAction, ) async { final uri = navigationAction.request.url!; if (uri.scheme == 'data') { return NavigationActionPolicy.ALLOW; } if (EpubWebViewHandler.isEpubRequest(uri)) { return NavigationActionPolicy.ALLOW; } return NavigationActionPolicy.CANCEL; } void _onWebViewCreated(InAppWebViewController controller) { _controller = controller; _bridge.attach(controller); _setupJavaScriptHandlers(controller); widget.onWebViewCreated?.call(); } 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; } } /// 计算节点在分栏排版中的页码 /// 与 Lumina 的 calculatePageIndexOfAnchor 使用相同算法: /// getBoundingClientRect + scrollOffset,配合 128px column-gap 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; if (iframeWidth <= 0 || iframeHeight <= 0) return -1; var bodyRect = doc.body.getBoundingClientRect(); var clientRects = el.getClientRects(); var rect = clientRects.length > 0 ? clientRects[0] : el.getBoundingClientRect(); if (!rect || rect.width === 0 && rect.height === 0) return -1; // 判断是水平还是垂直分栏(与 Lumina 一致:direction===1 为垂直) var isVertical = doc.body.classList.contains('lumina-is-vertical'); var page; if (isVertical) { var offsetY = rect.top + doc.body.scrollTop - bodyRect.top + rect.height / 5 + 1; page = Math.floor((offsetY + 128) / (iframeHeight + 128)); console.log('[MN] calcPage V: rect.top=' + rect.top + ' scrollTop=' + doc.body.scrollTop + ' bodyRect.top=' + bodyRect.top + ' offsetY=' + offsetY + ' iframeH=' + iframeHeight + ' page=' + page); } else { var offsetX = rect.left + doc.body.scrollLeft - bodyRect.left + rect.width / 5 + 1; page = Math.floor((offsetX + 128) / (iframeWidth + 128)); console.log('[MN] calcPage H: rect.left=' + rect.left + ' scrollLeft=' + doc.body.scrollLeft + ' bodyRect.left=' + bodyRect.left + ' offsetX=' + offsetX + ' iframeW=' + iframeWidth + ' page=' + page); } return page; } /// 文本搜索回退:遍历所有文本节点,拼接后搜索目标文本,定位并高亮 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 内所有文本节点,逐个包裹在 中 /// 保留段落结构,不用 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.setProperty('background-color', bg, 'important'); mark.style.setProperty('color', 'inherit', 'important'); mark.style.setProperty('border-radius', '2px', 'important'); 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.setProperty('background-color', bg, 'important'); mk.style.setProperty('color', 'inherit', 'important'); mk.style.setProperty('border-radius', '2px', 'important'); wrapNode.parentNode.insertBefore(mk, wrapNode); mk.appendChild(wrapNode); } 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 marks = doc.querySelectorAll('[data-hl-id="' + id + '"]'); for (var i = 0; i < marks.length; i++) { var mark = marks[i]; if (mark.tagName === 'MOOKNOTE-MARK') { var parent = mark.parentNode; while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); parent.removeChild(mark); } } // 合并相邻文本节点 if (doc.body) doc.body.normalize(); }, clearAllHighlights: function() { 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 CSS(loadFrame 会替换 contentDocument,CSS 会丢失) 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(); } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth - _currentTheme.padding.horizontal; final height = constraints.maxHeight - _currentTheme.padding.vertical; _initHeadlessWebViewIfNeeded(width, height); return Stack( children: [ RepaintBoundary( key: _repaintKey, child: AbsorbPointer( child: widget.shouldShowWebView ? InAppWebView( webViewEnvironment: windowsWebViewEnvironment, headlessWebView: _headlessWebView, initialData: _generateInitialData(width, height), initialSettings: defaultSettings, shouldInterceptRequest: _shouldInterceptRequest, onLoadResourceWithCustomScheme: _onLoadResourceWithCustomScheme, shouldOverrideUrlLoading: _shouldOverrideUrlLoading, onWebViewCreated: _onWebViewCreated, onLoadStop: _onLoadStop, onConsoleMessage: (controller, consoleMessage) { debugPrint('[EPUB-JS] ${consoleMessage.message}'); }, ) : Container(color: _currentTheme.surfaceColor), ), ), Positioned.fill( child: IgnorePointer( ignoring: !widget.isLoading && widget.shouldShowWebView, child: AnimatedOpacity( duration: (widget.isLoading || !widget.shouldShowWebView) ? Duration.zero : const Duration(milliseconds: 250), curve: Curves.easeOut, opacity: (widget.isLoading || !widget.shouldShowWebView) ? 1.0 : 0.0, child: Container( color: _currentTheme.surfaceColor, child: _isSubsequentLoad ? null : Center( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.4, maxWidth: MediaQuery.of(context).size.width * 0.6, ), child: _buildCoverPlaceholder(), ), ), ), ), ), ), ], ); }, ); } /// Simple cover placeholder — loads image from file if available, /// otherwise shows a book icon. Widget _buildCoverPlaceholder() { final path = widget.coverRelativePath; if (path != null && path.isNotEmpty) { final file = File(path); if (file.existsSync()) { return ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.file(file, fit: BoxFit.cover), ); } } return Container( decoration: BoxDecoration( color: _currentTheme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: Icon( Icons.book_outlined, size: 64, color: _currentTheme.colorScheme.onSurfaceVariant, ), ); } void _setupJavaScriptHandlers(InAppWebViewController controller) { controller.addJavaScriptHandler( handlerName: 'onPageCountReady', callback: (args) async { if (args.isNotEmpty && args[0] is int) { widget.callbacks.onPageCountReady(args[0] as int); } }, ); controller.addJavaScriptHandler( handlerName: 'onPageChanged', callback: (args) { if (args.isNotEmpty && args[0] is int) { widget.callbacks.onPageChanged(args[0] as int); } }, ); controller.addJavaScriptHandler( handlerName: 'onScrollAnchors', callback: (args) { if (args.isEmpty) return; final List anchors = List.from(args[0] as List); widget.callbacks.onScrollAnchors(anchors); }, ); controller.addJavaScriptHandler( handlerName: 'onTap', callback: (args) { if (args.isEmpty) return; final x = (args[0] as num).toDouble(); final y = (args[1] as num).toDouble(); widget.callbacks.onTap(x, y); }, ); controller.addJavaScriptHandler( handlerName: 'onFootnoteTap', callback: (args) { if (args.isEmpty) return; final innerHtml = args[0] as String; final rect = Rect.fromLTWH( (args[1] as num).toDouble(), (args[2] as num).toDouble(), (args[3] as num).toDouble(), (args[4] as num).toDouble(), ); final baseUrl = args.length > 5 && args[5] is String ? args[5] as String : ''; widget.callbacks.onFootnoteTap(innerHtml, rect, baseUrl); }, ); controller.addJavaScriptHandler( handlerName: 'onLinkTap', callback: (args) { if (args.isEmpty) return; final url = args[0] as String; final x = (args[1] as num).toDouble(); final y = (args[2] as num).toDouble(); if (widget.callbacks.shouldHandleLinkTap(url)) { widget.callbacks.onLinkTap(url); } else { widget.callbacks.onTap(x, y); } }, ); controller.addJavaScriptHandler( handlerName: 'onImageLongPress', callback: (args) { if (args.length >= 5 && args[0] is String) { final imageUrl = args[0] as String; final rect = Rect.fromLTWH( (args[1] as num).toDouble(), (args[2] as num).toDouble(), (args[3] as num).toDouble(), (args[4] as num).toDouble(), ); widget.callbacks.onImageLongPress(imageUrl, rect); } }, ); controller.addJavaScriptHandler( handlerName: 'onViewportResize', callback: (args) { _updateTheme(_currentTheme); }, ); controller.addJavaScriptHandler( handlerName: 'onEventFinished', callback: (args) { if (args.isNotEmpty) { _bridge.resolveToken(args[0] as int); } }, ); // ─── 文本选择(划线功能) ──────────────── 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? 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.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 _takeScreenshot() async { if (Platform.isAndroid) { // for Android final BuildContext? context = _repaintKey.currentContext; if (context == null) return null; final RenderRepaintBoundary? boundary = context.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) return null; ui.Image image = await boundary.toImage(pixelRatio: 3.0); return image; } else { throw UnimplementedError( 'Do not use screenshot on iOS, it may cause performance issues.', ); } } Future _updateTheme(EpubTheme theme) async { if (_controller == null) return; final width = MediaQuery.of(context).size.width - theme.padding.horizontal; final height = MediaQuery.of(context).size.height - theme.padding.vertical; _currentTheme = theme; await _api.updateTheme(width, height, theme.toThemeMap()); } }