Ctrl-Hover Linkifier

Hold Ctrl and hover over plain text URLs, hostnames, or IPs to temporarily turn them into clickable links.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Ctrl-Hover Linkifier
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Hold Ctrl and hover over plain text URLs, hostnames, or IPs to temporarily turn them into clickable links.
// @author       Gemini, skygate2012
// @match        *://*/*
// @license MIT
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // State variables
    let mouseX = 0;
    let mouseY = 0;
    let ctrlHeld = false;
    let activeLinkData = null;

    // Regex to detect standard URLs, domains (e.g. example.com), and IPs (e.g. 192.168.1.1)
    const linkRegex = /^(?:https?:\/\/)?(?:(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}|(?:\d{1,3}\.){3}\d{1,3})(?::\d+)?(?:\/[^\s]*)?$/i;

    // Boundary check for word extraction
    const isBoundary = (char) => /[\s<>"']/.test(char);

    // Track mouse position continuously (extremely cheap operation)
    document.addEventListener('mousemove', (e) => {
        mouseX = e.clientX;
        mouseY = e.clientY;

        // If a temporary link exists, check if we are still hovering it
        if (activeLinkData) {
            // e.target is the element currently under the mouse
            if (e.target === activeLinkData.aElement) {
                return; // Still hovering the created link
            } else {
                revertLink(); // Mouse moved away
            }
        }

        // If Ctrl is held and we have no active link, scan the point
        if (ctrlHeld) {
            processPoint(mouseX, mouseY);
        }
    }, { passive: true });

    // Track Ctrl key press
    document.addEventListener('keydown', (e) => {
        if (e.key === 'Control' && !ctrlHeld) {
            ctrlHeld = true;
            processPoint(mouseX, mouseY);
        }
    }, { passive: true });

    // Track Ctrl key release
    document.addEventListener('keyup', (e) => {
        if (e.key === 'Control') {
            ctrlHeld = false;
            revertLink();
        }
    }, { passive: true });

    // Revert the injected <a> tag back to a standard text node
    function revertLink() {
        if (!activeLinkData) return;

        const { aElement, word } = activeLinkData;

        // Ensure the element is still in the DOM before trying to replace it
        if (aElement.parentNode) {
            const textNode = document.createTextNode(word);
            aElement.replaceWith(textNode);
        }

        activeLinkData = null;
    }

    // Identify text under the cursor and linkify if appropriate
    function processPoint(x, y) {
        if (activeLinkData) return;

        // Cross-browser method to get the exact text node and character offset under the mouse
        let textNode, offset;
        if (document.caretPositionFromPoint) {
            const pos = document.caretPositionFromPoint(x, y);
            if (!pos) return;
            textNode = pos.offsetNode;
            offset = pos.offset;
        } else if (document.caretRangeFromPoint) { // WebKit / Blink fallback
            const range = document.caretRangeFromPoint(x, y);
            if (!range) return;
            textNode = range.startContainer;
            offset = range.startOffset;
        } else {
            return;
        }

        // Bail out early if we aren't hovering over a text node
        if (textNode.nodeType !== Node.TEXT_NODE) return;

        const parent = textNode.parentNode;

        // Fast bailouts: already in a link, or inside an input/editable area
        if (!parent || parent.closest('a') || parent.closest('input, textarea, [contenteditable="true"]')) return;

        const text = textNode.nodeValue;
        if (!text) return;

        // Expand outwards from the mouse offset to find the current "word" boundaries
        let start = offset;
        while (start > 0 && !isBoundary(text[start - 1])) {
            start--;
        }

        let end = offset;
        while (end < text.length && !isBoundary(text[end])) {
            end++;
        }

        let word = text.slice(start, end);
        if (word.length < 4) return; // Too short to be a valid domain/IP

        // Trim trailing punctuation (e.g. end of a sentence like "example.com.")
        let trimEnd = 0;
        while (word.length > 0 && /[.,!?;:]$/.test(word)) {
            word = word.slice(0, -1);
            trimEnd++;
        }
        end -= trimEnd;

        // Trim leading punctuation
        let trimStart = 0;
        while (word.length > 0 && /^[([]/.test(word)) {
            word = word.slice(1);
            trimStart++;
        }
        start += trimStart;

        // Test if the extracted word resembles a link
        if (linkRegex.test(word)) {
            createLink(textNode, start, end, word);
        }
    }

    // Convert the text into an <a> element seamlessly
    function createLink(textNode, start, end, urlText) {
        const text = textNode.nodeValue;
        const beforeText = text.slice(0, start);
        const afterText = text.slice(end);

        const a = document.createElement('a');
        a.textContent = urlText;
        // Prepend https:// if protocol is missing
        a.href = /^(https?:\/\/)/i.test(urlText) ? urlText : `https://${urlText}`;
        a.style.color = '#0056b3';
        a.style.textDecoration = 'underline';
        a.style.cursor = 'pointer';

        // Use a DocumentFragment to safely replace the text node without disrupting layout
        const frag = document.createDocumentFragment();
        if (beforeText) frag.appendChild(document.createTextNode(beforeText));
        frag.appendChild(a);
        if (afterText) frag.appendChild(document.createTextNode(afterText));

        textNode.parentNode.replaceChild(frag, textNode);

        // Save reference so we can revert it later
        activeLinkData = {
            aElement: a,
            word: urlText
        };
    }
})();