350 lines
12 KiB
HTML
350 lines
12 KiB
HTML
<!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&¤tScheme!=="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>
|