Ctrl-Hover Linkifier

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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
        };
    }
})();