LingQ-esque Word Highlighting Vocab Tracker for ttsu.app

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 }); };
})();