First version.
This commit is contained in:
@@ -1,3 +1,60 @@
|
||||
# LiveTransliterator
|
||||
# Live Transliterator
|
||||
|
||||
A lightweight browser-based tool for real-time and on-demand transliteration from Latin-script sequences into Russian, Ukrainian, or Esperanto characters.
|
||||
A lightweight web application that converts Latin-script input into Cyrillic (Russian or Ukrainian) or Esperanto characters in real time or on demand. Ideal for linguistic tools, learning aids, or any project requiring fast, accurate transliteration directly in the browser.
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time transliteration**: Automatically converts as you type when Live mode is enabled.
|
||||
- **On-demand transliteration**: Select text and click a button to transliterate specific blocks.
|
||||
- **Multiple schemes**: Supports Russian, Ukrainian, and Esperanto mapping.
|
||||
- **Bash-style shortcuts**: Familiar `Ctrl+A`, `Ctrl+E`, `Ctrl+K`, and `Ctrl+U` editing commands.
|
||||
- **Clipboard integration**: Automatically copies transliterated text to the clipboard after pauses.
|
||||
- **Easy customization**: Pure HTML, CSS, and vanilla JavaScript—zero dependencies.
|
||||
|
||||
## Demo
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/your-username/live-transliterator.git
|
||||
cd live-transliterator
|
||||
```
|
||||
|
||||
2. **Open in browser**
|
||||
Simply open `index.html` in your favorite browser. No build steps required.
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Select a scheme**
|
||||
Choose **Russian**, **Ukrainian**, or **Esperanto** from the dropdown.
|
||||
2. **Toggle Live mode**
|
||||
Enable the switch to transliterate as you type.
|
||||
3. **Type or paste text**
|
||||
Your Latin-script input will be converted according to the selected scheme.
|
||||
4. **On-demand**
|
||||
Disable Live, select a text range, then click **Transliterate** to process only that segment.
|
||||
5. **Keyboard shortcuts**
|
||||
|
||||
* `Ctrl+O`: Swap between Off and your last used scheme
|
||||
* `Ctrl+A/E/K/U`: Bash-style navigation and deletion
|
||||
|
||||
## Customization
|
||||
|
||||
* **Add new schemes**: Extend the `comboList`, `comboStarters`, and `map` definitions in `index.html`.
|
||||
* **Styling**: Modify the embedded CSS or replace with your own themes.
|
||||
* **Clipboard timing**: Adjust the debounce delay (`500ms`) in the `resetCopyTimer()` function.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repo.
|
||||
2. Create a feature branch: `git checkout -b feature-name`.
|
||||
3. Commit your changes: `git commit -m "Add new feature"`.
|
||||
4. Push to the branch: `git push origin feature-name`.
|
||||
5. Open a pull request.
|
||||
|
||||
## License
|
||||
|
||||
WTFPL
|
||||
|
||||
+349
@@ -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&¤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>
|
||||
Reference in New Issue
Block a user