Oleksandr Kozachuk 68e487e54c First version.
2025-06-08 15:52:56 +02:00

350 lines
12 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Live Transliterator</title>
<style>
/* Solarized Light & full-page layout */
html,body { height:100%; margin:0; background:#fdf6e3; color:#657b83; font-family:sans-serif; }
body { display:flex; flex-direction:column; }
.controls {
display:flex; align-items:center; gap:8px;
background:#eee8d5; padding:4px; font-size:14px;
}
.controls select, .controls button {
background:#fdf6e3; color:#657b83; border:1px solid #93a1a1;
padding:2px 4px; border-radius:3px; font-size:14px; cursor:pointer;
}
.controls select:focus, .controls button:focus,
#mainTextarea:focus {
outline:none; box-shadow:0 0 0 2px rgba(38,139,210,0.6);
}
.switch { position:relative; width:50px; height:24px; }
.switch input { opacity:0; width:0; height:0; }
.slider {
position:absolute; top:0; left:0; right:0; bottom:0;
background:#93a1a1; border-radius:34px; transition:.4s; cursor:pointer;
}
.slider:before {
content:""; position:absolute; width:18px; height:18px;
left:3px; bottom:3px; background:#fdf6e3; border-radius:50%; transition:.4s;
}
input:checked + .slider { background:#268bd2; }
input:checked + .slider:before { transform:translateX(26px); }
#mainTextarea {
flex:1; margin:4px; padding:4px;
background:#fdf6e3; color:#657b83;
font-family:"Courier New",Courier,monospace;
font-size:16px; line-height:1.5;
border:none; resize:none; overflow-y:auto;
}
#mainTextarea::placeholder { color:#93a1a1; }
</style>
</head>
<body>
<div class="controls">
<label for="methodSelect">Method:</label>
<select id="methodSelect">
<option value="Off">Off</option>
<option value="Russian">Russian</option>
<option value="Ukrainian">Ukrainian</option>
<option value="Esperanto">Esperanto</option>
</select>
<label class="switch">
<input type="checkbox" id="liveToggle"/>
<span class="slider"></span>
</label>
<label for="liveToggle">Live</label>
<button id="transliterateBtn">Transliterate</button>
</div>
<textarea id="mainTextarea"
placeholder="Type here…"
spellcheck="false"
autocapitalize="off"
autocomplete="off"></textarea>
<script>
(function(){
// DOM & state
const textarea = document.getElementById('mainTextarea');
const methodSelect = document.getElementById('methodSelect');
const liveToggle = document.getElementById('liveToggle');
const translitBtn = document.getElementById('transliterateBtn');
let currentScheme = methodSelect.value; // "Off"
let lastScheme = "Russian"; // default previous
let transliterationOn = false;
let copyTimeout = null;
let pendingSequence = "";
let pendingStartIdx = 0;
// Combos (all lowercase)
const comboList = {
Russian: ["shch","zh","ts","yu","yo","ye","''","\"\""],
Ukrainian: ["shch","zh","ts","yu","yo","ye","yi","ya","gg","''","\"\""],
Esperanto: ["cx","gx","hx","jx","sx","ux"]
};
// starters also lowercase
const comboStarters = {
Russian: ["s","z","t","y","'","\""],
Ukrainian: ["s","z","t","y","i","g","'","\""],
Esperanto: ["c","g","h","j","s","u"]
};
// Clipboard & flush
function copyAllToClipboard(){
const txt = textarea.value; if(!txt) return;
if(navigator.clipboard?.writeText){
navigator.clipboard.writeText(txt).catch(fallback);
} else fallback(txt);
function fallback(str){
const tmp = document.createElement("textarea");
tmp.value=str; tmp.style.position="fixed"; tmp.style.opacity=0;
document.body.appendChild(tmp); tmp.select();
document.execCommand("copy");
document.body.removeChild(tmp);
}
}
function flushPending(){
if(!pendingSequence) return;
const m = transliterateBlock(pendingSequence, currentScheme);
textarea.setRangeText(m,
pendingStartIdx,
pendingStartIdx + pendingSequence.length,
"end"
);
pendingSequence="";
}
function resetCopyTimer(){
if(copyTimeout) clearTimeout(copyTimeout);
copyTimeout = setTimeout(()=>{
flushPending();
copyAllToClipboard();
}, 500);
}
// Transliteration maps
function transliterateBlock(str, scheme){
let map = [];
if(scheme==="Russian"){
map = [
["shch","щ"],["Shch","Щ"],["SHCH","Щ"],
["zh","ж"], ["Zh","Ж"], ["ZH","Ж"],
["ts","ц"], ["Ts","Ц"], ["TS","Ц"],
["yu","ю"], ["Yu","Ю"], ["YU","Ю"],
["yo","ё"], ["Yo","Ё"], ["YO","Ё"],
["ye","э"], ["Ye","Э"], ["YE","Э"],
["''","ь"], ["\"\"","ъ"],
["q","я"], ["Q","Я"],
["w","ш"], ["W","Ш"],
["x","х"], ["X","Х"],
["h","ч"], ["H","Ч"],
["a","а"],["A","А"],["b","б"],["B","Б"],
["v","в"],["V","В"],["g","г"],["G","Г"],
["d","д"],["D","Д"],["e","е"],["E","Е"],
["z","з"],["Z","З"],["i","и"],["I","И"],
["j","й"],["J","Й"],["k","к"],["K","К"],
["l","л"],["L","Л"],["m","м"],["M","М"],
["n","н"],["N","Н"],["o","о"],["O","О"],
["p","п"],["P","П"],["r","р"],["R","Р"],
["s","с"],["S","С"],["t","т"],["T","Т"],
["u","у"],["U","У"],["f","ф"],["F","Ф"],
["y","ы"],["Y","Ы"]
];
} else if(scheme==="Ukrainian"){
map = [
["shch","щ"], ["Shch","Щ"], ["SHCH","Щ"],
["zh","ж"], ["Zh","Ж"], ["ZH","Ж"],
["ts","ц"], ["Ts","Ц"], ["TS","Ц"],
["yu","ю"], ["Yu","Ю"], ["YU","Ю"],
["yo","ё"], ["Yo","Ё"], ["YO","Ё"], // optional
["ye","є"], ["Ye","Є"], ["YE","Є"],
["yi","ї"], ["Yi","Ї"], ["YI","Ї"],
["ya","я"], ["Ya","Я"], ["YA","Я"],
["gg","ґ"], ["GG","Ґ"],
["''","ь"], ["\"\"","ъ"],
["q","я"], ["Q","Я"],
["w","ш"], ["W","Ш"],
["x","х"], ["X","Х"],
["h","ч"], ["H","Ч"],
["a","а"],["A","А"],["b","б"],["B","Б"],
["v","в"],["V","В"],["g","г"],["G","Г"],
["d","д"],["D","Д"],["e","е"],["E","Е"],
["z","з"],["Z","З"],["i","і"],["I","І"],
["j","й"],["J","Й"],["k","к"],["K","К"],
["l","л"],["L","Л"],["m","м"],["M","М"],
["n","н"],["N","Н"],["o","о"],["O","О"],
["p","п"],["P","П"],["r","р"],["R","Р"],
["s","с"],["S","С"],["t","т"],["T","Т"],
["u","у"],["U","У"],["f","ф"],["F","Ф"],
["y","и"],["Y","И"]
];
} else if(scheme==="Esperanto"){
map = [
["cx","ĉ"],["Cx","Ĉ"],["CX","Ĉ"],
["gx","ĝ"],["Gx","Ĝ"],["GX","Ĝ"],
["hx","ĥ"],["Hx","Ĥ"],["HX","Ĥ"],
["jx","ĵ"],["Jx","Ĵ"],["JX","Ĵ"],
["sx","ŝ"],["Sx","Ŝ"],["SX","Ŝ"],
["ux","ŭ"],["Ux","Ŭ"],["UX","Ŭ"]
];
}
for(const [pat,repl] of map){
str = str.split(pat).join(repl);
}
return str;
}
// Handle each typed char
function handleChar(ch) {
const chLow = ch.toLowerCase();
const combos = comboList[currentScheme]||[];
if (pendingSequence) {
const combined = pendingSequence + ch;
const lc = combined.toLowerCase();
if (combos.some(c=>c.startsWith(lc) && c.length>lc.length)) {
pendingSequence = combined;
return;
}
if (combos.some(c=>c===lc)) {
const m = transliterateBlock(combined, currentScheme);
textarea.setRangeText(m,
pendingStartIdx,
pendingStartIdx + combined.length,
"end"
);
pendingSequence = "";
return;
}
// flush old then re-handle
const old = pendingSequence;
const mOld = transliterateBlock(old, currentScheme);
textarea.setRangeText(mOld,
pendingStartIdx,
pendingStartIdx + old.length,
"end"
);
pendingSequence = "";
handleChar(ch);
return;
}
// new combo?
if (comboStarters[currentScheme]?.includes(chLow)) {
if (combos.some(c=>c.startsWith(chLow))) {
pendingSequence = ch;
pendingStartIdx = textarea.selectionStart;
return;
}
}
// single-letter
const single = transliterateBlock(ch, currentScheme);
textarea.setRangeText(single,
textarea.selectionStart,
textarea.selectionStart,
"end"
);
}
// Menu & toggle
methodSelect.addEventListener('change', e=>{
const old=currentScheme, neu=methodSelect.value;
if(e.isTrusted && neu!==old && old!=="Off") lastScheme = old;
currentScheme=neu;
liveToggle.disabled=(neu==="Off");
if(neu==="Off"){
liveToggle.checked=false;
} else if((e.isTrusted&&old==="Off")||!e.isTrusted){
liveToggle.checked=true;
}
transliterationOn=liveToggle.checked;
pendingSequence="";
});
liveToggle.addEventListener('change', ()=>{
transliterationOn=liveToggle.checked&&currentScheme!=="Off";
if(!transliterationOn) pendingSequence="";
});
// Ctrl+O
document.addEventListener('keydown', e=>{
if(e.ctrlKey&&!e.altKey&&!e.metaKey&&e.key.toLowerCase()==='o'){
e.preventDefault();
const tmp=currentScheme;
currentScheme=lastScheme;
lastScheme=tmp;
methodSelect.value=currentScheme;
methodSelect.dispatchEvent(new Event('change'));
}
});
// Bash-style shortcuts
textarea.addEventListener('keydown', e=>{
if(!(e.ctrlKey&&!e.altKey&&!e.metaKey)) return;
const v=textarea.value, p=textarea.selectionStart;
switch(e.key.toLowerCase()){
case 'a':
e.preventDefault();
const pr=v.lastIndexOf('\n',p-1), st=pr===-1?0:pr+1;
textarea.setSelectionRange(st,st);
break;
case 'e':
e.preventDefault();
const nx=v.indexOf('\n',textarea.selectionEnd),
ed=nx===-1?v.length:nx;
textarea.setSelectionRange(ed,ed);
break;
case 'k':
e.preventDefault();
const toE=v.indexOf('\n',p)!==-1?v.indexOf('\n',p):v.length;
textarea.setRangeText("",p,toE,"preserve");
resetCopyTimer();
break;
case 'u':
e.preventDefault();
const pr2=v.lastIndexOf('\n',p-1),
fm=pr2===-1?0:pr2+1;
textarea.setRangeText("",fm,p,"preserve");
resetCopyTimer();
break;
}
});
// Live transliteration
textarea.addEventListener('beforeinput', e=>{
if(!transliterationOn||currentScheme==="Off") return;
if(e.inputType!=="insertText"||!e.data) return;
e.preventDefault();
handleChar(e.data);
textarea.focus();
resetCopyTimer();
});
// Transliterate button
translitBtn.addEventListener('click', ()=>{
const s=textarea.selectionStart, t=textarea.selectionEnd;
if(s===t||currentScheme==="Off") return;
const sel=textarea.value.slice(s,t);
const out=transliterateBlock(sel,currentScheme);
textarea.setRangeText(out,s,t,"select");
});
// Init
liveToggle.disabled=(currentScheme==="Off");
})();
</script>
</body>
</html>