Hold Ctrl and hover over plain text URLs, hostnames, or IPs to temporarily turn them into clickable links.
От
// ==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
};
}
})();