| <!DOCTYPE html> |
| <html lang="ru"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Заменитель буков</title> |
| <style> |
| :root { |
| --bg-primary: #0d1117; |
| --bg-secondary: #161b22; |
| --bg-tertiary: #21262d; |
| --border: #30363d; |
| --text-primary: #e6edf3; |
| --text-secondary: #8b949e; |
| --accent: #58a6ff; |
| --accent-hover: #79b8ff; |
| --accent-glow: rgba(88, 166, 255, 0.15); |
| --success: #3fb950; |
| --toggle-bg: #30363d; |
| --toggle-active: #58a6ff; |
| --radius: 12px; |
| --transition: 0.25s ease; |
| } |
| |
| *, *::before, *::after { |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| } |
| |
| body { |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, |
| 'Helvetica Neue', Arial, sans-serif; |
| background: var(--bg-primary); |
| color: var(--text-primary); |
| min-height: 100vh; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: 20px; |
| } |
| |
| .container { |
| width: 100%; |
| max-width: 700px; |
| display: flex; |
| flex-direction: column; |
| gap: 20px; |
| } |
| |
| .header { |
| text-align: center; |
| } |
| |
| .header h1 { |
| font-size: 1.5rem; |
| font-weight: 600; |
| letter-spacing: -0.02em; |
| color: var(--text-primary); |
| } |
| |
| .header p { |
| font-size: 0.85rem; |
| color: var(--text-secondary); |
| margin-top: 6px; |
| } |
| |
| .controls { |
| display: flex; |
| justify-content: center; |
| gap: 32px; |
| flex-wrap: wrap; |
| } |
| |
| .control-group { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| |
| .control-label { |
| font-size: 0.8rem; |
| color: var(--text-secondary); |
| text-transform: uppercase; |
| letter-spacing: 0.05em; |
| font-weight: 500; |
| } |
| |
| .toggle { |
| position: relative; |
| display: flex; |
| background: var(--toggle-bg); |
| border-radius: 8px; |
| padding: 3px; |
| gap: 2px; |
| } |
| |
| .toggle input[type="radio"] { |
| position: absolute; |
| opacity: 0; |
| pointer-events: none; |
| } |
| |
| .toggle label { |
| padding: 6px 14px; |
| font-size: 0.8rem; |
| font-weight: 500; |
| color: var(--text-secondary); |
| border-radius: 6px; |
| cursor: pointer; |
| transition: all var(--transition); |
| user-select: none; |
| white-space: nowrap; |
| } |
| |
| .toggle input[type="radio"]:checked + label { |
| background: var(--accent); |
| color: #fff; |
| box-shadow: 0 2px 8px rgba(88, 166, 255, 0.3); |
| } |
| |
| .toggle label:hover { |
| color: var(--text-primary); |
| } |
| |
| .editor { |
| position: relative; |
| } |
| |
| #text { |
| width: 100%; |
| min-height: 280px; |
| height: calc(100vh - 300px); |
| padding: 18px 20px; |
| font-family: 'Consolas', 'Monaco', 'Courier New', monospace; |
| font-size: 0.95rem; |
| line-height: 1.6; |
| color: var(--text-primary); |
| background: var(--bg-secondary); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| resize: vertical; |
| outline: none; |
| transition: border-color var(--transition), box-shadow var(--transition); |
| } |
| |
| #text::placeholder { |
| color: var(--text-secondary); |
| opacity: 0.6; |
| } |
| |
| #text:focus { |
| border-color: var(--accent); |
| box-shadow: 0 0 0 3px var(--accent-glow); |
| } |
| |
| .editor-footer { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-top: 8px; |
| padding: 0 4px; |
| } |
| |
| .char-count { |
| font-size: 0.75rem; |
| color: var(--text-secondary); |
| font-variant-numeric: tabular-nums; |
| } |
| |
| .actions { |
| display: flex; |
| justify-content: center; |
| gap: 12px; |
| } |
| |
| .btn { |
| padding: 12px 48px; |
| font-size: 0.95rem; |
| font-weight: 600; |
| color: #fff; |
| background: var(--accent); |
| border: none; |
| border-radius: 10px; |
| cursor: pointer; |
| transition: all var(--transition); |
| letter-spacing: 0.01em; |
| position: relative; |
| overflow: hidden; |
| } |
| |
| .btn:hover { |
| background: var(--accent-hover); |
| transform: translateY(-1px); |
| box-shadow: 0 4px 16px rgba(88, 166, 255, 0.3); |
| } |
| |
| .btn:active { |
| transform: translateY(0); |
| } |
| |
| .btn.success { |
| background: var(--success); |
| } |
| .btn-secondary { |
| background: var(--bg-tertiary); |
| color: var(--text-secondary); |
| border: 1px solid var(--border); |
| padding: 12px 28px; |
| } |
| |
| .btn-secondary:hover { |
| background: var(--toggle-bg); |
| color: var(--text-primary); |
| box-shadow: none; |
| transform: translateY(-1px); |
| } |
| .toast { |
| position: fixed; |
| bottom: 30px; |
| left: 50%; |
| transform: translateX(-50%) translateY(80px); |
| background: var(--bg-tertiary); |
| color: var(--text-primary); |
| padding: 12px 24px; |
| border-radius: 10px; |
| font-size: 0.85rem; |
| font-weight: 500; |
| border: 1px solid var(--border); |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); |
| opacity: 0; |
| transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); |
| pointer-events: none; |
| z-index: 100; |
| } |
| |
| .toast.visible { |
| opacity: 1; |
| transform: translateX(-50%) translateY(0); |
| } |
| |
| .toast .icon { |
| margin-right: 8px; |
| } |
| |
| @media (max-width: 480px) { |
| .controls { |
| gap: 16px; |
| } |
| |
| .btn { |
| width: 100%; |
| } |
| |
| #text { |
| min-height: 220px; |
| height: calc(100vh - 380px); |
| font-size: 0.9rem; |
| } |
| } |
| </style> |
| </head> |
| <body> |
|
|
| <div class="container"> |
|
|
| <div class="header"> |
| <h1>заменитель буков</h1> |
| <p>заменяет символы на визуально идентичные юникод-аналоги</p> |
| </div> |
| |
| <div class="controls"> |
| <div class="control-group"> |
| <span class="control-label">язык</span> |
| <div class="toggle"> |
| <input type="radio" name="lang" id="lang-ru" value="ru" checked> |
| <label for="lang-ru">RU</label> |
| <input type="radio" name="lang" id="lang-en" value="en"> |
| <label for="lang-en">EN</label> |
| </div> |
| </div> |
|
|
| <div class="control-group"> |
| <span class="control-label">режим</span> |
| <div class="toggle"> |
| <input type="radio" name="mode" id="mode-light" value="light"> |
| <label for="mode-light">слабый</label> |
| <input type="radio" name="mode" id="mode-full" value="full" checked> |
| <label for="mode-full">сильный</label> |
| </div> |
| </div> |
| </div> |
| |
| <div class="editor"> |
| <textarea id="text" placeholder="вставь текст и нажми кнопку или Ctrl+Enter / Cmd+Enter для быстрого запуска: результат скопируется в буфер обмена автоматически"></textarea> |
| <div class="editor-footer"> |
| <span class="char-count" id="charCount">0 сим.</span> |
| </div> |
| </div> |
| |
| <div class="actions"> |
| <button class="btn btn-secondary" id="revertBtn" type="button">вернуть</button> |
| <button class="btn" id="processBtn" type="button">сделать</button> |
| </div> |
|
|
| </div> |
|
|
| <div class="toast" id="toast"></div> |
|
|
| <script> |
| (() => { |
| 'use strict'; |
| |
| const maps = { |
| ruLight: { |
| 'А': 'A', 'а': 'a', 'В': 'B', 'Е': 'E', 'е': 'e', |
| 'З': '3', 'й': 'ѝ', 'К': 'K', 'Л': 'Ʌ', 'М': 'M', |
| 'Н': 'H', 'О': 'O', 'о': 'o', 'П': 'Ⲡ', 'п': 'ⲡ', |
| 'Р': 'P', 'р': 'p', 'С': 'C', 'с': 'c', 'Т': 'T', |
| 'у': 'y', 'Х': 'X', 'х': 'x', 'Я': 'Я', 'я': 'ᴙ' |
| }, |
| ruFull: { |
| 'А': 'A', 'а': 'a', 'Б': 'Ƃ', 'б': 'б', 'В': 'B', 'в': 'ʙ', |
| 'Г': 'Γ', 'г': 'ᴦ', 'Е': 'E', 'е': 'e', 'Ё': 'Ë', 'ё': 'ë', |
| 'З': 'З', 'з': 'з', 'И': 'Ͷ', 'и': 'ᴎ', 'Й': 'Ѝ', 'й': 'ѝ', |
| 'К': 'K', 'к': 'ĸ', 'Л': 'Ʌ', 'л': 'ᴫ', 'М': 'M', 'м': 'ᴍ', |
| 'Н': 'Н', 'н': 'ʜ', 'О': 'O', 'о': 'o', 'П': 'Ⲡ', 'п': 'ⲡ', |
| 'Р': 'P', 'р': 'p', 'С': 'C', 'с': 'c', 'Т': 'T', 'т': 'ⲧ', |
| 'У': 'Ꭹ', 'у': 'y', 'Ф': 'Ⲫ', 'ф': 'ⲫ', 'Х': 'X', 'х': 'x', |
| 'Ч': 'Ч', 'ч': 'ч', 'Ш': 'Ɯ', 'ш': 'ꟺ', 'Щ': 'Щ', 'щ': 'ɰ', |
| 'Ъ': 'Ѣ', 'ъ': 'Ꙏ', 'Ь': 'Ⱃ', 'ь': 'ь', 'Я': 'Я', 'я': 'ᴙ' |
| }, |
| enLight: { |
| 'A': 'А', 'a': 'а', 'B': 'В', 'C': 'С', 'c': 'с', |
| 'E': 'Е', 'e': 'е', 'g': 'ɡ', 'H': 'Н', 'I': 'І', |
| 'i': 'і', 'J': 'Ј', 'j': 'ј', 'K': 'К', 'M': 'М', |
| 'n': 'ո', 'O': 'О', 'o': 'о', 'P': 'Р', 'p': 'р', |
| 'S': 'Ѕ', 's': 'ѕ', 'T': 'Т', 'U': 'Ս', 'w': 'ᴡ', |
| 'X': 'Х', 'x': 'х', 'Y': 'У', 'y': 'у', 'Z': 'Ζ', |
| 'z': 'ⲍ' |
| }, |
| enFull: { |
| 'A': 'А', 'a': 'а', 'B': 'В', 'b': 'ɓ', 'C': 'С', 'c': 'с', |
| 'D': 'D', 'd': 'ԁ', 'E': 'Е', 'e': 'е', 'F': 'Ϝ', 'f': 'ƒ', |
| 'G': 'Ꮆ', 'g': 'ɡ', 'H': 'Н', 'h': 'һ', 'I': 'І', 'i': 'і', |
| 'J': 'Ј', 'j': 'ј', 'K': 'К', 'k': 'κ', 'L': 'Ꝉ', 'l': 'ⅼ', |
| 'M': 'М', 'm': 'ᴍ', 'N': 'Ɲ', 'n': 'ո', 'O': 'О', 'o': 'о', |
| 'P': 'Р', 'p': 'р', 'Q': 'Ϙ', 'q': 'ɋ', 'R': 'Ɍ', 'r': 'ꭈ', |
| 'S': 'Ѕ', 's': 'ѕ', 'T': 'Т', 't': 'τ', 'U': 'Ս', 'u': 'υ', |
| 'V': 'Ѵ', 'v': 'ѵ', 'W': 'Ⱳ', 'w': 'ᴡ', 'X': 'Х', 'x': 'х', |
| 'Y': 'У', 'y': 'у', 'Z': 'Ζ', 'z': 'ⲍ' |
| }, |
| }; |
| |
| |
| function getSettings() { |
| const lang = document.querySelector('input[name="lang"]:checked').value; |
| const mode = document.querySelector('input[name="mode"]:checked').value; |
| return { lang, mode }; |
| } |
| |
| function getActiveMap({ lang, mode }) { |
| const key = `${lang}${mode.charAt(0).toUpperCase() + mode.slice(1)}`; |
| return maps[key] ?? {}; |
| } |
| |
| function invertMap(map) { |
| const inv = {}; |
| for (const [k, v] of Object.entries(map)) { |
| inv[v] = k; |
| } |
| return inv; |
| } |
| |
| async function revert() { |
| const settings = getSettings(); |
| const map = invertMap(getActiveMap(settings)); |
| |
| let text = textEl.value; |
| if (!text.trim()) { |
| showToast('⚠️ поле пустое'); |
| return; |
| } |
| |
| text = replaceByMap(text, map); |
| textEl.value = text; |
| updateCharCount(); |
| |
| try { |
| await copyToClipboard(text); |
| showToast('<span class="icon">↩️</span>возвращено и скопировано'); |
| } catch { |
| showToast('<span class="icon">↩️</span>возвращено'); |
| } |
| } |
| |
| function fixTypography(str) { |
| return str |
| .replace(/--/g, '—') |
| .replace(/ - /g, ' — ') |
| .replace(/(?<=\S)- /g, '— ') |
| .replace(/"([^"]+)"/g, '«$1»'); |
| } |
| |
| function replaceByMap(str, map) { |
| if (!Object.keys(map).length) return str; |
| |
| const keys = Object.keys(map).sort((a, b) => b.length - a.length); |
| const escaped = keys.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); |
| const regex = new RegExp(escaped.join('|'), 'g'); |
| |
| return str.replace(regex, match => map[match] ?? match); |
| } |
| |
| async function copyToClipboard(text) { |
| if (navigator.clipboard?.writeText) { |
| return navigator.clipboard.writeText(text); |
| } |
| |
| const ta = Object.assign(document.createElement('textarea'), { |
| value: text, |
| style: 'position:fixed;opacity:0', |
| }); |
| document.body.appendChild(ta); |
| ta.select(); |
| document.execCommand('copy'); |
| ta.remove(); |
| } |
| |
| function showToast(message, duration = 2000) { |
| const toast = document.getElementById('toast'); |
| toast.innerHTML = message; |
| toast.classList.add('visible'); |
| clearTimeout(toast._timer); |
| toast._timer = setTimeout(() => toast.classList.remove('visible'), duration); |
| } |
| |
| function updateCharCount() { |
| const len = textEl.value.length; |
| const word = len === 1 ? 'символ' : (len > 1 && len < 5 ? 'символа' : 'символов'); |
| charCountEl.textContent = `${len} ${word}`; |
| } |
| |
| |
| async function process() { |
| const settings = getSettings(); |
| const map = getActiveMap(settings); |
| |
| let text = textEl.value; |
| |
| if (!text.trim()) { |
| showToast('⚠️ поле пустое'); |
| return; |
| } |
| |
| if (!Object.keys(map).length) { |
| showToast('⚠️ карта замен в коде пуста'); |
| return; |
| } |
| |
| text = fixTypography(text); |
| |
| text = replaceByMap(text, map); |
| |
| textEl.value = text; |
| updateCharCount(); |
| |
| try { |
| await copyToClipboard(text); |
| showToast('<span class="icon">✅</span>готово и скопировано'); |
| btn.classList.add('success'); |
| setTimeout(() => btn.classList.remove('success'), 1200); |
| } catch { |
| showToast('<span class="icon">⚠️</span>готово, но копирование не удалось'); |
| } |
| } |
| |
| const textEl = document.getElementById('text'); |
| const btn = document.getElementById('processBtn'); |
| const revertBtn = document.getElementById('revertBtn'); |
| revertBtn.addEventListener('click', revert); |
| const charCountEl = document.getElementById('charCount'); |
| |
| btn.addEventListener('click', process); |
| textEl.addEventListener('input', updateCharCount); |
| |
| textEl.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { |
| e.preventDefault(); |
| process(); |
| } |
| }); |
| |
| updateCharCount(); |
| })(); |
| </script> |
|
|
| </body> |
| </html> |