您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Client-side @mention autocomplete + click-to-mention for TorrentBD shoutbox.
当前为
// ==UserScript== // @name TorrentBD Shoutbox Mentions // @namespace http://tampermonkey.net/ // @version 1.7 // @description Client-side @mention autocomplete + click-to-mention for TorrentBD shoutbox. // @author JeTexY // @namespace JeTexY // @match https://*.torrentbd.com/* // @match https://*.torrentbd.net/* // @match https://*.torrentbd.org/* // @license MIT // @run-at document-end // @icon https://static.torrentbd.net/bf68ee5a32904d2ca12f3050f9efbf91.png // @grant none // ==/UserScript== (function () { 'use strict'; // ------------------------- // Utilities // ------------------------- function waitFor(selector, { root = document, timeout = 15000 } = {}) { return new Promise((resolve, reject) => { const el = root.querySelector(selector); if (el) return resolve(el); const obs = new MutationObserver(() => { const found = root.querySelector(selector); if (found) { obs.disconnect(); resolve(found); } }); obs.observe(root, { childList: true, subtree: true }); if (timeout) { setTimeout(() => { obs.disconnect(); resolve(null); }, timeout); } }); } function uniqKeepOrder(arr) { const s = new Set(); const out = []; for (const x of arr) if (!s.has(x)) { s.add(x); out.push(x); } return out; } // replace last mention token before caret, or last mention in string, or insert at caret function replaceLastMentionOrInsert(inputEl, username) { const text = inputEl.value; const caret = inputEl.selectionStart; const before = text.slice(0, caret); const after = text.slice(caret); // 1) if there's an '@' token immediately before caret, replace that const atMatch = before.match(/@([^\s@]*)$/); if (atMatch) { const start = before.lastIndexOf('@' + atMatch[1]); const newBefore = before.slice(0, start) + '@' + username + ' '; inputEl.value = newBefore + after; const newCaret = newBefore.length; inputEl.setSelectionRange(newCaret, newCaret); return; } // 2) otherwise, replace the last mention anywhere in the string const all = Array.from(text.matchAll(/@([^\s@]+)/g)); if (all.length > 0) { const last = all[all.length - 1]; const s = last.index; const e = s + last[0].length; const newText = text.slice(0, s) + '@' + username + ' ' + text.slice(e); inputEl.value = newText; const newCaret = s + ('@' + username + ' ').length; inputEl.setSelectionRange(newCaret, newCaret); return; } // 3) no mentions at all: insert at caret const newBefore = before + '@' + username + ' '; inputEl.value = newBefore + after; const newCaret = newBefore.length; inputEl.setSelectionRange(newCaret, newCaret); } function appendMentionToEnd(inputEl, username) { let v = inputEl.value; if (!/\s$/.test(v)) v = v + ' '; v = v + '@' + username + ' '; inputEl.value = v; inputEl.setSelectionRange(v.length, v.length); } // measure text width for dropdown sizing function measureTextWidth(text, font) { const ctx = measureTextWidth._ctx || (measureTextWidth._ctx = document.createElement('canvas').getContext('2d')); ctx.font = font || getComputedStyle(document.body).font || '13px Arial'; return ctx.measureText(text).width; } // ------------------------- // Styles (dropdown + gradient border) // ------------------------- const injectedCss = ` #tbd-mention-dropdown { position: absolute; background: #1e1e1e; color: #e6e6e6; border-radius: 8px; border: 1px solid rgba(255,255,255,0.04); box-shadow: 0 10px 30px rgba(0,0,0,0.6); font-size: 13px; max-height: 260px; overflow-y: auto; z-index: 2147483000; display: none; padding: 6px 0; white-space: nowrap; } #tbd-mention-dropdown .tbd-mention-item { padding: 6px 12px; cursor: pointer; user-select: none; border-radius: 6px; } #tbd-mention-dropdown .tbd-mention-item.tbd-active { background: linear-gradient(90deg, rgba(255,110,196,0.14), rgba(120,115,245,0.12)); color: #fff; } /* scrollbars */ #tbd-mention-dropdown::-webkit-scrollbar { width: 8px; } #tbd-mention-dropdown::-webkit-scrollbar-track { background: transparent; } #tbd-mention-dropdown::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 8px; } /* light mode fallback */ @media (prefers-color-scheme: light) { #tbd-mention-dropdown { background: #fff; color: #111; border: 1px solid rgba(0,0,0,0.08); } #tbd-mention-dropdown .tbd-mention-item.tbd-active { background: linear-gradient(90deg, rgba(255,110,196,0.14), rgba(120,115,245,0.06)); color: #111; } #tbd-mention-dropdown::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.08); } } /* gradient border around the .shout-user span on hover (works both themes) */ .tbd-user-border { position: relative !important; display: inline-block !important; border-radius: 6px !important; padding: 0 2px !important; } .tbd-user-border::before { content: ""; position: absolute; inset: -3px; border-radius: 8px; padding: 3px; background: linear-gradient(135deg, #ff6b6b, #f8e71c, #7ed321, #50e3c2, #4a90e2, #bd10e0); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none; opacity: 0.98; } `; const styleTag = document.createElement('style'); styleTag.textContent = injectedCss; document.head.appendChild(styleTag); // ------------------------- // Main logic // ------------------------- (async function main() { // wait for input and shout container const input = await waitFor('#shout_text', { timeout: 15000 }); const shoutsContainer = await waitFor('#shouts-container', { timeout: 15000 }); if (!input || !shoutsContainer) { // site layout not present yet return; } // create dropdown element const dropdown = document.createElement('div'); dropdown.id = 'tbd-mention-dropdown'; document.body.appendChild(dropdown); let dropdownOpen = false; let suggestions = []; // current list of usernames let activeIndex = -1; function getUsernamesFromDOM() { // collect .tbdrank elements in document order (shout newest at top typically) const els = Array.from(shoutsContainer.querySelectorAll('.tbdrank')); const names = els.map(el => { // get the visible username text (strip newlines/spaces) return (el.textContent || '').trim().replace(/\s+/g, ' '); }).filter(Boolean); return uniqKeepOrder(names); } // build dropdown items function buildDropdown(list, openFresh = false) { suggestions = list.slice(); // copy dropdown.innerHTML = ''; for (let i = 0; i < list.length; i++) { const item = document.createElement('div'); item.className = 'tbd-mention-item'; item.textContent = list[i]; // mousedown so we capture selection before blur item.addEventListener('mousedown', (ev) => { ev.preventDefault(); ev.stopPropagation(); selectSuggestion(i); }); // touch support item.addEventListener('touchstart', (ev) => { ev.preventDefault(); selectSuggestion(i); }, { passive: false }); dropdown.appendChild(item); } if (list.length === 0) { closeDropdown(); return; } // width auto-size to longest string const font = getComputedStyle(dropdown).font || '13px Arial'; const longest = list.reduce((m, s) => Math.max(m, measureTextWidth(s, font)), 0); const padding = 36; // left+right + internal const w = Math.round(Math.min(Math.max(longest + padding, 120), 420)); // clamp 120..420px dropdown.style.width = `${w}px`; // position dropdown under input (left aligned) const rect = input.getBoundingClientRect(); dropdown.style.left = (rect.left + window.scrollX) + 'px'; dropdown.style.top = (rect.bottom + 4 + window.scrollY) + 'px'; // if opening fresh, highlight first; if already open, preserve index if possible if (!dropdownOpen || openFresh) { activeIndex = 0; } else { if (activeIndex >= list.length) activeIndex = list.length - 1; if (activeIndex < 0) activeIndex = 0; } updateActiveItem(); dropdown.style.display = 'block'; dropdownOpen = true; } function updateActiveItem() { const items = Array.from(dropdown.querySelectorAll('.tbd-mention-item')); items.forEach((el, idx) => el.classList.toggle('tbd-active', idx === activeIndex)); // ensure visible const sel = items[activeIndex]; if (sel) sel.scrollIntoView({ block: 'nearest' }); } function closeDropdown() { dropdown.style.display = 'none'; dropdownOpen = false; suggestions = []; activeIndex = -1; } function selectSuggestion(index) { if (!suggestions || index < 0 || index >= suggestions.length) return; const name = suggestions[index]; // on normal selection, replace last mention token or last mention in entire input replaceLastMentionOrInsert(input, name); // hide dropdown closeDropdown(); input.focus(); } // Input handling: show dropdown when user types @ and there's a token let lastQuery = null; input.addEventListener('input', (ev) => { const caret = input.selectionStart; const before = input.value.slice(0, caret); const m = before.match(/@([^\s@]*)$/); // '@' followed by non-space, non-@ characters until caret if (!m) { closeDropdown(); lastQuery = null; return; } const q = m[1].toLowerCase(); // fetch usernames fresh from DOM const all = getUsernamesFromDOM(); // filter by partial substring match (case-insensitive) const filtered = all.filter(name => name.toLowerCase().includes(q)); // If dropdown already open and query didn't change list length or items, preserve selection const openFresh = (lastQuery === null || lastQuery !== q || !dropdownOpen); buildDropdown(filtered, openFresh); lastQuery = q; }); // Key navigation (keydown to prevent default Tab focus moves) input.addEventListener('keydown', (ev) => { if (!dropdownOpen) { // if user pressed '@' key, we let input event handle showing dropdown // but for safety if they press '@' (Shift+2) we schedule input handler if (ev.key === '@') { setTimeout(() => input.dispatchEvent(new Event('input', { bubbles: true })), 0); } return; } const items = dropdown.querySelectorAll('.tbd-mention-item'); if (items.length === 0) return; if (ev.key === 'ArrowDown') { ev.preventDefault(); ev.stopPropagation(); activeIndex = (activeIndex + 1) % items.length; updateActiveItem(); } else if (ev.key === 'ArrowUp') { ev.preventDefault(); ev.stopPropagation(); activeIndex = (activeIndex - 1 + items.length) % items.length; updateActiveItem(); } else if (ev.key === 'Tab') { ev.preventDefault(); ev.stopPropagation(); if (ev.shiftKey) activeIndex = (activeIndex - 1 + items.length) % items.length; else activeIndex = (activeIndex + 1) % items.length; updateActiveItem(); } else if (ev.key === 'Enter') { ev.preventDefault(); ev.stopPropagation(); selectSuggestion(activeIndex); } else if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); closeDropdown(); } }); // close dropdown on outside clicks (but allow clicking dropdown items - they use mousedown) document.addEventListener('click', (ev) => { if (dropdownOpen && !dropdown.contains(ev.target) && ev.target !== input) { closeDropdown(); } }); // ------------------------- // Hover border + click-to-mention on each shout-item // ------------------------- function bindMessage(msg) { if (!msg || msg.dataset.tbdBound === '1') return; msg.dataset.tbdBound = '1'; // find .shout-user span (the container that holds tbdrank) const userSpan = msg.querySelector('.shout-user'); const tbdrank = msg.querySelector('.tbdrank'); if (!userSpan || !tbdrank) return; // username text const username = (tbdrank.textContent || '').trim().replace(/\s+/g, ' '); if (!username) return; // hover: add gradient border class on userSpan msg.addEventListener('mouseenter', () => { userSpan.classList.add('tbd-user-border'); }); msg.addEventListener('mouseleave', () => { userSpan.classList.remove('tbd-user-border'); }); // clicking on message: left click -> insert (or replace last mention); ctrl/cmd + left click -> append msg.addEventListener('click', (ev) => { // Only handle clicks if inside #shoutbox-container const shoutboxContainer = document.getElementById('shoutbox-container'); if ( !shoutboxContainer || !msg.closest('#shoutbox-container') ) { return; } if (ev.button !== 0) return; // left click only // If clicking a link inside .shout-user or .shout-text, do nothing (let default happen) const isUserOrText = ev.target.closest('.shout-user, .shout-text'); const isLink = ev.target.closest('a'); if (isUserOrText && isLink) { // Let browser handle link navigation, do not insert mention return; } // If clicking on .shout-delete or .material-icons, do nothing (let default happen) if ( ev.target.closest('.shout-delete') || ev.target.closest('.material-icons') ) { return; } // if user clicked a link inside the message, prevent navigation so click acts as mention const a = ev.target.closest('a'); if (a) { ev.preventDefault(); } ev.stopPropagation(); const isMulti = ev.ctrlKey || ev.metaKey; if (isMulti) { appendMentionToEnd(input, username); } else { replaceLastMentionOrInsert(input, username); } input.focus(); closeDropdown(); }, true); } function bindAllMessagesNow() { const msgs = Array.from(shoutsContainer.querySelectorAll('.shout-item')); msgs.forEach(bindMessage); } // initial bind bindAllMessagesNow(); // observe shout container for new messages (and for removed ones) const mo = new MutationObserver(() => { // rebind new messages (existing ones are skipped because of dataset flag) bindAllMessagesNow(); // if dropdown is open, refresh suggestions from current DOM (no cache) if (dropdownOpen) { // recompute based on lastQuery (so if items removed from DOM they disappear) const caret = input.selectionStart; const before = input.value.slice(0, caret); const m = before.match(/@([^\s@]*)$/); const q = m ? m[1].toLowerCase() : null; if (q !== null) { const all = getUsernamesFromDOM(); const filtered = all.filter(name => name.toLowerCase().includes(q)); // rebuild preserving index (openFresh = false) buildDropdown(filtered, false); } else { closeDropdown(); } } }); mo.observe(shoutsContainer, { childList: true, subtree: true }); // adjust dropdown position on scroll/resize function repositionIfOpen() { if (!dropdownOpen) return; const rect = input.getBoundingClientRect(); dropdown.style.left = (rect.left + window.scrollX) + 'px'; dropdown.style.top = (rect.bottom + 4 + window.scrollY) + 'px'; } window.addEventListener('resize', repositionIfOpen); window.addEventListener('scroll', repositionIfOpen, true); // done })(); })();