TesterTV_Translit

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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