Ctrl-Hover Linkifier

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

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل 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
        };
    }
})();