阅读位置标记

在安卓移动端(包括 Via 浏览器)上,选中文字后显示自定义浮动菜单,只提供标记功能,解决原生菜单冲突。

// ==UserScript==
// @name 阅读位置标记
// @namespace your.namespace
// @version 2.4
// @description 在安卓移动端(包括 Via 浏览器)上,选中文字后显示自定义浮动菜单,只提供标记功能,解决原生菜单冲突。
// @match *://*/*
// @grant none
// @run-at document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- CSS 样式注入 ---
    const highlightClass = 'reading-highlight-red-bg';
    const customMenuId = 'reading-custom-selection-menu';
    const styleId = 'reading-highlight-style-v1_4'; // 更新样式ID以避免缓存问题

    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内核浏览器 */
            }

            /* 自定义选择菜单样式 */
            #${customMenuId} {
                position: absolute;
                background-color: #333;
                color: white;
                padding: 5px 10px;
                border-radius: 5px;
                box-shadow: 0 2px 5px rgba(0,0,0,0.3);
                z-index: 99999;
                display: none; /* 默认隐藏 */
                white-space: nowrap; /* 确保按钮不换行 */
                font-family: sans-serif;
                font-size: 14px;
            }
            #${customMenuId} button {
                background-color: transparent;
                color: white;
                border: none;
                padding: 5px 10px;
                margin: 0; /* 调整 margin,因为只有一个按钮 */
                cursor: pointer;
                font-size: 14px;
                border-radius: 3px;
                min-width: 60px; /* 确保按钮有足够的宽度 */
                text-align: center;
            }
            #${customMenuId} button:hover {
                background-color: #555;
            }
            #${customMenuId} button:active {
                background-color: #666;
            }
        `;
        document.head.appendChild(styleElement);
    }

    // --- DOM 元素创建 ---
    let customMenu = document.getElementById(customMenuId);
    if (!customMenu) {
        customMenu = document.createElement('div');
        customMenu.id = customMenuId;
        customMenu.innerHTML = `
            <button id="${customMenuId}-mark">标记</button>
        `;
        document.body.appendChild(customMenu);
    }

    const markButton = document.getElementById(`${customMenuId}-mark`);

    let lastHighlightedElements = []; // 用于存储上次高亮的DOM元素

    // --- 辅助函数 ---

    /**
     * 将选中的文本包裹在一个带有高亮类的span标签中
     * @param {Selection} selection 选区对象
     */
    function applyHighlight(selection) {
        if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
            console.warn("没有有效选区,无法高亮。");
            return;
        }

        removeLastHighlight(); // 移除上次高亮

        const range = selection.getRangeAt(0);
        const nodesToHighlight = [];

        const container = range.commonAncestorContainer;
        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;
            let startOffset = 0;
            let endOffset = textContent.length;

            if (node === range.startContainer) {
                startOffset = range.startOffset;
            }
            if (node === range.endContainer) {
                endOffset = range.endOffset;
            }

            // 分割文本节点并插入高亮span
            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));

            // 安全替换节点
            if (parent) {
                // 先插入新节点,再移除旧节点
                if (node.nextSibling) {
                    parent.insertBefore(postText, node.nextSibling);
                    parent.insertBefore(highlightedText, postText);
                    parent.insertBefore(preText, highlightedText);
                } else {
                    parent.appendChild(preText);
                    parent.appendChild(highlightedText);
                    parent.appendChild(postText);
                }
                parent.removeChild(node);
                parent.normalize(); // 合并相邻的文本节点
            }
        }
        lastHighlightedElements = nodesToHighlight;
        console.log("高亮已应用。");
    }

    /**
     * 移除上次的高亮
     */
    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 = [];
        console.log("上次高亮已移除。");
    }

    /**
     * 保存阅读位置到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);
                        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 '';
        }
        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 {
            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;
        }
    }

    // --- 事件监听 ---

    let currentSelectionTimeout;
    document.addEventListener('selectionchange', () => {
        clearTimeout(currentSelectionTimeout); // 清除之前的延迟

        const selection = window.getSelection();
        if (selection.rangeCount > 0 && !selection.isCollapsed) {
            // 有效选区,延迟显示自定义菜单
            currentSelectionTimeout = setTimeout(() => {
                if (!window.getSelection().isCollapsed) { // 再次确认选区仍然存在
                    showCustomMenu(selection);
                }
            }, 200); // 200ms 延迟,给用户操作原生菜单的时间
        } else {
            // 没有有效选区或选区被清除,隐藏自定义菜单
            hideCustomMenu();
        }
    });

    // 监听标记按钮点击
    markButton.addEventListener('click', () => {
        const selection = window.getSelection();
        if (selection.rangeCount > 0 && !selection.isCollapsed) {
            applyHighlight(selection);
            saveReadingPosition(selection);
        } else {
            alert('请先选择要标记的文本。');
        }
        hideCustomMenu();
        // 标记后清除选区,防止再次误触发
        window.getSelection().removeAllRanges();
    });

    // 点击文档其他地方时隐藏菜单
    document.addEventListener('mousedown', (event) => {
        // 如果点击的是菜单或菜单里的按钮,不隐藏
        if (customMenu.contains(event.target)) {
            return;
        }
        // 如果有选区,且点击在选区内,也不隐藏(为了让用户有机会再次点击选区以显示菜单)
        const selection = window.getSelection();
        if (selection.rangeCount > 0 && !selection.isCollapsed) {
            const range = selection.getRangeAt(0);
            const rect = range.getBoundingClientRect();
            // 检查点击位置是否在选区矩形内
            if (event.clientX >= rect.left && event.clientX <= rect.right &&
                event.clientY >= rect.top && event.clientY <= rect.bottom) {
                 return;
            }
        }
        hideCustomMenu();
    });


    // --- 菜单显示/隐藏逻辑 ---

    /**
     * 显示自定义菜单并定位
     * @param {Selection} selection 当前选区
     */
    function showCustomMenu(selection) {
        if (selection.rangeCount === 0 || selection.isCollapsed) {
            hideCustomMenu();
            return;
        }

        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect(); // 获取选区位置信息

        // 计算菜单位置
        // 尝试放在选区上方,如果空间不够则放在下方
        let top = rect.top + window.scrollY - customMenu.offsetHeight - 10; // 10px 上方偏移
        let left = rect.left + window.scrollX + (rect.width / 2) - (customMenu.offsetWidth / 2);

        // 边界检查,防止菜单超出屏幕
        if (top < window.scrollY) { // 如果菜单在屏幕顶部之上
            top = rect.bottom + window.scrollY + 10; // 放到选区下方
        }
        if (left < window.scrollX) { // 菜单左侧超出屏幕
            left = window.scrollX + 10;
        }
        if (left + customMenu.offsetWidth > window.innerWidth + window.scrollX) { // 菜单右侧超出屏幕
            left = window.innerWidth + window.scrollX - customMenu.offsetWidth - 10;
        }

        customMenu.style.top = `${top}px`;
        customMenu.style.left = `${left}px`;
        customMenu.style.display = 'block';
    }

    /**
     * 隐藏自定义菜单
     */
    function hideCustomMenu() {
        customMenu.style.display = 'none';
    }

    // --- 页面加载完成时加载阅读位置 ---
    window.addEventListener('load', loadReadingPosition);

})();