// ==UserScript==
// @name Copy highlighted links
// @namespace https://github.com/chirag127/userscripts
// @version 0.1.3
// @description Copy URLs of every link found in the current text selection to the clipboard (one per line). Tampermonkey menu command — same behavior as the "Copy Highlighted Links" Chrome extension by CraftedIntuition, plus plain-text URL detection.
// @author chirag127
// @match *://*/*
// @run-at document-end
// @grant GM_registerMenuCommand
// @grant GM_setClipboard
// @license MIT
// @homepageURL https://github.com/chirag127/userscripts/tree/main/copy-highlighted-links
// @supportURL https://github.com/chirag127/userscripts/issues
// @updateURL https://raw.githubusercontent.com/chirag127/userscripts/main/copy-highlighted-links/copy-highlighted-links.user.js
// @downloadURL https://raw.githubusercontent.com/chirag127/userscripts/main/copy-highlighted-links/copy-highlighted-links.user.js
// ==/UserScript==
(() => {
'use strict'
// Matches http://, https://, and bare www.* URLs. Allows the common URL char set
// and stops at whitespace, common surrounding punctuation, and quotes/brackets.
// (Same regex as `open-links-in-selection` for behavior parity.)
const URL_RE = /\b((?:https?:\/\/|www\.)[^\s<>"'`()\[\]{},]+[^\s<>"'`()\[\]{},.;:!?])/gi
// Identical collection logic to `open-links-in-selection.user.js`.
// Deliberately duplicated rather than sharing a module — userscript managers
// don't have a reliable cross-script import story, and copy-paste is auditable.
function collectLinksFromSelection() {
const sel = window.getSelection()
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return []
const found = new Set()
// 1. Plain-text URLs inside the selection's stringified content.
const text = sel.toString()
if (text) {
for (const m of text.matchAll(URL_RE)) {
let u = m[1]
if (u.startsWith('www.')) u = 'https://' + u
found.add(u)
}
}
// 2. elements that intersect the selection range.
const activeEl = document.activeElement
const inTextarea =
activeEl && (activeEl.tagName === 'TEXTAREA' || (activeEl.tagName === 'INPUT' && activeEl.type === 'text'))
if (!inTextarea) {
for (let i = 0; i < sel.rangeCount; i++) {
const range = sel.getRangeAt(i)
const root = range.commonAncestorContainer
const rootEl = root.nodeType === Node.ELEMENT_NODE ? root : root.parentElement
if (!rootEl) continue
const anchors = rootEl.querySelectorAll('a[href]')
for (const a of anchors) {
if (!range.intersectsNode(a)) continue
const href = a.href
if (!href) continue
// Skip javascript:, mailto:, tel:, hash-only anchors
if (/^(javascript:|mailto:|tel:|#)/i.test(href)) continue
found.add(href)
}
// Also walk ancestors of both endpoints — selection started/ended INSIDE
// a single won't appear in commonAncestorContainer's descendants.
for (const node of [range.startContainer, range.endContainer]) {
let el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement
while (el && el !== document.body) {
if (el.tagName === 'A' && el.href && !/^(javascript:|mailto:|tel:|#)/i.test(el.href)) {
found.add(el.href)
break
}
el = el.parentElement
}
}
}
}
return [...found]
}
async function writeClipboard(text) {
// Prefer GM_setClipboard when available — bypasses navigator.clipboard
// gesture requirement and works on http:// pages too.
if (typeof GM_setClipboard === 'function') {
try { GM_setClipboard(text, 'text'); return true } catch { /* fall through */ }
}
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// execCommand fallback for old http:// pages where Clipboard API is blocked.
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
let ok = false
try { ok = document.execCommand('copy') } catch { /* ignore */ }
ta.remove()
return ok
}
}
// Tiny toast — minimal styling, max-z, gone after 2.5s.
function toast(message) {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'fixed',
left: '50%',
bottom: '20px',
transform: 'translateX(-50%)',
zIndex: '2147483647',
padding: '12px 20px',
borderRadius: '8px',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
color: '#fff',
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
fontSize: '14px',
fontWeight: '600',
boxShadow: '0 4px 14px rgba(0, 0, 0, 0.4)',
pointerEvents: 'none',
transition: 'opacity 250ms ease-in-out',
opacity: '0',
})
el.textContent = message
document.body.appendChild(el)
requestAnimationFrame(() => { el.style.opacity = '1' })
setTimeout(() => {
el.style.opacity = '0'
setTimeout(() => el.remove(), 300)
}, 2500)
}
async function run() {
const urls = collectLinksFromSelection()
if (urls.length === 0) {
toast('No links found in selection')
return
}
const payload = urls.join('\n')
const ok = await writeClipboard(payload)
if (ok) {
toast(`Copied ${urls.length} link${urls.length === 1 ? '' : 's'} to clipboard`)
} else {
toast('Clipboard copy failed (page may block clipboard access)')
console.warn('[copy-highlighted-links] failed to write clipboard; URLs:', urls)
}
}
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('Copy highlighted links', run, { accessKey: 'c' })
}
// Devtools-friendly global for keybind bindings via Tampermonkey's UI.
// @ts-ignore — userscript global
window.__copyHighlightedLinks = run
})()