CaretMark

Alt+Click to place a caretmark.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         CaretMark
// @namespace    http://tampermonkey.net/
// @version      2026-05-19
// @description  Alt+Click to place a caretmark.
// @author       Gemini, skygate2012
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const getStorageKey = () => location.href;

    const style = document.createElement('style');
    style.textContent = `
    /* Caret Animation */
    @keyframes smoothBlink {
      0%, 100% { opacity: 1; transform: scaleY(1); }
      50% { opacity: 0.3; transform: scaleY(0.9); }
    }

    /* Highlight Flash Animation */
    @keyframes flashAmber {
      0% { background-color: rgba(255, 165, 0, 0); }
      10% { background-color: rgba(255, 165, 0, 0.6); box-shadow: 0 0 15px rgba(255, 165, 0, 0.4); }
      100% { background-color: rgba(255, 165, 0, 0); }
    }

    /* The Caret Line */
    .caret-mark {
      position: absolute;
      width: 3px;
      background: #00e600;
      box-shadow: 0 0 4px rgba(0,255,0,0.8);
      animation: smoothBlink 1.2s ease-in-out infinite;
      pointer-events: none;
      z-index: 2147483647;
      border-radius: 2px;
    }

    /* The Flash Strip */
    .caret-flash-strip {
      position: absolute;
      left: 0;
      width: 100%;
      pointer-events: none;
      z-index: 2147483646;
      animation: flashAmber 1.5s ease-out forwards;
      mix-blend-mode: multiply;
    }

    /* The Floating Indicator */
    .caret-indicator {
      position: fixed;
      left: 50%;
      transform: translateX(-50%);
      padding: 8px 16px;
      background: linear-gradient(135deg, #ff8c00, #ff5500);
      color: white;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
      font-weight: 600;
      font-size: 14px;
      border-radius: 20px;
      box-shadow: 0 4px 15px rgba(255, 85, 0, 0.4);
      z-index: 2147483647;
      cursor: pointer;
      white-space: nowrap;
      transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
      border: 1px solid rgba(255,255,255,0.2);
      text-shadow: 0 1px 2px rgba(0,0,0,0.2);
    }

    .caret-indicator:hover {
      transform: translateX(-50%) scale(1.05);
      box-shadow: 0 6px 20px rgba(255, 85, 0, 0.5);
    }

    .caret-indicator:active {
      transform: translateX(-50%) scale(0.95);
    }

    .caret-indicator.top { top: 20px; }
    .caret-indicator.bottom { bottom: 20px; }

    /* Arrow Pointer on Indicator */
    .caret-indicator::after {
      content: '';
      position: absolute;
      left: 50%;
      transform: translateX(-50%);
      border: 8px solid transparent;
    }

    /* Pointing Up */
    .caret-indicator.top::after {
      top: -15px;
      border-bottom-color: #ff8c00;
    }

    /* Pointing Down */
    .caret-indicator.bottom::after {
      bottom: -15px;
      border-top-color: #ff5500;
    }
  `;
    document.head.appendChild(style);

    /* ----------------- State ----------------- */
    let caretEl = null;
    let indicatorEl = null;
    let currentRange = null;

    /* ----------------- XPath Helpers ----------------- */
    function getXPath(node) {
        if (node.nodeType === Node.TEXT_NODE) node = node.parentNode;
        if (node.id) return `//*[@id="${node.id}"]`;

        const parts = [];
        while (node && node.nodeType === Node.ELEMENT_NODE) {
            let count = 0;
            let sibling = node.previousSibling;
            while (sibling) {
                if (sibling.nodeType === Node.ELEMENT_NODE && sibling.nodeName === node.nodeName) {
                    count++;
                }
                sibling = sibling.previousSibling;
            }
            const tag = node.nodeName.toLowerCase();
            const pathIndex = count > 0 ? `[${count + 1}]` : '';
            parts.unshift(tag + pathIndex);
            node = node.parentNode;
        }
        return parts.length ? '/' + parts.join('/') : null;
    }

    function getNodeByXPath(path) {
        try {
            const evaluator = new XPathEvaluator();
            const result = evaluator.evaluate(path, document.documentElement, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
            return result.singleNodeValue;
        } catch (e) {
            return null;
        }
    }

    /* ----------------- Core Logic ----------------- */

    function clearVisuals() {
        caretEl?.remove();
        indicatorEl?.remove();
        caretEl = indicatorEl = currentRange = null;
    }

    // Differentiate between user-invoked removal (clear storage) and systemic redrawing
    function removeCaret(userAction = true) {
        clearVisuals();
        if (userAction) GM_deleteValue(getStorageKey());
    }

    function drawCaretFromRange(range) {
        if (!range) return;

        const rects = range.getClientRects();
        const rect = rects.length > 0 ? rects[0] : range.getBoundingClientRect();

        if (rect.height === 0 && rect.width === 0) return;

        if (!caretEl) {
            caretEl = document.createElement('div');
            caretEl.className = 'caret-mark';
            document.body.appendChild(caretEl);
        }

        const absoluteTop = rect.top + window.scrollY;
        const absoluteLeft = rect.left + window.scrollX;

        caretEl.style.top = `${absoluteTop}px`;
        caretEl.style.left = `${absoluteLeft}px`;
        caretEl.style.height = `${rect.height}px`;

        currentRange = range;
        updateIndicator();
    }

    function saveLocation(node, offset) {
        let textNodeIndex = 0;
        if (node.nodeType === Node.TEXT_NODE) {
            let sibling = node.previousSibling;
            while(sibling) {
                if(sibling.nodeType === Node.TEXT_NODE) textNodeIndex++;
                sibling = sibling.previousSibling;
            }
        }

        const data = {
            xpath: getXPath(node),
            offset: offset,
            isText: node.nodeType === Node.TEXT_NODE,
            textIndex: textNodeIndex
        };
        GM_setValue(getStorageKey(), JSON.stringify(data));
    }

    function restoreCaret() {
        const raw = GM_getValue(getStorageKey());
        if (!raw) return;

        const data = JSON.parse(raw);
        let parentNode = getNodeByXPath(data.xpath);

        if (!parentNode) return;

        let targetNode = parentNode;

        if (data.isText) {
            let currentIndex = 0;
            let found = false;
            for (let child of parentNode.childNodes) {
                if (child.nodeType === Node.TEXT_NODE) {
                    if (currentIndex === data.textIndex) {
                        targetNode = child;
                        found = true;
                        break;
                    }
                    currentIndex++;
                }
            }
            if (!found) targetNode = parentNode;
        }

        try {
            const range = document.createRange();
            const maxLen = targetNode.length || targetNode.childNodes.length || 0;
            const safeOffset = Math.min(data.offset, maxLen);

            range.setStart(targetNode, safeOffset);
            range.collapse(true);
            drawCaretFromRange(range);
        } catch (e) {
            // Suppress noisy console logs on dynamic page failures
            clearVisuals();
        }
    }

    /* ----------------- Indicator & Highlight Logic ----------------- */

    function estimateLineHeight() {
        return parseInt(getComputedStyle(document.body).lineHeight) || 20;
    }

    function updateIndicator() {
        if (!currentRange || !caretEl) return;

        const rect = caretEl.getBoundingClientRect();
        const viewportHeight = window.innerHeight;
        const isVisible = rect.top >= -10 && rect.bottom <= viewportHeight + 10;

        if (isVisible) {
            if (indicatorEl) indicatorEl.style.display = 'none';
            return;
        }

        if (!indicatorEl) {
            indicatorEl = document.createElement('div');
            indicatorEl.className = 'caret-indicator';
            indicatorEl.addEventListener('click', scrollToCaret);
            document.body.appendChild(indicatorEl);
        }

        indicatorEl.style.display = 'block';
        indicatorEl.classList.remove('top', 'bottom');

        const lh = estimateLineHeight();

        if (rect.top < 0) {
            const lines = Math.ceil(Math.abs(rect.top) / lh);
            indicatorEl.innerHTML = `&uarr; ${lines} lines up`;
            indicatorEl.classList.add('top');
        } else {
            const lines = Math.ceil((rect.top - viewportHeight) / lh);
            indicatorEl.innerHTML = `&darr; ${lines} lines down`;
            indicatorEl.classList.add('bottom');
        }
    }

    function flashHighlightLine() {
        if (!caretEl) return;

        const caretRect = caretEl.getBoundingClientRect();
        const absoluteTop = caretRect.top + window.scrollY;
        const height = caretRect.height || 20;

        const strip = document.createElement('div');
        strip.className = 'caret-flash-strip';
        strip.style.top = `${absoluteTop}px`;
        strip.style.height = `${height}px`;

        document.body.appendChild(strip);

        setTimeout(() => {
            strip.remove();
        }, 1600);
    }

    function scrollToCaret(e) {
        if (e) e.stopPropagation();
        if (!currentRange) return;

        const rect = caretEl.getBoundingClientRect();
        const absoluteTop = rect.top + window.scrollY;

        window.scrollTo({
            top: absoluteTop - (window.innerHeight / 2),
            behavior: 'smooth'
        });

        setTimeout(flashHighlightLine, 300);
    }

    /* ----------------- Event Listeners & Observers ----------------- */

    document.addEventListener('click', (e) => {
        if (!e.altKey) return;
        if (e.target.tagName === 'A') return;
        if (e.target.closest('.caret-indicator') || e.target.closest('.caret-mark')) return;

        let range;
        let offsetNode, offset;

        if (document.caretPositionFromPoint) {
            const pos = document.caretPositionFromPoint(e.clientX, e.clientY);
            if (!pos) return;
            offsetNode = pos.offsetNode;
            offset = pos.offset;
            range = document.createRange();
            range.setStart(offsetNode, offset);
        } else if (document.caretRangeFromPoint) {
            range = document.caretRangeFromPoint(e.clientX, e.clientY);
            offsetNode = range.startContainer;
            offset = range.startOffset;
        }

        if (!range) return;

        removeCaret(true); // User generated, so completely overwrite

        range.collapse(true);
        drawCaretFromRange(range);
        saveLocation(offsetNode, offset);
    });

    // Handle SPA URL Changes
    let lastUrl = location.href;
    function handleUrlChange() {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            removeCaret(false); // Clear visually, don't delete from storage
            setTimeout(restoreCaret, 200); // Slight delay for framework router mounting
        }
    }

    const originalPushState = history.pushState;
    history.pushState = function() {
        originalPushState.apply(this, arguments);
        handleUrlChange();
    };

    const originalReplaceState = history.replaceState;
    history.replaceState = function() {
        originalReplaceState.apply(this, arguments);
        handleUrlChange();
    };
    window.addEventListener('popstate', handleUrlChange);

    // Handle DOM shifts (Images loading, accordion expanding, etc.)
    if (typeof ResizeObserver !== 'undefined') {
        const resizeObserver = new ResizeObserver(() => {
            if (currentRange && document.contains(currentRange.startContainer)) {
                drawCaretFromRange(currentRange);
            }
        });
        resizeObserver.observe(document.body);
    }

    // Handle DOM framework re-renders (React/Vue tearing down and rebuilding nodes)
    let restoreDebounce;
    const domObserver = new MutationObserver(() => {
        // 1. We have data, but no caret is currently drawn (e.g., async data just loaded)
        if (!currentRange && GM_getValue(getStorageKey())) {
            clearTimeout(restoreDebounce);
            restoreDebounce = setTimeout(restoreCaret, 500);
        }
        // 2. We have a caret, but the underlying text node was destroyed by a framework
        else if (currentRange && !document.contains(currentRange.startContainer)) {
            clearVisuals();
            clearTimeout(restoreDebounce);
            restoreDebounce = setTimeout(restoreCaret, 300);
        }
    });

    // Wait for initial load
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
            domObserver.observe(document.body, { childList: true, subtree: true });
            restoreCaret();
        });
    } else {
        domObserver.observe(document.body, { childList: true, subtree: true });
        restoreCaret();
        setTimeout(restoreCaret, 1000);
    }

    window.addEventListener('scroll', updateIndicator, { passive: true });
    document.addEventListener('keydown', (e) => e.key === 'Escape' && removeCaret(true));

})();