TesterTV_Translit

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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());
})();