您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Colorises hint name mentions and highlights mentioned cards on hover (Shadow DOM + MutationObserver safe).
// ==UserScript== // @name Colours by Sam // @license MIT // @namespace https://cluesbysam.com/userscripts // @version 2025.08.11 // @description Colorises hint name mentions and highlights mentioned cards on hover (Shadow DOM + MutationObserver safe). // @author @whi-tw // @match https://cluesbysam.com/* // @match https://www.cluesbysam.com/* // @icon https://cluesbysam.com/images/favicon.ico // @grant none // @run-at document-start // @noframes // ==/UserScript== (function () { 'use strict'; let started = false; const state = { people: [], byName: new Map(), paletteByCoord: new Map() }; let cardClickListenerAttached = false; let domObserver; // ----- config ----- const PALETTE_20 = [ '#F3C300','#875692','#F38400','#A1CAF1','#BE0032', '#C2B280','#848482','#008856','#E68FAC','#0067A5', '#F99379','#604E97','#F6A600','#B3446C','#DCD300', '#882D17','#8DB600','#654522','#E25822','#2B3D26' ]; const COLOR_STRATEGY = 'coord'; // 'coord' | 'nameHash' // ---------- helpers ---------- const normalize = (s) => (s ?? '').toString().normalize('NFKC').trim().toLowerCase(); function tokenize(text) { const t = normalize(text).replace(/(['’]s)\b/g, ''); // helen's -> helen return t.split(/[^\p{L}\p{N}]+/u).filter(Boolean); } function nameTokens(name) { return normalize(name).split(/\s+/).filter(Boolean); } function containsNameTokens(hintTokens, targetNameTokens) { if (!hintTokens.length || !targetNameTokens.length) return false; for (let i = 0; i <= hintTokens.length - targetNameTokens.length; i++) { let ok = true; for (let j = 0; j < targetNameTokens.length; j++) { if (hintTokens[i + j] !== targetNameTokens[j]) { ok = false; break; } } if (ok) return true; } return false; } function djb2(str) { let h = 5381; for (let i=0;i<str.length;i++) h=((h<<5)+h)^str.charCodeAt(i); return h>>>0; } function parseCoord(coord) { const m = String(coord).trim().match(/^([A-Za-z]+)\s*(\d+)$/); if (!m) return { colLabel: '', rowNumber: NaN }; return { colLabel: m[1].toUpperCase(), rowNumber: parseInt(m[2], 10) }; } function whenCardsReady(cb) { if (document.querySelector('.card')) return cb(); const mo = new MutationObserver(() => { if (document.querySelector('.card')) { mo.disconnect(); cb(); } }); mo.observe(document.documentElement, { childList: true, subtree: true }); } // ---------- extraction & analysis ---------- function extractPeople() { const cards = Array.from(document.querySelectorAll('.card')); return cards.map(card => { const name = normalize(card.querySelector('.name')?.textContent ?? ''); const profession = normalize(card.querySelector('.profession')?.textContent ?? ''); const coord = (card.querySelector('p.coord')?.textContent ?? '').trim(); const flipped = card.classList.contains('flipped'); const hintEl = card.querySelector('p.hint'); const hint = flipped ? (hintEl?.textContent ?? '').trim() : ''; return { el: card, name, profession, coord, hint, hintEl, flipped, mentions: [], color: '' }; }); } function buildNameIndex(people) { const m = new Map(); for (const p of people) if (p.name) m.set(p.name, p); return m; } function annotateMentions(people) { const all = people.map(p => ({ name: p.name, tokens: nameTokens(p.name) })); for (const p of people) { p.mentions = []; if (!p.hint) continue; const htoks = tokenize(p.hint); for (const other of all) if (other.name && containsNameTokens(htoks, other.tokens)) p.mentions.push(other.name); p.mentions = [...new Set(p.mentions)]; } } // ---------- contrast helpers ---------- function hexToRgb(hex){ let h=hex.replace('#',''); if(h.length===3) h=[...h].map(c=>c+c).join(''); return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16),1]; } function parseCssColor(s){ if(!s) return null; s = s.trim().toLowerCase(); if (s === 'transparent') return [0,0,0,0]; if (s.startsWith('#')) return hexToRgb(s); const m = s.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i); if (m) return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] == null ? 1 : Number(m[4])]; return null; } function isTransparentRGBA(rgba){ return !rgba || rgba[3] === 0; } function srgbToLin(c){ c/=255; return c<=0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); } function luminance([r,g,b]){ return 0.2126*srgbToLin(r)+0.7152*srgbToLin(g)+0.0722*srgbToLin(b); } function contrastRatio(fg,bg){ const L1 = luminance(fg), L2 = luminance(bg); const [mx, mn] = [Math.max(L1,L2), Math.min(L1,L2)]; return (mx + 0.05) / (mn + 0.05); } function mixRgb(a,b,t){ return [0,1,2].map(i => Math.round(a[i]*(1-t)+b[i]*t)).concat(1); } function rgbToHex([r,g,b]){ return '#'+[r,g,b].map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase(); } // climb DOM (and out of shadow) to find a non-transparent background function getEffectiveBgColor(el){ let n = el; while (n) { const cs = getComputedStyle(n); const bg = parseCssColor(cs.backgroundColor); if (!isTransparentRGBA(bg)) return bg; // step up (handles shadow hosts too) const root = n.getRootNode && n.getRootNode(); n = n.parentElement || (root && root.host) || null; } return parseCssColor(getComputedStyle(document.body).backgroundColor) || [255,255,255,1]; } // nudge foreground towards white/black until reaching target contrast function adjustForContrast(fgHex, bgCssOrRgb, target=4.5){ const bg = Array.isArray(bgCssOrRgb) ? bgCssOrRgb : (parseCssColor(bgCssOrRgb) || [0,0,0,1]); let fg = hexToRgb(fgHex); if (contrastRatio(fg, bg) >= target) return fgHex; const lighten = luminance(bg) < 0.5; const pole = lighten ? [255,255,255,1] : [0,0,0,1]; let lo = 0, hi = 1, best = fg, bestR = contrastRatio(fg,bg); for (let i=0;i<22;i++){ const t = (lo+hi)/2; const cand = mixRgb(fg, pole, t); const r = contrastRatio(cand, bg); if (r >= target){ best = cand; bestR = r; hi = t; } else { lo = t; } } // if still short, pick black/white with max contrast if (bestR < target){ const cw = contrastRatio([255,255,255,1], bg); const cb = contrastRatio([0,0,0,1], bg); best = cw > cb ? [255,255,255,1] : [0,0,0,1]; } return rgbToHex(best); } // ----- highlight helpers ----- const highlighted = new Set(); let currentHoverCard = null; function indexByElement(people) { const wm = new WeakMap(); for (const p of people) wm.set(p.el, p); return wm; } function clearHighlights() { for (const el of highlighted) { el.style.borderColor = ''; el.style.boxShadow = ''; } highlighted.clear(); } function highlightMentionsFor(person) { clearHighlights(); if (!person || !person.hintEl || !person.hint || !person.mentions?.length) return; const hostBg = getEffectiveBgColor(person.hintEl); for (const name of person.mentions) { const target = state.byName.get(name); if (!target) continue; const el = target.el; const base = target.color; const adjusted = adjustForContrast(base, hostBg, 4.5); // Keep border width unchanged (no reflow); just recolor it el.style.borderColor = adjusted; // Add a non-layout ring; preserve the site's drop shadow el.style.boxShadow = `0 0 0 2px ${adjusted}, 0 1px 6px var(--shadow-color-secondary)`; highlighted.add(el); } } // ----- color assignment ----- function computeGridOrdering(people) { const cols = new Set(), rows = new Set(); for (const p of people) { const { colLabel, rowNumber } = parseCoord(p.coord); if (colLabel) cols.add(colLabel); if (!Number.isNaN(rowNumber)) rows.add(rowNumber); } const colsArr = [...cols].sort((a, b) => a.localeCompare(b)); const rowsArr = [...rows].sort((a, b) => a - b); return { colsArr, rowsArr }; } function assignColorsByCoord(people) { const { colsArr, rowsArr } = computeGridOrdering(people); const gridSlots = colsArr.length * rowsArr.length; if (PALETTE_20.length < gridSlots) return assignColorsByNameHash(people); state.paletteByCoord.clear(); for (const p of people) { const { colLabel, rowNumber } = parseCoord(p.coord); const ci = colsArr.indexOf(colLabel); const ri = rowsArr.indexOf(rowNumber); if (ci === -1 || ri === -1) continue; const paletteIndex = ci * rowsArr.length + ri; // column-major const color = PALETTE_20[paletteIndex % PALETTE_20.length]; state.paletteByCoord.set(p.coord, color); p.color = color; p.el.setAttribute('data-person-color', color); } } function assignColorsByNameHash(people) { const used = new Set(); for (const p of people) { const idx = djb2(p.name || p.coord) % PALETTE_20.length; let k = idx, steps = 0; while (used.has(k) && steps < PALETTE_20.length) { k = (k + 7) % PALETTE_20.length; steps++; } used.add(k); p.color = PALETTE_20[k]; p.el.setAttribute('data-person-color', p.color); } } function assignColors(people) { if (COLOR_STRATEGY === 'coord') assignColorsByCoord(people); else assignColorsByNameHash(people); } // Render colored names into a Shadow DOM so the app can't clobber what the user sees function paintHintMentions(people) { const names = people.map(p => p.name).filter(Boolean).sort((a, b) => b.length - a.length); if (!names.length) return; const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+'); const alt = names.map(esc).join('|'); const re = new RegExp(`(^|[^\\p{L}\\p{N}])(${alt})(['’]s)?(?=$|[^\\p{L}\\p{N}])`, 'giu'); for (const p of people) { if (!p.hintEl || !p.hint) continue; // Get or create a shadow root on the hint element const host = p.hintEl; const hostBg = getEffectiveBgColor(host); host.style.cursor = 'pointer'; // make the whole hint show a hand const lightText = (host.textContent || '').trim(); // If the element was replaced, we need to rebuild; the data attrs would be gone let sr = host.shadowRoot; if (!sr) sr = host.attachShadow({ mode: 'open' }); // Skip if already painted for this exact text if (host.dataset._decoratedFor === lightText) continue; // Build shadow content fresh from the light DOM text const frag = document.createDocumentFragment(); // Scoped styles inside the shadow root; site CSS can’t undo this const style = document.createElement('style'); style.textContent = ` :host { all: initial; display: block; white-space: pre-wrap; font: inherit; color: inherit; } .hint-name { font-weight: 600; } `; frag.appendChild(style); let lastIndex = 0; re.lastIndex = 0; let m; while ((m = re.exec(lightText)) !== null) { const [ , prefix, nameMatch, poss ] = m; const matchStart = m.index; const matchEnd = re.lastIndex; if (matchStart > lastIndex) frag.appendChild(document.createTextNode(lightText.slice(lastIndex, matchStart))); if (prefix) frag.appendChild(document.createTextNode(prefix)); const normalized = normalize(nameMatch).replace(/\s+/g, ' '); const person = state.byName.get(normalized); const color = person?.color || '#000'; const span = document.createElement('span'); span.className = 'hint-name'; span.textContent = nameMatch; const adjusted = adjustForContrast(color, hostBg, 4.5); span.style.color = adjusted; // optional extra safety for noisy backgrounds: span.style.textShadow = '0 1px 1px rgba(0,0,0,.6), 0 0 2px rgba(0,0,0,.4)'; frag.appendChild(span); if (poss) frag.appendChild(document.createTextNode(poss)); lastIndex = matchEnd; } if (lastIndex < lightText.length) frag.appendChild(document.createTextNode(lightText.slice(lastIndex))); // Swap visible content sr.replaceChildren(frag); host.dataset._decoratedFor = lightText; // idempotence key } } function debugPrint(people) { for (const p of people) { console.log(`${p.coord}: ${p.name} (${p.profession}) - ${p.hint}`); const mentions = p.hint ? (p.mentions.length ? p.mentions.join(', ') : '(none)') : '(no hint)'; console.log(` mentions: ${mentions} | color: ${p.color}`); } } function refreshState({ log = true } = {}) { state.people = extractPeople(); state.byName = buildNameIndex(state.people); state.byEl = indexByElement(state.people); annotateMentions(state.people); assignColors(state.people); paintHintMentions(state.people); if (log) debugPrint(state.people); } // ---------- observers ---------- function startDomObserver() { if (domObserver) return; let scheduled = false; const schedule = () => { if (scheduled) return; scheduled = true; // Let the app finish its render tick, then repaint once requestAnimationFrame(() => { scheduled = false; refreshState({ log: false }); }); }; domObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.type === 'characterData') { if (m.target.parentElement?.matches?.('p.hint')) { schedule(); break; } } else if (m.type === 'childList') { // New/changed cards or hints const added = [...m.addedNodes].some(n => n.nodeType === 1 && (n.matches?.('.card, p.hint') || n.querySelector?.('.card, p.hint')) ); const removed = [...m.removedNodes].some(n => n.nodeType === 1 && (n.matches?.('.card, p.hint') || n.querySelector?.('.card, p.hint')) ); if (added || removed || m.target?.matches?.('.card, p.hint')) { schedule(); break; } } } }); domObserver.observe(document.documentElement, { subtree: true, childList: true, characterData: true }); } // ---------- lifecycle ---------- let hoverListenerAttached = false; function main() { if (started) return; started = true; whenCardsReady(() => { refreshState({ log: true }); startDomObserver(); }); if (!cardClickListenerAttached) { document.addEventListener('click', (e) => { const card = e.target.closest('.card'); if (!card) return; queueMicrotask(() => refreshState({ log: true })); }, true); cardClickListenerAttached = true; } // --- hover highlighting --- if (!hoverListenerAttached) { document.addEventListener('pointerover', (e) => { const card = e.target.closest('.card'); if (!card || card === currentHoverCard) return; currentHoverCard = card; const person = (state.byEl && state.byEl.get(card)) || state.people.find(p => p.el === card); if (person && person.hint) { highlightMentionsFor(person); } else { clearHighlights(); } }, true); document.addEventListener('pointerout', (e) => { if (!currentHoverCard) return; // Ignore pointerout events that stay within the current card const toEl = e.relatedTarget; if (toEl && currentHoverCard.contains(toEl)) return; // Only clear when actually leaving the hovered card const fromCard = e.target.closest && e.target.closest('.card'); if (fromCard && fromCard === currentHoverCard) { currentHoverCard = null; clearHighlights(); } }, true); hoverListenerAttached = true; } const style = document.createElement('style'); style.textContent = `.card{ transition: border-color .12s ease, border-width .12s ease, box-shadow .12s ease; }`; document.documentElement.appendChild(style); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', main, { once: true }); } else { main(); } })();