gotiengviet
Version:
Add Vietnamese typing to your web app. Supports Telex, VNI, VIQR input methods
412 lines (407 loc) • 13.5 kB
JavaScript
const VIETNAMESE_CHARS = {
a: ['a', 'á', 'à', 'ả', 'ã', 'ạ'],
â: ['â', 'ấ', 'ầ', 'ẩ', 'ẫ', 'ậ'],
ă: ['ă', 'ắ', 'ằ', 'ẳ', 'ẵ', 'ặ'],
e: ['e', 'é', 'è', 'ẻ', 'ẽ', 'ẹ'],
ê: ['ê', 'ế', 'ề', 'ể', 'ễ', 'ệ'],
i: ['i', 'í', 'ì', 'ỉ', 'ĩ', 'ị'],
o: ['o', 'ó', 'ò', 'ỏ', 'õ', 'ọ'],
ô: ['ô', 'ố', 'ồ', 'ổ', 'ỗ', 'ộ'],
ơ: ['ơ', 'ớ', 'ờ', 'ở', 'ỡ', 'ợ'],
u: ['u', 'ú', 'ù', 'ủ', 'ũ', 'ụ'],
ư: ['ư', 'ứ', 'ừ', 'ử', 'ữ', 'ự'],
y: ['y', 'ý', 'ỳ', 'ỷ', 'ỹ', 'ỵ'],
// Uppercase
A: ['A', 'Á', 'À', 'Ả', 'Ã', 'Ạ'],
Â: ['Â', 'Ấ', 'Ầ', 'Ẩ', 'Ẫ', 'Ậ'],
Ă: ['Ă', 'Ắ', 'Ằ', 'Ẳ', 'Ẵ', 'Ặ'],
E: ['E', 'É', 'È', 'Ẻ', 'Ẽ', 'Ẹ'],
Ê: ['Ê', 'Ế', 'Ề', 'Ể', 'Ễ', 'Ệ'],
I: ['I', 'Í', 'Ì', 'Ỉ', 'Ĩ', 'Ị'],
O: ['O', 'Ó', 'Ò', 'Ỏ', 'Õ', 'Ọ'],
Ô: ['Ô', 'Ố', 'Ồ', 'Ổ', 'Ỗ', 'Ộ'],
Ơ: ['Ơ', 'Ớ', 'Ờ', 'Ở', 'Ỡ', 'Ợ'],
U: ['U', 'Ú', 'Ù', 'Ủ', 'Ũ', 'Ụ'],
Ư: ['Ư', 'Ứ', 'Ừ', 'Ử', 'Ữ', 'Ự'],
Y: ['Y', 'Ý', 'Ỳ', 'Ỷ', 'Ỹ', 'Ỵ'],
};
const INPUT_METHODS = {
telex: {
toneRules: { s: 1, f: 2, r: 3, x: 4, j: 5, z: 0 },
markRules: {
aa: 'â',
AA: 'Â',
ee: 'ê',
EE: 'Ê',
oo: 'ô',
OO: 'Ô',
aw: 'ă',
AW: 'Ă',
ow: 'ơ',
OW: 'Ơ',
uw: 'ư',
UW: 'Ư',
dd: 'đ',
DD: 'Đ',
},
},
vni: {
toneRules: { '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '0': 0 },
markRules: {
a6: 'â',
A6: 'Â',
e6: 'ê',
E6: 'Ê',
o6: 'ô',
O6: 'Ô',
a8: 'ă',
A8: 'Ă',
o7: 'ơ',
O7: 'Ơ',
u7: 'ư',
U7: 'Ư',
d9: 'đ',
D9: 'Đ',
},
},
viqr: {
toneRules: { "'": 1, '`': 2, '?': 3, '~': 4, '.': 5, '^': 0 },
markRules: {
'a^': 'â',
'A^': 'Â',
'e^': 'ê',
'E^': 'Ê',
'o^': 'ô',
'O^': 'Ô',
'a(': 'ă',
'A(': 'Ă',
'o+': 'ơ',
'O+': 'Ơ',
'u+': 'ư',
'U+': 'Ư',
dd: 'đ',
DD: 'Đ',
},
},
};
function getLastWord(value, position) {
const before = value.slice(0, position);
const match = before.match(/[^ \t\n\r.,!?]*$/);
return match ? match[0] : '';
}
function findVowelPosition(text) {
const vowels = /[aeiouyăâêôơưAEIOUYĂÂÊÔƠƯ]/gi;
const positions = [];
let match;
while ((match = vowels.exec(text)) !== null) {
positions.push(match.index);
}
return positions;
}
// Helper function to update text maintaining cursor position
function replaceText(element, newText, startPos, endPos) {
// Save scroll state for restoration (inspired by avim.js approach)
const savedScrollTop = element.scrollTop || 0;
if ('setRangeText' in element &&
typeof element.setRangeText === 'function') {
// setRangeText(replacement, start, end, selectionMode)
// selectionMode 'end' moves the caret to the end of the replaced text.
element.setRangeText(newText, startPos, endPos, 'end');
// Ensure selection reflects caret at end of inserted text
const newCursorPos = startPos + newText.length;
element.selectionStart = element.selectionEnd = newCursorPos;
}
else {
// Fallback
element.value =
element.value.slice(0, startPos) + newText + element.value.slice(endPos);
const newCursorPos = startPos + newText.length;
element.selectionStart = element.selectionEnd = newCursorPos;
}
// Restore scroll state
element.scrollTop = savedScrollTop;
}
/**
* Pure, side-effect-free transformation utilities extracted from VietnameseInput.
* These functions operate on strings only and can be unit-tested independently.
*/
function processInputByMethod(text, method) {
let working = text;
// Tone processing
let idx = 0;
while (idx < working.length) {
const ch = working[idx];
const lowerCh = ch.toLowerCase();
const toneIndex = method.toneRules[lowerCh];
if (toneIndex !== undefined) {
if (idx > 0 && working[idx - 1].toLowerCase() === lowerCh) {
idx++;
continue;
}
const before = working.slice(0, idx);
const after = working.slice(idx + 1);
const base = before + after;
const vowelPositions = findVowelPosition(base);
// Choose a vowel to the left of the tone key. If there's no vowel to the left
// (e.g. tone-like letter at the start of the word like "x" in "xi"), skip
// tone application. This prevents accidental transformation such as "xi" -> "ĩ".
let chosenPos = -1;
for (const vp of vowelPositions)
if (vp < idx)
chosenPos = vp;
if (chosenPos === -1) {
// No vowel to the left — do not apply tone
idx++;
continue;
}
// apply tone using helper below
working = applyToneToText(base, toneIndex);
idx = 0;
continue;
}
idx++;
}
// Mark rules (diacritic/key sequences)
let changed = true;
const markKeys = Object.keys(method.markRules).sort((a, b) => b.length - a.length);
while (changed) {
changed = false;
for (const key of markKeys) {
const result = method.markRules[key];
const idxExact = working.lastIndexOf(key);
if (idxExact !== -1) {
const before = working.slice(0, idxExact);
const after = working.slice(idxExact + key.length);
if (before.endsWith(result)) {
working = before + key + after;
}
else {
working = before + result + after;
}
changed = true;
break;
}
const lowerKey = key.toLowerCase();
const lowerText = working.toLowerCase();
const idxLower = lowerText.lastIndexOf(lowerKey);
if (idxLower !== -1) {
const before = working.slice(0, idxLower);
const after = working.slice(idxLower + key.length);
const segment = working.substr(idxLower, key.length);
const suggestsUpper = segment[0] === segment[0].toUpperCase();
let mapped = result;
const alt = method.markRules[key.toUpperCase()];
if (suggestsUpper && alt)
mapped = alt;
if (before.endsWith(mapped)) {
working = before + segment + after;
}
else {
working = before + mapped + after;
}
changed = true;
break;
}
}
}
// Normalize sequences like u + ơ -> ươ (and uppercase)
working = working.replace(/uơ/g, 'ươ');
working = working.replace(/UƠ/g, 'ƯƠ');
return working;
}
function applyToneToText(text, toneIndex) {
const vowelPositions = findVowelPosition(text);
if (vowelPositions.length === 0)
return text;
const priority = [
'a',
'ă',
'â',
'o',
'ô',
'ơ',
'e',
'ê',
'u',
'ư',
'i',
'y',
'A',
'Ă',
'Â',
'O',
'Ô',
'Ơ',
'E',
'Ê',
'U',
'Ư',
'I',
'Y',
];
const isAllUpper = text === text.toUpperCase();
const findMappingForChar = (ch) => {
const lower = ch.toLowerCase();
if (!isAllUpper && lower in VIETNAMESE_CHARS) {
return VIETNAMESE_CHARS[lower];
}
for (const key of Object.keys(VIETNAMESE_CHARS)) {
const arr = VIETNAMESE_CHARS[key];
if (arr.indexOf(ch) !== -1)
return arr;
}
return null;
};
let chosenPos = vowelPositions[vowelPositions.length - 1];
let bestRank = Number.MAX_SAFE_INTEGER;
for (const p of vowelPositions) {
const ch = text[p];
const mapping = findMappingForChar(ch);
if (!mapping)
continue;
const base = mapping[0];
const rank = priority.indexOf(base) !== -1
? priority.indexOf(base)
: Number.MAX_SAFE_INTEGER;
if (rank < bestRank || (rank === bestRank && p > chosenPos)) {
bestRank = rank;
chosenPos = p;
}
}
const vowel = text[chosenPos];
const arr = findMappingForChar(vowel);
if (!arr)
return text;
const idx = Math.max(0, Math.min(toneIndex, arr.length - 1));
const tonedVowel = arr[idx] || arr[0];
return text.slice(0, chosenPos) + tonedVowel + text.slice(chosenPos + 1);
}
class VietnameseInput {
/**
* Get the singleton instance (recommended usage)
*/
static getInstance(config = {}) {
if (!VietnameseInput._instance) {
VietnameseInput._instance = new VietnameseInput(config);
}
return VietnameseInput._instance;
}
// Backwards-compatible accessors for tests and callers that relied on internal methods.
// These delegate to the pure transform functions.
processInput(text, method) {
return processInputByMethod(text, method);
}
applyTone(text, toneIndex) {
return applyToneToText(text, toneIndex);
}
/**
* Create a new VietnameseInput instance (advanced usage, not recommended)
* Use VietnameseInput.getInstance() for singleton.
*/
constructor(config = {}) {
this.composing = false;
// Merge config with defaults
this.config = {
enabled: config.enabled !== undefined ? config.enabled : true,
inputMethod: config.inputMethod || 'telex',
};
this.enabled = !!this.config.enabled;
// Bind event handlers once
this.handleInputBound = this.handleInput.bind(this);
this.handleCompositionStart = () => {
this.composing = true;
};
this.handleCompositionEnd = () => {
this.composing = false;
};
this.setupListeners();
}
/**
* Destroy the singleton instance (for cleanup/testing)
*/
static destroyInstance() {
if (VietnameseInput._instance) {
VietnameseInput._instance.destroy();
VietnameseInput._instance = null;
}
}
setupListeners() {
document.addEventListener('input', this.handleInputBound);
document.addEventListener('compositionstart', this.handleCompositionStart);
document.addEventListener('compositionend', this.handleCompositionEnd);
}
handleInput(event) {
if (!this.enabled || this.composing) {
return;
}
const target = event.target;
const value = target.value;
const cursorPos = target.selectionStart || value.length;
const lastWord = getLastWord(value, cursorPos);
// keep previous behavior: only attempt when last word has at least 2 chars
if (lastWord.length < 2)
return;
const method = INPUT_METHODS[this.config.inputMethod || 'telex'];
const processed = processInputByMethod(lastWord, method);
if (processed !== lastWord) {
// start and end positions of the last word
const startPos = cursorPos - lastWord.length;
const endPos = cursorPos;
// Replace only the last word segment (replaceText expects the replacement fragment)
event.preventDefault();
replaceText(target, processed, startPos, endPos);
}
}
/**
* Toggle Vietnamese input on/off
*/
toggle() {
this.enabled = !this.enabled;
}
/**
* Enable Vietnamese input
*/
enable() {
this.enabled = true;
}
/**
* Disable Vietnamese input
*/
disable() {
this.enabled = false;
}
/**
* Check if Vietnamese input is enabled
*/
isEnabled() {
return this.enabled;
}
/**
* Remove all event listeners and clean up
*/
destroy() {
document.removeEventListener('input', this.handleInputBound);
document.removeEventListener('compositionstart', this.handleCompositionStart);
document.removeEventListener('compositionend', this.handleCompositionEnd);
}
/**
* Get current input method
*/
getInputMethod() {
return this.config.inputMethod || 'telex';
}
/**
* Set input method (telex, vni, viqr)
*/
setInputMethod(method) {
if (['telex', 'vni', 'viqr'].includes(method)) {
this.config.inputMethod = method;
}
}
}
/**
* Singleton instance
*/
VietnameseInput._instance = null;
export { VietnameseInput };
//# sourceMappingURL=index.esm.js.map