UNPKG

gotiengviet

Version:

Add Vietnamese typing to your web app. Supports Telex, VNI, VIQR input methods

412 lines (407 loc) 13.5 kB
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