TesterTV_Translit

Convert Latin text to Russian (custom mapping) with quick clipboard copy.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         TesterTV_Translit
// @namespace    https://greasyfork.org/ru/scripts/479202-testertv-translit-beta
// @version      2025.08.31
// @license      GNU GPLv3 or later
// @description  Convert Latin text to Russian (custom mapping) with quick clipboard copy.
// @author       TesterTV
// @match        *://*/*
// @grant        GM_setClipboard
// ==/UserScript==

(() => {
  // Do not run in iframes/embeds
  if (window.self !== window.top) return;

  // --------- CSS ---------
  const css = `
    #ttv-translit-wrap {
      position: fixed;
      top: 60px;
      right: 10px;
      z-index: 2147483647;
      font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Arial, Helvetica, sans-serif;
    }
    #ttv-translit-btn {
      background: none;
      border: none;
      font-size: 22px;
      cursor: pointer;
      line-height: 1;
      padding: 4px 6px;
      filter: drop-shadow(0 1px 1px rgba(0,0,0,.3));
    }
    #ttv-translit-panel {
      display: none;
      margin-top: 6px;
      background: #303236;
      border: 1px solid #666;
      border-radius: 6px;
      padding: 8px;
      box-shadow: 0 4px 16px rgba(0,0,0,.4);
      width: 400px;
    }
    #ttv-translit-textarea {
      width: 100%;
      height: 320px;
      box-sizing: border-box;
      background: #1f2023;
      color: #fff;
      border: 1px solid #555;
      border-radius: 4px;
      font-size: 16px;
      line-height: 1.4;
      outline: none;
      padding: 8px;
      resize: vertical;
    }
    #ttv-translit-meta {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-top: 6px;
      color: #aaa;
      font-size: 12px;
      user-select: none;
    }
    #ttv-translit-copyhint {
      opacity: .85;
    }
  `;
  const style = document.createElement('style');
  style.textContent = css;
  document.documentElement.appendChild(style);

  // --------- UI ---------
  const wrap = document.createElement('div');
  wrap.id = 'ttv-translit-wrap';

  const btn = document.createElement('button');
  btn.id = 'ttv-translit-btn';
  btn.title = 'Open translit (click). Paste Latin, get Cyrillic. Click outside to copy.';
  btn.textContent = '🇹';

  const panel = document.createElement('div');
  panel.id = 'ttv-translit-panel';

  const ta = document.createElement('textarea');
  ta.id = 'ttv-translit-textarea';
  ta.placeholder = 'Enter text here...';

  const meta = document.createElement('div');
  meta.id = 'ttv-translit-meta';

  const len = document.createElement('div');
  len.id = 'ttv-translit-length';
  len.textContent = 'length: 0';

  const hint = document.createElement('div');
  hint.id = 'ttv-translit-copyhint';
  hint.textContent = 'Click outside or press Esc to copy & close';

  meta.appendChild(len);
  meta.appendChild(hint);
  panel.appendChild(ta);
  panel.appendChild(meta);
  wrap.appendChild(btn);
  wrap.appendChild(panel);
  document.body.appendChild(wrap);

  // Prevent clicks inside from closing
  wrap.addEventListener('click', (e) => e.stopPropagation());

  // --------- Clipboard ---------
  function copyToClipboard(txt) {
    if (!txt || !txt.trim()) return;
    try { if (typeof GM_setClipboard === 'function') GM_setClipboard(txt); } catch {}
    if (navigator.clipboard && window.isSecureContext) {
      navigator.clipboard.writeText(txt).catch(() => {});
    }
  }

  // --------- Transliteration ---------
  // Base Latin→Cyrillic mapping (from your arrays, simplified)
  const baseMap = {
    a:'а', b:'б', c:'ц', d:'д', e:'е', f:'ф', g:'г', h:'х', i:'и', j:'й',
    k:'к', l:'л', m:'м', n:'н', o:'о', p:'п', q:'я', r:'р', s:'с', t:'т',
    u:'у', v:'в', w:'щ', x:'х', y:'ы', z:'з'
  };
  const reLatin = /[a-z]/gi;

  const hasUpper = (s) => s !== s.toLowerCase();

  // Case-aware helper
  const ca = (s, lowerChar) => hasUpper(s) ? lowerChar.toUpperCase() : lowerChar;

  // Multi-letter rules (applied after basic mapping)
  const pairMap = { 'зх':'ж', 'цх':'ч', 'сх':'ш', 'шх':'щ' };
  const seqRules = [
    // Order matters: longer tokens first
    { re: /##/g, fn: () => 'Ъ' },
    { re: /#/g,  fn: () => 'ъ' },
    { re: /''/g, fn: () => 'Ь' },
    { re: /'/g,  fn: () => 'ь' },

    { re: /(й|ы)о/gi, fn: (m) => ca(m, 'ё') },
    { re: /ö/gi,      fn: (m) => ca(m, 'ё') },

    { re: /йе/gi,     fn: (m) => ca(m, 'э') },
    { re: /ä/gi,      fn: (m) => ca(m, 'э') },

    { re: /(й|ы)у/gi, fn: (m) => ca(m, 'ю') },
    { re: /ü/gi,      fn: (m) => ca(m, 'ю') },

    { re: /(й|ы)а/gi, fn: (m) => ca(m, 'я') },

    { re: /č/gi,      fn: (m) => ca(m, 'ч') },
    { re: /ž/gi,      fn: (m) => ca(m, 'ж') },
    { re: /š/gi,      fn: (m) => ca(m, 'ш') },

    { re: /твз/gi,    fn: (m) => ca(m, 'ъ') },
    { re: /мйз/gi,    fn: (m) => ca(m, 'ь') }
  ];

  // Build pair rules for zh/ch/sh/shh patterns (зх/цх/сх/шх after basic map)
  for (const [inp, out] of Object.entries(pairMap)) {
    seqRules.push({
      re: new RegExp(inp, 'gi'),
      fn: (m) => ca(m, out)
    });
  }

  // Final custom fix (keep last to avoid undoing earlier rules)
  seqRules.push({
    re: /шод/gi,
    fn: (m) => ca(m, 'сход')
  });

  function translit(text) {
    if (!text) return '';
    // 1) Basic single-letter transliteration
    text = text.replace(reLatin, (ch) => {
      const lower = ch.toLowerCase();
      const mapped = baseMap[lower];
      if (!mapped) return ch;
      return ch === ch.toUpperCase() ? mapped.toUpperCase() : mapped;
    });

    // 2) Multi-letter, diacritics, special tokens
    for (const rule of seqRules) {
      text = text.replace(rule.re, rule.fn);
    }
    return text;
  }

  // --------- Behavior ---------
  function openPanel() {
    panel.style.display = 'block';
    ta.focus();
  }
  function closePanel(copy = true) {
    if (panel.style.display === 'none') return;
    if (copy && ta.value.trim()) copyToClipboard(ta.value);
    ta.value = '';
    len.textContent = 'length: 0';
    panel.style.display = 'none';
  }
  function togglePanel() {
    if (panel.style.display === 'none' || !panel.style.display) openPanel();
    else closePanel(false);
  }

  btn.addEventListener('click', togglePanel);
  // If you prefer the original mouseover open behavior, replace the above with:
  // btn.addEventListener('mouseover', openPanel);

  document.addEventListener('click', () => closePanel(true));
  document.addEventListener('keydown', (e) => {
    if (panel.style.display !== 'none' && e.key === 'Escape') {
      e.preventDefault();
      closePanel(true);
    }
  });

  ta.addEventListener('input', () => {
    const caretEnd = ta.selectionEnd; // optional: we can keep caret, but simple approach recalculates whole text
    const beforeLen = ta.value.length;
    ta.value = translit(ta.value);
    len.textContent = 'length: ' + ta.value.length;
    // Optional caret restore (best effort):
    const afterLen = ta.value.length;
    const delta = afterLen - beforeLen;
    try {
      ta.selectionStart = ta.selectionEnd = Math.max(0, caretEnd + delta);
    } catch {}
  });

  // Do not close when clicking inside the panel
  panel.addEventListener('click', (e) => e.stopPropagation());
})();