Greasy Fork

Greasy Fork is available in English.

阅读位置标记

在安卓移动端(包括 Via 浏览器)上,选中文字并标记阅读位置,给选中的词添加红色背景。

当前为 2025-06-05 提交的版本,查看 最新版本

// ==UserScript==
// @name 阅读位置标记
// @namespace your.namespace
// @version 1.1
// @description 在安卓移动端(包括 Via 浏览器)上,选中文字并标记阅读位置,给选中的词添加红色背景。
// @match *://*/*
// @grant none
// @run-at document-idle
// ==/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等方式异步加载的,且这些内容是用户需要标记的,
    // 则可能需要更复杂的逻辑来重新加载或监听内容变化。

})();