First version.

This commit is contained in:
Oleksandr Kozachuk
2025-06-08 15:52:56 +02:00
parent 40c624a2b8
commit 68e487e54c
2 changed files with 408 additions and 2 deletions
+349
View File
@@ -0,0 +1,349 @@
<!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>