|
440 | 440 |
|
441 | 441 | // --- 簡繁切換狀態 --- |
442 | 442 | let isSimplifiedMode = false; // 預設為繁體模式 |
443 | | - // 將 TS_MAP 改為可變的變數,並非同步載入 |
| 443 | + // 簡繁轉換表將從 ts.txt 非同步載入 |
444 | 444 | let TS_MAP = null; |
445 | 445 |
|
446 | 446 | /** |
|
458 | 458 | const text = await response.text(); |
459 | 459 |
|
460 | 460 | const newMap = {}; |
461 | | - // 根據換行符號切割,然後遍歷每一行 |
| 461 | + // 每一行是一個轉換對:繁體字\t簡體字 |
462 | 462 | const lines = text.trim().split('\n'); |
463 | 463 |
|
464 | 464 | for (const line of lines) { |
|
958 | 958 | // **修正**:如果浮層是開啟的,且點擊的是「簡繁切換鍵」(tool 1),則只切換模式並立即返回,不影響浮層狀態 |
959 | 959 | if (isOverlayActive && tool === "1") { |
960 | 960 | isSimplifiedMode = !isSimplifiedMode; |
| 961 | + target.innerHTML = isSimplifiedMode ? "简" : "繁"; // 立即更新按鈕文字 |
961 | 962 | console.log("TS mode switched while overlay active. Overlay preserved."); |
962 | 963 | // 這裡不需要 showCandidates,因為浮層開啟時,下方的 candidateDisplay 應該保持清空或顯示工具列 |
963 | 964 | // 但我們不希望它觸發重繪 (showCandidates) 導致浮層閃爍,因此直接 return |
|
1114 | 1115 | } |
1115 | 1116 | break; |
1116 | 1117 | case KEYCODE_RIGHT: |
1117 | | - if (cursorPosition < committedText.length) { |
| 1118 | + // 游標位置的限制應是邏輯字符的數量 |
| 1119 | + if (cursorPosition < Array.from(committedText).length) { |
1118 | 1120 | cursorPosition++; |
1119 | 1121 | updateOutputDisplay(); |
1120 | 1122 | } |
1121 | 1123 | break; |
1122 | 1124 | case KEYCODE_UP: |
1123 | 1125 | case KEYCODE_DOWN: |
1124 | 1126 | // 簡化處理:在沒有完整編輯器邏輯時,上下鍵暫時模擬左右移動 |
| 1127 | + const maxLen = Array.from(committedText).length; |
1125 | 1128 | if (code === KEYCODE_UP && cursorPosition > 0) { |
1126 | 1129 | cursorPosition--; |
1127 | 1130 | updateOutputDisplay(); |
1128 | | - } else if (code === KEYCODE_DOWN && cursorPosition < committedText.length) { |
| 1131 | + } else if (code === KEYCODE_DOWN && cursorPosition < maxLen) { |
1129 | 1132 | cursorPosition++; |
1130 | 1133 | updateOutputDisplay(); |
1131 | 1134 | } |
|
1135 | 1138 | updateOutputDisplay(); |
1136 | 1139 | break; |
1137 | 1140 | case KEYCODE_DEL_F: // Forward Delete |
1138 | | - if (cursorPosition < committedText.length) { |
1139 | | - committedText = committedText.slice(0, cursorPosition) + committedText.slice(cursorPosition + 1); |
| 1141 | + const chars = Array.from(committedText); |
| 1142 | + if (cursorPosition < chars.length) { |
| 1143 | + chars.splice(cursorPosition, 1); |
| 1144 | + committedText = chars.join(''); |
1140 | 1145 | updateOutputDisplay(); |
1141 | 1146 | } |
1142 | 1147 | break; |
|
1283 | 1288 | compositionHtml = formatComposition(currentCode); |
1284 | 1289 | } |
1285 | 1290 |
|
1286 | | - const fullTextLength = committedText.length; |
| 1291 | + // 關鍵:使用 Array.from 處理 committedText,確保能正確遍歷 Emoji |
| 1292 | + const committedChars = Array.from(committedText); |
| 1293 | + const fullTextLength = committedChars.length; |
1287 | 1294 |
|
1288 | 1295 | while (currentPos <= fullTextLength) { |
1289 | 1296 |
|
1290 | 1297 | if (softWrapPoints.includes(currentPos)) { |
1291 | | - if (currentPos > 0 && committedText[currentPos - 1] !== '\n') { |
| 1298 | + if (currentPos > 0 && committedChars[currentPos - 1] !== '\n') { |
1292 | 1299 | outputHtml += NON_BREAK_ANCHOR; |
1293 | 1300 | } |
1294 | 1301 | } |
|
1299 | 1306 | } |
1300 | 1307 |
|
1301 | 1308 | if (currentPos < fullTextLength) { |
1302 | | - const char = committedText[currentPos]; |
| 1309 | + const char = committedChars[currentPos]; |
1303 | 1310 | if (char === '\n') { |
1304 | 1311 | outputHtml += '<br>'; |
1305 | 1312 | } else { |
|
1320 | 1327 |
|
1321 | 1328 | /** |
1322 | 1329 | * 提交文字 (包含簡繁轉換邏輯) |
| 1330 | + * [修正:使用 Array.from() 確保 Emoji 正確插入] |
1323 | 1331 | */ |
1324 | 1332 | function commitText(textToCommit) { |
1325 | 1333 | // 在提交前進行簡繁轉換 |
1326 | 1334 | const finalCommitText = convertToSimplified(textToCommit); |
1327 | 1335 |
|
1328 | | - committedText = committedText.slice(0, cursorPosition) + finalCommitText + committedText.slice(cursorPosition); |
1329 | | - cursorPosition += finalCommitText.length; |
| 1336 | + // 關鍵修改:使用 Array.from() 將字串轉換為可正確處理代理對的字符陣列 |
| 1337 | + const committedChars = Array.from(committedText); |
| 1338 | + const charsToInsert = Array.from(finalCommitText); |
| 1339 | + |
| 1340 | + // 在游標位置插入新的字符 |
| 1341 | + committedChars.splice(cursorPosition, 0, ...charsToInsert); |
| 1342 | + committedText = committedChars.join(''); // 重組字串 |
| 1343 | + |
| 1344 | + // 游標移動的步數是插入字符的數量 |
| 1345 | + cursorPosition += charsToInsert.length; |
| 1346 | + |
1330 | 1347 | preferredOffset = -1; |
1331 | 1348 | resetInputState(); |
1332 | 1349 | } |
1333 | 1350 |
|
| 1351 | + /** |
| 1352 | + * 處理刪除鍵 |
| 1353 | + * [修正:使用 Array.from() 確保 Emoji 正確刪除] |
| 1354 | + */ |
1334 | 1355 | function backspaceOutput() { |
1335 | 1356 | if (!isEnglishMode && !isSymbolMode && currentCode.length > 0) { |
1336 | 1357 | currentCode = currentCode.slice(0, -1); |
1337 | 1358 | updateStateAndDisplay(); |
1338 | 1359 | } else if (cursorPosition > 0) { |
1339 | | - committedText = committedText.slice(0, cursorPosition - 1) + committedText.slice(cursorPosition); |
1340 | | - cursorPosition -= 1; |
| 1360 | + |
| 1361 | + // 關鍵修改:使用 Array.from() 將字串轉換為可正確處理 Emoji 的字符陣列 |
| 1362 | + const committedChars = Array.from(committedText); |
| 1363 | + |
| 1364 | + // 刪除游標前的一個字符 (無論它佔用多少個碼元) |
| 1365 | + committedChars.splice(cursorPosition - 1, 1); |
| 1366 | + |
| 1367 | + committedText = committedChars.join(''); // 重組字串 |
| 1368 | + cursorPosition -= 1; // 游標只移動一步 (因為刪除了一個可視字符) |
| 1369 | + |
1341 | 1370 | preferredOffset = -1; |
1342 | 1371 | updateOutputDisplay(); |
1343 | 1372 | resetInputState(); |
|
1574 | 1603 | } |
1575 | 1604 | function handleSearchKeydown(event) { if (event.key === 'Backspace') { event.stopPropagation(); } } |
1576 | 1605 |
|
| 1606 | + /** |
| 1607 | + * 將 DOM 文本節點偏移量 (UTF-16 碼元索引) 映射到邏輯字符索引 (可視字符索引) |
| 1608 | + * [修正:使用 Array.from() 處理 Emoji 代理對] |
| 1609 | + */ |
1577 | 1610 | function mapDOMOffsetToTextIndex(targetNode, targetOffset) { |
1578 | 1611 | let committedIndex = 0; |
1579 | 1612 | const outputDiv = document.getElementById('text-output'); |
|
1582 | 1615 | outputDiv, |
1583 | 1616 | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, |
1584 | 1617 | { acceptNode: (node) => { |
| 1618 | + // 排除游標和正在編輯的文字 |
1585 | 1619 | if (node.nodeType === Node.ELEMENT_NODE && (node.classList.contains('cursor') || node.classList.contains('composition-text'))) { |
1586 | 1620 | return NodeFilter.FILTER_SKIP; |
1587 | 1621 | } |
|
1595 | 1629 | if (node.nodeType === Node.TEXT_NODE) { |
1596 | 1630 | const textContent = node.textContent; |
1597 | 1631 |
|
| 1632 | + // 關鍵:將內容分解為可視字符(Grapheme Clusters) |
| 1633 | + const fullChars = Array.from(textContent); |
| 1634 | + |
1598 | 1635 | if (node === targetNode) { |
1599 | | - let count = 0; |
1600 | | - for (let i = 0; i < targetOffset; i++) { |
1601 | | - const char = textContent[i]; |
| 1636 | + let codeUnitCount = 0; // 累積的 UTF-16 碼元數 |
| 1637 | + let charCount = 0; // 累積的邏輯字符數 |
| 1638 | + |
| 1639 | + for (const char of fullChars) { |
| 1640 | + if (codeUnitCount >= targetOffset) { |
| 1641 | + // 點擊發生在該字符的開頭或之前,charCount 即為正確位置。 |
| 1642 | + break; |
| 1643 | + } |
| 1644 | + |
| 1645 | + // 累積碼元數 (Emoji 可能會讓 char.length > 1) |
| 1646 | + codeUnitCount += char.length; |
| 1647 | + |
| 1648 | + // 累積邏輯字符數 (排除特殊錨點) |
1602 | 1649 | if (char !== NON_BREAK_ANCHOR && char !== '\u200c') { |
1603 | | - count++; |
| 1650 | + charCount++; |
| 1651 | + } |
| 1652 | + |
| 1653 | + // 邊界修正:如果點擊恰好落在字元中間,瀏覽器可能會給出字元內部的 offset。 |
| 1654 | + // 為了保險,如果 targetOffset 在當前字符範圍內,但更接近字符結尾,我們將游標放在字符之後。 |
| 1655 | + // 這裡我們採用最簡單的:只要 currentCodeUnitIndex 加上該字符長度大於 targetOffset,就停止。 |
| 1656 | + if (codeUnitCount >= targetOffset) { |
| 1657 | + // 如果 targetOffset 落在這個字元中間或結尾,我們通常將游標放在這個字元之後。 |
| 1658 | + // 由於上面的 charCount 已經累加,這裡直接跳出即可。 |
| 1659 | + // *若要實現更精確的「四捨五入」游標,此處需複雜邏輯,但為了穩定性,我們信任單向累加。* |
| 1660 | + break; |
1604 | 1661 | } |
1605 | 1662 | } |
1606 | | - committedIndex += count; |
| 1663 | + |
| 1664 | + committedIndex += charCount; |
1607 | 1665 | return committedIndex; |
1608 | 1666 | } |
1609 | 1667 |
|
1610 | | - for (const char of textContent) { |
| 1668 | + // 如果不是目標節點,計算完整的字符數 |
| 1669 | + for (const char of fullChars) { |
1611 | 1670 | if (char !== NON_BREAK_ANCHOR && char !== '\u200c') { |
1612 | 1671 | committedIndex++; |
1613 | 1672 | } |
1614 | 1673 | } |
1615 | 1674 |
|
1616 | 1675 | } else if (node.nodeName === 'BR') { |
1617 | | - committedIndex++; |
| 1676 | + committedIndex++; // 換行符佔一個字符 |
1618 | 1677 | } |
1619 | 1678 | } |
1620 | 1679 |
|
1621 | | - return committedText.length; |
| 1680 | + // 如果遍歷結束仍未返回,則表示點擊在文字末尾 |
| 1681 | + return Array.from(committedText).length; |
1622 | 1682 | } |
1623 | 1683 |
|
1624 | 1684 | function handleTextTap(event) { |
|
1657 | 1717 | updateOutputDisplay(); |
1658 | 1718 | } |
1659 | 1719 | } else if (newNode === textOutput && newOffset === textOutput.childNodes.length) { |
1660 | | - if (cursorPosition !== committedText.length) { |
1661 | | - cursorPosition = committedText.length; |
| 1720 | + // 點擊在文本結尾的空白處 |
| 1721 | + if (cursorPosition !== Array.from(committedText).length) { |
| 1722 | + cursorPosition = Array.from(committedText).length; |
1662 | 1723 | preferredOffset = -1; |
1663 | 1724 | updateOutputDisplay(); |
1664 | 1725 | } |
|
1668 | 1729 |
|
1669 | 1730 |
|
1670 | 1731 | // 頁面啟動 |
1671 | | - document.addEventListener('DOMContentLoaded', async () => { // **改為 async** |
| 1732 | + document.addEventListener('DOMContentLoaded', async () => { // **注意:這裡必須改成 async** |
1672 | 1733 | drawKeyboard(); |
1673 | | - await loadTSMap(); // **新增:載入簡繁轉換表** |
| 1734 | + await loadTSMap(); // **在這裡等待轉換表載入** |
1674 | 1735 | loadSecondaryCodemap(); |
1675 | 1736 |
|
1676 | 1737 | textOutput.addEventListener('click', handleTextTap); |
|
0 commit comments