您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Track known words with a red→green scale; Alt+Right-Click to set level; page-aware regex; batched highlights; dark-mode contrast.
// ==UserScript== // @name LingQ-esque Word Highlighting Vocab Tracker for ttsu.app // @namespace tek // @version 1.3.0 // @license MIT // @description Track known words with a red→green scale; Alt+Right-Click to set level; page-aware regex; batched highlights; dark-mode contrast. // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @match *://ttsu.app/* // @match *://*.ttsu.app/* // @match *://reader.ttsu.app/* // ==/UserScript== (() => { "use strict"; /*** ---------- CONFIG ---------- ***/ const ONLY_IF_HOST_CONTAINS_TTSU = false; const TTSU_HOST_SNIPPETS = ["ttsu.app", "/ttsu", "reader.ttsu"]; // Page-aware: build regex only from words that actually appear on the page. const PAGE_AWARE = true; const MAX_NGRAM = 6; // for CJK runs, collect 1..MAX_NGRAM tokens // Palette trigger: false = Alt+Right-Click (keeps native menu). true = intercept all right-clicks. const ALWAYS_INTERCEPT_RIGHT_CLICK = true; // Scope work to a known content root (big speedup). Leave "" to use document.body. const CONTENT_SCOPE_SELECTOR = ""; // e.g. "#root main", ".reader-content" // Periodic “belt & suspenders” refresher for infinite-scroll / lazy hydration. // const ENABLE_PERIODIC_RECALC = false; // const PERIODIC_INTERVAL_MS = 2000; // how often to check for new text nodes // Knowledge levels → colors (red → green) const LEVELS = [ { id: 0, name: "Unknown", color: "#ffd9d9" }, { id: 1, name: "Familiar", color: "#ffcaa6" }, { id: 2, name: "Seen", color: "#fff3a6" }, { id: 3, name: "Learning", color: "#e0f7a6" }, { id: 4, name: "Comfortable", color: "#c7f0b7" }, { id: 5, name: "Known", color: "#b3e8ad" }, ]; /*** ---------- STYLES ---------- ***/ const HIGHLIGHT_CSS = ` .vm-lingq { border-radius: .25em; padding: .05em .15em; box-decoration-break: clone; -webkit-box-decoration-break: clone; transition: box-shadow 120ms; } .vm-lingq:hover { box-shadow: inset 0 0 0 2px rgba(0,0,0,.15); cursor: default; } .vm-palette { position: fixed; z-index: 2147483647; background: #fff; border: 1px solid rgba(0,0,0,.12); border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,.18); padding: 8px; min-width: 240px; font: 13px/1.4 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"Apple Color Emoji","Segoe UI Emoji"; user-select: none; } .vm-palette h4 { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: #444; } .vm-palette .vm-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; } .vm-level { display: inline-flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; border: 1px solid rgba(0,0,0,.08); background: #fafafa; cursor: pointer; flex: 1 1 48%; min-width: 98px; transition: transform .06s, background .12s, border .12s; } .vm-level:hover { transform: translateY(-1px); background: #f6f6f6; } .vm-swatch { width: 18px; height: 18px; border-radius: 4px; border: 1px solid rgba(0,0,0,.15); box-shadow: inset 0 0 0 1px rgba(255,255,255,.4); } .vm-actions { display: flex; gap: 8px; justify-content: space-between; } .vm-btn { flex: 1; text-align: center; padding: 6px 8px; border-radius: 8px; border: 1px solid rgba(0,0,0,.12); background: #fff; cursor: pointer; } .vm-btn:hover { background: #f7f7f7; } .vm-btn.vm-danger { color: #b20000; border-color: rgba(178,0,0,.25); } .vm-hidden { display: none !important; } .vm-palette { max-width: 92vw; max-height: 80vh; overflow: auto; } `; /*** ---------- EARLY EXIT (optional host filter) ---------- ***/ if (ONLY_IF_HOST_CONTAINS_TTSU) { const here = location.hostname + location.pathname; const ok = TTSU_HOST_SNIPPETS.some(s => here.includes(s)); if (!ok) return; } /*** ---------- STORAGE ---------- ***/ const STORE_KEY = "vm_lingq_words_v1"; function loadStore() { try { const raw = (typeof GM_getValue === "function") ? GM_getValue(STORE_KEY, "{}") : localStorage.getItem(STORE_KEY) || "{}"; const parsed = JSON.parse(raw); return (parsed && typeof parsed === "object") ? parsed : {}; } catch { return {}; } } function saveStore(obj) { const s = JSON.stringify(obj); if (typeof GM_setValue === "function") GM_setValue(STORE_KEY, s); else localStorage.setItem(STORE_KEY, s); } /*** ---------- UTIL ---------- ***/ const toKey = (s) => (s || "").trim().toLocaleLowerCase(); const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const isLatinish = (w) => /[A-Za-z\u00C0-\u024F]/.test(w); const scopeRoot = () => (CONTENT_SCOPE_SELECTOR && document.querySelector(CONTENT_SCOPE_SELECTOR)) || document.body; // Idle API (polyfill) const ric = window.requestIdleCallback || function (cb, {timeout} = {}) { const id = setTimeout(() => cb({ didTimeout: !!timeout, timeRemaining: () => 0 }), timeout || 50); return id; }; const cic = window.cancelIdleCallback || clearTimeout; // Contrast helpers: choose black/white text for the highlight color const hexToRgb = (h) => { const s = h.replace('#',''); const v = s.length === 3 ? s.split('').map(c=>c+c).join('') : s; const n = parseInt(v,16); return { r:(n>>16)&255, g:(n>>8)&255, b:n&255 }; }; const relLum = ({r,g,b}) => { const f = (u)=>{ u/=255; return u<=0.03928 ? u/12.92 : Math.pow((u+0.055)/1.055,2.4); }; const R=f(r), G=f(g), B=f(b); return 0.2126*R + 0.7152*G + 0.0722*B; }; const textOn = (hex) => (relLum(hexToRgb(hex)) > 0.55 ? "#111" : "#fff"); /*** ---------- TEXT NODE WALKER ---------- ***/ function walker(root, fnTextNode) { const reject = new Set(["SCRIPT","STYLE","NOSCRIPT","TEXTAREA","INPUT","SELECT","CODE","PRE"]); const tw = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { const p = node.parentElement; if (!p) return NodeFilter.FILTER_REJECT; if (reject.has(p.tagName)) return NodeFilter.FILTER_REJECT; if (p.closest(".vm-lingq, .vm-palette")) return NodeFilter.FILTER_REJECT; if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); const nodes = []; while (tw.nextNode()) nodes.push(tw.currentNode); for (const tn of nodes) fnTextNode(tn); } /*** ---------- PAGE-AWARE CANDIDATES ---------- ***/ function collectCandidates(root) { const seen = new Set(); walker(root, (tn) => { const s = tn.nodeValue; // Latin-ish tokens (letters + marks/digits + inner dash/apostrophe) for (const m of s.matchAll(/\p{L}[\p{L}\p{M}\p{N}'’-]*/gu)) { seen.add(m[0].toLocaleLowerCase()); } // CJK runs → n-grams (1..MAX_NGRAM) const runs = s.match( /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\u30FC\u309D\u309E\u30FD\u30FE\u3005]+/gu ); if (runs) for (const r of runs) { for (let i = 0; i < r.length; i++) { for (let L = 1; L <= MAX_NGRAM && i + L <= r.length; L++) { seen.add(r.slice(i, i + L)); } } } }); return seen; } /*** ---------- REGEX BUILDER ---------- ***/ function buildRegex(dictObj, candidates = null) { let words = Object.keys(dictObj); if (candidates) words = words.filter(w => candidates.has(w)); if (!words.length) return null; // Prefer longer matches first words.sort((a, b) => b.length - a.length); const latin = [], other = []; for (const w of words) { const esc = escapeRegExp(w); (isLatinish(w) ? latin : other).push(esc); } const parts = []; if (latin.length) parts.push(`(?<![\\p{L}\\p{N}])(?:${latin.join("|")})(?![\\p{L}\\p{N}])`); if (other.length) parts.push(`(?:${other.join("|")})`); return new RegExp(parts.join("|"), "giu"); } /*** ---------- HIGHLIGHTING CORE ---------- ***/ let dict = loadStore(); let compiled = null; const COLORS = Object.fromEntries(LEVELS.map(l => [String(l.id), l.color])); function spanFor(normalized, shownText) { const level = String(dict[normalized]); const span = document.createElement("span"); span.className = "vm-lingq"; span.dataset.word = normalized; span.dataset.level = level; span.textContent = shownText; const bg = COLORS[level] || "#e0e0e0"; span.style.backgroundColor = bg; span.style.color = textOn(bg); // auto black/white for contrast span.style.boxShadow = "inset 0 0 0 1px rgba(0,0,0,.06)"; return span; } function clearAllHighlights(root = scopeRoot()) { const spans = root.querySelectorAll(".vm-lingq"); for (const s of spans) s.replaceWith(document.createTextNode(s.textContent || "")); } function highlightIn(root) { if (!compiled) return; walker(root, (tn) => { const text = tn.nodeValue; compiled.lastIndex = 0; let m, last = 0; const frag = document.createDocumentFragment(); while ((m = compiled.exec(text)) !== null) { const start = m.index, end = start + m[0].length; if (start > last) frag.appendChild(document.createTextNode(text.slice(last, start))); const shown = text.slice(start, end); const normalized = toKey(shown); if (dict[normalized] !== undefined) frag.appendChild(spanFor(normalized, shown)); else frag.appendChild(document.createTextNode(shown)); last = end; } if (last === 0) return; // no changes if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); tn.parentNode.replaceChild(frag, tn); }); } /*** ---------- RE/COMPILE ---------- ***/ function recalcCompiled() { const root = scopeRoot(); const candidates = PAGE_AWARE ? collectCandidates(root) : null; compiled = buildRegex(dict, candidates); } const rehighlightAll = (() => { let h = null; return () => { if (h) { cic(h); h = null; } h = ric(() => { withObserverPaused(() => { clearAllHighlights(); recalcCompiled(); if (compiled) highlightIn(scopeRoot()); }); h = null; }, { timeout: 500 }); }; })(); /*** ---------- OBSERVER + BATCHING ---------- ***/ let observerPaused = false; const observeTarget = document.documentElement; const observeOpts = { childList: true, subtree: true }; let mo; // set below function pauseObserver() { if (!observerPaused) { mo.disconnect(); observerPaused = true; } } function resumeObserver() { if (observerPaused) { mo.observe(observeTarget, observeOpts); observerPaused = false; } } function withObserverPaused(fn) { pauseObserver(); try { fn(); } finally { resumeObserver(); } } const pending = new Set(); let idleHandle = null; let addedTextHint = 0; // increments when texty nodes are observed (used by periodic refresher) function scheduleNode(n) { if (!n) return; const target = (n.nodeType === Node.TEXT_NODE) ? (n.parentNode || scopeRoot()) : n; if (target && target.closest && target.closest(".vm-palette")) return; pending.add(target); addedTextHint++; // hint that new text appeared pump(); } function pump() { if (idleHandle) return; idleHandle = ric(() => { const nodes = Array.from(pending); pending.clear(); // For big bursts, refresh candidates first so highlights are accurate if (PAGE_AWARE && nodes.length > 50) recalcCompiled(); withObserverPaused(() => { if (!compiled) recalcCompiled(); if (!compiled) return; // If a lot changed, just scan the scoped root once. if (nodes.length > 50) highlightIn(scopeRoot()); else for (const n of nodes) highlightIn(n); }); idleHandle = null; }, { timeout: 300 }); } mo = new MutationObserver((muts) => { if (!compiled && !PAGE_AWARE) return; // if page-aware, we still want to collect candidates let count = 0; for (const m of muts) { for (const n of m.addedNodes) { if (n.nodeType !== 1 && n.nodeType !== 3) continue; scheduleNode(n); count++; if (count > 200) { pending.clear(); scheduleNode(scopeRoot()); return; } } } }); mo.observe(observeTarget, observeOpts); /*** ---------- PERIODIC REFRESHER (belt & suspenders) ---------- ***/ // if (ENABLE_PERIODIC_RECALC) { // let lastHint = 0; // setInterval(() => { // if (!PAGE_AWARE) return; // only useful in page-aware mode // if (addedTextHint === lastHint) return; // no new text since last check // lastHint = addedTextHint; // // Lightweight refresh: update candidates and pass once over the scope. // withObserverPaused(() => { // recalcCompiled(); // if (compiled) highlightIn(scopeRoot()); // }); // }, PERIODIC_INTERVAL_MS); // } /*** ---------- PALETTE UI ---------- ***/ if (typeof GM_addStyle === "function") GM_addStyle(HIGHLIGHT_CSS); else { const s = document.createElement("style"); s.textContent = HIGHLIGHT_CSS; document.head.appendChild(s); } const palette = (() => { const el = document.createElement("div"); el.className = "vm-palette vm-hidden"; el.innerHTML = ` <h4>Mark word</h4> <div class="vm-row vm-levels"></div> <div class="vm-actions"> <div class="vm-btn vm-remove">Remove word</div> <div class="vm-btn vm-cancel">Cancel</div> </div>`; document.documentElement.appendChild(el); const levelsWrap = el.querySelector(".vm-levels"); LEVELS.forEach(l => { const item = document.createElement("div"); item.className = "vm-level"; item.dataset.level = String(l.id); item.innerHTML = `<span class="vm-swatch" style="background:${l.color}"></span><span>${l.id} — ${l.name}</span>`; levelsWrap.appendChild(item); }); return el; })(); let lastAnchor = { x: 20, y: 20 }; let repositionHandlersAttached = false; function positionPalette(x, y) { const pad = 8; const vw = window.innerWidth, vh = window.innerHeight; // measure AFTER making it renderable palette.style.visibility = "hidden"; palette.classList.remove("vm-hidden"); const rect = palette.getBoundingClientRect(); let left = x, top = y; // clamp right edge if (left + rect.width + pad > vw) left = Math.max(pad, vw - rect.width - pad); // flip above if not enough space below if (top + rect.height + pad > vh) top = Math.max(pad, y - rect.height - pad); // final clamps left = Math.max(pad, left); top = Math.max(pad, top); palette.style.left = left + "px"; palette.style.top = top + "px"; palette.style.visibility = ""; // show } function showPalette(x, y, displayWord) { lastAnchor = { x, y }; palette.querySelector("h4").textContent = `Mark “${displayWord}”`; positionPalette(x, y); if (!repositionHandlersAttached) { const onReflow = () => positionPalette(lastAnchor.x, lastAnchor.y); window.addEventListener("resize", onReflow); window.addEventListener("scroll", onReflow, true); // capture scrolls in containers palette._onReflow = onReflow; repositionHandlersAttached = true; } } function hidePalette() { palette.classList.add("vm-hidden"); if (repositionHandlersAttached) { window.removeEventListener("resize", palette._onReflow); window.removeEventListener("scroll", palette._onReflow, true); repositionHandlersAttached = false; delete palette._onReflow; } } let pendingWord = null; palette.addEventListener("click", (e) => { const t = e.target.closest(".vm-level, .vm-remove, .vm-cancel"); if (!t) return; if (t.classList.contains("vm-cancel")) { hidePalette(); pendingWord = null; return; } if (!pendingWord) { hidePalette(); return; } if (t.classList.contains("vm-remove")) { if (dict[pendingWord] !== undefined) { delete dict[pendingWord]; saveStore(dict); rehighlightAll(); } hidePalette(); pendingWord = null; return; } if (t.classList.contains("vm-level")) { dict[pendingWord] = parseInt(t.dataset.level, 10); saveStore(dict); rehighlightAll(); hidePalette(); pendingWord = null; } }); document.addEventListener("mousedown", (e) => { if (!palette.classList.contains("vm-hidden") && !palette.contains(e.target)) hidePalette(); }, true); document.addEventListener("keydown", (e) => { if (e.key === "Escape") hidePalette(); }); function getWordFromPoint(clientX, clientY) { const sel = window.getSelection(); const selected = sel && sel.toString().trim(); if (selected) return selected; let range = null; if (document.caretRangeFromPoint) range = document.caretRangeFromPoint(clientX, clientY); else if (document.caretPositionFromPoint) { const pos = document.caretPositionFromPoint(clientX, clientY); if (pos) { range = document.createRange(); range.setStart(pos.offsetNode, pos.offset); range.setEnd(pos.offsetNode, pos.offset); } } if (!range) return null; const node = range.startContainer; if (!node || node.nodeType !== Node.TEXT_NODE) return null; const text = node.nodeValue; const i = range.startOffset; const isWordChar = (ch) => /\p{L}|\p{N}|\p{M}|[-'’]/u.test(ch); let a = i, b = i; while (a > 0 && isWordChar(text[a - 1])) a--; while (b < text.length && isWordChar(text[b])) b++; const candidate = text.slice(a, b).trim(); return candidate || null; } document.addEventListener("contextmenu", (e) => { if (!ALWAYS_INTERCEPT_RIGHT_CLICK && !e.altKey) return; const w = getWordFromPoint(e.clientX, e.clientY); if (!w) return; e.preventDefault(); pendingWord = toKey(w); showPalette(e.clientX, e.clientY, w); }, true); /*** ---------- INIT ---------- ***/ // First pass: compile page-aware regex and highlight recalcCompiled(); if (compiled) withObserverPaused(() => highlightIn(scopeRoot())); // SPA nav: debounce rehighlight on route changes window.addEventListener("popstate", () => ric(rehighlightAll, { timeout: 200 })); const _pushState = history.pushState; history.pushState = function () { _pushState.apply(this, arguments); ric(rehighlightAll, { timeout: 200 }); }; })();