// ==UserScript== // @name 阅读位置标记 // @namespace your.namespace // @version 1.1 // @description 在安卓移动端(包括 Via 浏览器)上,选中文字并标记阅读位置,给选中的词添加红色背景。 // @match *://*/* // @grant none // @run-at document-idle // @downloadURL none // ==/UserScript== (function() { 'use strict'; // 定义一个CSS类用于红色背景高亮 const highlightClass = 'reading-highlight-red-bg'; const styleId = 'reading-highlight-style'; // 注入CSS样式,避免重复注入 if (!document.getElementById(styleId)) { const styleElement = document.createElement('style'); styleElement.id = styleId; styleElement.innerHTML = ` .${highlightClass} { background-color: rgba(255, 0, 0, 0.5); /* 红色半透明背景 */ cursor: pointer; /* 确保高亮不影响文字布局,可根据需要调整 */ box-decoration-break: clone; /* 针对跨行选择时背景连续性 */ -webkit-box-decoration-break: clone; /* 兼容Webkit内核浏览器 */ } `; document.head.appendChild(styleElement); } let lastHighlightedElements = []; // 用于存储上次高亮的DOM元素,因为可能涉及多个span /** * 将选中的文本包裹在一个带有高亮类的span标签中 * 考虑到跨段落或复杂选区,可能需要更精细的处理 * @param {Selection} selection 选区对象 */ function applyHighlight(selection) { if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { return; // 没有选中内容或者选区是折叠的 } // 移除上次高亮,如果存在 removeLastHighlight(); const range = selection.getRangeAt(0); const fragment = range.cloneContents(); // 克隆选区内容,避免直接修改原始DOM const nodesToHighlight = []; // 遍历所有选中的文本节点或元素,将它们包裹起来 // 这是一个简化的处理,对于复杂的跨标签选择可能需要更复杂的逻辑 function wrapNode(node) { if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) { const span = document.createElement('span'); span.classList.add(highlightClass); span.textContent = node.nodeValue; node.parentNode.replaceChild(span, node); nodesToHighlight.push(span); } else if (node.nodeType === Node.ELEMENT_NODE) { // 如果是元素节点,并且其内部有文本被选中,则递归处理 Array.from(node.childNodes).forEach(child => wrapNode(child)); } } // 获取选区内的所有文本节点,然后逐个高亮 // 这部分逻辑对于复杂选区(如跨多个标签的选中)需要更鲁棒的实现 // 简单起见,这里假设选区内容相对扁平或集中 const container = range.commonAncestorContainer; if (container.nodeType === Node.TEXT_NODE) { // 如果选区在单个文本节点内 const parent = container.parentNode; const text = container.nodeValue; const start = range.startOffset; const end = range.endOffset; const preText = document.createTextNode(text.substring(0, start)); const highlightedText = document.createElement('span'); highlightedText.classList.add(highlightClass); highlightedText.textContent = text.substring(start, end); nodesToHighlight.push(highlightedText); const postText = document.createTextNode(text.substring(end)); parent.replaceChild(postText, container); parent.insertBefore(highlightedText, postText); parent.insertBefore(preText, highlightedText); } else { // 对于更复杂的选区,遍历选区内的所有节点 const iterator = document.createNodeIterator( container, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { // 过滤掉不在选区范围内的文本节点 const nodeRange = document.createRange(); nodeRange.selectNodeContents(node); return range.intersectsNode(node) && node.nodeValue.trim().length > 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } } ); let node; while ((node = iterator.nextNode())) { const textContent = node.nodeValue; if (textContent.trim().length === 0) continue; const parent = node.parentNode; const nodeRange = document.createRange(); nodeRange.selectNodeContents(node); let startOffset = 0; let endOffset = textContent.length; if (node === range.startContainer) { startOffset = range.startOffset; } if (node === range.endContainer) { endOffset = range.endOffset; } if (startOffset === 0 && endOffset === textContent.length) { // 整个文本节点被选中 const span = document.createElement('span'); span.classList.add(highlightClass); span.textContent = textContent; parent.replaceChild(span, node); nodesToHighlight.push(span); } else { // 部分文本节点被选中 const preText = document.createTextNode(textContent.substring(0, startOffset)); const highlightedText = document.createElement('span'); highlightedText.classList.add(highlightClass); highlightedText.textContent = textContent.substring(startOffset, endOffset); nodesToHighlight.push(highlightedText); const postText = document.createTextNode(textContent.substring(endOffset)); parent.replaceChild(postText, node); parent.insertBefore(highlightedText, postText); parent.insertBefore(preText, highlightedText); } } } lastHighlightedElements = nodesToHighlight; } /** * 移除上次的高亮 */ function removeLastHighlight() { lastHighlightedElements.forEach(span => { const parent = span.parentNode; if (parent) { while (span.firstChild) { parent.insertBefore(span.firstChild, span); } parent.removeChild(span); parent.normalize(); // 合并相邻的文本节点 } }); lastHighlightedElements = []; } /** * 保存阅读位置到localStorage * @param {Selection} selection 选区对象 */ function saveReadingPosition(selection) { if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return; const range = selection.getRangeAt(0); const selectionData = { anchorNodePath: getXPath(range.startContainer), anchorOffset: range.startOffset, focusNodePath: getXPath(range.endContainer), focusOffset: range.endOffset }; localStorage.setItem('readingPosition_' + window.location.href, JSON.stringify(selectionData)); console.log("阅读位置已保存:", selectionData); } /** * 从localStorage加载并恢复阅读位置 */ function loadReadingPosition() { const savedPosition = localStorage.getItem('readingPosition_' + window.location.href); if (savedPosition) { try { const selectionData = JSON.parse(savedPosition); const startNode = getElementByXPath(selectionData.anchorNodePath); const endNode = getElementByXPath(selectionData.focusNodePath); if (startNode && endNode) { const selection = window.getSelection(); const range = document.createRange(); try { range.setStart(startNode, selectionData.anchorOffset); range.setEnd(endNode, selectionData.focusOffset); selection.removeAllRanges(); // 清除现有选区 selection.addRange(range); // 恢复选区 // 自动滚动到高亮位置 const rect = range.getBoundingClientRect(); window.scrollBy({ top: rect.top - (window.innerHeight / 3), // 滚动到屏幕偏上位置 behavior: 'smooth' }); // 立即高亮加载的阅读位置 applyHighlight(selection); // 使用恢复的selection对象进行高亮 console.log("阅读位置已加载并恢复。"); } catch (e) { console.error("无法恢复选区:", e); } } else { console.warn("无法找到保存的节点,阅读位置可能已失效。"); } } catch (e) { console.error("解析保存的阅读位置失败:", e); } } } /** * 获取一个DOM节点的XPath * @param {Node} node 目标节点 * @returns {string} 节点的XPath */ function getXPath(node) { if (!node || node.nodeType === Node.DOCUMENT_NODE) { return ''; } // 如果是文本节点,获取其父元素的XPath,并加上文本节点索引 if (node.nodeType === Node.TEXT_NODE) { let index = 1; let sibling = node; while (sibling.previousSibling) { sibling = sibling.previousSibling; if (sibling.nodeType === Node.TEXT_NODE) { index++; } } return getXPath(node.parentNode) + `/text()[${index}]`; } const parts = []; let currentNode = node; while (currentNode && currentNode.nodeType !== Node.DOCUMENT_NODE) { let selector = currentNode.nodeName.toLowerCase(); if (currentNode.id) { selector += `[@id="${currentNode.id}"]`; } else { let sibling = currentNode; let nth = 1; while (sibling.previousSibling) { sibling = sibling.previousSibling; if (sibling.nodeName.toLowerCase() === selector) { nth++; } } if (nth > 1) { selector += `[${nth}]`; } } parts.unshift(selector); currentNode = currentNode.parentNode; } return parts.length ? '/' + parts.join('/') : ''; } /** * 根据XPath获取一个DOM节点 * @param {string} path 节点的XPath * @returns {Node|null} 找到的节点或者null */ function getElementByXPath(path) { if (!path) return null; try { // 如果XPath包含 /text(),则处理文本节点 if (path.includes('/text()')) { const parts = path.split('/text()'); const elementPath = parts[0]; const textIndexMatch = parts[1].match(/\[(\d+)\]/); const textIndex = textIndexMatch ? parseInt(textIndexMatch[1]) : 1; const element = getElementByXPath(elementPath); if (element) { let textNodeCount = 0; for (let i = 0; i < element.childNodes.length; i++) { const child = element.childNodes[i]; if (child.nodeType === Node.TEXT_NODE && child.nodeValue.trim().length > 0) { textNodeCount++; if (textNodeCount === textIndex) { return child; } } } } return null; } else { const result = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); return result.singleNodeValue; } } catch (e) { console.error("无效的XPath:", path, e); return null; } } // 监听selectionchange事件 // 在安卓移动端(包括 Via 浏览器),用户长按选择文字时, // selectionchange 事件会很好地触发。 let selectionTimeout; document.addEventListener('selectionchange', () => { clearTimeout(selectionTimeout); // 清除之前的延迟 const selection = window.getSelection(); if (selection.rangeCount > 0 && !selection.isCollapsed) { // 确保有实际选中内容 // 设置一个短延迟,等待用户完成选择 selectionTimeout = setTimeout(() => { // 再次检查,确保用户没有在延迟期间取消选择 if (!window.getSelection().isCollapsed) { applyHighlight(window.getSelection()); saveReadingPosition(window.getSelection()); } }, 300); // 300毫秒延迟 } else { // 如果选区被清除(用户点击页面其他地方),则清除高亮 // 这里可以根据需求决定是否清除,如果需要高亮常驻则不要清除 // removeLastHighlight(); // 取消此行注释可实现在取消选择时自动移除高亮 } }); // 页面加载完成后尝试恢复阅读位置 window.addEventListener('load', loadReadingPosition); // 针对动态加载内容的页面,可以使用 MutationObserver // 但对于大部分Via浏览器用户访问的普通网页,load 事件已足够。 // 如果页面内容是通过AJAX等方式异步加载的,且这些内容是用户需要标记的, // 则可能需要更复杂的逻辑来重新加载或监听内容变化。 })();