前天发了抖音后很多用户进来网站了,感谢支持,目前注册的人数达到50人,抖音上有诋毁有讨论问题的,发现有个人提了现有市面上的输入法不够智能或者说界面不友好,那我跟Tengri AI 探讨了如何做界面友好的输入法,让AI写了代码。以下是总结,还挺好玩的,但不具备完全使用条件。
正是基于这样的初心——渴望打造一款真正“懂你心思”、既智能又易用的传统蒙古文输入法——我踏上了这段充满未知的开发之旅。这个过程,远比我想象的要曲折,但也充满了学习和成长的喜悦。
从零到一:核心引擎的构建 (v1.0 – v6.0)
万事开头难。最初的版本,我们从最基础的拉丁字母到蒙古文字母的简单映射开始。这个阶段,我们主要解决了以下问题:
1. 基础转写规则: 定义了 `a, e, i, o, u, n, b, m…` 等基础拉丁字母与蒙古文字母的对应关系。
2. 候选词机制: 引入了简单的词典匹配和算法转换,用户输入拉丁字母后,可以从候选列表中选择正确的蒙古文词汇。
3. 界面迭代: 从最简陋的文本框,到逐渐模仿手机输入法的界面布局,包括字母键盘、候选词窗口等。
4. 长按与多字母映射: 解决了如 `o` 键长按弹出 `ö, ü, u`,以及 `sh, ch, ng` 等多字母组合的输入问题。
这个阶段虽然基础,但为后续的智能化打下了地基。然而,很快我们就遇到了传统蒙古文输入的核心难点——词缀。
攻坚克难:词缀引擎的进化 (v6.1 – v6.7)
传统蒙古文的魅力与挑战,很大一部分在于其复杂而精妙的词缀系统。一个词干可以根据语法功能附加多达数个词缀,而且词缀的选择还受到元音和谐、辅音变化等诸多规则的制约。
这部分是我们投入精力最多,也是反复迭代次数最多的地方:
1. 初步的词缀模式: 引入了专门的词缀触发键(最初是 ``,后来改为更直观的 `=`),用户在输入词干后,可以进入词缀选择模式。
2. 元音和谐: 词缀引擎初步具备了根据词干的阴阳性推荐不同词缀的能力(如 `un/ün`, `du/dü`)。
3. 词缀库的扩充: 不断将常用的格斯词缀(如属格、与格、复数、所有格等)加入规则库。
4. 上下文感知与辅音变化(里程碑!): 这是一个巨大的突破。我们让词缀引擎能够识别词干末尾的辅音,从而智能判断何时使用 `du`,何时使用 `tu` 这样的变体。这是输入法“智能”程度的一次飞跃。
5. 多级词缀处理: 确保用户可以连续添加多个词缀,例如 `ᠬᠡᠷᠡᠭ + ᠤᠨ + ᠳᠤᠭᠠᠷ`。
在这个过程中,我们经历了无数次的“调试地狱”。用户(也就是您!)提供的每一个“打不出来”的例子,都像一把精准的手术刀,帮助我们定位并修复了引擎深处的逻辑缺陷。
体验至上:界面与交互的革命 (v7.0 – v7.2)
当输入法引擎的“内功”逐渐强大后,我们发现用户体验的“招式”却显得笨拙。复杂的词缀规则如果不能以简单直观的方式呈现给用户,再智能的引擎也难以发挥其价值。
于是,我们开启了对界面和交互逻辑的彻底革新:
1. 动态语法栏: 这是v7.0引入的核心概念。当用户按下 `=` 键后,候选词窗口会“变身”为一系列清晰的语法功能按钮(如 `[ᠤᠨ]`, `[ᠳᠤ]`),用户只需点击表达意图的按钮,复杂的规则判断交给后台。
2. 清晰的视觉区分: 语法按钮拥有了与普通候选词完全不同的外观,避免了视觉混淆。
3. 专属的语法触发键: 在虚拟键盘上增加了醒目的 `=` 键,作为进入语法模式的唯一入口。
4. 逻辑精简与状态明确(v7.2 的核心): 这是我们目前最新的成果。我们彻底梳理并简化了输入法的工作流程,确保只有两种核心状态:“单词输入模式”和“词缀选择模式”。状态之间的转换路径清晰固定,杜绝了之前版本中因状态混乱导致的各种诡异bug。每一个用户操作,都有一个可预测的、唯一的后果。
目前版本 (v7.2) 的核心功能与亮点:
精准的拉丁-蒙古文转写。
丰富的词典支持,并持续扩充中。
强大的上下文感知词缀引擎:
自动处理元音和谐。
自动处理基于词尾辅音的词缀变体(如 `d/t` 的选择)。
支持多级词缀的连续添加。
直观的“动态语法栏”交互:
通过 `=` 键一键进入语法模式。
清晰的语法功能按钮,意图驱动,无需记忆复杂规则。
简化的核心逻辑: 状态清晰,行为可预测,大大提升了稳定性和易用性。
物理键盘与虚拟键盘的良好支持。
尚存的问题与未来展望:
尽管v7.2版本在逻辑清晰度和核心功能上有了长足的进步,但距离一款“完美”的输入法,我们仍有许多工作要做:
1. 词典规模: 虽然我们一直在努力扩充,但蒙古语词汇浩如烟海,当前的词典覆盖度仍有提升空间。特别是对于一些生僻词、专业术语以及不断出现的新词,需要一个更高效的词库维护和更新机制。
2. 智能组词与联想: 目前的输入法主要依赖于单个词的匹配和转换。未来可以探索基于语境的智能组词和联想功能,进一步提升输入效率。例如,输入“ᠮᠣᠩᠭᠣᠯ”后,能自动联想出“ᠮᠣᠩᠭᠣᠯ ᠬᠡᠯᠡ”、“ᠮᠣᠩᠭᠣᠯ ᠦᠨᠳᠦᠰᠦᠲᠡᠨ”等。
3. 用户自定义词库: 允许用户添加自己的常用词、专业术语,甚至自定义短语,这将极大提升个性化体验。
4. 更深层次的语法理解: 虽然词缀引擎已经很强大,但在一些更复杂的句法结构或特殊用例上,可能仍有提升空间。
5. 性能优化: 随着词典和规则的增加,需要持续关注性能,确保输入流畅不卡顿。
6. 跨平台与移动端适配: 目前我们主要在桌面浏览器环境测试。最终目标是将其适配到手机端,甚至打包成独立的应用程序,这需要考虑不同平台的特性和限制。
7. 更美观的UI设计: 目前的界面以功能优先,未来可以在视觉美感上投入更多精力。
以下是输入法v6.7
<!DOCTYPE html>
<html lang="mn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>传统蒙古文输入法 v6.7 (深度语法版)</title>
<style>
/* CSS 与 v6.6 完全一致 */
:root { --key-bg: #fff; --key-text: #000; --key-special-bg: #acb4be; --popup-bg: #e9ecef; --popup-shadow: rgba(0,0,0,0.25); --primary-color: #007bff; }
body { font-family: -apple-system, sans-serif; background-color: #e9ecef; margin: 0; display: flex; flex-direction: column; height: 100vh; }
.main-content { flex-grow: 1; padding: 10px; display: flex; flex-direction: column; }
#editor { width: 100%; flex-grow: 1; background: white; border: 1px solid #ccc; border-radius: 8px; padding: 15px; font-size: 28px; writing-mode: vertical-rl; text-orientation: mixed; font-family: 'Mongolian Baiti', 'Menksoft', 'Noto Sans Mongolian', sans-serif; line-height: 1.6; cursor: text; box-sizing: border-box; white-space: pre-wrap; }
#editor:focus { outline: none; border-color: var(--primary-color); }
.composing-text { color: var(--primary-color); border-right: 2px solid var(--primary-color); }
#candidate-window { flex-shrink: 0; background: #fff; padding: 5px; overflow-x: auto; white-space: nowrap; border-bottom: 1px solid #ddd; min-height: 42px; box-sizing: border-box; }
#candidate-window span { display: inline-block; font-size: 22px; padding: 8px 12px; margin: 2px; border-radius: 5px; font-family: 'Mongolian Baiti', 'Menksoft', 'Noto Sans Mongolian'; }
#candidate-window span.selected { background-color: var(--primary-color); color: white; }
#virtual-keyboard, #symbol-keyboard { position: fixed; bottom: 0; left: 0; right: 0; padding: 5px; background-color: #d1d5db; user-select: none; }
.keyboard-row { display: flex; justify-content: center; margin: 5px 0; }
.keyboard-key { flex: 1; margin: 0 3px; padding: 15px 5px; font-size: 20px; text-align: center; border: none; border-radius: 5px; background-color: var(--key-bg); box-shadow: 0 1px 1px rgba(0,0,0,0.2); cursor: pointer; touch-action: manipulation; position: relative; }
.keyboard-key.special { background-color: var(--key-special-bg); flex-grow: 1.2; }
.key-popup { position: absolute; bottom: 120%; left: 50%; transform: translateX(-50%); display: none; background-color: var(--popup-bg); border-radius: 8px; padding: 5px; box-shadow: 0 4px 10px var(--popup-shadow); display: flex; gap: 5px; z-index: 1000; }
.key-popup button { background: #fff; border: none; font-size: 24px; padding: 10px 15px; border-radius: 5px; font-family: 'Mongolian Baiti', 'Menksoft', 'Noto Sans Mongolian'; }
#symbol-keyboard { display: none; }
.instructions { background-color: #fff; border: 1px solid #ddd; padding: 15px; border-radius: 8px; line-height: 1.7; font-size: 14px; margin-bottom: 20px; }
</style>
</head>
<body>
<div class="main-content">
<div class="instructions">
<h2>v6.7:深度语法版</h2>
<p><strong>语法引擎重构:</strong></p>
<ul>
<li><b>上下文感知:</b> 引擎现在能识别词尾辅音,自动选择 `ᠳᠤ` / `ᠲᠤ`。试试输入 `oros` 再按 `-`。</li>
<li><b>词缀库扩充:</b> 已加入 `ᠤ`/`ᠦ`, `ᠨᠤᠭᠤᠳ`, `ᠳᠡᠭᠡᠨ` 等新词缀。</li>
<li><b>词典同步:</b> 已加入 `harbulga`, `jambutib` 等所有新词。</li>
</ul>
</div>
<div id="editor" tabindex="0"></div>
<div id="candidate-window"></div>
</div>
<div id="virtual-keyboard"> <!-- HTML is injected by JS --> </div>
<div id="symbol-keyboard"> <!-- HTML is injected by JS --> </div>
<script>
const MVS = '\u180E'; const NNBSP = '\u180A'; const FVS1 = '\u180B'; const FVS2 = '\u180C';
const RULES = { latinToMongolMap: { 'a': 'ᠠ', 'e': 'ᠡ', 'i': 'ᠢ', 'o': 'ᠣ', 'u': 'ᠤ', 'v': 'ᠥ', 'w': 'ᠦ', 'n': 'ᠨ', 'b': 'ᠪ', 'p': 'ᠫ', 'h': 'ᠬ', 'g': 'ᠭ', 'x': 'ᠺ', 'm': 'ᠮ', 'l': 'ᠯ', 's': 'ᠰ', 'd': 'ᠳ', 't': 'ᠲ', 'c': 'ᠴ', 'j': 'ᠵ', 'y': 'ᠶ', 'r': 'ᠷ', 'f': 'ᠹ', 'z': 'ᠽ', 'k': 'ᠺ', 'q': 'ᠬ', 'ng': 'ᠩ', 'ch': 'ᠴ', 'sh': 'ᠱ', 'zh': 'ᠵ' }, vowels: { masculine: ['ᠠ', 'ᠣ', 'ᠤ'], feminine: ['ᠡ', 'ᠥ', 'ᠦ'], neutral: ['ᠢ'] } };
const DICTIONARY = {
// 词典与v6.6合并,并加入新词
...{'hemnelte':'ᠬᠡᠮᠨᠡᠯᠲᠡ','amidural':'ᠠᠮᠢᠳᠤᠷᠠᠯ','hev':'ᠬᠡᠪ','mayig':'ᠮᠠᠶᠢᠭ','jebhen':'ᠵᠥᠪᠬᠡᠨ','mvnggv':'ᠮᠥᠩᠭᠦ','gamnahu':'ᠭᠠᠮᠨᠠᠬᠤ','bisi':'ᠪᠢᠰᠢ','harin':'ᠬᠠᠷᠢᠨ','uhamsartu':'ᠤᠬᠠᠮᠰᠠᠷᠲᠤ','songgulta':'ᠰᠣᠩᠭᠤᠯᠲᠠ','bolgadag':'ᠪᠣᠯᠭᠠᠳᠠᠭ','hvrvnge':'ᠬᠥᠷᠥᠩᠭᠡ','erhe':'ᠡᠷᠬᠡ','cilwge':'ᠴᠢᠯᠦᠭᠡ','abcirahu':'ᠠᠪᠴᠢᠷᠠᠬᠤ','busu':'ᠪᠤᠰᠤ','ilehw':'ᠢᠯᠡᠬᠦᠦ','emjeg':'ᠡᠮᠵᠡᠭ','baidal':'ᠪᠠᠶᠢᠳᠠᠯ','hvrgehw':`ᠬᠦᠷᠭᠡᠬᠦ${FVS1}`,'eresdel':'ᠡᠷᠡᠰᠳᠡᠯ','buyu':'ᠪᠤᠶᠤ','ayul':'ᠠᠶᠤᠯ','bolhu':'ᠪᠣᠯᠬᠤ','oncalagsan':`ᠣᠨᠴᠠᠯᠠᠭ${FVS2}ᠰᠠᠨ`,'ed':'ᠡᠳ᠋','jasag':'ᠵᠠᠰᠠᠭ','bulun':'ᠪᠤᠯᠤᠨ','sedxil':'ᠰᠡᠳᠬᠢᠯ','jvi':'ᠵᠦᠢ','tala':'ᠲᠠᠯᠠ','vilgehw':'ᠦᠢᠯᠡᠬᠦ᠋','cihula':'ᠴᠢᠬᠤᠯᠠ','bvgved':'ᠪᠥᠭᠡᠳ','dergede':'ᠳᠡᠷᠭᠡᠳᠡ','mini':'ᠮᠢᠨᠢ','baiju':'ᠪᠠᠢᠵᠤ','hair-a':'ᠬᠠᠢᠷᠠ','dengdegww':'ᠳᠡᠩᠳᠡᠭᠦᠦ','oirahan':'ᠣᠢᠷᠠᠬᠠᠨ','ci':'ᠴᠢ','hajagu':'ᠬᠠᠵᠠᠭᠤ','ene':'ᠡᠨᠡ','olan':'ᠣᠯᠠᠨ','jil':'ᠵᠢᠯ','cimai':'ᠴᠢᠮᠠᠢ','tanigsan':'ᠲᠠᠨᠢᠭᠰᠠᠨ','wgei':'ᠦᠭᠡᠢ','agucila':'ᠠᠭᠤᠴᠢᠯᠠ','nama':'ᠨᠠᠮᠠ','cinu':'ᠴᠢᠨᠤ'},
// 本次新增
'harbulga': 'ᠬᠠᠷᠪᠤᠯᠭᠠ',
'sairha': 'ᠰᠠᠢᠷᠬᠠ',
'wrgvlji': 'ᠦᠷᠭᠦᠯᠵᠢ',
'hereg': 'ᠬᠡᠷᠡᠭ',
'jambutib': 'ᠽᠠᠮᠪᠤᠲᠢᠪ',
'oros': 'ᠣᠷᠣᠰ' // for testing
};
// v6.7 核心革命: 深度语法规则库
const SUFFIX_RULES = {
// Dative-Locative (与格)
'tu': { masculine: 'ᠲᠤ', feminine: 'ᠲᠦ', neutral: 'ᠲᠤ', endings: ['ᠭ', 'ᠪ', 'ᠰ', 'ᠷ'] }, // 辅音结尾的特殊情况
'du': { masculine: 'ᠳᠤ', feminine: 'ᠳᠦ', neutral: 'ᠳᠤ' }, // 默认情况
// Genitive (属格)
'u': { masculine: 'ᠤ', feminine: 'ᠦ', neutral: 'ᠦ', endings: ['ᠭ', 'ᠪ', 'ᠳ', 'ᠰ', 'ᠷ', 'ᠯ', 'ᠮ', 'ᠨ'] }, // 辅音结尾
'un': { masculine: 'ᠤᠨ', feminine: 'ᠦᠨ', neutral: 'ᠢᠨ' }, // 元音结尾
'yin': { all: `ᠶ᠋ᠢᠨ` },
// Plural (复数)
'nugud': { masculine: 'ᠨᠤᠭᠤᠳ', feminine: 'ᠨᠦᠭᠦᠳ' },
'ud': { masculine: 'ᠤᠳ', feminine: 'ᠦᠳ', neutral: 'ᠤᠳ' }, 'nar': { all: 'ᠨᠠᠷ' },
// Reflexive (反身领属格)
'degen': { masculine: 'ᠳᠠᠭᠠᠨ', feminine: 'ᠳᠡᠭᠡᠨ' },
// 其他格
'aca': { masculine: 'ᠠᠴᠠ', feminine: 'ᠡᠴᠡ', neutral: 'ᠠᠴᠠ' },
'bar': { masculine: 'ᠪᠠᠷ', feminine: 'ᠪᠡᠷ', neutral: 'ᠢᠶᠠᠷ' },
'ban': { masculine: 'ᠪᠠᠨ', feminine: 'ᠪᠡᠨ', neutral: 'ᠢᠶᠠᠨ' },
'tai': { masculine: 'ᠲᠠᠢ', feminine: 'ᠲᠡᠢ', neutral: 'ᠲᠠᠢ' },
'ni': { all: 'ᠨᠢ' }, 'yi': { all: `ᠶ᠋ᠢ` }, 'i': { all: 'ᠢ' }
};
class MongolianIME {
constructor(rules, dictionary, suffixRules) { this.rules = rules; this.dictionary = dictionary; this.suffixRules = suffixRules; this.dictKeys = Object.keys(dictionary); this.resetComposition(); }
resetComposition() { this.composingLatin = ''; this.composingMongol = ''; this.candidates = []; this.selectedCandidateIndex = 0; this.isSuffixMode = false; }
getWordGender(word) { for (let i = word.length - 1; i >= 0; i--) { const char = word[i]; if (this.rules.vowels.masculine.includes(char)) return 'masculine'; if (this.rules.vowels.feminine.includes(char)) return 'feminine'; } return 'neutral'; }
convertWordByAlgo(latin) { let mongolWord = ''; let tempLatin = latin; const ligatures = ['ng', 'ch', 'sh', 'zh']; ligatures.forEach(lig => { tempLatin = tempLatin.replace(new RegExp(lig, 'g'), this.rules.latinToMongolMap[lig]); }); for (const char of tempLatin) { mongolWord += this.rules.latinToMongolMap[char] || ''; } return mongolWord.replace(/(.)\1/g, (match, char) => (Object.values(this.rules.vowels).flat().includes(char)) ? char + MVS + char : match); }
processInput(key) { this.isSuffixMode = false; if (key === 'Backspace') { this.composingLatin = this.composingLatin.slice(0, -1); } else { this.composingLatin += key.toLowerCase(); } this.updateCandidates(); }
commitCandidate(index) { const word = this.candidates[index]; if (!word) return null; const result = { content: word, isSuffix: this.isSuffixMode }; if (!this.isSuffixMode) { this.resetComposition(); } return result; }
activateSuffixMode(lastWord) { this.isSuffixMode = true; this.composingLatin = ''; this.updateCandidates(lastWord); }
navigateCandidates(direction) { if (this.candidates.length === 0) return; if (direction === 'ArrowRight') { this.selectedCandidateIndex = (this.selectedCandidateIndex + 1) % this.candidates.length; } else if (direction === 'ArrowLeft') { this.selectedCandidateIndex = (this.selectedCandidateIndex - 1 + this.candidates.length) % this.candidates.length; } this.composingMongol = this.candidates[this.selectedCandidateIndex] || ''; }
updateCandidates(lastWord = '') { if (this.isSuffixMode) { this.candidates = this.generateSuffixCandidates(lastWord); } else { if (this.composingLatin === '') { this.resetComposition(); return; } let candidates = new Set(); if (this.dictionary[this.composingLatin]) { candidates.add(this.dictionary[this.composingLatin]); } const mainAlgoResult = this.convertWordByAlgo(this.composingLatin); if (mainAlgoResult) { candidates.add(mainAlgoResult); } const genderSwaps = { 'o': 'v', 'u': 'w', 'g': 'x', 'v': 'o', 'w': 'u', 'x': 'g' }; let swappedLatin = this.composingLatin; let hasSwapped = false; for(const [original, swap] of Object.entries(genderSwaps)) { if(swappedLatin.includes(original)) { swappedLatin = swappedLatin.replace(new RegExp(original, 'g'), swap); hasSwapped = true; } } if(hasSwapped) { const swappedAlgoResult = this.convertWordByAlgo(swappedLatin); if (swappedAlgoResult) candidates.add(swappedAlgoResult); } this.dictKeys.filter(key => key.startsWith(this.composingLatin) && key !== this.composingLatin).forEach(key => candidates.add(this.dictionary[key])); this.candidates = Array.from(candidates); } this.selectedCandidateIndex = 0; this.composingMongol = this.candidates[0] || ''; }
// v6.7 核心革命: 重构的词缀生成器
generateSuffixCandidates(lastWord) {
if (!lastWord) return [];
const gender = this.getWordGender(lastWord);
const lastChar = lastWord.slice(-1);
const addedRules = new Set();
const candidates = [];
for (const key in this.suffixRules) {
if (addedRules.has(key)) continue;
const rule = this.suffixRules[key];
let suffix = '';
// 优先处理带辅音结尾规则的
if (rule.endings && rule.endings.includes(lastChar)) {
if (gender === 'feminine' && rule.feminine) suffix = rule.feminine;
else if (rule.masculine) suffix = rule.masculine;
else if (rule.all) suffix = rule.all;
}
// 否则,处理默认的元音和谐规则
else if (!rule.endings) {
if (rule.all) suffix = rule.all;
else if (gender === 'neutral' && rule.neutral) suffix = rule.neutral;
else if (gender === 'feminine' && rule.feminine) suffix = rule.feminine;
else if (rule.masculine) suffix = rule.masculine;
}
if (suffix) {
candidates.push(NNBSP + suffix);
addedRules.add(key);
}
}
return candidates;
}
}
const ime = new MongolianIME(RULES, DICTIONARY, SUFFIX_RULES);
const editor = document.getElementById('editor'); const candidateWindow = document.getElementById('candidate-window');
const mainKeyboard = document.getElementById('virtual-keyboard'); const symbolKeyboard = document.getElementById('symbol-keyboard');
let committedText = ''; let historyStack = [];
function setupKeyboards() { mainKeyboard.innerHTML = ` <div class="keyboard-row"> <button class="keyboard-key" data-key="a">a</button> <button class="keyboard-key" data-key="e">e</button> <button class="keyboard-key" data-key="i">i</button> <button class="keyboard-key" data-key="o" data-popup="v,u,w">o</button> </div> <div class="keyboard-row"> <button class="keyboard-key" data-key="n" data-popup="ng">n</button> <button class="keyboard-key" data-key="b" data-popup="p">b</button> <button class="keyboard-key" data-key="d" data-popup="t">d</button> <button class="keyboard-key" data-key="h" data-popup="g,x">h</button> </div> <div class="keyboard-row"> <button class="keyboard-key" data-key="s" data-popup="sh,ch,j,zh">s</button> <button class="keyboard-key" data-key="m">m</button> <button class="keyboard-key" data-key="l">l</button> <button class="keyboard-key" data-key="r">r</button> </div> <div class="keyboard-row"> <button class="keyboard-key special" data-key="Symbols">...</button> <button class="keyboard-key" data-key="y">y</button> <button class="keyboard-key special" data-key="Suffix"></button> <button class="keyboard-key special" data-key="Space">空格</button> <button class="keyboard-key special" data-key="Backspace">⌫</button> </div>`; symbolKeyboard.innerHTML = ` <div class="keyboard-row"> <button class="keyboard-key" data-key="Symbol" data-value="《">《</button> <button class="keyboard-key" data-key="Symbol" data-value="》">》</button> <button class="keyboard-key" data-key="Symbol" data-value="᠄">᠄</button> <button class="keyboard-key" data-key="Symbol" data-value="᠃">᠃</button> </div> <div class="keyboard-row"> <button class="keyboard-key" data-key="Symbol" data-value="᠂">᠂</button> <button class="keyboard-key" data-key="Symbol" data-value="︖">︖</button> <button class="keyboard-key" data-key="Symbol" data-value="︕">︕</button> <button class="keyboard-key" data-key="Symbol" data-value="·">·</button> </div> <div class="keyboard-row"> <button class="keyboard-key" data-key="Symbol" data-value="🍽️">🍽️</button> <button class="keyboard-key" data-key="Symbol" data-value="✂️">✂️</button> <button class="keyboard-key" data-key="Symbol" data-value="🎭">🎭</button> <button class="keyboard-key" data-key="Symbol" data-value="✔️">✔️</button> </div> <div class="keyboard-row"> <button class="keyboard-key special" data-key="SwitchToAlpha">ABC</button> <button class="keyboard-key special" data-key="Space">空格</button> <button class="keyboard-key special" data-key="Backspace">⌫</button> </div>`; }
function render() { const composingSpan = `<span class="composing-text">${ime.composingMongol}</span>`; editor.innerHTML = committedText + (ime.composingLatin || ime.isSuffixMode ? composingSpan : ''); candidateWindow.innerHTML = ''; ime.candidates.forEach((candidate, index) => { const span = document.createElement('span'); span.textContent = candidate.replace(NNBSP, ''); if (index === ime.selectedCandidateIndex) { span.classList.add('selected'); } span.addEventListener('click', () => handleCommit(ime.commitCandidate(index))); candidateWindow.appendChild(span); }); }
function handleCommit(commitResult) { if (commitResult) { let textToAdd = commitResult.content; if (commitResult.isSuffix) { committedText = committedText.trimEnd() + textToAdd; historyStack.push({type: 'suffix', content: textToAdd}); ime.activateSuffixMode(committedText.trim().split(/[\s\u202F《》᠄᠃᠂︖︕·]+/).pop()); } else { textToAdd += ' '; committedText += textToAdd; historyStack.push({type: 'word', content: textToAdd}); } render(); } }
function handleKeyPress(key, value = null) { if (navigator.vibrate) { navigator.vibrate(20); } if (key === 'Suffix') { if (ime.composingLatin.length > 0) { handleCommit(ime.commitCandidate(0)); } const lastWord = committedText.trim().split(/[\s\u202F《》᠄᠃᠂︖︕·]+/).pop(); ime.activateSuffixMode(lastWord); } else if (key === 'Space') { if (ime.composingLatin.length > 0 || ime.isSuffixMode) { handleCommit(ime.commitCandidate(ime.selectedCandidateIndex)); ime.resetComposition(); committedText += ' '; historyStack.push({type: 'space', content: ' '}); } else { committedText += ' '; historyStack.push({type: 'space', content: ' '}); } } else if (key === 'Backspace') { if (ime.composingLatin.length > 0) { ime.processInput('Backspace'); } else if (ime.isSuffixMode) { ime.resetComposition(); } else if (historyStack.length > 0) { const lastChunk = historyStack.pop(); committedText = committedText.slice(0, -lastChunk.content.length); } } else if (key === 'Symbols') { mainKeyboard.style.display = 'none'; symbolKeyboard.style.display = 'block'; } else if (key === 'SwitchToAlpha') { mainKeyboard.style.display = 'block'; symbolKeyboard.style.display = 'none'; } else if (key === 'Symbol') { committedText += value; historyStack.push({type: 'symbol', content: value}); } else { ime.processInput(key); } render(); }
function setupEventListeners() { document.querySelectorAll('.keyboard-key').forEach(key => { key.addEventListener('click', (e) => { document.querySelectorAll('.key-popup').forEach(p => p.remove()); handleKeyPress(e.currentTarget.dataset.key, e.currentTarget.dataset.value); }); }); let longPressTimer; mainKeyboard.querySelectorAll('.keyboard-key[data-popup]').forEach(key => { const primaryKey = key.dataset.key; const popupKeys = key.dataset.popup; const showPopup = () => { document.querySelectorAll('.key-popup').forEach(p => p.remove()); if (!popupKeys) return; const popup = document.createElement('div'); popup.className = 'key-popup'; popupKeys.split(',').forEach(popupKey => { const button = document.createElement('button'); button.textContent = RULES.latinToMongolMap[popupKey] || popupKey.toUpperCase(); button.onclick = (e) => { e.stopPropagation(); handleKeyPress(popupKey); popup.remove(); }; popup.appendChild(button); }); key.appendChild(popup); popup.style.display = 'flex'; }; const handlePressStart = (e) => { e.preventDefault(); longPressTimer = setTimeout(showPopup, 350); }; const handlePressEnd = (e) => { e.preventDefault(); clearTimeout(longPressTimer); }; key.addEventListener('mousedown', handlePressStart); key.addEventListener('mouseup', handlePressEnd); key.addEventListener('touchstart', handlePressStart, { passive: false }); key.addEventListener('touchend', handlePressEnd); }); window.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; const key = e.key; if (key.length === 1 && key >= 'a' && key <= 'z') { e.preventDefault(); handleKeyPress(key); return; } switch(key) { case ' ': case 'Enter': e.preventDefault(); handleKeyPress('Space'); break; case 'Backspace': e.preventDefault(); handleKeyPress('Backspace'); break; case '-': e.preventDefault(); handleKeyPress('Suffix'); break; case '/': e.preventDefault(); const isSymbolMode = symbolKeyboard.style.display === 'block'; handleKeyPress(isSymbolMode ? 'SwitchToAlpha' : 'Symbols'); break; case 'ArrowLeft': case 'ArrowRight': e.preventDefault(); ime.navigateCandidates(key); render(); break; default: const num = parseInt(key, 10); if (num >= 1 && num <= 9 && ime.candidates.length > 0) { e.preventDefault(); handleCommit(ime.commitCandidate(num - 1)); } break; } }); }
setupKeyboards();
setupEventListeners();
render(); editor.focus();
</script>
</body>
</html>
以下是传统蒙古文输入法 v7.2 (逻辑精简版)
<!DOCTYPE html>
<html lang="mn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>传统蒙古文输入法 v7.2 (逻辑精简版)</title>
<style>
/* CSS 与 v7.1 完全一致 */
:root { --key-bg: #fff; --key-text: #000; --key-special-bg: #acb4be; --popup-bg: #e9ecef; --popup-shadow: rgba(0,0,0,0.25); --primary-color: #007bff; --grammar-btn-bg: #e9ecef; --grammar-btn-text: #495057; }
body { font-family: -apple-system, sans-serif; background-color: #e9ecef; margin: 0; display: flex; flex-direction: column; height: 100vh; }
.main-content { flex-grow: 1; padding: 10px; display: flex; flex-direction: column; }
#editor { width: 100%; flex-grow: 1; background: white; border: 1px solid #ccc; border-radius: 8px; padding: 15px; font-size: 28px; writing-mode: vertical-rl; text-orientation: mixed; font-family: 'Mongolian Baiti', 'Menksoft', 'Noto Sans Mongolian', sans-serif; line-height: 1.6; cursor: text; box-sizing: border-box; white-space: pre-wrap; }
#editor:focus { outline: none; border-color: var(--primary-color); }
.composing-text { color: var(--primary-color); border-right: 2px solid var(--primary-color); }
#candidate-window { flex-shrink: 0; background: #fff; padding: 5px; overflow-x: auto; white-space: nowrap; border-bottom: 1px solid #ddd; min-height: 42px; box-sizing: border-box; }
#candidate-window .candidate { display: inline-block; font-size: 22px; padding: 8px 12px; margin: 2px; border-radius: 5px; font-family: 'Mongolian Baiti', 'Menksoft', 'Noto Sans Mongolian'; cursor: pointer; }
#candidate-window .candidate.selected { background-color: var(--primary-color); color: white; }
#candidate-window .grammar-btn { background-color: #f8f9fa; color: #212529; border: 1px solid #adb5bd; box-shadow: 0 1px 1px rgba(0,0,0,0.075); font-weight: 500; }
#candidate-window .grammar-btn:hover { background-color: #e9ecef; }
#virtual-keyboard, #symbol-keyboard { position: fixed; bottom: 0; left: 0; right: 0; padding: 5px; background-color: #d1d5db; user-select: none; }
.keyboard-row { display: flex; justify-content: center; margin: 5px 0; }
.keyboard-key { flex: 1; margin: 0 3px; padding: 15px 5px; font-size: 20px; text-align: center; border: none; border-radius: 5px; background-color: var(--key-bg); box-shadow: 0 1px 1px rgba(0,0,0,0.2); cursor: pointer; touch-action: manipulation; position: relative; }
.keyboard-key.special { background-color: var(--key-special-bg); flex-grow: 1.2; }
.key-popup { position: absolute; bottom: 120%; left: 50%; transform: translateX(-50%); display: none; background-color: var(--popup-bg); border-radius: 8px; padding: 5px; box-shadow: 0 4px 10px var(--popup-shadow); display: flex; gap: 5px; z-index: 1000; }
.key-popup button { background: #fff; border: none; font-size: 24px; padding: 10px 15px; border-radius: 5px; font-family: 'Mongolian Baiti', 'Menksoft', 'Noto Sans Mongolian'; }
#symbol-keyboard { display: none; }
.instructions { background-color: #fff; border: 1px solid #ddd; padding: 15px; border-radius: 8px; line-height: 1.7; font-size: 14px; margin-bottom: 20px; }
</style>
</head>
<body>
<div class="main-content">
<div class="instructions">
<h2>v7.2:逻辑精简版</h2>
<p><strong>核心逻辑重构:</strong> 输入流程更清晰,状态转换更明确。</p>
<ul>
<li><b>单词输入:</b> 正常输入,空格/回车/数字键提交。</li>
<li><b>进入词缀:</b> 按 <b>`=`</b> 键。</li>
<li><b>选择词缀:</b> 点击词缀按钮,可连续选择。</li>
<li><b>结束词缀:</b> 按空格/回车,词缀链最终确定并加空格。</li>
<li><b>词缀中按字母:</b> 结束词缀并加空格,开始新词。</li>
</ul>
</div>
<div id="editor" tabindex="0"></div>
<div id="candidate-window"></div>
</div>
<div id="virtual-keyboard"> <!-- HTML is injected by JS --> </div>
<div id="symbol-keyboard"> <!-- HTML is injected by JS --> </div>
<script>
// --- 核心引擎、规则、词典 (与v7.1一致) ---
const MVS = '\u180E'; const NNBSP = '\u180A'; const FVS1 = '\u180B'; const FVS2 = '\u180C';
const RULES = { latinToMongolMap: { 'a': 'ᠠ', 'e': 'ᠡ', 'i': 'ᠢ', 'o': 'ᠣ', 'u': 'ᠤ', 'v': 'ᠥ', 'w': 'ᠦ', 'n': 'ᠨ', 'b': 'ᠪ', 'p': 'ᠫ', 'h': 'ᠬ', 'g': 'ᠭ', 'x': 'ᠺ', 'm': 'ᠮ', 'l': 'ᠯ', 's': 'ᠰ', 'd': 'ᠳ', 't': 'ᠲ', 'c': 'ᠴ', 'j': 'ᠵ', 'y': 'ᠶ', 'r': 'ᠷ', 'f': 'ᠹ', 'z': 'ᠽ', 'k': 'ᠺ', 'q': 'ᠬ', 'ng': 'ᠩ', 'ch': 'ᠴ', 'sh': 'ᠱ', 'zh': 'ᠵ' }, vowels: { masculine: ['ᠠ', 'ᠣ', 'ᠤ'], feminine: ['ᠡ', 'ᠥ', 'ᠦ'], neutral: ['ᠢ'] } };
const DICTIONARY = { ...{'hemnelte':'ᠬᠡᠮᠨᠡᠯᠲᠡ','amidural':'ᠠᠮᠢᠳᠤᠷᠠᠯ','hev':'ᠬᠡᠪ','mayig':'ᠮᠠᠶᠢᠭ','jebhen':'ᠵᠥᠪᠬᠡᠨ','mvnggv':'ᠮᠥᠩᠭᠦ','gamnahu':'ᠭᠠᠮᠨᠠᠬᠤ','bisi':'ᠪᠢᠰᠢ','harin':'ᠬᠠᠷᠢᠨ','uhamsartu':'ᠤᠬᠠᠮᠰᠠᠷᠲᠤ','songgulta':'ᠰᠣᠩᠭᠤᠯᠲᠠ','bolgadag':'ᠪᠣᠯᠭᠠᠳᠠᠭ','hvrvnge':'ᠬᠥᠷᠥᠩᠭᠡ','erhe':'ᠡᠷᠬᠡ','cilwge':'ᠴᠢᠯᠦᠭᠡ','abcirahu':'ᠠᠪᠴᠢᠷᠠᠬᠤ','busu':'ᠪᠤᠰᠤ','ilehw':'ᠢᠯᠡᠬᠦᠦ','emjeg':'ᠡᠮᠵᠡᠭ','baidal':'ᠪᠠᠶᠢᠳᠠᠯ','hvrgehw':`ᠬᠦᠷᠭᠡᠬᠦ${FVS1}`,'eresdel':'ᠡᠷᠡᠰᠳᠡᠯ','buyu':'᪤ᠤᠶᠤ','ayul':'ᠠᠶᠤᠯ','bolhu':'ᠪᠣᠯᠬᠤ','oncalagsan':`ᠣᠨᠴᠠᠯᠠᠭ${FVS2}ᠰᠠᠨ`,'ed':'ᠡᠳ᠋','jasag':'ᠵᠠᠰᠠᠭ','bulun':'ᠪᠤᠯᠤᠨ','sedxil':'ᠰᠡᠳᠬᠢᠯ','jvi':'ᠵᠦᠢ','tala':'ᠲᠠᠯᠠ','vilgehw':'ᠦᠢᠯᠡᠬᠦ᠋','cihula':'ᠴᠢᠬᠤᠯᠠ','bvgved':'ᠪᠥᠭᠡᠳ','dergede':'ᠳᠡᠷᠭᠡᠳᠡ','mini':'ᠮᠢᠨᠢ','baiju':'ᠪᠠᠢᠵᠤ','hair-a':'ᠬᠠᠢᠷᠠ','dengdegww':'ᠳᠡᠩᠳᠡᠭᠦᠦ','oirahan':'ᠣᠢᠷᠠᠬᠠᠨ','ci':'ᠴᠢ','hajagu':'ᠬᠠᠵᠠᠭᠤ','ene':'ᠡᠨᠡ','olan':'ᠣᠯᠠᠨ','jil':'ᠵᠢᠯ','cimai':'ᠴᠢᠮᠠᠢ','tanigsan':'ᠲᠠᠨᠢᠭᠰᠠᠨ','wgei':'ᠦᠭᠡᠢ','agucila':'ᠠᠭᠤᠴᠢᠯᠠ','nama':'ᠨᠠᠮᠠ','cinu':'ᠴᠢᠨᠤ', 'harbulga': 'ᠬᠠᠷᠪᠤᠯᠭᠠ','sairha': 'ᠰᠠᠢᠷᠬᠠ','wrgvlji': 'ᠦᠷᠭᠦᠯᠵᠢ','hereg': 'ᠬᠡᠷᠡᠭ','jambutib': 'ᠽᠠᠮᠪᠤᠲᠢᠪ','oros': 'ᠣᠷᠣᠰ'}, 'eji': 'ᠡᠵᠢ', 'harag': 'ᠬᠠᠷᠠᠭ' };
const SUFFIX_RULES = { ...{'tu': { masculine: 'ᠲᠤ', feminine: 'ᠲᠦ', neutral: 'ᠲᠤ', endings: ['ᠭ', 'ᠪ', 'ᠰ', 'ᠷ'] }, 'du': { masculine: 'ᠳᠤ', feminine: 'ᠳᠦ', neutral: 'ᠳᠤ' }, 'u': { masculine: 'ᠤ', feminine: 'ᠦ', neutral: 'ᠦ', endings: ['ᠭ', 'ᠪ', 'ᠳ', 'ᠰ', 'ᠷ', 'ᠯ', 'ᠮ', 'ᠨ'] }, 'un': { masculine: 'ᠤᠨ', feminine: 'ᠦᠨ', neutral: 'ᠢᠨ' }, 'yin': { all: `ᠶ᠋ᠢᠨ` }, 'nugud': { masculine: 'ᠨᠤᠭᠤᠳ', feminine: 'ᠨᠦᠭᠦᠳ' }, 'ud': { masculine: 'ᠤᠳ', feminine: 'ᠦᠳ', neutral: 'ᠤᠳ' }, 'nar': { all: 'ᠨᠠᠷ' }, 'degen': { masculine: 'ᠳᠠᠭᠠᠨ', feminine: 'ᠳᠡᠭᠡᠨ' }, 'aca': { masculine: 'ᠠᠴᠠ', feminine: 'ᠡᠴᠡ', neutral: 'ᠠᠴᠠ' }, 'bar': { masculine: 'ᠪᠠᠷ', feminine: 'ᠪᠡᠷ', neutral: 'ᠢᠶᠠᠷ' }, 'ban': { masculine: 'ᠪᠠᠨ', feminine: 'ᠪᠡᠨ', neutral: 'ᠢᠶᠠᠨ' }, 'tai': { masculine: 'ᠲᠠᠢ', feminine: 'ᠲᠡᠢ', neutral: 'ᠲᠠᠢ' }, 'ni': { all: 'ᠨᠢ' }, 'yi': { all: `ᠶ᠋ᠢ` }, 'i': { all: 'ᠢ' }}, 'a': { masculine: 'ᠠ', feminine: 'ᠡ', neutral: 'ᠠ'} };
class MongolianIME {
constructor(rules, dictionary, suffixRules) {
this.rules = rules; this.dictionary = dictionary; this.suffixRules = suffixRules;
this.dictKeys = Object.keys(dictionary);
this.resetAllState();
}
resetAllState() { // 更彻底的重置
this.composingLatin = '';
this.composingMongol = '';
this.candidates = [];
this.selectedCandidateIndex = 0;
this.isSuffixMode = false;
this.currentBaseWordForSuffix = ''; // 用于词缀模式的基础词
}
// --- 单词输入模式核心逻辑 ---
processLetterInput(key) {
if (this.isSuffixMode) { // 如果在词缀模式下按字母,则退出词缀模式,并开始新词
this.exitSuffixModeAndPrepareNewWord();
}
this.composingLatin += key.toLowerCase();
this.updateWordCandidates();
}
updateWordCandidates() {
if (this.composingLatin === '') { this.resetAllState(); return; }
let candidates = new Set();
if (this.dictionary[this.composingLatin]) { candidates.add(this.dictionary[this.composingLatin]); }
const mainAlgoResult = this._convertLatinToMongol(this.composingLatin);
if (mainAlgoResult && !candidates.has(mainAlgoResult)) { candidates.add(mainAlgoResult); }
// (智能候补逻辑可以暂时简化或移除,以确保核心流程稳定)
this.dictKeys.filter(k => k.startsWith(this.composingLatin) && k !== this.composingLatin).forEach(k => {
if (!candidates.has(this.dictionary[k])) candidates.add(this.dictionary[k]);
});
this.candidates = Array.from(candidates);
this.selectedCandidateIndex = 0;
this.composingMongol = this.candidates[0] || mainAlgoResult || ''; // 优先显示候选词
}
commitCurrentWord(addSpace = true) {
let wordToCommit = '';
if (this.candidates.length > 0 && this.candidates[this.selectedCandidateIndex]) {
wordToCommit = this.candidates[this.selectedCandidateIndex];
} else if (this.composingMongol) {
wordToCommit = this.composingMongol;
}
if (wordToCommit) {
committedText += wordToCommit + (addSpace ? ' ' : '');
historyStack.push({ type: 'word', content: wordToCommit + (addSpace ? ' ' : '') });
}
this.resetAllState(); // 提交单词后,彻底重置
}
// --- 词缀选择模式核心逻辑 ---
enterSuffixMode() {
if (this.composingLatin) { // 如果有正在输入的词,先提交
this.commitCurrentWord(false); // 提交词干,但不加空格
}
const allCommittedWords = committedText.trim().split(/[\s\u202F《》᠄᠃᠂︖︕·]+/);
this.currentBaseWordForSuffix = allCommittedWords.pop() || '';
if (this.currentBaseWordForSuffix) {
this.isSuffixMode = true;
this.composingLatin = ''; // 清空拉丁输入
this.composingMongol = ''; // 清空蒙古文转换
this.updateSuffixCandidates();
}
}
updateSuffixCandidates() {
if (!this.isSuffixMode || !this.currentBaseWordForSuffix) return;
this.candidates = this._generateSuffixesForWord(this.currentBaseWordForSuffix);
this.selectedCandidateIndex = 0; // 在词缀模式下,可以不强调“选中”,因为是按钮点击
}
commitSuffix(suffixWithNnbsp) { // suffixWithNnbsp 是类似 "ᠤᠨ"
if (!this.isSuffixMode || !suffixWithNnbsp) return;
committedText += suffixWithNnbsp; // 直接附加带NNBSP的词缀
historyStack.push({ type: 'suffix', content: suffixWithNnbsp });
// 更新基础词,为下一级词缀做准备
this.currentBaseWordForSuffix += suffixWithNnbsp;
this.updateSuffixCandidates(); // 刷新词缀按钮
}
exitSuffixModeAndFinalize(addSpace = true) {
if (this.isSuffixMode) {
if (addSpace) {
committedText += ' ';
historyStack.push({ type: 'space', content: ' ' });
}
this.isSuffixMode = false;
this.resetAllState(); // 退出词缀模式后,彻底重置
}
}
exitSuffixModeAndPrepareNewWord() {
this.exitSuffixModeAndFinalize(true); // 结束词缀并加空格
}
// --- 通用辅助函数 ---
_convertLatinToMongol(latin) {
let mongolWord = ''; let tempLatin = latin;
const ligatures = ['ng', 'ch', 'sh', 'zh'];
ligatures.forEach(lig => { tempLatin = tempLatin.replace(new RegExp(lig, 'g'), this.rules.latinToMongolMap[lig]); });
for (const char of tempLatin) { mongolWord += this.rules.latinToMongolMap[char] || ''; }
return mongolWord.replace(/(.)\1/g, (match, char) => (Object.values(this.rules.vowels).flat().includes(char)) ? char + MVS + char : match);
}
_getWordGender(word) {
for (let i = word.length - 1; i >= 0; i--) {
const char = word[i];
if (this.rules.vowels.masculine.includes(char)) return 'masculine';
if (this.rules.vowels.feminine.includes(char)) return 'feminine';
}
return 'neutral';
}
_generateSuffixesForWord(baseWord) {
const gender = this._getWordGender(baseWord);
const lastCharOfBase = baseWord.slice(-1); // 获取基础词的最后一个字符
const suffixCandidates = [];
const ruleGroups = {dative: ['du', 'tu'], genitive: ['un', 'u', 'yin'], plural: ['ud', 'nugud', 'nar'], reflexive: ['degen'], ablative: ['aca'], instrumental: ['bar'], comitative:['ban'], possessive: ['ni'], accusative: ['yi', 'i'], adjectival: ['tai'], verbal_a: ['a'] };
for (const group in ruleGroups) {
let appliedInGroup = false;
for (const key of ruleGroups[group]) {
if (appliedInGroup) continue;
const rule = this.suffixRules[key];
let suffixForm = '';
if (rule.endings && rule.endings.includes(lastCharOfBase)) { // 优先匹配辅音结尾规则
if (gender === 'feminine' && rule.feminine) suffixForm = rule.feminine;
else if (rule.masculine) suffixForm = rule.masculine;
else if (rule.all) suffixForm = rule.all;
} else if (!rule.endings) { // 默认元音和谐
if (rule.all) suffixForm = rule.all;
else if (gender === 'neutral' && rule.neutral) suffixForm = rule.neutral;
else if (gender === 'feminine' && rule.feminine) suffixForm = rule.feminine;
else if (rule.masculine) suffixForm = rule.masculine;
}
if (suffixForm) {
suffixCandidates.push(NNBSP + suffixForm);
appliedInGroup = true;
}
}
}
return suffixCandidates;
}
navigateCandidates(direction) { // 主要用于单词输入模式
if (this.isSuffixMode || this.candidates.length === 0) return;
if (direction === 'ArrowRight') { this.selectedCandidateIndex = (this.selectedCandidateIndex + 1) % this.candidates.length; }
else if (direction === 'ArrowLeft') { this.selectedCandidateIndex = (this.selectedCandidateIndex - 1 + this.candidates.length) % this.candidates.length; }
this.composingMongol = this.candidates[this.selectedCandidateIndex] || '';
}
}
const ime = new MongolianIME(RULES, DICTIONARY, SUFFIX_RULES);
const editor = document.getElementById('editor'); const candidateWindow = document.getElementById('candidate-window');
const mainKeyboard = document.getElementById('virtual-keyboard'); const symbolKeyboard = document.getElementById('symbol-keyboard');
let committedText = ''; let historyStack = []; // historyStack 用于智能退格
// --- UI 渲染与事件处理 ---
function render() {
const displayComposingMongol = ime.isSuffixMode ? '' : ime.composingMongol; // 词缀模式不显示composingMongol
const composingSpan = `<span class="composing-text">${displayComposingMongol}</span>`;
editor.innerHTML = committedText + (ime.composingLatin && !ime.isSuffixMode ? composingSpan : '');
candidateWindow.innerHTML = '';
if (ime.isSuffixMode) {
ime.candidates.forEach((suffixCandidate, index) => { // suffixCandidate is like "ᠤᠨ"
const btn = document.createElement('span');
btn.className = 'candidate grammar-btn';
btn.textContent = suffixCandidate.replace(NNBSP, ''); // 显示时去掉NNBSP
btn.onclick = () => {
ime.commitSuffix(suffixCandidate); // 提交带NNBSP的词缀
render(); // 点击后立刻刷新界面
};
candidateWindow.appendChild(btn);
});
} else {
ime.candidates.forEach((wordCandidate, index) => {
const span = document.createElement('span');
span.className = 'candidate';
if (index === ime.selectedCandidateIndex) { span.classList.add('selected'); }
span.textContent = wordCandidate;
span.onclick = () => {
ime.selectedCandidateIndex = index; // 更新选中项
ime.commitCurrentWord(true);
render();
};
candidateWindow.appendChild(span);
});
}
}
// v7.2 核心: 全新的 handleKeyPress
function handleKeyPress(key, value = null) {
if (navigator.vibrate) { navigator.vibrate(20); }
// 字母键 (a-z)
if (key.length === 1 && key >= 'a' && key <= 'z') {
ime.processLetterInput(key);
}
// 功能键
else {
switch (key) {
case 'Space':
case 'Enter': // 将回车视为与空格相同的提交操作
if (ime.isSuffixMode) {
ime.exitSuffixModeAndFinalize(true);
} else if (ime.composingLatin) {
ime.commitCurrentWord(true);
} else { // 无输入时,直接加空格
committedText += ' ';
historyStack.push({ type: 'space', content: ' ' });
}
break;
case 'SuffixTrigger': // 即 '=' 键
if (ime.isSuffixMode) { // 如果已在词缀模式,再按=,则视为结束当前词缀链
ime.exitSuffixModeAndFinalize(true);
} else {
ime.enterSuffixMode();
}
break;
case 'Backspace':
if (ime.composingLatin && !ime.isSuffixMode) {
ime.composingLatin = ime.composingLatin.slice(0, -1);
ime.updateWordCandidates();
} else if (ime.isSuffixMode) {
ime.exitSuffixModeAndFinalize(false); // 退出词缀模式,但不加空格
} else if (historyStack.length > 0) {
const lastChunk = historyStack.pop();
committedText = committedText.slice(0, -lastChunk.content.length);
}
break;
case 'Symbols':
mainKeyboard.style.display = 'none'; symbolKeyboard.style.display = 'block';
break;
case 'SwitchToAlpha':
mainKeyboard.style.display = 'block'; symbolKeyboard.style.display = 'none';
break;
case 'Symbol':
if (ime.isSuffixMode) ime.exitSuffixModeAndFinalize(false); // 如果在词缀模式输符号,先退出
committedText += value; historyStack.push({ type: 'symbol', content: value });
break;
// 物理键盘数字选词 (仅在单词输入模式)
default:
if (!ime.isSuffixMode) {
const num = parseInt(key, 10);
if (num >= 1 && num <= 9 && ime.candidates.length >= num) {
ime.selectedCandidateIndex = num - 1;
ime.commitCurrentWord(true);
}
}
break;
}
}
render();
}
function setupKeyboards() { mainKeyboard.innerHTML = ` <div class="keyboard-row"> <button class="keyboard-key" data-key="a">a</button> <button class="keyboard-key" data-key="e">e</button> <button class="keyboard-key" data-key="i">i</button> <button class="keyboard-key" data-key="o" data-popup="v,u,w">o</button> </div> <div class="keyboard-row"> <button class="keyboard-key" data-key="n" data-popup="ng">n</button> <button class="keyboard-key" data-key="b" data-popup="p">b</button> <button class="keyboard-key" data-key="d" data-popup="t">d</button> <button class="keyboard-key" data-key="h" data-popup="g,x">h</button> </div> <div class="keyboard-row"> <button class="keyboard-key" data-key="s" data-popup="sh,ch,j,zh">s</button> <button class="keyboard-key" data-key="m">m</button> <button class="keyboard-key" data-key="l">l</button> <button class="keyboard-key" data-key="r">r</button> </div> <div class="keyboard-row"> <button class="keyboard-key special" data-key="Symbols">...</button> <button class="keyboard-key" data-key="y">y</button> <button class="keyboard-key special" data-key="SuffixTrigger">=</button> <button class="keyboard-key special" data-key="Space">空格</button> <button class="keyboard-key special" data-key="Backspace">⌫</button> </div>`; symbolKeyboard.innerHTML = ` <div class="keyboard-row"> <button class="keyboard-key" data-key="Symbol" data-value="《">《</button> <button class="keyboard-key" data-key="Symbol" data-value="》">》</button> <button class="keyboard-key" data-key="Symbol" data-value="᠄">᠄</button> <button class="keyboard-key" data-key="Symbol" data-value="᠃">᠃</button> </div> <div class="keyboard-row"> <button class="keyboard-key" data-key="Symbol" data-value="᠂">᠂</button> <button class="keyboard-key" data-key="Symbol" data-value="︖">︖</button> <button class="keyboard-key" data-key="Symbol" data-value="︕">︕</button> <button class="keyboard-key" data-key="Symbol" data-value="·">·</button> </div> <div class="keyboard-row"> <button class="keyboard-key" data-key="Symbol" data-value="🍽️">🍽️</button> <button class="keyboard-key" data-key="Symbol" data-value="✂️">✂️</button> <button class="keyboard-key" data-key="Symbol" data-value="🎭">🎭</button> <button class="keyboard-key" data-key="Symbol" data-value="✔️">✔️</button> </div> <div class="keyboard-row"> <button class="keyboard-key special" data-key="SwitchToAlpha">ABC</button> <button class="keyboard-key special" data-key="Space">空格</button> <button class="keyboard-key special" data-key="Backspace">⌫</button> </div>`; }
function setupEventListeners() { document.querySelectorAll('.keyboard-key').forEach(key => { key.addEventListener('click', (e) => { document.querySelectorAll('.key-popup').forEach(p => p.remove()); handleKeyPress(e.currentTarget.dataset.key, e.currentTarget.dataset.value); }); }); let longPressTimer; mainKeyboard.querySelectorAll('.keyboard-key[data-popup]').forEach(key => { const primaryKey = key.dataset.key; const popupKeys = key.dataset.popup; const showPopup = () => { document.querySelectorAll('.key-popup').forEach(p => p.remove()); if (!popupKeys) return; const popup = document.createElement('div'); popup.className = 'key-popup'; popupKeys.split(',').forEach(popupKey => { const button = document.createElement('button'); button.textContent = RULES.latinToMongolMap[popupKey] || popupKey.toUpperCase(); button.onclick = (e) => { e.stopPropagation(); handleKeyPress(popupKey); popup.remove(); }; popup.appendChild(button); }); key.appendChild(popup); popup.style.display = 'flex'; }; const handlePressStart = (e) => { e.preventDefault(); longPressTimer = setTimeout(showPopup, 350); }; const handlePressEnd = (e) => { e.preventDefault(); clearTimeout(longPressTimer); }; key.addEventListener('mousedown', handlePressStart); key.addEventListener('mouseup', handlePressEnd); key.addEventListener('touchstart', handlePressStart, { passive: false }); key.addEventListener('touchend', handlePressEnd); });
// 物理键盘监听
window.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
let keyToProcess = '';
if (e.key.length === 1 && e.key >= 'a' && e.key <= 'z') { keyToProcess = e.key; }
else if (e.key === ' ' || e.key === 'Enter') { keyToProcess = 'Space'; } // Enter 也视为 Space
else if (e.key === 'Backspace') { keyToProcess = 'Backspace'; }
else if (e.key === '=' || e.key === '-') { keyToProcess = 'SuffixTrigger'; }
else if (e.key === '/') { keyToProcess = symbolKeyboard.style.display === 'block' ? 'SwitchToAlpha' : 'Symbols'; }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
if (!ime.isSuffixMode) { // 仅在单词输入模式下导航候选词
ime.navigateCandidates(e.key);
render();
}
e.preventDefault(); return;
} else { // 数字键选词
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
keyToProcess = e.key; // 将数字作为key传给handleKeyPress
}
}
if (keyToProcess) {
e.preventDefault();
handleKeyPress(keyToProcess);
}
});
}
setupKeyboards();
setupEventListeners();
render();
editor.focus();
</script>
</body>
</html>
暂无评论内容