Compare commits
257 Commits
main
...
e5472ebd6e
| Author | SHA1 | Date | |
|---|---|---|---|
| e5472ebd6e | |||
| 8a778a6fdc | |||
| 2e95a0072a | |||
| 72142b0b71 | |||
| 0af7428353 | |||
| c1ace5f53e | |||
| 9fb2e2e694 | |||
| 6327f31f11 | |||
| cbcf8c4197 | |||
| e03287605c | |||
| 987391953a | |||
| 442d56decd | |||
| fb74fbed1c | |||
| 33a04186fb | |||
| bb74a330db | |||
| 3c18579a83 | |||
| d25dd38959 | |||
| eaf512be7f | |||
| d8a84dc478 | |||
| 8cc484edcb | |||
| a61f505f70 | |||
| 8316d42fb3 | |||
| cb0b8a0aee | |||
| fe08f8d54a | |||
| 7029209a4d | |||
| c42ccfbcdf | |||
| f2184cf9c6 | |||
| e7567909bc | |||
| 2d02e05956 | |||
| 973577c6eb | |||
| 5c0cf2b435 | |||
| fd5de4f197 | |||
| f9da0c40e5 | |||
| b1f1ddec7e | |||
| f30b1d7640 | |||
| 2a122d27a9 | |||
| 72069cc737 | |||
| 6786a76f41 | |||
| 361ccc12d6 | |||
| e5e059cf24 | |||
| 7adccd60c5 | |||
| 4c16ae1736 | |||
| a0c5afc75d | |||
| 4a26502c41 | |||
| b86801636a | |||
| bcc8981c06 | |||
| 211f30d793 | |||
| 494efb745e | |||
| 53c406c984 | |||
| 2aa5fa8d09 | |||
| 152c7052b4 | |||
| 2505de0f24 | |||
| fb6db0649c | |||
| a68fb9657f | |||
| 04cfc35485 | |||
| d79a1d15bc | |||
| 6e62394feb | |||
| 781e557e80 | |||
| da4649101e | |||
| 47291934a2 | |||
| e619f48f93 | |||
| f55a70681c | |||
| cb86f7c32c | |||
| 40ef964b8c | |||
| 4269fde923 | |||
| c3e037e070 | |||
| a711be4c4d | |||
| 69bd2b2af9 | |||
| 82222afd76 | |||
| 92ca5c6180 | |||
| 851c0d9531 | |||
| 1c9013bede | |||
| 0a16a4f240 | |||
| 27d4b2b817 | |||
| bc623676ca | |||
| 5edf1751ff | |||
| 0ac47925fd | |||
| 635ad932c7 | |||
| cbe0a53cac | |||
| 5c273c3963 | |||
| c9743cb363 | |||
| f0cb69948e | |||
| 0144f9cc6d | |||
| ae4070ae88 | |||
| a83fd918a8 | |||
| 4168da618e | |||
| d2ffada83f | |||
| 76d387e08b | |||
| ea0df4fb19 | |||
| 02323fb5f1 | |||
| 3c71797b7b | |||
| 4c57f16058 | |||
| cb2e8467a7 | |||
| 4dfd6f5cbb | |||
| e4223b3a4c | |||
| 3d19403539 | |||
| 3cb02d5b76 | |||
| 750b391100 | |||
| faccf6f10f | |||
| 35b1fc0f1e | |||
| b73f225d15 | |||
| dd59094a16 | |||
| bacaf537f3 | |||
| 619d356d31 | |||
| db9f07d199 | |||
| 3ed120106e | |||
| ff4edab820 | |||
| 3e30f619b9 | |||
| 533e23ebfe | |||
| 85fb407717 | |||
| c1b50b407d | |||
| 7c7e2477cb | |||
| faae0297cb | |||
| e50eaecbd9 | |||
| 879dbb860c | |||
| b4e4b7b606 | |||
| 68a610e0a8 | |||
| 305326aa9a | |||
| 61095a379f | |||
| 822a814f85 | |||
| 0bd0392191 | |||
| b9663037f5 | |||
| a0923c8572 | |||
| d482cfcb7d | |||
| 9e6d2906f8 | |||
| 6f7bb4f960 | |||
| fa9af5ff1b | |||
| 08628bcd1d | |||
| 19cb29616f | |||
| 6e50cdcd2a | |||
| f1b52151be | |||
| 993ec623af | |||
| 0416a64235 | |||
| 2b75ad90fb | |||
| 0ac9030f80 | |||
| ea9c40f64f | |||
| 48c90fa0be | |||
| fe59a0cb45 | |||
| 81bc50ce17 | |||
| 6ae504823b | |||
| d2f582b7f8 | |||
| cc82396195 | |||
| 2ff8a7a4af | |||
| 3c0b7e754c | |||
| 3705db4aab | |||
| 36774a8a2c | |||
| 36135313d8 | |||
| 23c0d14128 | |||
| d0c5cada35 | |||
| b556e6841d | |||
| 26096abbcc | |||
| 766c62f3c0 | |||
| 07a77149fc | |||
| 32ebc6fb65 | |||
| 25fbe9b64e | |||
| 4392296616 | |||
| ef52cd4872 | |||
| 70a8466d9f | |||
| 66d85f78a0 | |||
| 93a20cd92a | |||
| 9a54a2ae6c | |||
| 1b9ce1622d | |||
| b4db79eba8 | |||
| 22f77d56ea | |||
| d8d5bdc3ae | |||
| 7d583ceb1d | |||
| 51b744ecd7 | |||
| 3fd7d2af2e | |||
| db869552e4 | |||
| b34de116a3 | |||
| e67bc37571 | |||
| 2b749cd2b0 | |||
| ce889e1ed0 | |||
| e8b4b2c58a | |||
| 3a5a6395af | |||
| a22599feda | |||
| 6a177ceebc | |||
| f9d7579536 | |||
| 0fa31418f6 | |||
| 77fd46aa34 | |||
| 6ad9783bcb | |||
| edc25c159d | |||
| 06a572c08a | |||
| 36c0b0b210 | |||
| d1d47336c2 | |||
| 063ceae10f | |||
| 552387293c | |||
| 93489b09d9 | |||
| 663cb8493b | |||
| ac0d9584d8 | |||
| 7fa124d45f | |||
| 3dfb8f31e2 | |||
| 619c02f236 | |||
| 28852a8d4b | |||
| b021fd308f | |||
| 169a1929d7 | |||
| b5da9f35a5 | |||
| 8f4deaac4e | |||
| d479d1903b | |||
| 32c4138ae0 | |||
| da62d4f411 | |||
| 85dcd72a5d | |||
| 21fcbe3665 | |||
| 1b6724f043 | |||
| ef332ecaa1 | |||
| 3d6d673c0b | |||
| 674f09d5b6 | |||
| 11d8f78b1b | |||
| bbacef4ff7 | |||
| 8e692647d3 | |||
| 6f80f969a4 | |||
| bdf2a9af80 | |||
| e858d35722 | |||
| f2d5210313 | |||
| 1b0af3e2d6 | |||
| 0965cd3c7e | |||
| c3909d63da | |||
| 1096f24c57 | |||
| 7ed84fd445 | |||
| 4e2d7d2908 | |||
| 34089ddeea | |||
| 6ec98468de | |||
| 2d5919016f | |||
| c0fa51bb2e | |||
| 6713f36387 | |||
| f24750458a | |||
| 510a2f4d66 | |||
| ae37730da6 | |||
| 203f104ece | |||
| 8e934dd83a | |||
| 1676916a5c | |||
| 1af5a0e849 | |||
| 5b6e0a8fbf | |||
| 9968883bab | |||
| af5f637d31 | |||
| 0a725e845e | |||
| 6a539dc3c5 | |||
| 73d6ec933a | |||
| 000d603241 | |||
| fbf9fe9f2a | |||
| 8e4d7e1ee8 | |||
| 262eb57b36 | |||
| 2e1c261775 | |||
| 6ad2079351 | |||
| a477592f5d | |||
| 6f336e8368 | |||
| 17e038beb1 | |||
| 4e6fd90668 | |||
| 5cfc76e6c5 | |||
| 9e33c93763 | |||
| 1c9ae7bc06 | |||
| 472e9ad341 | |||
| 19c69f4f6f | |||
| 8788cbb105 | |||
| ea77e9a5f8 | |||
| eaaf0e1ed6 | |||
| 8a344b293d |
17
.claude/settings.local.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"WebSearch",
|
||||||
|
"Bash(git checkout:*)",
|
||||||
|
"Bash(xcodebuild:*)",
|
||||||
|
"Bash(plutil:*)",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(wc:*)",
|
||||||
|
"Bash(chmod +x:*)",
|
||||||
|
"Bash(python3:*)",
|
||||||
|
"Bash(/usr/libexec/PlistBuddy:*)",
|
||||||
|
"Bash(iconv -f UTF-8 -t UTF-8 \"/Users/mac/Downloads/隐私协议_修改版.txt\" 2>/dev/null | sed -n '290,305p')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Xcode / build artifacts
|
||||||
|
_DerivedData/
|
||||||
|
DerivedData/
|
||||||
|
*.xcresult/
|
||||||
|
xcuserdata/
|
||||||
|
_tmp/
|
||||||
|
ws.xcworkspace
|
||||||
|
|
||||||
|
# Codex / sandbox home mirror
|
||||||
|
_home/
|
||||||
|
|
||||||
|
# SwiftPM artifacts
|
||||||
|
_spm/
|
||||||
|
_SourcePackages/
|
||||||
|
.swiftpm/
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>kbkeyboardAppExtension</string>
|
<string>kbkeyboardAppExtension</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Microphone access is required for voice input and speech transcription.</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionAttributes</key>
|
<key>NSExtensionAttributes</key>
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "切图 270@2x.png",
|
"filename" : "切图 271@2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "切图 270@3x.png",
|
"filename" : "切图 271@3x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 11 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_key_icon.imageset/切图 271@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_key_icon.imageset/切图 271@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
22
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_close.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "ai_limit_close@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "ai_limit_close@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_close.imageset/ai_limit_close@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_close.imageset/ai_limit_close@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
22
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_goto.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "ai_limit_goto@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "ai_limit_goto@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_goto.imageset/ai_limit_goto@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_goto.imageset/ai_limit_goto@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
22
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "ai_limit_icon@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "ai_limit_icon@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_icon.imageset/ai_limit_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_icon.imageset/ai_limit_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 733 KiB |
@@ -5,12 +5,12 @@
|
|||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "key_123@2x.png",
|
"filename" : "close_icon@2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "key_123@3x.png",
|
"filename" : "close_icon@3x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/close_icon.imageset/close_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/close_icon.imageset/close_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
@@ -4,15 +4,79 @@
|
|||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "light"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "kb_del_icon@2x.png",
|
"filename" : "kb_del_icon@2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "light"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "kb_del_icon@2x 1.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "切图 256@2x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "kb_del_icon@3x.png",
|
"filename" : "kb_del_icon@3x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "light"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "kb_del_icon@3x 1.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "切图 256@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/kb_del_icon.imageset/kb_del_icon@2x 1.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/kb_del_icon.imageset/kb_del_icon@3x 1.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/kb_del_icon.imageset/切图 256@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1008 B |
BIN
CustomKeyboard/KeyboardAssets.xcassets/kb_del_icon.imageset/切图 256@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
@@ -5,12 +5,12 @@
|
|||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "key_ai@2x.png",
|
"filename" : "key_revoke@2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "key_ai@3x.png",
|
"filename" : "key_revoke@3x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/key_revoke.imageset/key_revoke@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/key_revoke.imageset/key_revoke@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
@@ -5,493 +5,376 @@
|
|||||||
// Created by Mac on 2025/10/27.
|
// Created by Mac on 2025/10/27.
|
||||||
//
|
//
|
||||||
|
|
||||||
#import "KeyboardViewController.h"
|
#import "KeyboardViewController+Private.h"
|
||||||
#import "KBKeyBoardMainView.h"
|
|
||||||
|
|
||||||
#import "KBKey.h"
|
#import "KBBackspaceUndoManager.h"
|
||||||
#import "KBFunctionView.h"
|
#import "KBChatLimitPopView.h"
|
||||||
#import "KBSettingView.h"
|
#import "KBChatPanelView.h"
|
||||||
#import "Masonry.h"
|
|
||||||
#import "KBAuthManager.h"
|
|
||||||
#import "KBFullAccessManager.h"
|
#import "KBFullAccessManager.h"
|
||||||
|
#import "KBFunctionView.h"
|
||||||
|
#import "KBInputBufferManager.h"
|
||||||
|
#import "KBKeyBoardMainView.h"
|
||||||
|
#import "KBKeyboardSubscriptionView.h"
|
||||||
|
#import "KBLocalizationManager.h"
|
||||||
#import "KBSkinManager.h"
|
#import "KBSkinManager.h"
|
||||||
#import "KBSkinInstallBridge.h"
|
#import "KBSkinInstallBridge.h"
|
||||||
#import "KBHostAppLauncher.h"
|
#import "KBSuggestionEngine.h"
|
||||||
#import "KBKeyboardSubscriptionView.h"
|
#import "KBKeyboardLayoutResolver.h"
|
||||||
#import "KBKeyboardSubscriptionProduct.h"
|
#import <SDWebImage/SDWebImage.h>
|
||||||
#import "KBBackspaceUndoManager.h"
|
|
||||||
|
|
||||||
// 提前声明一个类别,使编译器在 static 回调中识别 kb_consumePendingShopSkin 方法。
|
#if DEBUG
|
||||||
@interface KeyboardViewController (KBSkinShopBridge)
|
#import <mach/mach.h>
|
||||||
- (void)kb_consumePendingShopSkin;
|
#endif
|
||||||
@end
|
|
||||||
|
|
||||||
// 以 375 宽设计稿为基准的键盘总高度(包括顶部工具栏)
|
#if DEBUG
|
||||||
static const CGFloat kKBKeyboardDesignHeight = 250.0f;
|
static NSInteger sKBKeyboardVCAliveCount = 0;
|
||||||
|
|
||||||
static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
static uint64_t KBPhysFootprintBytes(void) {
|
||||||
void *observer,
|
task_vm_info_data_t vmInfo;
|
||||||
CFStringRef name,
|
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
|
||||||
const void *object,
|
kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO,
|
||||||
CFDictionaryRef userInfo) {
|
(task_info_t)&vmInfo, &count);
|
||||||
KeyboardViewController *strongSelf = (__bridge KeyboardViewController *)observer;
|
if (kr != KERN_SUCCESS) {
|
||||||
if (!strongSelf) { return; }
|
return 0;
|
||||||
dispatch_async(dispatch_get_main_queue(), ^{
|
}
|
||||||
if ([strongSelf respondsToSelector:@selector(kb_consumePendingShopSkin)]) {
|
return (uint64_t)vmInfo.phys_footprint;
|
||||||
[strongSelf kb_consumePendingShopSkin];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@interface KeyboardViewController () <KBKeyBoardMainViewDelegate, KBFunctionViewDelegate, KBKeyboardSubscriptionViewDelegate>
|
static NSString *KBFormatMB(uint64_t bytes) {
|
||||||
@property (nonatomic, strong) UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选)
|
double mb = (double)bytes / 1024.0 / 1024.0;
|
||||||
@property (nonatomic, strong) KBKeyBoardMainView *keyBoardMainView; // 功能面板视图(点击工具栏第0个时显示)
|
return [NSString stringWithFormat:@"%.1fMB", mb];
|
||||||
@property (nonatomic, strong) KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示)
|
}
|
||||||
@property (nonatomic, strong) KBSettingView *settingView; // 设置页
|
#endif
|
||||||
@property (nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层)
|
|
||||||
@property (nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView;
|
|
||||||
@end
|
|
||||||
|
|
||||||
@implementation KeyboardViewController
|
@implementation KeyboardViewController
|
||||||
|
|
||||||
{
|
{
|
||||||
BOOL _kb_didTriggerLoginDeepLinkOnce;
|
BOOL _kb_didTriggerLoginDeepLinkOnce;
|
||||||
|
NSString *_kb_lastLoadedProfileId; // 记录上次加载的 profileId
|
||||||
|
#if DEBUG
|
||||||
|
BOOL _kb_debugDidCountAlive;
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)viewDidLoad {
|
- (void)viewDidLoad {
|
||||||
[super viewDidLoad];
|
[super viewDidLoad];
|
||||||
[self setupUI];
|
#if DEBUG
|
||||||
// 指定 HUD 的承载视图(扩展里无法取到 App 的 KeyWindow)
|
if (!_kb_debugDidCountAlive) {
|
||||||
[KBHUD setContainerView:self.view];
|
_kb_debugDidCountAlive = YES;
|
||||||
// 绑定完全访问管理器,便于统一感知和联动网络开关
|
sKBKeyboardVCAliveCount += 1;
|
||||||
[[KBFullAccessManager shared] bindInputController:self];
|
}
|
||||||
__unused id token = [[NSNotificationCenter defaultCenter] addObserverForName:KBFullAccessChangedNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(__unused NSNotification * _Nonnull note) {
|
NSLog(@"[Keyboard] KeyboardViewController viewDidLoad alive=%ld self=%p mem=%@",
|
||||||
// 如需,可在此刷新与完全访问相关的 UI
|
(long)sKBKeyboardVCAliveCount, self, KBFormatMB(KBPhysFootprintBytes()));
|
||||||
}];
|
#endif
|
||||||
|
// 撤销删除是“上一段删除操作”的临时状态;键盘被系统回收/重建或跨页面回来时应当清空,避免误显示。
|
||||||
|
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||||
|
[self setupUI];
|
||||||
|
self.suggestionEngine = [KBSuggestionEngine shared];
|
||||||
|
self.currentWord = @"";
|
||||||
|
// 指定 HUD 的承载视图(扩展里无法取到 App 的 KeyWindow)
|
||||||
|
[KBHUD setContainerView:self.view];
|
||||||
|
// 绑定完全访问管理器,便于统一感知和联动网络开关
|
||||||
|
[[KBFullAccessManager shared] bindInputController:self];
|
||||||
|
self.kb_fullAccessObserverToken = [[NSNotificationCenter defaultCenter]
|
||||||
|
addObserverForName:KBFullAccessChangedNotification
|
||||||
|
object:nil
|
||||||
|
queue:[NSOperationQueue mainQueue]
|
||||||
|
usingBlock:^(__unused NSNotification *_Nonnull note){
|
||||||
|
// 如需,可在此刷新与完全访问相关的 UI
|
||||||
|
}];
|
||||||
|
|
||||||
// 皮肤变化时,立即应用
|
// 皮肤变化时,立即应用
|
||||||
__unused id token2 = [[NSNotificationCenter defaultCenter] addObserverForName:KBSkinDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(__unused NSNotification * _Nonnull note) {
|
__weak typeof(self) weakSelf = self;
|
||||||
[self kb_applyTheme];
|
self.kb_skinObserverToken = [[NSNotificationCenter defaultCenter]
|
||||||
}];
|
addObserverForName:KBSkinDidChangeNotification
|
||||||
[self kb_applyTheme];
|
object:nil
|
||||||
CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(),
|
queue:[NSOperationQueue mainQueue]
|
||||||
(__bridge const void *)(self),
|
usingBlock:^(__unused NSNotification *_Nonnull note) {
|
||||||
KBSkinInstallNotificationCallback,
|
__strong typeof(weakSelf) self = weakSelf;
|
||||||
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification,
|
if (!self) {
|
||||||
NULL,
|
return;
|
||||||
CFNotificationSuspensionBehaviorDeliverImmediately);
|
}
|
||||||
[self kb_consumePendingShopSkin];
|
[self kb_applyTheme];
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 语言变化时,重建键盘 UI(保证“App 语言=键盘语言”,并支持 App 内切换语言后键盘即时刷新)
|
||||||
|
self.kb_localizationObserverToken = [[NSNotificationCenter defaultCenter]
|
||||||
|
addObserverForName:KBLocalizationDidChangeNotification
|
||||||
|
object:nil
|
||||||
|
queue:[NSOperationQueue mainQueue]
|
||||||
|
usingBlock:^(__unused NSNotification *_Nonnull note) {
|
||||||
|
__strong typeof(weakSelf) self = weakSelf;
|
||||||
|
if (!self) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[self kb_reloadUIForLocalizationChange];
|
||||||
|
}];
|
||||||
|
[self kb_applyTheme];
|
||||||
|
[self kb_registerDarwinSkinInstallObserver];
|
||||||
|
[self kb_consumePendingShopSkin];
|
||||||
|
[self kb_applyDefaultSkinIfNeeded];
|
||||||
|
|
||||||
|
[self kb_startObservingAppGroupChanges];
|
||||||
|
|
||||||
|
// 监听 App Group 配置变化,动态切换键盘布局
|
||||||
|
[self kb_checkAndApplyLayoutIfNeeded];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)viewWillAppear:(BOOL)animated{
|
- (void)didReceiveMemoryWarning {
|
||||||
[super viewWillAppear:animated];
|
[super didReceiveMemoryWarning];
|
||||||
[[KBLocalizationManager shared] reloadFromSharedStorageIfNeeded];
|
// 扩展进程内存上限较小:在系统发出内存警告时主动清理可重建的缓存,降低被系统杀死概率。
|
||||||
|
self.kb_cachedGradientImage = nil;
|
||||||
|
[self.kb_defaultGradientLayer removeFromSuperlayer];
|
||||||
|
self.kb_defaultGradientLayer = nil;
|
||||||
|
[[KBSkinManager shared] clearRuntimeImageCaches];
|
||||||
|
[[SDImageCache sharedImageCache] clearMemory];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)viewWillAppear:(BOOL)animated {
|
||||||
|
[super viewWillAppear:animated];
|
||||||
|
// FIX: iOS 26 键盘闪烁问题 —— 恢复键盘正确高度
|
||||||
|
// setupUI 中高度初始为 0(防止系统预渲染快照闪烁),此处恢复为实际键盘高度。
|
||||||
|
// 此时系统已准备好键盘滑入动画,恢复高度后键盘将正常从底部滑入。
|
||||||
|
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||||
|
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
|
||||||
|
if (self.kb_heightConstraint) {
|
||||||
|
self.kb_heightConstraint.constant = keyboardHeight;
|
||||||
|
}
|
||||||
|
// 进入/重新进入输入界面时,清理上一次会话残留的撤销状态与缓存,避免显示“撤销删除”但实际上已不可撤销。
|
||||||
|
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||||
|
[[KBInputBufferManager shared] resetWithText:@""];
|
||||||
|
[[KBLocalizationManager shared] reloadFromSharedStorageIfNeeded];
|
||||||
|
// 键盘再次出现时,恢复 HUD 容器与主题(viewDidDisappear 里可能已清理图片/缓存)。
|
||||||
|
[KBHUD setContainerView:self.view];
|
||||||
|
[self kb_ensureKeyBoardMainViewIfNeeded];
|
||||||
|
[self kb_applyTheme];
|
||||||
|
#if DEBUG
|
||||||
|
NSLog(@"[Keyboard] viewWillAppear self=%p mem=%@",
|
||||||
|
self, KBFormatMB(KBPhysFootprintBytes()));
|
||||||
|
#endif
|
||||||
|
// 注意:微信/QQ 等宿主的 documentContext 可能是“截断窗口”,这里只更新
|
||||||
|
// liveText,不要把它当作全文 manualSnapshot。
|
||||||
|
[[KBInputBufferManager shared]
|
||||||
|
updateFromExternalContextBefore:self.textDocumentProxy
|
||||||
|
.documentContextBeforeInput
|
||||||
|
after:self.textDocumentProxy
|
||||||
|
.documentContextAfterInput];
|
||||||
|
}
|
||||||
|
|
||||||
- (void)setupUI {
|
- (void)viewWillDisappear:(BOOL)animated {
|
||||||
self.view.translatesAutoresizingMaskIntoConstraints = NO;
|
[super viewWillDisappear:animated];
|
||||||
|
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||||
// 按屏幕宽度对设计值做等比缩放,避免在不同机型上键盘整体高度失真导致皮肤被压缩/拉伸
|
[self kb_releaseMemoryWhenKeyboardHidden];
|
||||||
CGFloat keyboardHeight = KBFit(kKBKeyboardDesignHeight);
|
#if DEBUG
|
||||||
CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;
|
NSLog(@"[Keyboard] viewWillDisappear self=%p mem=%@",
|
||||||
CGFloat outerVerticalInset = KBFit(4.0f);
|
self, KBFormatMB(KBPhysFootprintBytes()));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
NSLayoutConstraint *h = [self.view.heightAnchor constraintEqualToConstant:keyboardHeight];
|
- (void)viewDidDisappear:(BOOL)animated {
|
||||||
NSLayoutConstraint *w = [self.view.widthAnchor constraintEqualToConstant:screenWidth];
|
[super viewDidDisappear:animated];
|
||||||
|
// 再兜底一次,防止某些宿主只触发 willDisappear 而未触发 didDisappear。
|
||||||
|
[self kb_releaseMemoryWhenKeyboardHidden];
|
||||||
|
}
|
||||||
|
|
||||||
h.priority = UILayoutPriorityRequired;
|
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
|
||||||
w.priority = UILayoutPriorityRequired;
|
[super traitCollectionDidChange:previousTraitCollection];
|
||||||
[NSLayoutConstraint activateConstraints:@[h, w]];
|
if (@available(iOS 13.0, *)) {
|
||||||
// 关闭 UIInputView 自适应(某些系统版本会尝试放大为全屏高度导致冲突)
|
if (previousTraitCollection.userInterfaceStyle !=
|
||||||
if ([self.view isKindOfClass:[UIInputView class]]) {
|
self.traitCollection.userInterfaceStyle) {
|
||||||
UIInputView *iv = (UIInputView *)self.view;
|
self.kb_cachedGradientImage = nil;
|
||||||
if ([iv respondsToSelector:@selector(setAllowsSelfSizing:)]) {
|
[self kb_applyDefaultSkinIfNeeded];
|
||||||
iv.allowsSelfSizing = NO;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 背景图铺底
|
}
|
||||||
[self.view addSubview:self.bgImageView];
|
|
||||||
[self.bgImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.edges.equalTo(self.view);
|
|
||||||
}];
|
|
||||||
// 预置功能面板(默认隐藏),与键盘区域共享相同布局
|
|
||||||
self.functionView.hidden = YES;
|
|
||||||
[self.view addSubview:self.functionView];
|
|
||||||
[self.functionView mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.left.right.equalTo(self.view);
|
|
||||||
make.top.equalTo(self.view).offset(0);
|
|
||||||
make.bottom.equalTo(self.view).offset(0);
|
|
||||||
}];
|
|
||||||
|
|
||||||
[self.view addSubview:self.keyBoardMainView];
|
|
||||||
[self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.left.right.equalTo(self.view);
|
|
||||||
make.top.equalTo(self.view).offset(0);
|
|
||||||
make.bottom.equalTo(self.view.mas_bottom).offset(-0);
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)textDidChange:(id<UITextInput>)textInput {
|
||||||
|
[super textDidChange:textInput];
|
||||||
#pragma mark - Private
|
[[KBInputBufferManager shared]
|
||||||
|
updateFromExternalContextBefore:self.textDocumentProxy
|
||||||
/// 切换显示功能面板/键盘主视图
|
.documentContextBeforeInput
|
||||||
- (void)showFunctionPanel:(BOOL)show {
|
after:self.textDocumentProxy
|
||||||
// 简单显隐切换,复用相同的布局区域
|
.documentContextAfterInput];
|
||||||
self.functionView.hidden = !show;
|
|
||||||
self.keyBoardMainView.hidden = show;
|
|
||||||
|
|
||||||
if (show) {
|
|
||||||
[self hideSubscriptionPanel];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 可选:把当前显示的视图置顶,避免层级遮挡
|
|
||||||
if (show) {
|
|
||||||
[self.view bringSubviewToFront:self.functionView];
|
|
||||||
} else {
|
|
||||||
[self.view bringSubviewToFront:self.keyBoardMainView];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 显示/隐藏设置页(高度与 keyBoardMainView 一致),右侧滑入/滑出
|
- (void)viewDidAppear:(BOOL)animated {
|
||||||
- (void)showSettingView:(BOOL)show {
|
[super viewDidAppear:animated];
|
||||||
if (show) {
|
// if (!_kb_didTriggerLoginDeepLinkOnce) {
|
||||||
// if (!self.settingView) {
|
// _kb_didTriggerLoginDeepLinkOnce = YES;
|
||||||
self.settingView = [[KBSettingView alloc] init];
|
// // 仅在未登录时尝试拉起主App登录
|
||||||
self.settingView.hidden = YES;
|
// if (!KBAuthManager.shared.isLoggedIn) {
|
||||||
[self.view addSubview:self.settingView];
|
// [self kb_tryOpenContainerForLoginIfNeeded];
|
||||||
[self.settingView mas_makeConstraints:^(MASConstraintMaker *make) {
|
// }
|
||||||
// 与键盘主视图完全等同的区域,保证高度、宽度一致
|
// }
|
||||||
make.edges.equalTo(self.keyBoardMainView);
|
|
||||||
}];
|
|
||||||
[self.settingView.backButton addTarget:self action:@selector(onTapSettingsBack) forControlEvents:UIControlEventTouchUpInside];
|
|
||||||
// }
|
|
||||||
[self.view bringSubviewToFront:self.settingView];
|
|
||||||
// 以 keyBoardMainView 的实际宽度为准,避免首次添加时 self.view 宽度尚未计算
|
|
||||||
[self.view layoutIfNeeded];
|
|
||||||
CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds);
|
|
||||||
if (w <= 0) { w = CGRectGetWidth(self.view.bounds); }
|
|
||||||
if (w <= 0) { w = [UIScreen mainScreen].bounds.size.width; }
|
|
||||||
self.settingView.transform = CGAffineTransformMakeTranslation(w, 0);
|
|
||||||
self.settingView.hidden = NO;
|
|
||||||
[UIView animateWithDuration:0.25 delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{
|
|
||||||
self.settingView.transform = CGAffineTransformIdentity;
|
|
||||||
} completion:nil];
|
|
||||||
} else {
|
|
||||||
if (!self.settingView || self.settingView.hidden) return;
|
|
||||||
CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds);
|
|
||||||
if (w <= 0) { w = CGRectGetWidth(self.view.bounds); }
|
|
||||||
if (w <= 0) { w = [UIScreen mainScreen].bounds.size.width; }
|
|
||||||
[UIView animateWithDuration:0.22 delay:0 options:UIViewAnimationOptionCurveEaseIn animations:^{
|
|
||||||
self.settingView.transform = CGAffineTransformMakeTranslation(w, 0);
|
|
||||||
} completion:^(BOOL finished) {
|
|
||||||
self.settingView.hidden = YES;
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)showSubscriptionPanel {
|
- (void)viewDidLayoutSubviews {
|
||||||
// 1) 先判断权限:未开启“完全访问”则走引导逻辑
|
[super viewDidLayoutSubviews];
|
||||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
// [self kb_updateKeyboardLayoutIfNeeded];
|
||||||
// 未开启完全访问:保持原有引导路径
|
|
||||||
// [KBHUD showInfo:KBLocalized(@"处理中…")];
|
// 首次布局完成后显示,避免闪烁
|
||||||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view];
|
if (self.contentView.hidden) {
|
||||||
return;
|
self.contentView.hidden = NO;
|
||||||
}
|
}
|
||||||
// 点击充值要先判断是否登录
|
if (self.kb_defaultGradientLayer) {
|
||||||
// 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App 负责完成登录
|
self.kb_defaultGradientLayer.frame = self.bgImageView.bounds;
|
||||||
if (!KBAuthManager.shared.isLoggedIn) {
|
}
|
||||||
NSString *schemeStr = [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
|
||||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
// 每次布局时检查是否需要切换键盘布局
|
||||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
[self kb_checkAndApplyLayoutIfNeeded];
|
||||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
[self showFunctionPanel:NO];
|
|
||||||
KBKeyboardSubscriptionView *panel = self.subscriptionView;
|
|
||||||
if (!panel.superview) {
|
|
||||||
panel.hidden = YES;
|
|
||||||
[self.view addSubview:panel];
|
|
||||||
[panel mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
||||||
make.edges.equalTo(self.keyBoardMainView);
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
[self.view bringSubviewToFront:panel];
|
|
||||||
panel.hidden = NO;
|
|
||||||
panel.alpha = 0.0;
|
|
||||||
CGFloat height = CGRectGetHeight(self.view.bounds);
|
|
||||||
if (height <= 0) { height = 260; }
|
|
||||||
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
|
||||||
[panel refreshProductsIfNeeded];
|
|
||||||
[UIView animateWithDuration:0.25 delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{
|
|
||||||
panel.alpha = 1.0;
|
|
||||||
panel.transform = CGAffineTransformIdentity;
|
|
||||||
} completion:nil];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)hideSubscriptionPanel {
|
- (void)viewWillTransitionToSize:(CGSize)size
|
||||||
if (!self.subscriptionView || self.subscriptionView.hidden) { return; }
|
withTransitionCoordinator:
|
||||||
CGFloat height = CGRectGetHeight(self.subscriptionView.bounds);
|
(id<UIViewControllerTransitionCoordinator>)coordinator {
|
||||||
if (height <= 0) { height = CGRectGetHeight(self.view.bounds); }
|
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
|
||||||
KBKeyboardSubscriptionView *panel = self.subscriptionView;
|
__weak typeof(self) weakSelf = self;
|
||||||
[UIView animateWithDuration:0.22 delay:0 options:UIViewAnimationOptionCurveEaseIn animations:^{
|
[coordinator
|
||||||
panel.alpha = 0.0;
|
animateAlongsideTransition:^(
|
||||||
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
|
||||||
} completion:^(BOOL finished) {
|
[weakSelf kb_updateKeyboardLayoutIfNeeded];
|
||||||
panel.hidden = YES;
|
}
|
||||||
panel.alpha = 1.0;
|
completion:^(
|
||||||
panel.transform = CGAffineTransformIdentity;
|
__unused id<
|
||||||
}];
|
UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
|
||||||
}
|
[weakSelf kb_updateKeyboardLayoutIfNeeded];
|
||||||
|
}];
|
||||||
|
|
||||||
|
|
||||||
// MARK: - KBKeyBoardMainViewDelegate
|
|
||||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapKey:(KBKey *)key {
|
|
||||||
if (key.type != KBKeyTypeShift && key.type != KBKeyTypeModeChange) {
|
|
||||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
|
||||||
}
|
|
||||||
switch (key.type) {
|
|
||||||
case KBKeyTypeCharacter:
|
|
||||||
[self.textDocumentProxy insertText:key.output ?: key.title ?: @""]; break;
|
|
||||||
case KBKeyTypeBackspace:
|
|
||||||
[self.textDocumentProxy deleteBackward]; break;
|
|
||||||
case KBKeyTypeSpace:
|
|
||||||
[self.textDocumentProxy insertText:@" "]; break;
|
|
||||||
case KBKeyTypeReturn:
|
|
||||||
[self.textDocumentProxy insertText:@"\n"]; break;
|
|
||||||
case KBKeyTypeGlobe:
|
|
||||||
[self advanceToNextInputMode]; break;
|
|
||||||
case KBKeyTypeCustom:
|
|
||||||
// 点击自定义键切换到功能面板
|
|
||||||
[self showFunctionPanel:YES];
|
|
||||||
break;
|
|
||||||
case KBKeyTypeModeChange:
|
|
||||||
case KBKeyTypeShift:
|
|
||||||
// 这些已在 KBKeyBoardMainView/KBKeyboardView 内部处理
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapToolActionAtIndex:(NSInteger)index {
|
|
||||||
if (index == 0) {
|
|
||||||
[self showFunctionPanel:YES];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
[self showFunctionPanel:NO];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView {
|
|
||||||
[self showSettingView:YES];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didSelectEmoji:(NSString *)emoji {
|
|
||||||
if (emoji.length == 0) { return; }
|
|
||||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
|
||||||
[self.textDocumentProxy insertText:emoji];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView {
|
|
||||||
[[KBBackspaceUndoManager shared] performUndoFromResponder:self.view];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)keyBoardMainViewDidTapEmojiSearch:(KBKeyBoardMainView *)keyBoardMainView {
|
|
||||||
[KBHUD showInfo:KBLocalized(@"Search coming soon")];
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - KBFunctionViewDelegate
|
|
||||||
- (void)functionView:(KBFunctionView *)functionView didTapToolActionAtIndex:(NSInteger)index {
|
|
||||||
// 需求:当 index == 0 时,切回键盘主视图
|
|
||||||
if (index == 0) {
|
|
||||||
[self showFunctionPanel:NO];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
- (void)functionView:(KBFunctionView *_Nullable)functionView didRightTapToolActionAtIndex:(NSInteger)index{
|
|
||||||
NSString *schemeStr = [NSString stringWithFormat:@"%@://recharge?src=keyboard", KB_APP_SCHEME];
|
|
||||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
|
||||||
//
|
|
||||||
// if (!ul && !scheme) { return; }
|
|
||||||
//
|
|
||||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
|
||||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
|
||||||
|
|
||||||
if (!ok) {
|
|
||||||
// 失败兜底:给个文案提示
|
|
||||||
// 比如:请回到桌面手动打开 XXX App 进行设置/充值
|
|
||||||
[KBHUD showInfo:@"请回到桌面手动打开App进行充值"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)functionViewDidRequestSubscription:(KBFunctionView *)functionView {
|
|
||||||
[self showSubscriptionPanel];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - KBKeyboardSubscriptionViewDelegate
|
|
||||||
|
|
||||||
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view {
|
|
||||||
[self hideSubscriptionPanel];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)subscriptionView:(KBKeyboardSubscriptionView *)view didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product {
|
|
||||||
[self hideSubscriptionPanel];
|
|
||||||
[self kb_openRechargeForProduct:product];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - lazy
|
|
||||||
- (KBKeyBoardMainView *)keyBoardMainView{
|
|
||||||
if (!_keyBoardMainView) {
|
|
||||||
_keyBoardMainView = [[KBKeyBoardMainView alloc] init];
|
|
||||||
_keyBoardMainView.delegate = self;
|
|
||||||
}
|
|
||||||
return _keyBoardMainView;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (KBFunctionView *)functionView{
|
|
||||||
if (!_functionView) {
|
|
||||||
_functionView = [[KBFunctionView alloc] init];
|
|
||||||
_functionView.delegate = self; // 监听功能面板顶部Bar点击
|
|
||||||
}
|
|
||||||
return _functionView;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (KBSettingView *)settingView {
|
|
||||||
if (!_settingView) {
|
|
||||||
_settingView = [[KBSettingView alloc] init];
|
|
||||||
}
|
|
||||||
return _settingView;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (KBKeyboardSubscriptionView *)subscriptionView {
|
|
||||||
if (!_subscriptionView) {
|
|
||||||
_subscriptionView = [[KBKeyboardSubscriptionView alloc] init];
|
|
||||||
_subscriptionView.delegate = self;
|
|
||||||
_subscriptionView.hidden = YES;
|
|
||||||
_subscriptionView.alpha = 0.0;
|
|
||||||
}
|
|
||||||
return _subscriptionView;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#pragma mark - Actions
|
|
||||||
|
|
||||||
- (void)kb_openRechargeForProduct:(KBKeyboardSubscriptionProduct *)product {
|
|
||||||
if (![product isKindOfClass:KBKeyboardSubscriptionProduct.class] || product.productId.length == 0) {
|
|
||||||
[KBHUD showInfo:KBLocalized(@"Product unavailable")];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NSString *encodedId = [self.class kb_urlEncodedString:product.productId];
|
|
||||||
NSString *title = [product displayTitle];
|
|
||||||
NSString *encodedTitle = [self.class kb_urlEncodedString:title];
|
|
||||||
NSMutableArray<NSString *> *params = [NSMutableArray arrayWithObjects:@"autoPay=1", @"prefill=1", nil];
|
|
||||||
if (encodedId.length) {
|
|
||||||
[params addObject:[NSString stringWithFormat:@"productId=%@", encodedId]];
|
|
||||||
}
|
|
||||||
if (encodedTitle.length) {
|
|
||||||
[params addObject:[NSString stringWithFormat:@"productTitle=%@", encodedTitle]];
|
|
||||||
}
|
|
||||||
NSString *query = [params componentsJoinedByString:@"&"];
|
|
||||||
NSString *urlString = [NSString stringWithFormat:@"%@://recharge?src=keyboard&%@", KB_APP_SCHEME, query];
|
|
||||||
NSURL *scheme = [NSURL URLWithString:urlString];
|
|
||||||
BOOL success = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
|
||||||
if (!success) {
|
|
||||||
[KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (NSString *)kb_urlEncodedString:(NSString *)value {
|
|
||||||
if (value.length == 0) { return @""; }
|
|
||||||
NSString *reserved = @"!*'();:@&=+$,/?%#[]";
|
|
||||||
NSMutableCharacterSet *allowed = [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy];
|
|
||||||
[allowed removeCharactersInString:reserved];
|
|
||||||
return [value stringByAddingPercentEncodingWithAllowedCharacters:allowed] ?: @"";
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)onTapSettingsBack {
|
|
||||||
[self showSettingView:NO];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)dealloc {
|
- (void)dealloc {
|
||||||
CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(),
|
if (self.kb_fullAccessObserverToken) {
|
||||||
(__bridge const void *)(self),
|
[[NSNotificationCenter defaultCenter]
|
||||||
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification,
|
removeObserver:self.kb_fullAccessObserverToken];
|
||||||
NULL);
|
self.kb_fullAccessObserverToken = nil;
|
||||||
|
}
|
||||||
|
if (self.kb_skinObserverToken) {
|
||||||
|
[[NSNotificationCenter defaultCenter]
|
||||||
|
removeObserver:self.kb_skinObserverToken];
|
||||||
|
self.kb_skinObserverToken = nil;
|
||||||
|
}
|
||||||
|
if (self.kb_localizationObserverToken) {
|
||||||
|
[[NSNotificationCenter defaultCenter]
|
||||||
|
removeObserver:self.kb_localizationObserverToken];
|
||||||
|
self.kb_localizationObserverToken = nil;
|
||||||
|
}
|
||||||
|
[self kb_stopObservingAppGroupChanges];
|
||||||
|
[self kb_unregisterDarwinSkinInstallObserver];
|
||||||
|
#if DEBUG
|
||||||
|
if (_kb_debugDidCountAlive) {
|
||||||
|
sKBKeyboardVCAliveCount -= 1;
|
||||||
|
}
|
||||||
|
NSLog(@"[Keyboard] KeyboardViewController dealloc alive=%ld self=%p mem=%@",
|
||||||
|
(long)sKBKeyboardVCAliveCount, self, KBFormatMB(KBPhysFootprintBytes()));
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#pragma mark - Localization
|
||||||
|
|
||||||
// 当键盘第一次显示时,尝试唤起主 App 以提示登录(由主 App 决定是否真的弹登录)。
|
- (void)kb_reloadUIForLocalizationChange {
|
||||||
- (void)viewDidAppear:(BOOL)animated {
|
if (![NSThread isMainThread]) {
|
||||||
[super viewDidAppear:animated];
|
__weak typeof(self) weakSelf = self;
|
||||||
// if (!_kb_didTriggerLoginDeepLinkOnce) {
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
// _kb_didTriggerLoginDeepLinkOnce = YES;
|
[weakSelf kb_reloadUIForLocalizationChange];
|
||||||
// // 仅在未登录时尝试拉起主App登录
|
});
|
||||||
// if (!KBAuthManager.shared.isLoggedIn) {
|
return;
|
||||||
// [self kb_tryOpenContainerForLoginIfNeeded];
|
}
|
||||||
// }
|
|
||||||
// }
|
// 记录当前面板状态,重建后尽量恢复。
|
||||||
|
KBKeyboardPanelMode targetMode = self.kb_panelMode;
|
||||||
|
// 强制下次布局刷新:即使 profileId 未变,也需要让新建的主视图应用一次当前 profile。
|
||||||
|
_kb_lastLoadedProfileId = nil;
|
||||||
|
|
||||||
|
// 主键盘/面板里有大量静态文案(init 时设置),语言变化后需要重建才能刷新。
|
||||||
|
if (_keyBoardMainView) {
|
||||||
|
[_keyBoardMainView removeFromSuperview];
|
||||||
|
_keyBoardMainView = nil;
|
||||||
|
}
|
||||||
|
self.keyBoardMainHeightConstraint = nil;
|
||||||
|
|
||||||
|
if (_functionView) {
|
||||||
|
[_functionView removeFromSuperview];
|
||||||
|
_functionView = nil;
|
||||||
|
}
|
||||||
|
if (_subscriptionView) {
|
||||||
|
[_subscriptionView removeFromSuperview];
|
||||||
|
_subscriptionView = nil;
|
||||||
|
}
|
||||||
|
if (_chatPanelView) {
|
||||||
|
[_chatPanelView removeFromSuperview];
|
||||||
|
_chatPanelView = nil;
|
||||||
|
}
|
||||||
|
self.chatPanelVisible = NO;
|
||||||
|
self.chatPanelHeightConstraint = nil;
|
||||||
|
|
||||||
|
// 强制触发面板刷新:先回到 Main,再切回目标面板(避免 kb_setPanelMode 直接 return)。
|
||||||
|
self.kb_panelMode = KBKeyboardPanelModeMain;
|
||||||
|
[self kb_setPanelMode:targetMode animated:NO];
|
||||||
|
// 语言变化后,键盘布局/profile 也可能需要同步更新(未手动选择键盘配置时会随 App 语言变化)
|
||||||
|
[self kb_checkAndApplyLayoutIfNeeded];
|
||||||
|
[KBHUD setContainerView:self.view];
|
||||||
|
[self kb_applyTheme];
|
||||||
}
|
}
|
||||||
|
|
||||||
//- (void)kb_tryOpenContainerForLoginIfNeeded {
|
#pragma mark - Layout Switching
|
||||||
// // 使用与主 App 一致的自定义 Scheme
|
|
||||||
// NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"%@@//login?src=keyboard", KB_APP_SCHEME]];
|
|
||||||
// if (!url) return;
|
|
||||||
// KBWeakSelf
|
|
||||||
// [self.extensionContext openURL:url completionHandler:^(__unused BOOL success) {
|
|
||||||
// // 即使失败也不重复尝试;避免打扰。
|
|
||||||
// __unused typeof(weakSelf) selfStrong = weakSelf;
|
|
||||||
// }];
|
|
||||||
//}
|
|
||||||
|
|
||||||
#pragma mark - Theme
|
- (void)kb_checkAndApplyLayoutIfNeeded {
|
||||||
|
NSString *currentProfileId = [[KBKeyboardLayoutResolver sharedResolver] currentProfileId];
|
||||||
|
if (currentProfileId.length == 0) {
|
||||||
|
currentProfileId = @"en_US_qwerty";
|
||||||
|
}
|
||||||
|
|
||||||
- (void)kb_applyTheme {
|
if ([currentProfileId isEqualToString:_kb_lastLoadedProfileId]) {
|
||||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
return;
|
||||||
UIImage *img = [[KBSkinManager shared] currentBackgroundImage];
|
}
|
||||||
self.bgImageView.image = img;
|
|
||||||
BOOL hasImg = (img != nil);
|
NSLog(@"[KeyboardViewController] Detected profileId change: %@ -> %@", _kb_lastLoadedProfileId, currentProfileId);
|
||||||
self.view.backgroundColor = hasImg ? [UIColor clearColor] : t.keyboardBackground;
|
_kb_lastLoadedProfileId = currentProfileId;
|
||||||
self.keyBoardMainView.backgroundColor = hasImg ? [UIColor clearColor] : t.keyboardBackground;
|
|
||||||
// 触发键区按主题重绘
|
if (self.keyBoardMainView && [self.keyBoardMainView respondsToSelector:@selector(reloadLayoutWithProfileId:)]) {
|
||||||
if ([self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) {
|
[self.keyBoardMainView performSelector:@selector(reloadLayoutWithProfileId:) withObject:currentProfileId];
|
||||||
// method declared in KBKeyBoardMainView.h
|
}
|
||||||
#pragma clang diagnostic push
|
|
||||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
NSString *suggestionEngine = [[KBKeyboardLayoutResolver sharedResolver] suggestionEngineForProfileId:currentProfileId];
|
||||||
[self.keyBoardMainView performSelector:@selector(kb_applyTheme)];
|
if (suggestionEngine.length > 0) {
|
||||||
#pragma clang diagnostic pop
|
[self kb_updateSuggestionEngineType:suggestionEngine];
|
||||||
}
|
}
|
||||||
if ([self.functionView respondsToSelector:@selector(kb_applyTheme)]) {
|
|
||||||
#pragma clang diagnostic push
|
NSString *languageCode = [[KBKeyboardLayoutResolver sharedResolver] currentLanguageCode];
|
||||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
if (languageCode.length > 0) {
|
||||||
[self.functionView performSelector:@selector(kb_applyTheme)];
|
NSLog(@"[KeyboardViewController] Reloading skin icon map for language: %@", languageCode);
|
||||||
#pragma clang diagnostic pop
|
[KBSkinInstallBridge reloadCurrentSkinIconMapForLanguageCode:languageCode];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)kb_consumePendingShopSkin {
|
- (void)kb_updateSuggestionEngineType:(NSString *)engineType {
|
||||||
KBWeakSelf
|
NSLog(@"[KeyboardViewController] Switching suggestion engine to: %@", engineType);
|
||||||
[KBSkinInstallBridge consumePendingRequestFromBundle:NSBundle.mainBundle
|
[[KBSuggestionEngine shared] setEngineTypeFromString:engineType];
|
||||||
completion:^(BOOL success, NSError * _Nullable error) {
|
|
||||||
if (!success) {
|
|
||||||
if (error) {
|
|
||||||
NSLog(@"[Keyboard] skin request failed: %@", error);
|
|
||||||
[KBHUD showInfo:KBLocalized(@"皮肤资源准备失败,请稍后再试")];
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
[weakSelf kb_applyTheme];
|
|
||||||
[KBHUD showInfo:KBLocalized(@"皮肤已更新,立即体验吧")];
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Lazy
|
#pragma mark - App Group KVO
|
||||||
|
|
||||||
- (UIImageView *)bgImageView {
|
- (void)kb_startObservingAppGroupChanges {
|
||||||
if (!_bgImageView) {
|
NSUserDefaults *appGroup = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||||
_bgImageView = [[UIImageView alloc] init];
|
|
||||||
_bgImageView.contentMode = UIViewContentModeScaleAspectFill;
|
__weak typeof(self) weakSelf = self;
|
||||||
_bgImageView.clipsToBounds = YES;
|
self.kb_appGroupObserverToken = [[NSNotificationCenter defaultCenter]
|
||||||
}
|
addObserverForName:NSUserDefaultsDidChangeNotification
|
||||||
return _bgImageView;
|
object:appGroup
|
||||||
|
queue:[NSOperationQueue mainQueue]
|
||||||
|
usingBlock:^(__unused NSNotification *_Nonnull note) {
|
||||||
|
__strong typeof(weakSelf) strongSelf = weakSelf;
|
||||||
|
if (!strongSelf) { return; }
|
||||||
|
[strongSelf kb_checkAndApplyLayoutIfNeeded];
|
||||||
|
}];
|
||||||
|
|
||||||
|
NSLog(@"[KeyboardViewController] Started observing App Group changes");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)kb_stopObservingAppGroupChanges {
|
||||||
|
if (self.kb_appGroupObserverToken) {
|
||||||
|
[[NSNotificationCenter defaultCenter] removeObserver:self.kb_appGroupObserverToken];
|
||||||
|
self.kb_appGroupObserverToken = nil;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -0,0 +1,724 @@
|
|||||||
|
//
|
||||||
|
// KeyboardViewController+Chat.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/02/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KeyboardViewController+Private.h"
|
||||||
|
|
||||||
|
#import "KBChatLimitPopView.h"
|
||||||
|
#import "KBChatMessage.h"
|
||||||
|
#import "KBChatPanelView.h"
|
||||||
|
#import "KBFullAccessManager.h"
|
||||||
|
#import "../Utils/KBExtensionAppLauncher.h"
|
||||||
|
#import "KBInputBufferManager.h"
|
||||||
|
#import "KBNetworkManager.h"
|
||||||
|
#import "KBVM.h"
|
||||||
|
#import "Masonry.h"
|
||||||
|
|
||||||
|
#import <AVFoundation/AVFoundation.h>
|
||||||
|
|
||||||
|
static const NSUInteger kKBChatMessageLimit = 6;
|
||||||
|
|
||||||
|
@implementation KeyboardViewController (Chat)
|
||||||
|
|
||||||
|
#pragma mark - KBChatPanelViewDelegate
|
||||||
|
|
||||||
|
- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text {
|
||||||
|
NSString *trim =
|
||||||
|
[text stringByTrimmingCharactersInSet:
|
||||||
|
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||||
|
if (trim.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[self kb_sendChatText:trim];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)chatPanelView:(KBChatPanelView *)view
|
||||||
|
didTapMessage:(KBChatMessage *)message {
|
||||||
|
if (message.audioFilePath.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[self kb_playChatAudioAtPath:message.audioFilePath];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)chatPanelView:(KBChatPanelView *)view
|
||||||
|
didTapVoiceButtonForMessage:(KBChatMessage *)message {
|
||||||
|
if (!message)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// 如果有 audioData,直接播放
|
||||||
|
if (message.audioData && message.audioData.length > 0) {
|
||||||
|
[self kb_playChatAudioData:message.audioData];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有 audioFilePath,播放文件
|
||||||
|
if (message.audioFilePath.length > 0) {
|
||||||
|
[self kb_playChatAudioAtPath:message.audioFilePath];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog(@"[Keyboard] 没有音频数据可播放");
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view {
|
||||||
|
// 清空 chatPanelView 内部的消息
|
||||||
|
[view kb_reloadWithMessages:@[]];
|
||||||
|
if (self.chatAudioPlayer.isPlaying) {
|
||||||
|
[self.chatAudioPlayer stop];
|
||||||
|
}
|
||||||
|
self.chatAudioPlayer = nil;
|
||||||
|
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Chat Helpers
|
||||||
|
|
||||||
|
- (void)kb_handleChatSendAction {
|
||||||
|
if (!self.chatPanelVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[[KBInputBufferManager shared]
|
||||||
|
refreshFromProxyIfPossible:self.textDocumentProxy];
|
||||||
|
NSString *fullText = [KBInputBufferManager shared].liveText ?: @"";
|
||||||
|
|
||||||
|
// 去掉打开聊天面板前宿主输入框里已有的基线文本,只取新增部分
|
||||||
|
NSString *baseline = self.chatPanelBaselineText ?: @"";
|
||||||
|
NSString *rawText = fullText;
|
||||||
|
if (baseline.length > 0 && [fullText hasPrefix:baseline]) {
|
||||||
|
rawText = [fullText substringFromIndex:baseline.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *trim =
|
||||||
|
[rawText stringByTrimmingCharactersInSet:
|
||||||
|
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||||
|
NSString *textToClear = rawText;
|
||||||
|
if (trim.length == 0) {
|
||||||
|
// 兼容「先输入再打开聊天面板」场景:
|
||||||
|
// 此时新增文本为空,但当前输入框已有可发送内容,应该允许直接发送。
|
||||||
|
NSString *fullTrim =
|
||||||
|
[fullText stringByTrimmingCharactersInSet:
|
||||||
|
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||||
|
if (fullTrim.length > 0) {
|
||||||
|
trim = fullTrim;
|
||||||
|
textToClear = fullText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trim.length == 0) {
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Please enter content")];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[self kb_sendChatText:trim];
|
||||||
|
// 默认只清新增文本;若命中兜底则清当前全文,避免“已发送但输入框残留”。
|
||||||
|
[self kb_clearHostInputForText:textToClear];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_sendChatText:(NSString *)text {
|
||||||
|
if (text.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
NSLog(@"[KB] 发送消息 len=%lu", (unsigned long)text.length);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
KBChatMessage *outgoing = [KBChatMessage userMessageWithText:text];
|
||||||
|
outgoing.avatarURL = [self kb_sharedUserAvatarURL];
|
||||||
|
[self.chatPanelView kb_addUserMessage:text];
|
||||||
|
[self kb_prefetchAvatarForMessage:outgoing];
|
||||||
|
|
||||||
|
if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) {
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Please enable Full Access to continue")];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 loading 消息
|
||||||
|
[self.chatPanelView kb_addLoadingAssistantMessage];
|
||||||
|
|
||||||
|
// 调用新的聊天接口
|
||||||
|
[self kb_requestChatMessageWithContent:text];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Chat Limit Pop
|
||||||
|
|
||||||
|
- (void)kb_showChatLimitPopWithMessage:(NSString *)message {
|
||||||
|
[self kb_dismissChatLimitPop];
|
||||||
|
|
||||||
|
UIControl *mask = [[UIControl alloc] init];
|
||||||
|
mask.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.4];
|
||||||
|
mask.alpha = 0.0;
|
||||||
|
[mask addTarget:self
|
||||||
|
action:@selector(kb_dismissChatLimitPop)
|
||||||
|
forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
[self.contentView addSubview:mask];
|
||||||
|
[mask mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.edges.equalTo(self.contentView);
|
||||||
|
}];
|
||||||
|
|
||||||
|
CGFloat width = 252.0;
|
||||||
|
CGFloat height = 252.0 + 18.0 + 53.0 + 18.0 + 28.0;
|
||||||
|
KBChatLimitPopView *content =
|
||||||
|
[[KBChatLimitPopView alloc] initWithFrame:CGRectMake(0, 0, width, height)];
|
||||||
|
content.message = message ?: @"";
|
||||||
|
content.delegate = self;
|
||||||
|
[mask addSubview:content];
|
||||||
|
[content mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.center.equalTo(mask);
|
||||||
|
make.width.mas_equalTo(width);
|
||||||
|
make.height.mas_equalTo(height);
|
||||||
|
}];
|
||||||
|
|
||||||
|
self.chatLimitMaskView = mask;
|
||||||
|
[self.contentView bringSubviewToFront:mask];
|
||||||
|
[UIView animateWithDuration:0.18
|
||||||
|
animations:^{
|
||||||
|
mask.alpha = 1.0;
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_dismissChatLimitPop {
|
||||||
|
if (!self.chatLimitMaskView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
UIControl *mask = self.chatLimitMaskView;
|
||||||
|
self.chatLimitMaskView = nil;
|
||||||
|
[UIView animateWithDuration:0.15
|
||||||
|
animations:^{
|
||||||
|
mask.alpha = 0.0;
|
||||||
|
}
|
||||||
|
completion:^(__unused BOOL finished) {
|
||||||
|
[mask removeFromSuperview];
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_clearHostInputForText:(NSString *)text {
|
||||||
|
if (text.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSUInteger count = [self kb_composedCharacterCountForString:text];
|
||||||
|
for (NSUInteger i = 0; i < count; i++) {
|
||||||
|
[self.textDocumentProxy deleteBackward];
|
||||||
|
}
|
||||||
|
[[KBInputBufferManager shared] clearAllLiveText];
|
||||||
|
[self kb_clearCurrentWord];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSUInteger)kb_composedCharacterCountForString:(NSString *)text {
|
||||||
|
if (text.length == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
__block NSUInteger count = 0;
|
||||||
|
[text enumerateSubstringsInRange:NSMakeRange(0, text.length)
|
||||||
|
options:NSStringEnumerationByComposedCharacterSequences
|
||||||
|
usingBlock:^(__unused NSString *substring,
|
||||||
|
__unused NSRange substringRange,
|
||||||
|
__unused NSRange enclosingRange,
|
||||||
|
__unused BOOL *stop) {
|
||||||
|
count += 1;
|
||||||
|
}];
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_sharedUserAvatarURL {
|
||||||
|
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||||
|
NSString *url = [ud stringForKey:AppGroup_UserAvatarURL];
|
||||||
|
return url ?: @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_prefetchAvatarForMessage:(KBChatMessage *)message {
|
||||||
|
if (!message || message.avatarImage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSString *urlString = message.avatarURL ?: @"";
|
||||||
|
if (urlString.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[[KBVM shared] downloadAvatarFromURL:urlString
|
||||||
|
completion:^(UIImage *image, NSError *error) {
|
||||||
|
__strong typeof(weakSelf) self = weakSelf;
|
||||||
|
if (!self || !image)
|
||||||
|
return;
|
||||||
|
|
||||||
|
message.avatarImage = image;
|
||||||
|
[self kb_reloadChatRowForMessage:message];
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_reloadChatRowForMessage:(KBChatMessage *)message {
|
||||||
|
// 头像预加载完成后不需要刷新表格
|
||||||
|
// 因为键盘扩展的聊天面板不显示头像,所以这里直接返回
|
||||||
|
// 如果将来需要显示头像,可以只刷新特定行而不是整个表格
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_requestChatAudioForText:(NSString *)text {
|
||||||
|
NSString *mockPath = [self kb_mockChatAudioPath];
|
||||||
|
if (mockPath.length > 0) {
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.35 * NSEC_PER_SEC)),
|
||||||
|
dispatch_get_main_queue(), ^{
|
||||||
|
NSString *displayText = KBLocalized(@"Voice reply");
|
||||||
|
KBChatMessage *incoming =
|
||||||
|
[KBChatMessage messageWithText:displayText
|
||||||
|
outgoing:NO
|
||||||
|
audioFilePath:mockPath];
|
||||||
|
incoming.displayName = KBLocalized(@"AI Assistant");
|
||||||
|
[self kb_appendChatMessage:incoming];
|
||||||
|
[self kb_playChatAudioAtPath:mockPath];
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSDictionary *payload = @{@"message" : text ?: @""};
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[[KBNetworkManager shared] POST:API_AI_TALK
|
||||||
|
jsonBody:payload
|
||||||
|
headers:nil
|
||||||
|
completion:^(NSDictionary *json, NSURLResponse *response,
|
||||||
|
NSError *error) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
__strong typeof(weakSelf) self = weakSelf;
|
||||||
|
if (!self) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
NSString *tip = error.localizedDescription
|
||||||
|
?: KBLocalized(@"Request failed");
|
||||||
|
[KBHUD showInfo:tip];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSString *displayText =
|
||||||
|
[self kb_chatTextFromJSON:json];
|
||||||
|
NSString *audioURL =
|
||||||
|
[self kb_chatAudioURLFromJSON:json];
|
||||||
|
NSString *audioBase64 =
|
||||||
|
[self kb_chatAudioBase64FromJSON:json];
|
||||||
|
if (audioURL.length > 0) {
|
||||||
|
[self kb_downloadChatAudioFromURL:audioURL
|
||||||
|
displayText:displayText];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (audioBase64.length > 0) {
|
||||||
|
NSData *data = [[NSData alloc]
|
||||||
|
initWithBase64EncodedString:audioBase64
|
||||||
|
options:0];
|
||||||
|
if (data.length == 0) {
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Failed to parse audio data")];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[self kb_handleChatAudioData:data
|
||||||
|
fileExtension:@"m4a"
|
||||||
|
displayText:displayText];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[KBHUD showInfo:KBLocalized(@"No audio file received")];
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - New Chat API (with typewriter effect and audio preload)
|
||||||
|
|
||||||
|
/// 调用新的聊天接口(返回文本和 audioId)
|
||||||
|
- (void)kb_requestChatMessageWithContent:(NSString *)content {
|
||||||
|
if (content.length == 0) {
|
||||||
|
[self.chatPanelView kb_removeLoadingAssistantMessage];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSInteger companionId = [[KBVM shared] selectedCompanionIdFromAppGroup];
|
||||||
|
NSLog(@"[KB] 请求聊天: companionId=%ld", (long)companionId);
|
||||||
|
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[[KBVM shared] sendChatMessageWithContent:content
|
||||||
|
companionId:companionId
|
||||||
|
completion:^(KBChatResponse *response) {
|
||||||
|
__strong typeof(weakSelf) self = weakSelf;
|
||||||
|
if (!self)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (response.code != 0) {
|
||||||
|
if (response.code == 50030) {
|
||||||
|
NSLog(@"[KB] ⚠️ 次数用尽: %@",
|
||||||
|
response.message);
|
||||||
|
[self.chatPanelView
|
||||||
|
kb_removeLoadingAssistantMessage];
|
||||||
|
[self kb_showChatLimitPopWithMessage:
|
||||||
|
response.message];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSLog(@"[KB] ❌ 请求失败: %@",
|
||||||
|
response.message);
|
||||||
|
[self.chatPanelView
|
||||||
|
kb_removeLoadingAssistantMessage];
|
||||||
|
[KBHUD showInfo:response.message
|
||||||
|
?: KBLocalized(@"Request failed")];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog(@"[KB] ✅ 收到回复: %@",
|
||||||
|
response.data.aiResponse);
|
||||||
|
|
||||||
|
if (response.data.aiResponse.length == 0) {
|
||||||
|
[self.chatPanelView
|
||||||
|
kb_removeLoadingAssistantMessage];
|
||||||
|
[KBHUD showInfo:KBLocalized(@"No reply content received")];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 AI 消息(带打字机效果)
|
||||||
|
NSLog(@"[KB] 准备添加 AI 消息");
|
||||||
|
[self.chatPanelView
|
||||||
|
kb_addAssistantMessage:response.data.aiResponse
|
||||||
|
audioId:response.data.audioId];
|
||||||
|
NSLog(@"[KB] AI 消息添加完成");
|
||||||
|
|
||||||
|
// 通知主 App 刷新对应 persona 的聊天记录
|
||||||
|
[self kb_notifyMainAppChatUpdatedWithCompanionId:companionId];
|
||||||
|
|
||||||
|
// 如果有 audioId,开始预加载音频
|
||||||
|
if (response.data.audioId.length > 0) {
|
||||||
|
[self kb_preloadAudioWithAudioId:
|
||||||
|
response.data.audioId];
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 AppGroup 获取选中的 persona companionId
|
||||||
|
- (NSInteger)kb_selectedCompanionId {
|
||||||
|
return [[KBVM shared] selectedCompanionIdFromAppGroup];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Audio Preload
|
||||||
|
|
||||||
|
/// 预加载音频(轮询获取 audioURL)
|
||||||
|
- (void)kb_preloadAudioWithAudioId:(NSString *)audioId {
|
||||||
|
if (audioId.length == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
NSLog(@"[Keyboard] 开始预加载音频,audioId: %@", audioId);
|
||||||
|
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[[KBVM shared] pollAudioURLWithAudioId:audioId
|
||||||
|
maxRetries:10
|
||||||
|
interval:1.0
|
||||||
|
completion:^(KBAudioResponse *response) {
|
||||||
|
__strong typeof(weakSelf) self = weakSelf;
|
||||||
|
if (!self)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!response.success ||
|
||||||
|
response.audioURL.length == 0) {
|
||||||
|
NSLog(@"[Keyboard] ❌ 预加载音频 URL 获取失败: %@",
|
||||||
|
response.errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog(@"[Keyboard] ✅ 预加载音频 URL 获取成功");
|
||||||
|
|
||||||
|
// 下载音频
|
||||||
|
[[KBVM shared]
|
||||||
|
downloadAudioFromURL:response.audioURL
|
||||||
|
completion:^(
|
||||||
|
KBAudioResponse *audioResponse) {
|
||||||
|
if (!audioResponse.success) {
|
||||||
|
NSLog(@"[Keyboard] ❌ 预加载音频下载失败: %@",
|
||||||
|
audioResponse.errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最后一条 AI 消息的音频数据
|
||||||
|
[self.chatPanelView
|
||||||
|
kb_updateLastAssistantMessageWithAudioData:
|
||||||
|
audioResponse.audioData
|
||||||
|
duration:
|
||||||
|
audioResponse.duration];
|
||||||
|
NSLog(@"[Keyboard] ✅ 预加载音频完成,音频时长: %.2f秒",
|
||||||
|
audioResponse.duration);
|
||||||
|
}];
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_downloadChatAudioFromURL:(NSString *)audioURL
|
||||||
|
displayText:(NSString *)displayText {
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[[KBVM shared] downloadAudioFromURL:audioURL
|
||||||
|
completion:^(KBAudioResponse *response) {
|
||||||
|
__strong typeof(weakSelf) self = weakSelf;
|
||||||
|
if (!self)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
[KBHUD showInfo:response.errorMessage
|
||||||
|
?: KBLocalized(@"Download failed")];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.audioData ||
|
||||||
|
response.audioData.length == 0) {
|
||||||
|
[KBHUD showInfo:KBLocalized(@"No audio data received")];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *ext = @"m4a";
|
||||||
|
NSURL *url = [NSURL URLWithString:audioURL];
|
||||||
|
if (url.pathExtension.length > 0) {
|
||||||
|
ext = url.pathExtension;
|
||||||
|
}
|
||||||
|
[self kb_handleChatAudioData:response.audioData
|
||||||
|
fileExtension:ext
|
||||||
|
displayText:displayText];
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_handleChatAudioData:(NSData *)data
|
||||||
|
fileExtension:(NSString *)extension
|
||||||
|
displayText:(NSString *)displayText {
|
||||||
|
if (data.length == 0) {
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Audio data is empty")];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSString *ext = extension.length > 0 ? extension : @"m4a";
|
||||||
|
NSString *fileName = [NSString
|
||||||
|
stringWithFormat:@"kb_chat_%@.%@",
|
||||||
|
@((long long)([NSDate date].timeIntervalSince1970 *
|
||||||
|
1000)),
|
||||||
|
ext];
|
||||||
|
NSString *filePath =
|
||||||
|
[NSTemporaryDirectory() stringByAppendingPathComponent:fileName];
|
||||||
|
if (![data writeToFile:filePath atomically:YES]) {
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Failed to save audio")];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSString *text =
|
||||||
|
displayText.length > 0 ? displayText : KBLocalized(@"Voice message");
|
||||||
|
KBChatMessage *incoming =
|
||||||
|
[KBChatMessage messageWithText:text outgoing:NO audioFilePath:filePath];
|
||||||
|
incoming.displayName = KBLocalized(@"AI Assistant");
|
||||||
|
[self kb_appendChatMessage:incoming];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_appendChatMessage:(KBChatMessage *)message {
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[self.chatMessages addObject:message];
|
||||||
|
if (self.chatMessages.count > kKBChatMessageLimit) {
|
||||||
|
NSUInteger overflow = self.chatMessages.count - kKBChatMessageLimit;
|
||||||
|
NSArray<KBChatMessage *> *removed =
|
||||||
|
[self.chatMessages subarrayWithRange:NSMakeRange(0, overflow)];
|
||||||
|
[self.chatMessages removeObjectsInRange:NSMakeRange(0, overflow)];
|
||||||
|
for (KBChatMessage *msg in removed) {
|
||||||
|
if (msg.audioFilePath.length > 0) {
|
||||||
|
NSString *tmpRoot = NSTemporaryDirectory();
|
||||||
|
if (tmpRoot.length > 0 && [msg.audioFilePath hasPrefix:tmpRoot]) {
|
||||||
|
[[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath
|
||||||
|
error:nil];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[self.chatPanelView kb_reloadWithMessages:self.chatMessages];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_mockChatAudioPath {
|
||||||
|
NSString *path = [[NSBundle mainBundle] pathForResource:@"ai_test"
|
||||||
|
ofType:@"m4a"];
|
||||||
|
return path ?: @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_chatTextFromJSON:(NSDictionary *)json {
|
||||||
|
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
|
||||||
|
NSString *text =
|
||||||
|
[self kb_stringValueInDict:data keys:@[ @"text", @"message", @"content" ]];
|
||||||
|
if (text.length == 0) {
|
||||||
|
text = [self kb_stringValueInDict:json
|
||||||
|
keys:@[ @"text", @"message", @"content" ]];
|
||||||
|
}
|
||||||
|
return text ?: @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_chatAudioURLFromJSON:(NSDictionary *)json {
|
||||||
|
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
|
||||||
|
NSArray<NSString *> *keys =
|
||||||
|
@[ @"audioUrl", @"audioURL", @"audio_url", @"url", @"fileUrl",
|
||||||
|
@"file_url", @"audioFileUrl", @"audio_file_url" ];
|
||||||
|
NSString *url = [self kb_stringValueInDict:data keys:keys];
|
||||||
|
if (url.length == 0) {
|
||||||
|
url = [self kb_stringValueInDict:json keys:keys];
|
||||||
|
}
|
||||||
|
return url ?: @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_chatAudioBase64FromJSON:(NSDictionary *)json {
|
||||||
|
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
|
||||||
|
NSArray<NSString *> *keys =
|
||||||
|
@[ @"audioBase64", @"audio_base64", @"audioData", @"audio_data",
|
||||||
|
@"base64" ];
|
||||||
|
NSString *b64 = [self kb_stringValueInDict:data keys:keys];
|
||||||
|
if (b64.length == 0) {
|
||||||
|
b64 = [self kb_stringValueInDict:json keys:keys];
|
||||||
|
}
|
||||||
|
return b64 ?: @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSDictionary *)kb_chatDataDictionaryFromJSON:(NSDictionary *)json {
|
||||||
|
if (![json isKindOfClass:[NSDictionary class]]) {
|
||||||
|
return @{};
|
||||||
|
}
|
||||||
|
id dataObj = json[@"data"] ?: json[@"result"] ?: json[@"response"];
|
||||||
|
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||||
|
return (NSDictionary *)dataObj;
|
||||||
|
}
|
||||||
|
return @{};
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_stringValueInDict:(NSDictionary *)dict
|
||||||
|
keys:(NSArray<NSString *> *)keys {
|
||||||
|
if (![dict isKindOfClass:[NSDictionary class]]) {
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
|
for (NSString *key in keys) {
|
||||||
|
id value = dict[key];
|
||||||
|
if ([value isKindOfClass:[NSString class]] &&
|
||||||
|
((NSString *)value).length > 0) {
|
||||||
|
return (NSString *)value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_playChatAudioAtPath:(NSString *)path {
|
||||||
|
if (path.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSURL *url = [NSURL fileURLWithPath:path];
|
||||||
|
if (![NSFileManager.defaultManager fileExistsAtPath:path]) {
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Audio file does not exist")];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) {
|
||||||
|
NSURL *currentURL = self.chatAudioPlayer.url;
|
||||||
|
if ([currentURL isEqual:url]) {
|
||||||
|
[self.chatAudioPlayer stop];
|
||||||
|
self.chatAudioPlayer = nil;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[self.chatAudioPlayer stop];
|
||||||
|
self.chatAudioPlayer = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSError *sessionError = nil;
|
||||||
|
AVAudioSession *session = [AVAudioSession sharedInstance];
|
||||||
|
if ([session respondsToSelector:@selector(setCategory:options:error:)]) {
|
||||||
|
[session setCategory:AVAudioSessionCategoryPlayback
|
||||||
|
withOptions:AVAudioSessionCategoryOptionDuckOthers
|
||||||
|
error:&sessionError];
|
||||||
|
} else {
|
||||||
|
[session setCategory:AVAudioSessionCategoryPlayback error:&sessionError];
|
||||||
|
}
|
||||||
|
[session setActive:YES error:nil];
|
||||||
|
|
||||||
|
NSError *playerError = nil;
|
||||||
|
AVAudioPlayer *player =
|
||||||
|
[[AVAudioPlayer alloc] initWithContentsOfURL:url error:&playerError];
|
||||||
|
if (playerError || !player) {
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Audio playback failed")];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.chatAudioPlayer = player;
|
||||||
|
[player prepareToPlay];
|
||||||
|
[player play];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 播放音频数据
|
||||||
|
- (void)kb_playChatAudioData:(NSData *)audioData {
|
||||||
|
if (!audioData || audioData.length == 0) {
|
||||||
|
NSLog(@"[Keyboard] 音频数据为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果正在播放,先停止
|
||||||
|
if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) {
|
||||||
|
[self.chatAudioPlayer stop];
|
||||||
|
self.chatAudioPlayer = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置音频会话
|
||||||
|
NSError *sessionError = nil;
|
||||||
|
AVAudioSession *session = [AVAudioSession sharedInstance];
|
||||||
|
if ([session respondsToSelector:@selector(setCategory:options:error:)]) {
|
||||||
|
[session setCategory:AVAudioSessionCategoryPlayback
|
||||||
|
withOptions:AVAudioSessionCategoryOptionDuckOthers
|
||||||
|
error:&sessionError];
|
||||||
|
} else {
|
||||||
|
[session setCategory:AVAudioSessionCategoryPlayback error:&sessionError];
|
||||||
|
}
|
||||||
|
[session setActive:YES error:nil];
|
||||||
|
|
||||||
|
// 创建播放器
|
||||||
|
NSError *playerError = nil;
|
||||||
|
AVAudioPlayer *player =
|
||||||
|
[[AVAudioPlayer alloc] initWithData:audioData error:&playerError];
|
||||||
|
if (playerError || !player) {
|
||||||
|
NSLog(@"[Keyboard] 音频播放器初始化失败: %@",
|
||||||
|
playerError.localizedDescription);
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Audio playback failed")];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.chatAudioPlayer = player;
|
||||||
|
player.volume = 1.0;
|
||||||
|
[player prepareToPlay];
|
||||||
|
[player play];
|
||||||
|
|
||||||
|
NSLog(@"[Keyboard] 开始播放音频,时长: %.2f秒", player.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Notify Main App
|
||||||
|
|
||||||
|
/// 通知主 App 刷新对应 persona 的聊天记录
|
||||||
|
- (void)kb_notifyMainAppChatUpdatedWithCompanionId:(NSInteger)companionId {
|
||||||
|
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||||
|
[ud setInteger:companionId forKey:AppGroup_ChatUpdatedCompanionId];
|
||||||
|
[ud synchronize];
|
||||||
|
|
||||||
|
CFNotificationCenterPostNotification(
|
||||||
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
|
(__bridge CFStringRef)kKBDarwinChatUpdated,
|
||||||
|
NULL, NULL, true);
|
||||||
|
|
||||||
|
NSLog(@"[KB] 已通知主 App 刷新 companionId=%ld 的聊天记录", (long)companionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - KBChatLimitPopViewDelegate
|
||||||
|
|
||||||
|
- (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view {
|
||||||
|
[self kb_dismissChatLimitPop];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)chatLimitPopViewDidTapRecharge:(KBChatLimitPopView *)view {
|
||||||
|
[self kb_dismissChatLimitPop];
|
||||||
|
NSString *urlString =
|
||||||
|
[NSString stringWithFormat:@"%@://recharge?src=keyboard&vipType=svip",
|
||||||
|
KB_APP_SCHEME];
|
||||||
|
NSURL *scheme = [NSURL URLWithString:urlString];
|
||||||
|
NSString *ulString = [NSString stringWithFormat:@"%@?src=keyboard&vipType=svip", KB_UL_RECHARGE];
|
||||||
|
NSURL *ul = [NSURL URLWithString:ulString];
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[KBExtensionAppLauncher openPrimaryURL:ul
|
||||||
|
fallbackURL:scheme
|
||||||
|
usingInputController:self
|
||||||
|
source:(self.view ?: (UIResponder *)weakSelf)
|
||||||
|
completion:^(BOOL success) {
|
||||||
|
if (success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")];
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//
|
||||||
|
// KeyboardViewController+Layout.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/02/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KeyboardViewController+Private.h"
|
||||||
|
|
||||||
|
// 以 375 宽设计稿为基准的键盘总高度
|
||||||
|
static const CGFloat kKBKeyboardBaseHeight = 250.0f;
|
||||||
|
static const CGFloat kKBChatPanelHeight = 180;
|
||||||
|
|
||||||
|
@implementation KeyboardViewController (Layout)
|
||||||
|
|
||||||
|
- (CGFloat)kb_portraitWidth {
|
||||||
|
CGSize s = [UIScreen mainScreen].bounds.size;
|
||||||
|
return MIN(s.width, s.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width {
|
||||||
|
if (width <= 0) {
|
||||||
|
width = KB_DESIGN_WIDTH;
|
||||||
|
}
|
||||||
|
CGFloat scale = width / KB_DESIGN_WIDTH;
|
||||||
|
CGFloat baseHeight = kKBKeyboardBaseHeight * scale;
|
||||||
|
CGFloat chatHeight = kKBChatPanelHeight * scale;
|
||||||
|
if (self.chatPanelVisible) {
|
||||||
|
return baseHeight + chatHeight;
|
||||||
|
}
|
||||||
|
return baseHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width {
|
||||||
|
if (width <= 0) {
|
||||||
|
width = KB_DESIGN_WIDTH;
|
||||||
|
}
|
||||||
|
CGFloat scale = width / KB_DESIGN_WIDTH;
|
||||||
|
return kKBKeyboardBaseHeight * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width {
|
||||||
|
if (width <= 0) {
|
||||||
|
width = KB_DESIGN_WIDTH;
|
||||||
|
}
|
||||||
|
CGFloat scale = width / KB_DESIGN_WIDTH;
|
||||||
|
return kKBChatPanelHeight * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_updateKeyboardLayoutIfNeeded {
|
||||||
|
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||||
|
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
|
||||||
|
CGFloat keyboardBaseHeight =
|
||||||
|
[self kb_keyboardBaseHeightForWidth:portraitWidth];
|
||||||
|
CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth];
|
||||||
|
CGFloat containerWidth = CGRectGetWidth(self.view.superview.bounds);
|
||||||
|
if (containerWidth <= 0) {
|
||||||
|
containerWidth = CGRectGetWidth(self.view.window.bounds);
|
||||||
|
}
|
||||||
|
if (containerWidth <= 0) {
|
||||||
|
containerWidth = CGRectGetWidth([UIScreen mainScreen].bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL widthChanged = (fabs(self.kb_lastPortraitWidth - portraitWidth) >= 0.5);
|
||||||
|
BOOL heightChanged =
|
||||||
|
(fabs(self.kb_lastKeyboardHeight - keyboardHeight) >= 0.5);
|
||||||
|
if (!widthChanged && !heightChanged && containerWidth > 0 &&
|
||||||
|
self.kb_widthConstraint.constant == containerWidth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.kb_lastPortraitWidth = portraitWidth;
|
||||||
|
self.kb_lastKeyboardHeight = keyboardHeight;
|
||||||
|
|
||||||
|
if (self.kb_heightConstraint) {
|
||||||
|
self.kb_heightConstraint.constant = keyboardHeight;
|
||||||
|
}
|
||||||
|
if (containerWidth > 0 && self.kb_widthConstraint) {
|
||||||
|
self.kb_widthConstraint.constant = containerWidth;
|
||||||
|
}
|
||||||
|
if (self.contentWidthConstraint) {
|
||||||
|
[self.contentWidthConstraint setOffset:portraitWidth];
|
||||||
|
}
|
||||||
|
if (self.contentHeightConstraint) {
|
||||||
|
[self.contentHeightConstraint setOffset:keyboardHeight];
|
||||||
|
}
|
||||||
|
if (self.keyBoardMainHeightConstraint) {
|
||||||
|
[self.keyBoardMainHeightConstraint setOffset:keyboardBaseHeight];
|
||||||
|
}
|
||||||
|
if (self.chatPanelHeightConstraint) {
|
||||||
|
[self.chatPanelHeightConstraint setOffset:chatPanelHeight];
|
||||||
|
}
|
||||||
|
[self.view layoutIfNeeded];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
//
|
||||||
|
// KeyboardViewController+Panels.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/02/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KeyboardViewController+Private.h"
|
||||||
|
|
||||||
|
#import "KBAuthManager.h"
|
||||||
|
#import "KBBackspaceUndoManager.h"
|
||||||
|
#import "KBChatMessage.h"
|
||||||
|
#import "KBChatPanelView.h"
|
||||||
|
#import "KBFunctionView.h"
|
||||||
|
#import "KBFullAccessManager.h"
|
||||||
|
#import "../Utils/KBExtensionAppLauncher.h"
|
||||||
|
#import "KBInputBufferManager.h"
|
||||||
|
#import "KBKey.h"
|
||||||
|
#import "KBKeyBoardMainView.h"
|
||||||
|
#import "KBKeyboardSubscriptionView.h"
|
||||||
|
#import "Masonry.h"
|
||||||
|
#import <SDWebImage/SDWebImage.h>
|
||||||
|
#import <AVFoundation/AVAudioPlayer.h>
|
||||||
|
|
||||||
|
@implementation KeyboardViewController (Panels)
|
||||||
|
|
||||||
|
#pragma mark - Panel Mode
|
||||||
|
|
||||||
|
- (void)kb_setPanelMode:(KBKeyboardPanelMode)mode animated:(BOOL)animated {
|
||||||
|
if (mode == self.kb_panelMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
KBKeyboardPanelMode fromMode = self.kb_panelMode;
|
||||||
|
|
||||||
|
// AI 入口先判完全访问:未开启时仅展示引导,不再继续登录态判断。
|
||||||
|
if (mode == KBKeyboardPanelModeFunction &&
|
||||||
|
![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未登录时,不要提前写入面板状态,避免 mode 被错误卡在 Function 导致后续点击无响应。
|
||||||
|
BOOL islogin = YES;
|
||||||
|
if (mode == KBKeyboardPanelModeFunction) {
|
||||||
|
[[KBAuthManager shared] reloadFromKeychain];
|
||||||
|
islogin = KBAuthManager.shared.isLoggedIn;
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
if (mode == KBKeyboardPanelModeFunction) {
|
||||||
|
NSString *token = [KBAuthManager shared].current.accessToken ?: @"";
|
||||||
|
NSLog(@"[AuthTrace][Ext] tapAI mode=%ld isLoggedIn=%d tokenLen=%lu",
|
||||||
|
(long)mode, islogin, (unsigned long)token.length);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
if (mode == KBKeyboardPanelModeFunction && !islogin) {
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Please sign in before using AI features")];
|
||||||
|
NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=keyboard", KB_UL_LOGIN]];
|
||||||
|
NSURL *scheme =
|
||||||
|
[NSURL URLWithString:[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME]];
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[KBExtensionAppLauncher openPrimaryURL:ul
|
||||||
|
fallbackURL:scheme
|
||||||
|
usingInputController:self
|
||||||
|
source:(self.view ?: (UIResponder *)weakSelf)
|
||||||
|
completion:^(BOOL success) {
|
||||||
|
if (success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Please return to the Home screen and open the app to sign in")];
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.kb_panelMode = mode;
|
||||||
|
|
||||||
|
// 主键盘视图是基础承载:确保存在(键盘隐藏后会被释放)
|
||||||
|
[self kb_ensureKeyBoardMainViewIfNeeded];
|
||||||
|
|
||||||
|
// 1) 先收起所有面板(再展开目标面板),避免互相调用导致漏关/层级错乱
|
||||||
|
[self kb_setSubscriptionPanelVisible:NO animated:animated];
|
||||||
|
[self kb_setChatPanelVisible:NO animated:animated];
|
||||||
|
[self kb_setFunctionPanelVisible:NO];
|
||||||
|
|
||||||
|
// 2) 再展开目标面板
|
||||||
|
switch (mode) {
|
||||||
|
case KBKeyboardPanelModeFunction:
|
||||||
|
[self kb_setFunctionPanelVisible:YES];
|
||||||
|
break;
|
||||||
|
case KBKeyboardPanelModeChat:
|
||||||
|
[self kb_setChatPanelVisible:YES animated:animated];
|
||||||
|
break;
|
||||||
|
case KBKeyboardPanelModeSubscription:
|
||||||
|
[self kb_setSubscriptionPanelVisible:YES animated:animated];
|
||||||
|
break;
|
||||||
|
case KBKeyboardPanelModeMain:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 事件埋点:保持原逻辑(仅功能面板/主面板会互相曝光)
|
||||||
|
if (mode == KBKeyboardPanelModeFunction) {
|
||||||
|
[[KBMaiPointReporter sharedReporter]
|
||||||
|
reportPageExposureWithEventName:@"enter_keyboard_function_panel"
|
||||||
|
pageId:@"keyboard_function_panel"
|
||||||
|
extra:nil
|
||||||
|
completion:nil];
|
||||||
|
} else if (mode == KBKeyboardPanelModeMain &&
|
||||||
|
fromMode == KBKeyboardPanelModeFunction) {
|
||||||
|
[[KBMaiPointReporter sharedReporter]
|
||||||
|
reportPageExposureWithEventName:@"enter_keyboard_main_panel"
|
||||||
|
pageId:@"keyboard_main_panel"
|
||||||
|
extra:nil
|
||||||
|
completion:nil];
|
||||||
|
} else if (mode == KBKeyboardPanelModeSubscription) {
|
||||||
|
[[KBMaiPointReporter sharedReporter]
|
||||||
|
reportPageExposureWithEventName:@"enter_keyboard_subscription_panel"
|
||||||
|
pageId:@"keyboard_subscription_panel"
|
||||||
|
extra:nil
|
||||||
|
completion:nil];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 层级:保证当前面板在最上层
|
||||||
|
if (mode == KBKeyboardPanelModeSubscription) {
|
||||||
|
[self.contentView bringSubviewToFront:self.subscriptionView];
|
||||||
|
} else if (mode == KBKeyboardPanelModeChat) {
|
||||||
|
[self.contentView bringSubviewToFront:self.chatPanelView];
|
||||||
|
} else if (mode == KBKeyboardPanelModeFunction) {
|
||||||
|
[self.contentView bringSubviewToFront:self.functionView];
|
||||||
|
} else {
|
||||||
|
[self.contentView bringSubviewToFront:self.keyBoardMainView];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对外兼容:切换显示功能面板/键盘主视图
|
||||||
|
- (void)showFunctionPanel:(BOOL)show {
|
||||||
|
if (show) {
|
||||||
|
[self kb_setPanelMode:KBKeyboardPanelModeFunction animated:NO];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (self.kb_panelMode == KBKeyboardPanelModeFunction) {
|
||||||
|
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:NO];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对外兼容:显示/隐藏聊天面板(覆盖整个键盘区域)
|
||||||
|
- (void)showChatPanel:(BOOL)show {
|
||||||
|
if (show) {
|
||||||
|
[self kb_setPanelMode:KBKeyboardPanelModeChat animated:YES];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (self.kb_panelMode == KBKeyboardPanelModeChat) {
|
||||||
|
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_setFunctionPanelVisible:(BOOL)visible {
|
||||||
|
if (visible) {
|
||||||
|
[self kb_ensureFunctionViewIfNeeded];
|
||||||
|
}
|
||||||
|
if (_functionView) {
|
||||||
|
_functionView.hidden = !visible;
|
||||||
|
} else if (visible) {
|
||||||
|
// ensure 后按理已存在;这里兜底一次,避免异常情况下状态不一致
|
||||||
|
self.functionView.hidden = NO;
|
||||||
|
}
|
||||||
|
self.keyBoardMainView.hidden = visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_setChatPanelVisible:(BOOL)visible animated:(BOOL)animated {
|
||||||
|
if (visible == self.chatPanelVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.chatPanelVisible = visible;
|
||||||
|
|
||||||
|
if (visible) {
|
||||||
|
// 记录打开聊天面板时宿主输入框已有的文本,发送时只取新增部分
|
||||||
|
[[KBInputBufferManager shared]
|
||||||
|
refreshFromProxyIfPossible:self.textDocumentProxy];
|
||||||
|
self.chatPanelBaselineText = [KBInputBufferManager shared].liveText ?: @"";
|
||||||
|
[self kb_ensureChatPanelViewIfNeeded];
|
||||||
|
self.chatPanelView.hidden = NO;
|
||||||
|
self.chatPanelView.alpha = 0.0;
|
||||||
|
if (animated) {
|
||||||
|
[UIView animateWithDuration:0.2
|
||||||
|
delay:0
|
||||||
|
options:UIViewAnimationOptionCurveEaseOut
|
||||||
|
animations:^{
|
||||||
|
self.chatPanelView.alpha = 1.0;
|
||||||
|
}
|
||||||
|
completion:nil];
|
||||||
|
} else {
|
||||||
|
self.chatPanelView.alpha = 1.0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 从未创建过聊天面板时,直接返回,避免 show/hide 触发额外内存分配
|
||||||
|
if (!_chatPanelView) {
|
||||||
|
[self kb_updateKeyboardLayoutIfNeeded];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (animated) {
|
||||||
|
[UIView animateWithDuration:0.18
|
||||||
|
delay:0
|
||||||
|
options:UIViewAnimationOptionCurveEaseIn
|
||||||
|
animations:^{
|
||||||
|
self.chatPanelView.alpha = 0.0;
|
||||||
|
}
|
||||||
|
completion:^(BOOL finished) {
|
||||||
|
self.chatPanelView.hidden = YES;
|
||||||
|
}];
|
||||||
|
} else {
|
||||||
|
self.chatPanelView.alpha = 0.0;
|
||||||
|
self.chatPanelView.hidden = YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[self kb_updateKeyboardLayoutIfNeeded];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_setSubscriptionPanelVisible:(BOOL)visible animated:(BOOL)animated {
|
||||||
|
if (visible) {
|
||||||
|
KBKeyboardSubscriptionView *panel = self.subscriptionView;
|
||||||
|
if (!panel.superview) {
|
||||||
|
panel.hidden = YES;
|
||||||
|
[self.contentView addSubview:panel];
|
||||||
|
[panel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.edges.equalTo(self.contentView);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
[self.contentView bringSubviewToFront:panel];
|
||||||
|
panel.hidden = NO;
|
||||||
|
panel.alpha = 0.0;
|
||||||
|
CGFloat height = CGRectGetHeight(self.contentView.bounds);
|
||||||
|
if (height <= 0) {
|
||||||
|
height = 260;
|
||||||
|
}
|
||||||
|
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
||||||
|
[panel refreshProductsIfNeeded];
|
||||||
|
if (animated) {
|
||||||
|
[UIView animateWithDuration:0.25
|
||||||
|
delay:0
|
||||||
|
options:UIViewAnimationOptionCurveEaseOut
|
||||||
|
animations:^{
|
||||||
|
panel.alpha = 1.0;
|
||||||
|
panel.transform = CGAffineTransformIdentity;
|
||||||
|
}
|
||||||
|
completion:nil];
|
||||||
|
} else {
|
||||||
|
panel.alpha = 1.0;
|
||||||
|
panel.transform = CGAffineTransformIdentity;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
KBKeyboardSubscriptionView *panel = _subscriptionView;
|
||||||
|
if (!panel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!panel.superview || panel.hidden) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CGFloat height = CGRectGetHeight(panel.bounds);
|
||||||
|
if (height <= 0) {
|
||||||
|
height = CGRectGetHeight(self.contentView.bounds);
|
||||||
|
}
|
||||||
|
if (animated) {
|
||||||
|
[UIView animateWithDuration:0.22
|
||||||
|
delay:0
|
||||||
|
options:UIViewAnimationOptionCurveEaseIn
|
||||||
|
animations:^{
|
||||||
|
panel.alpha = 0.0;
|
||||||
|
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
||||||
|
}
|
||||||
|
completion:^(BOOL finished) {
|
||||||
|
panel.hidden = YES;
|
||||||
|
panel.alpha = 1.0;
|
||||||
|
panel.transform = CGAffineTransformIdentity;
|
||||||
|
}];
|
||||||
|
} else {
|
||||||
|
panel.hidden = YES;
|
||||||
|
panel.alpha = 1.0;
|
||||||
|
panel.transform = CGAffineTransformIdentity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟创建:仅在用户真正打开功能面板时才创建/布局,降低默认内存占用。
|
||||||
|
- (void)kb_ensureFunctionViewIfNeeded {
|
||||||
|
if (_functionView && _functionView.superview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KBFunctionView *v = self.functionView;
|
||||||
|
if (!v.superview) {
|
||||||
|
v.hidden = YES;
|
||||||
|
[self.contentView addSubview:v];
|
||||||
|
[v mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.edges.equalTo(self.contentView);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟创建:仅在用户打开聊天面板时才创建/布局。
|
||||||
|
- (void)kb_ensureChatPanelViewIfNeeded {
|
||||||
|
if (_chatPanelView && _chatPanelView.superview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||||
|
CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth];
|
||||||
|
KBChatPanelView *v = self.chatPanelView;
|
||||||
|
if (!v.superview) {
|
||||||
|
[self.contentView addSubview:v];
|
||||||
|
[v mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.right.equalTo(self.contentView);
|
||||||
|
make.bottom.equalTo(self.keyBoardMainView.mas_top);
|
||||||
|
self.chatPanelHeightConstraint =
|
||||||
|
make.height.mas_equalTo(chatPanelHeight);
|
||||||
|
}];
|
||||||
|
v.hidden = YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟创建:键盘主面板(按键区)在隐藏时会被释放;再次显示时需要重建。
|
||||||
|
- (void)kb_ensureKeyBoardMainViewIfNeeded {
|
||||||
|
if (_keyBoardMainView && _keyBoardMainView.superview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||||
|
CGFloat keyboardBaseHeight =
|
||||||
|
[self kb_keyboardBaseHeightForWidth:portraitWidth];
|
||||||
|
KBKeyBoardMainView *v = self.keyBoardMainView;
|
||||||
|
if (!v.superview) {
|
||||||
|
[self.contentView addSubview:v];
|
||||||
|
[v mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.right.equalTo(self.contentView);
|
||||||
|
make.bottom.equalTo(self.contentView);
|
||||||
|
self.keyBoardMainHeightConstraint =
|
||||||
|
make.height.mas_equalTo(keyboardBaseHeight);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
[self.contentView bringSubviewToFront:v];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 键盘隐藏时释放可重建资源(背景图/缓存/非必需面板),降低扩展内存峰值。
|
||||||
|
- (void)kb_releaseMemoryWhenKeyboardHidden {
|
||||||
|
[KBHUD setContainerView:nil];
|
||||||
|
self.bgImageView.image = nil;
|
||||||
|
self.kb_cachedGradientImage = nil;
|
||||||
|
[self.kb_defaultGradientLayer removeFromSuperlayer];
|
||||||
|
self.kb_defaultGradientLayer = nil;
|
||||||
|
[[SDImageCache sharedImageCache] clearMemory];
|
||||||
|
|
||||||
|
// 聊天相关可能持有音频数据/临时文件,键盘隐藏时直接清空,避免累计占用。
|
||||||
|
if (self.chatAudioPlayer) {
|
||||||
|
[self.chatAudioPlayer stop];
|
||||||
|
self.chatAudioPlayer = nil;
|
||||||
|
}
|
||||||
|
if (_chatMessages.count > 0) {
|
||||||
|
NSString *tmpRoot = NSTemporaryDirectory();
|
||||||
|
for (KBChatMessage *msg in _chatMessages.copy) {
|
||||||
|
if (tmpRoot.length > 0 && msg.audioFilePath.length > 0 &&
|
||||||
|
[msg.audioFilePath hasPrefix:tmpRoot]) {
|
||||||
|
[[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath
|
||||||
|
error:nil];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[_chatMessages removeAllObjects];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_keyBoardMainView) {
|
||||||
|
[_keyBoardMainView removeFromSuperview];
|
||||||
|
_keyBoardMainView = nil;
|
||||||
|
}
|
||||||
|
self.keyBoardMainHeightConstraint = nil;
|
||||||
|
|
||||||
|
if (_functionView) {
|
||||||
|
[_functionView removeFromSuperview];
|
||||||
|
_functionView = nil;
|
||||||
|
}
|
||||||
|
if (_chatPanelView) {
|
||||||
|
[_chatPanelView removeFromSuperview];
|
||||||
|
_chatPanelView = nil;
|
||||||
|
}
|
||||||
|
self.chatPanelVisible = NO;
|
||||||
|
self.kb_panelMode = KBKeyboardPanelModeMain;
|
||||||
|
|
||||||
|
if (_subscriptionView) {
|
||||||
|
[_subscriptionView removeFromSuperview];
|
||||||
|
_subscriptionView = nil;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - KBKeyBoardMainViewDelegate
|
||||||
|
|
||||||
|
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||||||
|
didTapKey:(KBKey *)key {
|
||||||
|
switch (key.type) {
|
||||||
|
case KBKeyTypeCharacter: {
|
||||||
|
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||||
|
NSString *text = key.output ?: key.title ?: @"";
|
||||||
|
[self.textDocumentProxy insertText:text];
|
||||||
|
[self kb_updateCurrentWordWithInsertedText:text];
|
||||||
|
[[KBInputBufferManager shared] appendText:text];
|
||||||
|
} break;
|
||||||
|
case KBKeyTypeBackspace:
|
||||||
|
[[KBInputBufferManager shared]
|
||||||
|
refreshFromProxyIfPossible:self.textDocumentProxy];
|
||||||
|
[[KBInputBufferManager shared]
|
||||||
|
prepareSnapshotForDeleteWithContextBefore:
|
||||||
|
self.textDocumentProxy.documentContextBeforeInput
|
||||||
|
after:
|
||||||
|
self.textDocumentProxy
|
||||||
|
.documentContextAfterInput];
|
||||||
|
[[KBBackspaceUndoManager shared]
|
||||||
|
captureAndDeleteBackwardFromProxy:self.textDocumentProxy
|
||||||
|
count:1];
|
||||||
|
[self kb_scheduleContextRefreshResetSuppression:NO];
|
||||||
|
[[KBInputBufferManager shared] applyHoldDeleteCount:1];
|
||||||
|
break;
|
||||||
|
case KBKeyTypeSpace:
|
||||||
|
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||||
|
[self.textDocumentProxy insertText:@" "];
|
||||||
|
[self kb_clearCurrentWord];
|
||||||
|
[[KBInputBufferManager shared] appendText:@" "];
|
||||||
|
break;
|
||||||
|
case KBKeyTypeReturn:
|
||||||
|
if (self.chatPanelVisible) {
|
||||||
|
[self kb_handleChatSendAction];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||||
|
[self.textDocumentProxy insertText:@"\n"];
|
||||||
|
[self kb_clearCurrentWord];
|
||||||
|
[[KBInputBufferManager shared] appendText:@"\n"];
|
||||||
|
break;
|
||||||
|
case KBKeyTypeGlobe:
|
||||||
|
[self advanceToNextInputMode];
|
||||||
|
break;
|
||||||
|
case KBKeyTypeCustom:
|
||||||
|
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||||
|
// 点击自定义键切换到功能面板
|
||||||
|
[self kb_setPanelMode:KBKeyboardPanelModeFunction animated:NO];
|
||||||
|
[self kb_clearCurrentWord];
|
||||||
|
break;
|
||||||
|
case KBKeyTypeModeChange:
|
||||||
|
case KBKeyTypeShift:
|
||||||
|
// 这些已在 KBKeyBoardMainView/KBKeyboardView 内部处理
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||||||
|
didTapToolActionAtIndex:(NSInteger)index {
|
||||||
|
NSDictionary *extra = @{@"index" : @(index)};
|
||||||
|
[[KBMaiPointReporter sharedReporter]
|
||||||
|
reportClickWithEventName:@"click_keyboard_toolbar_action"
|
||||||
|
pageId:@"keyboard_main_panel"
|
||||||
|
elementId:@"toolbar_action"
|
||||||
|
extra:extra
|
||||||
|
completion:nil];
|
||||||
|
if (index == 0) {
|
||||||
|
[self kb_setPanelMode:KBKeyboardPanelModeFunction animated:YES];
|
||||||
|
[self kb_clearCurrentWord];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (index == 1) {
|
||||||
|
[self kb_setPanelMode:KBKeyboardPanelModeChat animated:YES];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||||||
|
didSelectEmoji:(NSString *)emoji {
|
||||||
|
if (emoji.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||||
|
[self.textDocumentProxy insertText:emoji];
|
||||||
|
[self kb_clearCurrentWord];
|
||||||
|
[[KBInputBufferManager shared] appendText:emoji];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView {
|
||||||
|
[[KBMaiPointReporter sharedReporter]
|
||||||
|
reportClickWithEventName:@"click_keyboard_undo_btn"
|
||||||
|
pageId:@"keyboard_main_panel"
|
||||||
|
elementId:@"undo_btn"
|
||||||
|
extra:nil
|
||||||
|
completion:nil];
|
||||||
|
[[KBBackspaceUndoManager shared] performUndoFromResponder:self.view];
|
||||||
|
[self kb_scheduleContextRefreshResetSuppression:YES];
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - KBFunctionViewDelegate
|
||||||
|
|
||||||
|
- (void)functionView:(KBFunctionView *)functionView
|
||||||
|
didTapToolActionAtIndex:(NSInteger)index {
|
||||||
|
// 需求:当 index == 0 时,切回键盘主视图
|
||||||
|
if (index == 0) {
|
||||||
|
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:NO];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)functionView:(KBFunctionView *_Nullable)functionView
|
||||||
|
didRightTapToolActionAtIndex:(NSInteger)index {
|
||||||
|
[[KBMaiPointReporter sharedReporter]
|
||||||
|
reportClickWithEventName:@"click_keyboard_function_right_action"
|
||||||
|
pageId:@"keyboard_function_panel"
|
||||||
|
elementId:@"right_action"
|
||||||
|
extra:@{@"action" : @"login_or_recharge"}
|
||||||
|
completion:nil];
|
||||||
|
if (!KBAuthManager.shared.isLoggedIn) {
|
||||||
|
NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=keyboard", KB_UL_LOGIN]];
|
||||||
|
NSURL *scheme =
|
||||||
|
[NSURL URLWithString:[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME]];
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[KBExtensionAppLauncher openPrimaryURL:ul
|
||||||
|
fallbackURL:scheme
|
||||||
|
usingInputController:self
|
||||||
|
source:(self.view ?: (UIResponder *)weakSelf)
|
||||||
|
completion:nil];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=keyboard", KB_UL_RECHARGE]];
|
||||||
|
NSURL *scheme =
|
||||||
|
[NSURL URLWithString:[NSString stringWithFormat:@"%@://recharge?src=keyboard", KB_APP_SCHEME]];
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[KBExtensionAppLauncher openPrimaryURL:ul
|
||||||
|
fallbackURL:scheme
|
||||||
|
usingInputController:self
|
||||||
|
source:(self.view ?: (UIResponder *)weakSelf)
|
||||||
|
completion:^(BOOL success) {
|
||||||
|
if (success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
[KBHUD showInfo:KBLocalized(@"This app does not allow the keyboard to open the main app directly. Please return to the Home screen and open the app manually to recharge")];
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)functionViewDidRequestSubscription:(KBFunctionView *)functionView {
|
||||||
|
[self showSubscriptionPanel];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//
|
||||||
|
// KeyboardViewController+Private.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/02/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KeyboardViewController.h"
|
||||||
|
#import "Masonry.h"
|
||||||
|
|
||||||
|
@class AVAudioPlayer;
|
||||||
|
@class CAGradientLayer;
|
||||||
|
@class KBChatMessage;
|
||||||
|
@class KBChatPanelView;
|
||||||
|
@class KBFunctionView;
|
||||||
|
@class KBKeyBoardMainView;
|
||||||
|
@class KBKeyboardSubscriptionView;
|
||||||
|
@class KBSuggestionEngine;
|
||||||
|
|
||||||
|
@protocol KBChatLimitPopViewDelegate;
|
||||||
|
@protocol KBChatPanelViewDelegate;
|
||||||
|
@protocol KBFunctionViewDelegate;
|
||||||
|
@protocol KBKeyBoardMainViewDelegate;
|
||||||
|
@protocol KBKeyboardSubscriptionViewDelegate;
|
||||||
|
|
||||||
|
typedef NS_ENUM(NSInteger, KBKeyboardPanelMode) {
|
||||||
|
KBKeyboardPanelModeMain = 0,
|
||||||
|
KBKeyboardPanelModeFunction,
|
||||||
|
KBKeyboardPanelModeChat,
|
||||||
|
KBKeyboardPanelModeSubscription,
|
||||||
|
};
|
||||||
|
|
||||||
|
@interface KeyboardViewController () <KBKeyBoardMainViewDelegate,
|
||||||
|
KBFunctionViewDelegate,
|
||||||
|
KBKeyboardSubscriptionViewDelegate,
|
||||||
|
KBChatPanelViewDelegate,
|
||||||
|
KBChatLimitPopViewDelegate>
|
||||||
|
{
|
||||||
|
UIButton *_nextKeyboardButton;
|
||||||
|
UIView *_contentView;
|
||||||
|
KBKeyBoardMainView *_keyBoardMainView;
|
||||||
|
KBFunctionView *_functionView;
|
||||||
|
UIImageView *_bgImageView;
|
||||||
|
KBChatPanelView *_chatPanelView;
|
||||||
|
KBKeyboardSubscriptionView *_subscriptionView;
|
||||||
|
KBSuggestionEngine *_suggestionEngine;
|
||||||
|
NSString *_currentWord;
|
||||||
|
UIControl *_chatLimitMaskView;
|
||||||
|
MASConstraint *_contentWidthConstraint;
|
||||||
|
MASConstraint *_contentHeightConstraint;
|
||||||
|
MASConstraint *_keyBoardMainHeightConstraint;
|
||||||
|
MASConstraint *_chatPanelHeightConstraint;
|
||||||
|
NSLayoutConstraint *_kb_heightConstraint;
|
||||||
|
NSLayoutConstraint *_kb_widthConstraint;
|
||||||
|
CGFloat _kb_lastPortraitWidth;
|
||||||
|
CGFloat _kb_lastKeyboardHeight;
|
||||||
|
UIImage *_kb_cachedGradientImage;
|
||||||
|
CGSize _kb_cachedGradientSize;
|
||||||
|
CAGradientLayer *_kb_defaultGradientLayer;
|
||||||
|
NSString *_kb_lastAppliedThemeKey;
|
||||||
|
NSMutableArray<KBChatMessage *> *_chatMessages;
|
||||||
|
AVAudioPlayer *_chatAudioPlayer;
|
||||||
|
BOOL _suppressSuggestions;
|
||||||
|
BOOL _chatPanelVisible;
|
||||||
|
NSString *_chatPanelBaselineText;
|
||||||
|
id _kb_fullAccessObserverToken;
|
||||||
|
id _kb_skinObserverToken;
|
||||||
|
id _kb_localizationObserverToken;
|
||||||
|
KBKeyboardPanelMode _kb_panelMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property(nonatomic, strong)
|
||||||
|
UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选)
|
||||||
|
@property(nonatomic, strong) UIView *contentView;
|
||||||
|
@property(nonatomic, strong) KBKeyBoardMainView
|
||||||
|
*keyBoardMainView; // 功能面板视图(点击工具栏第0个时显示)
|
||||||
|
@property(nonatomic, strong)
|
||||||
|
KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示)
|
||||||
|
@property(nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层)
|
||||||
|
@property(nonatomic, strong) KBChatPanelView *chatPanelView;
|
||||||
|
@property(nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView;
|
||||||
|
@property(nonatomic, strong) KBSuggestionEngine *suggestionEngine;
|
||||||
|
@property(nonatomic, copy) NSString *currentWord;
|
||||||
|
@property(nonatomic, assign) BOOL suppressSuggestions;
|
||||||
|
@property(nonatomic, strong) UIControl *chatLimitMaskView;
|
||||||
|
@property(nonatomic, strong) MASConstraint *contentWidthConstraint;
|
||||||
|
@property(nonatomic, strong) MASConstraint *contentHeightConstraint;
|
||||||
|
@property(nonatomic, strong) MASConstraint *keyBoardMainHeightConstraint;
|
||||||
|
@property(nonatomic, strong) MASConstraint *chatPanelHeightConstraint;
|
||||||
|
@property(nonatomic, strong) NSLayoutConstraint *kb_heightConstraint;
|
||||||
|
@property(nonatomic, strong) NSLayoutConstraint *kb_widthConstraint;
|
||||||
|
@property(nonatomic, assign) CGFloat kb_lastPortraitWidth;
|
||||||
|
@property(nonatomic, assign) CGFloat kb_lastKeyboardHeight;
|
||||||
|
@property(nonatomic, strong) UIImage *kb_cachedGradientImage;
|
||||||
|
@property(nonatomic, assign) CGSize kb_cachedGradientSize;
|
||||||
|
@property(nonatomic, strong, nullable) CAGradientLayer *kb_defaultGradientLayer;
|
||||||
|
@property(nonatomic, copy, nullable) NSString *kb_lastAppliedThemeKey;
|
||||||
|
@property(nonatomic, strong) NSMutableArray<KBChatMessage *> *chatMessages;
|
||||||
|
@property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer;
|
||||||
|
@property(nonatomic, assign) BOOL chatPanelVisible;
|
||||||
|
@property(nonatomic, copy) NSString *chatPanelBaselineText; // 打开聊天面板时宿主输入框已有的文本
|
||||||
|
@property(nonatomic, strong, nullable) id kb_fullAccessObserverToken;
|
||||||
|
@property(nonatomic, strong, nullable) id kb_skinObserverToken;
|
||||||
|
@property(nonatomic, strong, nullable) id kb_localizationObserverToken;
|
||||||
|
@property(nonatomic, assign) KBKeyboardPanelMode kb_panelMode;
|
||||||
|
@property(nonatomic, strong, nullable) id kb_appGroupObserverToken;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KeyboardViewController (KBPrivate)
|
||||||
|
|
||||||
|
// UI
|
||||||
|
- (void)setupUI;
|
||||||
|
- (nullable KBFunctionView *)kb_functionViewIfCreated;
|
||||||
|
|
||||||
|
// Panels
|
||||||
|
- (void)showFunctionPanel:(BOOL)show;
|
||||||
|
- (void)showChatPanel:(BOOL)show;
|
||||||
|
- (void)showSubscriptionPanel;
|
||||||
|
- (void)hideSubscriptionPanel;
|
||||||
|
- (void)kb_setPanelMode:(KBKeyboardPanelMode)mode animated:(BOOL)animated;
|
||||||
|
- (void)kb_ensureFunctionViewIfNeeded;
|
||||||
|
- (void)kb_ensureChatPanelViewIfNeeded;
|
||||||
|
- (void)kb_ensureKeyBoardMainViewIfNeeded;
|
||||||
|
- (void)kb_releaseMemoryWhenKeyboardHidden;
|
||||||
|
|
||||||
|
// Suggestions
|
||||||
|
- (void)kb_updateCurrentWordWithInsertedText:(NSString *)text;
|
||||||
|
- (void)kb_clearCurrentWord;
|
||||||
|
- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression;
|
||||||
|
- (void)kb_refreshCurrentWordFromDocumentContextResetSuppression:
|
||||||
|
(BOOL)resetSuppression;
|
||||||
|
- (void)kb_updateSuggestionsForCurrentWord;
|
||||||
|
|
||||||
|
// Chat
|
||||||
|
- (void)kb_handleChatSendAction;
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
- (void)kb_applyTheme;
|
||||||
|
- (void)kb_applyDefaultSkinIfNeeded;
|
||||||
|
- (void)kb_consumePendingShopSkin;
|
||||||
|
- (void)kb_registerDarwinSkinInstallObserver;
|
||||||
|
- (void)kb_unregisterDarwinSkinInstallObserver;
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
- (CGFloat)kb_portraitWidth;
|
||||||
|
- (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width;
|
||||||
|
- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width;
|
||||||
|
- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width;
|
||||||
|
- (void)kb_updateKeyboardLayoutIfNeeded;
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
//
|
||||||
|
// KeyboardViewController+Subscription.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/02/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KeyboardViewController+Private.h"
|
||||||
|
|
||||||
|
#import "KBAuthManager.h"
|
||||||
|
#import "KBFullAccessManager.h"
|
||||||
|
#import "../Utils/KBExtensionAppLauncher.h"
|
||||||
|
#import "KBKeyboardSubscriptionProduct.h"
|
||||||
|
#import "KBKeyboardSubscriptionView.h"
|
||||||
|
|
||||||
|
@implementation KeyboardViewController (Subscription)
|
||||||
|
|
||||||
|
- (void)showSubscriptionPanel {
|
||||||
|
// 1) 先判断权限:未开启“完全访问”则走引导逻辑
|
||||||
|
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||||
|
// 未开启完全访问:保持原有引导路径
|
||||||
|
// [KBHUD showInfo:KBLocalized(@"Processing...")];
|
||||||
|
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 点击充值要先判断是否登录
|
||||||
|
// 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App 负责完成登录
|
||||||
|
if (!KBAuthManager.shared.isLoggedIn) {
|
||||||
|
NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=keyboard", KB_UL_LOGIN]];
|
||||||
|
NSURL *scheme =
|
||||||
|
[NSURL URLWithString:[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME]];
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[KBExtensionAppLauncher openPrimaryURL:ul
|
||||||
|
fallbackURL:scheme
|
||||||
|
usingInputController:self
|
||||||
|
source:(self.view ?: (UIResponder *)weakSelf)
|
||||||
|
completion:nil];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[self kb_setPanelMode:KBKeyboardPanelModeSubscription animated:YES];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)hideSubscriptionPanel {
|
||||||
|
if (self.kb_panelMode != KBKeyboardPanelModeSubscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - KBKeyboardSubscriptionViewDelegate
|
||||||
|
|
||||||
|
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view {
|
||||||
|
[[KBMaiPointReporter sharedReporter]
|
||||||
|
reportClickWithEventName:@"click_keyboard_subscription_close_btn"
|
||||||
|
pageId:@"keyboard_subscription_panel"
|
||||||
|
elementId:@"close_btn"
|
||||||
|
extra:nil
|
||||||
|
completion:nil];
|
||||||
|
[self hideSubscriptionPanel];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)subscriptionView:(KBKeyboardSubscriptionView *)view
|
||||||
|
didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product {
|
||||||
|
NSMutableDictionary *extra = [NSMutableDictionary dictionary];
|
||||||
|
if ([product.productId isKindOfClass:NSString.class] &&
|
||||||
|
product.productId.length > 0) {
|
||||||
|
extra[@"product_id"] = product.productId;
|
||||||
|
}
|
||||||
|
[[KBMaiPointReporter sharedReporter]
|
||||||
|
reportClickWithEventName:@"click_keyboard_subscription_product_btn"
|
||||||
|
pageId:@"keyboard_subscription_panel"
|
||||||
|
elementId:@"product_btn"
|
||||||
|
extra:extra.copy
|
||||||
|
completion:nil];
|
||||||
|
[self hideSubscriptionPanel];
|
||||||
|
[self kb_openRechargeForProduct:product];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)subscriptionViewDidTapAgreement:(KBKeyboardSubscriptionView *)view {
|
||||||
|
(void)view;
|
||||||
|
[self hideSubscriptionPanel];
|
||||||
|
NSString *query = [NSString stringWithFormat:@"type=%@&src=keyboard",
|
||||||
|
@"membership"];
|
||||||
|
NSString *ulString = [NSString stringWithFormat:@"%@?%@", KB_UL_LEGAL, query];
|
||||||
|
NSString *schemeString =
|
||||||
|
[NSString stringWithFormat:@"%@://legal?%@", KB_APP_SCHEME, query];
|
||||||
|
NSURL *ul = [NSURL URLWithString:ulString];
|
||||||
|
NSURL *scheme = [NSURL URLWithString:schemeString];
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[KBExtensionAppLauncher openPrimaryURL:ul
|
||||||
|
fallbackURL:scheme
|
||||||
|
usingInputController:self
|
||||||
|
source:(self.view ?: (UIResponder *)weakSelf)
|
||||||
|
completion:^(BOOL success) {
|
||||||
|
if (success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Please open the App to view the agreement")];
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Actions
|
||||||
|
|
||||||
|
- (void)kb_openRechargeForProduct:(KBKeyboardSubscriptionProduct *)product {
|
||||||
|
if (![product isKindOfClass:KBKeyboardSubscriptionProduct.class] ||
|
||||||
|
product.productId.length == 0) {
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Product unavailable")];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSString *encodedId = [self.class kb_urlEncodedString:product.productId];
|
||||||
|
NSString *title = [product displayTitle];
|
||||||
|
NSString *encodedTitle = [self.class kb_urlEncodedString:title];
|
||||||
|
NSMutableArray<NSString *> *params =
|
||||||
|
[NSMutableArray arrayWithObjects:@"autoPay=1", @"prefill=1", nil];
|
||||||
|
if (encodedId.length) {
|
||||||
|
[params addObject:[NSString stringWithFormat:@"productId=%@", encodedId]];
|
||||||
|
}
|
||||||
|
if (encodedTitle.length) {
|
||||||
|
[params
|
||||||
|
addObject:[NSString stringWithFormat:@"productTitle=%@", encodedTitle]];
|
||||||
|
}
|
||||||
|
NSString *query = [params componentsJoinedByString:@"&"];
|
||||||
|
NSString *urlString = [NSString
|
||||||
|
stringWithFormat:@"%@://recharge?src=keyboard&%@", KB_APP_SCHEME, query];
|
||||||
|
NSURL *scheme = [NSURL URLWithString:urlString];
|
||||||
|
NSString *ulString = [NSString stringWithFormat:@"%@?src=keyboard&%@", KB_UL_RECHARGE, query];
|
||||||
|
NSURL *ul = [NSURL URLWithString:ulString];
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[KBExtensionAppLauncher openPrimaryURL:ul
|
||||||
|
fallbackURL:scheme
|
||||||
|
usingInputController:self
|
||||||
|
source:(self.view ?: (UIResponder *)weakSelf)
|
||||||
|
completion:^(BOOL success) {
|
||||||
|
if (success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")];
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (NSString *)kb_urlEncodedString:(NSString *)value {
|
||||||
|
if (value.length == 0) {
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
|
NSString *reserved = @"!*'();:@&=+$,/?%#[]";
|
||||||
|
NSMutableCharacterSet *allowed =
|
||||||
|
[[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy];
|
||||||
|
[allowed removeCharactersInString:reserved];
|
||||||
|
return [value stringByAddingPercentEncodingWithAllowedCharacters:allowed]
|
||||||
|
?: @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
//
|
||||||
|
// KeyboardViewController+Suggestions.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/02/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KeyboardViewController+Private.h"
|
||||||
|
|
||||||
|
#import "KBBackspaceUndoManager.h"
|
||||||
|
#import "KBInputBufferManager.h"
|
||||||
|
#import "KBKeyBoardMainView.h"
|
||||||
|
#import "KBSuggestionEngine.h"
|
||||||
|
|
||||||
|
@implementation KeyboardViewController (Suggestions)
|
||||||
|
|
||||||
|
// MARK: - Suggestions
|
||||||
|
|
||||||
|
- (void)kb_updateCurrentWordWithInsertedText:(NSString *)text {
|
||||||
|
if (text.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ([self kb_isAlphabeticString:text]) {
|
||||||
|
NSString *current = self.currentWord ?: @"";
|
||||||
|
self.currentWord = [current stringByAppendingString:text];
|
||||||
|
self.suppressSuggestions = NO;
|
||||||
|
[self kb_updateSuggestionsForCurrentWord];
|
||||||
|
} else {
|
||||||
|
[self kb_clearCurrentWord];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_clearCurrentWord {
|
||||||
|
self.currentWord = @"";
|
||||||
|
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||||||
|
self.suppressSuggestions = NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
[self kb_refreshCurrentWordFromDocumentContextResetSuppression:
|
||||||
|
resetSuppression];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_refreshCurrentWordFromDocumentContextResetSuppression:
|
||||||
|
(BOOL)resetSuppression {
|
||||||
|
NSString *context = self.textDocumentProxy.documentContextBeforeInput ?: @"";
|
||||||
|
NSString *word = [self kb_extractTrailingWordFromContext:context];
|
||||||
|
self.currentWord = word ?: @"";
|
||||||
|
if (resetSuppression) {
|
||||||
|
self.suppressSuggestions = NO;
|
||||||
|
}
|
||||||
|
[self kb_updateSuggestionsForCurrentWord];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_extractTrailingWordFromContext:(NSString *)context {
|
||||||
|
if (context.length == 0) {
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
|
NSCharacterSet *letters = [self kb_allowedSuggestionCharacterSet];
|
||||||
|
|
||||||
|
NSInteger idx = (NSInteger)context.length - 1;
|
||||||
|
while (idx >= 0) {
|
||||||
|
unichar ch = [context characterAtIndex:(NSUInteger)idx];
|
||||||
|
if (![letters characterIsMember:ch]) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
idx -= 1;
|
||||||
|
}
|
||||||
|
NSUInteger start = (NSUInteger)(idx + 1);
|
||||||
|
if (start >= context.length) {
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
|
return [context substringFromIndex:start];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)kb_isAlphabeticString:(NSString *)text {
|
||||||
|
if (text.length == 0) {
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
NSCharacterSet *letters = [self kb_allowedSuggestionCharacterSet];
|
||||||
|
for (NSUInteger i = 0; i < text.length; i++) {
|
||||||
|
if (![letters characterIsMember:[text characterAtIndex:i]]) {
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSCharacterSet *)kb_allowedSuggestionCharacterSet {
|
||||||
|
switch (self.suggestionEngine.engineType) {
|
||||||
|
case KBSuggestionEngineTypeSpanish:
|
||||||
|
return [self kb_spanishSuggestionCharacterSet];
|
||||||
|
case KBSuggestionEngineTypeBopomofo:
|
||||||
|
return [self kb_bopomofoSuggestionCharacterSet];
|
||||||
|
case KBSuggestionEngineTypeLatin:
|
||||||
|
case KBSuggestionEngineTypeEnglish:
|
||||||
|
case KBSuggestionEngineTypePortuguese:
|
||||||
|
case KBSuggestionEngineTypeIndonesian:
|
||||||
|
case KBSuggestionEngineTypePinyinSimplified:
|
||||||
|
case KBSuggestionEngineTypePinyinTraditional:
|
||||||
|
default:
|
||||||
|
return [self kb_latinSuggestionCharacterSet];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSCharacterSet *)kb_latinSuggestionCharacterSet {
|
||||||
|
static NSCharacterSet *set = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
set = [NSCharacterSet characterSetWithCharactersInString:
|
||||||
|
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
"áÁàÀâÂãÃäÄåÅæÆçÇ"
|
||||||
|
"éÉèÈêÊëË"
|
||||||
|
"íÍìÌîÎïÏ"
|
||||||
|
"ñÑ"
|
||||||
|
"óÓòÒôÔõÕöÖøØ"
|
||||||
|
"úÚùÙûÛüÜ"
|
||||||
|
"ýÝÿ"];
|
||||||
|
});
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSCharacterSet *)kb_spanishSuggestionCharacterSet {
|
||||||
|
static NSCharacterSet *set = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
set = [NSCharacterSet characterSetWithCharactersInString:
|
||||||
|
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
"áÁéÉíÍóÓúÚñÑüÜ"];
|
||||||
|
});
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSCharacterSet *)kb_bopomofoSuggestionCharacterSet {
|
||||||
|
static NSCharacterSet *set = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
set = [NSCharacterSet characterSetWithCharactersInString:
|
||||||
|
@"ㄅㄆㄇㄈㄉㄊㄋㄌㄍㄎㄏㄐㄑㄒㄓㄔㄕㄖㄗㄘㄙㄧㄨㄩㄚㄛㄜㄝㄞㄟㄠㄡㄢㄣㄤㄥㄦ"
|
||||||
|
"˙ˊˇˋ"];
|
||||||
|
});
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_updateSuggestionsForCurrentWord {
|
||||||
|
NSString *prefix = self.currentWord ?: @"";
|
||||||
|
if (prefix.length == 0) {
|
||||||
|
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (self.suppressSuggestions) {
|
||||||
|
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSArray<NSString *> *items =
|
||||||
|
[self.suggestionEngine suggestionsForPrefix:prefix limit:5];
|
||||||
|
NSArray<NSString *> *cased = [self kb_applyCaseToSuggestions:items
|
||||||
|
prefix:prefix];
|
||||||
|
[self.keyBoardMainView kb_setSuggestions:cased];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_applyCaseToSuggestions:(NSArray<NSString *> *)items
|
||||||
|
prefix:(NSString *)prefix {
|
||||||
|
if (items.count == 0 || prefix.length == 0) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
BOOL allUpper = [prefix isEqualToString:prefix.uppercaseString];
|
||||||
|
BOOL firstUpper = [[prefix substringToIndex:1]
|
||||||
|
isEqualToString:[[prefix substringToIndex:1] uppercaseString]];
|
||||||
|
|
||||||
|
if (!allUpper && !firstUpper) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableArray<NSString *> *result =
|
||||||
|
[NSMutableArray arrayWithCapacity:items.count];
|
||||||
|
for (NSString *word in items) {
|
||||||
|
if (allUpper) {
|
||||||
|
[result addObject:word.uppercaseString];
|
||||||
|
} else {
|
||||||
|
NSString *first = [[word substringToIndex:1] uppercaseString];
|
||||||
|
NSString *rest = (word.length > 1) ? [word substringFromIndex:1] : @"";
|
||||||
|
[result addObject:[first stringByAppendingString:rest]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - KBKeyBoardMainViewDelegate (Suggestion)
|
||||||
|
|
||||||
|
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||||||
|
didSelectSuggestion:(NSString *)suggestion {
|
||||||
|
if (suggestion.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSDictionary *extra = @{@"suggestion_len" : @(suggestion.length)};
|
||||||
|
// [[KBMaiPointReporter sharedReporter]
|
||||||
|
// reportClickWithEventName:@"click_keyboard_suggestion_item"
|
||||||
|
// pageId:@"keyboard_main_panel"
|
||||||
|
// elementId:@"suggestion_item"
|
||||||
|
// extra:extra
|
||||||
|
// completion:nil];
|
||||||
|
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||||
|
NSString *current = self.currentWord ?: @"";
|
||||||
|
if (current.length > 0) {
|
||||||
|
for (NSUInteger i = 0; i < current.length; i++) {
|
||||||
|
[self.textDocumentProxy deleteBackward];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[self.textDocumentProxy insertText:suggestion];
|
||||||
|
self.currentWord = suggestion;
|
||||||
|
[self.suggestionEngine recordSelection:suggestion];
|
||||||
|
self.suppressSuggestions = YES;
|
||||||
|
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||||||
|
[[KBInputBufferManager shared] replaceTailWithText:suggestion
|
||||||
|
deleteCount:current.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
//
|
||||||
|
// KeyboardViewController+Theme.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/02/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KeyboardViewController+Private.h"
|
||||||
|
|
||||||
|
#import "KBFunctionView.h"
|
||||||
|
#import "KBKeyBoardMainView.h"
|
||||||
|
#import "KBSkinInstallBridge.h"
|
||||||
|
#import "KBSkinManager.h"
|
||||||
|
#import "UIImage+KBColor.h"
|
||||||
|
#import <QuartzCore/QuartzCore.h>
|
||||||
|
|
||||||
|
static NSString *const kKBDefaultSkinIdLight = @"normal_them";
|
||||||
|
static NSString *const kKBDefaultSkinZipNameLight = @"normal_them";
|
||||||
|
static NSString *const kKBDefaultSkinIdDark = @"normal_hei_them";
|
||||||
|
static NSString *const kKBDefaultSkinZipNameDark = @"normal_hei_them";
|
||||||
|
|
||||||
|
// 提前声明一个类别,使编译器在 static 回调中识别 kb_consumePendingShopSkin 方法。
|
||||||
|
@interface KeyboardViewController (KBSkinShopBridge)
|
||||||
|
- (void)kb_consumePendingShopSkin;
|
||||||
|
@end
|
||||||
|
|
||||||
|
static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||||
|
void *observer, CFStringRef name,
|
||||||
|
const void *object,
|
||||||
|
CFDictionaryRef userInfo) {
|
||||||
|
KeyboardViewController *strongSelf =
|
||||||
|
(__bridge KeyboardViewController *)observer;
|
||||||
|
if (!strongSelf) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
if ([strongSelf respondsToSelector:@selector(kb_consumePendingShopSkin)]) {
|
||||||
|
[strongSelf kb_consumePendingShopSkin];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@implementation KeyboardViewController (Theme)
|
||||||
|
|
||||||
|
- (void)kb_registerDarwinSkinInstallObserver {
|
||||||
|
CFNotificationCenterAddObserver(
|
||||||
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
|
(__bridge const void *)(self), KBSkinInstallNotificationCallback,
|
||||||
|
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL,
|
||||||
|
CFNotificationSuspensionBehaviorDeliverImmediately);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_unregisterDarwinSkinInstallObserver {
|
||||||
|
CFNotificationCenterRemoveObserver(
|
||||||
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
|
(__bridge const void *)(self),
|
||||||
|
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_applyTheme {
|
||||||
|
@autoreleasepool {
|
||||||
|
KBSkinTheme *t = [KBSkinManager shared].current;
|
||||||
|
UIImage *img = nil;
|
||||||
|
BOOL isDefaultTheme = [self kb_isDefaultKeyboardTheme:t];
|
||||||
|
BOOL isDarkMode = [self kb_isDarkModeActive];
|
||||||
|
|
||||||
|
NSString *skinId = t.skinId ?: @"";
|
||||||
|
NSString *themeKey =
|
||||||
|
[NSString stringWithFormat:@"%@|default=%d|dark=%d", skinId,
|
||||||
|
isDefaultTheme, isDarkMode];
|
||||||
|
BOOL themeChanged =
|
||||||
|
(self.kb_lastAppliedThemeKey.length == 0 ||
|
||||||
|
![self.kb_lastAppliedThemeKey isEqualToString:themeKey]);
|
||||||
|
if (themeChanged) {
|
||||||
|
self.kb_lastAppliedThemeKey = themeKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
CGSize size = self.bgImageView.bounds.size;
|
||||||
|
if (isDefaultTheme) {
|
||||||
|
if (isDarkMode) {
|
||||||
|
// 暗黑模式:直接使用背景色,不使用图片渲染
|
||||||
|
// 这样可以避免图片渲染时的色彩空间转换导致颜色不一致
|
||||||
|
img = nil;
|
||||||
|
self.bgImageView.image = nil;
|
||||||
|
[self.kb_defaultGradientLayer removeFromSuperlayer];
|
||||||
|
self.kb_defaultGradientLayer = nil;
|
||||||
|
// 使用与系统键盘底部完全相同的颜色
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
// iOS 系统键盘使用的实际颜色 (RGB: 44, 44, 46 in sRGB, 或 #2C2C2E)
|
||||||
|
// 但为了完美匹配,我们使用动态颜色并直接设置为背景
|
||||||
|
UIColor *kbBgColor =
|
||||||
|
[UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
|
||||||
|
UITraitCollection *_Nonnull traitCollection) {
|
||||||
|
if (traitCollection.userInterfaceStyle ==
|
||||||
|
UIUserInterfaceStyleDark) {
|
||||||
|
// 暗黑模式下系统键盘实际背景色
|
||||||
|
return [UIColor colorWithRed:43.0 / 255.0
|
||||||
|
green:43.0 / 255.0
|
||||||
|
blue:43.0 / 255.0
|
||||||
|
alpha:1.0];
|
||||||
|
} else {
|
||||||
|
return [UIColor colorWithRed:209.0 / 255.0
|
||||||
|
green:211.0 / 255.0
|
||||||
|
blue:219.0 / 255.0
|
||||||
|
alpha:1.0];
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
self.contentView.backgroundColor = kbBgColor;
|
||||||
|
self.bgImageView.backgroundColor = kbBgColor;
|
||||||
|
} else {
|
||||||
|
UIColor *darkColor = [UIColor colorWithRed:43.0 / 255.0
|
||||||
|
green:43.0 / 255.0
|
||||||
|
blue:43.0 / 255.0
|
||||||
|
alpha:1.0];
|
||||||
|
self.contentView.backgroundColor = darkColor;
|
||||||
|
self.bgImageView.backgroundColor = darkColor;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 浅色模式:使用渐变层(避免生成大位图导致内存上涨)
|
||||||
|
if (size.width <= 0 || size.height <= 0) {
|
||||||
|
[self.view layoutIfNeeded];
|
||||||
|
size = self.bgImageView.bounds.size;
|
||||||
|
}
|
||||||
|
if (size.width <= 0 || size.height <= 0) {
|
||||||
|
size = self.view.bounds.size;
|
||||||
|
}
|
||||||
|
if (size.width <= 0 || size.height <= 0) {
|
||||||
|
size = [UIScreen mainScreen].bounds.size;
|
||||||
|
}
|
||||||
|
UIColor *topColor = [UIColor colorWithHex:0xDEDFE4];
|
||||||
|
UIColor *bottomColor = [UIColor colorWithHex:0xD1D3DB];
|
||||||
|
UIColor *resolvedTopColor = topColor;
|
||||||
|
UIColor *resolvedBottomColor = bottomColor;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
resolvedTopColor =
|
||||||
|
[topColor resolvedColorWithTraitCollection:self.traitCollection];
|
||||||
|
resolvedBottomColor =
|
||||||
|
[bottomColor resolvedColorWithTraitCollection:self.traitCollection];
|
||||||
|
}
|
||||||
|
CAGradientLayer *layer = self.kb_defaultGradientLayer;
|
||||||
|
if (!layer) {
|
||||||
|
layer = [CAGradientLayer layer];
|
||||||
|
layer.startPoint = CGPointMake(0.5, 0.0);
|
||||||
|
layer.endPoint = CGPointMake(0.5, 1.0);
|
||||||
|
[self.bgImageView.layer insertSublayer:layer atIndex:0];
|
||||||
|
self.kb_defaultGradientLayer = layer;
|
||||||
|
}
|
||||||
|
layer.colors =
|
||||||
|
@[ (id)resolvedTopColor.CGColor, (id)resolvedBottomColor.CGColor ];
|
||||||
|
layer.frame = (CGRect){CGPointZero, size};
|
||||||
|
img = nil;
|
||||||
|
self.bgImageView.image = nil;
|
||||||
|
self.contentView.backgroundColor = [UIColor clearColor];
|
||||||
|
self.bgImageView.backgroundColor = [UIColor clearColor];
|
||||||
|
}
|
||||||
|
NSLog(@"===");
|
||||||
|
} else {
|
||||||
|
// 自定义皮肤:清除背景色,使用皮肤图片
|
||||||
|
self.contentView.backgroundColor = [UIColor clearColor];
|
||||||
|
self.bgImageView.backgroundColor = [UIColor clearColor];
|
||||||
|
[self.kb_defaultGradientLayer removeFromSuperlayer];
|
||||||
|
self.kb_defaultGradientLayer = nil;
|
||||||
|
img = [[KBSkinManager shared] currentBackgroundImage];
|
||||||
|
}
|
||||||
|
NSLog(@"⌨️[Keyboard] apply theme id=%@ hasBg=%d", t.skinId, (img != nil));
|
||||||
|
[self kb_logSkinDiagnosticsWithTheme:t backgroundImage:img];
|
||||||
|
self.bgImageView.image = img;
|
||||||
|
|
||||||
|
// 皮肤资源可能被“重新下载”,即使 skinId 未变也需要刷新按键图标。
|
||||||
|
if ([self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) {
|
||||||
|
// method declared in KBKeyBoardMainView.h
|
||||||
|
#pragma clang diagnostic push
|
||||||
|
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||||
|
[self.keyBoardMainView performSelector:@selector(kb_applyTheme)];
|
||||||
|
#pragma clang diagnostic pop
|
||||||
|
}
|
||||||
|
// 注意:这里不能直接访问 self.functionView,否则会导致功能面板提前创建,占用内存。
|
||||||
|
KBFunctionView *functionView = [self kb_functionViewIfCreated];
|
||||||
|
if (functionView &&
|
||||||
|
[functionView respondsToSelector:@selector(kb_applyTheme)]) {
|
||||||
|
#pragma clang diagnostic push
|
||||||
|
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||||
|
[functionView performSelector:@selector(kb_applyTheme)];
|
||||||
|
#pragma clang diagnostic pop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)kb_isDefaultKeyboardTheme:(KBSkinTheme *)theme {
|
||||||
|
NSString *skinId = theme.skinId ?: @"";
|
||||||
|
if (skinId.length == 0 || [skinId isEqualToString:@"default"]) {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
if ([skinId isEqualToString:kKBDefaultSkinIdLight]) {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
return [skinId isEqualToString:kKBDefaultSkinIdDark];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)kb_isDarkModeActive {
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
return self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark;
|
||||||
|
}
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_defaultSkinIdForCurrentStyle {
|
||||||
|
return [self kb_isDarkModeActive] ? kKBDefaultSkinIdDark
|
||||||
|
: kKBDefaultSkinIdLight;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_defaultSkinZipNameForCurrentStyle {
|
||||||
|
return [self kb_isDarkModeActive] ? kKBDefaultSkinZipNameDark
|
||||||
|
: kKBDefaultSkinZipNameLight;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIImage *)kb_defaultGradientImageWithSize:(CGSize)size
|
||||||
|
topColor:(UIColor *)topColor
|
||||||
|
bottomColor:(UIColor *)bottomColor {
|
||||||
|
if (size.width <= 0 || size.height <= 0) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尺寸未变则复用缓存,避免反复创建图片撑爆键盘扩展内存
|
||||||
|
if (self.kb_cachedGradientImage &&
|
||||||
|
CGSizeEqualToSize(self.kb_cachedGradientSize, size)) {
|
||||||
|
return self.kb_cachedGradientImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
UIColor *resolvedTopColor = topColor;
|
||||||
|
UIColor *resolvedBottomColor = bottomColor;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
resolvedTopColor =
|
||||||
|
[topColor resolvedColorWithTraitCollection:self.traitCollection];
|
||||||
|
resolvedBottomColor =
|
||||||
|
[bottomColor resolvedColorWithTraitCollection:self.traitCollection];
|
||||||
|
}
|
||||||
|
|
||||||
|
CAGradientLayer *layer = [CAGradientLayer layer];
|
||||||
|
layer.frame = CGRectMake(0, 0, size.width, size.height);
|
||||||
|
layer.startPoint = CGPointMake(0.5, 0.0);
|
||||||
|
layer.endPoint = CGPointMake(0.5, 1.0);
|
||||||
|
layer.colors =
|
||||||
|
@[ (id)resolvedTopColor.CGColor, (id)resolvedBottomColor.CGColor ];
|
||||||
|
|
||||||
|
UIGraphicsBeginImageContextWithOptions(size, YES, 0);
|
||||||
|
[layer renderInContext:UIGraphicsGetCurrentContext()];
|
||||||
|
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
|
||||||
|
UIGraphicsEndImageContext();
|
||||||
|
|
||||||
|
self.kb_cachedGradientImage = image;
|
||||||
|
self.kb_cachedGradientSize = size;
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_logSkinDiagnosticsWithTheme:(KBSkinTheme *)theme
|
||||||
|
backgroundImage:(UIImage *)image {
|
||||||
|
#if DEBUG
|
||||||
|
NSString *skinId = theme.skinId ?: @"";
|
||||||
|
NSString *name = theme.name ?: @"";
|
||||||
|
NSMutableArray<NSString *> *roots = [NSMutableArray array];
|
||||||
|
NSURL *containerURL = [[NSFileManager defaultManager]
|
||||||
|
containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
||||||
|
if (containerURL.path.length > 0) {
|
||||||
|
[roots addObject:containerURL.path];
|
||||||
|
}
|
||||||
|
NSString *cacheRoot = NSSearchPathForDirectoriesInDomains(
|
||||||
|
NSCachesDirectory, NSUserDomainMask, YES)
|
||||||
|
.firstObject;
|
||||||
|
if (cacheRoot.length > 0) {
|
||||||
|
[roots addObject:cacheRoot];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSFileManager *fm = [NSFileManager defaultManager];
|
||||||
|
NSMutableArray<NSString *> *lines = [NSMutableArray array];
|
||||||
|
for (NSString *root in roots) {
|
||||||
|
NSString *iconsDir = [[root stringByAppendingPathComponent:@"Skins"]
|
||||||
|
stringByAppendingPathComponent:skinId];
|
||||||
|
iconsDir = [iconsDir stringByAppendingPathComponent:@"icons"];
|
||||||
|
BOOL isDir = NO;
|
||||||
|
BOOL exists = [fm fileExistsAtPath:iconsDir isDirectory:&isDir] && isDir;
|
||||||
|
NSArray *contents =
|
||||||
|
exists ? [fm contentsOfDirectoryAtPath:iconsDir error:nil] : nil;
|
||||||
|
NSUInteger count = contents.count;
|
||||||
|
BOOL hasQ =
|
||||||
|
exists &&
|
||||||
|
[fm fileExistsAtPath:[iconsDir
|
||||||
|
stringByAppendingPathComponent:@"key_q.png"]];
|
||||||
|
BOOL hasQUp =
|
||||||
|
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
|
||||||
|
@"key_q_up.png"]];
|
||||||
|
BOOL hasDel =
|
||||||
|
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
|
||||||
|
@"key_del.png"]];
|
||||||
|
BOOL hasShift =
|
||||||
|
exists &&
|
||||||
|
[fm fileExistsAtPath:[iconsDir
|
||||||
|
stringByAppendingPathComponent:@"key_up.png"]];
|
||||||
|
BOOL hasShiftUpper =
|
||||||
|
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
|
||||||
|
@"key_up_upper.png"]];
|
||||||
|
NSString *line = [NSString
|
||||||
|
stringWithFormat:@"root=%@ icons=%@ exist=%d count=%tu key_q=%d "
|
||||||
|
@"key_q_up=%d key_del=%d key_up=%d key_up_upper=%d",
|
||||||
|
root, iconsDir, exists, count, hasQ, hasQUp, hasDel,
|
||||||
|
hasShift, hasShiftUpper];
|
||||||
|
[lines addObject:line];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog(@"[Keyboard] theme id=%@ name=%@ hasBg=%d\n%@", skinId, name,
|
||||||
|
(image != nil), [lines componentsJoinedByString:@"\n"]);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_consumePendingShopSkin {
|
||||||
|
KBWeakSelf [KBSkinInstallBridge
|
||||||
|
consumePendingRequestFromBundle:NSBundle.mainBundle
|
||||||
|
completion:^(BOOL success,
|
||||||
|
NSError *_Nullable error) {
|
||||||
|
if (!success) {
|
||||||
|
if (error) {
|
||||||
|
NSLog(@"[Keyboard] skin request failed: %@",
|
||||||
|
error);
|
||||||
|
[KBHUD
|
||||||
|
showInfo:KBLocalized(
|
||||||
|
@"Theme resource preparation failed, please try again later")];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[weakSelf kb_applyTheme];
|
||||||
|
[KBHUD showInfo:KBLocalized(
|
||||||
|
@"Theme updated, try it now")];
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_applyDefaultSkinIfNeeded {
|
||||||
|
NSDictionary *pending = [KBSkinInstallBridge pendingRequestPayload];
|
||||||
|
if (pending.count > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *currentId = [KBSkinManager shared].current.skinId ?: @"";
|
||||||
|
BOOL isDefault =
|
||||||
|
(currentId.length == 0 || [currentId isEqualToString:@"default"]);
|
||||||
|
BOOL isLightDefault = [currentId isEqualToString:kKBDefaultSkinIdLight];
|
||||||
|
BOOL isDarkDefault = [currentId isEqualToString:kKBDefaultSkinIdDark];
|
||||||
|
if (!isDefault && !isLightDefault && !isDarkDefault) {
|
||||||
|
// 用户已应用自定义皮肤:不随深色模式切换默认皮肤
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSString *targetId = [self kb_defaultSkinIdForCurrentStyle];
|
||||||
|
if (currentId.length > 0 && [currentId isEqualToString:targetId]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSError *applyError = nil;
|
||||||
|
if ([KBSkinInstallBridge applyInstalledSkinWithId:targetId error:&applyError]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 默认皮肤 zip 仅由主 App 持有并解压。扩展侧不再尝试从自身 bundle 解压。
|
||||||
|
// 若主 App 尚未安装对应默认皮肤,这里仅保留当前主题,避免“找不到 zip”报错。
|
||||||
|
if (applyError) {
|
||||||
|
NSLog(@"[Keyboard] default skin %@ not installed in AppGroup yet: %@",
|
||||||
|
targetId, applyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//
|
||||||
|
// KeyboardViewController+UI.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/02/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KeyboardViewController+Private.h"
|
||||||
|
|
||||||
|
#import "KBChatMessage.h"
|
||||||
|
#import "KBChatPanelView.h"
|
||||||
|
#import "KBFunctionView.h"
|
||||||
|
#import "KBKeyBoardMainView.h"
|
||||||
|
#import "KBKeyboardSubscriptionView.h"
|
||||||
|
#import "Masonry.h"
|
||||||
|
|
||||||
|
@implementation KeyboardViewController (UI)
|
||||||
|
|
||||||
|
- (void)setupUI {
|
||||||
|
self.view.translatesAutoresizingMaskIntoConstraints = NO;
|
||||||
|
|
||||||
|
// 按“短边”宽度等比缩放,横屏保持竖屏布局比例
|
||||||
|
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||||
|
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
|
||||||
|
CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth];
|
||||||
|
CGFloat screenWidth = CGRectGetWidth([UIScreen mainScreen].bounds);
|
||||||
|
|
||||||
|
// FIX: iOS 26 键盘闪烁问题
|
||||||
|
// iOS 26 在键盘滑入动画开始前,会对 self.view 做一次离屏预渲染快照(非实时 view),
|
||||||
|
// 该快照会短暂显示在屏幕中间。如果此时 view 已有完整高度和内容,用户就会看到
|
||||||
|
// 键盘 UI 在屏幕中间闪现一帧,然后键盘才从底部正常滑入。
|
||||||
|
// 解决方案:初始高度设为 0,让系统快照时无内容可渲染;
|
||||||
|
// 在 viewWillAppear: 中恢复正确高度,此时系统已准备好滑入动画。
|
||||||
|
// (iOS 18 及更早版本无此预渲染机制,不受影响)
|
||||||
|
NSLayoutConstraint *h = [self.view.heightAnchor constraintEqualToConstant:0];
|
||||||
|
NSLayoutConstraint *w =
|
||||||
|
[self.view.widthAnchor constraintEqualToConstant:screenWidth];
|
||||||
|
self.kb_heightConstraint = h;
|
||||||
|
self.kb_widthConstraint = w;
|
||||||
|
|
||||||
|
h.priority = UILayoutPriorityRequired;
|
||||||
|
w.priority = UILayoutPriorityRequired;
|
||||||
|
[NSLayoutConstraint activateConstraints:@[ h, w ]];
|
||||||
|
// 关闭 UIInputView 自适应(某些系统版本会尝试放大为全屏高度导致冲突)
|
||||||
|
if ([self.view isKindOfClass:[UIInputView class]]) {
|
||||||
|
UIInputView *iv = (UIInputView *)self.view;
|
||||||
|
if ([iv respondsToSelector:@selector(setAllowsSelfSizing:)]) {
|
||||||
|
iv.allowsSelfSizing = NO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 内容容器:横屏时保持竖屏宽度,居中显示
|
||||||
|
[self.view addSubview:self.contentView];
|
||||||
|
[self.contentView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.centerX.equalTo(self.view);
|
||||||
|
make.bottom.equalTo(self.view);
|
||||||
|
self.contentWidthConstraint = make.width.mas_equalTo(portraitWidth);
|
||||||
|
self.contentHeightConstraint = make.height.mas_equalTo(keyboardHeight);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 背景图铺底(仅在内容容器内)
|
||||||
|
[self.contentView addSubview:self.bgImageView];
|
||||||
|
[self.bgImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.edges.equalTo(self.contentView);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.contentView addSubview:self.keyBoardMainView];
|
||||||
|
[self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.right.equalTo(self.contentView);
|
||||||
|
make.bottom.equalTo(self.contentView);
|
||||||
|
self.keyBoardMainHeightConstraint =
|
||||||
|
make.height.mas_equalTo(keyboardBaseHeight);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 初始隐藏,避免布局完成前闪烁
|
||||||
|
self.contentView.hidden = YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Lazy
|
||||||
|
|
||||||
|
- (nullable KBFunctionView *)kb_functionViewIfCreated {
|
||||||
|
return _functionView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIView *)contentView {
|
||||||
|
if (!_contentView) {
|
||||||
|
_contentView = [[UIView alloc] init];
|
||||||
|
_contentView.backgroundColor = [UIColor clearColor];
|
||||||
|
}
|
||||||
|
return _contentView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIImageView *)bgImageView {
|
||||||
|
if (!_bgImageView) {
|
||||||
|
_bgImageView = [[UIImageView alloc] init];
|
||||||
|
_bgImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||||
|
_bgImageView.clipsToBounds = YES;
|
||||||
|
}
|
||||||
|
return _bgImageView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (KBKeyBoardMainView *)keyBoardMainView {
|
||||||
|
if (!_keyBoardMainView) {
|
||||||
|
_keyBoardMainView = [[KBKeyBoardMainView alloc] init];
|
||||||
|
_keyBoardMainView.delegate = self;
|
||||||
|
}
|
||||||
|
return _keyBoardMainView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (KBFunctionView *)functionView {
|
||||||
|
if (!_functionView) {
|
||||||
|
_functionView = [[KBFunctionView alloc] init];
|
||||||
|
_functionView.delegate = self; // 监听功能面板顶部Bar点击
|
||||||
|
}
|
||||||
|
return _functionView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (KBChatPanelView *)chatPanelView {
|
||||||
|
if (!_chatPanelView) {
|
||||||
|
NSLog(@"[Keyboard] ⚠️ chatPanelView 被创建!");
|
||||||
|
_chatPanelView = [[KBChatPanelView alloc] init];
|
||||||
|
_chatPanelView.delegate = self;
|
||||||
|
}
|
||||||
|
return _chatPanelView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSMutableArray<KBChatMessage *> *)chatMessages {
|
||||||
|
if (!_chatMessages) {
|
||||||
|
_chatMessages = [NSMutableArray array];
|
||||||
|
}
|
||||||
|
return _chatMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (KBKeyboardSubscriptionView *)subscriptionView {
|
||||||
|
if (!_subscriptionView) {
|
||||||
|
_subscriptionView = [[KBKeyboardSubscriptionView alloc] init];
|
||||||
|
_subscriptionView.delegate = self;
|
||||||
|
_subscriptionView.hidden = YES;
|
||||||
|
_subscriptionView.alpha = 0.0;
|
||||||
|
}
|
||||||
|
return _subscriptionView;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -41,6 +41,9 @@ FOUNDATION_EXPORT NSString * const KBEmojiRecentsDidChangeNotification;
|
|||||||
/// 更新当前语言对应的分类标题。
|
/// 更新当前语言对应的分类标题。
|
||||||
- (void)refreshLocalizedTitles;
|
- (void)refreshLocalizedTitles;
|
||||||
|
|
||||||
|
/// 释放大块缓存(emoji 分类与索引),下次访问会重新加载。
|
||||||
|
- (void)purgeLargeCaches;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|||||||
@@ -195,6 +195,12 @@ static const NSUInteger kKBEmojiRecentsLimit = 32;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)purgeLargeCaches {
|
||||||
|
self.categoriesInternal = nil;
|
||||||
|
self.itemLookup = nil;
|
||||||
|
self.recentValues = nil;
|
||||||
|
}
|
||||||
|
|
||||||
- (void)onLocalizationChanged:(__unused NSNotification *)note {
|
- (void)onLocalizationChanged:(__unused NSNotification *)note {
|
||||||
[self refreshLocalizedTitles];
|
[self refreshLocalizedTitles];
|
||||||
[[NSNotificationCenter defaultCenter] postNotificationName:KBEmojiRecentsDidChangeNotification object:nil];
|
[[NSNotificationCenter defaultCenter] postNotificationName:KBEmojiRecentsDidChangeNotification object:nil];
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
// KBFullAccessManager.m
|
// KBFullAccessManager.m
|
||||||
//
|
//
|
||||||
// 统一封装“允许完全访问”检测:
|
// 统一封装“允许完全访问”检测:
|
||||||
// 1) 首选:反射调用 UIInputViewController 的 hasFullAccess(避免直接引用私有 API 标识)
|
// 1) 直接使用 UIInputViewController.hasFullAccess(公开 API)
|
||||||
// 2) 兜底:无法判断时返回 Unknown(上层可按需降级为 Denied 并提示)
|
// 2) 兜底:无法判断时返回 Unknown(上层可按需降级为 Denied 并提示)
|
||||||
//
|
//
|
||||||
|
|
||||||
#import "KBFullAccessManager.h"
|
#import "KBFullAccessManager.h"
|
||||||
#import <objc/message.h>
|
|
||||||
#if __has_include("KBNetworkManager.h")
|
#if __has_include("KBNetworkManager.h")
|
||||||
#import "KBNetworkManager.h"
|
#import "KBNetworkManager.h"
|
||||||
#endif
|
#endif
|
||||||
@@ -62,7 +61,10 @@ NSNotificationName const KBFullAccessChangedNotification = @"KBFullAccessChanged
|
|||||||
Class guideCls = NSClassFromString(@"KBFullAccessGuideView");
|
Class guideCls = NSClassFromString(@"KBFullAccessGuideView");
|
||||||
if (guideCls && [guideCls respondsToSelector:NSSelectorFromString(@"showInView:")]) {
|
if (guideCls && [guideCls respondsToSelector:NSSelectorFromString(@"showInView:")]) {
|
||||||
SEL sel = NSSelectorFromString(@"showInView:");
|
SEL sel = NSSelectorFromString(@"showInView:");
|
||||||
((void (*)(id, SEL, UIView *))objc_msgSend)(guideCls, sel, parent);
|
#pragma clang diagnostic push
|
||||||
|
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||||
|
[guideCls performSelector:sel withObject:parent];
|
||||||
|
#pragma clang diagnostic pop
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
return NO;
|
return NO;
|
||||||
@@ -74,13 +76,9 @@ NSNotificationName const KBFullAccessChangedNotification = @"KBFullAccessChanged
|
|||||||
- (KBFullAccessState)p_detectFullAccessState {
|
- (KBFullAccessState)p_detectFullAccessState {
|
||||||
UIInputViewController *ivc = self.ivc;
|
UIInputViewController *ivc = self.ivc;
|
||||||
if (!ivc) return KBFullAccessStateUnknown;
|
if (!ivc) return KBFullAccessStateUnknown;
|
||||||
|
if ([ivc respondsToSelector:@selector(hasFullAccess)]) {
|
||||||
SEL sel = NSSelectorFromString(@"hasFullAccess");
|
return ivc.hasFullAccess ? KBFullAccessStateGranted : KBFullAccessStateDenied;
|
||||||
if ([ivc respondsToSelector:sel]) {
|
|
||||||
BOOL granted = ((BOOL (*)(id, SEL))objc_msgSend)(ivc, sel);
|
|
||||||
return granted ? KBFullAccessStateGranted : KBFullAccessStateDenied;
|
|
||||||
}
|
}
|
||||||
// 无法判断时标记 Unknown(上层可按需处理为未开启)
|
|
||||||
return KBFullAccessStateUnknown;
|
return KBFullAccessStateUnknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
37
CustomKeyboard/Manager/KBKeyboardLayoutResolver.h
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// KBKeyboardLayoutResolver.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// 扩展侧布局解析器:根据 profileId 解析对应的布局配置
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@interface KBKeyboardLayoutResolver : NSObject
|
||||||
|
|
||||||
|
+ (instancetype)sharedResolver;
|
||||||
|
|
||||||
|
/// 根据 profileId 获取对应的布局 JSON ID
|
||||||
|
/// @param profileId 输入配置 ID(如 "es_ES_azerty")
|
||||||
|
/// @return 布局 JSON ID(如 "letters_azerty"),如果未找到返回 "letters"
|
||||||
|
- (NSString *)layoutJsonIdForProfileId:(NSString *)profileId;
|
||||||
|
|
||||||
|
/// 根据 profileId 获取对应的联想引擎类型
|
||||||
|
/// @param profileId 输入配置 ID
|
||||||
|
/// @return 联想引擎类型(如 "latin", "pinyin_traditional", "bopomofo")
|
||||||
|
- (NSString *)suggestionEngineForProfileId:(NSString *)profileId;
|
||||||
|
|
||||||
|
/// 从 App Group 读取当前选中的 profileId
|
||||||
|
- (nullable NSString *)currentProfileId;
|
||||||
|
|
||||||
|
/// 从 App Group 读取当前选中的语言代码
|
||||||
|
- (nullable NSString *)currentLanguageCode;
|
||||||
|
|
||||||
|
/// 从 App Group 读取当前选中的布局变体
|
||||||
|
- (nullable NSString *)currentLayoutVariant;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
106
CustomKeyboard/Manager/KBKeyboardLayoutResolver.m
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
//
|
||||||
|
// KBKeyboardLayoutResolver.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBKeyboardLayoutResolver.h"
|
||||||
|
#import "KBInputProfileManager.h"
|
||||||
|
#import "KBConfig.h"
|
||||||
|
#import "KBLocalizationManager.h"
|
||||||
|
|
||||||
|
@implementation KBKeyboardLayoutResolver
|
||||||
|
|
||||||
|
+ (instancetype)sharedResolver {
|
||||||
|
static KBKeyboardLayoutResolver *instance = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
instance = [[self alloc] init];
|
||||||
|
});
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未手动选择键盘输入配置时,根据当前 App 语言推导默认键盘语言码(对应 kb_input_profiles.json 的 code)。
|
||||||
|
- (NSString *)kb_defaultKeyboardLanguageCodeForAppLanguageCode:(NSString *)appLanguageCode {
|
||||||
|
NSString *lc = (appLanguageCode ?: @"").lowercaseString;
|
||||||
|
if ([lc hasPrefix:@"es"]) { return @"es"; }
|
||||||
|
if ([lc hasPrefix:@"pt"]) { return @"pt"; }
|
||||||
|
if ([lc hasPrefix:@"id"]) { return @"id"; }
|
||||||
|
if ([lc hasPrefix:@"zh-hant"]) { return @"zh-Hant-Pinyin"; }
|
||||||
|
return @"en";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)kb_didUserSelectKeyboardProfileInAppGroup:(NSUserDefaults *)appGroup {
|
||||||
|
return [appGroup boolForKey:AppGroup_DidUserSelectKeyboardProfile];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (nullable KBInputProfileLayout *)kb_defaultLayoutForCurrentAppLanguage {
|
||||||
|
NSString *appLang = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish;
|
||||||
|
NSString *kbLang = [self kb_defaultKeyboardLanguageCodeForAppLanguageCode:appLang];
|
||||||
|
KBInputProfile *profile = [[KBInputProfileManager sharedManager] profileForLanguageCode:kbLang];
|
||||||
|
if (!profile) {
|
||||||
|
profile = [[KBInputProfileManager sharedManager] profileForLanguageCode:@"en"];
|
||||||
|
}
|
||||||
|
return profile.layouts.firstObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)layoutJsonIdForProfileId:(NSString *)profileId {
|
||||||
|
if (profileId.length == 0) {
|
||||||
|
return @"letters";
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *layoutJsonId = [[KBInputProfileManager sharedManager] layoutJsonIdForProfileId:profileId];
|
||||||
|
if (layoutJsonId.length > 0) {
|
||||||
|
return layoutJsonId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到默认布局
|
||||||
|
NSLog(@"[KBKeyboardLayoutResolver] No layoutJsonId found for profileId: %@, using default 'letters'", profileId);
|
||||||
|
return @"letters";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)suggestionEngineForProfileId:(NSString *)profileId {
|
||||||
|
if (profileId.length == 0) {
|
||||||
|
return @"latin";
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *engine = [[KBInputProfileManager sharedManager] suggestionEngineForProfileId:profileId];
|
||||||
|
if (engine.length > 0) {
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到默认引擎
|
||||||
|
NSLog(@"[KBKeyboardLayoutResolver] No suggestionEngine found for profileId: %@, using default 'latin'", profileId);
|
||||||
|
return @"latin";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (nullable NSString *)currentProfileId {
|
||||||
|
NSUserDefaults *appGroup = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||||
|
NSString *profileId = [appGroup stringForKey:AppGroup_SelectedKeyboardProfileId];
|
||||||
|
if ([self kb_didUserSelectKeyboardProfileInAppGroup:appGroup]) {
|
||||||
|
return profileId;
|
||||||
|
}
|
||||||
|
KBInputProfileLayout *layout = [self kb_defaultLayoutForCurrentAppLanguage];
|
||||||
|
return layout.profileId.length > 0 ? layout.profileId : profileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (nullable NSString *)currentLanguageCode {
|
||||||
|
NSUserDefaults *appGroup = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||||
|
NSString *languageCode = [appGroup stringForKey:AppGroup_SelectedKeyboardLanguageCode];
|
||||||
|
if ([self kb_didUserSelectKeyboardProfileInAppGroup:appGroup]) {
|
||||||
|
return languageCode;
|
||||||
|
}
|
||||||
|
NSString *appLang = [KBLocalizationManager shared].currentLanguageCode ?: KBLanguageCodeEnglish;
|
||||||
|
return [self kb_defaultKeyboardLanguageCodeForAppLanguageCode:appLang];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (nullable NSString *)currentLayoutVariant {
|
||||||
|
NSUserDefaults *appGroup = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||||
|
NSString *layoutVariant = [appGroup stringForKey:AppGroup_SelectedKeyboardLayoutVariant];
|
||||||
|
if ([self kb_didUserSelectKeyboardProfileInAppGroup:appGroup]) {
|
||||||
|
return layoutVariant;
|
||||||
|
}
|
||||||
|
KBInputProfileLayout *layout = [self kb_defaultLayoutForCurrentAppLanguage];
|
||||||
|
return layout.variant.length > 0 ? layout.variant : layoutVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
39
CustomKeyboard/Manager/KBSuggestionEngine.h
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// KBSuggestionEngine.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
typedef NS_ENUM(NSInteger, KBSuggestionEngineType) {
|
||||||
|
KBSuggestionEngineTypeLatin = 0, // 拉丁字母(兼容旧值)
|
||||||
|
KBSuggestionEngineTypeEnglish, // 英语
|
||||||
|
KBSuggestionEngineTypeSpanish, // 西班牙语
|
||||||
|
KBSuggestionEngineTypePortuguese, // 葡萄牙语
|
||||||
|
KBSuggestionEngineTypeIndonesian, // 印度尼西亚语
|
||||||
|
KBSuggestionEngineTypePinyinSimplified, // 简体拼音
|
||||||
|
KBSuggestionEngineTypePinyinTraditional, // 繁体拼音
|
||||||
|
KBSuggestionEngineTypeBopomofo // 注音(繁体)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Simple local suggestion engine (prefix match + lightweight ranking).
|
||||||
|
@interface KBSuggestionEngine : NSObject
|
||||||
|
|
||||||
|
@property (nonatomic, assign) KBSuggestionEngineType engineType;
|
||||||
|
|
||||||
|
+ (instancetype)shared;
|
||||||
|
|
||||||
|
/// Returns suggestions for prefix (lowercase expected), limited by count.
|
||||||
|
- (NSArray<NSString *> *)suggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit;
|
||||||
|
|
||||||
|
/// Record a selection to slightly boost ranking next time.
|
||||||
|
- (void)recordSelection:(NSString *)word;
|
||||||
|
|
||||||
|
/// 设置联想引擎类型(根据 profileId 的 suggestionEngine 字段)
|
||||||
|
- (void)setEngineTypeFromString:(NSString *)engineTypeString;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
974
CustomKeyboard/Manager/KBSuggestionEngine.m
Normal file
@@ -0,0 +1,974 @@
|
|||||||
|
//
|
||||||
|
// KBSuggestionEngine.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBSuggestionEngine.h"
|
||||||
|
#import "KBConfig.h"
|
||||||
|
|
||||||
|
@interface KBSuggestionEngine ()
|
||||||
|
@property (nonatomic, copy) NSArray<NSString *> *words;
|
||||||
|
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *selectionCounts;
|
||||||
|
@property (nonatomic, strong) NSSet<NSString *> *priorityWords;
|
||||||
|
@property (nonatomic, copy) NSArray<NSString *> *traditionalChineseWords;
|
||||||
|
@property (nonatomic, copy) NSArray<NSString *> *simplifiedChineseWords;
|
||||||
|
@property (nonatomic, strong) NSDictionary<NSString *, NSArray<NSString *> *> *pinyinToTraditionalMap;
|
||||||
|
@property (nonatomic, strong) NSDictionary<NSString *, NSArray<NSString *> *> *bopomofoToChineseMap;
|
||||||
|
@property (nonatomic, copy) NSArray<NSString *> *spanishWords;
|
||||||
|
@property (nonatomic, copy) NSArray<NSString *> *englishWords;
|
||||||
|
@property (nonatomic, copy) NSArray<NSString *> *portugueseWords;
|
||||||
|
@property (nonatomic, copy) NSArray<NSString *> *indonesianWords;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBSuggestionEngine
|
||||||
|
|
||||||
|
+ (instancetype)shared {
|
||||||
|
static KBSuggestionEngine *engine;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
engine = [[KBSuggestionEngine alloc] init];
|
||||||
|
});
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (instancetype)init {
|
||||||
|
if (self = [super init]) {
|
||||||
|
_engineType = KBSuggestionEngineTypeLatin;
|
||||||
|
_selectionCounts = [NSMutableDictionary dictionary];
|
||||||
|
NSArray<NSString *> *defaults = [self.class kb_defaultWords];
|
||||||
|
_priorityWords = [NSSet setWithArray:defaults];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)suggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit {
|
||||||
|
if (prefix.length == 0 || limit == 0) { return @[]; }
|
||||||
|
// 为过滤留出候选空间,避免过滤后数量过少。
|
||||||
|
NSUInteger fetchLimit = limit;
|
||||||
|
if (fetchLimit < 80) {
|
||||||
|
fetchLimit = MIN((NSUInteger)80, MAX(fetchLimit * 4, fetchLimit));
|
||||||
|
}
|
||||||
|
NSArray<NSString *> *raw = nil;
|
||||||
|
|
||||||
|
switch (self.engineType) {
|
||||||
|
case KBSuggestionEngineTypeEnglish:
|
||||||
|
raw = [self kb_englishSuggestionsForPrefix:prefix limit:fetchLimit];
|
||||||
|
break;
|
||||||
|
case KBSuggestionEngineTypeSpanish:
|
||||||
|
raw = [self kb_spanishSuggestionsForPrefix:prefix limit:fetchLimit];
|
||||||
|
break;
|
||||||
|
case KBSuggestionEngineTypePortuguese:
|
||||||
|
raw = [self kb_portugueseSuggestionsForPrefix:prefix limit:fetchLimit];
|
||||||
|
break;
|
||||||
|
case KBSuggestionEngineTypeIndonesian:
|
||||||
|
raw = [self kb_indonesianSuggestionsForPrefix:prefix limit:fetchLimit];
|
||||||
|
break;
|
||||||
|
case KBSuggestionEngineTypePinyinTraditional:
|
||||||
|
raw = [self kb_traditionalPinyinSuggestionsForPrefix:prefix limit:fetchLimit];
|
||||||
|
break;
|
||||||
|
case KBSuggestionEngineTypePinyinSimplified:
|
||||||
|
raw = [self kb_simplifiedPinyinSuggestionsForPrefix:prefix limit:fetchLimit];
|
||||||
|
break;
|
||||||
|
case KBSuggestionEngineTypeBopomofo:
|
||||||
|
raw = [self kb_bopomofoSuggestionsForPrefix:prefix limit:fetchLimit];
|
||||||
|
break;
|
||||||
|
case KBSuggestionEngineTypeLatin:
|
||||||
|
default:
|
||||||
|
raw = [self kb_latinSuggestionsForPrefix:prefix limit:fetchLimit];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return [self kb_filterSensitiveSuggestions:raw limit:limit];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)recordSelection:(NSString *)word {
|
||||||
|
if (word.length == 0) { return; }
|
||||||
|
NSString *key = word.lowercaseString;
|
||||||
|
NSInteger count = self.selectionCounts[key].integerValue + 1;
|
||||||
|
self.selectionCounts[key] = @(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Defaults
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_loadWords {
|
||||||
|
NSMutableOrderedSet<NSString *> *set = [[NSMutableOrderedSet alloc] init];
|
||||||
|
[set addObjectsFromArray:[self.class kb_defaultWords]];
|
||||||
|
|
||||||
|
NSArray<NSString *> *paths = [self kb_wordListPaths];
|
||||||
|
for (NSString *path in paths) {
|
||||||
|
if (path.length == 0) { continue; }
|
||||||
|
NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
|
||||||
|
if (content.length == 0) { continue; }
|
||||||
|
NSArray<NSString *> *lines = [content componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]];
|
||||||
|
for (NSString *line in lines) {
|
||||||
|
NSString *word = [self kb_sanitizedWordFromLine:line];
|
||||||
|
if (word.length == 0) { continue; }
|
||||||
|
[set addObject:word];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NSArray<NSString *> *result = set.array ?: @[];
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_wordListPaths {
|
||||||
|
NSMutableArray<NSString *> *paths = [NSMutableArray array];
|
||||||
|
// 1) App Group override (allows server-downloaded large list).
|
||||||
|
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
||||||
|
if (containerURL.path.length > 0) {
|
||||||
|
NSString *groupPath = [[containerURL path] stringByAppendingPathComponent:@"kb_words.txt"];
|
||||||
|
[paths addObject:groupPath];
|
||||||
|
}
|
||||||
|
// 2) Bundle fallback.
|
||||||
|
NSString *bundlePath = [[NSBundle mainBundle] pathForResource:@"kb_words" ofType:@"txt"];
|
||||||
|
if (bundlePath.length > 0) {
|
||||||
|
[paths addObject:bundlePath];
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_sanitizedWordFromLine:(NSString *)line {
|
||||||
|
NSString *trimmed = [[line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] lowercaseString];
|
||||||
|
if (trimmed.length == 0) { return @""; }
|
||||||
|
static NSCharacterSet *letters = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
letters = [NSCharacterSet characterSetWithCharactersInString:@"abcdefghijklmnopqrstuvwxyz"];
|
||||||
|
});
|
||||||
|
for (NSUInteger i = 0; i < trimmed.length; i++) {
|
||||||
|
if (![letters characterIsMember:[trimmed characterAtIndex:i]]) {
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (NSArray<NSString *> *)kb_defaultWords {
|
||||||
|
return @[
|
||||||
|
@"a", @"an", @"and", @"are", @"as", @"at",
|
||||||
|
@"app", @"ap", @"apple", @"apply", @"april", @"application",
|
||||||
|
@"about", @"above", @"after", @"again", @"against", @"all",
|
||||||
|
@"am", @"among", @"amount", @"any", @"around",
|
||||||
|
@"be", @"because", @"been", @"before", @"being", @"below",
|
||||||
|
@"best", @"between", @"both", @"but", @"by",
|
||||||
|
@"can", @"could", @"come", @"common", @"case",
|
||||||
|
@"do", @"does", @"down", @"day",
|
||||||
|
@"each", @"early", @"end", @"even", @"every",
|
||||||
|
@"for", @"from", @"first", @"found", @"free",
|
||||||
|
@"get", @"good", @"great", @"go",
|
||||||
|
@"have", @"has", @"had", @"help", @"how",
|
||||||
|
@"in", @"is", @"it", @"if", @"into",
|
||||||
|
@"just", @"keep", @"kind", @"know",
|
||||||
|
@"like", @"look", @"long", @"last",
|
||||||
|
@"make", @"more", @"most", @"my",
|
||||||
|
@"new", @"no", @"not", @"now",
|
||||||
|
@"of", @"on", @"one", @"or", @"other", @"our", @"out",
|
||||||
|
@"people", @"place", @"please",
|
||||||
|
@"quick", @"quite",
|
||||||
|
@"right", @"read", @"real",
|
||||||
|
@"see", @"say", @"some", @"such", @"so",
|
||||||
|
@"the", @"to", @"this", @"that", @"them", @"then", @"there", @"they", @"these", @"time",
|
||||||
|
@"use", @"up", @"under",
|
||||||
|
@"very",
|
||||||
|
@"we", @"with", @"what", @"when", @"where", @"who", @"why", @"will", @"would",
|
||||||
|
@"you", @"your"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Engine Type Management
|
||||||
|
|
||||||
|
- (void)setEngineTypeFromString:(NSString *)engineTypeString {
|
||||||
|
if ([engineTypeString isEqualToString:@"latin"]) {
|
||||||
|
self.engineType = KBSuggestionEngineTypeLatin;
|
||||||
|
} else if ([engineTypeString isEqualToString:@"spanish"]) {
|
||||||
|
self.engineType = KBSuggestionEngineTypeSpanish;
|
||||||
|
} else if ([engineTypeString isEqualToString:@"english"]) {
|
||||||
|
self.engineType = KBSuggestionEngineTypeEnglish;
|
||||||
|
} else if ([engineTypeString isEqualToString:@"portuguese"]) {
|
||||||
|
self.engineType = KBSuggestionEngineTypePortuguese;
|
||||||
|
} else if ([engineTypeString isEqualToString:@"indonesian"]) {
|
||||||
|
self.engineType = KBSuggestionEngineTypeIndonesian;
|
||||||
|
} else if ([engineTypeString isEqualToString:@"pinyin_traditional"]) {
|
||||||
|
self.engineType = KBSuggestionEngineTypePinyinTraditional;
|
||||||
|
} else if ([engineTypeString isEqualToString:@"pinyin_simplified"]) {
|
||||||
|
self.engineType = KBSuggestionEngineTypePinyinSimplified;
|
||||||
|
} else if ([engineTypeString isEqualToString:@"bopomofo"]) {
|
||||||
|
self.engineType = KBSuggestionEngineTypeBopomofo;
|
||||||
|
} else {
|
||||||
|
self.engineType = KBSuggestionEngineTypeLatin;
|
||||||
|
}
|
||||||
|
[self kb_trimCachesForEngineType:self.engineType];
|
||||||
|
NSLog(@"[KBSuggestionEngine] Engine type set to: %@", engineTypeString);
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - English Suggestions
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_englishSuggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit {
|
||||||
|
if (!self.englishWords) {
|
||||||
|
self.englishWords = [self kb_loadEnglishWords];
|
||||||
|
}
|
||||||
|
NSArray<NSString *> *matches = [self kb_suggestionsFromWordList:self.englishWords
|
||||||
|
prefix:prefix
|
||||||
|
limit:limit];
|
||||||
|
if (matches.count == 0) {
|
||||||
|
return [self kb_latinSuggestionsForPrefix:prefix limit:limit];
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_loadEnglishWords {
|
||||||
|
NSString *path = [[NSBundle mainBundle] pathForResource:@"english_words" ofType:@"json"];
|
||||||
|
if (!path) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] english_words.json not found, using default words");
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSData *data = [NSData dataWithContentsOfFile:path];
|
||||||
|
if (!data) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Failed to read english_words.json");
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSError *error = nil;
|
||||||
|
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
|
||||||
|
if (error || ![json isKindOfClass:NSDictionary.class]) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Failed to parse english_words.json: %@", error);
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSArray *wordsArray = json[@"words"];
|
||||||
|
if (![wordsArray isKindOfClass:NSArray.class]) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Invalid words array in english_words.json");
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableArray<NSString *> *result = [NSMutableArray array];
|
||||||
|
for (id item in wordsArray) {
|
||||||
|
if ([item isKindOfClass:NSString.class]) {
|
||||||
|
[result addObject:item];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog(@"[KBSuggestionEngine] Loaded %lu English words", (unsigned long)result.count);
|
||||||
|
return result.count > 0 ? [result copy] : [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Latin Suggestions
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_latinSuggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit {
|
||||||
|
if (!self.words) {
|
||||||
|
self.words = [self kb_loadWords];
|
||||||
|
}
|
||||||
|
NSString *lower = prefix.lowercaseString;
|
||||||
|
NSMutableArray<NSString *> *matches = [NSMutableArray array];
|
||||||
|
|
||||||
|
for (NSString *word in self.words) {
|
||||||
|
if ([word hasPrefix:lower]) {
|
||||||
|
[matches addObject:word];
|
||||||
|
if (matches.count >= limit * 3) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.count == 0) { return @[]; }
|
||||||
|
|
||||||
|
[matches sortUsingComparator:^NSComparisonResult(NSString *a, NSString *b) {
|
||||||
|
NSInteger ca = self.selectionCounts[a].integerValue;
|
||||||
|
NSInteger cb = self.selectionCounts[b].integerValue;
|
||||||
|
if (ca != cb) {
|
||||||
|
return (cb > ca) ? NSOrderedAscending : NSOrderedDescending;
|
||||||
|
}
|
||||||
|
BOOL pa = [self.priorityWords containsObject:a];
|
||||||
|
BOOL pb = [self.priorityWords containsObject:b];
|
||||||
|
if (pa != pb) {
|
||||||
|
return pa ? NSOrderedAscending : NSOrderedDescending;
|
||||||
|
}
|
||||||
|
return [a compare:b];
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (matches.count > limit) {
|
||||||
|
return [matches subarrayWithRange:NSMakeRange(0, limit)];
|
||||||
|
}
|
||||||
|
return matches.copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Traditional Chinese Pinyin Suggestions
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_traditionalPinyinSuggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit {
|
||||||
|
if (!self.pinyinToTraditionalMap) {
|
||||||
|
self.pinyinToTraditionalMap = [self kb_loadPinyinToTraditionalMap];
|
||||||
|
}
|
||||||
|
NSString *lower = prefix.lowercaseString;
|
||||||
|
NSMutableArray<NSString *> *matches = [NSMutableArray array];
|
||||||
|
|
||||||
|
NSArray<NSString *> *directMatches = self.pinyinToTraditionalMap[lower];
|
||||||
|
if (directMatches.count > 0) {
|
||||||
|
[matches addObjectsFromArray:directMatches];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (NSString *key in self.pinyinToTraditionalMap) {
|
||||||
|
if ([key hasPrefix:lower] && ![key isEqualToString:lower]) {
|
||||||
|
NSArray<NSString *> *candidates = self.pinyinToTraditionalMap[key];
|
||||||
|
[matches addObjectsFromArray:candidates];
|
||||||
|
if (matches.count >= limit * 2) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.count == 0) {
|
||||||
|
return [self kb_fallbackTraditionalSuggestions:lower limit:limit];
|
||||||
|
}
|
||||||
|
|
||||||
|
[matches sortUsingComparator:^NSComparisonResult(NSString *a, NSString *b) {
|
||||||
|
NSInteger ca = self.selectionCounts[a].integerValue;
|
||||||
|
NSInteger cb = self.selectionCounts[b].integerValue;
|
||||||
|
if (ca != cb) {
|
||||||
|
return (cb > ca) ? NSOrderedAscending : NSOrderedDescending;
|
||||||
|
}
|
||||||
|
return [a compare:b];
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (matches.count > limit) {
|
||||||
|
return [matches subarrayWithRange:NSMakeRange(0, limit)];
|
||||||
|
}
|
||||||
|
return matches.copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_fallbackTraditionalSuggestions:(NSString *)prefix limit:(NSUInteger)limit {
|
||||||
|
if (!self.traditionalChineseWords) {
|
||||||
|
self.traditionalChineseWords = [self kb_loadTraditionalChineseWords];
|
||||||
|
}
|
||||||
|
NSMutableArray<NSString *> *matches = [NSMutableArray array];
|
||||||
|
for (NSString *word in self.traditionalChineseWords) {
|
||||||
|
[matches addObject:word];
|
||||||
|
if (matches.count >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches.copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Simplified Chinese Pinyin Suggestions
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_simplifiedPinyinSuggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit {
|
||||||
|
if (!self.pinyinToTraditionalMap) {
|
||||||
|
self.pinyinToTraditionalMap = [self kb_loadPinyinToTraditionalMap];
|
||||||
|
}
|
||||||
|
NSString *lower = prefix.lowercaseString;
|
||||||
|
NSMutableArray<NSString *> *matches = [NSMutableArray array];
|
||||||
|
|
||||||
|
NSArray<NSString *> *directMatches = self.pinyinToTraditionalMap[lower];
|
||||||
|
if (directMatches.count > 0) {
|
||||||
|
for (NSString *tradChar in directMatches) {
|
||||||
|
NSString *simplified = [self kb_toSimplified:tradChar];
|
||||||
|
if (simplified.length > 0) {
|
||||||
|
[matches addObject:simplified];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (NSString *key in self.pinyinToTraditionalMap) {
|
||||||
|
if ([key hasPrefix:lower] && ![key isEqualToString:lower]) {
|
||||||
|
NSArray<NSString *> *candidates = self.pinyinToTraditionalMap[key];
|
||||||
|
for (NSString *tradChar in candidates) {
|
||||||
|
NSString *simplified = [self kb_toSimplified:tradChar];
|
||||||
|
if (simplified.length > 0) {
|
||||||
|
[matches addObject:simplified];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches.count >= limit * 2) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.count == 0) {
|
||||||
|
return [self kb_fallbackSimplifiedSuggestions:lower limit:limit];
|
||||||
|
}
|
||||||
|
|
||||||
|
[matches sortUsingComparator:^NSComparisonResult(NSString *a, NSString *b) {
|
||||||
|
NSInteger ca = self.selectionCounts[a].integerValue;
|
||||||
|
NSInteger cb = self.selectionCounts[b].integerValue;
|
||||||
|
if (ca != cb) {
|
||||||
|
return (cb > ca) ? NSOrderedAscending : NSOrderedDescending;
|
||||||
|
}
|
||||||
|
return [a compare:b];
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (matches.count > limit) {
|
||||||
|
return [matches subarrayWithRange:NSMakeRange(0, limit)];
|
||||||
|
}
|
||||||
|
return matches.copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_fallbackSimplifiedSuggestions:(NSString *)prefix limit:(NSUInteger)limit {
|
||||||
|
if (!self.simplifiedChineseWords) {
|
||||||
|
self.simplifiedChineseWords = [self kb_loadSimplifiedChineseWords];
|
||||||
|
}
|
||||||
|
NSMutableArray<NSString *> *matches = [NSMutableArray array];
|
||||||
|
for (NSString *word in self.simplifiedChineseWords) {
|
||||||
|
[matches addObject:word];
|
||||||
|
if (matches.count >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches.copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_toSimplified:(NSString *)traditional {
|
||||||
|
static NSDictionary<NSString *, NSString *> *tradToSimpMap = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
tradToSimpMap = @{
|
||||||
|
@"臺": @"台", @"臺": @"台", @"灣": @"湾", @"語": @"语", @"體": @"体",
|
||||||
|
@"國": @"国", @"學": @"学", @"時": @"时", @"問": @"问", @"見": @"见",
|
||||||
|
@"經": @"经", @"動": @"动", @"長": @"长", @"開": @"开", @"關": @"关",
|
||||||
|
@"無": @"无", @"說": @"说", @"書": @"书", @"電": @"电", @"機": @"机",
|
||||||
|
@"氣": @"气", @"這": @"这", @"們": @"们", @"個": @"个", @"對": @"对",
|
||||||
|
@"來": @"来", @"還": @"还", @"過": @"过", @"會": @"会", @"進": @"进",
|
||||||
|
@"開": @"开", @"頭": @"头", @"點": @"点", @"問": @"问", @"題": @"题",
|
||||||
|
@"變": @"变", @"條": @"条", @"東": @"东", @"車": @"车", @"錢": @"钱",
|
||||||
|
@"門": @"门", @"聽": @"听", @"聲": @"声", @"醫": @"医", @"讓": @"让",
|
||||||
|
@"識": @"识", @"務": @"务", @"農": @"农", @"業": @"业", @"產": @"产",
|
||||||
|
@"黨": @"党", @"歷": @"历", @"史": @"史", @"後": @"后", @"前": @"前",
|
||||||
|
@"強": @"强", @"當": @"当", @"應": @"应", @"從": @"从", @"優": @"优",
|
||||||
|
@"兒": @"儿", @"兩": @"两", @"幾": @"几", @"廣": @"广", @"場": @"场",
|
||||||
|
@"決": @"决", @"許": @"许", @"設": @"设", @"請": @"请", @"論": @"论",
|
||||||
|
@"認": @"认", @"斷": @"断", @"離": @"离", @"須": @"须", @"導": @"导",
|
||||||
|
@"爭": @"争", @"重": @"重", @"輕": @"轻", @"難": @"难", @"極": @"极",
|
||||||
|
@"據": @"据", @"實": @"实", @"際": @"际", @"標": @"标", @"準": @"准",
|
||||||
|
@"確": @"确", @"證": @"证", @"驗": @"验", @"權": @"权", @"規": @"规",
|
||||||
|
@"則": @"则", @"劃": @"划", @"計": @"计", @"劃": @"划", @"術": @"术",
|
||||||
|
@"藝": @"艺", @"術": @"术", @"選": @"选", @"舉": @"举", @"團": @"团",
|
||||||
|
@"結": @"结", @"組": @"组", @"織": @"织", @"義": @"义", @"務": @"务",
|
||||||
|
@"親": @"亲", @"愛": @"爱", @"情": @"情", @"懷": @"怀", @"家": @"家",
|
||||||
|
@"屬": @"属", @"幫": @"帮", @"助": @"助", @"友": @"友", @"誼": @"谊",
|
||||||
|
@"謝": @"谢", @"謝": @"谢", @"對": @"对", @"起": @"起", @"早": @"早",
|
||||||
|
@"安": @"安", @"晚": @"晚", @"請": @"请", @"問": @"问", @"沒": @"没",
|
||||||
|
@"關": @"关", @"係": @"系", @"加": @"加", @"油": @"油", @"台": @"台",
|
||||||
|
@"北": @"北", @"高": @"高", @"雄": @"雄", @"中": @"中", @"南": @"南",
|
||||||
|
@"朋": @"朋", @"友": @"友", @"人": @"人", @"工": @"工", @"作": @"作",
|
||||||
|
@"習": @"习", @"生": @"生", @"活": @"活", @"地": @"地", @"方": @"方",
|
||||||
|
@"法": @"法", @"答": @"答", @"喜": @"喜", @"歡": @"欢", @"想": @"想",
|
||||||
|
@"念": @"念", @"開": @"开", @"心": @"心", @"快": @"快", @"樂": @"乐",
|
||||||
|
@"美": @"美", @"麗": @"丽", @"漂": @"漂", @"亮": @"亮", @"帥": @"帅",
|
||||||
|
@"氣": @"气", @"可": @"可", @"愛": @"爱", @"溫": @"温", @"柔": @"柔"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tradToSimpMap[traditional]) {
|
||||||
|
return tradToSimpMap[traditional];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableString *result = [traditional mutableCopy];
|
||||||
|
[tradToSimpMap enumerateKeysAndObjectsUsingBlock:^(NSString *trad, NSString *simp, BOOL *stop) {
|
||||||
|
[result replaceOccurrencesOfString:trad withString:simp options:0 range:NSMakeRange(0, result.length)];
|
||||||
|
}];
|
||||||
|
|
||||||
|
return result.length > 0 ? [result copy] : traditional;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Bopomofo (Zhuyin) Suggestions
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_bopomofoSuggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit {
|
||||||
|
if (!self.bopomofoToChineseMap) {
|
||||||
|
self.bopomofoToChineseMap = [self kb_loadBopomofoToChineseMap];
|
||||||
|
}
|
||||||
|
NSMutableArray<NSString *> *matches = [NSMutableArray array];
|
||||||
|
|
||||||
|
NSArray<NSString *> *directMatches = self.bopomofoToChineseMap[prefix];
|
||||||
|
if (directMatches.count > 0) {
|
||||||
|
[matches addObjectsFromArray:directMatches];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (NSString *key in self.bopomofoToChineseMap) {
|
||||||
|
if ([key hasPrefix:prefix] && ![key isEqualToString:prefix]) {
|
||||||
|
NSArray<NSString *> *candidates = self.bopomofoToChineseMap[key];
|
||||||
|
[matches addObjectsFromArray:candidates];
|
||||||
|
if (matches.count >= limit * 2) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.count == 0) {
|
||||||
|
return [self kb_fallbackTraditionalSuggestions:prefix limit:limit];
|
||||||
|
}
|
||||||
|
|
||||||
|
[matches sortUsingComparator:^NSComparisonResult(NSString *a, NSString *b) {
|
||||||
|
NSInteger ca = self.selectionCounts[a].integerValue;
|
||||||
|
NSInteger cb = self.selectionCounts[b].integerValue;
|
||||||
|
if (ca != cb) {
|
||||||
|
return (cb > ca) ? NSOrderedAscending : NSOrderedDescending;
|
||||||
|
}
|
||||||
|
return [a compare:b];
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (matches.count > limit) {
|
||||||
|
return [matches subarrayWithRange:NSMakeRange(0, limit)];
|
||||||
|
}
|
||||||
|
return matches.copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Chinese Word Loading
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_loadTraditionalChineseWords {
|
||||||
|
// 加载繁体中文常用词
|
||||||
|
// 这里先返回一些示例词,实际应该从文件或数据库加载
|
||||||
|
return @[
|
||||||
|
@"你好", @"謝謝", @"對不起", @"再見", @"早安",
|
||||||
|
@"晚安", @"請問", @"不好意思", @"沒關係", @"加油",
|
||||||
|
@"台灣", @"台北", @"高雄", @"台中", @"台南",
|
||||||
|
@"朋友", @"家人", @"工作", @"學習", @"生活",
|
||||||
|
@"時間", @"地點", @"方法", @"問題", @"答案",
|
||||||
|
@"喜歡", @"愛", @"想念", @"開心", @"快樂",
|
||||||
|
@"美麗", @"漂亮", @"帥氣", @"可愛", @"溫柔"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_loadSimplifiedChineseWords {
|
||||||
|
return @[
|
||||||
|
@"你好", @"谢谢", @"对不起", @"再见", @"早安",
|
||||||
|
@"晚安", @"请问", @"不好意思", @"没关系", @"加油",
|
||||||
|
@"中国", @"北京", @"上海", @"广州", @"深圳",
|
||||||
|
@"朋友", @"家人", @"工作", @"学习", @"生活",
|
||||||
|
@"时间", @"地点", @"方法", @"问题", @"答案",
|
||||||
|
@"喜欢", @"爱", @"想念", @"开心", @"快乐",
|
||||||
|
@"美丽", @"漂亮", @"帅气", @"可爱", @"温柔"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Pinyin & Bopomofo Map Loading
|
||||||
|
|
||||||
|
- (NSDictionary<NSString *, NSArray<NSString *> *> *)kb_loadPinyinToTraditionalMap {
|
||||||
|
NSString *path = [[NSBundle mainBundle] pathForResource:@"pinyin_to_traditional" ofType:@"json"];
|
||||||
|
if (!path) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] pinyin_to_traditional.json not found, using empty map");
|
||||||
|
return @{};
|
||||||
|
}
|
||||||
|
|
||||||
|
NSData *data = [NSData dataWithContentsOfFile:path];
|
||||||
|
if (!data) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Failed to read pinyin_to_traditional.json");
|
||||||
|
return @{};
|
||||||
|
}
|
||||||
|
|
||||||
|
NSError *error = nil;
|
||||||
|
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
|
||||||
|
if (error || ![json isKindOfClass:NSDictionary.class]) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Failed to parse pinyin_to_traditional.json: %@", error);
|
||||||
|
return @{};
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDictionary *mappings = json[@"mappings"];
|
||||||
|
if (![mappings isKindOfClass:NSDictionary.class]) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Invalid mappings in pinyin_to_traditional.json");
|
||||||
|
return @{};
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableDictionary<NSString *, NSArray<NSString *> *> *result = [NSMutableDictionary dictionary];
|
||||||
|
[mappings enumerateKeysAndObjectsUsingBlock:^(NSString *key, id obj, BOOL *stop) {
|
||||||
|
if ([obj isKindOfClass:NSArray.class]) {
|
||||||
|
NSMutableArray<NSString *> *chars = [NSMutableArray array];
|
||||||
|
for (id item in (NSArray *)obj) {
|
||||||
|
if ([item isKindOfClass:NSString.class]) {
|
||||||
|
[chars addObject:item];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chars.count > 0) {
|
||||||
|
result[key] = [chars copy];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
|
NSLog(@"[KBSuggestionEngine] Loaded %lu pinyin mappings", (unsigned long)result.count);
|
||||||
|
return [result copy];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSDictionary<NSString *, NSArray<NSString *> *> *)kb_loadBopomofoToChineseMap {
|
||||||
|
NSString *path = [[NSBundle mainBundle] pathForResource:@"bopomofo_to_chinese" ofType:@"json"];
|
||||||
|
if (!path) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] bopomofo_to_chinese.json not found, using empty map");
|
||||||
|
return @{};
|
||||||
|
}
|
||||||
|
|
||||||
|
NSData *data = [NSData dataWithContentsOfFile:path];
|
||||||
|
if (!data) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Failed to read bopomofo_to_chinese.json");
|
||||||
|
return @{};
|
||||||
|
}
|
||||||
|
|
||||||
|
NSError *error = nil;
|
||||||
|
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
|
||||||
|
if (error || ![json isKindOfClass:NSDictionary.class]) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Failed to parse bopomofo_to_chinese.json: %@", error);
|
||||||
|
return @{};
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDictionary *mappings = json[@"mappings"];
|
||||||
|
if (![mappings isKindOfClass:NSDictionary.class]) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Invalid mappings in bopomofo_to_chinese.json");
|
||||||
|
return @{};
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableDictionary<NSString *, NSArray<NSString *> *> *result = [NSMutableDictionary dictionary];
|
||||||
|
[mappings enumerateKeysAndObjectsUsingBlock:^(NSString *key, id obj, BOOL *stop) {
|
||||||
|
if ([obj isKindOfClass:NSArray.class]) {
|
||||||
|
NSMutableArray<NSString *> *chars = [NSMutableArray array];
|
||||||
|
for (id item in (NSArray *)obj) {
|
||||||
|
if ([item isKindOfClass:NSString.class]) {
|
||||||
|
[chars addObject:item];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chars.count > 0) {
|
||||||
|
result[key] = [chars copy];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
|
NSLog(@"[KBSuggestionEngine] Loaded %lu bopomofo mappings", (unsigned long)result.count);
|
||||||
|
return [result copy];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Spanish Suggestions
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_spanishSuggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit {
|
||||||
|
if (!self.spanishWords) {
|
||||||
|
self.spanishWords = [self kb_loadSpanishWords];
|
||||||
|
}
|
||||||
|
NSArray<NSString *> *matches = [self kb_suggestionsFromWordList:self.spanishWords
|
||||||
|
prefix:prefix
|
||||||
|
limit:limit];
|
||||||
|
if (matches.count == 0) {
|
||||||
|
return [self kb_latinSuggestionsForPrefix:prefix limit:limit];
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_loadSpanishWords {
|
||||||
|
NSString *path = [[NSBundle mainBundle] pathForResource:@"spanish_words" ofType:@"json"];
|
||||||
|
if (!path) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] spanish_words.json not found, using default words");
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSData *data = [NSData dataWithContentsOfFile:path];
|
||||||
|
if (!data) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Failed to read spanish_words.json");
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSError *error = nil;
|
||||||
|
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
|
||||||
|
if (error || ![json isKindOfClass:NSDictionary.class]) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Failed to parse spanish_words.json: %@", error);
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSArray *wordsArray = json[@"words"];
|
||||||
|
if (![wordsArray isKindOfClass:NSArray.class]) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Invalid words array in spanish_words.json");
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableArray<NSString *> *result = [NSMutableArray array];
|
||||||
|
for (id item in wordsArray) {
|
||||||
|
if ([item isKindOfClass:NSString.class]) {
|
||||||
|
[result addObject:item];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog(@"[KBSuggestionEngine] Loaded %lu Spanish words", (unsigned long)result.count);
|
||||||
|
return result.count > 0 ? [result copy] : [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Portuguese Suggestions
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_portugueseSuggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit {
|
||||||
|
if (!self.portugueseWords) {
|
||||||
|
self.portugueseWords = [self kb_loadPortugueseWords];
|
||||||
|
}
|
||||||
|
NSArray<NSString *> *matches = [self kb_suggestionsFromWordList:self.portugueseWords
|
||||||
|
prefix:prefix
|
||||||
|
limit:limit];
|
||||||
|
if (matches.count == 0) {
|
||||||
|
return [self kb_latinSuggestionsForPrefix:prefix limit:limit];
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_loadPortugueseWords {
|
||||||
|
NSString *path = [[NSBundle mainBundle] pathForResource:@"portuguese_words" ofType:@"json"];
|
||||||
|
if (!path) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] portuguese_words.json not found, using default words");
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSData *data = [NSData dataWithContentsOfFile:path];
|
||||||
|
if (!data) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Failed to read portuguese_words.json");
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSError *error = nil;
|
||||||
|
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
|
||||||
|
if (error || ![json isKindOfClass:NSDictionary.class]) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Failed to parse portuguese_words.json: %@", error);
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSArray *wordsArray = json[@"words"];
|
||||||
|
if (![wordsArray isKindOfClass:NSArray.class]) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Invalid words array in portuguese_words.json");
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableArray<NSString *> *result = [NSMutableArray array];
|
||||||
|
for (id item in wordsArray) {
|
||||||
|
if ([item isKindOfClass:NSString.class]) {
|
||||||
|
[result addObject:item];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog(@"[KBSuggestionEngine] Loaded %lu Portuguese words", (unsigned long)result.count);
|
||||||
|
return result.count > 0 ? [result copy] : [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Indonesian Suggestions
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_indonesianSuggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit {
|
||||||
|
if (!self.indonesianWords) {
|
||||||
|
self.indonesianWords = [self kb_loadIndonesianWords];
|
||||||
|
}
|
||||||
|
NSArray<NSString *> *matches = [self kb_suggestionsFromWordList:self.indonesianWords
|
||||||
|
prefix:prefix
|
||||||
|
limit:limit];
|
||||||
|
if (matches.count == 0) {
|
||||||
|
return [self kb_latinSuggestionsForPrefix:prefix limit:limit];
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_loadIndonesianWords {
|
||||||
|
NSString *path = [[NSBundle mainBundle] pathForResource:@"indonesian_words" ofType:@"json"];
|
||||||
|
if (!path) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] indonesian_words.json not found, using default words");
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSData *data = [NSData dataWithContentsOfFile:path];
|
||||||
|
if (!data) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Failed to read indonesian_words.json");
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSError *error = nil;
|
||||||
|
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
|
||||||
|
if (error || ![json isKindOfClass:NSDictionary.class]) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Failed to parse indonesian_words.json: %@", error);
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSArray *wordsArray = json[@"words"];
|
||||||
|
if (![wordsArray isKindOfClass:NSArray.class]) {
|
||||||
|
NSLog(@"[KBSuggestionEngine] Invalid words array in indonesian_words.json");
|
||||||
|
return [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableArray<NSString *> *result = [NSMutableArray array];
|
||||||
|
for (id item in wordsArray) {
|
||||||
|
if ([item isKindOfClass:NSString.class]) {
|
||||||
|
[result addObject:item];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog(@"[KBSuggestionEngine] Loaded %lu Indonesian words", (unsigned long)result.count);
|
||||||
|
return result.count > 0 ? [result copy] : [self.class kb_defaultWords];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Word List Helpers
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_suggestionsFromWordList:(NSArray<NSString *> *)words
|
||||||
|
prefix:(NSString *)prefix
|
||||||
|
limit:(NSUInteger)limit {
|
||||||
|
NSString *lower = prefix.lowercaseString;
|
||||||
|
NSMutableArray<NSString *> *matches = [NSMutableArray array];
|
||||||
|
|
||||||
|
for (NSString *word in words) {
|
||||||
|
if ([word hasPrefix:lower]) {
|
||||||
|
[matches addObject:word];
|
||||||
|
if (matches.count >= limit * 2) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.count == 0) { return @[]; }
|
||||||
|
|
||||||
|
[matches sortUsingComparator:^NSComparisonResult(NSString *a, NSString *b) {
|
||||||
|
NSInteger ca = self.selectionCounts[a].integerValue;
|
||||||
|
NSInteger cb = self.selectionCounts[b].integerValue;
|
||||||
|
if (ca != cb) {
|
||||||
|
return (cb > ca) ? NSOrderedAscending : NSOrderedDescending;
|
||||||
|
}
|
||||||
|
return [a compare:b];
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (matches.count > limit) {
|
||||||
|
return [matches subarrayWithRange:NSMakeRange(0, limit)];
|
||||||
|
}
|
||||||
|
return matches.copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_trimCachesForEngineType:(KBSuggestionEngineType)engineType {
|
||||||
|
switch (engineType) {
|
||||||
|
case KBSuggestionEngineTypeEnglish:
|
||||||
|
self.spanishWords = nil;
|
||||||
|
self.portugueseWords = nil;
|
||||||
|
self.indonesianWords = nil;
|
||||||
|
self.words = nil;
|
||||||
|
self.traditionalChineseWords = nil;
|
||||||
|
self.simplifiedChineseWords = nil;
|
||||||
|
self.pinyinToTraditionalMap = nil;
|
||||||
|
self.bopomofoToChineseMap = nil;
|
||||||
|
break;
|
||||||
|
case KBSuggestionEngineTypeSpanish:
|
||||||
|
self.englishWords = nil;
|
||||||
|
self.portugueseWords = nil;
|
||||||
|
self.indonesianWords = nil;
|
||||||
|
self.words = nil;
|
||||||
|
self.traditionalChineseWords = nil;
|
||||||
|
self.simplifiedChineseWords = nil;
|
||||||
|
self.pinyinToTraditionalMap = nil;
|
||||||
|
self.bopomofoToChineseMap = nil;
|
||||||
|
break;
|
||||||
|
case KBSuggestionEngineTypePortuguese:
|
||||||
|
self.englishWords = nil;
|
||||||
|
self.spanishWords = nil;
|
||||||
|
self.indonesianWords = nil;
|
||||||
|
self.words = nil;
|
||||||
|
self.traditionalChineseWords = nil;
|
||||||
|
self.simplifiedChineseWords = nil;
|
||||||
|
self.pinyinToTraditionalMap = nil;
|
||||||
|
self.bopomofoToChineseMap = nil;
|
||||||
|
break;
|
||||||
|
case KBSuggestionEngineTypeIndonesian:
|
||||||
|
self.englishWords = nil;
|
||||||
|
self.spanishWords = nil;
|
||||||
|
self.portugueseWords = nil;
|
||||||
|
self.words = nil;
|
||||||
|
self.traditionalChineseWords = nil;
|
||||||
|
self.simplifiedChineseWords = nil;
|
||||||
|
self.pinyinToTraditionalMap = nil;
|
||||||
|
self.bopomofoToChineseMap = nil;
|
||||||
|
break;
|
||||||
|
case KBSuggestionEngineTypePinyinTraditional:
|
||||||
|
case KBSuggestionEngineTypePinyinSimplified:
|
||||||
|
self.words = nil;
|
||||||
|
self.englishWords = nil;
|
||||||
|
self.spanishWords = nil;
|
||||||
|
self.portugueseWords = nil;
|
||||||
|
self.indonesianWords = nil;
|
||||||
|
self.bopomofoToChineseMap = nil;
|
||||||
|
break;
|
||||||
|
case KBSuggestionEngineTypeBopomofo:
|
||||||
|
self.words = nil;
|
||||||
|
self.englishWords = nil;
|
||||||
|
self.spanishWords = nil;
|
||||||
|
self.portugueseWords = nil;
|
||||||
|
self.indonesianWords = nil;
|
||||||
|
self.pinyinToTraditionalMap = nil;
|
||||||
|
self.simplifiedChineseWords = nil;
|
||||||
|
break;
|
||||||
|
case KBSuggestionEngineTypeLatin:
|
||||||
|
default:
|
||||||
|
self.englishWords = nil;
|
||||||
|
self.spanishWords = nil;
|
||||||
|
self.portugueseWords = nil;
|
||||||
|
self.indonesianWords = nil;
|
||||||
|
self.traditionalChineseWords = nil;
|
||||||
|
self.simplifiedChineseWords = nil;
|
||||||
|
self.pinyinToTraditionalMap = nil;
|
||||||
|
self.bopomofoToChineseMap = nil;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Safety Filter
|
||||||
|
|
||||||
|
- (NSArray<NSString *> *)kb_filterSensitiveSuggestions:(NSArray<NSString *> *)items
|
||||||
|
limit:(NSUInteger)limit {
|
||||||
|
if (items.count == 0 || limit == 0) { return @[]; }
|
||||||
|
NSMutableOrderedSet<NSString *> *result = [NSMutableOrderedSet orderedSet];
|
||||||
|
for (id item in items) {
|
||||||
|
if (![item isKindOfClass:NSString.class]) { continue; }
|
||||||
|
NSString *word = (NSString *)item;
|
||||||
|
if (word.length == 0) { continue; }
|
||||||
|
if ([self kb_isSensitiveSuggestion:word]) { continue; }
|
||||||
|
[result addObject:word];
|
||||||
|
if (result.count >= limit) { break; }
|
||||||
|
}
|
||||||
|
return result.array ?: @[];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (BOOL)kb_isSensitiveSuggestion:(NSString *)word {
|
||||||
|
NSString *normalized = [self kb_normalizedSuggestionToken:word];
|
||||||
|
if (normalized.length == 0) { return YES; }
|
||||||
|
if ([[self.class kb_blockedSuggestionWords] containsObject:normalized]) {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
for (NSString *fragment in [self.class kb_blockedSuggestionFragments]) {
|
||||||
|
if ([normalized containsString:fragment]) {
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_normalizedSuggestionToken:(NSString *)word {
|
||||||
|
if (![word isKindOfClass:NSString.class]) { return @""; }
|
||||||
|
NSString *value = [[word stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]
|
||||||
|
lowercaseString];
|
||||||
|
if (value.length == 0) { return @""; }
|
||||||
|
value = [value stringByFoldingWithOptions:NSDiacriticInsensitiveSearch
|
||||||
|
locale:[NSLocale currentLocale]];
|
||||||
|
NSMutableCharacterSet *trimSet = [[NSCharacterSet punctuationCharacterSet] mutableCopy];
|
||||||
|
[trimSet formUnionWithCharacterSet:[NSCharacterSet symbolCharacterSet]];
|
||||||
|
return [value stringByTrimmingCharactersInSet:trimSet];
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (NSSet<NSString *> *)kb_blockedSuggestionWords {
|
||||||
|
static NSSet<NSString *> *words = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
// 上架合规优先:过滤常见成人、露骨性行为、毒品、暴力武器等高风险词。
|
||||||
|
words = [NSSet setWithArray:@[
|
||||||
|
@"sex", @"sexy", @"porn", @"porno", @"xxx", @"nude", @"naked",
|
||||||
|
@"fuck", @"fucking", @"shit", @"bitch", @"penis", @"vagina",
|
||||||
|
@"boob", @"rape", @"cocaine", @"heroin", @"drug", @"drugs",
|
||||||
|
@"kill", @"murder", @"gun", @"weapon",
|
||||||
|
@"sexo", @"porno", @"pornografia", @"violacion", @"violacao",
|
||||||
|
@"drogas", @"cocaina", @"heroina", @"arma", @"matar", @"muerte",
|
||||||
|
@"pene",
|
||||||
|
@"色情", @"裸露", @"裸体", @"裸聊", @"裸照",
|
||||||
|
@"强奸", @"毒品", @"海洛因", @"可卡因",
|
||||||
|
@"枪", @"武器", @"杀人", @"谋杀"
|
||||||
|
]];
|
||||||
|
});
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (NSArray<NSString *> *)kb_blockedSuggestionFragments {
|
||||||
|
static NSArray<NSString *> *fragments = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
fragments = @[
|
||||||
|
@"porn", @"fuck", @"rape", @"cocaine", @"heroin",
|
||||||
|
@"色情", @"裸聊", @"裸照", @"强奸", @"毒品", @"杀人"
|
||||||
|
];
|
||||||
|
});
|
||||||
|
return fragments;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
49
CustomKeyboard/Model/KBChatMessage.h
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// KBChatMessage.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@interface KBChatMessage : NSObject
|
||||||
|
|
||||||
|
@property (nonatomic, copy) NSString *text;
|
||||||
|
@property (nonatomic, assign) BOOL outgoing;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *audioFilePath;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *avatarURL;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *displayName;
|
||||||
|
@property (nonatomic, strong, nullable) UIImage *avatarImage;
|
||||||
|
|
||||||
|
/// 是否处于加载状态
|
||||||
|
@property (nonatomic, assign) BOOL isLoading;
|
||||||
|
/// 是否完成(用于打字机效果)
|
||||||
|
@property (nonatomic, assign) BOOL isComplete;
|
||||||
|
/// 是否需要打字机效果
|
||||||
|
@property (nonatomic, assign) BOOL needsTypewriterEffect;
|
||||||
|
/// 音频 ID(用于异步加载音频)
|
||||||
|
@property (nonatomic, copy, nullable) NSString *audioId;
|
||||||
|
/// 音频数据(缓存)
|
||||||
|
@property (nonatomic, strong, nullable) NSData *audioData;
|
||||||
|
/// 音频时长(秒)
|
||||||
|
@property (nonatomic, assign) NSTimeInterval audioDuration;
|
||||||
|
|
||||||
|
+ (instancetype)messageWithText:(NSString *)text
|
||||||
|
outgoing:(BOOL)outgoing
|
||||||
|
audioFilePath:(nullable NSString *)audioFilePath;
|
||||||
|
|
||||||
|
/// 创建用户消息
|
||||||
|
+ (instancetype)userMessageWithText:(NSString *)text;
|
||||||
|
|
||||||
|
/// 创建 AI 消息(带 audioId)
|
||||||
|
+ (instancetype)assistantMessageWithText:(NSString *)text
|
||||||
|
audioId:(nullable NSString *)audioId;
|
||||||
|
|
||||||
|
/// 创建加载中的 AI 消息
|
||||||
|
+ (instancetype)loadingAssistantMessage;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
55
CustomKeyboard/Model/KBChatMessage.m
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// KBChatMessage.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatMessage.h"
|
||||||
|
|
||||||
|
@implementation KBChatMessage
|
||||||
|
|
||||||
|
+ (instancetype)messageWithText:(NSString *)text
|
||||||
|
outgoing:(BOOL)outgoing
|
||||||
|
audioFilePath:(NSString *)audioFilePath {
|
||||||
|
KBChatMessage *msg = [[KBChatMessage alloc] init];
|
||||||
|
msg.text = text ?: @"";
|
||||||
|
msg.outgoing = outgoing;
|
||||||
|
msg.audioFilePath = audioFilePath;
|
||||||
|
msg.isComplete = YES;
|
||||||
|
msg.isLoading = NO;
|
||||||
|
msg.needsTypewriterEffect = NO;
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (instancetype)userMessageWithText:(NSString *)text {
|
||||||
|
KBChatMessage *msg = [[KBChatMessage alloc] init];
|
||||||
|
msg.text = text ?: @"";
|
||||||
|
msg.outgoing = YES;
|
||||||
|
msg.isComplete = YES;
|
||||||
|
msg.isLoading = NO;
|
||||||
|
msg.needsTypewriterEffect = NO;
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (instancetype)assistantMessageWithText:(NSString *)text
|
||||||
|
audioId:(NSString *)audioId {
|
||||||
|
KBChatMessage *msg = [[KBChatMessage alloc] init];
|
||||||
|
msg.text = text ?: @"";
|
||||||
|
msg.outgoing = NO;
|
||||||
|
msg.audioId = audioId;
|
||||||
|
msg.isComplete = NO;
|
||||||
|
msg.isLoading = NO;
|
||||||
|
msg.needsTypewriterEffect = YES;
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (instancetype)loadingAssistantMessage {
|
||||||
|
KBChatMessage *msg = [[KBChatMessage alloc] init];
|
||||||
|
msg.text = @"";
|
||||||
|
msg.outgoing = NO;
|
||||||
|
msg.isComplete = NO;
|
||||||
|
msg.isLoading = YES;
|
||||||
|
msg.needsTypewriterEffect = NO;
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
100
CustomKeyboard/Model/KBKeyboardLayoutConfig.h
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
//
|
||||||
|
// KBKeyboardLayoutConfig.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// 键盘布局配置模型(由 JSON 驱动)
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@interface KBKeyboardLayoutMetrics : NSObject
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *rowSpacing;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *topInset;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *bottomInset;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *keyHeight;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *edgeInset;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *gap;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *letterWidth;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *controlWidth;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *sendWidth;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *symbolsWideWidth;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *symbolsSideWidth;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBKeyboardLayoutFonts : NSObject
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *letter;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *digit;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *symbol;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *mode;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *space;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *send;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBKeyboardKeyDef : NSObject
|
||||||
|
@property (nonatomic, copy, nullable) NSString *type;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *title;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *selectedTitle;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *symbolName;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *selectedSymbolName;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *font;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *width;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *widthValue;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *backgroundColor;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBKeyboardRowItem : NSObject
|
||||||
|
@property (nonatomic, copy, nullable) NSString *itemId;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *width;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *widthValue;
|
||||||
|
+ (NSArray<KBKeyboardRowItem *> *)itemsFromRawArray:(NSArray *)raw;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBKeyboardRowSegments : NSObject
|
||||||
|
@property (nonatomic, strong, nullable) NSArray *left;
|
||||||
|
@property (nonatomic, strong, nullable) NSArray *center;
|
||||||
|
@property (nonatomic, strong, nullable) NSArray *right;
|
||||||
|
- (NSArray<KBKeyboardRowItem *> *)leftItems;
|
||||||
|
- (NSArray<KBKeyboardRowItem *> *)centerItems;
|
||||||
|
- (NSArray<KBKeyboardRowItem *> *)rightItems;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBKeyboardRowConfig : NSObject
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *height;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *insetLeft;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *insetRight;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *gap;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *align;
|
||||||
|
@property (nonatomic, strong, nullable) NSArray *items;
|
||||||
|
@property (nonatomic, strong, nullable) KBKeyboardRowSegments *segments;
|
||||||
|
- (NSArray<KBKeyboardRowItem *> *)resolvedItems;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBKeyboardLayout : NSObject
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *rowSpacing;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *topInset;
|
||||||
|
@property (nonatomic, strong, nullable) NSNumber *bottomInset;
|
||||||
|
@property (nonatomic, strong, nullable) NSArray<KBKeyboardRowConfig *> *rows;
|
||||||
|
@property (nonatomic, strong, nullable) NSArray<KBKeyboardRowConfig *> *shiftRows;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBKeyboardLayoutConfig : NSObject
|
||||||
|
@property (nonatomic, assign) CGFloat designWidth;
|
||||||
|
@property (nonatomic, strong, nullable) KBKeyboardLayoutMetrics *metrics;
|
||||||
|
@property (nonatomic, strong, nullable) KBKeyboardLayoutFonts *fonts;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *defaultKeyBackground;
|
||||||
|
@property (nonatomic, strong, nullable) NSDictionary<NSString *, KBKeyboardKeyDef *> *keyDefs;
|
||||||
|
@property (nonatomic, strong, nullable) NSDictionary<NSString *, KBKeyboardLayout *> *layouts;
|
||||||
|
|
||||||
|
+ (nullable instancetype)sharedConfig;
|
||||||
|
+ (nullable instancetype)configFromJSONData:(NSData *)data;
|
||||||
|
- (CGFloat)scaledValue:(CGFloat)designValue;
|
||||||
|
- (CGFloat)keyboardAreaDesignHeight;
|
||||||
|
- (CGFloat)keyboardAreaScaledHeight;
|
||||||
|
- (nullable KBKeyboardLayout *)layoutForName:(NSString *)name;
|
||||||
|
- (nullable KBKeyboardKeyDef *)keyDefForIdentifier:(NSString *)identifier;
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
285
CustomKeyboard/Model/KBKeyboardLayoutConfig.m
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
//
|
||||||
|
// KBKeyboardLayoutConfig.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBKeyboardLayoutConfig.h"
|
||||||
|
#import <MJExtension/MJExtension.h>
|
||||||
|
#import "KBConfig.h"
|
||||||
|
|
||||||
|
static NSString * const kKBKeyboardLayoutConfigFileName = @"kb_keyboard_layout_config";
|
||||||
|
static NSString * const kKBKeyboardLayoutI18nFileName = @"kb_keyboard_layouts_i18n";
|
||||||
|
|
||||||
|
@implementation KBKeyboardLayoutMetrics
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBKeyboardLayoutFonts
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBKeyboardKeyDef
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBKeyboardRowItem
|
||||||
|
|
||||||
|
+ (NSDictionary *)mj_replacedKeyFromPropertyName {
|
||||||
|
return @{ @"itemId": @"id" };
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (NSArray<KBKeyboardRowItem *> *)itemsFromRawArray:(NSArray *)raw {
|
||||||
|
if (![raw isKindOfClass:[NSArray class]] || raw.count == 0) {
|
||||||
|
return @[];
|
||||||
|
}
|
||||||
|
NSMutableArray<KBKeyboardRowItem *> *items = [NSMutableArray arrayWithCapacity:raw.count];
|
||||||
|
for (id obj in raw) {
|
||||||
|
if ([obj isKindOfClass:[NSString class]]) {
|
||||||
|
KBKeyboardRowItem *item = [KBKeyboardRowItem new];
|
||||||
|
item.itemId = (NSString *)obj;
|
||||||
|
[items addObject:item];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ([obj isKindOfClass:[NSDictionary class]]) {
|
||||||
|
KBKeyboardRowItem *item = [KBKeyboardRowItem mj_objectWithKeyValues:obj];
|
||||||
|
if (item.itemId.length == 0) {
|
||||||
|
NSString *fallback = ((NSDictionary *)obj)[@"id"];
|
||||||
|
if ([fallback isKindOfClass:[NSString class]]) {
|
||||||
|
item.itemId = fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.itemId.length > 0) {
|
||||||
|
[items addObject:item];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items.copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBKeyboardRowSegments
|
||||||
|
|
||||||
|
- (NSArray<KBKeyboardRowItem *> *)leftItems {
|
||||||
|
return [KBKeyboardRowItem itemsFromRawArray:self.left ?: @[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<KBKeyboardRowItem *> *)centerItems {
|
||||||
|
return [KBKeyboardRowItem itemsFromRawArray:self.center ?: @[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<KBKeyboardRowItem *> *)rightItems {
|
||||||
|
return [KBKeyboardRowItem itemsFromRawArray:self.right ?: @[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBKeyboardRowConfig
|
||||||
|
|
||||||
|
- (NSArray<KBKeyboardRowItem *> *)resolvedItems {
|
||||||
|
return [KBKeyboardRowItem itemsFromRawArray:self.items ?: @[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBKeyboardLayout
|
||||||
|
|
||||||
|
+ (NSDictionary *)mj_objectClassInArray {
|
||||||
|
return @{ @"rows": [KBKeyboardRowConfig class], @"shiftRows": [KBKeyboardRowConfig class] };
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBKeyboardLayoutConfig
|
||||||
|
|
||||||
|
+ (instancetype)sharedConfig {
|
||||||
|
static KBKeyboardLayoutConfig *config = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
config = [[KBKeyboardLayoutConfig alloc] init];
|
||||||
|
[config kb_loadMainConfig];
|
||||||
|
[config kb_loadI18nConfig];
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_loadMainConfig {
|
||||||
|
NSString *path = [[NSBundle mainBundle] pathForResource:kKBKeyboardLayoutConfigFileName ofType:@"json"];
|
||||||
|
NSData *data = path.length ? [NSData dataWithContentsOfFile:path] : nil;
|
||||||
|
if (data.length == 0) { return; }
|
||||||
|
|
||||||
|
KBKeyboardLayoutConfig *mainConfig = [KBKeyboardLayoutConfig configFromJSONData:data];
|
||||||
|
if (mainConfig) {
|
||||||
|
self.metrics = mainConfig.metrics;
|
||||||
|
self.designWidth = mainConfig.designWidth;
|
||||||
|
self.keyDefs = mainConfig.keyDefs;
|
||||||
|
self.layouts = mainConfig.layouts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSArray<KBKeyboardRowConfig *> *)kb_mergeRowsFromBase:(NSArray<KBKeyboardRowConfig *> *)baseRows
|
||||||
|
override:(NSArray<KBKeyboardRowConfig *> *)overrideRows {
|
||||||
|
if (baseRows.count == 0) { return overrideRows ?: @[]; }
|
||||||
|
if (overrideRows.count == 0) { return baseRows; }
|
||||||
|
|
||||||
|
NSUInteger maxCount = MAX(baseRows.count, overrideRows.count);
|
||||||
|
NSMutableArray<KBKeyboardRowConfig *> *merged = [NSMutableArray arrayWithCapacity:maxCount];
|
||||||
|
for (NSUInteger i = 0; i < maxCount; i++) {
|
||||||
|
KBKeyboardRowConfig *baseRow = (i < baseRows.count) ? baseRows[i] : nil;
|
||||||
|
KBKeyboardRowConfig *overrideRow = (i < overrideRows.count) ? overrideRows[i] : nil;
|
||||||
|
if (!baseRow) {
|
||||||
|
if (overrideRow) { [merged addObject:overrideRow]; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!overrideRow) {
|
||||||
|
[merged addObject:baseRow];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
KBKeyboardRowConfig *row = [KBKeyboardRowConfig new];
|
||||||
|
row.height = baseRow.height ?: overrideRow.height;
|
||||||
|
row.insetLeft = baseRow.insetLeft ?: overrideRow.insetLeft;
|
||||||
|
row.insetRight = baseRow.insetRight ?: overrideRow.insetRight;
|
||||||
|
row.gap = baseRow.gap ?: overrideRow.gap;
|
||||||
|
row.align = baseRow.align.length > 0 ? baseRow.align : overrideRow.align;
|
||||||
|
BOOL hasOverrideItems = [overrideRow.items isKindOfClass:[NSArray class]] && ((NSArray *)overrideRow.items).count > 0;
|
||||||
|
row.items = hasOverrideItems ? overrideRow.items : baseRow.items;
|
||||||
|
row.segments = overrideRow.segments ?: baseRow.segments;
|
||||||
|
[merged addObject:row];
|
||||||
|
}
|
||||||
|
return merged.copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_loadI18nConfig {
|
||||||
|
NSString *path = [[NSBundle mainBundle] pathForResource:kKBKeyboardLayoutI18nFileName ofType:@"json"];
|
||||||
|
NSData *data = path.length ? [NSData dataWithContentsOfFile:path] : nil;
|
||||||
|
if (data.length == 0) {
|
||||||
|
NSLog(@"[KBKeyboardLayoutConfig] i18n layout file not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSError *error = nil;
|
||||||
|
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
|
||||||
|
if (error || ![json isKindOfClass:[NSDictionary class]]) {
|
||||||
|
NSLog(@"[KBKeyboardLayoutConfig] Failed to parse i18n layout file: %@", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDictionary *dict = (NSDictionary *)json;
|
||||||
|
NSDictionary *layoutsRaw = dict[@"layouts"];
|
||||||
|
if (![layoutsRaw isKindOfClass:[NSDictionary class]]) {
|
||||||
|
NSLog(@"[KBKeyboardLayoutConfig] No layouts found in i18n file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableDictionary<NSString *, KBKeyboardLayout *> *mergedLayouts = [NSMutableDictionary dictionaryWithDictionary:self.layouts ?: @{}];
|
||||||
|
|
||||||
|
[layoutsRaw enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
||||||
|
if (![key isKindOfClass:[NSString class]] || ![obj isKindOfClass:[NSDictionary class]]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KBKeyboardLayout *layout = [KBKeyboardLayout mj_objectWithKeyValues:obj];
|
||||||
|
if (!layout) { return; }
|
||||||
|
|
||||||
|
KBKeyboardLayout *baseLayout = mergedLayouts[key];
|
||||||
|
if (!baseLayout) {
|
||||||
|
mergedLayouts[key] = layout;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
KBKeyboardLayout *mergedLayout = [KBKeyboardLayout new];
|
||||||
|
mergedLayout.rowSpacing = baseLayout.rowSpacing ?: layout.rowSpacing;
|
||||||
|
mergedLayout.topInset = baseLayout.topInset ?: layout.topInset;
|
||||||
|
mergedLayout.bottomInset = baseLayout.bottomInset ?: layout.bottomInset;
|
||||||
|
mergedLayout.rows = [self kb_mergeRowsFromBase:baseLayout.rows override:layout.rows];
|
||||||
|
mergedLayout.shiftRows = [self kb_mergeRowsFromBase:baseLayout.shiftRows override:layout.shiftRows];
|
||||||
|
mergedLayouts[key] = mergedLayout;
|
||||||
|
}];
|
||||||
|
|
||||||
|
self.layouts = mergedLayouts.copy;
|
||||||
|
NSLog(@"[KBKeyboardLayoutConfig] Loaded %lu i18n layouts, total: %lu",
|
||||||
|
(unsigned long)layoutsRaw.count, (unsigned long)self.layouts.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (instancetype)configFromJSONData:(NSData *)data {
|
||||||
|
if (data.length == 0) { return nil; }
|
||||||
|
NSError *error = nil;
|
||||||
|
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
|
||||||
|
if (error || ![json isKindOfClass:[NSDictionary class]]) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
NSDictionary *dict = (NSDictionary *)json;
|
||||||
|
KBKeyboardLayoutConfig *config = [KBKeyboardLayoutConfig mj_objectWithKeyValues:dict];
|
||||||
|
|
||||||
|
NSDictionary *keyDefsRaw = dict[@"keyDefs"];
|
||||||
|
if ([keyDefsRaw isKindOfClass:[NSDictionary class]]) {
|
||||||
|
NSMutableDictionary<NSString *, KBKeyboardKeyDef *> *defs = [NSMutableDictionary dictionaryWithCapacity:keyDefsRaw.count];
|
||||||
|
[keyDefsRaw enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
||||||
|
if (![key isKindOfClass:[NSString class]] || ![obj isKindOfClass:[NSDictionary class]]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KBKeyboardKeyDef *def = [KBKeyboardKeyDef mj_objectWithKeyValues:obj];
|
||||||
|
if (def) {
|
||||||
|
defs[key] = def;
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
config.keyDefs = defs.copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDictionary *layoutsRaw = dict[@"layouts"];
|
||||||
|
if ([layoutsRaw isKindOfClass:[NSDictionary class]]) {
|
||||||
|
NSMutableDictionary<NSString *, KBKeyboardLayout *> *layouts = [NSMutableDictionary dictionaryWithCapacity:layoutsRaw.count];
|
||||||
|
[layoutsRaw enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
||||||
|
if (![key isKindOfClass:[NSString class]] || ![obj isKindOfClass:[NSDictionary class]]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KBKeyboardLayout *layout = [KBKeyboardLayout mj_objectWithKeyValues:obj];
|
||||||
|
if (layout) {
|
||||||
|
layouts[key] = layout;
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
config.layouts = layouts.copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)scaledValue:(CGFloat)designValue {
|
||||||
|
CGFloat baseWidth = (self.designWidth > 0.0) ? self.designWidth : KB_DESIGN_WIDTH;
|
||||||
|
CGFloat scale = KBScreenWidth() / baseWidth;
|
||||||
|
return designValue * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)keyboardAreaDesignHeight {
|
||||||
|
KBKeyboardLayout *layout = [self layoutForName:@"letters"] ?: self.layouts.allValues.firstObject;
|
||||||
|
NSUInteger rowCount = layout.rows.count;
|
||||||
|
if (rowCount == 0) { return 0.0; }
|
||||||
|
|
||||||
|
CGFloat rowSpacing = self.metrics.rowSpacing.doubleValue;
|
||||||
|
CGFloat topInset = self.metrics.topInset.doubleValue;
|
||||||
|
CGFloat bottomInset = self.metrics.bottomInset.doubleValue;
|
||||||
|
|
||||||
|
CGFloat total = topInset + bottomInset + rowSpacing * (rowCount - 1);
|
||||||
|
for (KBKeyboardRowConfig *row in layout.rows) {
|
||||||
|
CGFloat height = row.height.doubleValue;
|
||||||
|
if (height <= 0.0) {
|
||||||
|
height = self.metrics.keyHeight.doubleValue;
|
||||||
|
}
|
||||||
|
if (height <= 0.0) { height = 40.0; }
|
||||||
|
total += height;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)keyboardAreaScaledHeight {
|
||||||
|
CGFloat designHeight = [self keyboardAreaDesignHeight];
|
||||||
|
return designHeight > 0.0 ? [self scaledValue:designHeight] : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (KBKeyboardLayout *)layoutForName:(NSString *)name {
|
||||||
|
if (name.length == 0) { return nil; }
|
||||||
|
return self.layouts[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (KBKeyboardKeyDef *)keyDefForIdentifier:(NSString *)identifier {
|
||||||
|
if (identifier.length == 0) { return nil; }
|
||||||
|
return self.keyDefs[identifier];
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -64,6 +64,15 @@ typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data,
|
|||||||
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
||||||
completion:(KBNetworkCompletion)completion;
|
completion:(KBNetworkCompletion)completion;
|
||||||
|
|
||||||
|
/// POST multipart 上传文件(常用于语音/图片等文件)
|
||||||
|
- (nullable NSURLSessionDataTask *)uploadFile:(NSString *)path
|
||||||
|
fileURL:(NSURL *)fileURL
|
||||||
|
name:(NSString *)name
|
||||||
|
mimeType:(NSString *)mimeType
|
||||||
|
parameters:(nullable NSDictionary *)parameters
|
||||||
|
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
||||||
|
completion:(KBNetworkCompletion)completion;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|||||||
@@ -41,38 +41,10 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)getSignWithParare:(NSDictionary *)bodyParams{
|
- (void)getSignWithParare:(NSDictionary *)bodyParams{
|
||||||
|
NSDictionary<NSString *, NSString *> *signHeaders = [KBSignUtils signHeadersWithBodyParams:bodyParams];
|
||||||
NSString *appId = @"loveKeyboard";
|
|
||||||
NSString *secret = @"kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H"; // 和服务端保持一致
|
|
||||||
NSString *timestamp = [KBSignUtils currentTimestamp];
|
|
||||||
NSString *nonce = [KBSignUtils generateNonceWithLength:16];
|
|
||||||
// 1. 组装参与签名的所有参数
|
|
||||||
NSMutableDictionary<NSString *, NSString *> *signParams = [NSMutableDictionary dictionary];
|
|
||||||
signParams[@"appId"] = appId;
|
|
||||||
signParams[@"timestamp"] = timestamp;
|
|
||||||
signParams[@"nonce"] = nonce;
|
|
||||||
// 把 body 里的字段也加入签名参数
|
|
||||||
[bodyParams enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
|
||||||
if ([obj isKindOfClass:[NSString class]]) {
|
|
||||||
signParams[key] = obj;
|
|
||||||
} else {
|
|
||||||
signParams[key] = [obj description];
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
NSString *sign = [KBSignUtils signWithParams:signParams secret:secret];
|
|
||||||
|
|
||||||
// 将签名相关字段合并进默认请求头
|
|
||||||
NSMutableDictionary<NSString *, NSString *> *headers =
|
NSMutableDictionary<NSString *, NSString *> *headers =
|
||||||
[self.defaultHeaders mutableCopy] ?: [NSMutableDictionary dictionary];
|
[self.defaultHeaders mutableCopy] ?: [NSMutableDictionary dictionary];
|
||||||
|
[headers addEntriesFromDictionary:signHeaders ?: @{}];
|
||||||
if (sign.length > 0) {
|
|
||||||
headers[@"X-Sign"] = sign;
|
|
||||||
}
|
|
||||||
headers[@"X-App-Id"] = appId;
|
|
||||||
headers[@"X-Timestamp"] = timestamp;
|
|
||||||
headers[@"X-Nonce"] = nonce;
|
|
||||||
|
|
||||||
// 触发 copy 语义,确保对外仍是不可变字典
|
|
||||||
self.defaultHeaders = headers;
|
self.defaultHeaders = headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +96,84 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
|||||||
return [self startAFJSONTaskWithRequest:req completion:completion];
|
return [self startAFJSONTaskWithRequest:req completion:completion];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (NSURLSessionDataTask *)uploadFile:(NSString *)path
|
||||||
|
fileURL:(NSURL *)fileURL
|
||||||
|
name:(NSString *)name
|
||||||
|
mimeType:(NSString *)mimeType
|
||||||
|
parameters:(NSDictionary *)parameters
|
||||||
|
headers:(NSDictionary<NSString *, NSString *> *)headers
|
||||||
|
completion:(KBNetworkCompletion)completion {
|
||||||
|
[self getSignWithParare:parameters];
|
||||||
|
if (![self ensureEnabled:completion]) return nil;
|
||||||
|
NSString *urlString = [self buildURLStringWithPath:path];
|
||||||
|
if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
|
||||||
|
if (!fileURL) {
|
||||||
|
if (completion) completion(nil, nil, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid file")}]);
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
|
||||||
|
serializer.timeoutInterval = self.timeout;
|
||||||
|
NSError *error = nil;
|
||||||
|
NSMutableURLRequest *req = [serializer multipartFormRequestWithMethod:@"POST"
|
||||||
|
URLString:urlString
|
||||||
|
parameters:parameters
|
||||||
|
constructingBodyWithBlock:^(id<AFMultipartFormData> formData) {
|
||||||
|
NSString *safeName = (name.length > 0) ? name : @"file";
|
||||||
|
NSString *fileName = fileURL.lastPathComponent ?: @"upload.bin";
|
||||||
|
NSString *type = (mimeType.length > 0) ? mimeType : @"application/octet-stream";
|
||||||
|
[formData appendPartWithFileURL:fileURL name:safeName fileName:fileName mimeType:type error:nil];
|
||||||
|
} error:&error];
|
||||||
|
if (error || !req) {
|
||||||
|
if (completion) completion(nil, nil, error ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]);
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
[self applyHeaders:headers toMutableRequest:req contentType:nil];
|
||||||
|
self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
|
||||||
|
NSURLSessionUploadTask *task = [self.manager uploadTaskWithStreamedRequest:req progress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
|
||||||
|
if (error) {
|
||||||
|
if (completion) completion(nil, response, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSData *data = (NSData *)responseObject;
|
||||||
|
if (![data isKindOfClass:[NSData class]]) {
|
||||||
|
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSString *ct = nil;
|
||||||
|
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
|
||||||
|
ct = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Type"];
|
||||||
|
}
|
||||||
|
BOOL looksJSON = (ct && [[ct lowercaseString] containsString:@"json"]);
|
||||||
|
if (!looksJSON) {
|
||||||
|
const unsigned char *bytes = data.bytes;
|
||||||
|
NSUInteger len = data.length;
|
||||||
|
for (NSUInteger i = 0; !looksJSON && i < len; i++) {
|
||||||
|
unsigned char c = bytes[i];
|
||||||
|
if (c == ' ' || c == '\n' || c == '\r' || c == '\t') continue;
|
||||||
|
looksJSON = (c == '{' || c == '[');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (looksJSON) {
|
||||||
|
NSError *jsonErr = nil;
|
||||||
|
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr];
|
||||||
|
if (jsonErr) {
|
||||||
|
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Failed to parse JSON")}]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (![json isKindOfClass:[NSDictionary class]]) {
|
||||||
|
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (completion) completion((NSDictionary *)json, response, nil);
|
||||||
|
} else {
|
||||||
|
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
[task resume];
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
- (NSURLSessionDataTask *)GETData:(NSString *)path
|
- (NSURLSessionDataTask *)GETData:(NSString *)path
|
||||||
parameters:(NSDictionary *)parameters
|
parameters:(NSDictionary *)parameters
|
||||||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
headers:(NSDictionary<NSString *,NSString *> *)headers
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// - 兼容后端“/t”作为分段标记:可自动替换为制表符“\t”
|
// - 兼容后端“/t”作为分段标记:可自动替换为制表符“\t”
|
||||||
// - 首段去首个“\t”:若首次正文以一个制表符起始(允许前导空白),可只移除“一个”\t
|
// - 首段去首个“\t”:若首次正文以一个制表符起始(允许前导空白),可只移除“一个”\t
|
||||||
//
|
//
|
||||||
|
// 暂未使用
|
||||||
#import <Foundation/Foundation.h>
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|||||||
@@ -225,7 +225,10 @@ static NSString * const kKBStreamSplitToken = @"<SPLIT>";
|
|||||||
}
|
}
|
||||||
if (payload.length > 0) {
|
if (payload.length > 0) {
|
||||||
if (self.loggingEnabled) {
|
if (self.loggingEnabled) {
|
||||||
NSLog(@"[KBStream] SSE raw payload: %@", payload);
|
#if DEBUG
|
||||||
|
NSLog(@"[KBStream] SSE raw payload len=%lu",
|
||||||
|
(unsigned long)(payload ?: @"").length);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
NSString *llmText = nil;
|
NSString *llmText = nil;
|
||||||
if ([self processLLMChunkPayload:payload output:&llmText]) {
|
if ([self processLLMChunkPayload:payload output:&llmText]) {
|
||||||
@@ -278,7 +281,10 @@ static NSString * const kKBStreamSplitToken = @"<SPLIT>";
|
|||||||
}
|
}
|
||||||
if (payload.length > 0) {
|
if (payload.length > 0) {
|
||||||
if (self.loggingEnabled) {
|
if (self.loggingEnabled) {
|
||||||
NSLog(@"[KBStream] SSE raw payload: %@", payload);
|
#if DEBUG
|
||||||
|
NSLog(@"[KBStream] SSE raw payload len=%lu",
|
||||||
|
(unsigned long)(payload ?: @"").length);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
NSString *delta = nil;
|
NSString *delta = nil;
|
||||||
if ((NSInteger)payload.length >= self.deliveredCharCount) {
|
if ((NSInteger)payload.length >= self.deliveredCharCount) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// Created by Mac on 2025/11/12.
|
// Created by Mac on 2025/11/12.
|
||||||
//
|
//
|
||||||
|
// 暂未使用
|
||||||
#import <Foundation/Foundation.h>
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
#import "NetworkStreamHandler.h"
|
#import "NetworkStreamHandler.h"
|
||||||
|
#import <Security/Security.h>
|
||||||
|
#import "KBLocalizationManager.h"
|
||||||
|
|
||||||
@interface NetworkStreamHandler ()
|
@interface NetworkStreamHandler ()
|
||||||
|
|
||||||
@@ -100,7 +102,11 @@
|
|||||||
// 设置常见的请求头(根据您的截图)
|
// 设置常见的请求头(根据您的截图)
|
||||||
[request setValue:@"text/html, application/xhtml+xml, application/xml; q=0.9, image/avif, image/webp, image/apng, */*; q=0.8, application/signed-exchange; v=b3; q=0.7" forHTTPHeaderField:@"Accept"];
|
[request setValue:@"text/html, application/xhtml+xml, application/xml; q=0.9, image/avif, image/webp, image/apng, */*; q=0.8, application/signed-exchange; v=b3; q=0.7" forHTTPHeaderField:@"Accept"];
|
||||||
[request setValue:@"gzip, deflate" forHTTPHeaderField:@"Accept-Encoding"];
|
[request setValue:@"gzip, deflate" forHTTPHeaderField:@"Accept-Encoding"];
|
||||||
[request setValue:@"zh-CN, zh; q=0.9, ko; q=0.8, ja; q=0.7" forHTTPHeaderField:@"Accept-Language"];
|
NSString *lang = [[KBLocalizationManager shared] currentLanguageHeaderValue];
|
||||||
|
if (lang.length == 0) {
|
||||||
|
lang = @"en";
|
||||||
|
}
|
||||||
|
[request setValue:lang forHTTPHeaderField:@"Accept-Language"];
|
||||||
[request setValue:@"keep-alive" forHTTPHeaderField:@"Connection"];
|
[request setValue:@"keep-alive" forHTTPHeaderField:@"Connection"];
|
||||||
[request setValue:@"1" forHTTPHeaderField:@"Upgrade-Insecure-Requests"];
|
[request setValue:@"1" forHTTPHeaderField:@"Upgrade-Insecure-Requests"];
|
||||||
|
|
||||||
@@ -243,8 +249,26 @@ didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
|
|||||||
|
|
||||||
// 处理 SSL 认证挑战
|
// 处理 SSL 认证挑战
|
||||||
if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
|
if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
|
||||||
NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
|
SecTrustRef trust = challenge.protectionSpace.serverTrust;
|
||||||
completionHandler(NSURLSessionAuthChallengeUseCredential, credential);
|
if (!trust) {
|
||||||
|
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
BOOL ok = NO;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
ok = SecTrustEvaluateWithError(trust, nil);
|
||||||
|
} else {
|
||||||
|
SecTrustResultType result = kSecTrustResultInvalid;
|
||||||
|
OSStatus status = SecTrustEvaluate(trust, &result);
|
||||||
|
ok = (status == errSecSuccess) &&
|
||||||
|
(result == kSecTrustResultUnspecified || result == kSecTrustResultProceed);
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
NSURLCredential *credential = [NSURLCredential credentialForTrust:trust];
|
||||||
|
completionHandler(NSURLSessionAuthChallengeUseCredential, credential);
|
||||||
|
} else {
|
||||||
|
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
|
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
#import "Masonry.h"
|
#import "Masonry.h"
|
||||||
#import "KBHUD.h" // 复用 App 内的 HUD 封装
|
#import "KBHUD.h" // 复用 App 内的 HUD 封装
|
||||||
#import "KBLocalizationManager.h" // 复用多语言封装(可在扩展内使用)
|
#import "KBLocalizationManager.h" // 复用多语言封装(可在扩展内使用)
|
||||||
|
#import "KBMaiPointReporter.h"
|
||||||
|
//#import "KBLog.h"
|
||||||
|
|
||||||
|
|
||||||
// 通用链接(Universal Links)统一配置
|
// 通用链接(Universal Links)统一配置
|
||||||
// 配置好 AASA 与 Associated Domains 后,只需修改这里即可切换域名/path。
|
// 配置好 AASA 与 Associated Domains 后,只需修改这里即可切换域名/path。
|
||||||
@@ -28,10 +31,16 @@
|
|||||||
#define KB_UL_LOGIN KB_UL_BASE @"/login"
|
#define KB_UL_LOGIN KB_UL_BASE @"/login"
|
||||||
#define KB_UL_SETTINGS KB_UL_BASE @"/settings"
|
#define KB_UL_SETTINGS KB_UL_BASE @"/settings"
|
||||||
|
|
||||||
// 在扩展内,启用 URL Bridge(仅在显式的用户点击动作中使用)
|
// 说明:
|
||||||
// 这样即便宿主 App(如备忘录)拒绝 extensionContext 的 openURL,仍可通过响应链兜底拉起容器 App。
|
// - `extensionContext openURL:` 是 Apple 官方推荐方式,但部分宿主 App(尤其是“B 类应用”)
|
||||||
|
// 可能会拦截该调用,导致无法直接唤起容器 App;
|
||||||
|
// 如你要走更稳妥的上架策略:把该宏改为 0(仅保留 extensionContext 方案)。
|
||||||
#ifndef KB_URL_BRIDGE_ENABLE
|
#ifndef KB_URL_BRIDGE_ENABLE
|
||||||
|
#if DEBUG
|
||||||
#define KB_URL_BRIDGE_ENABLE 1
|
#define KB_URL_BRIDGE_ENABLE 1
|
||||||
|
#else
|
||||||
|
#define KB_URL_BRIDGE_ENABLE 1
|
||||||
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
77
CustomKeyboard/PrivacyInfo.xcprivacy
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeUserID</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeOtherUserContent</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeProductInteraction</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>CA92.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryActiveKeyboards</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>3EC4.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>C617.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
/* 字母 g(小写) */
|
/* 字母 g(小写) */
|
||||||
"letter_g_lower" = "key_g";
|
"letter_g_lower" = "key_g";
|
||||||
/* 字母 G(大写) */
|
/* 字母 G(大写) */
|
||||||
"letter_g_upper" = "key_f_up";
|
"letter_g_upper" = "key_g_up";
|
||||||
|
|
||||||
/* 字母 h(小写) */
|
/* 字母 h(小写) */
|
||||||
"letter_h_lower" = "key_h";
|
"letter_h_lower" = "key_h";
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
/* 自定义 AI 功能键 */
|
/* 自定义 AI 功能键 */
|
||||||
"ai" = "key_ai";
|
"ai" = "key_ai";
|
||||||
/* Emoji功能键 */
|
/* Emoji功能键 */
|
||||||
"emoji" = "key_emoji";
|
//"emoji" = "key_emoji";
|
||||||
|
"emoji_panel" = "key_emoji";
|
||||||
/* 发送/换行键 */
|
/* 发送/换行键 */
|
||||||
"return" = "key_send";
|
"return" = "key_send";
|
||||||
|
|
||||||
|
|||||||
262
CustomKeyboard/Resource/KBSkinIconMap_es.strings
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
/* 西班牙语(拉丁美洲)键盘皮肤映射 */
|
||||||
|
/* Spanish (Latin America) Keyboard Skin Icon Map */
|
||||||
|
|
||||||
|
/* 字母 q(小写) */
|
||||||
|
"letter_q_lower" = "key_q";
|
||||||
|
/* 字母 Q(大写) */
|
||||||
|
"letter_q_upper" = "key_q_up";
|
||||||
|
|
||||||
|
/* 字母 w(小写) */
|
||||||
|
"letter_w_lower" = "key_w";
|
||||||
|
/* 字母 W(大写) */
|
||||||
|
"letter_w_upper" = "key_w_up";
|
||||||
|
|
||||||
|
/* 字母 e(小写) */
|
||||||
|
"letter_e_lower" = "key_e";
|
||||||
|
/* 字母 E(大写) */
|
||||||
|
"letter_e_upper" = "key_e_up";
|
||||||
|
|
||||||
|
/* 字母 r(小写) */
|
||||||
|
"letter_r_lower" = "key_r";
|
||||||
|
/* 字母 R(大写) */
|
||||||
|
"letter_r_upper" = "key_r_up";
|
||||||
|
|
||||||
|
/* 字母 t(小写) */
|
||||||
|
"letter_t_lower" = "key_t";
|
||||||
|
/* 字母 T(大写) */
|
||||||
|
"letter_t_upper" = "key_t_up";
|
||||||
|
|
||||||
|
/* 字母 y(小写) */
|
||||||
|
"letter_y_lower" = "key_y";
|
||||||
|
/* 字母 Y(大写) */
|
||||||
|
"letter_y_upper" = "key_y_up";
|
||||||
|
|
||||||
|
/* 字母 u(小写) */
|
||||||
|
"letter_u_lower" = "key_u";
|
||||||
|
/* 字母 U(大写) */
|
||||||
|
"letter_u_upper" = "key_u_up";
|
||||||
|
|
||||||
|
/* 字母 i(小写) */
|
||||||
|
"letter_i_lower" = "key_i";
|
||||||
|
/* 字母 I(大写) */
|
||||||
|
"letter_i_upper" = "key_i_up";
|
||||||
|
|
||||||
|
/* 字母 o(小写) */
|
||||||
|
"letter_o_lower" = "key_o";
|
||||||
|
/* 字母 O(大写) */
|
||||||
|
"letter_o_upper" = "key_o_up";
|
||||||
|
|
||||||
|
/* 字母 p(小写) */
|
||||||
|
"letter_p_lower" = "key_p";
|
||||||
|
/* 字母 P(大写) */
|
||||||
|
"letter_p_upper" = "key_p_up";
|
||||||
|
|
||||||
|
/* 字母 a(小写) */
|
||||||
|
"letter_a_lower" = "key_a";
|
||||||
|
/* 字母 A(大写) */
|
||||||
|
"letter_a_upper" = "key_a_up";
|
||||||
|
|
||||||
|
/* 字母 s(小写) */
|
||||||
|
"letter_s_lower" = "key_s";
|
||||||
|
/* 字母 S(大写) */
|
||||||
|
"letter_s_upper" = "key_s_up";
|
||||||
|
|
||||||
|
/* 字母 d(小写) */
|
||||||
|
"letter_d_lower" = "key_d";
|
||||||
|
/* 字母 D(大写) */
|
||||||
|
"letter_d_upper" = "key_d_up";
|
||||||
|
|
||||||
|
/* 字母 f(小写) */
|
||||||
|
"letter_f_lower" = "key_f";
|
||||||
|
/* 字母 F(大写) */
|
||||||
|
"letter_f_upper" = "key_f_up";
|
||||||
|
|
||||||
|
/* 字母 g(小写) */
|
||||||
|
"letter_g_lower" = "key_g";
|
||||||
|
/* 字母 G(大写) */
|
||||||
|
"letter_g_upper" = "key_g_up";
|
||||||
|
|
||||||
|
/* 字母 h(小写) */
|
||||||
|
"letter_h_lower" = "key_h";
|
||||||
|
/* 字母 H(大写) */
|
||||||
|
"letter_h_upper" = "key_h_up";
|
||||||
|
|
||||||
|
/* 字母 j(小写) */
|
||||||
|
"letter_j_lower" = "key_j";
|
||||||
|
/* 字母 J(大写) */
|
||||||
|
"letter_j_upper" = "key_j_up";
|
||||||
|
|
||||||
|
/* 字母 k(小写) */
|
||||||
|
"letter_k_lower" = "key_k";
|
||||||
|
/* 字母 K(大写) */
|
||||||
|
"letter_k_upper" = "key_k_up";
|
||||||
|
|
||||||
|
/* 字母 l(小写) */
|
||||||
|
"letter_l_lower" = "key_l";
|
||||||
|
/* 字母 L(大写) */
|
||||||
|
"letter_l_upper" = "key_l_up";
|
||||||
|
|
||||||
|
/* 字母 ñ(小写)- 西班牙语专用 */
|
||||||
|
"letter_ñ_lower" = "key_ñ";
|
||||||
|
/* 字母 Ñ(大写)- 西班牙语专用 */
|
||||||
|
"letter_ñ_upper" = "key_ñ_up";
|
||||||
|
/* 字母 ñ(基础映射) */
|
||||||
|
"letter_ñ" = "key_ñ";
|
||||||
|
|
||||||
|
/* 字母 z(小写) */
|
||||||
|
"letter_z_lower" = "key_z";
|
||||||
|
/* 字母 Z(大写) */
|
||||||
|
"letter_z_upper" = "key_z_up";
|
||||||
|
|
||||||
|
/* 字母 x(小写) */
|
||||||
|
"letter_x_lower" = "key_x";
|
||||||
|
/* 字母 X(大写) */
|
||||||
|
"letter_x_upper" = "key_x_up";
|
||||||
|
|
||||||
|
/* 字母 c(小写) */
|
||||||
|
"letter_c_lower" = "key_c";
|
||||||
|
/* 字母 C(大写) */
|
||||||
|
"letter_c_upper" = "key_c_up";
|
||||||
|
|
||||||
|
/* 字母 v(小写) */
|
||||||
|
"letter_v_lower" = "key_v";
|
||||||
|
/* 字母 V(大写) */
|
||||||
|
"letter_v_upper" = "key_v_up";
|
||||||
|
|
||||||
|
/* 字母 b(小写) */
|
||||||
|
"letter_b_lower" = "key_b";
|
||||||
|
/* 字母 B(大写) */
|
||||||
|
"letter_b_upper" = "key_b_up";
|
||||||
|
|
||||||
|
/* 字母 n(小写) */
|
||||||
|
"letter_n_lower" = "key_n";
|
||||||
|
/* 字母 N(大写) */
|
||||||
|
"letter_n_upper" = "key_n_up";
|
||||||
|
|
||||||
|
/* 字母 m(小写) */
|
||||||
|
"letter_m_lower" = "key_m";
|
||||||
|
/* 字母 M(大写) */
|
||||||
|
"letter_m_upper" = "key_m_up";
|
||||||
|
|
||||||
|
/* 数字 1 */
|
||||||
|
"digit_1" = "key_1";
|
||||||
|
/* 数字 2 */
|
||||||
|
"digit_2" = "key_2";
|
||||||
|
/* 数字 3 */
|
||||||
|
"digit_3" = "key_3";
|
||||||
|
/* 数字 4 */
|
||||||
|
"digit_4" = "key_4";
|
||||||
|
/* 数字 5 */
|
||||||
|
"digit_5" = "key_5";
|
||||||
|
/* 数字 6 */
|
||||||
|
"digit_6" = "key_6";
|
||||||
|
/* 数字 7 */
|
||||||
|
"digit_7" = "key_7";
|
||||||
|
/* 数字 8 */
|
||||||
|
"digit_8" = "key_8";
|
||||||
|
/* 数字 9 */
|
||||||
|
"digit_9" = "key_9";
|
||||||
|
/* 数字 0 */
|
||||||
|
"digit_0" = "key_0";
|
||||||
|
|
||||||
|
/* '-' */
|
||||||
|
"sym_minus" = "key_minus";
|
||||||
|
/* '/' */
|
||||||
|
"sym_slash" = "key_slash";
|
||||||
|
/* ':' */
|
||||||
|
"sym_colon" = "key_colon";
|
||||||
|
/* ';' */
|
||||||
|
"sym_semicolon" = "key_semicolon";
|
||||||
|
/* '(' */
|
||||||
|
"sym_paren_l" = "key_paren_l";
|
||||||
|
/* ')' */
|
||||||
|
"sym_paren_r" = "key_paren_r";
|
||||||
|
/* '$' */
|
||||||
|
"sym_dollar" = "key_dollar";
|
||||||
|
/* '&' */
|
||||||
|
"sym_amp" = "key_amp";
|
||||||
|
/* '@' */
|
||||||
|
"sym_at" = "key_at";
|
||||||
|
/* 双引号 " */
|
||||||
|
"sym_quote_double" = "key_quote_d";
|
||||||
|
|
||||||
|
/* ',' */
|
||||||
|
"sym_comma" = "key_comma";
|
||||||
|
/* '.' */
|
||||||
|
"sym_dot" = "key_dot";
|
||||||
|
/* '?' */
|
||||||
|
"sym_question" = "key_question";
|
||||||
|
/* '!' */
|
||||||
|
"sym_exclam" = "key_exclam";
|
||||||
|
/* 单引号 ' */
|
||||||
|
"sym_quote_single" = "key_quote";
|
||||||
|
|
||||||
|
/* '¿' - 西班牙语专用 */
|
||||||
|
"sym_question_inv" = "key_question_inv";
|
||||||
|
/* '¡' - 西班牙语专用 */
|
||||||
|
"sym_exclam_inv" = "key_exclam_inv";
|
||||||
|
|
||||||
|
/* '[' */
|
||||||
|
"sym_bracket_l" = "key_bracket_l";
|
||||||
|
/* ']' */
|
||||||
|
"sym_bracket_r" = "key_bracket_r";
|
||||||
|
/* '{' */
|
||||||
|
"sym_brace_l" = "key_brace_l";
|
||||||
|
/* '}' */
|
||||||
|
"sym_brace_r" = "key_brace_r";
|
||||||
|
/* '#' */
|
||||||
|
"sym_hash" = "key_hash";
|
||||||
|
/* '%' */
|
||||||
|
"sym_percent" = "key_percent";
|
||||||
|
/* '^' */
|
||||||
|
"sym_caret" = "key_caret";
|
||||||
|
/* '*' */
|
||||||
|
"sym_asterisk" = "key_asterisk";
|
||||||
|
/* '+' */
|
||||||
|
"sym_plus" = "key_plus";
|
||||||
|
/* '=' */
|
||||||
|
"sym_equal" = "key_equal";
|
||||||
|
|
||||||
|
/* '_' */
|
||||||
|
"sym_underscore" = "key_underscore";
|
||||||
|
/* '\' */
|
||||||
|
"sym_backslash" = "key_backslash";
|
||||||
|
/* '|' */
|
||||||
|
"sym_pipe" = "key_pipe";
|
||||||
|
/* '~' */
|
||||||
|
"sym_tilde" = "key_tilde";
|
||||||
|
/* '<' */
|
||||||
|
"sym_lt" = "key_lt";
|
||||||
|
/* '>' */
|
||||||
|
"sym_gt" = "key_gt";
|
||||||
|
/* '¥' */
|
||||||
|
"sym_money" = "key_money";
|
||||||
|
/* '€' */
|
||||||
|
"sym_euro" = "key_euro";
|
||||||
|
/* '£' */
|
||||||
|
"sym_pound" = "key_pound";
|
||||||
|
/* '•' */
|
||||||
|
"sym_bullet" = "key_bullet";
|
||||||
|
|
||||||
|
/* 空格键 */
|
||||||
|
"space" = "key_space";
|
||||||
|
/* 删除键(⌫) */
|
||||||
|
"backspace" = "key_del";
|
||||||
|
/* Shift(⇧) */
|
||||||
|
"shift" = "key_up";
|
||||||
|
/* Shift(⇧)大写 */
|
||||||
|
"shift_upper" = "key_up_upper";
|
||||||
|
/* 字母面板左下角 "123" */
|
||||||
|
"mode_123" = "key_123";
|
||||||
|
/* 数字面板左下角 "abc" */
|
||||||
|
"mode_abc" = "key_abc";
|
||||||
|
/* 数字面板内 "123 -> #+=" */
|
||||||
|
"symbols_toggle_more" = "key_symbols_more";
|
||||||
|
/* 数字面板内 "#+= -> 123" */
|
||||||
|
"symbols_toggle_123" = "key_symbols_123";
|
||||||
|
/* 自定义 AI 功能键 */
|
||||||
|
"ai" = "key_ai";
|
||||||
|
/* Emoji功能键 */
|
||||||
|
"emoji_panel" = "key_emoji";
|
||||||
|
/* 发送/换行键 */
|
||||||
|
"return" = "key_send";
|
||||||
@@ -1,132 +1,135 @@
|
|||||||
|
/* 印尼语键盘皮肤映射 */
|
||||||
|
/* Indonesian Keyboard Skin Icon Map */
|
||||||
|
|
||||||
/* 字母 q(小写) */
|
/* 字母 q(小写) */
|
||||||
"letter_q_lower" = "key_q";
|
"letter_q_lower" = "key_q";
|
||||||
/* 字母 Q(大写) */
|
/* 字母 Q(大写) */
|
||||||
"letter_q_upper" = "key_q";
|
"letter_q_upper" = "key_q_up";
|
||||||
|
|
||||||
/* 字母 w(小写) */
|
/* 字母 w(小写) */
|
||||||
"letter_w_lower" = "key_w";
|
"letter_w_lower" = "key_w";
|
||||||
/* 字母 W(大写) */
|
/* 字母 W(大写) */
|
||||||
"letter_w_upper" = "key_w";
|
"letter_w_upper" = "key_w_up";
|
||||||
|
|
||||||
/* 字母 e(小写) */
|
/* 字母 e(小写) */
|
||||||
"letter_e_lower" = "key_e";
|
"letter_e_lower" = "key_e";
|
||||||
/* 字母 E(大写) */
|
/* 字母 E(大写) */
|
||||||
"letter_e_upper" = "key_e";
|
"letter_e_upper" = "key_e_up";
|
||||||
|
|
||||||
/* 字母 r(小写) */
|
/* 字母 r(小写) */
|
||||||
"letter_r_lower" = "key_r";
|
"letter_r_lower" = "key_r";
|
||||||
/* 字母 R(大写) */
|
/* 字母 R(大写) */
|
||||||
"letter_r_upper" = "key_r";
|
"letter_r_upper" = "key_r_up";
|
||||||
|
|
||||||
/* 字母 t(小写) */
|
/* 字母 t(小写) */
|
||||||
"letter_t_lower" = "key_t";
|
"letter_t_lower" = "key_t";
|
||||||
/* 字母 T(大写) */
|
/* 字母 T(大写) */
|
||||||
"letter_t_upper" = "key_t";
|
"letter_t_upper" = "key_t_up";
|
||||||
|
|
||||||
/* 字母 y(小写) */
|
/* 字母 y(小写) */
|
||||||
"letter_y_lower" = "key_y";
|
"letter_y_lower" = "key_y";
|
||||||
/* 字母 Y(大写) */
|
/* 字母 Y(大写) */
|
||||||
"letter_y_upper" = "key_y";
|
"letter_y_upper" = "key_y_up";
|
||||||
|
|
||||||
/* 字母 u(小写) */
|
/* 字母 u(小写) */
|
||||||
"letter_u_lower" = "key_u";
|
"letter_u_lower" = "key_u";
|
||||||
/* 字母 U(大写) */
|
/* 字母 U(大写) */
|
||||||
"letter_u_upper" = "key_u";
|
"letter_u_upper" = "key_u_up";
|
||||||
|
|
||||||
/* 字母 i(小写) */
|
/* 字母 i(小写) */
|
||||||
"letter_i_lower" = "key_i";
|
"letter_i_lower" = "key_i";
|
||||||
/* 字母 I(大写) */
|
/* 字母 I(大写) */
|
||||||
"letter_i_upper" = "key_i";
|
"letter_i_upper" = "key_i_up";
|
||||||
|
|
||||||
/* 字母 o(小写) */
|
/* 字母 o(小写) */
|
||||||
"letter_o_lower" = "key_o";
|
"letter_o_lower" = "key_o";
|
||||||
/* 字母 O(大写) */
|
/* 字母 O(大写) */
|
||||||
"letter_o_upper" = "key_o";
|
"letter_o_upper" = "key_o_up";
|
||||||
|
|
||||||
/* 字母 p(小写) */
|
/* 字母 p(小写) */
|
||||||
"letter_p_lower" = "key_p";
|
"letter_p_lower" = "key_p";
|
||||||
/* 字母 P(大写) */
|
/* 字母 P(大写) */
|
||||||
"letter_p_upper" = "key_p";
|
"letter_p_upper" = "key_p_up";
|
||||||
|
|
||||||
/* 字母 a(小写) */
|
/* 字母 a(小写) */
|
||||||
"letter_a_lower" = "key_a";
|
"letter_a_lower" = "key_a";
|
||||||
/* 字母 A(大写) */
|
/* 字母 A(大写) */
|
||||||
"letter_a_upper" = "key_a";
|
"letter_a_upper" = "key_a_up";
|
||||||
|
|
||||||
/* 字母 s(小写) */
|
/* 字母 s(小写) */
|
||||||
"letter_s_lower" = "key_s";
|
"letter_s_lower" = "key_s";
|
||||||
/* 字母 S(大写) */
|
/* 字母 S(大写) */
|
||||||
"letter_s_upper" = "key_s";
|
"letter_s_upper" = "key_s_up";
|
||||||
|
|
||||||
/* 字母 d(小写) */
|
/* 字母 d(小写) */
|
||||||
"letter_d_lower" = "key_d";
|
"letter_d_lower" = "key_d";
|
||||||
/* 字母 D(大写) */
|
/* 字母 D(大写) */
|
||||||
"letter_d_upper" = "key_d";
|
"letter_d_upper" = "key_d_up";
|
||||||
|
|
||||||
/* 字母 f(小写) */
|
/* 字母 f(小写) */
|
||||||
"letter_f_lower" = "key_f";
|
"letter_f_lower" = "key_f";
|
||||||
/* 字母 F(大写) */
|
/* 字母 F(大写) */
|
||||||
"letter_f_upper" = "key_f";
|
"letter_f_upper" = "key_f_up";
|
||||||
|
|
||||||
/* 字母 g(小写) */
|
/* 字母 g(小写) */
|
||||||
"letter_g_lower" = "key_g";
|
"letter_g_lower" = "key_g";
|
||||||
/* 字母 G(大写) */
|
/* 字母 G(大写) */
|
||||||
"letter_g_upper" = "key_g";
|
"letter_g_upper" = "key_g_up";
|
||||||
|
|
||||||
/* 字母 h(小写) */
|
/* 字母 h(小写) */
|
||||||
"letter_h_lower" = "key_h";
|
"letter_h_lower" = "key_h";
|
||||||
/* 字母 H(大写) */
|
/* 字母 H(大写) */
|
||||||
"letter_h_upper" = "key_h";
|
"letter_h_upper" = "key_h_up";
|
||||||
|
|
||||||
/* 字母 j(小写) */
|
/* 字母 j(小写) */
|
||||||
"letter_j_lower" = "key_j";
|
"letter_j_lower" = "key_j";
|
||||||
/* 字母 J(大写) */
|
/* 字母 J(大写) */
|
||||||
"letter_j_upper" = "key_j";
|
"letter_j_upper" = "key_j_up";
|
||||||
|
|
||||||
/* 字母 k(小写) */
|
/* 字母 k(小写) */
|
||||||
"letter_k_lower" = "key_k";
|
"letter_k_lower" = "key_k";
|
||||||
/* 字母 K(大写) */
|
/* 字母 K(大写) */
|
||||||
"letter_k_upper" = "key_k";
|
"letter_k_upper" = "key_k_up";
|
||||||
|
|
||||||
/* 字母 l(小写) */
|
/* 字母 l(小写) */
|
||||||
"letter_l_lower" = "key_l";
|
"letter_l_lower" = "key_l";
|
||||||
/* 字母 L(大写) */
|
/* 字母 L(大写) */
|
||||||
"letter_l_upper" = "key_l";
|
"letter_l_upper" = "key_l_up";
|
||||||
|
|
||||||
/* 字母 z(小写) */
|
/* 字母 z(小写) */
|
||||||
"letter_z_lower" = "key_z";
|
"letter_z_lower" = "key_z";
|
||||||
/* 字母 Z(大写) */
|
/* 字母 Z(大写) */
|
||||||
"letter_z_upper" = "key_z";
|
"letter_z_upper" = "key_z_up";
|
||||||
|
|
||||||
/* 字母 x(小写) */
|
/* 字母 x(小写) */
|
||||||
"letter_x_lower" = "key_x";
|
"letter_x_lower" = "key_x";
|
||||||
/* 字母 X(大写) */
|
/* 字母 X(大写) */
|
||||||
"letter_x_upper" = "key_x";
|
"letter_x_upper" = "key_x_up";
|
||||||
|
|
||||||
/* 字母 c(小写) */
|
/* 字母 c(小写) */
|
||||||
"letter_c_lower" = "key_c";
|
"letter_c_lower" = "key_c";
|
||||||
/* 字母 C(大写) */
|
/* 字母 C(大写) */
|
||||||
"letter_c_upper" = "key_c";
|
"letter_c_upper" = "key_c_up";
|
||||||
|
|
||||||
/* 字母 v(小写) */
|
/* 字母 v(小写) */
|
||||||
"letter_v_lower" = "key_v";
|
"letter_v_lower" = "key_v";
|
||||||
/* 字母 V(大写) */
|
/* 字母 V(大写) */
|
||||||
"letter_v_upper" = "key_v";
|
"letter_v_upper" = "key_v_up";
|
||||||
|
|
||||||
/* 字母 b(小写) */
|
/* 字母 b(小写) */
|
||||||
"letter_b_lower" = "key_b";
|
"letter_b_lower" = "key_b";
|
||||||
/* 字母 B(大写) */
|
/* 字母 B(大写) */
|
||||||
"letter_b_upper" = "key_b";
|
"letter_b_upper" = "key_b_up";
|
||||||
|
|
||||||
/* 字母 n(小写) */
|
/* 字母 n(小写) */
|
||||||
"letter_n_lower" = "key_n";
|
"letter_n_lower" = "key_n";
|
||||||
/* 字母 N(大写) */
|
/* 字母 N(大写) */
|
||||||
"letter_n_upper" = "key_n";
|
"letter_n_upper" = "key_n_up";
|
||||||
|
|
||||||
/* 字母 m(小写) */
|
/* 字母 m(小写) */
|
||||||
"letter_m_lower" = "key_m";
|
"letter_m_lower" = "key_m";
|
||||||
/* 字母 M(大写) */
|
/* 字母 M(大写) */
|
||||||
"letter_m_upper" = "key_m";
|
"letter_m_upper" = "key_m_up";
|
||||||
|
|
||||||
/* 数字 1 */
|
/* 数字 1 */
|
||||||
"digit_1" = "key_1";
|
"digit_1" = "key_1";
|
||||||
@@ -229,6 +232,8 @@
|
|||||||
"backspace" = "key_del";
|
"backspace" = "key_del";
|
||||||
/* Shift(⇧) */
|
/* Shift(⇧) */
|
||||||
"shift" = "key_up";
|
"shift" = "key_up";
|
||||||
|
/* Shift(⇧)大写 */
|
||||||
|
"shift_upper" = "key_up_upper";
|
||||||
/* 字母面板左下角 "123" */
|
/* 字母面板左下角 "123" */
|
||||||
"mode_123" = "key_123";
|
"mode_123" = "key_123";
|
||||||
/* 数字面板左下角 "abc" */
|
/* 数字面板左下角 "abc" */
|
||||||
@@ -239,6 +244,7 @@
|
|||||||
"symbols_toggle_123" = "key_symbols_123";
|
"symbols_toggle_123" = "key_symbols_123";
|
||||||
/* 自定义 AI 功能键 */
|
/* 自定义 AI 功能键 */
|
||||||
"ai" = "key_ai";
|
"ai" = "key_ai";
|
||||||
|
/* Emoji功能键 */
|
||||||
|
"emoji_panel" = "key_emoji";
|
||||||
/* 发送/换行键 */
|
/* 发送/换行键 */
|
||||||
"return" = "key_send";
|
"return" = "key_send";
|
||||||
|
|
||||||
250
CustomKeyboard/Resource/KBSkinIconMap_pt.strings
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
/* 葡萄牙语键盘皮肤映射 */
|
||||||
|
/* Portuguese Keyboard Skin Icon Map */
|
||||||
|
|
||||||
|
/* 字母 q(小写) */
|
||||||
|
"letter_q_lower" = "key_q";
|
||||||
|
/* 字母 Q(大写) */
|
||||||
|
"letter_q_upper" = "key_q_up";
|
||||||
|
|
||||||
|
/* 字母 w(小写) */
|
||||||
|
"letter_w_lower" = "key_w";
|
||||||
|
/* 字母 W(大写) */
|
||||||
|
"letter_w_upper" = "key_w_up";
|
||||||
|
|
||||||
|
/* 字母 e(小写) */
|
||||||
|
"letter_e_lower" = "key_e";
|
||||||
|
/* 字母 E(大写) */
|
||||||
|
"letter_e_upper" = "key_e_up";
|
||||||
|
|
||||||
|
/* 字母 r(小写) */
|
||||||
|
"letter_r_lower" = "key_r";
|
||||||
|
/* 字母 R(大写) */
|
||||||
|
"letter_r_upper" = "key_r_up";
|
||||||
|
|
||||||
|
/* 字母 t(小写) */
|
||||||
|
"letter_t_lower" = "key_t";
|
||||||
|
/* 字母 T(大写) */
|
||||||
|
"letter_t_upper" = "key_t_up";
|
||||||
|
|
||||||
|
/* 字母 y(小写) */
|
||||||
|
"letter_y_lower" = "key_y";
|
||||||
|
/* 字母 Y(大写) */
|
||||||
|
"letter_y_upper" = "key_y_up";
|
||||||
|
|
||||||
|
/* 字母 u(小写) */
|
||||||
|
"letter_u_lower" = "key_u";
|
||||||
|
/* 字母 U(大写) */
|
||||||
|
"letter_u_upper" = "key_u_up";
|
||||||
|
|
||||||
|
/* 字母 i(小写) */
|
||||||
|
"letter_i_lower" = "key_i";
|
||||||
|
/* 字母 I(大写) */
|
||||||
|
"letter_i_upper" = "key_i_up";
|
||||||
|
|
||||||
|
/* 字母 o(小写) */
|
||||||
|
"letter_o_lower" = "key_o";
|
||||||
|
/* 字母 O(大写) */
|
||||||
|
"letter_o_upper" = "key_o_up";
|
||||||
|
|
||||||
|
/* 字母 p(小写) */
|
||||||
|
"letter_p_lower" = "key_p";
|
||||||
|
/* 字母 P(大写) */
|
||||||
|
"letter_p_upper" = "key_p_up";
|
||||||
|
|
||||||
|
/* 字母 a(小写) */
|
||||||
|
"letter_a_lower" = "key_a";
|
||||||
|
/* 字母 A(大写) */
|
||||||
|
"letter_a_upper" = "key_a_up";
|
||||||
|
|
||||||
|
/* 字母 s(小写) */
|
||||||
|
"letter_s_lower" = "key_s";
|
||||||
|
/* 字母 S(大写) */
|
||||||
|
"letter_s_upper" = "key_s_up";
|
||||||
|
|
||||||
|
/* 字母 d(小写) */
|
||||||
|
"letter_d_lower" = "key_d";
|
||||||
|
/* 字母 D(大写) */
|
||||||
|
"letter_d_upper" = "key_d_up";
|
||||||
|
|
||||||
|
/* 字母 f(小写) */
|
||||||
|
"letter_f_lower" = "key_f";
|
||||||
|
/* 字母 F(大写) */
|
||||||
|
"letter_f_upper" = "key_f_up";
|
||||||
|
|
||||||
|
/* 字母 g(小写) */
|
||||||
|
"letter_g_lower" = "key_g";
|
||||||
|
/* 字母 G(大写) */
|
||||||
|
"letter_g_upper" = "key_g_up";
|
||||||
|
|
||||||
|
/* 字母 h(小写) */
|
||||||
|
"letter_h_lower" = "key_h";
|
||||||
|
/* 字母 H(大写) */
|
||||||
|
"letter_h_upper" = "key_h_up";
|
||||||
|
|
||||||
|
/* 字母 j(小写) */
|
||||||
|
"letter_j_lower" = "key_j";
|
||||||
|
/* 字母 J(大写) */
|
||||||
|
"letter_j_upper" = "key_j_up";
|
||||||
|
|
||||||
|
/* 字母 k(小写) */
|
||||||
|
"letter_k_lower" = "key_k";
|
||||||
|
/* 字母 K(大写) */
|
||||||
|
"letter_k_upper" = "key_k_up";
|
||||||
|
|
||||||
|
/* 字母 l(小写) */
|
||||||
|
"letter_l_lower" = "key_l";
|
||||||
|
/* 字母 L(大写) */
|
||||||
|
"letter_l_upper" = "key_l_up";
|
||||||
|
|
||||||
|
/* 字母 z(小写) */
|
||||||
|
"letter_z_lower" = "key_z";
|
||||||
|
/* 字母 Z(大写) */
|
||||||
|
"letter_z_upper" = "key_z_up";
|
||||||
|
|
||||||
|
/* 字母 x(小写) */
|
||||||
|
"letter_x_lower" = "key_x";
|
||||||
|
/* 字母 X(大写) */
|
||||||
|
"letter_x_upper" = "key_x_up";
|
||||||
|
|
||||||
|
/* 字母 c(小写) */
|
||||||
|
"letter_c_lower" = "key_c";
|
||||||
|
/* 字母 C(大写) */
|
||||||
|
"letter_c_upper" = "key_c_up";
|
||||||
|
|
||||||
|
/* 字母 v(小写) */
|
||||||
|
"letter_v_lower" = "key_v";
|
||||||
|
/* 字母 V(大写) */
|
||||||
|
"letter_v_upper" = "key_v_up";
|
||||||
|
|
||||||
|
/* 字母 b(小写) */
|
||||||
|
"letter_b_lower" = "key_b";
|
||||||
|
/* 字母 B(大写) */
|
||||||
|
"letter_b_upper" = "key_b_up";
|
||||||
|
|
||||||
|
/* 字母 n(小写) */
|
||||||
|
"letter_n_lower" = "key_n";
|
||||||
|
/* 字母 N(大写) */
|
||||||
|
"letter_n_upper" = "key_n_up";
|
||||||
|
|
||||||
|
/* 字母 m(小写) */
|
||||||
|
"letter_m_lower" = "key_m";
|
||||||
|
/* 字母 M(大写) */
|
||||||
|
"letter_m_upper" = "key_m_up";
|
||||||
|
|
||||||
|
/* 数字 1 */
|
||||||
|
"digit_1" = "key_1";
|
||||||
|
/* 数字 2 */
|
||||||
|
"digit_2" = "key_2";
|
||||||
|
/* 数字 3 */
|
||||||
|
"digit_3" = "key_3";
|
||||||
|
/* 数字 4 */
|
||||||
|
"digit_4" = "key_4";
|
||||||
|
/* 数字 5 */
|
||||||
|
"digit_5" = "key_5";
|
||||||
|
/* 数字 6 */
|
||||||
|
"digit_6" = "key_6";
|
||||||
|
/* 数字 7 */
|
||||||
|
"digit_7" = "key_7";
|
||||||
|
/* 数字 8 */
|
||||||
|
"digit_8" = "key_8";
|
||||||
|
/* 数字 9 */
|
||||||
|
"digit_9" = "key_9";
|
||||||
|
/* 数字 0 */
|
||||||
|
"digit_0" = "key_0";
|
||||||
|
|
||||||
|
/* '-' */
|
||||||
|
"sym_minus" = "key_minus";
|
||||||
|
/* '/' */
|
||||||
|
"sym_slash" = "key_slash";
|
||||||
|
/* ':' */
|
||||||
|
"sym_colon" = "key_colon";
|
||||||
|
/* ';' */
|
||||||
|
"sym_semicolon" = "key_semicolon";
|
||||||
|
/* '(' */
|
||||||
|
"sym_paren_l" = "key_paren_l";
|
||||||
|
/* ')' */
|
||||||
|
"sym_paren_r" = "key_paren_r";
|
||||||
|
/* '$' */
|
||||||
|
"sym_dollar" = "key_dollar";
|
||||||
|
/* '&' */
|
||||||
|
"sym_amp" = "key_amp";
|
||||||
|
/* '@' */
|
||||||
|
"sym_at" = "key_at";
|
||||||
|
/* 双引号 " */
|
||||||
|
"sym_quote_double" = "key_quote_d";
|
||||||
|
|
||||||
|
/* ',' */
|
||||||
|
"sym_comma" = "key_comma";
|
||||||
|
/* '.' */
|
||||||
|
"sym_dot" = "key_dot";
|
||||||
|
/* '?' */
|
||||||
|
"sym_question" = "key_question";
|
||||||
|
/* '!' */
|
||||||
|
"sym_exclam" = "key_exclam";
|
||||||
|
/* 单引号 ' */
|
||||||
|
"sym_quote_single" = "key_quote";
|
||||||
|
|
||||||
|
/* '[' */
|
||||||
|
"sym_bracket_l" = "key_bracket_l";
|
||||||
|
/* ']' */
|
||||||
|
"sym_bracket_r" = "key_bracket_r";
|
||||||
|
/* '{' */
|
||||||
|
"sym_brace_l" = "key_brace_l";
|
||||||
|
/* '}' */
|
||||||
|
"sym_brace_r" = "key_brace_r";
|
||||||
|
/* '#' */
|
||||||
|
"sym_hash" = "key_hash";
|
||||||
|
/* '%' */
|
||||||
|
"sym_percent" = "key_percent";
|
||||||
|
/* '^' */
|
||||||
|
"sym_caret" = "key_caret";
|
||||||
|
/* '*' */
|
||||||
|
"sym_asterisk" = "key_asterisk";
|
||||||
|
/* '+' */
|
||||||
|
"sym_plus" = "key_plus";
|
||||||
|
/* '=' */
|
||||||
|
"sym_equal" = "key_equal";
|
||||||
|
|
||||||
|
/* '_' */
|
||||||
|
"sym_underscore" = "key_underscore";
|
||||||
|
/* '\' */
|
||||||
|
"sym_backslash" = "key_backslash";
|
||||||
|
/* '|' */
|
||||||
|
"sym_pipe" = "key_pipe";
|
||||||
|
/* '~' */
|
||||||
|
"sym_tilde" = "key_tilde";
|
||||||
|
/* '<' */
|
||||||
|
"sym_lt" = "key_lt";
|
||||||
|
/* '>' */
|
||||||
|
"sym_gt" = "key_gt";
|
||||||
|
/* '¥' */
|
||||||
|
"sym_money" = "key_money";
|
||||||
|
/* '€' */
|
||||||
|
"sym_euro" = "key_euro";
|
||||||
|
/* '£' */
|
||||||
|
"sym_pound" = "key_pound";
|
||||||
|
/* '•' */
|
||||||
|
"sym_bullet" = "key_bullet";
|
||||||
|
|
||||||
|
/* 空格键 */
|
||||||
|
"space" = "key_space";
|
||||||
|
/* 删除键(⌫) */
|
||||||
|
"backspace" = "key_del";
|
||||||
|
/* Shift(⇧) */
|
||||||
|
"shift" = "key_up";
|
||||||
|
/* Shift(⇧)大写 */
|
||||||
|
"shift_upper" = "key_up_upper";
|
||||||
|
/* 字母面板左下角 "123" */
|
||||||
|
"mode_123" = "key_123";
|
||||||
|
/* 数字面板左下角 "abc" */
|
||||||
|
"mode_abc" = "key_abc";
|
||||||
|
/* 数字面板内 "123 -> #+=" */
|
||||||
|
"symbols_toggle_more" = "key_symbols_more";
|
||||||
|
/* 数字面板内 "#+= -> 123" */
|
||||||
|
"symbols_toggle_123" = "key_symbols_123";
|
||||||
|
/* 自定义 AI 功能键 */
|
||||||
|
"ai" = "key_ai";
|
||||||
|
/* Emoji功能键 */
|
||||||
|
"emoji_panel" = "key_emoji";
|
||||||
|
/* 发送/换行键 */
|
||||||
|
"return" = "key_send";
|
||||||
330
CustomKeyboard/Resource/KBSkinIconMap_zh_hant.strings
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
/* 繁体中文键盘皮肤映射 */
|
||||||
|
/* Traditional Chinese Keyboard Skin Icon Map */
|
||||||
|
/* 包含:拼音布局 + 注音布局 */
|
||||||
|
|
||||||
|
/* ========== 拼音布局(与英文相同)========== */
|
||||||
|
|
||||||
|
/* 字母 q(小写) */
|
||||||
|
"letter_q_lower" = "key_q";
|
||||||
|
/* 字母 Q(大写) */
|
||||||
|
"letter_q_upper" = "key_q_up";
|
||||||
|
|
||||||
|
/* 字母 w(小写) */
|
||||||
|
"letter_w_lower" = "key_w";
|
||||||
|
/* 字母 W(大写) */
|
||||||
|
"letter_w_upper" = "key_w_up";
|
||||||
|
|
||||||
|
/* 字母 e(小写) */
|
||||||
|
"letter_e_lower" = "key_e";
|
||||||
|
/* 字母 E(大写) */
|
||||||
|
"letter_e_upper" = "key_e_up";
|
||||||
|
|
||||||
|
/* 字母 r(小写) */
|
||||||
|
"letter_r_lower" = "key_r";
|
||||||
|
/* 字母 R(大写) */
|
||||||
|
"letter_r_upper" = "key_r_up";
|
||||||
|
|
||||||
|
/* 字母 t(小写) */
|
||||||
|
"letter_t_lower" = "key_t";
|
||||||
|
/* 字母 T(大写) */
|
||||||
|
"letter_t_upper" = "key_t_up";
|
||||||
|
|
||||||
|
/* 字母 y(小写) */
|
||||||
|
"letter_y_lower" = "key_y";
|
||||||
|
/* 字母 Y(大写) */
|
||||||
|
"letter_y_upper" = "key_y_up";
|
||||||
|
|
||||||
|
/* 字母 u(小写) */
|
||||||
|
"letter_u_lower" = "key_u";
|
||||||
|
/* 字母 U(大写) */
|
||||||
|
"letter_u_upper" = "key_u_up";
|
||||||
|
|
||||||
|
/* 字母 i(小写) */
|
||||||
|
"letter_i_lower" = "key_i";
|
||||||
|
/* 字母 I(大写) */
|
||||||
|
"letter_i_upper" = "key_i_up";
|
||||||
|
|
||||||
|
/* 字母 o(小写) */
|
||||||
|
"letter_o_lower" = "key_o";
|
||||||
|
/* 字母 O(大写) */
|
||||||
|
"letter_o_upper" = "key_o_up";
|
||||||
|
|
||||||
|
/* 字母 p(小写) */
|
||||||
|
"letter_p_lower" = "key_p";
|
||||||
|
/* 字母 P(大写) */
|
||||||
|
"letter_p_upper" = "key_p_up";
|
||||||
|
|
||||||
|
/* 字母 a(小写) */
|
||||||
|
"letter_a_lower" = "key_a";
|
||||||
|
/* 字母 A(大写) */
|
||||||
|
"letter_a_upper" = "key_a_up";
|
||||||
|
|
||||||
|
/* 字母 s(小写) */
|
||||||
|
"letter_s_lower" = "key_s";
|
||||||
|
/* 字母 S(大写) */
|
||||||
|
"letter_s_upper" = "key_s_up";
|
||||||
|
|
||||||
|
/* 字母 d(小写) */
|
||||||
|
"letter_d_lower" = "key_d";
|
||||||
|
/* 字母 D(大写) */
|
||||||
|
"letter_d_upper" = "key_d_up";
|
||||||
|
|
||||||
|
/* 字母 f(小写) */
|
||||||
|
"letter_f_lower" = "key_f";
|
||||||
|
/* 字母 F(大写) */
|
||||||
|
"letter_f_upper" = "key_f_up";
|
||||||
|
|
||||||
|
/* 字母 g(小写) */
|
||||||
|
"letter_g_lower" = "key_g";
|
||||||
|
/* 字母 G(大写) */
|
||||||
|
"letter_g_upper" = "key_g_up";
|
||||||
|
|
||||||
|
/* 字母 h(小写) */
|
||||||
|
"letter_h_lower" = "key_h";
|
||||||
|
/* 字母 H(大写) */
|
||||||
|
"letter_h_upper" = "key_h_up";
|
||||||
|
|
||||||
|
/* 字母 j(小写) */
|
||||||
|
"letter_j_lower" = "key_j";
|
||||||
|
/* 字母 J(大写) */
|
||||||
|
"letter_j_upper" = "key_j_up";
|
||||||
|
|
||||||
|
/* 字母 k(小写) */
|
||||||
|
"letter_k_lower" = "key_k";
|
||||||
|
/* 字母 K(大写) */
|
||||||
|
"letter_k_upper" = "key_k_up";
|
||||||
|
|
||||||
|
/* 字母 l(小写) */
|
||||||
|
"letter_l_lower" = "key_l";
|
||||||
|
/* 字母 L(大写) */
|
||||||
|
"letter_l_upper" = "key_l_up";
|
||||||
|
|
||||||
|
/* 字母 z(小写) */
|
||||||
|
"letter_z_lower" = "key_z";
|
||||||
|
/* 字母 Z(大写) */
|
||||||
|
"letter_z_upper" = "key_z_up";
|
||||||
|
|
||||||
|
/* 字母 x(小写) */
|
||||||
|
"letter_x_lower" = "key_x";
|
||||||
|
/* 字母 X(大写) */
|
||||||
|
"letter_x_upper" = "key_x_up";
|
||||||
|
|
||||||
|
/* 字母 c(小写) */
|
||||||
|
"letter_c_lower" = "key_c";
|
||||||
|
/* 字母 C(大写) */
|
||||||
|
"letter_c_upper" = "key_c_up";
|
||||||
|
|
||||||
|
/* 字母 v(小写) */
|
||||||
|
"letter_v_lower" = "key_v";
|
||||||
|
/* 字母 V(大写) */
|
||||||
|
"letter_v_upper" = "key_v_up";
|
||||||
|
|
||||||
|
/* 字母 b(小写) */
|
||||||
|
"letter_b_lower" = "key_b";
|
||||||
|
/* 字母 B(大写) */
|
||||||
|
"letter_b_upper" = "key_b_up";
|
||||||
|
|
||||||
|
/* 字母 n(小写) */
|
||||||
|
"letter_n_lower" = "key_n";
|
||||||
|
/* 字母 N(大写) */
|
||||||
|
"letter_n_upper" = "key_n_up";
|
||||||
|
|
||||||
|
/* 字母 m(小写) */
|
||||||
|
"letter_m_lower" = "key_m";
|
||||||
|
/* 字母 M(大写) */
|
||||||
|
"letter_m_upper" = "key_m_up";
|
||||||
|
|
||||||
|
/* ========== 注音符号 ========== */
|
||||||
|
|
||||||
|
/* 声母 */
|
||||||
|
"letter_ㄅ" = "key_bopomofo_b";
|
||||||
|
"letter_ㄆ" = "key_bopomofo_p";
|
||||||
|
"letter_ㄇ" = "key_bopomofo_m";
|
||||||
|
"letter_ㄈ" = "key_bopomofo_f";
|
||||||
|
"letter_ㄉ" = "key_bopomofo_d";
|
||||||
|
"letter_ㄊ" = "key_bopomofo_t";
|
||||||
|
"letter_ㄋ" = "key_bopomofo_n";
|
||||||
|
"letter_ㄌ" = "key_bopomofo_l";
|
||||||
|
"letter_ㄍ" = "key_bopomofo_g";
|
||||||
|
"letter_ㄎ" = "key_bopomofo_k";
|
||||||
|
"letter_ㄏ" = "key_bopomofo_h";
|
||||||
|
"letter_ㄐ" = "key_bopomofo_j";
|
||||||
|
"letter_ㄑ" = "key_bopomofo_q";
|
||||||
|
"letter_ㄒ" = "key_bopomofo_x";
|
||||||
|
"letter_ㄓ" = "key_bopomofo_zh";
|
||||||
|
"letter_ㄔ" = "key_bopomofo_ch";
|
||||||
|
"letter_ㄕ" = "key_bopomofo_sh";
|
||||||
|
"letter_ㄖ" = "key_bopomofo_r";
|
||||||
|
"letter_ㄗ" = "key_bopomofo_z";
|
||||||
|
"letter_ㄘ" = "key_bopomofo_c";
|
||||||
|
"letter_ㄙ" = "key_bopomofo_s";
|
||||||
|
|
||||||
|
/* 韵母 */
|
||||||
|
"letter_ㄚ" = "key_bopomofo_a";
|
||||||
|
"letter_ㄛ" = "key_bopomofo_o";
|
||||||
|
"letter_ㄜ" = "key_bopomofo_e";
|
||||||
|
"letter_ㄝ" = "key_bopomofo_eh";
|
||||||
|
"letter_ㄞ" = "key_bopomofo_ai";
|
||||||
|
"letter_ㄟ" = "key_bopomofo_ei";
|
||||||
|
"letter_ㄠ" = "key_bopomofo_au";
|
||||||
|
"letter_ㄡ" = "key_bopomofo_ou";
|
||||||
|
"letter_ㄢ" = "key_bopomofo_an";
|
||||||
|
"letter_ㄣ" = "key_bopomofo_en";
|
||||||
|
"letter_ㄤ" = "key_bopomofo_ang";
|
||||||
|
"letter_ㄥ" = "key_bopomofo_eng";
|
||||||
|
"letter_ㄦ" = "key_bopomofo_er";
|
||||||
|
"letter_ㄧ" = "key_bopomofo_i";
|
||||||
|
"letter_ㄨ" = "key_bopomofo_u";
|
||||||
|
"letter_ㄩ" = "key_bopomofo_iu";
|
||||||
|
|
||||||
|
/* 声调 */
|
||||||
|
"letter_ˊ" = "key_bopomofo_tone2";
|
||||||
|
"letter_ˇ" = "key_bopomofo_tone3";
|
||||||
|
"letter_ˋ" = "key_bopomofo_tone4";
|
||||||
|
"letter_˙" = "key_bopomofo_tone5";
|
||||||
|
|
||||||
|
/* ========== 数字 ========== */
|
||||||
|
|
||||||
|
/* 数字 1 */
|
||||||
|
"digit_1" = "key_1";
|
||||||
|
/* 数字 2 */
|
||||||
|
"digit_2" = "key_2";
|
||||||
|
/* 数字 3 */
|
||||||
|
"digit_3" = "key_3";
|
||||||
|
/* 数字 4 */
|
||||||
|
"digit_4" = "key_4";
|
||||||
|
/* 数字 5 */
|
||||||
|
"digit_5" = "key_5";
|
||||||
|
/* 数字 6 */
|
||||||
|
"digit_6" = "key_6";
|
||||||
|
/* 数字 7 */
|
||||||
|
"digit_7" = "key_7";
|
||||||
|
/* 数字 8 */
|
||||||
|
"digit_8" = "key_8";
|
||||||
|
/* 数字 9 */
|
||||||
|
"digit_9" = "key_9";
|
||||||
|
/* 数字 0 */
|
||||||
|
"digit_0" = "key_0";
|
||||||
|
|
||||||
|
/* ========== 符号 ========== */
|
||||||
|
|
||||||
|
/* '-' */
|
||||||
|
"sym_minus" = "key_minus";
|
||||||
|
/* '/' */
|
||||||
|
"sym_slash" = "key_slash";
|
||||||
|
/* ':' */
|
||||||
|
"sym_colon" = "key_colon";
|
||||||
|
/* ';' */
|
||||||
|
"sym_semicolon" = "key_semicolon";
|
||||||
|
/* '(' */
|
||||||
|
"sym_paren_l" = "key_paren_l";
|
||||||
|
/* ')' */
|
||||||
|
"sym_paren_r" = "key_paren_r";
|
||||||
|
/* '$' */
|
||||||
|
"sym_dollar" = "key_dollar";
|
||||||
|
/* '&' */
|
||||||
|
"sym_amp" = "key_amp";
|
||||||
|
/* '@' */
|
||||||
|
"sym_at" = "key_at";
|
||||||
|
/* 双引号 " */
|
||||||
|
"sym_quote_double" = "key_quote_d";
|
||||||
|
|
||||||
|
/* ',' */
|
||||||
|
"sym_comma" = "key_comma";
|
||||||
|
/* '、' 顿号 */
|
||||||
|
"sym_dun" = "key_dun";
|
||||||
|
/* '.' */
|
||||||
|
"sym_dot" = "key_dot";
|
||||||
|
/* '。' 中文句号 */
|
||||||
|
"sym_chinese_dot" = "key_chinese_dot";
|
||||||
|
/* '?' */
|
||||||
|
"sym_question" = "key_question";
|
||||||
|
/* '!' */
|
||||||
|
"sym_exclam" = "key_exclam";
|
||||||
|
/* 单引号 ' */
|
||||||
|
"sym_quote_single" = "key_quote";
|
||||||
|
|
||||||
|
/* '[' */
|
||||||
|
"sym_bracket_l" = "key_bracket_l";
|
||||||
|
/* ']' */
|
||||||
|
"sym_bracket_r" = "key_bracket_r";
|
||||||
|
/* '{' */
|
||||||
|
"sym_brace_l" = "key_brace_l";
|
||||||
|
/* '}' */
|
||||||
|
"sym_brace_r" = "key_brace_r";
|
||||||
|
/* '「' */
|
||||||
|
"sym_corner_l" = "key_corner_l";
|
||||||
|
/* '」' */
|
||||||
|
"sym_corner_r" = "key_corner_r";
|
||||||
|
/* '#' */
|
||||||
|
"sym_hash" = "key_hash";
|
||||||
|
/* '%' */
|
||||||
|
"sym_percent" = "key_percent";
|
||||||
|
/* '^' */
|
||||||
|
"sym_caret" = "key_caret";
|
||||||
|
/* '*' */
|
||||||
|
"sym_asterisk" = "key_asterisk";
|
||||||
|
/* '+' */
|
||||||
|
"sym_plus" = "key_plus";
|
||||||
|
/* '=' */
|
||||||
|
"sym_equal" = "key_equal";
|
||||||
|
|
||||||
|
/* '_' */
|
||||||
|
"sym_underscore" = "key_underscore";
|
||||||
|
/* '\' */
|
||||||
|
"sym_backslash" = "key_backslash";
|
||||||
|
/* '|' */
|
||||||
|
"sym_pipe" = "key_pipe";
|
||||||
|
/* '~' */
|
||||||
|
"sym_tilde" = "key_tilde";
|
||||||
|
/* '<' */
|
||||||
|
"sym_lt" = "key_lt";
|
||||||
|
/* '>' */
|
||||||
|
"sym_gt" = "key_gt";
|
||||||
|
/* '¥' */
|
||||||
|
"sym_money" = "key_money";
|
||||||
|
/* '€' */
|
||||||
|
"sym_euro" = "key_euro";
|
||||||
|
/* '£' */
|
||||||
|
"sym_pound" = "key_pound";
|
||||||
|
/* '•' */
|
||||||
|
"sym_bullet" = "key_bullet";
|
||||||
|
/* '^_^' 笑脸 */
|
||||||
|
"sym_face" = "key_face";
|
||||||
|
/* '—' 长横线 */
|
||||||
|
"sym_emdash" = "key_emdash";
|
||||||
|
/* '«' 左双尖括号 */
|
||||||
|
"sym_guillemet_l" = "key_guillemet_l";
|
||||||
|
/* '»' 右双尖括号 */
|
||||||
|
"sym_guillemet_r" = "key_guillemet_r";
|
||||||
|
/* '《' 左书名号 */
|
||||||
|
"sym_book_title_l" = "key_book_title_l";
|
||||||
|
/* '》' 右书名号 */
|
||||||
|
"sym_book_title_r" = "key_book_title_r";
|
||||||
|
/* '...' 省略号 */
|
||||||
|
"sym_ellipsis" = "key_ellipsis";
|
||||||
|
|
||||||
|
/* ========== 功能键 ========== */
|
||||||
|
|
||||||
|
/* 空格键 */
|
||||||
|
"space" = "key_space";
|
||||||
|
/* 删除键(⌫) */
|
||||||
|
"backspace" = "key_del";
|
||||||
|
/* Shift(⇧) */
|
||||||
|
"shift" = "key_up";
|
||||||
|
/* Shift(⇧)大写 */
|
||||||
|
"shift_upper" = "key_up_upper";
|
||||||
|
/* 字母面板左下角 "123" */
|
||||||
|
"mode_123" = "key_123";
|
||||||
|
/* 数字面板左下角 "abc" */
|
||||||
|
"mode_abc" = "key_拼音";
|
||||||
|
/* 数字面板内 "123 -> #+=" */
|
||||||
|
"symbols_toggle_more" = "key_symbols_more";
|
||||||
|
/* 数字面板内 "#+= -> 123" */
|
||||||
|
"symbols_toggle_123" = "key_symbols_123";
|
||||||
|
/* 自定义 AI 功能键 */
|
||||||
|
"ai" = "key_ai";
|
||||||
|
/* Emoji功能键 */
|
||||||
|
"emoji_panel" = "key_emoji";
|
||||||
|
/* 发送/换行键 */
|
||||||
|
"return" = "key_send";
|
||||||
345
CustomKeyboard/Resource/bopomofo_to_chinese.json
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
{
|
||||||
|
"__comment": "注音符号映射表:注音组合 -> 繁体字候选词列表",
|
||||||
|
"__comment_symbols": "聲母: ㄅㄆㄇㄈㄉㄊㄋㄌㄍㄎㄏㄐㄑㄒㄓㄔㄕㄖㄗㄘㄙ",
|
||||||
|
"__comment_vowels": "韻母: ㄚㄛㄜㄝㄞㄟㄠㄡㄢㄣㄤㄥㄦㄧㄨㄩ",
|
||||||
|
"__comment_tones": "聲調: ˊ(二聲) ˇ(三聲) ˋ(四聲) ˙(輕聲), 無符號為一聲",
|
||||||
|
"mappings": {
|
||||||
|
"ㄅㄚ": ["八", "巴", "吧", "爸", "拔", "罷", "霸", "扒", "叭", "芭", "疤", "粑"],
|
||||||
|
"ㄅㄞ": ["白", "百", "拜", "敗", "柏", "擺", "佰", "佰"],
|
||||||
|
"ㄅㄢ": ["班", "般", "板", "版", "半", "伴", "扮", "拌", "瓣", "頒", "斑", "搬"],
|
||||||
|
"ㄅㄤ": ["幫", "邦", "榜", "膀", "綁", "棒", "磅", "邦"],
|
||||||
|
"ㄅㄠ": ["包", "保", "報", "寶", "抱", "暴", "爆", "薄", "豹", "飽", "堡", "刨", "苞", "胞", "雹"],
|
||||||
|
"ㄅㄟ": ["北", "被", "背", "備", "悲", "杯", "碑", "輩", "倍", "貝", "臂"],
|
||||||
|
"ㄅㄣ": ["本", "奔", "笨", "盆", "賁"],
|
||||||
|
"ㄅㄥ": ["崩", "繃", "蹦", "泵", "甭"],
|
||||||
|
"ㄅㄧ": ["比", "必", "筆", "畢", "避", "閉", "鼻", "彼", "碧", "壁", "弊", "臂", "秘", "辟", "逼", "幣", "庇", "痹", "匕"],
|
||||||
|
"ㄅㄧㄝ": ["別", "憋", "癟", "鱉"],
|
||||||
|
"ㄅㄧㄢ": ["變", "便", "邊", "編", "辯", "遍", "鞭", "辨", "扁", "貶", "匾", "蝙"],
|
||||||
|
"ㄅㄧㄠ": ["表", "標", "彪", "錶", "鏢", "錶", "裱", "婊"],
|
||||||
|
"ㄅㄧㄣ": ["賓", "彬", "斌", "瀕", "濱", "殯", "鬢"],
|
||||||
|
"ㄅㄧㄥ": ["病", "並", "冰", "兵", "餅", "柄", "秉", "稟", "炳", "稟"],
|
||||||
|
"ㄅㄛ": ["波", "博", "播", "伯", "薄", "泊", "柏", "勃", "搏", "撥", "剝", "脖", "卜", "玻", "柏"],
|
||||||
|
"ㄅㄨ": ["不", "步", "部", "布", "補", "捕", "簿", "卜", "怖", "哺", "埠", "簿"],
|
||||||
|
"ㄆㄚ": ["趴", "啪", "葩", "扒"],
|
||||||
|
"ㄆㄞ": ["排", "拍", "牌", "派", "徘", "湃", "俳"],
|
||||||
|
"ㄆㄢ": ["判", "盤", "盼", "攀", "畔", "胖", "叛", "潘", "磐", "蹣", "拚", "泮"],
|
||||||
|
"ㄆㄤ": ["旁", "胖", "龐", "膀", "磅", "彷", "螃", "乓"],
|
||||||
|
"ㄆㄠ": ["跑", "炮", "泡", "拋", "刨", "袍", "咆", "庖", "匏"],
|
||||||
|
"ㄆㄟ": ["配", "陪", "培", "賠", "佩", "沛", "裴", "胚", "霈"],
|
||||||
|
"ㄆㄣ": ["噴", "盆"],
|
||||||
|
"ㄆㄥ": ["朋", "碰", "彭", "棚", "蓬", "鵬", "捧", "烹", "澎", "怦", "砰", "堋"],
|
||||||
|
"ㄆㄧ": ["皮", "批", "披", "匹", "疲", "僻", "脾", "劈", "琵", "毗", "啤", "坯", "譬", "霹", "屁", "闢", "紕", "闢"],
|
||||||
|
"ㄆㄧㄝ": ["撇", "瞥", "苤"],
|
||||||
|
"ㄆㄧㄢ": ["片", "便", "騙", "偏", "篇", "翩", "扁", "諞", "騙"],
|
||||||
|
"ㄆㄧㄠ": ["票", "飄", "漂", "瓢", "嫖", "縹", "驃", "飄"],
|
||||||
|
"ㄆㄧㄣ": ["品", "貧", "頻", "聘", "拼", "拚", "嬪"],
|
||||||
|
"ㄆㄧㄥ": ["平", "評", "憑", "瓶", "萍", "屏", "蘋", "坪", "秤", "娉", "馮", "萍"],
|
||||||
|
"ㄆㄛ": ["破", "迫", "婆", "頗", "坡", "潑", "泊", "魄", "粕", "朴", "珀", "叵", "鄱"],
|
||||||
|
"ㄆㄨ": ["普", "鋪", "樸", "譜", "浦", "葡", "蒲", "僕", "撲", "圃", "濮", "璞", "噗", "莆"],
|
||||||
|
"ㄇㄚ": ["媽", "馬", "麻", "罵", "嘛", "螞", "碼", "瑪", "抹", "摩", "螞"],
|
||||||
|
"ㄇㄞ": ["買", "賣", "麥", "埋", "邁", "脈", "霾", "賣"],
|
||||||
|
"ㄇㄢ": ["滿", "慢", "曼", "漫", "蠻", "瞞", "饅", "蔓", "謾", "墁", "幔", "曼"],
|
||||||
|
"ㄇㄤ": ["忙", "盲", "茫", "芒", "莽", "氓", "硭", "邙"],
|
||||||
|
"ㄇㄠ": ["貓", "毛", "矛", "茅", "茂", "冒", "帽", "貌", "貿", "卯", "錨", "耄", "髦", "瑁", "懋", "卯"],
|
||||||
|
"ㄇㄟ": ["沒", "美", "妹", "每", "梅", "媒", "煤", "眉", "霉", "魅", "玫", "枚", "寐", "昧", "媚", "湄", "鎂", "糜", "梅"],
|
||||||
|
"ㄇㄣ": ["們", "門", "悶", "燜", "捫", "悶"],
|
||||||
|
"ㄇㄥ": ["夢", "孟", "猛", "蒙", "盟", "萌", "朦", "檬", "懵", "礞", "甍", "萌"],
|
||||||
|
"ㄇㄧ": ["米", "密", "迷", "蜜", "祕", "眯", "靡", "糜", "彌", "覓", "冪", "泌", "祕", "謎"],
|
||||||
|
"ㄇㄧㄝ": ["滅", "蔑", "篾", "乜", "咩"],
|
||||||
|
"ㄇㄧㄢ": ["面", "免", "棉", "眠", "綿", "勉", "緬", "冕", "娩", "湎", "眄", "冕"],
|
||||||
|
"ㄇㄧㄠ": ["描", "秒", "妙", "廟", "苗", "瞄", "渺", "淼", "緲", "藐", "喵"],
|
||||||
|
"ㄇㄧㄣ": ["民", "敏", "名", "皿", "閔", "抿", "泯", "憫", "閔", "愍"],
|
||||||
|
"ㄇㄧㄥ": ["名", "明", "命", "鳴", "銘", "冥", "茗", "溟", "瞑", "螟", "銘"],
|
||||||
|
"ㄇㄛ": ["麼", "摸", "磨", "摩", "魔", "膜", "默", "墨", "抹", "末", "莫", "漠", "寞", "陌", "謨", "茉", "驀", "歿", "麼"],
|
||||||
|
"ㄇㄡ": ["某", "謀", "牟", "眸", "繆", "鍪", "哞"],
|
||||||
|
"ㄇㄨ": ["目", "母", "木", "幕", "牧", "慕", "墓", "暮", "穆", "睦", "沐", "募", "姆", "拇", "牡", "畝", "慕"],
|
||||||
|
"ㄈㄚ": ["發", "法", "罰", "乏", "伐", "閥", "筏", "佳", "髮", "法"],
|
||||||
|
"ㄈㄢ": ["反", "飯", "煩", "繁", "範", "犯", "泛", "番", "翻", "凡", "帆", "返", "販", "礬", "釩", "蕃"],
|
||||||
|
"ㄈㄤ": ["方", "放", "房", "防", "訪", "仿", "芳", "坊", "妨", "紡", "舫", "肪", "仿"],
|
||||||
|
"ㄈㄟ": ["非", "飛", "費", "肥", "廢", "匪", "誹", "啡", "菲", "沸", "翡", "吠", "肺", "狒", "妃"],
|
||||||
|
"ㄈㄣ": ["分", "份", "粉", "奮", "憤", "紛", "芬", "墳", "焚", "氛", "糞", "吩", "汾"],
|
||||||
|
"ㄈㄥ": ["風", "封", "豐", "峰", "鋒", "蜂", "瘋", "逢", "縫", "鳳", "奉", "諷", "楓", "烽", "豐", "峰"],
|
||||||
|
"ㄈㄛ": ["佛", "彿"],
|
||||||
|
"ㄈㄡ": ["否", "縫", "缶"],
|
||||||
|
"ㄈㄨ": ["父", "夫", "付", "服", "福", "府", "負", "富", "復", "副", "婦", "撫", "附", "幅", "浮", "腐", "符", "弗", "腹", "輻", "敷", "氟", "芙", "敷", "伏", "扶", "俘", "袱", "芙", "斧", "脯", "腑", "滏", "蚨", "跗", "馥"],
|
||||||
|
"ㄉㄚ": ["大", "打", "答", "達", "搭", "塔", "瘩", "妲", "怛", "耷"],
|
||||||
|
"ㄉㄞ": ["大", "代", "帶", "待", "袋", "戴", "呆", "貸", "逮", "怠", "殆", "黛", "岱", "迨"],
|
||||||
|
"ㄉㄢ": ["但", "單", "擔", "膽", "丹", "淡", "蛋", "誕", "彈", "旦", "氮", "耽", "憚", "殫", "瘅", "眈"],
|
||||||
|
"ㄉㄤ": ["當", "黨", "檔", "擋", "蕩", "宕", "檔", "璫", "璫"],
|
||||||
|
"ㄉㄠ": ["到", "道", "導", "刀", "倒", "島", "盜", "悼", "搗", "禱", "蹈", "叨", "忉", "氘"],
|
||||||
|
"ㄉㄜ": ["的", "得", "德", "底", "德"],
|
||||||
|
"ㄉㄥ": ["等", "燈", "登", "鄧", "瞪", "凳", "蹬", "噔", "嶝"],
|
||||||
|
"ㄉㄧ": ["的", "地", "第", "低", "底", "敵", "弟", "帝", "抵", "遞", "迪", "滴", "堤", "笛", "締", "嫡", "詆", "邸", "砥", "睇", "鏑"],
|
||||||
|
"ㄉㄧㄝ": ["爹", "跌", "叠", "蝶", "碟", "諜", "迭", "帖", "耋", "牒", "瓞", "鰈"],
|
||||||
|
"ㄉㄧㄢ": ["點", "電", "店", "典", "墊", "澱", "殿", "顛", "滇", "碘", "巔", "癲", "惦", "奠", "甸", "阽"],
|
||||||
|
"ㄉㄧㄠ": ["調", "掉", "吊", "雕", "刁", "釣", "凋", "碉", "貂", "雕"],
|
||||||
|
"ㄉㄧㄥ": ["定", "訂", "頂", "丁", "釘", "盯", "叮", "鼎", "叮", "丁", "町"],
|
||||||
|
"ㄉㄨ": ["讀", "都", "度", "獨", "毒", "渡", "杜", "肚", "堵", "賭", "鍍", "督", "篤", "嘟", "睹", "妒", "芏"],
|
||||||
|
"ㄉㄨㄢ": ["段", "斷", "短", "鍛", "緞", "端", "椴", "煅"],
|
||||||
|
"ㄉㄨㄟ": ["對", "隊", "堆", "兌", "懟", "憝"],
|
||||||
|
"ㄉㄨㄣ": ["頓", "噸", "盾", "蹲", "敦", "墩", "燉", "鈍", "囤", "遁", "燉"],
|
||||||
|
"ㄉㄨㄛ": ["多", "度", "奪", "躲", "朵", "墮", "舵", "跺", "惰", "哆", "垛", "躲", "踱", "剁", "咄"],
|
||||||
|
"ㄊㄚ": ["他", "她", "它", "塔", "踏", "拓", "榻", "獺", "撻", "闒", "遢", "遢"],
|
||||||
|
"ㄊㄞ": ["太", "台", "臺", "態", "泰", "抬", "胎", "鮐", "薹", "駘", "炱", "邰", "苔", "颱"],
|
||||||
|
"ㄊㄢ": ["談", "探", "彈", "壇", "攤", "貪", "嘆", "潭", "坦", "毯", "痰", "檀", "譚", "忐", "袒", "郯", "澹", "覃", "忐", "曇", "忐"],
|
||||||
|
"ㄊㄤ": ["堂", "唐", "糖", "躺", "趟", "湯", "燙", "塘", "膛", "棠", "搪", "螳", "鏜", "鐋", "耥", "鏜"],
|
||||||
|
"ㄊㄠ": ["套", "逃", "桃", "陶", "討", "濤", "掏", "滔", "萄", "淘", "燾", "絳", "叨", "洮", "啕", "饕"],
|
||||||
|
"ㄊㄜ": ["特", "忒", "慝", "鋱", "忒"],
|
||||||
|
"ㄊㄥ": ["疼", "騰", "藤", "滕", "謄", "疼", "滕"],
|
||||||
|
"ㄊㄧ": ["提", "題", "體", "替", "踢", "梯", "剔", "蹄", "啼", "惕", "涕", "銻", "倜", "悌", "嚏", "醍", "緹"],
|
||||||
|
"ㄊㄧㄝ": ["鐵", "貼", "帖", "萜", "帖", "餮"],
|
||||||
|
"ㄊㄧㄢ": ["天", "田", "填", "甜", "添", "恬", "腆", "殄", "忝", "闐", "祆", "忝"],
|
||||||
|
"ㄊㄧㄠ": ["條", "跳", "調", "挑", "眺", "佻", "祧", "銚", "髫", "鰷", "調", "眺"],
|
||||||
|
"ㄊㄧㄥ": ["聽", "停", "庭", "挺", "廳", "廷", "亭", "婷", "艇", "汀", "蜓", "霆", "鋌", "莛", "汀"],
|
||||||
|
"ㄊㄨ": ["圖", "土", "突", "途", "吐", "兔", "屠", "徒", "凸", "禿", "荼", "釷", "菟", "兔"],
|
||||||
|
"ㄊㄨㄢ": ["團", "摶", "彖", "湍", "摶"],
|
||||||
|
"ㄊㄨㄟ": ["推", "退", "腿", "蛻", "頹", "褪", "忒"],
|
||||||
|
"ㄊㄨㄣ": ["吞", "屯", "臀", "囤", "褪", "豚", "吞"],
|
||||||
|
"ㄊㄨㄛ": ["脫", "托", "拖", "妥", "拓", "唾", "陀", "沱", "坨", "駝", "鴕", "橐", "砣", "佗", "跎", "坨", "酡"],
|
||||||
|
"ㄋㄚ": ["那", "拿", "哪", "納", "吶", "娜", "鈉", "衲", "鎿"],
|
||||||
|
"ㄋㄞ": ["奶", "耐", "乃", "奈", "氖", "萘", "鼐", "氖"],
|
||||||
|
"ㄋㄢ": ["南", "難", "男", "喃", "楠", "赧", "囝", "囡"],
|
||||||
|
"ㄋㄤ": ["囊", "囔", "餿"],
|
||||||
|
"ㄋㄠ": ["腦", "惱", "鬧", "撓", "淖", "鐃", "橈", "鬧", "鬧"],
|
||||||
|
"ㄋㄜ": ["呢", "訥"],
|
||||||
|
"ㄋㄟ": ["內", "那", "餒"],
|
||||||
|
"ㄋㄣ": ["嫩", "恁"],
|
||||||
|
"ㄋㄥ": ["能"],
|
||||||
|
"ㄋㄧ": ["你", "妳", "呢", "泥", "尼", "擬", "逆", "妮", "霓", "倪", "匿", "溺", "膩", "旎", "昵", "妮"],
|
||||||
|
"ㄋㄧㄝ": ["捏", "聶", "孽", "躡", "鎳", "囁", "臬", "涅", "孽"],
|
||||||
|
"ㄋㄧㄢ": ["年", "念", "黏", "碾", "捻", "撚", "蔦", "念", "唸"],
|
||||||
|
"ㄋㄧㄤ": ["娘", "釀", "釀"],
|
||||||
|
"ㄋㄧㄠ": ["鳥", "尿", "裊", "嬲", "蔦", "鳥"],
|
||||||
|
"ㄋㄧㄣ": ["您"],
|
||||||
|
"ㄋㄧㄥ": ["寧", "凝", "擰", "檸", "獰", "嚀", "甯", "寧"],
|
||||||
|
"ㄋㄧㄡ": ["牛", "紐", "扭", "鈕", "妞", "拗", "妞"],
|
||||||
|
"ㄋㄨ": ["女", "努", "怒", "奴", "弩", "胬", "弩"],
|
||||||
|
"ㄋㄨㄢ": ["暖"],
|
||||||
|
"ㄋㄨㄣ": ["嫩", "恁"],
|
||||||
|
"ㄋㄨㄛ": ["挪", "諾", "懦", "糯", "喏", "懦"],
|
||||||
|
"ㄌㄚ": ["拉", "啦", "蠟", "辣", "臘", "喇", "落", "啦", "邋"],
|
||||||
|
"ㄌㄞ": ["來", "賴", "萊", "徠", "賚", "賴", "睞"],
|
||||||
|
"ㄌㄢ": ["藍", "蘭", "攔", "籃", "懶", "爛", "濫", "覽", "欄", "瀾", "嵐", "襤", "懶", "讕"],
|
||||||
|
"ㄌㄤ": ["浪", "郎", "狼", "廊", "朗", "琅", "螂", "朗", "郎", "閬"],
|
||||||
|
"ㄌㄠ": ["老", "勞", "落", "牢", "撈", "澇", "絡", "姥", "佬", "潦", "澇", "癆"],
|
||||||
|
"ㄌㄜ": ["了", "樂", "勒", "肋", "勒", "肋"],
|
||||||
|
"ㄌㄟ": ["累", "類", "淚", "雷", "勒", "壘", "蕾", "磊", "擂", "鐳", "儡", "勒", "擂"],
|
||||||
|
"ㄌㄥ": ["冷", "愣", "楞", "冷"],
|
||||||
|
"ㄌㄧ": ["裡", "力", "理", "利", "立", "離", "例", "歷", "李", "禮", "麗", "勵", "梨", "厘", "莉", "犁", "黎", "璃", "狸", "漓", "罹", "驪", "鱧", "吏", "栗", "俐", "荔", "痢", "裡", "裏", "裡", "吏", "戾", "蠡", "蜊", "悝", "喱"],
|
||||||
|
"ㄌㄧㄚ": ["倆"],
|
||||||
|
"ㄌㄧㄝ": ["列", "烈", "獵", "裂", "劣", "咧", "冽", "捩", "躐", "冽", "洌"],
|
||||||
|
"ㄌㄧㄢ": ["連", "聯", "臉", "練", "蓮", "戀", "煉", "廉", "憐", "漣", "鐮", "斂", "璉", "斂", "斂"],
|
||||||
|
"ㄌㄧㄤ": ["兩", "亮", "量", "良", "涼", "梁", "糧", "樑", "諒", "晾", "踉", "靚", "倆", "倆", "粱", "量"],
|
||||||
|
"ㄌㄧㄠ": ["了", "料", "聊", "療", "遼", "撩", "僚", "燎", "繚", "潦", "寥", "嘹", "撩", "鐐", "獠"],
|
||||||
|
"ㄌㄧㄝ": ["列", "烈", "獵", "裂", "劣", "咧", "冽", "捩", "躐", "獵", "獵"],
|
||||||
|
"ㄌㄧㄣ": ["林", "臨", "鄰", "淋", "琳", "霖", "鱗", "麟", "遴", "藺", "吝", "躪", "琳", "淋"],
|
||||||
|
"ㄌㄧㄥ": ["領", "零", "靈", "令", "另", "玲", "鈴", "陵", "嶺", "凌", "菱", "羚", "翎", "聆", "伶", "拎", "凌", "鈴", "鈴"],
|
||||||
|
"ㄌㄧㄡ": ["六", "流", "留", "劉", "柳", "溜", "琉", "榴", "硫", "鎏", "鷚", "溜", "溜", "鎦"],
|
||||||
|
"ㄌㄨ": ["路", "錄", "陸", "綠", "露", "旅", "律", "慮", "呂", "履", "侶", "屢", "濾", "氯", "廬", "爐", "蘆", "盧", "顱", "魯", "擼", "祿", "麓", "碌", "陸", "輅", "輅"],
|
||||||
|
"ㄌㄨㄢ": ["亂", "卵", "巒", "鑾", "鸞", "欒", "鸞", "鑾"],
|
||||||
|
"ㄌㄨㄣ": ["論", "輪", "倫", "侖", "綸", "淪", "論", "論"],
|
||||||
|
"ㄌㄨㄛ": ["落", "羅", "洛", "絡", "邏", "鑼", "籮", "駱", "裸", "螺", "蘿", "摞", "囉", "羅", "邏"],
|
||||||
|
"ㄍㄚ": ["嘎", "噶", "軋", "噶"],
|
||||||
|
"ㄍㄞ": ["改", "該", "蓋", "概", "溉", "丐", "芥", "鈣", "蓋", "蓋"],
|
||||||
|
"ㄍㄢ": ["幹", "感", "敢", "甘", "肝", "趕", "桿", "乾", "贛", "柑", "竿", "尴", "擀", "乾", "乾"],
|
||||||
|
"ㄍㄤ": ["剛", "鋼", "港", "崗", "綱", "岡", "缸", "槓", "扛", "剛", "崗"],
|
||||||
|
"ㄍㄠ": ["高", "告", "搞", "稿", "糕", "鎬", "膏", "篙", "稿", "稿"],
|
||||||
|
"ㄍㄜ": ["個", "各", "歌", "格", "哥", "割", "革", "隔", "閣", "葛", "戈", "擱", "鴿", "胳", "骼", "個", "個"],
|
||||||
|
"ㄍㄟ": ["給"],
|
||||||
|
"ㄍㄣ": ["跟", "根", "亙", "艮", "跟"],
|
||||||
|
"ㄍㄥ": ["更", "耕", "庚", "羹", "耿", "梗", "更", "耕"],
|
||||||
|
"ㄍㄨ": ["古", "故", "顧", "骨", "谷", "股", "鼓", "固", "孤", "姑", "辜", "沽", "咕", "估", "谷", "谷"],
|
||||||
|
"ㄍㄨㄚ": ["掛", "瓜", "刮", "寡", "呱", "褂", "掛", "掛"],
|
||||||
|
"ㄍㄨㄞ": ["怪", "乖", "拐", "乖"],
|
||||||
|
"ㄍㄨㄢ": ["關", "觀", "管", "官", "館", "慣", "灌", "冠", "罐", "貫", "棺", "倌", "觀", "關"],
|
||||||
|
"ㄍㄨㄤ": ["光", "廣", "逛", "胱", "光", "光"],
|
||||||
|
"ㄍㄨㄟ": ["貴", "規", "歸", "鬼", "軌", "櫃", "桂", "跪", "龜", "瑰", "詭", "閨", "圭", "桂", "歸"],
|
||||||
|
"ㄍㄨㄣ": ["滾", "棍", "滾"],
|
||||||
|
"ㄍㄨㄛ": ["過", "國", "果", "鍋", "郭", "裹", "渦", "過", "過"],
|
||||||
|
"ㄎㄚ": ["卡", "咖", "喀", "咔", "卡"],
|
||||||
|
"ㄎㄞ": ["開", "凱", "楷", "慨", "愷", "鎧", "鍇", "開", "凱"],
|
||||||
|
"ㄎㄢ": ["看", "砍", "坎", "勘", "刊", "堪", "瞰", "龕", "看", "看"],
|
||||||
|
"ㄎㄤ": ["康", "抗", "扛", "亢", "糠", "慷", "伉", "康", "康"],
|
||||||
|
"ㄎㄠ": ["考", "靠", "烤", "拷", "栲", "犒", "考", "考"],
|
||||||
|
"ㄎㄜ": ["可", "客", "科", "刻", "課", "顆", "克", "渴", "柯", "棵", "磕", "咳", "殼", "坷", "可", "可"],
|
||||||
|
"ㄎㄣ": ["肯", "懇", "啃", "齦", "肯"],
|
||||||
|
"ㄎㄥ": ["坑", "吭", "鏗", "坑"],
|
||||||
|
"ㄎㄨ": ["苦", "哭", "庫", "酷", "枯", "窟", "骷", "苦", "苦"],
|
||||||
|
"ㄎㄨㄚ": ["跨", "誇", "垮", "挎", "胯", "跨", "跨"],
|
||||||
|
"ㄎㄨㄞ": ["快", "塊", "筷", "儈", "膾", "快", "快"],
|
||||||
|
"ㄎㄨㄢ": ["寬", "款", "寬"],
|
||||||
|
"ㄎㄨㄤ": ["況", "礦", "狂", "框", "曠", "眶", "筐", "匡", "誑", "況", "況"],
|
||||||
|
"ㄎㄨㄟ": ["虧", "愧", "潰", "窺", "葵", "魁", "饋", "匱", "睽", "聵", "虧", "虧"],
|
||||||
|
"ㄎㄨㄣ": ["困", "昆", "坤", "捆", "琨", "鯤", "困", "困"],
|
||||||
|
"ㄎㄨㄛ": ["擴", "括", "闊", "廓", "擴", "擴"],
|
||||||
|
"ㄏㄚ": ["哈", "蛤", "哈"],
|
||||||
|
"ㄏㄞ": ["還", "海", "害", "孩", "嗨", "亥", "骸", "氦", "海", "海"],
|
||||||
|
"ㄏㄢ": ["漢", "寒", "汗", "喊", "韓", "旱", "憾", "悍", "翰", "涵", "酣", "憨", "漢", "漢"],
|
||||||
|
"ㄏㄤ": ["行", "航", "杭", "巷", "夯", "吭", "行", "行"],
|
||||||
|
"ㄏㄠ": ["好", "號", "豪", "毫", "浩", "耗", "郝", "蒿", "嚎", "壕", "濠", "好", "好"],
|
||||||
|
"ㄏㄜ": ["和", "合", "河", "何", "核", "賀", "喝", "赫", "褐", "鶴", "荷", "盒", "禾", "嚇", "呵", "和", "和"],
|
||||||
|
"ㄏㄟ": ["黑", "嘿", "黑"],
|
||||||
|
"ㄏㄣ": ["很", "狠", "恨", "痕", "很", "很"],
|
||||||
|
"ㄏㄥ": ["橫", "恆", "衡", "亨", "哼", "橫", "橫"],
|
||||||
|
"ㄏㄨ": ["湖", "呼", "戶", "虎", "護", "互", "忽", "胡", "壺", "狐", "糊", "弧", "蝴", "乎", "滬", "戶", "戶"],
|
||||||
|
"ㄏㄨㄚ": ["話", "花", "化", "華", "畫", "劃", "滑", "嘩", "樺", "驊", "花", "花"],
|
||||||
|
"ㄏㄨㄞ": ["壞", "懷", "槐", "徊", "壞", "壞"],
|
||||||
|
"ㄏㄨㄢ": ["還", "換", "環", "歡", "緩", "患", "喚", "幻", "煥", "桓", "宦", "渙", "瘓", "歡", "歡"],
|
||||||
|
"ㄏㄨㄤ": ["黃", "皇", "荒", "慌", "煌", "晃", "謊", "凰", "惶", "簧", "恍", "黃", "黃"],
|
||||||
|
"ㄏㄨㄟ": ["會", "回", "灰", "輝", "惠", "慧", "繪", "匯", "毀", "悔", "晦", "賄", "穢", "會", "會"],
|
||||||
|
"ㄏㄨㄣ": ["婚", "魂", "混", "渾", "昏", "葷", "餛", "婚", "婚"],
|
||||||
|
"ㄏㄨㄛ": ["活", "火", "或", "夥", "獲", "貨", "禍", "惑", "霍", "豁", "鍬", "鑊", "活", "活"],
|
||||||
|
"ㄐㄧ": ["幾", "機", "己", "記", "計", "集", "基", "際", "極", "擊", "激", "其", "及", "級", "即", "急", "季", "跡", "技", "績", "輯", "籍", "擠", "吉", "雞", "奇", "肌", "饑", "譏", "磯", "姬", "嫉", "棘", "寂", "冀", "驥", "己", "己"],
|
||||||
|
"ㄐㄧㄚ": ["家", "加", "價", "假", "架", "佳", "甲", "駕", "嘉", "稼", "嫁", "夾", "頰", "戛", "枷", "家", "家"],
|
||||||
|
"ㄐㄧㄢ": ["見", "間", "建", "件", "簡", "檢", "堅", "健", "漸", "劍", "鍵", "尖", "肩", "艦", "鑒", "剪", "撿", "踐", "賤", "箭", "澗", "濺", "薦", "餞", "諫", "見", "見"],
|
||||||
|
"ㄐㄧㄤ": ["將", "江", "強", "講", "降", "獎", "疆", "匠", "蔣", "漿", "僵", "薑", "絳", "將", "將"],
|
||||||
|
"ㄐㄧㄠ": ["叫", "教", "腳", "角", "交", "覺", "較", "焦", "膠", "驕", "澆", "攪", "椒", "嬌", "郊", "蕉", "矯", "絞", "僥", "佼", "叫", "叫"],
|
||||||
|
"ㄐㄧㄝ": ["接", "節", "街", "結", "解", "姐", "介", "界", "借", "傑", "潔", "截", "揭", "劫", "捷", "睫", "竭", "桔", "戒", "芥", "藉", "拮", "接", "接"],
|
||||||
|
"ㄐㄧㄣ": ["進", "金", "近", "今", "緊", "盡", "僅", "勁", "錦", "津", "筋", "巾", "斤", "禁", "襟", "瑾", "進", "進"],
|
||||||
|
"ㄐㄧㄥ": ["經", "精", "景", "警", "靜", "境", "競", "淨", "鏡", "徑", "驚", "京", "晶", "睛", "莖", "荊", "兢", "涇", "憬", "經", "經"],
|
||||||
|
"ㄐㄧㄡ": ["就", "九", "久", "酒", "舊", "救", "究", "糾", "舅", "揪", "韭", "灸", "玖", "臼", "就", "就"],
|
||||||
|
"ㄐㄩ": ["句", "具", "據", "局", "舉", "巨", "聚", "居", "距", "懼", "劇", "鋸", "矩", "拒", "俱", "菊", "橘", "颶", "踞", "遽", "句", "句"],
|
||||||
|
"ㄐㄩㄢ": ["卷", "捐", "圈", "眷", "倦", "娟", "雋", "涓", "鐫", "卷", "卷"],
|
||||||
|
"ㄐㄩㄝ": ["決", "覺", "絕", "角", "爵", "掘", "倔", "厥", "譎", "獗", "矍", "嚼", "決", "決"],
|
||||||
|
"ㄐㄩㄣ": ["軍", "君", "均", "俊", "菌", "竣", "鈞", "峻", "雋", "軍", "軍"],
|
||||||
|
"ㄑㄧ": ["起", "其", "氣", "期", "七", "奇", "妻", "棋", "齊", "旗", "企", "啟", "器", "棄", "汽", "祈", "騎", "豈", "漆", "契", "砌", "琪", "淇", "岐", "祁", "崎", "祺", "臍", "訖", "磧", "起", "起"],
|
||||||
|
"ㄑㄧㄚ": ["恰", "洽", "卡", "掐", "髂", "袷", "恰", "恰"],
|
||||||
|
"ㄑㄧㄢ": ["前", "錢", "千", "簽", "遷", "淺", "欠", "牽", "潛", "鉛", "謙", "乾", "嵌", "譴", "倩", "槍", "嗆", "薔", "牆", "強", "搶", "腔", "羌", "嬙", "檣", "鏘", "鏹", "前", "前"],
|
||||||
|
"ㄑㄧㄠ": ["橋", "瞧", "巧", "敲", "俏", "殼", "竅", "喬", "翹", "峭", "撬", "憔", "譙", "樵", "橋", "橋"],
|
||||||
|
"ㄑㄧㄝ": ["切", "且", "茄", "怯", "竊", "妾", "愜", "鍥", "伽", "切", "切"],
|
||||||
|
"ㄑㄧㄣ": ["親", "琴", "勤", "侵", "秦", "欽", "禽", "寢", "沁", "芹", "擒", "噙", "覃", "親", "親"],
|
||||||
|
"ㄑㄧㄥ": ["情", "請", "清", "青", "輕", "慶", "傾", "頃", "晴", "擎", "卿", "氫", "罄", "磬", "蜻", "鯖", "綮", "情", "情"],
|
||||||
|
"ㄑㄩ": ["去", "取", "曲", "區", "趣", "娶", "渠", "屈", "驅", "蛆", "軀", "祛", "瞿", "蛐", "麴", "衢", "去", "去"],
|
||||||
|
"ㄑㄩㄢ": ["全", "權", "圈", "泉", "拳", "犬", "勸", "券", "詮", "痊", "銓", "蜷", "顴", "全", "全"],
|
||||||
|
"ㄑㄩㄝ": ["確", "卻", "缺", "雀", "鵲", "闕", "瘸", "榷", "愨", "確", "確"],
|
||||||
|
"ㄑㄩㄣ": ["群", "裙", "逡", "群", "群"],
|
||||||
|
"ㄒㄧ": ["西", "系", "息", "希", "席", "習", "細", "喜", "戲", "洗", "惜", "稀", "溪", "錫", "析", "膝", "襲", "昔", "熙", "夕", "兮", "悉", "熄", "嬉", "汐", "犀", "烯", "曦", "奚", "唏", "淅", "嘻", "樨", "蠡", "璽", "徙", "隙", "餼", "覡", "西", "西"],
|
||||||
|
"ㄒㄧㄚ": ["下", "夏", "嚇", "廈", "峽", "蝦", "瞎", "霞", "轄", "俠", "暇", "遐", "瑕", "匣", "黠", "硤", "罅", "下", "下"],
|
||||||
|
"ㄒㄧㄢ": ["先", "現", "線", "限", "縣", "顯", "險", "鮮", "獻", "賢", "閒", "仙", "鹹", "羨", "陷", "憲", "餡", "掀", "纖", "閑", "涎", "嫻", "銜", "冼", "燹", "蜆", "筧", "薟", "躚", "先", "先"],
|
||||||
|
"ㄒㄧㄤ": ["想", "向", "相", "鄉", "香", "響", "享", "像", "象", "項", "巷", "降", "箱", "祥", "湘", "詳", "翔", "襄", "鑲", "廂", "驤", "薌", "餉", "緗", "嚮", "想", "想"],
|
||||||
|
"ㄒㄧㄠ": ["小", "笑", "效", "消", "校", "銷", "曉", "蕭", "肖", "削", "孝", "宵", "硝", "霄", "淆", "嘯", "驍", "梟", "瀟", "簫", "筱", "嘵", "蟰", "小", "小"],
|
||||||
|
"ㄒㄧㄝ": ["些", "寫", "謝", "協", "鞋", "血", "歇", "斜", "脅", "諧", "攜", "洩", "卸", "懈", "蟹", "邪", "械", "屑", "偕", "褻", "榭", "廨", "瀣", "薤", "躞", "頡", "擷", "些", "些"],
|
||||||
|
"ㄒㄧㄣ": ["新", "心", "信", "辛", "欣", "薪", "馨", "鑫", "芯", "鋅", "昕", "忻", "歆", "鐔", "囟", "新", "新"],
|
||||||
|
"ㄒㄧㄥ": ["行", "星", "形", "性", "姓", "興", "刑", "型", "幸", "杏", "腥", "猩", "邢", "悻", "滎", "餳", "行", "行"],
|
||||||
|
"ㄒㄩ": ["須", "需", "許", "續", "序", "徐", "虛", "緒", "蓄", "敘", "旭", "恤", "墟", "絮", "婿", "栩", "戌", "詡", "洫", "溆", "酗", "糈", "勖", "昫", "盱", "蓿", "須", "須"],
|
||||||
|
"ㄒㄩㄢ": ["選", "宣", "懸", "旋", "玄", "軒", "喧", "炫", "渲", "萱", "漩", "璇", "癬", "煊", "諼", "鋗", "選", "選"],
|
||||||
|
"ㄒㄩㄝ": ["學", "雪", "血", "穴", "謔", "噱", "鱈", "學", "學"],
|
||||||
|
"ㄒㄩㄣ": ["訊", "迅", "尋", "巡", "訓", "詢", "循", "旬", "熏", "勳", "薰", "潯", "馴", "汛", "遜", "殉", "徇", "巽", "塤", "曛", "窯", "鱘", "訊", "訊"],
|
||||||
|
"ㄓㄚ": ["炸", "紮", "查", "渣", "扎", "眨", "柵", "詐", "乍", "榨", "吒", "砟", "蚱", "齇", "鮓", "醡", "炸", "炸"],
|
||||||
|
"ㄓㄞ": ["債", "寨", "齋", "摘", "窄", "翟", "瘵", "齋", "齋"],
|
||||||
|
"ㄓㄢ": ["站", "展", "戰", "佔", "斬", "瞻", "沾", "詹", "盞", "嶄", "湛", "綻", "輾", "搌", "旃", "站", "站"],
|
||||||
|
"ㄓㄤ": ["長", "張", "章", "掌", "丈", "帳", "仗", "脹", "障", "彰", "漳", "璋", "嶂", "幛", "瘴", "鄣", "張", "張"],
|
||||||
|
"ㄓㄠ": ["找", "照", "招", "朝", "趙", "兆", "罩", "肇", "詔", "沼", "爪", "召", "昭", "嘲", "濯", "櫂", "笊", "招", "招"],
|
||||||
|
"ㄓㄜ": ["這", "著", "者", "折", "哲", "蔗", "遮", "轍", "浙", "褶", "蟄", "鷓", "謫", "輒", "晢", "蜇", "這", "這"],
|
||||||
|
"ㄓㄣ": ["真", "針", "鎮", "陣", "珍", "震", "振", "診", "枕", "斟", "甄", "臻", "疹", "砧", "貞", "偵", "軫", "縝", "榛", "楨", "賑", "禎", "畛", "圳", "蓁", "真", "真"],
|
||||||
|
"ㄓㄥ": ["正", "政", "整", "爭", "證", "鄭", "征", "蒸", "掙", "睜", "錚", "崢", "箏", "怔", "拯", "鉦", "幀", "諍", "癥", "正", "正"],
|
||||||
|
"ㄓㄨ": ["主", "住", "注", "著", "助", "築", "逐", "祝", "豬", "珠", "朱", "諸", "竹", "株", "燭", "矚", "駐", "鑄", "煮", "拄", "囑", "佇", "杼", "渚", "瀦", "躅", "櫫", "褚", "苧", "洙", "麈", "瘃", "主", "主"],
|
||||||
|
"ㄓㄨㄚ": ["抓", "爪", "抓"],
|
||||||
|
"ㄓㄨㄞ": ["轉", "拽", "轉"],
|
||||||
|
"ㄓㄨㄢ": ["專", "轉", "傳", "賺", "磚", "撰", "篆", "饌", "顓", "專", "專"],
|
||||||
|
"ㄓㄨㄤ": ["裝", "狀", "莊", "撞", "壯", "幢", "妝", "樁", "裝", "裝"],
|
||||||
|
"ㄓㄨㄟ": ["追", "墜", "綴", "贅", "縋", "惴", "騅", "追", "追"],
|
||||||
|
"ㄓㄨㄣ": ["準", "諄", "肫", "窀", "準", "準"],
|
||||||
|
"ㄓㄨㄛ": ["著", "桌", "捉", "卓", "濁", "灼", "酌", "拙", "琢", "茁", "擢", "倬", "涿", "浞", "禚", "斫", "桌", "桌"],
|
||||||
|
"ㄔㄚ": ["查", "茶", "差", "插", "察", "剎", "叉", "岔", "詫", "差", "差"],
|
||||||
|
"ㄔㄞ": ["差", "拆", "柴", "豺", "差"],
|
||||||
|
"ㄔㄢ": ["產", "纏", "禪", "蟬", "鏟", "闡", "顫", "摻", "潺", "產", "產"],
|
||||||
|
"ㄔㄤ": ["長", "常", "場", "唱", "廠", "昌", "倡", "嘗", "腸", "暢", "償", "長", "長"],
|
||||||
|
"ㄔㄠ": ["超", "朝", "潮", "吵", "炒", "抄", "鈔", "巢", "嘲", "超", "超"],
|
||||||
|
"ㄔㄜ": ["車", "徹", "撤", "扯", "澈", "車", "車"],
|
||||||
|
"ㄔㄣ": ["陳", "晨", "沉", "趁", "襯", "臣", "塵", "辰", "忱", "陳", "陳"],
|
||||||
|
"ㄔㄥ": ["成", "城", "程", "稱", "承", "誠", "乘", "撐", "橙", "呈", "懲", "成", "成"],
|
||||||
|
"ㄔㄨ": ["出", "處", "初", "除", "書", "楚", "觸", "儲", "廚", "畜", "鋤", "出", "出"],
|
||||||
|
"ㄔㄨㄞ": ["揣", "踹", "揣"],
|
||||||
|
"ㄔㄨㄢ": ["傳", "穿", "船", "川", "串", "喘", "釧", "傳", "傳"],
|
||||||
|
"ㄔㄨㄤ": ["床", "窗", "創", "闖", "幢", "床", "床"],
|
||||||
|
"ㄔㄨㄟ": ["吹", "垂", "錘", "捶", "炊", "吹", "吹"],
|
||||||
|
"ㄔㄨㄣ": ["春", "純", "唇", "淳", "醇", "春", "春"],
|
||||||
|
"ㄔㄨㄛ": ["戳", "綽", "輟", "齪", "戳"],
|
||||||
|
"ㄕㄚ": ["殺", "沙", "紗", "傻", "啥", "煞", "莎", "杉", "剎", "砂", "痧", "裟", "鎩", "霎", "殺", "殺"],
|
||||||
|
"ㄕㄞ": ["曬", "篩", "色", "曬", "曬"],
|
||||||
|
"ㄕㄢ": ["山", "善", "閃", "衫", "扇", "杉", "刪", "珊", "柵", "膳", "擅", "贍", "汕", "潸", "姍", "煽", "跚", "訕", "疝", "鱔", "山", "山"],
|
||||||
|
"ㄕㄤ": ["上", "商", "傷", "尚", "賞", "裳", "熵", "觴", "殤", "垧", "上", "上"],
|
||||||
|
"ㄕㄠ": ["少", "燒", "紹", "稍", "勺", "哨", "韶", "捎", "梢", "芍", "苕", "蛸", "筲", "少", "少"],
|
||||||
|
"ㄕㄜ": ["社", "設", "射", "蛇", "舌", "捨", "涉", "赦", "攝", "奢", "賒", "麝", "懾", "灄", "社", "社"],
|
||||||
|
"ㄕㄣ": ["身", "深", "神", "什", "申", "伸", "審", "慎", "腎", "滲", "沈", "參", "甚", "嬸", "砷", "莘", "哂", "瀋", "糝", "身", "身"],
|
||||||
|
"ㄕㄥ": ["生", "聲", "勝", "升", "省", "聖", "盛", "剩", "繩", "笙", "甥", "晟", "生", "生"],
|
||||||
|
"ㄕㄨ": ["書", "數", "樹", "輸", "術", "述", "叔", "屬", "暑", "署", "鼠", "束", "疏", "舒", "淑", "梳", "抒", "殊", "蔬", "孰", "贖", "熟", "恕", "庶", "墅", "俞", "澍", "紓", "倏", "毹", "書", "書"],
|
||||||
|
"ㄕㄨㄚ": ["刷", "耍", "唰", "刷", "刷"],
|
||||||
|
"ㄕㄨㄞ": ["帥", "率", "摔", "甩", "蟀", "帥", "帥"],
|
||||||
|
"ㄕㄨㄢ": ["栓", "拴", "閂", "涮", "栓", "栓"],
|
||||||
|
"ㄕㄨㄤ": ["雙", "爽", "霜", "孀", "雙", "雙"],
|
||||||
|
"ㄕㄨㄟ": ["水", "說", "稅", "睡", "誰", "水", "水"],
|
||||||
|
"ㄕㄨㄣ": ["順", "瞬", "舜", "吮", "順", "順"],
|
||||||
|
"ㄕㄨㄛ": ["說", "數", "碩", "朔", "爍", "鑠", "蒴", "搠", "說", "說"],
|
||||||
|
"ㄖㄢ": ["然", "燃", "染", "冉", "髯", "蚺", "然", "然"],
|
||||||
|
"ㄖㄤ": ["讓", "嚷", "壤", "攘", "穰", "瓤", "讓", "讓"],
|
||||||
|
"ㄖㄠ": ["擾", "繞", "饒", "嬈", "橈", "蕘", "擾", "擾"],
|
||||||
|
"ㄖㄜ": ["熱", "惹", "喏", "熱", "熱"],
|
||||||
|
"ㄖㄣ": ["人", "認", "任", "仁", "忍", "刃", "韌", "紉", "妊", "葚", "稔", "人", "人"],
|
||||||
|
"ㄖㄥ": ["仍", "扔", "仍", "仍"],
|
||||||
|
"ㄖㄨ": ["如", "入", "儒", "乳", "辱", "孺", "茹", "蠕", "嚅", "濡", "縟", "洳", "如", "如"],
|
||||||
|
"ㄖㄨㄢ": ["軟", "阮", "軟", "軟"],
|
||||||
|
"ㄖㄨㄟ": ["瑞", "銳", "蕊", "芮", "蚋", "枘", "瑞", "瑞"],
|
||||||
|
"ㄖㄨㄣ": ["潤", "閏", "潤", "潤"],
|
||||||
|
"ㄖㄨㄛ": ["若", "弱", "偌", "箬", "蒻", "若", "若"],
|
||||||
|
"ㄗㄚ": ["雜", "砸", "咂", "拶", "雜", "雜"],
|
||||||
|
"ㄗㄞ": ["在", "再", "載", "災", "宰", "栽", "崽", "哉", "在", "在"],
|
||||||
|
"ㄗㄢ": ["咱", "讚", "暫", "拶", "昝", "簪", "糌", "咱", "咱"],
|
||||||
|
"ㄗㄤ": ["藏", "臟", "葬", "臧", "奘", "駔", "臟", "臟"],
|
||||||
|
"ㄗㄠ": ["早", "造", "遭", "燥", "澡", "藻", "棗", "躁", "鑿", "蚤", "皁", "竈", "早", "早"],
|
||||||
|
"ㄗㄜ": ["則", "責", "擇", "澤", "側", "仄", "迮", "幘", "賾", "箦", "則", "則"],
|
||||||
|
"ㄗㄟ": ["賊", "賊", "賊"],
|
||||||
|
"ㄗㄣ": ["怎", "譖", "怎", "怎"],
|
||||||
|
"ㄗㄥ": ["增", "贈", "憎", "甑", "繒", "罾", "增", "增"],
|
||||||
|
"ㄗㄨ": ["租", "族", "組", "阻", "卒", "俎", "詛", "菹", "祖", "祖"],
|
||||||
|
"ㄗㄨㄢ": ["鑽", "纂", "攢", "繵", "躜", "鑽", "鑽"],
|
||||||
|
"ㄗㄨㄟ": ["最", "罪", "嘴", "醉", "蕞", "最", "最"],
|
||||||
|
"ㄗㄨㄣ": ["尊", "遵", "樽", "撙", "尊", "尊"],
|
||||||
|
"ㄗㄨㄛ": ["做", "作", "座", "左", "昨", "佐", "琢", "撮", "唑", "嘬", "怍", "祚", "胙", "做", "做"],
|
||||||
|
"ㄘㄚ": ["擦", "嚓", "擦", "擦"],
|
||||||
|
"ㄘㄞ": ["才", "材", "才", "財", "采", "彩", "菜", "猜", "裁", "踩", "才", "才"],
|
||||||
|
"ㄘㄢ": ["參", "餐", "殘", "慘", "燦", "蠶", "參", "參"],
|
||||||
|
"ㄘㄤ": ["藏", "倉", "蒼", "艙", "藏", "藏"],
|
||||||
|
"ㄘㄠ": ["草", "操", "曹", "糙", "槽", "草", "草"],
|
||||||
|
"ㄘㄜ": ["策", "測", "側", "廁", "冊", "策", "策"],
|
||||||
|
"ㄘㄥ": ["層", "曾", "蹭", "層", "層"],
|
||||||
|
"ㄘㄨ": ["粗", "促", "醋", "簇", "猝", "粗", "粗"],
|
||||||
|
"ㄘㄨㄢ": ["竄", "攢", "篡", "竄", "竄"],
|
||||||
|
"ㄘㄨㄟ": ["催", "脆", "翠", "粹", "崔", "淬", "萃", "催", "催"],
|
||||||
|
"ㄘㄨㄣ": ["村", "存", "寸", "磋", "村", "村"],
|
||||||
|
"ㄘㄨㄛ": ["錯", "措", "搓", "磋", "挫", "錯", "錯"],
|
||||||
|
"ㄙㄚ": ["撒", "灑", "薩", "卅", "颯", "撒", "撒"],
|
||||||
|
"ㄙㄞ": ["賽", "塞", "腮", "鰓", "噻", "賽", "賽"],
|
||||||
|
"ㄙㄢ": ["三", "散", "傘", "參", "霰", "三", "三"],
|
||||||
|
"ㄙㄤ": ["喪", "桑", "嗓", "顙", "搡", "喪", "喪"],
|
||||||
|
"ㄙㄠ": ["掃", "嫂", "騷", "搔", "瘙", "繅", "掃", "掃"],
|
||||||
|
"ㄙㄜ": ["色", "塞", "瑟", "澀", "嗇", "穡", "色", "色"],
|
||||||
|
"ㄙㄣ": ["森", "森", "森"],
|
||||||
|
"ㄙㄥ": ["僧", "僧", "僧"],
|
||||||
|
"ㄙㄨ": ["速", "素", "蘇", "訴", "俗", "塑", "溯", "宿", "粟", "夙", "簌", "愫", "嗉", "謖", "速", "速"],
|
||||||
|
"ㄙㄨㄢ": ["算", "酸", "蒜", "狻", "算", "算"],
|
||||||
|
"ㄙㄨㄟ": ["隨", "歲", "雖", "碎", "遂", "穗", "隧", "髓", "祟", "綏", "邃", "燧", "謁", "隨", "隨"],
|
||||||
|
"ㄙㄨㄣ": ["損", "孫", "筍", "遜", "榫", "蓀", "猻", "損", "損"],
|
||||||
|
"ㄙㄨㄛ": ["所", "鎖", "索", "縮", "瑣", "嗦", "唆", "梭", "嗩", "娑", "蓑", "所", "所"],
|
||||||
|
"ㄧㄚ": ["呀", "壓", "牙", "亞", "雅", "鴨", "押", "芽", "涯", "訝", "崖", "啞", "衙", "軋", "蚜", "睚", "痖", "呀", "呀"],
|
||||||
|
"ㄧㄞ": ["涯", "崖", "睚", "涯"],
|
||||||
|
"ㄧㄢ": ["言", "研", "眼", "嚴", "演", "驗", "煙", "顏", "鹽", "延", "沿", "燕", "宴", "炎", "掩", "衍", "岩", "艷", "雁", "焰", "厭", "彥", "諺", "堰", "硯", "嫣", "閻", "焉", "淹", "偃", "儼", "兗", "讌", "讞", "筵", "蜓", "鼴", "罨", "剡", "鄢", "閆", "滟", "妍", "琰", "罳", "言", "言"],
|
||||||
|
"ㄧㄤ": ["樣", "陽", "洋", "養", "央", "揚", "羊", "氧", "仰", "癢", "漾", "殃", "秧", "恙", "颺", "煬", "佯", "瘍", "鞅", "樣", "樣"],
|
||||||
|
"ㄧㄠ": ["要", "藥", "搖", "遙", "腰", "邀", "耀", "瑤", "姚", "咬", "堯", "鑰", "謠", "夭", "妖", "窯", "杳", "舀", "徭", "珧", "軺", "銚", "鰩", "么", "瘧", "要", "要"],
|
||||||
|
"ㄧㄝ": ["也", "業", "夜", "葉", "爺", "野", "液", "謁", "頁", "邪", "掖", "曳", "腋", "噎", "鄴", "曄", "燁", "鐺", "也", "也"],
|
||||||
|
"ㄧㄣ": ["因", "音", "引", "銀", "印", "飲", "隱", "陰", "吟", "尹", "殷", "茵", "蔭", "垠", "夤", "齦", "湮", "氤", "胤", "鄞", "喑", "洇", "狺", "因", "因"],
|
||||||
|
"ㄧㄥ": ["應", "英", "營", "迎", "影", "贏", "硬", "映", "盈", "穎", "瑩", "鷹", "嬰", "櫻", "瀛", "蠅", "嬴", "罌", "縈", "楹", "熒", "螢", "瀅", "瓔", "鸚", "膺", "瀠", "應", "應"],
|
||||||
|
"ㄨㄚ": ["挖", "哇", "蛙", "瓦", "娃", "襪", "凹", "媧", "佤", "腽", "挖", "挖"],
|
||||||
|
"ㄨㄞ": ["外", "歪", "崴", "外", "外"],
|
||||||
|
"ㄨㄢ": ["完", "晚", "玩", "碗", "彎", "灣", "丸", "婉", "腕", "惋", "宛", "蜿", "豌", "莞", "綰", "剜", "完", "完"],
|
||||||
|
"ㄨㄤ": ["王", "往", "忘", "亡", "望", "網", "旺", "汪", "妄", "罔", "惘", "輞", "尪", "王", "王"],
|
||||||
|
"ㄨㄟ": ["為", "位", "未", "委", "圍", "唯", "威", "偉", "危", "尾", "微", "維", "違", "胃", "餵", "味", "慰", "魏", "衛", "畏", "萎", "偽", "娓", "惟", "巍", "緯", "煒", "韋", "薇", "帷", "渭", "猬", "闈", "洧", "沩", "為", "為"],
|
||||||
|
"ㄨㄣ": ["問", "文", "聞", "溫", "穩", "紋", "吻", "蚊", "雯", "紊", "刎", "璺", "問", "問"],
|
||||||
|
"ㄨㄥ": ["翁", "嗡", "甕", "蓊", "翁", "翁"],
|
||||||
|
"ㄩㄢ": ["元", "原", "員", "圓", "院", "源", "遠", "願", "緣", "園", "怨", "冤", "援", "袁", "淵", "猿", "轅", "媛", "垣", "沅", "塬", "圜", "鴛", "鳶", "螈", "爰", "瑗", "掾", "元", "元"],
|
||||||
|
"ㄩㄝ": ["月", "約", "越", "樂", "曰", "閱", "躍", "悅", "岳", "粵", "鑰", "櫟", "鉞", "瀹", "龠", "刖", "軏", "月", "月"],
|
||||||
|
"ㄩㄣ": ["雲", "運", "員", "韻", "勻", "允", "孕", "蘊", "暈", "隕", "耘", "紜", "慍", "殞", "惲", "醞", "狁", "鄖", "雲", "雲"],
|
||||||
|
"ㄦ": ["二", "兒", "耳", "而", "爾", "餌", "洱", "貳", "兒", "兒"]
|
||||||
|
}
|
||||||
|
}
|
||||||
48267
CustomKeyboard/Resource/english_words.json
Normal file
50005
CustomKeyboard/Resource/indonesian_words.json
Normal file
80
CustomKeyboard/Resource/kb_diacritics_map.json
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"__comment": "长按字符变体映射:languages.<lang>.<baseChar> = 变体数组(第一个建议为 baseChar 本身)。默认只配置小写;大写由代码自动派生。",
|
||||||
|
"languages": {
|
||||||
|
"common": {
|
||||||
|
"__comment": "通用符号长按变体(适用于所有语言)。如需语言特化(西语 ¿/¡ 等),在对应语言下覆盖同名 key 即可。",
|
||||||
|
"-": ["-", "–", "—", "−"],
|
||||||
|
"/": ["/", "\\"],
|
||||||
|
":": [":", ":"],
|
||||||
|
";": [";", ";"],
|
||||||
|
"(": ["(", "(", "[", "{", "<"],
|
||||||
|
")": [")", ")", "]", "}", ">"],
|
||||||
|
".": [".", "…", "..."],
|
||||||
|
",": [",", ","],
|
||||||
|
"\"": ["\"", "“", "”"],
|
||||||
|
"“": ["“", "”", "\""],
|
||||||
|
"'": ["'", "‘", "’"],
|
||||||
|
"‘": ["‘", "’", "'"],
|
||||||
|
"?": ["?", "?"],
|
||||||
|
"!": ["!", "!"],
|
||||||
|
"_": ["_", "—"],
|
||||||
|
"\\": ["\\", "|"],
|
||||||
|
"|": ["|", "¦"],
|
||||||
|
"~": ["~", "~"],
|
||||||
|
"<": ["<", "«", "‹"],
|
||||||
|
">": [">", "»", "›"],
|
||||||
|
"#": ["#", "№"],
|
||||||
|
"%": ["%", "‰"],
|
||||||
|
"*": ["*", "•", "·"],
|
||||||
|
"+": ["+", "±"],
|
||||||
|
"=": ["=", "≠", "≈"],
|
||||||
|
"·": ["·", "•"],
|
||||||
|
"$": ["$", "€", "£", "¥", "₩"],
|
||||||
|
"€": ["€", "$", "£", "¥"],
|
||||||
|
"¥": ["¥", "¥", "$", "€", "£"],
|
||||||
|
"¥": ["¥", "¥", "$", "€", "£"],
|
||||||
|
"0": ["0", "°"],
|
||||||
|
"1": ["1", "¹"],
|
||||||
|
"2": ["2", "²"],
|
||||||
|
"3": ["3", "³"]
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"__comment": "英文(通用拉丁增强):用于输入外来词/人名等。仅配置小写;大写自动派生。",
|
||||||
|
"a": ["a", "à", "á", "â", "ä", "æ", "ã", "å", "ā"],
|
||||||
|
"c": ["c", "ç"],
|
||||||
|
"e": ["e", "è", "é", "ê", "ë", "ē", "ė", "ę"],
|
||||||
|
"i": ["i", "ì", "í", "î", "ï", "ī", "į"],
|
||||||
|
"n": ["n", "ñ"],
|
||||||
|
"o": ["o", "ò", "ó", "ô", "ö", "œ", "õ", "ø", "ō"],
|
||||||
|
"u": ["u", "ù", "ú", "û", "ü", "ū"],
|
||||||
|
"y": ["y", "ÿ"]
|
||||||
|
},
|
||||||
|
"pt": {
|
||||||
|
"a": ["a", "á", "à", "â", "ã", "ä"],
|
||||||
|
"e": ["e", "é", "è", "ê", "ë"],
|
||||||
|
"i": ["i", "í", "ì", "î", "ï"],
|
||||||
|
"o": ["o", "ó", "ò", "ô", "õ", "ö"],
|
||||||
|
"u": ["u", "ú", "ù", "û", "ü"],
|
||||||
|
"c": ["c", "ç"]
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"a": ["a", "á"],
|
||||||
|
"e": ["e", "é"],
|
||||||
|
"i": ["i", "í"],
|
||||||
|
"o": ["o", "ó"],
|
||||||
|
"u": ["u", "ú", "ü"],
|
||||||
|
"n": ["n", "ñ"],
|
||||||
|
"?": ["?", "¿"],
|
||||||
|
"!": ["!", "¡"]
|
||||||
|
},
|
||||||
|
"zh-hant-pinyin": {
|
||||||
|
"__comment": "繁体拼音:长按元音输出声调字符;v 用于 ü / ǖǘǚǜ(常见拼音输入习惯)",
|
||||||
|
"a": ["a", "ā", "á", "ǎ", "à"],
|
||||||
|
"e": ["e", "ē", "é", "ě", "è"],
|
||||||
|
"i": ["i", "ī", "í", "ǐ", "ì"],
|
||||||
|
"o": ["o", "ō", "ó", "ǒ", "ò"],
|
||||||
|
"u": ["u", "ū", "ú", "ǔ", "ù", "ü"],
|
||||||
|
"v": ["v", "ü", "ǖ", "ǘ", "ǚ", "ǜ"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
948
CustomKeyboard/Resource/kb_keyboard_layout_config.json
Normal file
@@ -0,0 +1,948 @@
|
|||||||
|
{
|
||||||
|
"__comment": "键盘布局配置:所有尺寸为设计稿值(会按 designWidth 等比缩放)",
|
||||||
|
"designWidth": 375,
|
||||||
|
"__comment_designWidth": "设计稿宽度(如 375),用于计算缩放比例",
|
||||||
|
"defaultKeyBackground": "#FFFFFF",
|
||||||
|
"__comment_defaultKeyBackground": "无皮肤时按键默认背景色",
|
||||||
|
"metrics": {
|
||||||
|
"__comment": "全局尺寸参数(单位:pt,按 designWidth 缩放)",
|
||||||
|
"rowSpacing": 8,
|
||||||
|
"__comment_rowSpacing": "行间距(垂直)",
|
||||||
|
"topInset": 8,
|
||||||
|
"__comment_topInset": "键盘顶部内边距",
|
||||||
|
"bottomInset": 6,
|
||||||
|
"__comment_bottomInset": "键盘底部内边距",
|
||||||
|
"keyHeight": 41,
|
||||||
|
"__comment_keyHeight": "默认按键高度",
|
||||||
|
"edgeInset": 4,
|
||||||
|
"__comment_edgeInset": "行左右内边距(默认)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "按键之间水平间距",
|
||||||
|
"letterWidth": 32,
|
||||||
|
"__comment_letterWidth": "字母键默认宽度",
|
||||||
|
"controlWidth": 41,
|
||||||
|
"__comment_controlWidth": "控制键宽度(如 shift/backspace/123)",
|
||||||
|
"sendWidth": 88,
|
||||||
|
"__comment_sendWidth": "send 键宽度",
|
||||||
|
"symbolsWideWidth": 47,
|
||||||
|
"__comment_symbolsWideWidth": "符号第3行中间大键宽度",
|
||||||
|
"symbolsSideWidth": 41,
|
||||||
|
"__comment_symbolsSideWidth": "符号第3行左右控制键宽度"
|
||||||
|
},
|
||||||
|
"fonts": {
|
||||||
|
"__comment": "字体大小(pt)",
|
||||||
|
"letter": 20,
|
||||||
|
"__comment_letter": "字母键字体大小",
|
||||||
|
"digit": 20,
|
||||||
|
"__comment_digit": "数字键字体大小",
|
||||||
|
"symbol": 18,
|
||||||
|
"__comment_symbol": "符号键字体大小",
|
||||||
|
"mode": 14,
|
||||||
|
"__comment_mode": "模式切换键字体大小(ABC/#+=/123)",
|
||||||
|
"space": 18,
|
||||||
|
"__comment_space": "空格键字体大小",
|
||||||
|
"send": 18,
|
||||||
|
"__comment_send": "发送键字体大小"
|
||||||
|
},
|
||||||
|
"keyDefs": {
|
||||||
|
"__comment": "特殊功能键配置(id 对应布局中的 item)",
|
||||||
|
"shift": {
|
||||||
|
"__comment": "大小写切换键",
|
||||||
|
"type": "shift",
|
||||||
|
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||||
|
"title": "⇧",
|
||||||
|
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||||
|
"symbolName": "shift",
|
||||||
|
"__comment_symbolName": "无皮肤时使用 SF Symbol 名称",
|
||||||
|
"selectedSymbolName": "shift.fill",
|
||||||
|
"__comment_selectedSymbolName": "选中态 SF Symbol 名称",
|
||||||
|
"font": "symbol",
|
||||||
|
"__comment_font": "使用 fonts 中哪一类字号",
|
||||||
|
"width": "controlWidth",
|
||||||
|
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||||
|
"backgroundColor": "#B7BBC4",
|
||||||
|
"__comment_backgroundColor": "按键背景色"
|
||||||
|
},
|
||||||
|
"backspace": {
|
||||||
|
"__comment": "删除键",
|
||||||
|
"type": "backspace",
|
||||||
|
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||||
|
"title": "⌫",
|
||||||
|
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||||
|
"font": "symbol",
|
||||||
|
"__comment_font": "使用 fonts 中哪一类字号",
|
||||||
|
"width": "controlWidth",
|
||||||
|
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||||
|
"backgroundColor": "#B7BBC4",
|
||||||
|
"__comment_backgroundColor": "按键背景色"
|
||||||
|
},
|
||||||
|
"mode_123": {
|
||||||
|
"__comment": "字母面板左下角 123",
|
||||||
|
"type": "mode",
|
||||||
|
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||||
|
"title": "123",
|
||||||
|
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||||
|
"font": "mode",
|
||||||
|
"__comment_font": "使用 fonts 中哪一类字号",
|
||||||
|
"width": "controlWidth",
|
||||||
|
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||||
|
"backgroundColor": "#B7BBC4",
|
||||||
|
"__comment_backgroundColor": "按键背景色"
|
||||||
|
},
|
||||||
|
"mode_abc": {
|
||||||
|
"__comment": "数字面板左下角 ABC",
|
||||||
|
"type": "mode",
|
||||||
|
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||||
|
"title": "ABC",
|
||||||
|
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||||
|
"font": "mode",
|
||||||
|
"__comment_font": "使用 fonts 中哪一类字号",
|
||||||
|
"width": "controlWidth",
|
||||||
|
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||||
|
"backgroundColor": "#B7BBC4",
|
||||||
|
"__comment_backgroundColor": "按键背景色"
|
||||||
|
},
|
||||||
|
"symbols_toggle_more": {
|
||||||
|
"__comment": "数字面板内 123 -> #+=",
|
||||||
|
"type": "symbolsToggle",
|
||||||
|
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||||
|
"title": "#+=",
|
||||||
|
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||||
|
"font": "mode",
|
||||||
|
"__comment_font": "使用 fonts 中哪一类字号",
|
||||||
|
"width": "symbolsSideWidth",
|
||||||
|
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||||
|
"backgroundColor": "#B7BBC4",
|
||||||
|
"__comment_backgroundColor": "按键背景色"
|
||||||
|
},
|
||||||
|
"symbols_toggle_123": {
|
||||||
|
"__comment": "数字面板内 #+= -> 123",
|
||||||
|
"type": "symbolsToggle",
|
||||||
|
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||||
|
"title": "123",
|
||||||
|
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||||
|
"font": "mode",
|
||||||
|
"__comment_font": "使用 fonts 中哪一类字号",
|
||||||
|
"width": "symbolsSideWidth",
|
||||||
|
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||||
|
"backgroundColor": "#B7BBC4",
|
||||||
|
"__comment_backgroundColor": "按键背景色"
|
||||||
|
},
|
||||||
|
"emoji": {
|
||||||
|
"__comment": "emoji 功能键",
|
||||||
|
"type": "custom",
|
||||||
|
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||||
|
"title": "😁",
|
||||||
|
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||||
|
"font": "symbol",
|
||||||
|
"__comment_font": "使用 fonts 中哪一类字号",
|
||||||
|
"width": "controlWidth",
|
||||||
|
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||||
|
"backgroundColor": "#B7BBC4",
|
||||||
|
"__comment_backgroundColor": "按键背景色"
|
||||||
|
},
|
||||||
|
"space": {
|
||||||
|
"__comment": "空格键",
|
||||||
|
"type": "space",
|
||||||
|
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||||
|
"title": "space",
|
||||||
|
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||||
|
"font": "space",
|
||||||
|
"__comment_font": "使用 fonts 中哪一类字号",
|
||||||
|
"width": "flex",
|
||||||
|
"__comment_width": "flex 表示自动占满剩余空间"
|
||||||
|
},
|
||||||
|
"send": {
|
||||||
|
"__comment": "发送键",
|
||||||
|
"type": "return",
|
||||||
|
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||||
|
"title": "send",
|
||||||
|
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||||
|
"font": "send",
|
||||||
|
"__comment_font": "使用 fonts 中哪一类字号",
|
||||||
|
"width": "sendWidth",
|
||||||
|
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||||
|
"backgroundColor": "#B7BBC4",
|
||||||
|
"__comment_backgroundColor": "按键背景色"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layouts": {
|
||||||
|
"__comment": "布局集合:letters/numbers/symbolsMore",
|
||||||
|
"letters": {
|
||||||
|
"__comment": "字母布局(小写/大写共用)",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"__comment": "字母第一行 qwertyuiop",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"letter:q", "letter:w", "letter:e", "letter:r", "letter:t",
|
||||||
|
"letter:y", "letter:u", "letter:i", "letter:o", "letter:p"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第二行 asdfghjkl",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 23,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 0,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"letter:a", "letter:s", "letter:d", "letter:f", "letter:g",
|
||||||
|
"letter:h", "letter:j", "letter:k", "letter:l"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第三行:左 shift,中间字母,右 backspace",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"segments": {
|
||||||
|
"__comment": "分段布局:left/center/right",
|
||||||
|
"left": [
|
||||||
|
{ "id": "shift", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
|
||||||
|
],
|
||||||
|
"__comment_left": "左侧固定按钮",
|
||||||
|
"center": [
|
||||||
|
"letter:z", "letter:x", "letter:c", "letter:v", "letter:b", "letter:n", "letter:m"
|
||||||
|
],
|
||||||
|
"__comment_center": "中间字母键集合,整体居中",
|
||||||
|
"right": [
|
||||||
|
{ "id": "backspace", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
|
||||||
|
],
|
||||||
|
"__comment_right": "右侧固定按钮"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第四行:123/emoji/space/send",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"mode_123", "emoji", "space", "send"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"letters_es": {
|
||||||
|
"__comment": "西班牙语布局(QWERTY)",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"__comment": "字母第一行 qwertyuiop",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"letter:q", "letter:w", "letter:e", "letter:r", "letter:t",
|
||||||
|
"letter:y", "letter:u", "letter:i", "letter:o", "letter:p"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第二行 asdfghjkl",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"letter:a", "letter:s", "letter:d", "letter:f", "letter:g",
|
||||||
|
"letter:h", "letter:j", "letter:k", "letter:l"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第三行:左 shift,中间字母,右 backspace",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"segments": {
|
||||||
|
"__comment": "分段布局:left/center/right",
|
||||||
|
"left": [
|
||||||
|
{ "id": "shift", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
|
||||||
|
],
|
||||||
|
"__comment_left": "左侧固定按钮",
|
||||||
|
"center": [
|
||||||
|
"letter:z", "letter:x", "letter:c", "letter:v", "letter:b", "letter:n", "letter:m"
|
||||||
|
],
|
||||||
|
"__comment_center": "中间字母键集合,整体居中",
|
||||||
|
"right": [
|
||||||
|
{ "id": "backspace", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
|
||||||
|
],
|
||||||
|
"__comment_right": "右侧固定按钮"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第四行:123/emoji/space/send",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"mode_123", "emoji", "space", "send"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"letters_id": {
|
||||||
|
"__comment": "印度尼西亚语布局(QWERTY)",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"__comment": "字母第一行 qwertyuiop",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"letter:q", "letter:w", "letter:e", "letter:r", "letter:t",
|
||||||
|
"letter:y", "letter:u", "letter:i", "letter:o", "letter:p"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第二行 asdfghjkl",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 23,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 0,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"letter:a", "letter:s", "letter:d", "letter:f", "letter:g",
|
||||||
|
"letter:h", "letter:j", "letter:k", "letter:l"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第三行:左 shift,中间字母,右 backspace",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"segments": {
|
||||||
|
"__comment": "分段布局:left/center/right",
|
||||||
|
"left": [
|
||||||
|
{ "id": "shift", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
|
||||||
|
],
|
||||||
|
"__comment_left": "左侧固定按钮",
|
||||||
|
"center": [
|
||||||
|
"letter:z", "letter:x", "letter:c", "letter:v", "letter:b", "letter:n", "letter:m"
|
||||||
|
],
|
||||||
|
"__comment_center": "中间字母键集合,整体居中",
|
||||||
|
"right": [
|
||||||
|
{ "id": "backspace", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
|
||||||
|
],
|
||||||
|
"__comment_right": "右侧固定按钮"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第四行:123/emoji/space/send",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"mode_123", "emoji", "space", "send"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"letters_pt": {
|
||||||
|
"__comment": "葡萄牙语布局(QWERTY)",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"__comment": "字母第一行 qwertyuiop",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"letter:q", "letter:w", "letter:e", "letter:r", "letter:t",
|
||||||
|
"letter:y", "letter:u", "letter:i", "letter:o", "letter:p"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第二行 asdfghjkl",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 23,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 0,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"letter:a", "letter:s", "letter:d", "letter:f", "letter:g",
|
||||||
|
"letter:h", "letter:j", "letter:k", "letter:l"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第三行:左 shift,中间字母,右 backspace",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"segments": {
|
||||||
|
"__comment": "分段布局:left/center/right",
|
||||||
|
"left": [
|
||||||
|
{ "id": "shift", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
|
||||||
|
],
|
||||||
|
"__comment_left": "左侧固定按钮",
|
||||||
|
"center": [
|
||||||
|
"letter:z", "letter:x", "letter:c", "letter:v", "letter:b", "letter:n", "letter:m"
|
||||||
|
],
|
||||||
|
"__comment_center": "中间字母键集合,整体居中",
|
||||||
|
"right": [
|
||||||
|
{ "id": "backspace", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
|
||||||
|
],
|
||||||
|
"__comment_right": "右侧固定按钮"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第四行:123/emoji/space/send",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"mode_123", "emoji", "space", "send"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"letters_zh_hant_pinyin": {
|
||||||
|
"__comment": "繁体拼音布局(QWERTY)",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"__comment": "字母第一行 qwertyuiop",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"letter:q", "letter:w", "letter:e", "letter:r", "letter:t",
|
||||||
|
"letter:y", "letter:u", "letter:i", "letter:o", "letter:p"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第二行 asdfghjkl",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 23,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 0,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"letter:a", "letter:s", "letter:d", "letter:f", "letter:g",
|
||||||
|
"letter:h", "letter:j", "letter:k", "letter:l"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第三行:左 shift,中间字母,右 backspace",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"segments": {
|
||||||
|
"__comment": "分段布局:left/center/right",
|
||||||
|
"left": [
|
||||||
|
{ "id": "shift", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
|
||||||
|
],
|
||||||
|
"__comment_left": "左侧固定按钮",
|
||||||
|
"center": [
|
||||||
|
"letter:z", "letter:x", "letter:c", "letter:v", "letter:b", "letter:n", "letter:m"
|
||||||
|
],
|
||||||
|
"__comment_center": "中间字母键集合,整体居中",
|
||||||
|
"right": [
|
||||||
|
{ "id": "backspace", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
|
||||||
|
],
|
||||||
|
"__comment_right": "右侧固定按钮"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "字母第四行:123/emoji/space/send",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"mode_123", "emoji", "space", "send"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"numbers": {
|
||||||
|
"__comment": "数字面板布局(123 页)",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"__comment": "数字第一行 1234567890",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"digit:1", "digit:2", "digit:3", "digit:4", "digit:5",
|
||||||
|
"digit:6", "digit:7", "digit:8", "digit:9", "digit:0"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "数字第二行 - / : ; ( ) ¥ & @ “",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"sym:-", "sym:/", "sym::", "sym:;", "sym:(",
|
||||||
|
"sym:)", "sym:¥", "sym:&", "sym:@", "sym:“"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "数字第三行:#+= / 中间符号 / 删除",
|
||||||
|
"align": "center",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"segments": {
|
||||||
|
"__comment": "分段布局:left/center/right",
|
||||||
|
"left": [
|
||||||
|
{ "id": "symbols_toggle_more", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
|
||||||
|
],
|
||||||
|
"__comment_left": "左侧切换按钮",
|
||||||
|
"center": [
|
||||||
|
{ "id": "sym:.", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||||
|
{ "id": "sym:,", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||||
|
{ "id": "sym:?", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||||
|
{ "id": "sym:!", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||||
|
{ "id": "sym:‘", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" }
|
||||||
|
],
|
||||||
|
"__comment_center": "中间符号键集合,整体居中",
|
||||||
|
"right": [
|
||||||
|
{ "id": "backspace", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
|
||||||
|
],
|
||||||
|
"__comment_right": "右侧删除键"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "数字第四行:ABC/emoji/space/send",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"mode_abc", "emoji", "space", "send"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"symbolsMore": {
|
||||||
|
"__comment": "符号面板布局(#+= 页)",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"__comment": "符号第一行 [ ] { } # % ^ * + =",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"sym:[", "sym:]", "sym:{", "sym:}", "sym:#",
|
||||||
|
"sym:%", "sym:^", "sym:*", "sym:+", "sym:="
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "符号第二行 _ \\ | ~ < > € ¥ $ ·",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"sym:_", "sym:\\", "sym:|", "sym:~", "sym:<",
|
||||||
|
"sym:>", "sym:€", "sym:¥", "sym:$", "sym:·"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "符号第三行:123 / 中间符号 / 删除",
|
||||||
|
"align": "center",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"segments": {
|
||||||
|
"__comment": "分段布局:left/center/right",
|
||||||
|
"left": [
|
||||||
|
{ "id": "symbols_toggle_123", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
|
||||||
|
],
|
||||||
|
"__comment_left": "左侧切换按钮",
|
||||||
|
"center": [
|
||||||
|
{ "id": "sym:.", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||||
|
{ "id": "sym:,", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||||
|
{ "id": "sym:?", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||||
|
{ "id": "sym:!", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||||
|
{ "id": "sym:‘", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" }
|
||||||
|
],
|
||||||
|
"__comment_center": "中间符号键集合,整体居中",
|
||||||
|
"right": [
|
||||||
|
{ "id": "backspace", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
|
||||||
|
],
|
||||||
|
"__comment_right": "右侧删除键"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "符号第四行:ABC/emoji/space/send",
|
||||||
|
"align": "left",
|
||||||
|
"__comment_align": "对齐方式:left/center",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||||
|
"insetRight": 4,
|
||||||
|
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||||
|
"gap": 5,
|
||||||
|
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||||
|
"items": [
|
||||||
|
"mode_abc", "emoji", "space", "send"
|
||||||
|
],
|
||||||
|
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"letters_azerty": {
|
||||||
|
"__comment": "AZERTY 布局(法语)- 下个版本启用",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"__comment": "第一行 azertyuiop",
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"letter:a", "letter:z", "letter:e", "letter:r", "letter:t",
|
||||||
|
"letter:y", "letter:u", "letter:i", "letter:o", "letter:p"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "第二行 qsdfghjklm",
|
||||||
|
"align": "center",
|
||||||
|
"insetLeft": 0,
|
||||||
|
"insetRight": 0,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"letter:q", "letter:s", "letter:d", "letter:f", "letter:g",
|
||||||
|
"letter:h", "letter:j", "letter:k", "letter:l", "letter:m"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "第三行:shift + wxcvbn + backspace",
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"segments": {
|
||||||
|
"left": [
|
||||||
|
{ "id": "shift", "width": "controlWidth" }
|
||||||
|
],
|
||||||
|
"center": [
|
||||||
|
"letter:w", "letter:x", "letter:c", "letter:v", "letter:b", "letter:n"
|
||||||
|
],
|
||||||
|
"right": [
|
||||||
|
{ "id": "backspace", "width": "controlWidth" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "第四行:123/emoji/space/send",
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"mode_123", "emoji", "space", "send"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"letters_qwertz": {
|
||||||
|
"__comment": "QWERTZ 布局(德语)- 下个版本启用",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"__comment": "第一行 qwertzuiop",
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"letter:q", "letter:w", "letter:e", "letter:r", "letter:t",
|
||||||
|
"letter:z", "letter:u", "letter:i", "letter:o", "letter:p"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "第二行 asdfghjkl",
|
||||||
|
"align": "center",
|
||||||
|
"insetLeft": 0,
|
||||||
|
"insetRight": 0,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"letter:a", "letter:s", "letter:d", "letter:f", "letter:g",
|
||||||
|
"letter:h", "letter:j", "letter:k", "letter:l"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "第三行:shift + yxcvbnm + backspace",
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"segments": {
|
||||||
|
"left": [
|
||||||
|
{ "id": "shift", "width": "controlWidth" }
|
||||||
|
],
|
||||||
|
"center": [
|
||||||
|
"letter:y", "letter:x", "letter:c", "letter:v", "letter:b", "letter:n", "letter:m"
|
||||||
|
],
|
||||||
|
"right": [
|
||||||
|
{ "id": "backspace", "width": "controlWidth" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"__comment": "第四行:123/emoji/space/send",
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"mode_123", "emoji", "space", "send"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"letters_bopomofo_full": {
|
||||||
|
"__comment": "繁体注音全键盘布局(iOS 标准注音排列)",
|
||||||
|
"__comment_layout": "第一行:ㄅㄉˇˋㄓˊ˙ㄚㄞㄢㄦ | 第二行:ㄆㄊㄍㄐㄔㄗㄧㄛㄟㄣ | 第三行:ㄇㄋㄎㄑㄕㄘㄨㄜㄠㄤ | 第四行:ㄈㄌㄏㄒㄖㄙㄩㄝㄡㄥ",
|
||||||
|
"rowSpacing": 3,
|
||||||
|
"topInset": 5,
|
||||||
|
"bottomInset": 0,
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 6,
|
||||||
|
"items": [
|
||||||
|
"letter:ㄅ", "letter:ㄉ", "letter:ˇ", "letter:ˋ", "letter:ㄓ",
|
||||||
|
"letter:ˊ", "letter:˙", "letter:ㄚ", "letter:ㄞ", "letter:ㄢ", "letter:ㄦ"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 15,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"letter:ㄆ", "letter:ㄊ", "letter:ㄍ", "letter:ㄐ", "letter:ㄔ",
|
||||||
|
"letter:ㄗ", "letter:ㄧ", "letter:ㄛ", "letter:ㄟ", "letter:ㄣ"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 27,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"letter:ㄇ", "letter:ㄋ", "letter:ㄎ", "letter:ㄑ", "letter:ㄕ",
|
||||||
|
"letter:ㄘ", "letter:ㄨ", "letter:ㄜ", "letter:ㄠ", "letter:ㄤ"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"letter:ㄈ", "letter:ㄌ", "letter:ㄏ", "letter:ㄒ", "letter:ㄖ",
|
||||||
|
"letter:ㄙ", "letter:ㄩ", "letter:ㄝ", "letter:ㄡ", "letter:ㄥ", "backspace"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"mode_123", "emoji", "space", "send"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"letters_bopomofo_standard": {
|
||||||
|
"__comment": "繁体注音标准布局(与全键盘相同)",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"letter:ㄅ", "letter:ㄉ", "letter:ˇ", "letter:ˋ", "letter:ㄓ",
|
||||||
|
"letter:ˊ", "letter:˙", "letter:ㄚ", "letter:ㄞ", "letter:ㄢ", "letter:ㄦ"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 15,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"letter:ㄆ", "letter:ㄊ", "letter:ㄍ", "letter:ㄐ", "letter:ㄔ",
|
||||||
|
"letter:ㄗ", "letter:ㄧ", "letter:ㄛ", "letter:ㄟ", "letter:ㄣ"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 27,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"letter:ㄇ", "letter:ㄋ", "letter:ㄎ", "letter:ㄑ", "letter:ㄕ",
|
||||||
|
"letter:ㄘ", "letter:ㄨ", "letter:ㄜ", "letter:ㄠ", "letter:ㄤ"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"letter:ㄈ", "letter:ㄌ", "letter:ㄏ", "letter:ㄒ", "letter:ㄖ",
|
||||||
|
"letter:ㄙ", "letter:ㄩ", "letter:ㄝ", "letter:ㄡ", "letter:ㄥ", "backspace"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"align": "left",
|
||||||
|
"insetLeft": 4,
|
||||||
|
"insetRight": 4,
|
||||||
|
"gap": 5,
|
||||||
|
"items": [
|
||||||
|
"mode_123", "emoji", "space", "send"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1654
CustomKeyboard/Resource/kb_keyboard_layouts_i18n.json
Normal file
234454
CustomKeyboard/Resource/kb_words.txt
Normal file
405
CustomKeyboard/Resource/pinyin_to_traditional.json
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
{
|
||||||
|
"__comment": "繁体拼音映射表:拼音 -> 繁体字候选词列表",
|
||||||
|
"mappings": {
|
||||||
|
"a": ["阿", "啊", "呀"],
|
||||||
|
"ai": ["愛", "愛", "艾", "哀", "矮", "礙", "挨", "唉"],
|
||||||
|
"an": ["安", "按", "暗", "岸", "案", "俺", "鞍"],
|
||||||
|
"ang": ["昂", "盎"],
|
||||||
|
"ao": ["奧", "傲", "熬", "澳", "襖", "懊", "敖"],
|
||||||
|
"ba": ["吧", "把", "八", "爸", "巴", "拔", "罷", "霸", "扒", "叭"],
|
||||||
|
"bai": ["白", "百", "拜", "敗", "柏", "擺", "佰"],
|
||||||
|
"ban": ["辦", "班", "般", "板", "版", "半", "伴", "扮", "拌", "瓣", "頒"],
|
||||||
|
"bang": ["幫", "邦", "榜", "膀", "綁", "棒", "磅"],
|
||||||
|
"bao": ["包", "保", "報", "寶", "抱", "暴", "爆", "薄", "爆", "豹", "飽", "堡", "刨"],
|
||||||
|
"bei": ["北", "被", "背", "備", "悲", "杯", "碑", "輩", "倍", "貝"],
|
||||||
|
"ben": ["本", "奔", "笨", "盆"],
|
||||||
|
"beng": ["崩", "繃", "蹦", "泵"],
|
||||||
|
"bi": ["比", "必", "筆", "畢", "避", "閉", "鼻", "彼", "碧", "壁", "弊", "臂", "秘", "辟", "逼"],
|
||||||
|
"bian": ["變", "便", "邊", "編", "辯", "遍", "鞭", "辨", "扁", "貶"],
|
||||||
|
"biao": ["表", "標", "彪", "錶", "鏢"],
|
||||||
|
"bie": ["別", "憋", "癟"],
|
||||||
|
"bin": ["賓", "彬", "斌", "瀕", "濱"],
|
||||||
|
"bing": ["病", "並", "冰", "兵", "餅", "柄", "秉", "稟"],
|
||||||
|
"bo": ["不", "波", "博", "播", "伯", "薄", "泊", "柏", "勃", "搏", "撥", "剝", "脖", "博"],
|
||||||
|
"bu": ["不", "步", "部", "布", "補", "捕", "簿", "卜", "怖"],
|
||||||
|
"ca": ["擦", "嚓"],
|
||||||
|
"cai": ["才", "材", "才", "財", "采", "彩", "菜", "猜", "裁", "踩"],
|
||||||
|
"can": ["參", "餐", "殘", "慘", "燦", "蠶"],
|
||||||
|
"cang": ["藏", "倉", "蒼", "艙"],
|
||||||
|
"cao": ["草", "操", "曹", "糙", "槽"],
|
||||||
|
"ce": ["策", "測", "側", "廁", "冊"],
|
||||||
|
"ceng": ["層", "曾", "蹭"],
|
||||||
|
"cha": ["查", "茶", "差", "插", "察", "剎", "叉", "岔", "詫"],
|
||||||
|
"chai": ["差", "拆", "柴", "豺"],
|
||||||
|
"chan": ["產", "纏", "禪", "蟬", "鏟", "闡", "顫", "摻", "潺"],
|
||||||
|
"chang": ["長", "常", "場", "唱", "廠", "昌", "倡", "嘗", "腸", "暢", "償"],
|
||||||
|
"chao": ["超", "朝", "潮", "吵", "炒", "抄", "鈔", "巢", "嘲"],
|
||||||
|
"che": ["車", "徹", "撤", "扯", "澈"],
|
||||||
|
"chen": ["陳", "晨", "沉", "趁", "襯", "臣", "塵", "辰", "忱"],
|
||||||
|
"cheng": ["成", "城", "程", "稱", "承", "誠", "乘", "撐", "橙", "呈", "懲", "撐"],
|
||||||
|
"chi": ["吃", "持", "遲", "池", "尺", "齒", "赤", "翅", "斥", "馳", "癡", "侈"],
|
||||||
|
"chong": ["充", "衝", "蟲", "重", "崇", "寵", "沖", "憧"],
|
||||||
|
"chou": ["抽", "愁", "醜", "臭", "仇", "籌", "稠", "綢", "酬", "疇"],
|
||||||
|
"chu": ["出", "處", "初", "除", "書", "楚", "觸", "儲", "廚", "畜", "鋤"],
|
||||||
|
"chuai": ["揣", "踹"],
|
||||||
|
"chuan": ["傳", "穿", "船", "川", "串", "喘", "釧"],
|
||||||
|
"chuang": ["床", "窗", "創", "闖", "幢"],
|
||||||
|
"chui": ["吹", "垂", "錘", "捶", "炊"],
|
||||||
|
"chun": ["春", "純", "唇", "淳", "醇"],
|
||||||
|
"ci": ["次", "此", "詞", "辭", "慈", "瓷", "磁", "賜", "刺", "茨"],
|
||||||
|
"cong": ["從", "聰", "匆", "蔥", "叢", "淙"],
|
||||||
|
"cou": ["湊"],
|
||||||
|
"cu": ["粗", "促", "醋", "簇", "猝"],
|
||||||
|
"cuan": ["竄", "攢", "篡"],
|
||||||
|
"cui": ["催", "脆", "翠", "粹", "崔", "淬", "萃"],
|
||||||
|
"cun": ["村", "存", "寸", "磋"],
|
||||||
|
"cuo": ["錯", "措", "搓", "磋", "挫"],
|
||||||
|
"da": ["大", "打", "答", "達", "搭", "塔", "瘩"],
|
||||||
|
"dai": ["大", "代", "帶", "待", "袋", "戴", "呆", "貸", "逮", "怠", "殆", "黛"],
|
||||||
|
"dan": ["但", "單", "擔", "膽", "丹", "淡", "蛋", "誕", "彈", "旦", "氮", "耽"],
|
||||||
|
"dang": ["當", "黨", "檔", "擋", "蕩", "檔", "宕"],
|
||||||
|
"dao": ["到", "道", "導", "刀", "倒", "島", "盜", "悼", "搗", "禱", "蹈"],
|
||||||
|
"de": ["的", "得", "德", "底"],
|
||||||
|
"dei": ["得"],
|
||||||
|
"deng": ["等", "燈", "登", "鄧", "瞪", "凳", "蹬"],
|
||||||
|
"di": ["的", "地", "第", "低", "底", "敵", "弟", "帝", "抵", "遞", "迪", "滴", "堤", "笛", "締"],
|
||||||
|
"dia": ["嗲"],
|
||||||
|
"dian": ["點", "電", "店", "典", "墊", "澱", "殿", "顛", "滇", "碘", "巔"],
|
||||||
|
"diao": ["調", "掉", "吊", "雕", "刁", "釣", "凋", "碉"],
|
||||||
|
"die": ["爹", "跌", "叠", "蝶", "碟", "諜", "迭", "帖", "耋"],
|
||||||
|
"ding": ["定", "訂", "頂", "丁", "釘", "盯", "叮", "鼎", "叮"],
|
||||||
|
"diu": ["丟"],
|
||||||
|
"dong": ["動", "東", "冬", "懂", "洞", "凍", "棟", "董", "咚"],
|
||||||
|
"dou": ["都", "鬥", "豆", "抖", "逗", "兜", "痘"],
|
||||||
|
"du": ["讀", "都", "度", "獨", "毒", "渡", "杜", "肚", "堵", "賭", "鍍", "督"],
|
||||||
|
"duan": ["段", "斷", "短", "鍛", "緞", "端"],
|
||||||
|
"dui": ["對", "隊", "堆", "兌", "懟"],
|
||||||
|
"dun": ["頓", "噸", "盾", "蹲", "敦", "墩", "燉", "鈍"],
|
||||||
|
"duo": ["多", "度", "奪", "躲", "朵", "墮", "舵", "跺", "惰", "哆"],
|
||||||
|
"e": ["餓", "惡", "額", "俄", "鵝", "娥", "訛", "峨", "扼", "遏", "鄂", "噩"],
|
||||||
|
"ei": ["誒"],
|
||||||
|
"en": ["恩", "摁"],
|
||||||
|
"er": ["二", "兒", "耳", "而", "爾", "餌", "洱", "貳"],
|
||||||
|
"fa": ["發", "法", "罰", "乏", "伐", "閥", "筏", "佳"],
|
||||||
|
"fan": ["反", "飯", "煩", "繁", "範", "犯", "泛", "番", "翻", "凡", "帆", "返", "販", "礬"],
|
||||||
|
"fang": ["方", "放", "房", "防", "訪", "仿", "芳", "坊", "妨", "紡", "舫"],
|
||||||
|
"fei": ["非", "飛", "費", "肥", "廢", "匪", "誹", "啡", "菲", "沸", "翡", "吠"],
|
||||||
|
"fen": ["分", "份", "粉", "奮", "憤", "紛", "芬", "墳", "焚", "氛", "糞"],
|
||||||
|
"feng": ["風", "封", "豐", "峰", "鋒", "蜂", "瘋", "逢", "縫", "鳳", "奉", "諷", "楓"],
|
||||||
|
"fo": ["佛"],
|
||||||
|
"fou": ["否", "縫"],
|
||||||
|
"fu": ["父", "夫", "付", "服", "福", "府", "負", "富", "復", "副", "婦", "撫", "附", "幅", "浮", "腐", "符", "弗", "腹", "輻", "敷", "氟", "芙", "敷"],
|
||||||
|
"ga": ["嘎", "噶", "軋"],
|
||||||
|
"gai": ["改", "該", "蓋", "概", "溉", "丐", "芥", "鈣"],
|
||||||
|
"gan": ["幹", "感", "敢", "甘", "肝", "趕", "桿", "乾", "贛", "柑", "竿", "尴", "擀"],
|
||||||
|
"gang": ["剛", "鋼", "港", "崗", "綱", "岡", "缸", "槓", "扛"],
|
||||||
|
"gao": ["高", "告", "搞", "稿", "糕", "鎬", "膏", "篙"],
|
||||||
|
"ge": ["個", "各", "歌", "格", "哥", "割", "革", "隔", "閣", "葛", "戈", "擱", "鴿", "胳", "骼"],
|
||||||
|
"gei": ["給"],
|
||||||
|
"gen": ["跟", "根", "亙", "艮"],
|
||||||
|
"geng": ["更", "耕", "庚", "羹", "耿", "梗"],
|
||||||
|
"gong": ["工", "公", "共", "供", "功", "攻", "宮", "恭", "鞏", "弓", "躬", "拱", "貢"],
|
||||||
|
"gou": ["狗", "夠", "構", "購", "溝", "鉤", "勾", "苟", "垢", "篝"],
|
||||||
|
"gu": ["古", "故", "顧", "骨", "谷", "股", "鼓", "固", "孤", "姑", "辜", "沽", "咕", "估"],
|
||||||
|
"gua": ["掛", "瓜", "刮", "寡", "呱", "褂"],
|
||||||
|
"guai": ["怪", "乖", "拐"],
|
||||||
|
"guan": ["關", "觀", "管", "官", "館", "慣", "灌", "冠", "罐", "貫", "棺", "倌"],
|
||||||
|
"guang": ["光", "廣", "逛", "胱"],
|
||||||
|
"gui": ["貴", "規", "歸", "鬼", "軌", "櫃", "桂", "跪", "龜", "瑰", "詭", "閨"],
|
||||||
|
"gun": ["滾", "棍"],
|
||||||
|
"guo": ["過", "國", "果", "鍋", "郭", "裹", "渦"],
|
||||||
|
"ha": ["哈", "蛤"],
|
||||||
|
"hai": ["還", "海", "害", "孩", "嗨", "亥", "骸", "氦"],
|
||||||
|
"han": ["漢", "寒", "汗", "喊", "韓", "旱", "憾", "悍", "翰", "涵", "酣", "憨"],
|
||||||
|
"hang": ["行", "航", "杭", "巷", "夯", "吭"],
|
||||||
|
"hao": ["好", "號", "豪", "毫", "浩", "耗", "郝", "蒿", "嚎", "壕", "濠"],
|
||||||
|
"he": ["和", "合", "河", "何", "核", "賀", "喝", "赫", "褐", "鶴", "荷", "盒", "禾", "嚇", "呵"],
|
||||||
|
"hei": ["黑", "嘿"],
|
||||||
|
"hen": ["很", "狠", "恨", "痕"],
|
||||||
|
"heng": ["橫", "恆", "衡", "亨", "哼"],
|
||||||
|
"hong": ["紅", "轟", "洪", "宏", "虹", "鴻", "烘", "弘", "訌", "泓"],
|
||||||
|
"hou": ["後", "候", "厚", "喉", "猴", "吼", "侯", "吼"],
|
||||||
|
"hu": ["湖", "呼", "戶", "虎", "護", "互", "忽", "胡", "壺", "狐", "糊", "弧", "蝴", "乎", "滬"],
|
||||||
|
"hua": ["話", "花", "化", "華", "畫", "劃", "滑", "嘩", "樺", "驊"],
|
||||||
|
"huai": ["壞", "懷", "槐", "徊"],
|
||||||
|
"huan": ["還", "換", "環", "歡", "緩", "患", "喚", "幻", "煥", "桓", "宦", "渙", "瘓"],
|
||||||
|
"huang": ["黃", "皇", "荒", "慌", "煌", "晃", "謊", "凰", "惶", "煌", "簧", "恍"],
|
||||||
|
"hui": ["會", "回", "灰", "輝", "輝", "惠", "慧", "繪", "匯", "輝", "毀", "悔", "晦", "賄", "穢"],
|
||||||
|
"hun": ["婚", "魂", "混", "渾", "昏", "葷", "餛"],
|
||||||
|
"huo": ["活", "火", "或", "夥", "獲", "貨", "禍", "惑", "霍", "豁", "鍬", "鑊"],
|
||||||
|
"ji": ["幾", "機", "己", "記", "計", "集", "基", "際", "極", "擊", "激", "其", "及", "級", "即", "急", "季", "跡", "技", "績", "輯", "籍", "擠", "吉", "雞", "奇", "肌", "饑", "譏", "磯", "姬", "嫉", "棘", "寂", "冀", "驥"],
|
||||||
|
"jia": ["家", "加", "價", "假", "架", "佳", "甲", "駕", "嘉", "稼", "嫁", "夾", "頰", "戛", "枷"],
|
||||||
|
"jian": ["見", "間", "建", "件", "簡", "檢", "堅", "健", "漸", "劍", "鍵", "尖", "肩", "艦", "鑒", "剪", "撿", "踐", "賤", "箭", "澗", "濺", "薦", "餞", "漸", "諫"],
|
||||||
|
"jiang": ["將", "江", "強", "講", "降", "獎", "疆", "匠", "蔣", "漿", "僵", "薑", "絳"],
|
||||||
|
"jiao": ["叫", "教", "腳", "角", "交", "覺", "較", "焦", "膠", "驕", "澆", "攪", "椒", "嬌", "郊", "蕉", "矯", "絞", "僥", "佼", "僥"],
|
||||||
|
"jie": ["接", "節", "街", "結", "解", "姐", "介", "界", "借", "傑", "潔", "截", "揭", "劫", "捷", "睫", "竭", "桔", "戒", "芥", "藉", "拮"],
|
||||||
|
"jin": ["進", "金", "近", "今", "緊", "盡", "僅", "勁", "錦", "津", "筋", "巾", "斤", "禁", "襟", "瑾"],
|
||||||
|
"jing": ["經", "精", "景", "警", "靜", "境", "競", "淨", "鏡", "徑", "驚", "京", "晶", "睛", "莖", "荊", "兢", "涇", "憬"],
|
||||||
|
"jiong": ["窘", "炯", "迥"],
|
||||||
|
"jiu": ["就", "九", "久", "酒", "舊", "救", "究", "糾", "舅", "揪", "韭", "灸", "玖", "臼"],
|
||||||
|
"ju": ["句", "具", "據", "局", "舉", "巨", "聚", "居", "距", "懼", "劇", "鋸", "矩", "拒", "俱", "菊", "橘", "颶", "踞", "遽"],
|
||||||
|
"juan": ["卷", "捐", "圈", "眷", "倦", "娟", "雋", "涓", "鐫"],
|
||||||
|
"jue": ["決", "覺", "絕", "角", "爵", "掘", "倔", "厥", "譎", "獗", "矍", "嚼"],
|
||||||
|
"jun": ["軍", "君", "均", "俊", "菌", "竣", "鈞", "峻", "雋"],
|
||||||
|
"ka": ["卡", "咖", "喀", "咔"],
|
||||||
|
"kai": ["開", "凱", "楷", "慨", "愷", "鎧", "鍇"],
|
||||||
|
"kan": ["看", "砍", "坎", "勘", "刊", "堪", "瞰", "龕"],
|
||||||
|
"kang": ["康", "抗", "扛", "亢", "糠", "慷", "伉"],
|
||||||
|
"kao": ["考", "靠", "烤", "拷", "栲", "犒"],
|
||||||
|
"ke": ["可", "客", "科", "刻", "課", "顆", "克", "渴", "柯", "棵", "磕", "咳", "殼", "坷"],
|
||||||
|
"ken": ["肯", "懇", "啃", "齦"],
|
||||||
|
"keng": ["坑", "吭", "鏗"],
|
||||||
|
"kong": ["空", "控", "恐", "孔"],
|
||||||
|
"kou": ["口", "扣", "叩", "寇", "摳"],
|
||||||
|
"ku": ["苦", "哭", "庫", "酷", "枯", "窟", "骷"],
|
||||||
|
"kua": ["跨", "誇", "垮", "挎", "胯"],
|
||||||
|
"kuai": ["快", "塊", "筷", "儈", "膾"],
|
||||||
|
"kuan": ["寬", "款"],
|
||||||
|
"kuang": ["況", "礦", "狂", "框", "曠", "眶", "筐", "匡", "誑"],
|
||||||
|
"kui": ["虧", "愧", "潰", "窺", "葵", "魁", "饋", "匱", "睽", "聵"],
|
||||||
|
"kun": ["困", "昆", "坤", "捆", "琨", "鯤"],
|
||||||
|
"kuo": ["擴", "括", "闊", "廓"],
|
||||||
|
"la": ["拉", "啦", "蠟", "辣", "臘", "喇", "落"],
|
||||||
|
"lai": ["來", "賴", "萊", "徠", "賚"],
|
||||||
|
"lan": ["藍", "蘭", "攔", "籃", "懶", "爛", "濫", "覽", "欄", "瀾", "嵐", "襤"],
|
||||||
|
"lang": ["浪", "郎", "狼", "廊", "朗", "琅", "螂", "朗"],
|
||||||
|
"lao": ["老", "勞", "落", "牢", "撈", "澇", "絡", "姥", "佬", "潦"],
|
||||||
|
"le": ["了", "樂", "勒", "肋"],
|
||||||
|
"lei": ["累", "類", "淚", "雷", "勒", "壘", "蕾", "磊", "擂", "鐳", "儡"],
|
||||||
|
"leng": ["冷", "愣", "楞"],
|
||||||
|
"li": ["裡", "力", "理", "利", "立", "離", "例", "歷", "李", "禮", "麗", "勵", "梨", "厘", "莉", "犁", "黎", "璃", "狸", "漓", "罹", "驪", "鱧", "吏", "栗"],
|
||||||
|
"lia": ["倆"],
|
||||||
|
"lian": ["連", "聯", "臉", "練", "蓮", "戀", "煉", "廉", "憐", "漣", "鐮", "斂", "璉"],
|
||||||
|
"liang": ["兩", "亮", "量", "良", "涼", "梁", "糧", "樑", "諒", "晾", "踉", "靚"],
|
||||||
|
"liao": ["了", "料", "聊", "療", "遼", "撩", "僚", "燎", "繚", "潦", "寥", "嘹"],
|
||||||
|
"lie": ["列", "烈", "獵", "裂", "劣", "咧", "冽", "捩", "躐"],
|
||||||
|
"lin": ["林", "臨", "鄰", "淋", "琳", "霖", "鱗", "麟", "遴", "藺", "吝", "躪"],
|
||||||
|
"ling": ["領", "零", "靈", "令", "另", "玲", "鈴", "陵", "嶺", "凌", "菱", "羚", "翎", "聆", "伶", "拎"],
|
||||||
|
"liu": ["六", "流", "留", "劉", "柳", "溜", "琉", "榴", "硫", "溜", "鎏", "鷚"],
|
||||||
|
"long": ["龍", "隆", "弄", "籠", "聾", "攏", "壟", "朗", "隴"],
|
||||||
|
"lou": ["樓", "漏", "露", "婁", "摟", "簍", "嘍", "螻"],
|
||||||
|
"lu": ["路", "錄", "陸", "綠", "露", "旅", "律", "慮", "呂", "履", "侶", "屢", "濾", "氯", "廬", "爐", "蘆", "盧", "顱", "魯", "擼", "祿", "麓"],
|
||||||
|
"lv": ["綠", "律", "旅", "慮", "呂", "履", "侶", "屢", "濾", "氯"],
|
||||||
|
"luan": ["亂", "卵", "巒", "鑾", "鸞", "欒"],
|
||||||
|
"lue": ["略", "掠"],
|
||||||
|
"lun": ["論", "輪", "倫", "侖", "綸", "淪"],
|
||||||
|
"luo": ["落", "羅", "洛", "絡", "邏", "鑼", "籮", "駱", "裸", "螺", "蘿", "摞"],
|
||||||
|
"ma": ["嗎", "媽", "馬", "麻", "罵", "嘛", "螞", "碼", "瑪", "抹", "摩"],
|
||||||
|
"mai": ["買", "賣", "麥", "埋", "邁", "脈", "霾"],
|
||||||
|
"man": ["滿", "慢", "曼", "漫", "蠻", "瞞", "饅", "蔓", "謾", "墁", "幔"],
|
||||||
|
"mang": ["忙", "盲", "茫", "芒", "莽", "氓", "硭"],
|
||||||
|
"mao": ["貓", "毛", "矛", "茅", "茂", "冒", "帽", "貌", "貿", "卯", "錨", "耄", "髦", "瑁", "懋"],
|
||||||
|
"me": ["麼"],
|
||||||
|
"mei": ["沒", "美", "妹", "每", "梅", "媒", "煤", "眉", "霉", "魅", "玫", "枚", "寐", "昧", "媚", "湄", "鎂", "糜"],
|
||||||
|
"men": ["們", "門", "悶", "燜", "捫"],
|
||||||
|
"meng": ["夢", "孟", "猛", "蒙", "盟", "萌", "朦", "檬", "懵", "礞", "蠐"],
|
||||||
|
"mi": ["米", "密", "迷", "蜜", "祕", "祕", "眯", "靡", "糜", "彌", "覓", "冪", "泌"],
|
||||||
|
"mian": ["面", "免", "棉", "眠", "綿", "勉", "緬", "冕", "娩", "湎", "眄"],
|
||||||
|
"miao": ["描", "秒", "妙", "廟", "苗", "瞄", "渺", "淼", "緲", "藐"],
|
||||||
|
"mie": ["滅", "蔑", "篾", "乜"],
|
||||||
|
"min": ["民", "敏", "名", "皿", "閔", "抿", "泯", "憫", "閔"],
|
||||||
|
"ming": ["名", "明", "命", "鳴", "銘", "冥", "茗", "溟", "瞑", "螟"],
|
||||||
|
"miu": ["謬"],
|
||||||
|
"mo": ["麼", "摸", "磨", "摩", "魔", "膜", "默", "墨", "抹", "末", "莫", "漠", "寞", "陌", "謨", "茉", "驀", "歿"],
|
||||||
|
"mou": ["某", "謀", "牟", "眸", "繆", "鍪"],
|
||||||
|
"mu": ["目", "母", "木", "幕", "牧", "慕", "墓", "暮", "穆", "睦", "沐", "募", "姆", "拇", "牡", "畝"],
|
||||||
|
"na": ["那", "拿", "哪", "納", "吶", "娜", "鈉", "衲"],
|
||||||
|
"nai": ["奶", "耐", "乃", "奈", "氖", "萘", "鼐"],
|
||||||
|
"nan": ["南", "難", "男", "喃", "楠", "赧"],
|
||||||
|
"nang": ["囊", "囔"],
|
||||||
|
"nao": ["腦", "惱", "鬧", "撓", "淖", "鐃", "橈"],
|
||||||
|
"ne": ["呢", "訥"],
|
||||||
|
"nei": ["內", "那"],
|
||||||
|
"nen": ["嫩", "恁"],
|
||||||
|
"neng": ["能"],
|
||||||
|
"ni": ["你", "妳", "呢", "泥", "尼", "擬", "逆", "妮", "霓", "倪", "匿", "溺", "膩", "旎"],
|
||||||
|
"nian": ["年", "念", "黏", "碾", "捻", "撚", "蔦"],
|
||||||
|
"niang": ["娘", "釀"],
|
||||||
|
"niao": ["鳥", "尿", "裊", "嬲"],
|
||||||
|
"nie": ["捏", "聶", "孽", "躡", "鎳", "囁", "臬", "涅"],
|
||||||
|
"nin": ["您"],
|
||||||
|
"ning": ["寧", "凝", "擰", "檸", "獰", "嚀", "甯"],
|
||||||
|
"niu": ["牛", "紐", "扭", "鈕", "妞", "拗"],
|
||||||
|
"nong": ["農", "濃", "弄", "膿", "儂"],
|
||||||
|
"nu": ["女", "努", "怒", "奴", "弩", "胬"],
|
||||||
|
"nv": ["女"],
|
||||||
|
"nuan": ["暖"],
|
||||||
|
"nue": ["虐", "瘧"],
|
||||||
|
"nuo": ["挪", "諾", "懦", "糯", "喏"],
|
||||||
|
"o": ["哦", "噢", "喔"],
|
||||||
|
"ou": ["歐", "偶", "嘔", "藕", "鷗", "漚", "慪"],
|
||||||
|
"pa": ["怕", "爬", "帕", "趴", "琶", "葩", "耙"],
|
||||||
|
"pai": ["排", "拍", "牌", "派", "徘", "湃", "俳"],
|
||||||
|
"pan": ["判", "盤", "盼", "攀", "畔", "胖", "叛", "潘", "磐", "蹣", "拚"],
|
||||||
|
"pang": ["旁", "胖", "龐", "膀", "磅", "彷", "螃"],
|
||||||
|
"pao": ["跑", "炮", "泡", "拋", "刨", "袍", "咆", "庖"],
|
||||||
|
"pei": ["配", "陪", "培", "賠", "佩", "沛", "裴", "胚", "霈"],
|
||||||
|
"pen": ["盆", "噴"],
|
||||||
|
"peng": ["朋", "碰", "彭", "棚", "蓬", "鵬", "捧", "烹", "澎", "朋", "怦", "砰", "堋"],
|
||||||
|
"pi": ["皮", "批", "披", "匹", "疲", "僻", "脾", "劈", "琵", "毗", "啤", "坯", "譬", "霹", "屁", "闢", "紕"],
|
||||||
|
"pian": ["片", "便", "騙", "偏", "篇", "翩", "扁", "諞"],
|
||||||
|
"piao": ["票", "飄", "漂", "瓢", "嫖", "縹", "驃"],
|
||||||
|
"pie": ["撇", "瞥", "苤"],
|
||||||
|
"pin": ["品", "貧", "頻", "聘", "拼", "拚", "嬪"],
|
||||||
|
"ping": ["平", "評", "憑", "瓶", "萍", "屏", "蘋", "坪", "萍", "秤", "娉", "馮"],
|
||||||
|
"po": ["破", "迫", "婆", "頗", "坡", "潑", "泊", "魄", "粕", "朴", "珀", "叵", "鄱"],
|
||||||
|
"pou": ["剖", "掊", "裒"],
|
||||||
|
"pu": ["普", "鋪", "樸", "譜", "浦", "葡", "蒲", "僕", "撲", "圃", "濮", "璞", "噗"],
|
||||||
|
"qi": ["起", "其", "氣", "期", "七", "奇", "妻", "棋", "齊", "旗", "企", "啟", "器", "棄", "汽", "祈", "騎", "豈", "漆", "契", "砌", "琪", "淇", "岐", "祁", "崎", "祺", "臍", "訖", "訖", "磧"],
|
||||||
|
"qia": ["恰", "洽", "卡", "掐", "髂", "袷"],
|
||||||
|
"qian": ["前", "錢", "千", "簽", "遷", "淺", "欠", "牽", "潛", "鉛", "謙", "乾", "嵌", "譴", "譴", "倩", "倩", "槍", "嗆", "薔", "牆", "強", "搶", "腔", "嗆", "羌", "嬙", "檣", "鏘", "鏹"],
|
||||||
|
"qiao": ["橋", "瞧", "巧", "敲", "俏", "殼", "竅", "喬", "翹", "峭", "俏", "撬", "憔", "譙", "樵"],
|
||||||
|
"qie": ["切", "且", "茄", "怯", "竊", "妾", "愜", "鍥", "伽"],
|
||||||
|
"qin": ["親", "琴", "勤", "侵", "秦", "欽", "禽", "寢", "沁", "芹", "擒", "噙", "覃"],
|
||||||
|
"qing": ["情", "請", "清", "青", "輕", "慶", "傾", "頃", "晴", "擎", "卿", "氫", "罄", "磬", "蜻", "鯖", "綮"],
|
||||||
|
"qiong": ["窮", "瓊", "穹", "煢", "邛", "蛩"],
|
||||||
|
"qiu": ["求", "球", "秋", "丘", "邱", "囚", "酋", "泅", "俅", "裘", "遒", "賒"],
|
||||||
|
"qu": ["去", "取", "曲", "區", "趣", "娶", "渠", "屈", "驅", "蛆", "軀", "祛", "瞿", "蛐", "麴", "衢"],
|
||||||
|
"quan": ["全", "權", "圈", "泉", "拳", "犬", "勸", "券", "詮", "痊", "銓", "蜷", "顴"],
|
||||||
|
"que": ["確", "卻", "缺", "雀", "鵲", "闕", "瘸", "榷", "愨"],
|
||||||
|
"qun": ["群", "裙", "逡"],
|
||||||
|
"ran": ["然", "燃", "染", "冉", "髯", "蚺"],
|
||||||
|
"rang": ["讓", "嚷", "壤", "攘", "穰", "瓤"],
|
||||||
|
"rao": ["擾", "繞", "饒", "嬈", "橈", "蕘"],
|
||||||
|
"re": ["熱", "惹", "喏"],
|
||||||
|
"ren": ["人", "認", "任", "仁", "忍", "刃", "韌", "紉", "妊", "葚", "稔"],
|
||||||
|
"reng": ["仍", "扔"],
|
||||||
|
"ri": ["日"],
|
||||||
|
"rong": ["容", "榮", "融", "絨", "溶", "蓉", "榕", "戎", "茸", "冗", "嶸", "狨"],
|
||||||
|
"rou": ["肉", "柔", "揉", "蹂", "鞣", "糅"],
|
||||||
|
"ru": ["如", "入", "儒", "乳", "辱", "孺", "茹", "蠕", "嚅", "濡", "縟", "洳"],
|
||||||
|
"ruan": ["軟", "阮"],
|
||||||
|
"rui": ["瑞", "銳", "蕊", "芮", "蚋", "枘"],
|
||||||
|
"run": ["潤", "閏"],
|
||||||
|
"ruo": ["若", "弱", "偌", "箬", "蒻"],
|
||||||
|
"sa": ["撒", "灑", "薩", "卅", "颯"],
|
||||||
|
"sai": ["賽", "塞", "腮", "鰓", "噻"],
|
||||||
|
"san": ["三", "散", "傘", "參", "霰"],
|
||||||
|
"sang": ["喪", "桑", "嗓", "顙", "搡"],
|
||||||
|
"sao": ["掃", "嫂", "騷", "搔", "瘙", "繅"],
|
||||||
|
"se": ["色", "塞", "瑟", "澀", "嗇", "穡"],
|
||||||
|
"sen": ["森"],
|
||||||
|
"seng": ["僧"],
|
||||||
|
"sha": ["殺", "沙", "紗", "傻", "啥", "煞", "莎", "杉", "剎", "砂", "痧", "裟", "鎩", "霎"],
|
||||||
|
"shai": ["曬", "篩", "色"],
|
||||||
|
"shan": ["山", "善", "閃", "衫", "扇", "杉", "刪", "珊", "柵", "膳", "擅", "贍", "汕", "潸", "姍", "煽", "跚", "訕", "疝", "鱔"],
|
||||||
|
"shang": ["上", "商", "傷", "尚", "賞", "裳", "熵", "觴", "殤", "垧"],
|
||||||
|
"shao": ["少", "燒", "紹", "稍", "勺", "哨", "韶", "捎", "梢", "芍", "苕", "蛸", "筲"],
|
||||||
|
"she": ["社", "設", "射", "蛇", "舌", "捨", "涉", "赦", "攝", "奢", "賒", "麝", "懾", "灄"],
|
||||||
|
"shei": ["誰"],
|
||||||
|
"shen": ["身", "深", "神", "什", "申", "伸", "審", "慎", "腎", "滲", "沈", "參", "甚", "嬸", "砷", "莘", "哂", "瀋", "糝"],
|
||||||
|
"sheng": ["生", "聲", "勝", "升", "省", "聖", "盛", "剩", "繩", "笙", "甥", "晟"],
|
||||||
|
"shi": ["是", "時", "事", "實", "十", "使", "史", "市", "世", "師", "施", "式", "示", "石", "室", "士", "視", "試", "食", "駛", "始", "勢", "失", "適", "仕", "飾", "濕", "詩", "屍", "虱", "誓", "嗜", "噬", "柿", "拭", "逝", "螫", "諡", "鈰", "鰣"],
|
||||||
|
"shou": ["手", "首", "受", "收", "授", "瘦", "獸", "壽", "售", "守", "狩", "綬", "艏"],
|
||||||
|
"shu": ["書", "數", "樹", "輸", "術", "述", "叔", "屬", "暑", "署", "鼠", "束", "疏", "舒", "淑", "梳", "抒", "殊", "蔬", "孰", "贖", "熟", "恕", "庶", "墅", "俞", "澍", "紓", "倏", "毹"],
|
||||||
|
"shua": ["刷", "耍", "唰"],
|
||||||
|
"shuai": ["帥", "率", "摔", "甩", "蟀"],
|
||||||
|
"shuan": ["栓", "拴", "閂", "涮"],
|
||||||
|
"shuang": ["雙", "爽", "霜", "孀"],
|
||||||
|
"shui": ["水", "說", "稅", "睡", "誰"],
|
||||||
|
"shun": ["順", "瞬", "舜", "吮"],
|
||||||
|
"shuo": ["說", "數", "碩", "朔", "爍", "鑠", "蒴", "搠"],
|
||||||
|
"si": ["四", "死", "思", "絲", "私", "司", "斯", "撕", "似", "肆", "寺", "祀", "廝", "嘶", "俬", "巳", "廝"],
|
||||||
|
"song": ["送", "松", "宋", "頌", "誦", "聳", "嵩", "凇", "菘", "淞"],
|
||||||
|
"sou": ["搜", "艘", "擻", "叟", "嗖", "餿", "溲", "颼", "瞍"],
|
||||||
|
"su": ["速", "素", "蘇", "訴", "俗", "塑", "溯", "宿", "粟", "夙", "簌", "愫", "嗉", "謖"],
|
||||||
|
"suan": ["算", "酸", "蒜", "狻"],
|
||||||
|
"sui": ["隨", "歲", "雖", "碎", "遂", "穗", "隧", "髓", "遂", "祟", "綏", "邃", "燧", "謁"],
|
||||||
|
"sun": ["損", "孫", "筍", "遜", "榫", "蓀", "猻"],
|
||||||
|
"suo": ["所", "鎖", "索", "縮", "瑣", "嗦", "唆", "梭", "嗩", "娑", "蓑"],
|
||||||
|
"ta": ["他", "她", "它", "塔", "踏", "拓", "榻", "獺", "撻", "闒", "遢"],
|
||||||
|
"tai": ["太", "台", "臺", "態", "泰", "抬", "胎", "臺", "鮐", "薹", "駘", "炱", "邰"],
|
||||||
|
"tan": ["談", "探", "彈", "壇", "攤", "貪", "嘆", "潭", "坦", "毯", "痰", "檀", "譚", "忐", "袒", "郯", "澹", "覃"],
|
||||||
|
"tang": ["堂", "唐", "糖", "躺", "趟", "湯", "燙", "塘", "膛", "棠", "搪", "螳", "鏜", "鏜", "鐋", "耥"],
|
||||||
|
"tao": ["套", "逃", "桃", "陶", "討", "濤", "掏", "滔", "萄", "淘", "陶", "燾", "絳", "叨"],
|
||||||
|
"te": ["特", "忒", "慝", "鋱"],
|
||||||
|
"teng": ["疼", "騰", "藤", "滕", "謄"],
|
||||||
|
"ti": ["提", "題", "體", "替", "踢", "梯", "剔", "蹄", "啼", "惕", "涕", "銻", "倜", "悌", "嚏"],
|
||||||
|
"tian": ["天", "田", "填", "甜", "添", "恬", "腆", "殄", "忝", "闐", "祆"],
|
||||||
|
"tiao": ["條", "跳", "調", "挑", "眺", "佻", "祧", "銚", "髫", "鰷"],
|
||||||
|
"tie": ["鐵", "貼", "帖", "萜"],
|
||||||
|
"ting": ["聽", "停", "庭", "挺", "廳", "廷", "亭", "婷", "艇", "汀", "蜓", "霆", "鋌", "莛"],
|
||||||
|
"tong": ["通", "同", "統", "童", "痛", "銅", "桶", "筒", "桐", "彤", "瞳", "佟", "酮", "嗵", "憧"],
|
||||||
|
"tou": ["頭", "投", "透", "偷", "骰"],
|
||||||
|
"tu": ["圖", "土", "突", "途", "吐", "兔", "屠", "徒", "凸", "禿", "荼", "釷", "菟"],
|
||||||
|
"tuan": ["團", "摶", "彖", "湍"],
|
||||||
|
"tui": ["推", "退", "腿", "蛻", "頹", "褪"],
|
||||||
|
"tun": ["吞", "屯", "臀", "囤", "褪", "豚"],
|
||||||
|
"tuo": ["脫", "托", "拖", "妥", "拓", "唾", "陀", "沱", "坨", "駝", "鴕", "橐", "砣", "佗", "跎"],
|
||||||
|
"wa": ["挖", "哇", "蛙", "瓦", "娃", "襪", "凹", "媧", "佤", "腽"],
|
||||||
|
"wai": ["外", "歪", "崴"],
|
||||||
|
"wan": ["完", "晚", "玩", "碗", "彎", "灣", "丸", "婉", "腕", "惋", "宛", "蜿", "豌", "莞", "綰", "剜"],
|
||||||
|
"wang": ["王", "往", "忘", "亡", "望", "網", "旺", "汪", "妄", "罔", "惘", "輞", "尪"],
|
||||||
|
"wei": ["為", "位", "未", "委", "圍", "唯", "威", "偉", "危", "尾", "微", "維", "違", "胃", "餵", "味", "慰", "魏", "衛", "畏", "萎", "偽", "娓", "惟", "巍", "緯", "煒", "韋", "薇", "帷", "渭", "猬", "闈", "洧", "沩"],
|
||||||
|
"wen": ["問", "文", "聞", "溫", "穩", "紋", "吻", "蚊", "雯", "紊", "刎", "璺", "問"],
|
||||||
|
"weng": ["翁", "嗡", "甕", "蓊"],
|
||||||
|
"wo": ["我", "握", "臥", "窩", "沃", "蝸", "幄", "斡", "喔", "倭", "萵", "齷"],
|
||||||
|
"wu": ["無", "五", "物", "務", "武", "舞", "誤", "惡", "午", "吳", "吾", "屋", "烏", "污", "悟", "霧", "捂", "巫", "嗚", "蕪", "梧", "唔", "戊", "塢", "憮", "嫵", "廡", "忤", "兀", "鵡", "鎢", "浯", "蜈", "齬"],
|
||||||
|
"xi": ["西", "系", "息", "希", "席", "習", "細", "喜", "戲", "洗", "惜", "稀", "溪", "錫", "析", "膝", "襲", "昔", "熙", "夕", "兮", "悉", "惜", "熄", "嬉", "汐", "犀", "烯", "曦", "奚", "唏", "唶", "淅", "嘻", "樨", "熙", "蠡", "璽", "徙", "隙", "戲", "餼", "覡", "闟"],
|
||||||
|
"xia": ["下", "夏", "嚇", "廈", "峽", "蝦", "瞎", "霞", "轄", "俠", "暇", "遐", "瑕", "匣", "黠", "硤", "罅"],
|
||||||
|
"xian": ["先", "現", "線", "限", "縣", "顯", "險", "鮮", "獻", "賢", "閒", "仙", "鹹", "羨", "陷", "憲", "餡", "羨", "掀", "纖", "閑", "涎", "嫻", "銜", "冼", "燹", "蜆", "筧", "薟", "躚"],
|
||||||
|
"xiang": ["想", "向", "相", "鄉", "香", "響", "享", "像", "象", "項", "巷", "降", "箱", "祥", "湘", "詳", "翔", "享", "襄", "鑲", "廂", "驤", "薌", "餉", "緗", "嚮", "嚮"],
|
||||||
|
"xiao": ["小", "笑", "效", "消", "校", "銷", "曉", "蕭", "肖", "削", "孝", "宵", "硝", "霄", "淆", "嘯", "驍", "梟", "瀟", "簫", "筱", "驍", "嘵", "蟰"],
|
||||||
|
"xie": ["些", "寫", "謝", "協", "鞋", "血", "歇", "斜", "脅", "諧", "攜", "洩", "卸", "懈", "蟹", "邪", "械", "屑", "偕", "褻", "榭", "廨", "瀣", "薤", "躞", "頡", "擷"],
|
||||||
|
"xin": ["新", "心", "信", "辛", "欣", "薪", "馨", "鑫", "芯", "鋅", "昕", "忻", "歆", "鐔", "囟"],
|
||||||
|
"xing": ["行", "星", "形", "性", "姓", "興", "刑", "型", "幸", "杏", "腥", "猩", "邢", "悻", "滎", "滎", "餳"],
|
||||||
|
"xiong": ["兄", "胸", "兇", "雄", "熊", "匈", "洶", "夐"],
|
||||||
|
"xiu": ["修", "休", "秀", "宿", "袖", "秀", "繡", "羞", "臭", "朽", "嗅", "鏽", "饈", "貅", "鵂", "岫"],
|
||||||
|
"xu": ["須", "需", "許", "續", "序", "徐", "虛", "緒", "蓄", "敘", "旭", "恤", "墟", "絮", "婿", "栩", "戌", "詡", "洫", "溆", "酗", "糈", "勖", "昫", "盱", "蓿"],
|
||||||
|
"xuan": ["選", "宣", "懸", "旋", "玄", "軒", "喧", "炫", "渲", "萱", "漩", "璇", "癬", "炫", "煊", "諼", "鋗"],
|
||||||
|
"xue": ["學", "雪", "血", "穴", "謔", "噱", "鱈"],
|
||||||
|
"xun": ["訊", "迅", "尋", "巡", "訓", "詢", "循", "旬", "熏", "勳", "薰", "潯", "馴", "汛", "遜", "殉", "徇", "巽", "塤", "曛", "窯", "鱘"],
|
||||||
|
"ya": ["呀", "壓", "牙", "亞", "雅", "鴨", "押", "芽", "涯", "訝", "崖", "啞", "衙", "軋", "蚜", "崖", "睚", "痖"],
|
||||||
|
"yan": ["言", "研", "眼", "嚴", "演", "驗", "煙", "顏", "鹽", "延", "沿", "燕", "宴", "炎", "掩", "演", "衍", "岩", "研", "艷", "雁", "焰", "厭", "彥", "諺", "堰", "硯", "嫣", "閻", "焉", "淹", "偃", "儼", "兗", "讌", "讞", "筵", "蜓", "鼴", "罨", "剡", "鄢", "閆", "滟", "妍", "琰", "罳"],
|
||||||
|
"yang": ["樣", "陽", "洋", "養", "央", "揚", "羊", "氧", "仰", "癢", "漾", "殃", "秧", "恙", "颺", "煬", "佯", "瘍", "鞅", "樣"],
|
||||||
|
"yao": ["要", "藥", "搖", "遙", "腰", "邀", "耀", "瑤", "姚", "咬", "堯", "鑰", "謠", "夭", "妖", "窯", "杳", "舀", "徭", "珧", "軺", "銚", "鰩", "么", "瘧"],
|
||||||
|
"ye": ["也", "業", "夜", "葉", "爺", "野", "液", "謁", "頁", "邪", "掖", "曳", "腋", "噎", "鄴", "曄", "燁", "鐺"],
|
||||||
|
"yi": ["一", "以", "已", "意", "義", "議", "易", "藝", "醫", "億", "憶", "移", "依", "疑", "譯", "異", "益", "亦", "役", "抑", "譯", "溢", "宜", "儀", "逸", "怡", "姨", "夷", "遺", "倚", "椅", "伊", "毅", "誼", "翌", "熠", "臆", "肄", "懿", "裔", "縊", "軼", "貽", "漪", "迤", "弋", "噫", "屹", "猗", "嶷", "揖", "壹", "挹", "佚", "詣", "懌", "懿", "曀", "繹", "驛", "羿", "釔", "鐿", "瘞", "苡", "薏", "悒", "挹", "嗌", "峄"],
|
||||||
|
"yin": ["因", "音", "引", "銀", "印", "飲", "隱", "陰", "吟", "尹", "殷", "茵", "蔭", "垠", "夤", "齦", "湮", "氤", "胤", "鄞", "喑", "洇", "狺"],
|
||||||
|
"ying": ["應", "英", "營", "迎", "影", "贏", "硬", "映", "盈", "穎", "瑩", "鷹", "嬰", "櫻", "瀛", "蠅", "瀛", "嬴", "罌", "縈", "楹", "熒", "螢", "瀅", "瓔", "鸚", "膺", "瀠", "瀛"],
|
||||||
|
"yo": ["喲", "唷"],
|
||||||
|
"yong": ["用", "永", "擁", "勇", "湧", "庸", "泳", "庸", "傭", "踴", "蛹", "恿", "鏞", "傭", "臃", "癰", "邕", "鏞", "墉", "慵", "灉"],
|
||||||
|
"you": ["有", "又", "由", "友", "右", "優", "油", "遊", "幼", "尤", "憂", "幽", "悠", "誘", "佑", "釉", "柚", "酉", "猶", "黝", "卣", "疣", "蚰", "宥", "侑", "呦", "銪", "牖", "蝣", "蝤", "繇", "輶", "夂"],
|
||||||
|
"yu": ["與", "於", "語", "雨", "魚", "遇", "欲", "育", "域", "預", "愈", "玉", "宇", "余", "譽", "獄", "漁", "愚", "輿", "寓", "御", "裕", "郁", "喻", "逾", "娛", "吁", "逾", "瑜", "馭", "毓", "諭", "豫", "隅", "昱", "覦", "覦", "歟", "煜", "燠", "聿", "鈺", "嶼", "傴", "圄", "圉", "禺", "芋", "飫", "閾", "嫗", "煜", "鷸", "譽", "瘐", "窳", "餘", "雩", "齬", "禺", "滪", "窳", "肀"],
|
||||||
|
"yuan": ["元", "原", "員", "圓", "院", "源", "遠", "願", "緣", "園", "怨", "冤", "援", "袁", "淵", "猿", "轅", "媛", "垣", "沅", "塬", "圜", "鴛", "鳶", "螈", "爰", "瑗", "掾", "圜"],
|
||||||
|
"yue": ["月", "約", "越", "樂", "曰", "閱", "躍", "悅", "岳", "粵", "淵", "曰", "鑰", "櫟", "鉞", "瀹", "龠", "刖", "軏"],
|
||||||
|
"yun": ["雲", "運", "員", "韻", "勻", "允", "孕", "蘊", "暈", "隕", "耘", "紜", "韻", "慍", "殞", "惲", "醞", "狁", "勻", "鄖"],
|
||||||
|
"za": ["雜", "砸", "咂", "拶"],
|
||||||
|
"zai": ["在", "再", "載", "災", "宰", "栽", "崽", "哉"],
|
||||||
|
"zan": ["咱", "讚", "暫", "讚", "拶", "昝", "簪", "糌"],
|
||||||
|
"zang": ["藏", "臟", "葬", "臟", "臧", "奘", "駔"],
|
||||||
|
"zao": ["早", "造", "遭", "燥", "澡", "藻", "棗", "躁", "鑿", "蚤", "皁", "竈"],
|
||||||
|
"ze": ["則", "責", "擇", "澤", "側", "仄", "迮", "幘", "賾", "箦"],
|
||||||
|
"zei": ["賊"],
|
||||||
|
"zen": ["怎", "譖"],
|
||||||
|
"zeng": ["增", "贈", "憎", "甑", "繒", "罾"],
|
||||||
|
"zha": ["炸", "紮", "查", "渣", "扎", "眨", "柵", "詐", "乍", "榨", "吒", "砟", "蚱", "齇", "鮓", "醡"],
|
||||||
|
"zhai": ["債", "寨", "齋", "摘", "窄", "翟", "瘵"],
|
||||||
|
"zhan": ["站", "展", "戰", "佔", "斬", "瞻", "沾", "詹", "盞", "嶄", "湛", "綻", "輾", "搌", "旃"],
|
||||||
|
"zhang": ["長", "張", "章", "掌", "丈", "帳", "仗", "脹", "障", "彰", "漳", "璋", "嶂", "幛", "瘴", "鄣"],
|
||||||
|
"zhao": ["找", "照", "招", "朝", "趙", "兆", "罩", "肇", "詔", "沼", "爪", "召", "昭", "嘲", "濯", "櫂", "笊"],
|
||||||
|
"zhe": ["這", "著", "者", "折", "哲", "蔗", "遮", "轍", "浙", "褶", "蟄", "鷓", "謫", "輒", "晢", "蜇"],
|
||||||
|
"zhei": ["這"],
|
||||||
|
"zhen": ["真", "針", "鎮", "陣", "珍", "震", "振", "診", "枕", "斟", "甄", "臻", "疹", "砧", "貞", "偵", "軫", "縝", "榛", "楨", "賑", "禎", "畛", "圳", "蓁", "斟"],
|
||||||
|
"zheng": ["正", "政", "整", "爭", "證", "鄭", "征", "蒸", "掙", "睜", "錚", "崢", "箏", "怔", "拯", "鉦", "幀", "諍", "癥"],
|
||||||
|
"zhi": ["之", "知", "只", "至", "指", "支", "直", "值", "制", "質", "治", "職", "紙", "誌", "置", "智", "植", "枝", "止", "址", "芝", "脂", "肢", "旨", "侄", "稚", "滯", "摯", "緻", "秩", "幟", "峙", "窒", "幟", "炙", "幟", "幟", "卮", "芷", "梔", "趾", "蜘", "躓", "雉", "膣", "騭", "躑", "豸", "幟", "輊", "贄", "鷙", "痣", "蛭", "幟"],
|
||||||
|
"zhong": ["中", "種", "重", "眾", "終", "鐘", "忠", "腫", "仲", "衷", "鍾", "盅", "舯", "螽", "冢"],
|
||||||
|
"zhou": ["周", "州", "洲", "舟", "皺", "軸", "宙", "粥", "肘", "帚", "胄", "紂", "咒", "晝", "縐", "碡", "僽"],
|
||||||
|
"zhu": ["主", "住", "注", "著", "助", "築", "逐", "祝", "豬", "珠", "朱", "諸", "竹", "株", "燭", "矚", "駐", "鑄", "煮", "拄", "囑", "矚", "佇", "杼", "渚", "瀦", "躅", "櫫", "褚", "苧", "洙", "瀦", "麈", "瘃"],
|
||||||
|
"zhua": ["抓", "爪"],
|
||||||
|
"zhuai": ["轉", "拽"],
|
||||||
|
"zhuan": ["專", "轉", "傳", "賺", "磚", "撰", "篆", "饌", "顓"],
|
||||||
|
"zhuang": ["裝", "狀", "莊", "撞", "壯", "幢", "妝", "樁"],
|
||||||
|
"zhui": ["追", "墜", "綴", "贅", "縋", "惴", "騅"],
|
||||||
|
"zhun": ["準", "諄", "肫", "窀"],
|
||||||
|
"zhuo": ["著", "桌", "捉", "卓", "濁", "灼", "酌", "拙", "琢", "茁", "濁", "擢", "倬", "涿", "浞", "禚", "斫"],
|
||||||
|
"zi": ["子", "自", "字", "資", "紫", "茲", "姿", "咨", "滋", "孜", "籽", "梓", "漬", "諮", "姊", "孳", "恣", "甾", "輜", "錙", "齜", "耔", "笫"],
|
||||||
|
"zong": ["總", "從", "縱", "綜", "宗", "棕", "蹤", "鬃", "粽", "偬", "綜", "腙"],
|
||||||
|
"zou": ["走", "奏", "鄒", "揍", "騶", "諏", "陬", "鯫"],
|
||||||
|
"zu": ["足", "族", "組", "租", "阻", "卒", "俎", "詛", "菹"],
|
||||||
|
"zuan": ["鑽", "纂", "攢", "繵", "躜"],
|
||||||
|
"zui": ["最", "罪", "嘴", "醉", "蕞"],
|
||||||
|
"zun": ["尊", "遵", "樽", "撙"],
|
||||||
|
"zuo": ["做", "作", "座", "左", "昨", "佐", "琢", "撮", "唑", "嘬", "怍", "祚", "胙"]
|
||||||
|
}
|
||||||
|
}
|
||||||
42269
CustomKeyboard/Resource/portuguese_words.json
Normal file
54839
CustomKeyboard/Resource/spanish_words.json
Normal file
@@ -11,10 +11,10 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
|
|
||||||
- (instancetype)initWithContainerView:(UIView *)containerView;
|
- (instancetype)initWithContainerView:(UIView *)containerView;
|
||||||
|
|
||||||
/// 配置删除按钮(包含长按删除;可选是否显示“立刻清空”提示)
|
/// 配置删除按钮(包含长按删除;可选是否显示“上滑清空”提示)
|
||||||
- (void)bindDeleteButton:(nullable UIView *)button showClearLabel:(BOOL)showClearLabel;
|
- (void)bindDeleteButton:(nullable UIView *)button showClearLabel:(BOOL)showClearLabel;
|
||||||
|
|
||||||
/// 触发“立刻清空”逻辑(可用于功能面板的清空按钮)
|
/// 触发“上滑清空”逻辑(可用于功能面板的清空按钮)
|
||||||
- (void)performClearAction;
|
- (void)performClearAction;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -7,24 +7,25 @@
|
|||||||
#import "KBResponderUtils.h"
|
#import "KBResponderUtils.h"
|
||||||
#import "KBSkinManager.h"
|
#import "KBSkinManager.h"
|
||||||
#import "KBBackspaceUndoManager.h"
|
#import "KBBackspaceUndoManager.h"
|
||||||
|
#import "KBInputBufferManager.h"
|
||||||
|
|
||||||
static const NSTimeInterval kKBBackspaceLongPressMinDuration = 0.35;
|
static const NSTimeInterval kKBBackspaceLongPressMinDuration = 0.35;
|
||||||
static const NSTimeInterval kKBBackspaceRepeatInterval = 0.06;
|
static const NSTimeInterval kKBBackspaceRepeatInterval = 0.06;
|
||||||
static const NSTimeInterval kKBBackspaceChunkStartDelay = 1.0;
|
static const NSTimeInterval kKBBackspaceChunkStartDelay = 0.6;
|
||||||
static const NSTimeInterval kKBBackspaceChunkRepeatInterval = 0.1;
|
static const NSTimeInterval kKBBackspaceChunkRepeatInterval = 0.1;
|
||||||
static const NSTimeInterval kKBBackspaceChunkFastDelay = 1.4;
|
static const NSTimeInterval kKBBackspaceChunkFastDelay = 1.2;
|
||||||
static const NSInteger kKBBackspaceChunkSize = 6;
|
static const NSInteger kKBBackspaceChunkSize = 8;
|
||||||
static const NSInteger kKBBackspaceChunkSizeFast = 12;
|
static const NSInteger kKBBackspaceChunkSizeFast = 16;
|
||||||
static const CGFloat kKBBackspaceClearLabelCornerRadius = 8.0;
|
static const CGFloat kKBBackspaceClearLabelCornerRadius = 8.0;
|
||||||
static const CGFloat kKBBackspaceClearLabelHeight = 26.0;
|
static const CGFloat kKBBackspaceClearLabelHeight = 34;
|
||||||
static const CGFloat kKBBackspaceClearLabelPaddingX = 10.0;
|
static const CGFloat kKBBackspaceClearLabelPaddingX = 10.0;
|
||||||
static const CGFloat kKBBackspaceClearLabelTopGap = 6.0;
|
static const CGFloat kKBBackspaceClearLabelTopGap = 6.0;
|
||||||
static const CGFloat kKBBackspaceClearLabelHorizontalInset = 6.0;
|
static const CGFloat kKBBackspaceClearLabelHorizontalInset = 6.0;
|
||||||
static const NSInteger kKBBackspaceClearBatchSize = 24;
|
static const NSTimeInterval kKBBackspaceClearBatchInterval = 0.02;
|
||||||
static const NSTimeInterval kKBBackspaceClearBatchInterval = 0.005;
|
|
||||||
static const NSInteger kKBBackspaceClearMaxDeletes = 10000;
|
static const NSInteger kKBBackspaceClearMaxDeletes = 10000;
|
||||||
static const NSInteger kKBBackspaceClearEmptyContextMaxRounds = 40;
|
static const NSInteger kKBBackspaceClearEmptyContextMaxRounds = 40;
|
||||||
static const NSInteger kKBBackspaceClearMaxStep = 80;
|
static const NSInteger kKBBackspaceClearMaxStep = 80;
|
||||||
|
static const NSInteger kKBBackspaceClearDeletesPerTick = 10;
|
||||||
|
|
||||||
typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||||
KBBackspaceChunkClassUnknown = 0,
|
KBBackspaceChunkClassUnknown = 0,
|
||||||
@@ -34,6 +35,12 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
KBBackspaceChunkClassOther
|
KBBackspaceChunkClassOther
|
||||||
};
|
};
|
||||||
|
|
||||||
|
typedef NS_ENUM(NSInteger, KBClearPhase) {
|
||||||
|
KBClearPhaseSkipWhitespace = 0,
|
||||||
|
KBClearPhaseSkipTrailingBoundary,
|
||||||
|
KBClearPhaseDeleteUntilBoundary
|
||||||
|
};
|
||||||
|
|
||||||
@interface KBBackspaceLongPressHandler ()
|
@interface KBBackspaceLongPressHandler ()
|
||||||
@property (nonatomic, weak) UIView *containerView;
|
@property (nonatomic, weak) UIView *containerView;
|
||||||
@property (nonatomic, weak) UIView *backspaceButton;
|
@property (nonatomic, weak) UIView *backspaceButton;
|
||||||
@@ -48,6 +55,9 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
@property (nonatomic, assign) CGPoint backspaceLastTouchPointInSelf;
|
@property (nonatomic, assign) CGPoint backspaceLastTouchPointInSelf;
|
||||||
@property (nonatomic, assign) NSUInteger backspaceClearToken;
|
@property (nonatomic, assign) NSUInteger backspaceClearToken;
|
||||||
@property (nonatomic, strong) UILabel *backspaceClearLabel;
|
@property (nonatomic, strong) UILabel *backspaceClearLabel;
|
||||||
|
@property (nonatomic, copy) NSString *pendingClearBefore;
|
||||||
|
@property (nonatomic, copy) NSString *pendingClearAfter;
|
||||||
|
@property (nonatomic, assign) KBClearPhase backspaceClearPhase;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation KBBackspaceLongPressHandler
|
@implementation KBBackspaceLongPressHandler
|
||||||
@@ -55,6 +65,7 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
- (instancetype)initWithContainerView:(UIView *)containerView {
|
- (instancetype)initWithContainerView:(UIView *)containerView {
|
||||||
if (self = [super init]) {
|
if (self = [super init]) {
|
||||||
_containerView = containerView;
|
_containerView = containerView;
|
||||||
|
_backspaceClearPhase = KBClearPhaseSkipWhitespace;
|
||||||
}
|
}
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
@@ -73,6 +84,8 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
self.backspaceHasLastTouchPoint = NO;
|
self.backspaceHasLastTouchPoint = NO;
|
||||||
self.backspaceHoldToken += 1;
|
self.backspaceHoldToken += 1;
|
||||||
[self kb_hideBackspaceClearLabel];
|
[self kb_hideBackspaceClearLabel];
|
||||||
|
self.pendingClearBefore = nil;
|
||||||
|
self.pendingClearAfter = nil;
|
||||||
|
|
||||||
if (!button) { return; }
|
if (!button) { return; }
|
||||||
|
|
||||||
@@ -99,7 +112,18 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
}
|
}
|
||||||
switch (gr.state) {
|
switch (gr.state) {
|
||||||
case UIGestureRecognizerStateBegan: {
|
case UIGestureRecognizerStateBegan: {
|
||||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||||
|
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||||
|
if (ivc) {
|
||||||
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||||
|
[[KBInputBufferManager shared] refreshFromProxyIfPossible:proxy];
|
||||||
|
[[KBInputBufferManager shared] prepareSnapshotForDeleteWithContextBefore:proxy.documentContextBeforeInput
|
||||||
|
after:proxy.documentContextAfterInput];
|
||||||
|
}
|
||||||
|
if (self.showClearLabelEnabled) {
|
||||||
|
[self kb_capturePendingClearSnapshotIfNeeded];
|
||||||
|
[[KBInputBufferManager shared] beginPendingClearSnapshot];
|
||||||
|
}
|
||||||
self.backspaceHoldToken += 1;
|
self.backspaceHoldToken += 1;
|
||||||
NSUInteger token = self.backspaceHoldToken;
|
NSUInteger token = self.backspaceHoldToken;
|
||||||
self.backspaceHoldActive = YES;
|
self.backspaceHoldActive = YES;
|
||||||
@@ -134,6 +158,7 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
if (!ivc) { self.backspaceHoldActive = NO; return; }
|
if (!ivc) { self.backspaceHoldActive = NO; return; }
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||||
|
if (before.length == 0) { before = [KBInputBufferManager shared].liveText ?: @""; }
|
||||||
NSTimeInterval elapsed = [NSDate date].timeIntervalSinceReferenceDate - self.backspaceHoldStartTime;
|
NSTimeInterval elapsed = [NSDate date].timeIntervalSinceReferenceDate - self.backspaceHoldStartTime;
|
||||||
NSInteger deleteCount = 1;
|
NSInteger deleteCount = 1;
|
||||||
if (before.length > 0) {
|
if (before.length > 0) {
|
||||||
@@ -145,9 +170,8 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
[self kb_showBackspaceClearLabelIfNeeded];
|
[self kb_showBackspaceClearLabelIfNeeded];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (NSInteger i = 0; i < deleteCount; i++) {
|
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:(NSUInteger)deleteCount];
|
||||||
[proxy deleteBackward];
|
[[KBInputBufferManager shared] applyHoldDeleteCount:(NSUInteger)deleteCount];
|
||||||
}
|
|
||||||
|
|
||||||
NSTimeInterval interval = [self kb_backspaceRepeatIntervalForElapsed:elapsed];
|
NSTimeInterval interval = [self kb_backspaceRepeatIntervalForElapsed:elapsed];
|
||||||
__weak typeof(self) weakSelf = self;
|
__weak typeof(self) weakSelf = self;
|
||||||
@@ -186,34 +210,77 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||||
asciiWordSet = [NSCharacterSet characterSetWithCharactersInString:
|
asciiWordSet = [NSCharacterSet characterSetWithCharactersInString:
|
||||||
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"];
|
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"];
|
||||||
punctuationSet = [NSCharacterSet punctuationCharacterSet];
|
NSMutableCharacterSet *punct = [[NSCharacterSet punctuationCharacterSet] mutableCopy];
|
||||||
|
// 补齐常见中文/全角标点(避免 chunk 总是只删 1 个符号)
|
||||||
|
[punct addCharactersInString:@",。!?;:、()【】《》“”‘’·…—"];
|
||||||
|
punctuationSet = [punct copy];
|
||||||
});
|
});
|
||||||
|
|
||||||
__block NSInteger deleteCount = 0;
|
__block NSInteger deleteCount = 0;
|
||||||
__block KBBackspaceChunkClass chunkClass = KBBackspaceChunkClassUnknown;
|
typedef NS_ENUM(NSInteger, KBBackspaceChunkPhase) {
|
||||||
|
KBBackspaceChunkPhaseWhitespace = 0,
|
||||||
|
KBBackspaceChunkPhasePunctuation,
|
||||||
|
KBBackspaceChunkPhaseCore
|
||||||
|
};
|
||||||
|
__block KBBackspaceChunkPhase phase = KBBackspaceChunkPhaseWhitespace;
|
||||||
|
__block KBBackspaceChunkClass coreClass = KBBackspaceChunkClassUnknown;
|
||||||
|
|
||||||
[context enumerateSubstringsInRange:NSMakeRange(0, context.length)
|
[context enumerateSubstringsInRange:NSMakeRange(0, context.length)
|
||||||
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
||||||
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
||||||
if (substring.length == 0) { return; }
|
if (substring.length == 0) { return; }
|
||||||
KBBackspaceChunkClass currentClass = KBBackspaceChunkClassOther;
|
if (deleteCount >= maxCount) {
|
||||||
if ([substring rangeOfCharacterFromSet:whitespaceSet].location != NSNotFound) {
|
|
||||||
currentClass = KBBackspaceChunkClassWhitespace;
|
|
||||||
} else if ([substring rangeOfCharacterFromSet:asciiWordSet].location != NSNotFound) {
|
|
||||||
currentClass = KBBackspaceChunkClassASCIIWord;
|
|
||||||
} else if ([substring rangeOfCharacterFromSet:punctuationSet].location != NSNotFound) {
|
|
||||||
currentClass = KBBackspaceChunkClassPunctuation;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunkClass == KBBackspaceChunkClassUnknown) {
|
|
||||||
chunkClass = currentClass;
|
|
||||||
} else if (chunkClass != currentClass) {
|
|
||||||
*stop = YES;
|
*stop = YES;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteCount += 1;
|
KBBackspaceChunkClass currentClass = KBBackspaceChunkClassOther;
|
||||||
|
if ([substring rangeOfCharacterFromSet:whitespaceSet].location != NSNotFound) {
|
||||||
|
currentClass = KBBackspaceChunkClassWhitespace;
|
||||||
|
} else if ([substring rangeOfCharacterFromSet:punctuationSet].location != NSNotFound) {
|
||||||
|
currentClass = KBBackspaceChunkClassPunctuation;
|
||||||
|
} else if ([substring rangeOfCharacterFromSet:asciiWordSet].location != NSNotFound) {
|
||||||
|
currentClass = KBBackspaceChunkClassASCIIWord;
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL consumed = NO;
|
||||||
|
while (!consumed) {
|
||||||
|
if (phase == KBBackspaceChunkPhaseWhitespace) {
|
||||||
|
if (currentClass == KBBackspaceChunkClassWhitespace) {
|
||||||
|
deleteCount += 1;
|
||||||
|
consumed = YES;
|
||||||
|
} else {
|
||||||
|
phase = KBBackspaceChunkPhasePunctuation;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase == KBBackspaceChunkPhasePunctuation) {
|
||||||
|
if (currentClass == KBBackspaceChunkClassPunctuation) {
|
||||||
|
deleteCount += 1;
|
||||||
|
consumed = YES;
|
||||||
|
} else {
|
||||||
|
phase = KBBackspaceChunkPhaseCore;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// phase == Core:连续删同一类(ASCII 单词 / 其它),让效果更像微信“几个字一组”
|
||||||
|
if (coreClass == KBBackspaceChunkClassUnknown) {
|
||||||
|
coreClass = currentClass;
|
||||||
|
}
|
||||||
|
if (currentClass != coreClass) {
|
||||||
|
*stop = YES;
|
||||||
|
consumed = YES;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
deleteCount += 1;
|
||||||
|
consumed = YES;
|
||||||
|
}
|
||||||
|
|
||||||
if (deleteCount >= maxCount) {
|
if (deleteCount >= maxCount) {
|
||||||
*stop = YES;
|
*stop = YES;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
|
|
||||||
@@ -222,13 +289,16 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
|
|
||||||
- (NSInteger)kb_clearDeleteCountForContext:(NSString *)context
|
- (NSInteger)kb_clearDeleteCountForContext:(NSString *)context
|
||||||
hitBoundary:(BOOL *)hitBoundary {
|
hitBoundary:(BOOL *)hitBoundary {
|
||||||
if (context.length == 0) { return kKBBackspaceClearBatchSize; }
|
if (context.length == 0) {
|
||||||
|
if (hitBoundary) { *hitBoundary = NO; }
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
static NSCharacterSet *sentenceBoundarySet = nil;
|
static NSCharacterSet *sentenceBoundarySet = nil;
|
||||||
static NSCharacterSet *whitespaceSet = nil;
|
static NSCharacterSet *whitespaceSet = nil;
|
||||||
static dispatch_once_t onceToken;
|
static dispatch_once_t onceToken;
|
||||||
dispatch_once(&onceToken, ^{
|
dispatch_once(&onceToken, ^{
|
||||||
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
|
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?"];
|
||||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -303,6 +373,12 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
shouldClear = [self kb_isPointInsideBackspaceClearLabel:point];
|
shouldClear = [self kb_isPointInsideBackspaceClearLabel:point];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
|
NSLog(@"[kb_handleBackspaceLongPressEnded] shouldClear=%@ highlighted=%@ labelHidden=%@",
|
||||||
|
shouldClear ? @"YES" : @"NO",
|
||||||
|
self.backspaceClearHighlighted ? @"YES" : @"NO",
|
||||||
|
self.backspaceClearLabel.hidden ? @"YES" : @"NO");
|
||||||
|
#endif
|
||||||
self.backspaceHoldActive = NO;
|
self.backspaceHoldActive = NO;
|
||||||
self.backspaceChunkModeActive = NO;
|
self.backspaceChunkModeActive = NO;
|
||||||
self.backspaceHoldToken += 1;
|
self.backspaceHoldToken += 1;
|
||||||
@@ -310,7 +386,13 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
[self kb_hideBackspaceClearLabel];
|
[self kb_hideBackspaceClearLabel];
|
||||||
if (shouldClear) {
|
if (shouldClear) {
|
||||||
[self kb_clearAllInput];
|
[self kb_clearAllInput];
|
||||||
|
} else {
|
||||||
|
self.pendingClearBefore = nil;
|
||||||
|
self.pendingClearAfter = nil;
|
||||||
|
[[KBInputBufferManager shared] clearPendingClearSnapshot];
|
||||||
|
[[KBInputBufferManager shared] commitLiveToManual];
|
||||||
}
|
}
|
||||||
|
[self kb_refreshSuggestionsAfterLongPressClear:shouldClear];
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Clear Label
|
#pragma mark - Clear Label
|
||||||
@@ -401,9 +483,9 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
- (UILabel *)backspaceClearLabel {
|
- (UILabel *)backspaceClearLabel {
|
||||||
if (!_backspaceClearLabel) {
|
if (!_backspaceClearLabel) {
|
||||||
UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
|
UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
|
||||||
label.text = @"立刻清空";
|
label.text = KBLocalized(@"Clear");
|
||||||
label.textAlignment = NSTextAlignmentCenter;
|
label.textAlignment = NSTextAlignmentCenter;
|
||||||
label.font = [UIFont systemFontOfSize:12 weight:UIFontWeightSemibold];
|
label.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
|
||||||
label.textColor = [KBSkinManager shared].current.keyTextColor ?: UIColor.blackColor;
|
label.textColor = [KBSkinManager shared].current.keyTextColor ?: UIColor.blackColor;
|
||||||
label.backgroundColor = [self kb_backspaceClearLabelNormalColor];
|
label.backgroundColor = [self kb_backspaceClearLabelNormalColor];
|
||||||
label.layer.cornerRadius = kKBBackspaceClearLabelCornerRadius;
|
label.layer.cornerRadius = kKBBackspaceClearLabelCornerRadius;
|
||||||
@@ -418,13 +500,18 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
#pragma mark - Clear
|
#pragma mark - Clear
|
||||||
|
|
||||||
- (void)kb_clearAllInput {
|
- (void)kb_clearAllInput {
|
||||||
|
[self kb_clearCurrentWordIfPossible];
|
||||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||||
if (ivc) {
|
if (ivc) {
|
||||||
NSString *before = ivc.textDocumentProxy.documentContextBeforeInput ?: @"";
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||||
[[KBBackspaceUndoManager shared] recordClearWithContext:before];
|
[[KBInputBufferManager shared] refreshFromProxyIfPossible:proxy];
|
||||||
}
|
}
|
||||||
|
self.pendingClearBefore = nil;
|
||||||
|
self.pendingClearAfter = nil;
|
||||||
|
[[KBInputBufferManager shared] clearPendingClearSnapshot];
|
||||||
self.backspaceClearToken += 1;
|
self.backspaceClearToken += 1;
|
||||||
|
self.backspaceClearPhase = KBClearPhaseSkipWhitespace;
|
||||||
NSUInteger token = self.backspaceClearToken;
|
NSUInteger token = self.backspaceClearToken;
|
||||||
[self kb_clearAllInputStepForToken:token guard:0 emptyRounds:0];
|
[self kb_clearAllInputStepForToken:token guard:0 emptyRounds:0];
|
||||||
}
|
}
|
||||||
@@ -437,40 +524,101 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||||
if (!ivc) { return; }
|
if (!ivc) { return; }
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
|
||||||
NSInteger count = before.length;
|
|
||||||
NSInteger batch = 0;
|
|
||||||
NSInteger nextEmptyRounds = emptyRounds;
|
NSInteger nextEmptyRounds = emptyRounds;
|
||||||
BOOL hitBoundary = NO;
|
static NSCharacterSet *stopBoundarySet = nil;
|
||||||
if (count > 0) {
|
static NSCharacterSet *trailingBoundarySet = nil;
|
||||||
batch = [self kb_clearDeleteCountForContext:before hitBoundary:&hitBoundary];
|
static NSCharacterSet *trailingWhitespaceSet = nil;
|
||||||
nextEmptyRounds = 0;
|
static dispatch_once_t onceToken;
|
||||||
} else {
|
dispatch_once(&onceToken, ^{
|
||||||
batch = kKBBackspaceClearBatchSize;
|
// stopBoundary: 遇到这些符号就停(不删除它)
|
||||||
nextEmptyRounds = emptyRounds + 1;
|
// - 句末符号:. ! ? 。!?
|
||||||
}
|
// - 省略号:…(中文里“……”常用作句/段落的停顿)
|
||||||
if (batch <= 0) { batch = 1; }
|
// - 换行:\n \r(段落边界,避免一次“清空”跨段把全文删完)
|
||||||
|
stopBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?…\n\r\u2028\u2029"];
|
||||||
|
|
||||||
if (guard >= kKBBackspaceClearMaxDeletes ||
|
// trailingBoundary: 允许作为“尾部句末符号”先删掉,再继续删上一句(更接近微信体验)
|
||||||
nextEmptyRounds > kKBBackspaceClearEmptyContextMaxRounds) {
|
// 注意:不要把换行/省略号放进来,否则可能跨段/跨停顿继续删。
|
||||||
|
trailingBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?"];
|
||||||
|
|
||||||
|
// trailingWhitespace: 只跳过空格/Tab(不包含换行,换行由 stopBoundarySet 处理)
|
||||||
|
trailingWhitespaceSet = [NSCharacterSet whitespaceCharacterSet];
|
||||||
|
});
|
||||||
|
KBClearPhase phase = self.backspaceClearPhase;
|
||||||
|
|
||||||
|
NSInteger deletedThisTick = 0;
|
||||||
|
BOOL shouldStop = NO;
|
||||||
|
NSString *lastBefore = nil;
|
||||||
|
for (NSInteger i = 0; i < kKBBackspaceClearDeletesPerTick; i++) {
|
||||||
|
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||||
|
if (before.length == 0) {
|
||||||
|
nextEmptyRounds += 1;
|
||||||
|
// 宿主(微信/QQ 等)可能在长文本场景下返回空 context,即使还有很多内容。
|
||||||
|
// 为了避免一次“清空”误删全文:一旦拿不到 before,就立刻停止本次清空。
|
||||||
|
shouldStop = YES;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
nextEmptyRounds = 0;
|
||||||
|
|
||||||
|
if (lastBefore && [before isEqualToString:lastBefore] && deletedThisTick > 0) {
|
||||||
|
// 宿主未及时刷新 context,留到下一 tick 再继续,避免越界/重复记录
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lastBefore = before;
|
||||||
|
|
||||||
|
// 取最后一个组合字符
|
||||||
|
__block NSString *lastChar = @"";
|
||||||
|
[before enumerateSubstringsInRange:NSMakeRange(0, before.length)
|
||||||
|
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
||||||
|
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
||||||
|
lastChar = substring ?: @"";
|
||||||
|
*stop = YES;
|
||||||
|
}];
|
||||||
|
if (lastChar.length == 0) { break; }
|
||||||
|
|
||||||
|
BOOL isWhitespace = ([lastChar rangeOfCharacterFromSet:trailingWhitespaceSet].location != NSNotFound);
|
||||||
|
BOOL isStopBoundary = ([lastChar rangeOfCharacterFromSet:stopBoundarySet].location != NSNotFound);
|
||||||
|
BOOL isTrailingBoundary = ([lastChar rangeOfCharacterFromSet:trailingBoundarySet].location != NSNotFound);
|
||||||
|
|
||||||
|
if (phase == KBClearPhaseSkipWhitespace) {
|
||||||
|
if (isWhitespace) {
|
||||||
|
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:1];
|
||||||
|
[[KBInputBufferManager shared] applyClearDeleteCount:1];
|
||||||
|
deletedThisTick += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
phase = KBClearPhaseSkipTrailingBoundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase == KBClearPhaseSkipTrailingBoundary) {
|
||||||
|
if (isTrailingBoundary) {
|
||||||
|
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:1];
|
||||||
|
[[KBInputBufferManager shared] applyClearDeleteCount:1];
|
||||||
|
deletedThisTick += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
phase = KBClearPhaseDeleteUntilBoundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// phase == DeleteUntilBoundary
|
||||||
|
if (isStopBoundary) {
|
||||||
|
shouldStop = YES; // 保留该句末符号
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:1];
|
||||||
|
[[KBInputBufferManager shared] applyClearDeleteCount:1];
|
||||||
|
deletedThisTick += 1;
|
||||||
|
if (guard + deletedThisTick >= kKBBackspaceClearMaxDeletes) { break; }
|
||||||
|
if (deletedThisTick >= kKBBackspaceClearMaxStep) { break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
self.backspaceClearPhase = phase;
|
||||||
|
NSInteger nextGuard = guard + deletedThisTick;
|
||||||
|
if (nextGuard >= kKBBackspaceClearMaxDeletes ||
|
||||||
|
nextEmptyRounds > kKBBackspaceClearEmptyContextMaxRounds ||
|
||||||
|
shouldStop) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (NSInteger i = 0; i < batch; i++) {
|
|
||||||
[proxy deleteBackward];
|
|
||||||
}
|
|
||||||
|
|
||||||
NSInteger nextGuard = guard + batch;
|
|
||||||
BOOL shouldContinue = NO;
|
|
||||||
if (count > 0 && !hitBoundary) {
|
|
||||||
if (count > batch) {
|
|
||||||
shouldContinue = YES;
|
|
||||||
} else if ([proxy hasText]) {
|
|
||||||
shouldContinue = YES;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldContinue) { return; }
|
|
||||||
__weak typeof(self) weakSelf = self;
|
__weak typeof(self) weakSelf = self;
|
||||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
||||||
(int64_t)(kKBBackspaceClearBatchInterval * NSEC_PER_SEC)),
|
(int64_t)(kKBBackspaceClearBatchInterval * NSEC_PER_SEC)),
|
||||||
@@ -489,4 +637,60 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|||||||
return self.backspaceButton.superview;
|
return self.backspaceButton.superview;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)kb_captureDeletionSnapshotIfNeeded {
|
||||||
|
if ([KBBackspaceUndoManager shared].hasUndo) { return; }
|
||||||
|
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||||
|
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||||
|
if (!ivc) { return; }
|
||||||
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||||
|
[[KBBackspaceUndoManager shared] recordDeletionSnapshotBefore:proxy.documentContextBeforeInput
|
||||||
|
after:proxy.documentContextAfterInput];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_capturePendingClearSnapshotIfNeeded {
|
||||||
|
if (self.pendingClearBefore.length > 0 || self.pendingClearAfter.length > 0) { return; }
|
||||||
|
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||||
|
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||||
|
if (!ivc) { return; }
|
||||||
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||||
|
self.pendingClearBefore = proxy.documentContextBeforeInput ?: @"";
|
||||||
|
self.pendingClearAfter = proxy.documentContextAfterInput ?: @"";
|
||||||
|
#if DEBUG
|
||||||
|
NSLog(@"[kb_capturePendingClearSnapshotIfNeeded/before] len=%lu text=%@", (unsigned long)self.pendingClearBefore.length, self.pendingClearBefore);
|
||||||
|
NSLog(@"[kb_capturePendingClearSnapshotIfNeeded/after] len=%lu text=%@", (unsigned long)self.pendingClearAfter.length, self.pendingClearAfter);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_clearCurrentWordIfPossible {
|
||||||
|
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||||
|
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||||
|
if (!ivc) { return; }
|
||||||
|
#pragma clang diagnostic push
|
||||||
|
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||||
|
if ([ivc respondsToSelector:@selector(kb_clearCurrentWord)]) {
|
||||||
|
[ivc performSelector:@selector(kb_clearCurrentWord)];
|
||||||
|
}
|
||||||
|
#pragma clang diagnostic pop
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_refreshSuggestionsAfterLongPressClear:(BOOL)shouldClear {
|
||||||
|
NSTimeInterval delay = shouldClear ? 0.06 : 0.0;
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)),
|
||||||
|
dispatch_get_main_queue(), ^{
|
||||||
|
[self kb_scheduleContextRefreshResetSuppression:NO];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression {
|
||||||
|
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||||
|
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||||
|
if (!ivc) { return; }
|
||||||
|
SEL sel = @selector(kb_scheduleContextRefreshResetSuppression:);
|
||||||
|
if (![ivc respondsToSelector:sel]) { return; }
|
||||||
|
void (*func)(id, SEL, BOOL) = (void *)[ivc methodForSelector:sel];
|
||||||
|
if (func) {
|
||||||
|
func(ivc, sel, resetSuppression);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -15,13 +15,19 @@ extern NSNotificationName const KBBackspaceUndoStateDidChangeNotification;
|
|||||||
|
|
||||||
+ (instancetype)shared;
|
+ (instancetype)shared;
|
||||||
|
|
||||||
/// 记录一次“立刻清空”删除的内容(基于 documentContextBeforeInput)
|
/// 记录一次删除前的快照(不改变撤销按钮显示)。
|
||||||
- (void)recordClearWithContext:(NSString *)context;
|
- (void)recordDeletionSnapshotBefore:(NSString *)before after:(NSString *)after;
|
||||||
|
|
||||||
|
/// 记录一次“立刻清空”删除的内容(基于 documentContextBeforeInput/AfterInput)。
|
||||||
|
- (void)recordClearWithContextBefore:(NSString *)before after:(NSString *)after;
|
||||||
|
|
||||||
|
/// 记录本次将被 deleteBackward 的内容,并执行 deleteBackward(支持多次累计,撤销时一次性插回)。
|
||||||
|
- (void)captureAndDeleteBackwardFromProxy:(id<UITextDocumentProxy>)proxy count:(NSUInteger)count;
|
||||||
|
|
||||||
/// 在指定 responder 处执行撤销(向光标处插回删除的内容)
|
/// 在指定 responder 处执行撤销(向光标处插回删除的内容)
|
||||||
- (void)performUndoFromResponder:(UIResponder *)responder;
|
- (void)performUndoFromResponder:(UIResponder *)responder;
|
||||||
|
|
||||||
/// 非清空行为触发时,清理撤销状态
|
/// 非删除行为触发时,清理撤销状态
|
||||||
- (void)registerNonClearAction;
|
- (void)registerNonClearAction;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -5,13 +5,38 @@
|
|||||||
|
|
||||||
#import "KBBackspaceUndoManager.h"
|
#import "KBBackspaceUndoManager.h"
|
||||||
#import "KBResponderUtils.h"
|
#import "KBResponderUtils.h"
|
||||||
|
#import "KBInputBufferManager.h"
|
||||||
|
|
||||||
NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspaceUndoStateDidChangeNotification";
|
NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspaceUndoStateDidChangeNotification";
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
static NSString *KBLogString(NSString *tag, NSString *text) {
|
||||||
|
NSString *safeTag = tag ?: @"";
|
||||||
|
NSString *safeText = text ?: @"";
|
||||||
|
if (safeText.length <= 2000) {
|
||||||
|
return [NSString stringWithFormat:@"[%@] len=%lu text=%@", safeTag, (unsigned long)safeText.length, safeText];
|
||||||
|
}
|
||||||
|
NSString *head = [safeText substringToIndex:800];
|
||||||
|
NSString *tail = [safeText substringFromIndex:safeText.length - 800];
|
||||||
|
return [NSString stringWithFormat:@"[%@] len=%lu head=%@ ... tail=%@", safeTag, (unsigned long)safeText.length, head, tail];
|
||||||
|
}
|
||||||
|
#define KB_UNDO_LOG(tag, text) NSLog(@"%@", KBLogString((tag), (text)))
|
||||||
|
#else
|
||||||
|
#define KB_UNDO_LOG(tag, text) do {} while(0)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
typedef NS_ENUM(NSInteger, KBUndoSnapshotSource) {
|
||||||
|
KBUndoSnapshotSourceNone = 0,
|
||||||
|
KBUndoSnapshotSourceDeletionSnapshot,
|
||||||
|
KBUndoSnapshotSourceClear
|
||||||
|
};
|
||||||
|
|
||||||
@interface KBBackspaceUndoManager ()
|
@interface KBBackspaceUndoManager ()
|
||||||
@property (nonatomic, strong) NSMutableArray<NSString *> *segments; // deletion order (last -> first)
|
@property (nonatomic, copy) NSString *undoText;
|
||||||
@property (nonatomic, assign) BOOL lastActionWasClear;
|
@property (nonatomic, assign) NSInteger undoAfterLength;
|
||||||
@property (nonatomic, assign) BOOL hasUndo;
|
@property (nonatomic, assign) BOOL hasUndo;
|
||||||
|
@property (nonatomic, assign) KBUndoSnapshotSource snapshotSource;
|
||||||
|
@property (nonatomic, strong) NSMutableArray<NSString *> *undoDeletedPieces;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation KBBackspaceUndoManager
|
@implementation KBBackspaceUndoManager
|
||||||
@@ -27,42 +52,191 @@ NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspa
|
|||||||
|
|
||||||
- (instancetype)init {
|
- (instancetype)init {
|
||||||
if (self = [super init]) {
|
if (self = [super init]) {
|
||||||
_segments = [NSMutableArray array];
|
_undoText = @"";
|
||||||
|
_undoAfterLength = 0;
|
||||||
|
_snapshotSource = KBUndoSnapshotSourceNone;
|
||||||
|
_undoDeletedPieces = [NSMutableArray array];
|
||||||
}
|
}
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)recordClearWithContext:(NSString *)context {
|
- (void)captureAndDeleteBackwardFromProxy:(id<UITextDocumentProxy>)proxy count:(NSUInteger)count {
|
||||||
if (context.length == 0) { return; }
|
if (!proxy || count == 0) { return; }
|
||||||
NSString *segment = [self kb_segmentForClearFromContext:context];
|
|
||||||
if (segment.length == 0) { return; }
|
|
||||||
|
|
||||||
if (!self.lastActionWasClear) {
|
NSString *selected = proxy.selectedText ?: @"";
|
||||||
[self.segments removeAllObjects];
|
NSString *ctxBefore = proxy.documentContextBeforeInput ?: @"";
|
||||||
|
NSString *ctxAfter = proxy.documentContextAfterInput ?: @"";
|
||||||
|
NSUInteger ctxLen = ctxBefore.length + ctxAfter.length;
|
||||||
|
BOOL isSelectAllLike = (selected.length > 0 &&
|
||||||
|
(ctxLen == 0 || selected.length >= MAX((NSUInteger)40, ctxLen * 2)));
|
||||||
|
if (isSelectAllLike) {
|
||||||
|
// “全选删除”在微信/QQ中通常拿不到可靠的全文,因此禁用撤销,避免插回错误/不完整内容。
|
||||||
|
if (self.hasUndo) {
|
||||||
|
[self registerNonClearAction];
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
KB_UNDO_LOG(@"captureAndDelete/selectAllDisableUndo", selected);
|
||||||
|
#endif
|
||||||
|
[proxy deleteBackward];
|
||||||
|
[[KBInputBufferManager shared] resetWithText:@""];
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
[self.segments addObject:segment];
|
|
||||||
self.lastActionWasClear = YES;
|
if (!self.hasUndo) {
|
||||||
|
[self.undoDeletedPieces removeAllObjects];
|
||||||
|
self.undoText = @"";
|
||||||
|
self.undoAfterLength = 0;
|
||||||
|
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
||||||
|
[self kb_updateHasUndo:YES];
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL didAppend = NO;
|
||||||
|
NSString *lastObservedBefore = nil;
|
||||||
|
for (NSUInteger i = 0; i < count; i++) {
|
||||||
|
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||||
|
if (before.length > 0) {
|
||||||
|
// 若宿主在同一 runloop 内不更新 context,则跳过记录,避免把同一个字符重复记录成“多句”。
|
||||||
|
if (lastObservedBefore && [before isEqualToString:lastObservedBefore]) {
|
||||||
|
// still delete, but don't record
|
||||||
|
} else {
|
||||||
|
NSString *piece = [self kb_lastComposedCharacterFromString:before];
|
||||||
|
if (piece.length > 0) {
|
||||||
|
[self.undoDeletedPieces addObject:piece];
|
||||||
|
didAppend = YES;
|
||||||
|
}
|
||||||
|
lastObservedBefore = before;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[proxy deleteBackward];
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
if (didAppend) {
|
||||||
|
NSUInteger piecesCount = self.undoDeletedPieces.count;
|
||||||
|
if (piecesCount <= 20) {
|
||||||
|
KB_UNDO_LOG(@"captureAndDelete/undoInsertTextNow", [self kb_buildUndoInsertTextFromPieces]);
|
||||||
|
} else if (piecesCount % 50 == 0) {
|
||||||
|
NSString *lastPiece = self.undoDeletedPieces.lastObject ?: @"";
|
||||||
|
NSLog(@"[captureAndDelete/undoPieces] pieces=%lu lastPiece=%@",
|
||||||
|
(unsigned long)piecesCount,
|
||||||
|
lastPiece);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)recordDeletionSnapshotBefore:(NSString *)before after:(NSString *)after {
|
||||||
|
if (self.hasUndo) { return; }
|
||||||
|
NSString *pending = [KBInputBufferManager shared].pendingClearSnapshot;
|
||||||
|
NSString *manual = [KBInputBufferManager shared].manualSnapshot;
|
||||||
|
NSString *fallbackText = (pending.length > 0) ? pending : ((manual.length > 0) ? manual : [KBInputBufferManager shared].liveText);
|
||||||
|
if (fallbackText.length > 0) {
|
||||||
|
self.undoText = fallbackText;
|
||||||
|
self.undoAfterLength = 0;
|
||||||
|
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
||||||
|
KB_UNDO_LOG(@"recordDeletionSnapshot/fallback", self.undoText);
|
||||||
|
[self kb_updateHasUndo:YES];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSString *safeBefore = before ?: @"";
|
||||||
|
NSString *safeAfter = after ?: @"";
|
||||||
|
NSString *full = [safeBefore stringByAppendingString:safeAfter];
|
||||||
|
if (full.length == 0) { return; }
|
||||||
|
self.undoText = full;
|
||||||
|
self.undoAfterLength = (NSInteger)safeAfter.length;
|
||||||
|
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
||||||
|
KB_UNDO_LOG(@"recordDeletionSnapshot/context", self.undoText);
|
||||||
|
[self kb_updateHasUndo:YES];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)recordClearWithContextBefore:(NSString *)before after:(NSString *)after {
|
||||||
|
NSString *pending = [KBInputBufferManager shared].pendingClearSnapshot;
|
||||||
|
NSString *manual = [KBInputBufferManager shared].manualSnapshot;
|
||||||
|
NSString *fallbackText = (pending.length > 0) ? pending : ((manual.length > 0) ? manual : [KBInputBufferManager shared].liveText);
|
||||||
|
|
||||||
|
NSString *safeBefore = before ?: @"";
|
||||||
|
NSString *safeAfter = after ?: @"";
|
||||||
|
NSString *contextText = [[safeBefore stringByAppendingString:safeAfter] copy];
|
||||||
|
|
||||||
|
NSString *candidate = (fallbackText.length > 0) ? fallbackText : contextText;
|
||||||
|
NSInteger candidateAfterLen = (fallbackText.length > 0) ? 0 : (NSInteger)safeAfter.length;
|
||||||
|
|
||||||
|
if (candidate.length == 0) { return; }
|
||||||
|
|
||||||
|
KB_UNDO_LOG(@"recordClear/candidate", candidate);
|
||||||
|
|
||||||
|
if (self.undoText.length > 0) {
|
||||||
|
if (self.snapshotSource == KBUndoSnapshotSourceClear) {
|
||||||
|
KB_UNDO_LOG(@"recordClear/ignored(alreadyClear)", self.undoText);
|
||||||
|
[self kb_updateHasUndo:YES];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (self.snapshotSource == KBUndoSnapshotSourceDeletionSnapshot) {
|
||||||
|
if (candidate.length > self.undoText.length) {
|
||||||
|
self.undoText = candidate;
|
||||||
|
self.undoAfterLength = candidateAfterLen;
|
||||||
|
KB_UNDO_LOG(@"recordClear/upgradedFromDeletion", self.undoText);
|
||||||
|
} else {
|
||||||
|
KB_UNDO_LOG(@"recordClear/keepDeletionSnapshot", self.undoText);
|
||||||
|
}
|
||||||
|
self.snapshotSource = KBUndoSnapshotSourceClear;
|
||||||
|
[self kb_updateHasUndo:YES];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.undoText = candidate;
|
||||||
|
self.undoAfterLength = candidateAfterLen;
|
||||||
|
self.snapshotSource = KBUndoSnapshotSourceClear;
|
||||||
|
KB_UNDO_LOG(@"recordClear/set", self.undoText);
|
||||||
[self kb_updateHasUndo:YES];
|
[self kb_updateHasUndo:YES];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)performUndoFromResponder:(UIResponder *)responder {
|
- (void)performUndoFromResponder:(UIResponder *)responder {
|
||||||
if (self.segments.count == 0) { return; }
|
if (!self.hasUndo) { return; }
|
||||||
UIInputViewController *ivc = KBFindInputViewController(responder);
|
UIInputViewController *ivc = KBFindInputViewController(responder);
|
||||||
if (!ivc) { return; }
|
if (!ivc) { return; }
|
||||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||||
NSString *text = [self kb_buildUndoText];
|
NSString *curBefore = proxy.documentContextBeforeInput ?: @"";
|
||||||
if (text.length == 0) { return; }
|
NSString *curAfter = proxy.documentContextAfterInput ?: @"";
|
||||||
[proxy insertText:text];
|
KB_UNDO_LOG(@"performUndo/currentBefore", curBefore);
|
||||||
|
KB_UNDO_LOG(@"performUndo/currentAfter", curAfter);
|
||||||
[self.segments removeAllObjects];
|
NSString *insertText = [self kb_buildUndoInsertTextFromPieces];
|
||||||
self.lastActionWasClear = NO;
|
if (insertText.length > 0) {
|
||||||
|
KB_UNDO_LOG(@"performUndo/insertDeletedText", insertText);
|
||||||
|
[proxy insertText:insertText];
|
||||||
|
[[KBInputBufferManager shared] appendText:insertText];
|
||||||
|
} else if (self.undoText.length > 0) {
|
||||||
|
KB_UNDO_LOG(@"performUndo/fallbackUndoText", self.undoText);
|
||||||
|
[self kb_clearAllTextForProxy:proxy];
|
||||||
|
[proxy insertText:self.undoText];
|
||||||
|
if (self.undoAfterLength > 0 &&
|
||||||
|
[proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) {
|
||||||
|
[proxy adjustTextPositionByCharacterOffset:-self.undoAfterLength];
|
||||||
|
}
|
||||||
|
[[KBInputBufferManager shared] resetWithText:self.undoText];
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.undoText = @"";
|
||||||
|
self.undoAfterLength = 0;
|
||||||
|
self.snapshotSource = KBUndoSnapshotSourceNone;
|
||||||
|
[self.undoDeletedPieces removeAllObjects];
|
||||||
[self kb_updateHasUndo:NO];
|
[self kb_updateHasUndo:NO];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)registerNonClearAction {
|
- (void)registerNonClearAction {
|
||||||
self.lastActionWasClear = NO;
|
if (!self.hasUndo) { return; }
|
||||||
if (self.segments.count == 0) { return; }
|
if (self.undoText.length > 0) {
|
||||||
[self.segments removeAllObjects];
|
KB_UNDO_LOG(@"registerNonClearAction/clearUndoText", self.undoText);
|
||||||
|
}
|
||||||
|
if (self.undoDeletedPieces.count > 0) {
|
||||||
|
KB_UNDO_LOG(@"registerNonClearAction/clearDeletedPieces", [self kb_buildUndoInsertTextFromPieces]);
|
||||||
|
}
|
||||||
|
self.undoText = @"";
|
||||||
|
self.undoAfterLength = 0;
|
||||||
|
self.snapshotSource = KBUndoSnapshotSourceNone;
|
||||||
|
[self.undoDeletedPieces removeAllObjects];
|
||||||
[self kb_updateHasUndo:NO];
|
[self kb_updateHasUndo:NO];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,97 +248,57 @@ NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspa
|
|||||||
[[NSNotificationCenter defaultCenter] postNotificationName:KBBackspaceUndoStateDidChangeNotification object:self];
|
[[NSNotificationCenter defaultCenter] postNotificationName:KBBackspaceUndoStateDidChangeNotification object:self];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (NSString *)kb_segmentForClearFromContext:(NSString *)context {
|
- (NSString *)kb_lastComposedCharacterFromString:(NSString *)text {
|
||||||
NSInteger length = context.length;
|
if (text.length == 0) { return @""; }
|
||||||
if (length == 0) { return @""; }
|
__block NSString *last = @"";
|
||||||
|
[text enumerateSubstringsInRange:NSMakeRange(0, text.length)
|
||||||
|
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
||||||
|
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
||||||
|
last = substring ?: @"";
|
||||||
|
*stop = YES;
|
||||||
|
}];
|
||||||
|
return last ?: @"";
|
||||||
|
}
|
||||||
|
|
||||||
static NSCharacterSet *sentenceBoundarySet = nil;
|
- (NSString *)kb_buildUndoInsertTextFromPieces {
|
||||||
static NSCharacterSet *whitespaceSet = nil;
|
if (self.undoDeletedPieces.count == 0) { return @""; }
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
|
|
||||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
|
||||||
});
|
|
||||||
|
|
||||||
NSInteger end = length;
|
|
||||||
while (end > 0) {
|
|
||||||
unichar ch = [context characterAtIndex:end - 1];
|
|
||||||
if ([whitespaceSet characterIsMember:ch]) {
|
|
||||||
end -= 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NSInteger searchEnd = end;
|
|
||||||
while (searchEnd > 0) {
|
|
||||||
unichar ch = [context characterAtIndex:searchEnd - 1];
|
|
||||||
if ([sentenceBoundarySet characterIsMember:ch]) {
|
|
||||||
searchEnd -= 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NSInteger boundaryIndex = NSNotFound;
|
|
||||||
for (NSInteger i = searchEnd - 1; i >= 0; i--) {
|
|
||||||
unichar ch = [context characterAtIndex:i];
|
|
||||||
if ([sentenceBoundarySet characterIsMember:ch]) {
|
|
||||||
boundaryIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NSInteger start = (boundaryIndex == NSNotFound) ? 0 : (boundaryIndex + 1);
|
|
||||||
if (start >= length) { return @""; }
|
|
||||||
return [context substringFromIndex:start];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)kb_buildUndoText {
|
|
||||||
if (self.segments.count == 0) { return @""; }
|
|
||||||
NSArray<NSString *> *ordered = [[self.segments reverseObjectEnumerator] allObjects];
|
|
||||||
NSMutableString *result = [NSMutableString string];
|
NSMutableString *result = [NSMutableString string];
|
||||||
for (NSInteger i = 0; i < ordered.count; i++) {
|
for (NSInteger i = (NSInteger)self.undoDeletedPieces.count - 1; i >= 0; i--) {
|
||||||
NSString *segment = ordered[i] ?: @"";
|
NSString *piece = self.undoDeletedPieces[(NSUInteger)i] ?: @"";
|
||||||
if (segment.length == 0) { continue; }
|
if (piece.length == 0) { continue; }
|
||||||
if (i < ordered.count - 1) {
|
[result appendString:piece];
|
||||||
segment = [self kb_replaceTrailingBoundaryWithComma:segment];
|
}
|
||||||
}
|
return result;
|
||||||
[result appendString:segment];
|
}
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (NSString *)kb_replaceTrailingBoundaryWithComma:(NSString *)segment {
|
static const NSInteger kKBUndoClearMaxRounds = 200;
|
||||||
if (segment.length == 0) { return segment; }
|
|
||||||
|
|
||||||
static NSCharacterSet *boundarySet = nil;
|
- (void)kb_clearAllTextForProxy:(id<UITextDocumentProxy>)proxy {
|
||||||
static NSCharacterSet *englishBoundarySet = nil;
|
if (!proxy) { return; }
|
||||||
static NSCharacterSet *whitespaceSet = nil;
|
|
||||||
static dispatch_once_t onceToken;
|
|
||||||
dispatch_once(&onceToken, ^{
|
|
||||||
boundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
|
|
||||||
englishBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;"];
|
|
||||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
|
||||||
});
|
|
||||||
|
|
||||||
NSInteger idx = segment.length - 1;
|
if ([proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) {
|
||||||
while (idx >= 0) {
|
NSInteger guard = 0;
|
||||||
unichar ch = [segment characterAtIndex:idx];
|
NSString *contextAfter = proxy.documentContextAfterInput ?: @"";
|
||||||
if ([whitespaceSet characterIsMember:ch]) {
|
while (contextAfter.length > 0 && guard < kKBUndoClearMaxRounds) {
|
||||||
idx -= 1;
|
NSInteger offset = (NSInteger)contextAfter.length;
|
||||||
continue;
|
[proxy adjustTextPositionByCharacterOffset:offset];
|
||||||
|
for (NSUInteger i = 0; i < contextAfter.length; i++) {
|
||||||
|
[proxy deleteBackward];
|
||||||
|
}
|
||||||
|
guard += 1;
|
||||||
|
contextAfter = proxy.documentContextAfterInput ?: @"";
|
||||||
}
|
}
|
||||||
if (![boundarySet characterIsMember:ch]) {
|
|
||||||
return segment;
|
|
||||||
}
|
|
||||||
NSString *comma = [englishBoundarySet characterIsMember:ch] ? @"," : @",";
|
|
||||||
NSMutableString *mutable = [segment mutableCopy];
|
|
||||||
NSRange r = NSMakeRange(idx, 1);
|
|
||||||
[mutable replaceCharactersInRange:r withString:comma];
|
|
||||||
return mutable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return segment;
|
NSInteger guard = 0;
|
||||||
|
NSString *contextBefore = proxy.documentContextBeforeInput ?: @"";
|
||||||
|
while (contextBefore.length > 0 && guard < kKBUndoClearMaxRounds) {
|
||||||
|
for (NSUInteger i = 0; i < contextBefore.length; i++) {
|
||||||
|
[proxy deleteBackward];
|
||||||
|
}
|
||||||
|
guard += 1;
|
||||||
|
contextBefore = proxy.documentContextBeforeInput ?: @"";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// KBExtensionAppLauncher.h
|
// KBExtensionAppLauncher.h
|
||||||
// CustomKeyboard
|
// CustomKeyboard
|
||||||
//
|
//
|
||||||
// 封装:在键盘扩展中拉起主 App(Scheme / Universal Link + 响应链兜底)。
|
// 封装:在键盘扩展中拉起主 App(Scheme / Universal Link)。
|
||||||
//
|
//
|
||||||
|
|
||||||
#import <UIKit/UIKit.h>
|
#import <UIKit/UIKit.h>
|
||||||
@@ -12,23 +12,24 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
@interface KBExtensionAppLauncher : NSObject
|
@interface KBExtensionAppLauncher : NSObject
|
||||||
|
|
||||||
/// 通用入口:优先尝试 primaryURL,失败后尝试 fallbackURL,
|
/// 通用入口:优先尝试 primaryURL,失败后尝试 fallbackURL,
|
||||||
/// 两者都失败时再通过响应链(openURL:)做兜底。
|
/// 均通过 `extensionContext openURL` 发起跳转(避免使用扩展禁用 API/响应链绕行)。
|
||||||
|
/// 若开启 `KB_URL_BRIDGE_ENABLE=1`,会在两次 `extensionContext openURL` 均失败时,
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - primaryURL: 第一优先尝试的 URL(可为 Scheme 或 UL)
|
/// - primaryURL: 第一优先尝试的 URL(可为 Scheme 或 UL)
|
||||||
/// - fallbackURL: 失败时的备用 URL(可为 nil)
|
/// - fallbackURL: 失败时的备用 URL(可为 nil)
|
||||||
/// - ivc: 当前的 UIInputViewController(用于 extensionContext openURL)
|
/// - ivc: 当前的 UIInputViewController(用于 extensionContext openURL)
|
||||||
/// - source: 兜底时用作起点的 responder(通常传 self 或 self.view)
|
/// - source: 作为响应链兜底的起点(可为 nil)
|
||||||
/// - completion: 最终是否“看起来已成功发起”打开动作(不保证一定跳转到 App)
|
/// - completion: 最终是否“看起来已成功发起”打开动作(不保证一定跳转到 App)
|
||||||
+ (void)openPrimaryURL:(NSURL * _Nullable)primaryURL
|
+ (void)openPrimaryURL:(NSURL * _Nullable)primaryURL
|
||||||
fallbackURL:(NSURL * _Nullable)fallbackURL
|
fallbackURL:(NSURL * _Nullable)fallbackURL
|
||||||
usingInputController:(UIInputViewController *)ivc
|
usingInputController:(UIInputViewController *)ivc
|
||||||
source:(UIResponder *)source
|
source:(UIResponder * _Nullable)source
|
||||||
completion:(void (^ _Nullable)(BOOL success))completion;
|
completion:(void (^ _Nullable)(BOOL success))completion;
|
||||||
|
|
||||||
/// 简化版:只针对单一 Scheme 做尝试 + 响应链兜底。
|
/// 简化版:只针对单一 Scheme 做尝试。
|
||||||
+ (void)openScheme:(NSURL *)scheme
|
+ (void)openScheme:(NSURL *)scheme
|
||||||
usingInputController:(UIInputViewController *)ivc
|
usingInputController:(UIInputViewController *)ivc
|
||||||
source:(UIResponder *)source
|
source:(UIResponder * _Nullable)source
|
||||||
completion:(void (^ _Nullable)(BOOL success))completion;
|
completion:(void (^ _Nullable)(BOOL success))completion;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -4,15 +4,86 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
#import "KBExtensionAppLauncher.h"
|
#import "KBExtensionAppLauncher.h"
|
||||||
|
|
||||||
|
#if KB_URL_BRIDGE_ENABLE
|
||||||
#import <objc/message.h>
|
#import <objc/message.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
@implementation KBExtensionAppLauncher
|
@implementation KBExtensionAppLauncher
|
||||||
|
|
||||||
|
#if KB_URL_BRIDGE_ENABLE
|
||||||
|
+ (BOOL)kb_openURLViaResponderChain:(NSURL *)url
|
||||||
|
source:(nullable UIResponder *)source
|
||||||
|
completion:(void (^ _Nullable)(BOOL success))completion {
|
||||||
|
if (!url) {
|
||||||
|
if (completion) { completion(NO); }
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
UIResponder *responder = source;
|
||||||
|
|
||||||
|
// 优先尝试 openURL:options:completionHandler:
|
||||||
|
// 注意:在键盘扩展里走“响应链兜底”本身就存在不确定性;不同系统/宿主 App 的实现
|
||||||
|
// 可能对 options 参数的类型有不同假设。为避免类型不匹配导致崩溃,options 统一传 nil。
|
||||||
|
SEL openURLOptionsSel = NSSelectorFromString(@"openURL:options:completionHandler:");
|
||||||
|
while (responder) {
|
||||||
|
if ([responder respondsToSelector:openURLOptionsSel]) {
|
||||||
|
void (*msgSend)(id, SEL, NSURL *, id, void (^)(BOOL)) = (void *)objc_msgSend;
|
||||||
|
msgSend(responder, openURLOptionsSel, url, nil, ^(BOOL ok) {
|
||||||
|
if (completion) { completion(ok); }
|
||||||
|
});
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
responder = responder.nextResponder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试 openURL:completionHandler:
|
||||||
|
responder = source;
|
||||||
|
SEL openURLCompletionSel = NSSelectorFromString(@"openURL:completionHandler:");
|
||||||
|
while (responder) {
|
||||||
|
if ([responder respondsToSelector:openURLCompletionSel]) {
|
||||||
|
void (*msgSend)(id, SEL, NSURL *, void (^)(BOOL)) = (void *)objc_msgSend;
|
||||||
|
msgSend(responder, openURLCompletionSel, url, ^(BOOL ok) {
|
||||||
|
if (completion) { completion(ok); }
|
||||||
|
});
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
responder = responder.nextResponder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:openURL:
|
||||||
|
responder = source;
|
||||||
|
SEL openURLSel = NSSelectorFromString(@"openURL:");
|
||||||
|
while (responder) {
|
||||||
|
if ([responder respondsToSelector:openURLSel]) {
|
||||||
|
BOOL (*msgSend)(id, SEL, NSURL *) = (void *)objc_msgSend;
|
||||||
|
BOOL ok = msgSend(responder, openURLSel, url);
|
||||||
|
if (completion) { completion(ok); }
|
||||||
|
return YES;
|
||||||
|
}
|
||||||
|
responder = responder.nextResponder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completion) { completion(NO); }
|
||||||
|
return NO;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
+ (void)openPrimaryURL:(NSURL * _Nullable)primaryURL
|
+ (void)openPrimaryURL:(NSURL * _Nullable)primaryURL
|
||||||
fallbackURL:(NSURL * _Nullable)fallbackURL
|
fallbackURL:(NSURL * _Nullable)fallbackURL
|
||||||
usingInputController:(UIInputViewController *)ivc
|
usingInputController:(UIInputViewController *)ivc
|
||||||
source:(UIResponder *)source
|
source:(UIResponder * _Nullable)source
|
||||||
completion:(void (^ _Nullable)(BOOL success))completion {
|
completion:(void (^ _Nullable)(BOOL success))completion {
|
||||||
|
if (![NSThread isMainThread]) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
[self openPrimaryURL:primaryURL
|
||||||
|
fallbackURL:fallbackURL
|
||||||
|
usingInputController:ivc
|
||||||
|
source:source
|
||||||
|
completion:completion];
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!ivc || (!primaryURL && !fallbackURL)) {
|
if (!ivc || (!primaryURL && !fallbackURL)) {
|
||||||
if (completion) { completion(NO); }
|
if (completion) { completion(NO); }
|
||||||
return;
|
return;
|
||||||
@@ -48,19 +119,37 @@
|
|||||||
finish(YES);
|
finish(YES);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BOOL bridged = [self p_bridgeFirst:first second:second from:source];
|
|
||||||
finish(bridged);
|
#if KB_URL_BRIDGE_ENABLE
|
||||||
|
// 的场景且业务强依赖时才开启此兜底。
|
||||||
|
UIResponder *start = (source ?: (UIResponder *)ivc.view ?: (UIResponder *)ivc);
|
||||||
|
[self kb_openURLViaResponderChain:second
|
||||||
|
source:start
|
||||||
|
completion:^(BOOL ok3) {
|
||||||
|
finish(ok3);
|
||||||
|
}];
|
||||||
|
#else
|
||||||
|
finish(NO);
|
||||||
|
#endif
|
||||||
}];
|
}];
|
||||||
} else {
|
} else {
|
||||||
BOOL bridged = [self p_bridgeFirst:first second:nil from:source];
|
#if KB_URL_BRIDGE_ENABLE
|
||||||
finish(bridged);
|
UIResponder *start = (source ?: (UIResponder *)ivc.view ?: (UIResponder *)ivc);
|
||||||
|
[self kb_openURLViaResponderChain:first
|
||||||
|
source:start
|
||||||
|
completion:^(BOOL ok3) {
|
||||||
|
finish(ok3);
|
||||||
|
}];
|
||||||
|
#else
|
||||||
|
finish(NO);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
+ (void)openScheme:(NSURL *)scheme
|
+ (void)openScheme:(NSURL *)scheme
|
||||||
usingInputController:(UIInputViewController *)ivc
|
usingInputController:(UIInputViewController *)ivc
|
||||||
source:(UIResponder *)source
|
source:(UIResponder * _Nullable)source
|
||||||
completion:(void (^ _Nullable)(BOOL success))completion {
|
completion:(void (^ _Nullable)(BOOL success))completion {
|
||||||
[self openPrimaryURL:scheme
|
[self openPrimaryURL:scheme
|
||||||
fallbackURL:nil
|
fallbackURL:nil
|
||||||
@@ -69,53 +158,4 @@
|
|||||||
completion:completion];
|
completion:completion];
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Private
|
|
||||||
|
|
||||||
// 通过响应链尝试调用 openURL:(等价于原 KBURLOpenBridge 实现)
|
|
||||||
+ (BOOL)p_openURLViaResponder:(NSURL *)url from:(UIResponder *)start {
|
|
||||||
#if KB_URL_BRIDGE_ENABLE
|
|
||||||
if (!url || !start) return NO;
|
|
||||||
SEL sel = NSSelectorFromString(@"openURL:");
|
|
||||||
UIResponder *responder = start;
|
|
||||||
while (responder) {
|
|
||||||
@try {
|
|
||||||
if ([responder respondsToSelector:sel]) {
|
|
||||||
BOOL handled = NO;
|
|
||||||
BOOL (*funcBool)(id, SEL, NSURL *) = (BOOL (*)(id, SEL, NSURL *))objc_msgSend;
|
|
||||||
if (funcBool) {
|
|
||||||
handled = funcBool(responder, sel, url);
|
|
||||||
} else {
|
|
||||||
#pragma clang diagnostic push
|
|
||||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
|
||||||
[responder performSelector:sel withObject:url];
|
|
||||||
handled = YES;
|
|
||||||
#pragma clang diagnostic pop
|
|
||||||
}
|
|
||||||
return handled;
|
|
||||||
}
|
|
||||||
} @catch (__unused NSException *e) {
|
|
||||||
// ignore and continue
|
|
||||||
}
|
|
||||||
responder = responder.nextResponder;
|
|
||||||
}
|
|
||||||
return NO;
|
|
||||||
#else
|
|
||||||
(void)url; (void)start;
|
|
||||||
return NO;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
+ (BOOL)p_bridgeFirst:(NSURL * _Nullable)first
|
|
||||||
second:(NSURL * _Nullable)second
|
|
||||||
from:(UIResponder *)source {
|
|
||||||
BOOL bridged = NO;
|
|
||||||
if (first) {
|
|
||||||
bridged = [self p_openURLViaResponder:first from:source];
|
|
||||||
}
|
|
||||||
if (!bridged && second) {
|
|
||||||
bridged = [self p_openURLViaResponder:second from:source];
|
|
||||||
}
|
|
||||||
return bridged;
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
34
CustomKeyboard/Utils/KBInputBufferManager.h
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@protocol UITextDocumentProxy;
|
||||||
|
|
||||||
|
@interface KBInputBufferManager : NSObject
|
||||||
|
|
||||||
|
+ (instancetype)shared;
|
||||||
|
|
||||||
|
@property (nonatomic, copy, readonly) NSString *liveText;
|
||||||
|
@property (nonatomic, copy, readonly) NSString *manualSnapshot;
|
||||||
|
@property (nonatomic, copy, readonly) NSString *pendingClearSnapshot;
|
||||||
|
|
||||||
|
- (void)seedIfEmptyWithContextBefore:(nullable NSString *)before after:(nullable NSString *)after;
|
||||||
|
- (void)updateFromExternalContextBefore:(nullable NSString *)before after:(nullable NSString *)after;
|
||||||
|
- (void)refreshFromProxyIfPossible:(nullable id<UITextDocumentProxy>)proxy;
|
||||||
|
- (void)prepareSnapshotForDeleteWithContextBefore:(nullable NSString *)before
|
||||||
|
after:(nullable NSString *)after;
|
||||||
|
- (void)beginPendingClearSnapshot;
|
||||||
|
- (void)clearPendingClearSnapshot;
|
||||||
|
- (void)resetWithText:(NSString *)text;
|
||||||
|
- (void)appendText:(NSString *)text;
|
||||||
|
- (void)deleteBackwardByCount:(NSUInteger)count;
|
||||||
|
- (void)replaceTailWithText:(NSString *)text deleteCount:(NSUInteger)count;
|
||||||
|
- (void)applyHoldDeleteCount:(NSUInteger)count;
|
||||||
|
- (void)applyClearDeleteCount:(NSUInteger)count;
|
||||||
|
- (void)clearAllLiveText;
|
||||||
|
- (void)commitLiveToManual;
|
||||||
|
- (void)restoreManualSnapshot;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
279
CustomKeyboard/Utils/KBInputBufferManager.m
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
#import "KBInputBufferManager.h"
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
static NSString *KBLogString2(NSString *tag, NSString *text) {
|
||||||
|
NSString *safeTag = tag ?: @"";
|
||||||
|
NSString *safeText = text ?: @"";
|
||||||
|
if (safeText.length <= 2000) {
|
||||||
|
return [NSString stringWithFormat:@"[%@] len=%lu text=%@", safeTag, (unsigned long)safeText.length, safeText];
|
||||||
|
}
|
||||||
|
NSString *head = [safeText substringToIndex:800];
|
||||||
|
NSString *tail = [safeText substringFromIndex:safeText.length - 800];
|
||||||
|
return [NSString stringWithFormat:@"[%@] len=%lu head=%@ ... tail=%@", safeTag, (unsigned long)safeText.length, head, tail];
|
||||||
|
}
|
||||||
|
#define KB_BUF_LOG(tag, text) NSLog(@"❤️=%@", KBLogString2((tag), (text)))
|
||||||
|
#else
|
||||||
|
#define KB_BUF_LOG(tag, text) do {} while(0)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@interface KBInputBufferManager ()
|
||||||
|
@property (nonatomic, copy, readwrite) NSString *liveText;
|
||||||
|
@property (nonatomic, copy, readwrite) NSString *manualSnapshot;
|
||||||
|
@property (nonatomic, copy, readwrite) NSString *pendingClearSnapshot;
|
||||||
|
@property (nonatomic, assign) BOOL manualSnapshotDirty;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBInputBufferManager
|
||||||
|
|
||||||
|
+ (instancetype)shared {
|
||||||
|
static KBInputBufferManager *mgr = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
mgr = [[KBInputBufferManager alloc] init];
|
||||||
|
});
|
||||||
|
return mgr;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (instancetype)init {
|
||||||
|
if (self = [super init]) {
|
||||||
|
_liveText = @"";
|
||||||
|
_manualSnapshot = @"";
|
||||||
|
_pendingClearSnapshot = @"";
|
||||||
|
_manualSnapshotDirty = NO;
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)seedIfEmptyWithContextBefore:(NSString *)before after:(NSString *)after {
|
||||||
|
if (self.liveText.length > 0 || self.manualSnapshot.length > 0) { return; }
|
||||||
|
NSString *safeBefore = before ?: @"";
|
||||||
|
NSString *safeAfter = after ?: @"";
|
||||||
|
NSString *full = [safeBefore stringByAppendingString:safeAfter];
|
||||||
|
if (full.length == 0) { return; }
|
||||||
|
self.liveText = full;
|
||||||
|
self.manualSnapshot = full;
|
||||||
|
self.manualSnapshotDirty = NO;
|
||||||
|
KB_BUF_LOG(@"seedIfEmpty", full);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)updateFromExternalContextBefore:(NSString *)before after:(NSString *)after {
|
||||||
|
NSString *safeBefore = before ?: @"";
|
||||||
|
NSString *safeAfter = after ?: @"";
|
||||||
|
NSString *context = [safeBefore stringByAppendingString:safeAfter];
|
||||||
|
if (context.length == 0) { return; }
|
||||||
|
|
||||||
|
// 微信/QQ 等宿主通常只提供光标附近“截断窗口”,不应当作为全文快照。
|
||||||
|
// 这里只更新 liveText,给删除/清空逻辑做参考;manualSnapshot 仅由键盘自身输入/撤销来维护。
|
||||||
|
self.liveText = context;
|
||||||
|
self.manualSnapshotDirty = YES;
|
||||||
|
#if DEBUG
|
||||||
|
static NSUInteger sExternalLogCounter = 0;
|
||||||
|
sExternalLogCounter += 1;
|
||||||
|
if (sExternalLogCounter % 12 == 0) {
|
||||||
|
KB_BUF_LOG(@"updateFromExternalContext/liveOnly", context);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)refreshFromProxyIfPossible:(id<UITextDocumentProxy>)proxy {
|
||||||
|
NSString *harvested = [self kb_harvestFullTextFromProxy:proxy];
|
||||||
|
if (harvested.length == 0) {
|
||||||
|
KB_BUF_LOG(@"refreshFromProxy/failedOrUnsupported", @"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL manualEmpty = (self.manualSnapshot.length == 0);
|
||||||
|
BOOL longerThanManual = (harvested.length > self.manualSnapshot.length);
|
||||||
|
if (!(manualEmpty || longerThanManual)) {
|
||||||
|
KB_BUF_LOG(@"refreshFromProxy/ignoredShorter", harvested);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.liveText = harvested;
|
||||||
|
self.manualSnapshot = harvested;
|
||||||
|
self.manualSnapshotDirty = NO;
|
||||||
|
KB_BUF_LOG(@"refreshFromProxy/accepted", harvested);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)prepareSnapshotForDeleteWithContextBefore:(NSString *)before
|
||||||
|
after:(NSString *)after {
|
||||||
|
NSString *safeBefore = before ?: @"";
|
||||||
|
NSString *safeAfter = after ?: @"";
|
||||||
|
NSString *context = [safeBefore stringByAppendingString:safeAfter];
|
||||||
|
|
||||||
|
BOOL manualValid = (self.manualSnapshot.length > 0 &&
|
||||||
|
(context.length == 0 ||
|
||||||
|
(self.manualSnapshot.length >= context.length &&
|
||||||
|
[self.manualSnapshot rangeOfString:context].location != NSNotFound)));
|
||||||
|
if (manualValid) { return; }
|
||||||
|
|
||||||
|
if (self.liveText.length > 0) {
|
||||||
|
self.manualSnapshot = self.liveText;
|
||||||
|
self.manualSnapshotDirty = NO;
|
||||||
|
KB_BUF_LOG(@"prepareSnapshotForDelete/fromLiveText", self.manualSnapshot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context.length > 0) {
|
||||||
|
self.manualSnapshot = context;
|
||||||
|
self.manualSnapshotDirty = NO;
|
||||||
|
KB_BUF_LOG(@"prepareSnapshotForDelete/fromContext", self.manualSnapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)beginPendingClearSnapshot {
|
||||||
|
if (self.pendingClearSnapshot.length > 0) { return; }
|
||||||
|
if (self.manualSnapshot.length > 0) {
|
||||||
|
self.pendingClearSnapshot = self.manualSnapshot;
|
||||||
|
KB_BUF_LOG(@"beginPendingClearSnapshot/fromManual", self.pendingClearSnapshot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (self.liveText.length > 0) {
|
||||||
|
self.pendingClearSnapshot = self.liveText;
|
||||||
|
KB_BUF_LOG(@"beginPendingClearSnapshot/fromLive", self.pendingClearSnapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)clearPendingClearSnapshot {
|
||||||
|
self.pendingClearSnapshot = @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)resetWithText:(NSString *)text {
|
||||||
|
NSString *safe = text ?: @"";
|
||||||
|
self.liveText = safe;
|
||||||
|
self.manualSnapshot = safe;
|
||||||
|
self.pendingClearSnapshot = @"";
|
||||||
|
self.manualSnapshotDirty = NO;
|
||||||
|
KB_BUF_LOG(@"resetWithText", safe);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)appendText:(NSString *)text {
|
||||||
|
if (text.length == 0) { return; }
|
||||||
|
[self kb_syncManualSnapshotIfNeeded];
|
||||||
|
self.liveText = [self.liveText stringByAppendingString:text];
|
||||||
|
self.manualSnapshot = [self.manualSnapshot stringByAppendingString:text];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)deleteBackwardByCount:(NSUInteger)count {
|
||||||
|
if (count == 0) { return; }
|
||||||
|
self.liveText = [self kb_stringByDeletingComposedCharacters:count from:self.liveText];
|
||||||
|
self.manualSnapshot = [self kb_stringByDeletingComposedCharacters:count from:self.manualSnapshot];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)replaceTailWithText:(NSString *)text deleteCount:(NSUInteger)count {
|
||||||
|
[self kb_syncManualSnapshotIfNeeded];
|
||||||
|
[self deleteBackwardByCount:count];
|
||||||
|
[self appendText:text];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)applyHoldDeleteCount:(NSUInteger)count {
|
||||||
|
if (count == 0) { return; }
|
||||||
|
self.liveText = [self kb_stringByDeletingComposedCharacters:count from:self.liveText];
|
||||||
|
self.manualSnapshotDirty = YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)applyClearDeleteCount:(NSUInteger)count {
|
||||||
|
if (count == 0) { return; }
|
||||||
|
self.liveText = [self kb_stringByDeletingComposedCharacters:count from:self.liveText];
|
||||||
|
self.manualSnapshotDirty = YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)clearAllLiveText {
|
||||||
|
self.liveText = @"";
|
||||||
|
self.pendingClearSnapshot = @"";
|
||||||
|
self.manualSnapshotDirty = YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)commitLiveToManual {
|
||||||
|
self.manualSnapshot = self.liveText ?: @"";
|
||||||
|
self.manualSnapshotDirty = NO;
|
||||||
|
KB_BUF_LOG(@"commitLiveToManual", self.manualSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)restoreManualSnapshot {
|
||||||
|
self.liveText = self.manualSnapshot ?: @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Helpers
|
||||||
|
|
||||||
|
- (void)kb_syncManualSnapshotIfNeeded {
|
||||||
|
if (!self.manualSnapshotDirty) { return; }
|
||||||
|
self.manualSnapshot = self.liveText ?: @"";
|
||||||
|
self.manualSnapshotDirty = NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_stringByDeletingComposedCharacters:(NSUInteger)count
|
||||||
|
from:(NSString *)text {
|
||||||
|
if (count == 0) { return text ?: @""; }
|
||||||
|
NSString *source = text ?: @"";
|
||||||
|
if (source.length == 0) { return @""; }
|
||||||
|
|
||||||
|
__block NSUInteger removed = 0;
|
||||||
|
__block NSUInteger endIndex = source.length;
|
||||||
|
[source enumerateSubstringsInRange:NSMakeRange(0, source.length)
|
||||||
|
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
||||||
|
usingBlock:^(__unused NSString *substring, NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
||||||
|
removed += 1;
|
||||||
|
endIndex = substringRange.location;
|
||||||
|
if (removed >= count) {
|
||||||
|
*stop = YES;
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
if (removed < count) { return @""; }
|
||||||
|
return [source substringToIndex:endIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (NSString *)kb_harvestFullTextFromProxy:(id<UITextDocumentProxy>)proxy {
|
||||||
|
if (!proxy) { return @""; }
|
||||||
|
if (![proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) { return @""; }
|
||||||
|
|
||||||
|
static const NSInteger kKBHarvestMaxRounds = 160;
|
||||||
|
static const NSInteger kKBHarvestMaxChars = 50000;
|
||||||
|
|
||||||
|
NSInteger movedToEnd = 0;
|
||||||
|
NSInteger movedLeft = 0;
|
||||||
|
NSMutableArray<NSString *> *chunks = [NSMutableArray array];
|
||||||
|
NSInteger totalChars = 0;
|
||||||
|
|
||||||
|
@try {
|
||||||
|
NSInteger guard = 0;
|
||||||
|
NSString *after = proxy.documentContextAfterInput ?: @"";
|
||||||
|
while (after.length > 0 && guard < kKBHarvestMaxRounds) {
|
||||||
|
NSInteger step = (NSInteger)after.length;
|
||||||
|
[(id)proxy adjustTextPositionByCharacterOffset:step];
|
||||||
|
movedToEnd += step;
|
||||||
|
guard += 1;
|
||||||
|
after = proxy.documentContextAfterInput ?: @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
guard = 0;
|
||||||
|
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||||
|
while (before.length > 0 && guard < kKBHarvestMaxRounds && totalChars < kKBHarvestMaxChars) {
|
||||||
|
[chunks addObject:before];
|
||||||
|
totalChars += (NSInteger)before.length;
|
||||||
|
NSInteger step = (NSInteger)before.length;
|
||||||
|
[(id)proxy adjustTextPositionByCharacterOffset:-step];
|
||||||
|
movedLeft += step;
|
||||||
|
guard += 1;
|
||||||
|
before = proxy.documentContextBeforeInput ?: @"";
|
||||||
|
}
|
||||||
|
} @finally {
|
||||||
|
if (movedLeft != 0) {
|
||||||
|
[(id)proxy adjustTextPositionByCharacterOffset:movedLeft];
|
||||||
|
}
|
||||||
|
if (movedToEnd != 0) {
|
||||||
|
[(id)proxy adjustTextPositionByCharacterOffset:-movedToEnd];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunks.count == 0) { return @""; }
|
||||||
|
NSMutableString *result = [NSMutableString stringWithCapacity:(NSUInteger)totalChars];
|
||||||
|
for (NSInteger i = (NSInteger)chunks.count - 1; i >= 0; i--) {
|
||||||
|
NSString *part = chunks[(NSUInteger)i] ?: @"";
|
||||||
|
if (part.length == 0) { continue; }
|
||||||
|
[result appendString:part];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
105
CustomKeyboard/VM/KBVM.h
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
//
|
||||||
|
// KBVM.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// 键盘扩展的 ViewModel,封装网络请求逻辑
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
@class KBChatDataModel;
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
/// 聊天响应模型
|
||||||
|
@interface KBChatResponse : NSObject
|
||||||
|
@property (nonatomic, strong, nullable) KBChatDataModel *data;
|
||||||
|
//@property (nonatomic, copy, nullable) NSString *audioId;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *message;
|
||||||
|
@property (nonatomic, assign) BOOL success;
|
||||||
|
@property (nonatomic, assign) NSInteger code;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBChatDataModel : NSObject
|
||||||
|
@property (nonatomic, copy, nullable) NSString *aiResponse;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *audioId;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *llmDuration;
|
||||||
|
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
/// 音频响应模型
|
||||||
|
@interface KBAudioResponse : NSObject
|
||||||
|
@property (nonatomic, copy, nullable) NSString *audioURL;
|
||||||
|
@property (nonatomic, strong, nullable) NSData *audioData;
|
||||||
|
@property (nonatomic, assign) NSTimeInterval duration;
|
||||||
|
@property (nonatomic, copy, nullable) NSString *errorMessage;
|
||||||
|
@property (nonatomic, assign) BOOL success;
|
||||||
|
@end
|
||||||
|
|
||||||
|
/// 聊天请求回调
|
||||||
|
typedef void(^KBChatCompletion)(KBChatResponse *response);
|
||||||
|
/// 音频 URL 回调
|
||||||
|
typedef void(^KBAudioURLCompletion)(KBAudioResponse *response);
|
||||||
|
/// 音频数据回调
|
||||||
|
typedef void(^KBAudioDataCompletion)(KBAudioResponse *response);
|
||||||
|
/// 头像回调
|
||||||
|
typedef void(^KBAvatarCompletion)(UIImage * _Nullable image, NSError * _Nullable error);
|
||||||
|
|
||||||
|
@interface KBVM : NSObject
|
||||||
|
|
||||||
|
+ (instancetype)shared;
|
||||||
|
|
||||||
|
#pragma mark - Chat API
|
||||||
|
|
||||||
|
/// 发送聊天消息
|
||||||
|
/// @param content 消息内容
|
||||||
|
/// @param companionId 人设 ID
|
||||||
|
/// @param completion 回调
|
||||||
|
- (void)sendChatMessageWithContent:(NSString *)content
|
||||||
|
companionId:(NSInteger)companionId
|
||||||
|
completion:(KBChatCompletion)completion;
|
||||||
|
|
||||||
|
#pragma mark - Audio API
|
||||||
|
|
||||||
|
/// 获取音频 URL(单次请求)
|
||||||
|
/// @param audioId 音频 ID
|
||||||
|
/// @param completion 回调
|
||||||
|
- (void)fetchAudioURLWithAudioId:(NSString *)audioId
|
||||||
|
completion:(KBAudioURLCompletion)completion;
|
||||||
|
|
||||||
|
/// 轮询获取音频 URL(自动重试)
|
||||||
|
/// @param audioId 音频 ID
|
||||||
|
/// @param maxRetries 最大重试次数
|
||||||
|
/// @param interval 重试间隔(秒)
|
||||||
|
/// @param completion 回调
|
||||||
|
- (void)pollAudioURLWithAudioId:(NSString *)audioId
|
||||||
|
maxRetries:(NSInteger)maxRetries
|
||||||
|
interval:(NSTimeInterval)interval
|
||||||
|
completion:(KBAudioURLCompletion)completion;
|
||||||
|
|
||||||
|
/// 下载音频数据
|
||||||
|
/// @param urlString 音频 URL
|
||||||
|
/// @param completion 回调
|
||||||
|
- (void)downloadAudioFromURL:(NSString *)urlString
|
||||||
|
completion:(KBAudioDataCompletion)completion;
|
||||||
|
|
||||||
|
#pragma mark - Avatar API
|
||||||
|
|
||||||
|
/// 下载头像图片
|
||||||
|
/// @param urlString 头像 URL
|
||||||
|
/// @param completion 回调
|
||||||
|
- (void)downloadAvatarFromURL:(NSString *)urlString
|
||||||
|
completion:(KBAvatarCompletion)completion;
|
||||||
|
|
||||||
|
#pragma mark - Helper
|
||||||
|
|
||||||
|
/// 从 AppGroup 获取选中的 persona companionId
|
||||||
|
- (NSInteger)selectedCompanionIdFromAppGroup;
|
||||||
|
|
||||||
|
/// 从 AppGroup 获取选中的 persona 信息
|
||||||
|
- (nullable NSDictionary *)selectedPersonaFromAppGroup;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
337
CustomKeyboard/VM/KBVM.m
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
//
|
||||||
|
// KBVM.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBVM.h"
|
||||||
|
#import "KBNetworkManager.h"
|
||||||
|
#import "KBConfig.h"
|
||||||
|
#import <AVFoundation/AVFoundation.h>
|
||||||
|
#import <MJExtension/MJExtension.h>
|
||||||
|
|
||||||
|
@implementation KBChatResponse
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBChatDataModel
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBAudioResponse
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBVM ()
|
||||||
|
@property (nonatomic, strong) NSCache<NSString *, UIImage *> *avatarCache;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBVM
|
||||||
|
|
||||||
|
+ (instancetype)shared {
|
||||||
|
static KBVM *instance = nil;
|
||||||
|
static dispatch_once_t onceToken;
|
||||||
|
dispatch_once(&onceToken, ^{
|
||||||
|
instance = [[KBVM alloc] init];
|
||||||
|
});
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (instancetype)init {
|
||||||
|
if (self = [super init]) {
|
||||||
|
_avatarCache = [[NSCache alloc] init];
|
||||||
|
_avatarCache.countLimit = 20;
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Chat API
|
||||||
|
|
||||||
|
- (void)sendChatMessageWithContent:(NSString *)content
|
||||||
|
companionId:(NSInteger)companionId
|
||||||
|
completion:(KBChatCompletion)completion {
|
||||||
|
if (content.length == 0) {
|
||||||
|
if (completion) {
|
||||||
|
KBChatResponse *response = [[KBChatResponse alloc] init];
|
||||||
|
response.success = NO;
|
||||||
|
response.message = KBLocalized(@"Content is empty");
|
||||||
|
completion(response);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *encodedContent = [content stringByAddingPercentEncodingWithAllowedCharacters:
|
||||||
|
[NSCharacterSet URLQueryAllowedCharacterSet]];
|
||||||
|
NSString *path = [NSString stringWithFormat:@"%@?content=%@&companionId=%ld",
|
||||||
|
API_AI_CHAT_MESSAGE, encodedContent ?: @"", (long)companionId];
|
||||||
|
NSDictionary *params = @{
|
||||||
|
@"content": content ?: @"",
|
||||||
|
@"companionId": @(companionId)
|
||||||
|
};
|
||||||
|
|
||||||
|
[[KBNetworkManager shared] POST:path
|
||||||
|
jsonBody:params
|
||||||
|
headers:nil
|
||||||
|
completion:^(NSDictionary *json, NSURLResponse *response, NSError *error) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
KBChatResponse *chatResponse = [KBChatResponse mj_objectWithKeyValues:json];
|
||||||
|
if (chatResponse.code != 0) {
|
||||||
|
chatResponse.success = NO;
|
||||||
|
// chatResponse.errorMessage = error.localizedDescription ?: @"请求失败";
|
||||||
|
if (completion) completion(chatResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// // 解析文本
|
||||||
|
// chatResponse.text = [self p_parseTextFromJSON:json];
|
||||||
|
// // 解析 audioId
|
||||||
|
// chatResponse.audioId = [self p_parseAudioIdFromJSON:json];
|
||||||
|
|
||||||
|
// chatResponse.success = (chatResponse.text.length > 0);
|
||||||
|
// if (!chatResponse.success) {
|
||||||
|
// chatResponse.errorMessage = @"未获取到回复内容";
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (completion) completion(chatResponse);
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Audio API
|
||||||
|
|
||||||
|
- (void)fetchAudioURLWithAudioId:(NSString *)audioId
|
||||||
|
completion:(KBAudioURLCompletion)completion {
|
||||||
|
if (audioId.length == 0) {
|
||||||
|
if (completion) {
|
||||||
|
KBAudioResponse *response = [[KBAudioResponse alloc] init];
|
||||||
|
response.success = NO;
|
||||||
|
response.errorMessage = KBLocalized(@"audioId is empty");
|
||||||
|
completion(response);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *path = [NSString stringWithFormat:@"/chat/audio/%@", audioId];
|
||||||
|
|
||||||
|
[[KBNetworkManager shared] GET:path
|
||||||
|
parameters:nil
|
||||||
|
headers:nil
|
||||||
|
completion:^(NSDictionary *json, NSURLResponse *response, NSError *error) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
KBAudioResponse *audioResponse = [[KBAudioResponse alloc] init];
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
audioResponse.success = NO;
|
||||||
|
audioResponse.errorMessage = error.localizedDescription;
|
||||||
|
if (completion) completion(audioResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 audioURL
|
||||||
|
NSString *audioURL = [self p_parseAudioURLFromJSON:json];
|
||||||
|
audioResponse.audioURL = audioURL;
|
||||||
|
audioResponse.success = (audioURL.length > 0);
|
||||||
|
|
||||||
|
if (completion) completion(audioResponse);
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)pollAudioURLWithAudioId:(NSString *)audioId
|
||||||
|
maxRetries:(NSInteger)maxRetries
|
||||||
|
interval:(NSTimeInterval)interval
|
||||||
|
completion:(KBAudioURLCompletion)completion {
|
||||||
|
[self p_pollAudioURLWithAudioId:audioId
|
||||||
|
retryCount:0
|
||||||
|
maxRetries:maxRetries
|
||||||
|
interval:interval
|
||||||
|
completion:completion];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)p_pollAudioURLWithAudioId:(NSString *)audioId
|
||||||
|
retryCount:(NSInteger)retryCount
|
||||||
|
maxRetries:(NSInteger)maxRetries
|
||||||
|
interval:(NSTimeInterval)interval
|
||||||
|
completion:(KBAudioURLCompletion)completion {
|
||||||
|
|
||||||
|
[self fetchAudioURLWithAudioId:audioId completion:^(KBAudioResponse *response) {
|
||||||
|
if (response.success && response.audioURL.length > 0) {
|
||||||
|
// 成功获取到 URL
|
||||||
|
if (completion) completion(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果还没达到最大重试次数,继续轮询
|
||||||
|
if (retryCount < maxRetries - 1) {
|
||||||
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(interval * NSEC_PER_SEC)),
|
||||||
|
dispatch_get_main_queue(), ^{
|
||||||
|
[self p_pollAudioURLWithAudioId:audioId
|
||||||
|
retryCount:retryCount + 1
|
||||||
|
maxRetries:maxRetries
|
||||||
|
interval:interval
|
||||||
|
completion:completion];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 达到最大重试次数
|
||||||
|
KBAudioResponse *failResponse = [[KBAudioResponse alloc] init];
|
||||||
|
failResponse.success = NO;
|
||||||
|
failResponse.errorMessage = [NSString stringWithFormat:KBLocalized(@"Polling failed after %ld retries"), (long)maxRetries];
|
||||||
|
if (completion) completion(failResponse);
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)downloadAudioFromURL:(NSString *)urlString
|
||||||
|
completion:(KBAudioDataCompletion)completion {
|
||||||
|
if (urlString.length == 0) {
|
||||||
|
if (completion) {
|
||||||
|
KBAudioResponse *response = [[KBAudioResponse alloc] init];
|
||||||
|
response.success = NO;
|
||||||
|
response.errorMessage = KBLocalized(@"URL is empty");
|
||||||
|
completion(response);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[KBNetworkManager shared] GETData:urlString
|
||||||
|
parameters:nil
|
||||||
|
headers:nil
|
||||||
|
completion:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
KBAudioResponse *audioResponse = [[KBAudioResponse alloc] init];
|
||||||
|
|
||||||
|
if (error || !data || data.length == 0) {
|
||||||
|
audioResponse.success = NO;
|
||||||
|
audioResponse.errorMessage = error.localizedDescription ?: KBLocalized(@"Download failed");
|
||||||
|
if (completion) completion(audioResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioResponse.audioData = data;
|
||||||
|
|
||||||
|
// 计算音频时长
|
||||||
|
NSError *playerError = nil;
|
||||||
|
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError];
|
||||||
|
if (!playerError && player) {
|
||||||
|
audioResponse.duration = player.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioResponse.success = YES;
|
||||||
|
if (completion) completion(audioResponse);
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Avatar API
|
||||||
|
|
||||||
|
- (void)downloadAvatarFromURL:(NSString *)urlString
|
||||||
|
completion:(KBAvatarCompletion)completion {
|
||||||
|
if (urlString.length == 0) {
|
||||||
|
if (completion) completion(nil, nil);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查缓存
|
||||||
|
UIImage *cached = [self.avatarCache objectForKey:urlString];
|
||||||
|
if (cached) {
|
||||||
|
if (completion) completion(cached, nil);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[KBNetworkManager shared] GETData:urlString
|
||||||
|
parameters:nil
|
||||||
|
headers:nil
|
||||||
|
completion:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
if (error || data.length == 0) {
|
||||||
|
if (completion) completion(nil, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UIImage *image = [UIImage imageWithData:data];
|
||||||
|
if (image) {
|
||||||
|
[self.avatarCache setObject:image forKey:urlString];
|
||||||
|
}
|
||||||
|
if (completion) completion(image, nil);
|
||||||
|
});
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Helper
|
||||||
|
|
||||||
|
- (NSInteger)selectedCompanionIdFromAppGroup {
|
||||||
|
NSDictionary *persona = [self selectedPersonaFromAppGroup];
|
||||||
|
if (persona) {
|
||||||
|
id companionIdObj = persona[@"personaId"] ?: persona[@"companionId"] ?: persona[@"id"];
|
||||||
|
if ([companionIdObj respondsToSelector:@selector(integerValue)]) {
|
||||||
|
return [companionIdObj integerValue];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (nullable NSDictionary *)selectedPersonaFromAppGroup {
|
||||||
|
NSUserDefaults *shared = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||||
|
return [shared objectForKey:@"AppGroup_SelectedPersona"];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Private Parse Methods
|
||||||
|
|
||||||
|
/// 解析聊天文本
|
||||||
|
- (NSString *)p_parseTextFromJSON:(NSDictionary *)json {
|
||||||
|
if (![json isKindOfClass:[NSDictionary class]]) return @"";
|
||||||
|
|
||||||
|
id dataObj = json[@"data"];
|
||||||
|
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||||
|
NSDictionary *data = (NSDictionary *)dataObj;
|
||||||
|
// 优先读取 aiResponse 字段
|
||||||
|
NSArray *keys = @[@"aiResponse", @"content", @"text", @"message"];
|
||||||
|
for (NSString *key in keys) {
|
||||||
|
id value = data[key];
|
||||||
|
if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) {
|
||||||
|
return (NSString *)value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ([dataObj isKindOfClass:[NSString class]]) {
|
||||||
|
return (NSString *)dataObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
return @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 audioId
|
||||||
|
- (NSString *)p_parseAudioIdFromJSON:(NSDictionary *)json {
|
||||||
|
if (![json isKindOfClass:[NSDictionary class]]) return nil;
|
||||||
|
|
||||||
|
id dataObj = json[@"data"];
|
||||||
|
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||||
|
NSDictionary *data = (NSDictionary *)dataObj;
|
||||||
|
NSString *audioId = data[@"audioId"];
|
||||||
|
if ([audioId isKindOfClass:[NSString class]] && audioId.length > 0) {
|
||||||
|
return audioId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容其他字段名
|
||||||
|
NSArray *keys = @[@"audioId", @"audio_id"];
|
||||||
|
for (NSString *key in keys) {
|
||||||
|
id value = json[key];
|
||||||
|
if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) {
|
||||||
|
return (NSString *)value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 audioURL
|
||||||
|
- (NSString *)p_parseAudioURLFromJSON:(NSDictionary *)json {
|
||||||
|
if (![json isKindOfClass:[NSDictionary class]]) return nil;
|
||||||
|
|
||||||
|
id dataObj = json[@"data"];
|
||||||
|
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||||
|
NSDictionary *data = (NSDictionary *)dataObj;
|
||||||
|
id audioUrlObj = data[@"audioUrl"] ?: data[@"url"];
|
||||||
|
if (audioUrlObj && ![audioUrlObj isKindOfClass:[NSNull class]] && [audioUrlObj isKindOfClass:[NSString class]]) {
|
||||||
|
return (NSString *)audioUrlObj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
@optional
|
@optional
|
||||||
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view;
|
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view;
|
||||||
- (void)subscriptionView:(KBKeyboardSubscriptionView *)view didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product;
|
- (void)subscriptionView:(KBKeyboardSubscriptionView *)view didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product;
|
||||||
|
- (void)subscriptionViewDidTapAgreement:(KBKeyboardSubscriptionView *)view;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
/// 键盘内的订阅弹层
|
/// 键盘内的订阅弹层
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ static id KBKeyboardSubscriptionSanitizeJSON(id obj) {
|
|||||||
|
|
||||||
- (void)setupFeatureItems {
|
- (void)setupFeatureItems {
|
||||||
NSArray *titles = @[
|
NSArray *titles = @[
|
||||||
KBLocalized(@"Wireless Sub-ai\nDialogue"),
|
KBLocalized(@"Wireless Sub-ai Dialogue"),
|
||||||
KBLocalized(@"Personalized\nKeyboard"),
|
KBLocalized(@"Personalized\nKeyboard"),
|
||||||
KBLocalized(@"Chat\nPersona"),
|
KBLocalized(@"Chat\nPersona"),
|
||||||
KBLocalized(@"Emotional\nCounseling")
|
KBLocalized(@"Emotional\nCounseling")
|
||||||
@@ -192,7 +192,11 @@ static id KBKeyboardSubscriptionSanitizeJSON(id obj) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)onTapAgreement {
|
- (void)onTapAgreement {
|
||||||
[KBHUD showInfo:KBLocalized(@"Agreement coming soon")];
|
if ([self.delegate respondsToSelector:@selector(subscriptionViewDidTapAgreement:)]) {
|
||||||
|
[self.delegate subscriptionViewDidTapAgreement:self];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[KBHUD showInfo:KBLocalized(@"Please open the App to view the agreement")];
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Data
|
#pragma mark - Data
|
||||||
@@ -200,7 +204,7 @@ static id KBKeyboardSubscriptionSanitizeJSON(id obj) {
|
|||||||
- (void)fetchProducts {
|
- (void)fetchProducts {
|
||||||
if (self.isLoading) { return; }
|
if (self.isLoading) { return; }
|
||||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||||
[KBHUD showInfo:KBLocalized(@"Enable Full Access to continue")];
|
[KBHUD showInfo:KBLocalized(@"Please enable Full Access to continue")];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.loading = YES;
|
self.loading = YES;
|
||||||
@@ -405,7 +409,7 @@ static id KBKeyboardSubscriptionSanitizeJSON(id obj) {
|
|||||||
- (UILabel *)agreementLabel {
|
- (UILabel *)agreementLabel {
|
||||||
if (!_agreementLabel) {
|
if (!_agreementLabel) {
|
||||||
_agreementLabel = [[UILabel alloc] init];
|
_agreementLabel = [[UILabel alloc] init];
|
||||||
_agreementLabel.text = KBLocalized(@"By clicking \"pay\", you agree to the");
|
_agreementLabel.text = KBLocalized(@"By clicking Pay, you indicate your agreement to the");
|
||||||
_agreementLabel.font = [UIFont systemFontOfSize:11];
|
_agreementLabel.font = [UIFont systemFontOfSize:11];
|
||||||
_agreementLabel.textColor = [UIColor colorWithHex:0x4A4A4A];
|
_agreementLabel.textColor = [UIColor colorWithHex:0x4A4A4A];
|
||||||
}
|
}
|
||||||
|
|||||||
40
CustomKeyboard/View/Chat/KBChatAssistantCell.h
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//
|
||||||
|
// KBChatAssistantCell.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// AI 消息 Cell(左侧显示,带语音按钮和打字机效果)
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
@class KBChatMessage;
|
||||||
|
@class KBChatAssistantCell;
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@protocol KBChatAssistantCellDelegate <NSObject>
|
||||||
|
@optional
|
||||||
|
/// 点击语音播放按钮
|
||||||
|
- (void)assistantCell:(KBChatAssistantCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBChatAssistantCell : UITableViewCell
|
||||||
|
|
||||||
|
@property (nonatomic, weak) id<KBChatAssistantCellDelegate> delegate;
|
||||||
|
|
||||||
|
- (void)configureWithMessage:(KBChatMessage *)message;
|
||||||
|
|
||||||
|
/// 更新语音播放状态
|
||||||
|
- (void)updateVoicePlayingState:(BOOL)isPlaying;
|
||||||
|
|
||||||
|
/// 显示语音加载动画
|
||||||
|
- (void)showVoiceLoadingAnimation;
|
||||||
|
|
||||||
|
/// 隐藏语音加载动画
|
||||||
|
- (void)hideVoiceLoadingAnimation;
|
||||||
|
|
||||||
|
/// 停止打字机效果
|
||||||
|
- (void)stopTypewriterEffect;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
346
CustomKeyboard/View/Chat/KBChatAssistantCell.m
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
//
|
||||||
|
// KBChatAssistantCell.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// AI 消息 Cell(左侧显示,带语音按钮和打字机效果)
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatAssistantCell.h"
|
||||||
|
#import "KBChatMessage.h"
|
||||||
|
#import "Masonry.h"
|
||||||
|
|
||||||
|
@interface KBChatAssistantCell ()
|
||||||
|
|
||||||
|
@property (nonatomic, strong) UIButton *voiceButton;
|
||||||
|
@property (nonatomic, strong) UILabel *durationLabel;
|
||||||
|
@property (nonatomic, strong) UIView *bubbleView;
|
||||||
|
@property (nonatomic, strong) UILabel *messageLabel;
|
||||||
|
@property (nonatomic, strong) UIActivityIndicatorView *voiceLoadingIndicator;
|
||||||
|
@property (nonatomic, strong) UIActivityIndicatorView *messageLoadingIndicator;
|
||||||
|
@property (nonatomic, strong) KBChatMessage *currentMessage;
|
||||||
|
|
||||||
|
/// 打字机效果
|
||||||
|
@property (nonatomic, strong) NSTimer *typewriterTimer;
|
||||||
|
@property (nonatomic, copy) NSString *fullText;
|
||||||
|
@property (nonatomic, assign) NSInteger currentCharIndex;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBChatAssistantCell
|
||||||
|
|
||||||
|
- (instancetype)initWithStyle:(UITableViewCellStyle)style
|
||||||
|
reuseIdentifier:(NSString *)reuseIdentifier {
|
||||||
|
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
||||||
|
if (self) {
|
||||||
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
self.contentView.backgroundColor = [UIColor clearColor];
|
||||||
|
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||||
|
[self setupUI];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setupUI {
|
||||||
|
[self.contentView addSubview:self.voiceButton];
|
||||||
|
[self.contentView addSubview:self.durationLabel];
|
||||||
|
[self.contentView addSubview:self.voiceLoadingIndicator];
|
||||||
|
[self.contentView addSubview:self.messageLoadingIndicator];
|
||||||
|
[self.contentView addSubview:self.bubbleView];
|
||||||
|
[self.bubbleView addSubview:self.messageLabel];
|
||||||
|
|
||||||
|
// 语音按钮
|
||||||
|
[self.voiceButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.contentView).offset(12);
|
||||||
|
make.top.equalTo(self.contentView).offset(6);
|
||||||
|
make.width.height.mas_equalTo(20);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 语音时长
|
||||||
|
[self.durationLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.voiceButton.mas_right).offset(4);
|
||||||
|
make.centerY.equalTo(self.voiceButton);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 语音加载指示器
|
||||||
|
[self.voiceLoadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.center.equalTo(self.voiceButton);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 消息加载指示器
|
||||||
|
[self.messageLoadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.contentView).offset(12);
|
||||||
|
make.top.equalTo(self.voiceButton);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 气泡
|
||||||
|
[self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.voiceButton.mas_bottom).offset(4);
|
||||||
|
make.bottom.equalTo(self.contentView).offset(-4);
|
||||||
|
make.left.equalTo(self.contentView).offset(12);
|
||||||
|
make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.7);
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 消息文本
|
||||||
|
[self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.bubbleView).offset(8);
|
||||||
|
make.bottom.equalTo(self.bubbleView).offset(-8);
|
||||||
|
make.left.equalTo(self.bubbleView).offset(12);
|
||||||
|
make.right.equalTo(self.bubbleView).offset(-12);
|
||||||
|
make.height.greaterThanOrEqualTo(@18);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)configureWithMessage:(KBChatMessage *)message {
|
||||||
|
NSLog(@"[KBChatAssistantCell] ========== configureWithMessage ==========");
|
||||||
|
NSLog(@"[KBChatAssistantCell] text: %@", message.text);
|
||||||
|
NSLog(@"[KBChatAssistantCell] outgoing: %d, isLoading: %d, isComplete: %d, needsTypewriter: %d",
|
||||||
|
message.outgoing, message.isLoading, message.isComplete, message.needsTypewriterEffect);
|
||||||
|
|
||||||
|
// 先停止之前的打字机效果
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
|
||||||
|
self.currentMessage = message;
|
||||||
|
|
||||||
|
// 处理 loading 状态
|
||||||
|
if (message.isLoading) {
|
||||||
|
NSLog(@"[KBChatAssistantCell] 显示 loading 状态");
|
||||||
|
self.messageLabel.attributedText = nil;
|
||||||
|
self.messageLabel.text = @"";
|
||||||
|
self.bubbleView.hidden = YES;
|
||||||
|
self.voiceButton.hidden = YES;
|
||||||
|
self.durationLabel.hidden = YES;
|
||||||
|
[self.messageLoadingIndicator startAnimating];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 loading 状态
|
||||||
|
[self.messageLoadingIndicator stopAnimating];
|
||||||
|
self.bubbleView.hidden = NO;
|
||||||
|
|
||||||
|
// 语音按钮显示逻辑
|
||||||
|
BOOL hasAudio = (message.audioId.length > 0) || (message.audioData.length > 0);
|
||||||
|
self.voiceButton.hidden = !hasAudio;
|
||||||
|
self.durationLabel.hidden = !hasAudio;
|
||||||
|
NSLog(@"[KBChatAssistantCell] hasAudio: %d, audioId: %@", hasAudio, message.audioId);
|
||||||
|
|
||||||
|
// 语音时长
|
||||||
|
if (message.audioDuration > 0) {
|
||||||
|
NSInteger seconds = (NSInteger)ceil(message.audioDuration);
|
||||||
|
self.durationLabel.text = [NSString stringWithFormat:@"%ld\"", (long)seconds];
|
||||||
|
} else {
|
||||||
|
self.durationLabel.text = @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打字机效果
|
||||||
|
if (message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) {
|
||||||
|
NSLog(@"[KBChatAssistantCell] ✅ 启动打字机效果");
|
||||||
|
[self startTypewriterEffectWithText:message.text];
|
||||||
|
} else {
|
||||||
|
NSLog(@"[KBChatAssistantCell] 直接显示文本(不使用打字机)");
|
||||||
|
self.messageLabel.attributedText = nil;
|
||||||
|
self.messageLabel.text = message.text ?: @"";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Typewriter Effect
|
||||||
|
|
||||||
|
- (void)startTypewriterEffectWithText:(NSString *)text {
|
||||||
|
if (text.length == 0) return;
|
||||||
|
|
||||||
|
self.fullText = text;
|
||||||
|
self.currentCharIndex = 0;
|
||||||
|
|
||||||
|
// 先设置完整文本让布局计算正确高度
|
||||||
|
self.messageLabel.text = text;
|
||||||
|
[self.contentView setNeedsLayout];
|
||||||
|
[self.contentView layoutIfNeeded];
|
||||||
|
|
||||||
|
// 应用打字机效果
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:[UIColor clearColor]
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
[attributedText addAttribute:NSFontAttributeName
|
||||||
|
value:self.messageLabel.font
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
self.messageLabel.attributedText = attributedText;
|
||||||
|
|
||||||
|
self.typewriterTimer = [NSTimer scheduledTimerWithTimeInterval:0.03
|
||||||
|
target:self
|
||||||
|
selector:@selector(typewriterTick)
|
||||||
|
userInfo:nil
|
||||||
|
repeats:YES];
|
||||||
|
[[NSRunLoop currentRunLoop] addTimer:self.typewriterTimer forMode:NSRunLoopCommonModes];
|
||||||
|
[self typewriterTick];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)typewriterTick {
|
||||||
|
NSString *text = self.fullText;
|
||||||
|
if (!text || text.length == 0) {
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.currentCharIndex < text.length) {
|
||||||
|
self.currentCharIndex++;
|
||||||
|
|
||||||
|
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||||
|
UIColor *textColor = [UIColor whiteColor];
|
||||||
|
|
||||||
|
if (self.currentCharIndex > 0) {
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:textColor
|
||||||
|
range:NSMakeRange(0, self.currentCharIndex)];
|
||||||
|
}
|
||||||
|
if (self.currentCharIndex < text.length) {
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:[UIColor clearColor]
|
||||||
|
range:NSMakeRange(self.currentCharIndex, text.length - self.currentCharIndex)];
|
||||||
|
}
|
||||||
|
[attributedText addAttribute:NSFontAttributeName
|
||||||
|
value:self.messageLabel.font
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
|
||||||
|
self.messageLabel.attributedText = attributedText;
|
||||||
|
} else {
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
|
||||||
|
// 显示完整文本
|
||||||
|
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:[UIColor whiteColor]
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
[attributedText addAttribute:NSFontAttributeName
|
||||||
|
value:self.messageLabel.font
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
self.messageLabel.attributedText = attributedText;
|
||||||
|
|
||||||
|
// 标记完成
|
||||||
|
if (self.currentMessage) {
|
||||||
|
self.currentMessage.isComplete = YES;
|
||||||
|
self.currentMessage.needsTypewriterEffect = NO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)stopTypewriterEffect {
|
||||||
|
if (self.typewriterTimer && self.typewriterTimer.isValid) {
|
||||||
|
[self.typewriterTimer invalidate];
|
||||||
|
}
|
||||||
|
self.typewriterTimer = nil;
|
||||||
|
self.currentCharIndex = 0;
|
||||||
|
self.fullText = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Voice Button
|
||||||
|
|
||||||
|
- (void)updateVoicePlayingState:(BOOL)isPlaying {
|
||||||
|
UIImage *icon = nil;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
icon = isPlaying ? [UIImage systemImageNamed:@"pause.circle.fill"] : [UIImage systemImageNamed:@"play.circle.fill"];
|
||||||
|
}
|
||||||
|
[self.voiceButton setImage:icon forState:UIControlStateNormal];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)showVoiceLoadingAnimation {
|
||||||
|
[self.voiceButton setImage:nil forState:UIControlStateNormal];
|
||||||
|
[self.voiceLoadingIndicator startAnimating];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)hideVoiceLoadingAnimation {
|
||||||
|
[self.voiceLoadingIndicator stopAnimating];
|
||||||
|
UIImage *icon = nil;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
icon = [UIImage systemImageNamed:@"play.circle.fill"];
|
||||||
|
}
|
||||||
|
[self.voiceButton setImage:icon forState:UIControlStateNormal];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)voiceButtonTapped {
|
||||||
|
if ([self.delegate respondsToSelector:@selector(assistantCell:didTapVoiceButtonForMessage:)]) {
|
||||||
|
[self.delegate assistantCell:self didTapVoiceButtonForMessage:self.currentMessage];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Reuse
|
||||||
|
|
||||||
|
- (void)prepareForReuse {
|
||||||
|
[super prepareForReuse];
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
self.messageLabel.text = @"";
|
||||||
|
self.messageLabel.attributedText = nil;
|
||||||
|
[self.messageLoadingIndicator stopAnimating];
|
||||||
|
[self.voiceLoadingIndicator stopAnimating];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)dealloc {
|
||||||
|
[self stopTypewriterEffect];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Lazy
|
||||||
|
|
||||||
|
- (UIButton *)voiceButton {
|
||||||
|
if (!_voiceButton) {
|
||||||
|
_voiceButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||||
|
UIImage *icon = nil;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
icon = [UIImage systemImageNamed:@"play.circle.fill"];
|
||||||
|
}
|
||||||
|
[_voiceButton setImage:icon forState:UIControlStateNormal];
|
||||||
|
_voiceButton.tintColor = [UIColor whiteColor];
|
||||||
|
[_voiceButton addTarget:self action:@selector(voiceButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
}
|
||||||
|
return _voiceButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)durationLabel {
|
||||||
|
if (!_durationLabel) {
|
||||||
|
_durationLabel = [[UILabel alloc] init];
|
||||||
|
_durationLabel.font = [UIFont systemFontOfSize:11];
|
||||||
|
_durationLabel.textColor = [UIColor whiteColor];
|
||||||
|
}
|
||||||
|
return _durationLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIActivityIndicatorView *)voiceLoadingIndicator {
|
||||||
|
if (!_voiceLoadingIndicator) {
|
||||||
|
_voiceLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||||
|
_voiceLoadingIndicator.color = [UIColor whiteColor];
|
||||||
|
_voiceLoadingIndicator.hidesWhenStopped = YES;
|
||||||
|
}
|
||||||
|
return _voiceLoadingIndicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIActivityIndicatorView *)messageLoadingIndicator {
|
||||||
|
if (!_messageLoadingIndicator) {
|
||||||
|
_messageLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||||
|
_messageLoadingIndicator.color = [UIColor whiteColor];
|
||||||
|
_messageLoadingIndicator.hidesWhenStopped = YES;
|
||||||
|
}
|
||||||
|
return _messageLoadingIndicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIView *)bubbleView {
|
||||||
|
if (!_bubbleView) {
|
||||||
|
_bubbleView = [[UIView alloc] init];
|
||||||
|
_bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7];
|
||||||
|
_bubbleView.layer.cornerRadius = 12;
|
||||||
|
_bubbleView.layer.masksToBounds = YES;
|
||||||
|
}
|
||||||
|
return _bubbleView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)messageLabel {
|
||||||
|
if (!_messageLabel) {
|
||||||
|
_messageLabel = [[UILabel alloc] init];
|
||||||
|
_messageLabel.numberOfLines = 0;
|
||||||
|
_messageLabel.font = [UIFont systemFontOfSize:14];
|
||||||
|
_messageLabel.textColor = [UIColor whiteColor];
|
||||||
|
_messageLabel.lineBreakMode = NSLineBreakByWordWrapping;
|
||||||
|
}
|
||||||
|
return _messageLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
49
CustomKeyboard/View/Chat/KBChatPanelView.h
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// KBChatPanelView.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
@class KBChatPanelView, KBChatMessage;
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@protocol KBChatPanelViewDelegate <NSObject>
|
||||||
|
@optional
|
||||||
|
- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text;
|
||||||
|
- (void)chatPanelView:(KBChatPanelView *)view didTapMessage:(KBChatMessage *)message;
|
||||||
|
- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view;
|
||||||
|
/// 点击语音播放按钮
|
||||||
|
- (void)chatPanelView:(KBChatPanelView *)view didTapVoiceButtonForMessage:(KBChatMessage *)message;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBChatPanelView : UIView
|
||||||
|
|
||||||
|
@property (nonatomic, weak) id<KBChatPanelViewDelegate> delegate;
|
||||||
|
|
||||||
|
@property (nonatomic, strong, readonly) UITableView *tableView;
|
||||||
|
|
||||||
|
//- (void)kb_setBackgroundImage:(nullable UIImage *)image;
|
||||||
|
- (void)kb_reloadWithMessages:(NSArray<KBChatMessage *> *)messages;
|
||||||
|
|
||||||
|
/// 添加用户消息
|
||||||
|
- (void)kb_addUserMessage:(NSString *)text;
|
||||||
|
|
||||||
|
/// 添加 loading 状态的 AI 消息
|
||||||
|
- (void)kb_addLoadingAssistantMessage;
|
||||||
|
|
||||||
|
/// 移除 loading 状态的 AI 消息
|
||||||
|
- (void)kb_removeLoadingAssistantMessage;
|
||||||
|
|
||||||
|
/// 添加 AI 消息(带打字机效果)
|
||||||
|
- (void)kb_addAssistantMessage:(NSString *)text audioId:(nullable NSString *)audioId;
|
||||||
|
|
||||||
|
/// 更新最后一条 AI 消息的音频数据
|
||||||
|
- (void)kb_updateLastAssistantMessageWithAudioData:(NSData *)audioData duration:(NSTimeInterval)duration;
|
||||||
|
|
||||||
|
/// 滚动到底部
|
||||||
|
- (void)kb_scrollToBottom;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
348
CustomKeyboard/View/Chat/KBChatPanelView.m
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
//
|
||||||
|
// KBChatPanelView.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatPanelView.h"
|
||||||
|
#import "KBChatMessage.h"
|
||||||
|
#import "KBChatUserCell.h"
|
||||||
|
#import "KBChatAssistantCell.h"
|
||||||
|
#import "Masonry.h"
|
||||||
|
|
||||||
|
static NSString * const kUserCellIdentifier = @"KBChatUserCell";
|
||||||
|
static NSString * const kAssistantCellIdentifier = @"KBChatAssistantCell";
|
||||||
|
static const NSUInteger kKBChatMessageLimit = 10;
|
||||||
|
|
||||||
|
@interface KBChatPanelView () <UITableViewDataSource, UITableViewDelegate, KBChatAssistantCellDelegate>
|
||||||
|
@property (nonatomic, strong) UIView *headerView;
|
||||||
|
@property (nonatomic, strong) UILabel *titleLabel;
|
||||||
|
@property (nonatomic, strong) UIButton *closeButton;
|
||||||
|
@property (nonatomic, strong) UITableView *tableViewInternal;
|
||||||
|
@property (nonatomic, strong) NSMutableArray<KBChatMessage *> *messages;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBChatPanelView
|
||||||
|
|
||||||
|
- (instancetype)initWithFrame:(CGRect)frame {
|
||||||
|
if (self = [super initWithFrame:frame]) {
|
||||||
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
self.messages = [NSMutableArray array];
|
||||||
|
|
||||||
|
[self addSubview:self.headerView];
|
||||||
|
[self addSubview:self.tableViewInternal];
|
||||||
|
|
||||||
|
[self.headerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.right.equalTo(self);
|
||||||
|
make.top.equalTo(self.mas_top);
|
||||||
|
make.height.mas_equalTo(KBFit(36.0f));
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.tableViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.right.equalTo(self);
|
||||||
|
make.top.equalTo(self.headerView.mas_bottom).offset(4);
|
||||||
|
make.bottom.equalTo(self.mas_bottom).offset(-8);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Public
|
||||||
|
|
||||||
|
- (void)kb_reloadWithMessages:(NSArray<KBChatMessage *> *)messages {
|
||||||
|
NSLog(@"[Panel] ⚠️ kb_reloadWithMessages 被调用,传入 %lu 条消息", (unsigned long)messages.count);
|
||||||
|
|
||||||
|
[self.messages removeAllObjects];
|
||||||
|
if (messages.count > 0) {
|
||||||
|
[self.messages addObjectsFromArray:messages];
|
||||||
|
}
|
||||||
|
[self.tableViewInternal reloadData];
|
||||||
|
[self kb_scrollToBottom];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_addUserMessage:(NSString *)text {
|
||||||
|
if (text.length == 0) return;
|
||||||
|
|
||||||
|
NSLog(@"[Panel] 添加用户消息: %@,当前消息数: %lu", text, (unsigned long)self.messages.count);
|
||||||
|
|
||||||
|
KBChatMessage *msg = [KBChatMessage userMessageWithText:text];
|
||||||
|
[self kb_appendMessage:msg];
|
||||||
|
|
||||||
|
NSLog(@"[Panel] 添加后消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_addLoadingAssistantMessage {
|
||||||
|
NSLog(@"[Panel] 添加 loading 消息,当前消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
|
||||||
|
KBChatMessage *msg = [KBChatMessage loadingAssistantMessage];
|
||||||
|
[self kb_appendMessage:msg];
|
||||||
|
|
||||||
|
NSLog(@"[Panel] 添加后消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_removeLoadingAssistantMessage {
|
||||||
|
NSLog(@"[Panel] 移除 loading 消息,当前消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
|
||||||
|
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||||
|
KBChatMessage *msg = self.messages[i];
|
||||||
|
// 只移除 AI 消息(outgoing == NO)且是 loading 状态的
|
||||||
|
if (!msg.outgoing && msg.isLoading) {
|
||||||
|
NSLog(@"[Panel] ✅ 找到 loading 消息,移除索引: %ld", (long)i);
|
||||||
|
[self.messages removeObjectAtIndex:i];
|
||||||
|
|
||||||
|
// 使用 beginUpdates/endUpdates 包裹删除操作
|
||||||
|
[self.tableViewInternal beginUpdates];
|
||||||
|
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
|
||||||
|
[self.tableViewInternal deleteRowsAtIndexPaths:@[indexPath]
|
||||||
|
withRowAnimation:UITableViewRowAnimationNone];
|
||||||
|
[self.tableViewInternal endUpdates];
|
||||||
|
|
||||||
|
NSLog(@"[Panel] 移除后消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_addAssistantMessage:(NSString *)text audioId:(NSString *)audioId {
|
||||||
|
NSLog(@"[Panel] ========== kb_addAssistantMessage ==========");
|
||||||
|
NSLog(@"[Panel] 当前消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
|
||||||
|
// 查找 loading 消息的索引
|
||||||
|
NSInteger loadingIndex = -1;
|
||||||
|
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||||
|
KBChatMessage *msg = self.messages[i];
|
||||||
|
if (!msg.outgoing && msg.isLoading) {
|
||||||
|
loadingIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 AI 消息
|
||||||
|
KBChatMessage *msg = [KBChatMessage assistantMessageWithText:text audioId:audioId];
|
||||||
|
msg.displayName = KBLocalized(@"AI Assistant");
|
||||||
|
NSLog(@"[Panel] 创建 AI 消息,needsTypewriter: %d", msg.needsTypewriterEffect);
|
||||||
|
|
||||||
|
// 使用批量更新,避免界面跳动
|
||||||
|
[self.tableViewInternal beginUpdates];
|
||||||
|
|
||||||
|
if (loadingIndex >= 0) {
|
||||||
|
// 移除 loading 消息
|
||||||
|
NSLog(@"[Panel] 移除 loading 索引: %ld", (long)loadingIndex);
|
||||||
|
[self.messages removeObjectAtIndex:loadingIndex];
|
||||||
|
NSIndexPath *deleteIndexPath = [NSIndexPath indexPathForRow:loadingIndex inSection:0];
|
||||||
|
[self.tableViewInternal deleteRowsAtIndexPaths:@[deleteIndexPath]
|
||||||
|
withRowAnimation:UITableViewRowAnimationNone];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 AI 消息
|
||||||
|
NSInteger insertIndex = self.messages.count;
|
||||||
|
[self.messages addObject:msg];
|
||||||
|
NSLog(@"[Panel] 插入 AI 消息索引: %ld", (long)insertIndex);
|
||||||
|
NSIndexPath *insertIndexPath = [NSIndexPath indexPathForRow:insertIndex inSection:0];
|
||||||
|
[self.tableViewInternal insertRowsAtIndexPaths:@[insertIndexPath]
|
||||||
|
withRowAnimation:UITableViewRowAnimationNone];
|
||||||
|
|
||||||
|
[self.tableViewInternal endUpdates];
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
[self kb_scrollToBottom];
|
||||||
|
|
||||||
|
NSLog(@"[Panel] 添加后消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_updateLastAssistantMessageWithAudioData:(NSData *)audioData duration:(NSTimeInterval)duration {
|
||||||
|
NSLog(@"[Panel] 更新音频数据,duration: %.2f", duration);
|
||||||
|
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||||
|
KBChatMessage *msg = self.messages[i];
|
||||||
|
// 只更新 AI 消息(outgoing == NO)且非 loading 状态的
|
||||||
|
if (!msg.outgoing && !msg.isLoading) {
|
||||||
|
msg.audioData = audioData;
|
||||||
|
msg.audioDuration = duration;
|
||||||
|
|
||||||
|
// 不刷新 Cell,避免打断打字机效果
|
||||||
|
if (duration > 0) {
|
||||||
|
msg.needsTypewriterEffect = NO;
|
||||||
|
msg.isComplete = YES;
|
||||||
|
}
|
||||||
|
NSLog(@"[Panel] ✅ 音频数据已更新");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_scrollToBottom {
|
||||||
|
if (self.messages.count == 0) return;
|
||||||
|
|
||||||
|
NSLog(@"[Panel] 滚动到底部,消息数: %lu", (unsigned long)self.messages.count);
|
||||||
|
|
||||||
|
[self.tableViewInternal layoutIfNeeded];
|
||||||
|
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:self.messages.count - 1 inSection:0];
|
||||||
|
[self.tableViewInternal scrollToRowAtIndexPath:indexPath
|
||||||
|
atScrollPosition:UITableViewScrollPositionBottom
|
||||||
|
animated:NO]; // 改为 NO,避免动画导致跳动
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Private
|
||||||
|
|
||||||
|
- (void)kb_appendMessage:(KBChatMessage *)message {
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
NSInteger oldCount = self.messages.count;
|
||||||
|
[self.messages addObject:message];
|
||||||
|
NSLog(@"[Panel] kb_appendMessage: oldCount=%ld, newCount=%lu", (long)oldCount, (unsigned long)self.messages.count);
|
||||||
|
|
||||||
|
// 限制消息数量
|
||||||
|
if (self.messages.count > kKBChatMessageLimit) {
|
||||||
|
NSUInteger overflow = self.messages.count - kKBChatMessageLimit;
|
||||||
|
[self.messages removeObjectsInRange:NSMakeRange(0, overflow)];
|
||||||
|
NSLog(@"[Panel] 消息超限,reloadData");
|
||||||
|
[self.tableViewInternal reloadData];
|
||||||
|
} else {
|
||||||
|
NSLog(@"[Panel] 插入新行: %ld", (long)oldCount);
|
||||||
|
[self.tableViewInternal beginUpdates];
|
||||||
|
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:oldCount inSection:0];
|
||||||
|
[self.tableViewInternal insertRowsAtIndexPaths:@[indexPath]
|
||||||
|
withRowAnimation:UITableViewRowAnimationNone];
|
||||||
|
[self.tableViewInternal endUpdates];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接滚动,不用 dispatch_async
|
||||||
|
[self kb_scrollToBottom];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Actions
|
||||||
|
|
||||||
|
- (void)kb_onTapClose {
|
||||||
|
if ([self.delegate respondsToSelector:@selector(chatPanelViewDidTapClose:)]) {
|
||||||
|
[self.delegate chatPanelViewDidTapClose:self];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - UITableViewDataSource
|
||||||
|
|
||||||
|
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
|
||||||
|
return self.messages.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
if (indexPath.row >= self.messages.count) {
|
||||||
|
NSLog(@"[Panel] ❌ cellForRow 索引越界: %ld >= %lu", (long)indexPath.row, (unsigned long)self.messages.count);
|
||||||
|
return [[UITableViewCell alloc] init];
|
||||||
|
}
|
||||||
|
|
||||||
|
KBChatMessage *msg = self.messages[indexPath.row];
|
||||||
|
NSLog(@"[Panel] cellForRow[%ld]: outgoing=%d, isLoading=%d", (long)indexPath.row, msg.outgoing, msg.isLoading);
|
||||||
|
|
||||||
|
if (msg.outgoing) {
|
||||||
|
// 用户消息(右侧)
|
||||||
|
KBChatUserCell *cell = [tableView dequeueReusableCellWithIdentifier:kUserCellIdentifier forIndexPath:indexPath];
|
||||||
|
[cell configureWithMessage:msg];
|
||||||
|
return cell;
|
||||||
|
} else {
|
||||||
|
// AI 消息(左侧)
|
||||||
|
KBChatAssistantCell *cell = [tableView dequeueReusableCellWithIdentifier:kAssistantCellIdentifier forIndexPath:indexPath];
|
||||||
|
cell.delegate = self;
|
||||||
|
[cell configureWithMessage:msg];
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - UITableViewDelegate
|
||||||
|
|
||||||
|
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
return UITableViewAutomaticDimension;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
return 60.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
if (indexPath.row >= self.messages.count) { return; }
|
||||||
|
KBChatMessage *msg = self.messages[indexPath.row];
|
||||||
|
if ([self.delegate respondsToSelector:@selector(chatPanelView:didTapMessage:)]) {
|
||||||
|
[self.delegate chatPanelView:self didTapMessage:msg];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - KBChatAssistantCellDelegate
|
||||||
|
|
||||||
|
- (void)assistantCell:(KBChatAssistantCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message {
|
||||||
|
if ([self.delegate respondsToSelector:@selector(chatPanelView:didTapVoiceButtonForMessage:)]) {
|
||||||
|
[self.delegate chatPanelView:self didTapVoiceButtonForMessage:message];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Lazy
|
||||||
|
|
||||||
|
- (UITableView *)tableViewInternal {
|
||||||
|
if (!_tableViewInternal) {
|
||||||
|
_tableViewInternal = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
|
||||||
|
_tableViewInternal.backgroundColor = [UIColor clearColor];
|
||||||
|
_tableViewInternal.backgroundView = nil;
|
||||||
|
_tableViewInternal.separatorStyle = UITableViewCellSeparatorStyleNone;
|
||||||
|
_tableViewInternal.dataSource = self;
|
||||||
|
_tableViewInternal.delegate = self;
|
||||||
|
_tableViewInternal.estimatedRowHeight = 60.0;
|
||||||
|
_tableViewInternal.rowHeight = UITableViewAutomaticDimension;
|
||||||
|
// 注册两种 Cell
|
||||||
|
[_tableViewInternal registerClass:KBChatUserCell.class forCellReuseIdentifier:kUserCellIdentifier];
|
||||||
|
[_tableViewInternal registerClass:KBChatAssistantCell.class forCellReuseIdentifier:kAssistantCellIdentifier];
|
||||||
|
if (@available(iOS 11.0, *)) {
|
||||||
|
_tableViewInternal.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _tableViewInternal;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIView *)headerView {
|
||||||
|
if (!_headerView) {
|
||||||
|
_headerView = [[UIView alloc] init];
|
||||||
|
_headerView.backgroundColor = [UIColor clearColor];
|
||||||
|
[_headerView addSubview:self.titleLabel];
|
||||||
|
[_headerView addSubview:self.closeButton];
|
||||||
|
|
||||||
|
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(_headerView.mas_left).offset(12);
|
||||||
|
make.centerY.equalTo(_headerView);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.closeButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.right.equalTo(_headerView.mas_right).offset(-12);
|
||||||
|
make.centerY.equalTo(_headerView);
|
||||||
|
make.width.height.mas_equalTo(KBFit(24.0f));
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return _headerView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)titleLabel {
|
||||||
|
if (!_titleLabel) {
|
||||||
|
_titleLabel = [[UILabel alloc] init];
|
||||||
|
_titleLabel.hidden = true;
|
||||||
|
_titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightMedium];
|
||||||
|
_titleLabel.textColor =
|
||||||
|
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0x1B1F1A]
|
||||||
|
darkColor:[UIColor whiteColor]];
|
||||||
|
_titleLabel.text = KBLocalized(@"AI Chat");
|
||||||
|
}
|
||||||
|
return _titleLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIButton *)closeButton {
|
||||||
|
if (!_closeButton) {
|
||||||
|
_closeButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||||
|
UIImage *icon = [UIImage imageNamed:@"close_icon"];
|
||||||
|
[_closeButton setImage:icon forState:UIControlStateNormal];
|
||||||
|
_closeButton.backgroundColor = [UIColor clearColor];
|
||||||
|
[_closeButton addTarget:self
|
||||||
|
action:@selector(kb_onTapClose)
|
||||||
|
forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
}
|
||||||
|
return _closeButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Expose
|
||||||
|
|
||||||
|
- (UITableView *)tableView { return self.tableViewInternal; }
|
||||||
|
|
||||||
|
@end
|
||||||
19
CustomKeyboard/View/Chat/KBChatUserCell.h
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// KBChatUserCell.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// 用户消息 Cell(右侧显示)
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
@class KBChatMessage;
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@interface KBChatUserCell : UITableViewCell
|
||||||
|
|
||||||
|
- (void)configureWithMessage:(KBChatMessage *)message;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
85
CustomKeyboard/View/Chat/KBChatUserCell.m
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
//
|
||||||
|
// KBChatUserCell.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
// 用户消息 Cell(右侧显示)
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatUserCell.h"
|
||||||
|
#import "KBChatMessage.h"
|
||||||
|
#import "Masonry.h"
|
||||||
|
|
||||||
|
@interface KBChatUserCell ()
|
||||||
|
|
||||||
|
@property (nonatomic, strong) UIView *bubbleView;
|
||||||
|
@property (nonatomic, strong) UILabel *messageLabel;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBChatUserCell
|
||||||
|
|
||||||
|
- (instancetype)initWithStyle:(UITableViewCellStyle)style
|
||||||
|
reuseIdentifier:(NSString *)reuseIdentifier {
|
||||||
|
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
||||||
|
if (self) {
|
||||||
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
self.contentView.backgroundColor = [UIColor clearColor];
|
||||||
|
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||||
|
[self setupUI];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)setupUI {
|
||||||
|
[self.contentView addSubview:self.bubbleView];
|
||||||
|
[self.bubbleView addSubview:self.messageLabel];
|
||||||
|
|
||||||
|
[self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.contentView).offset(4);
|
||||||
|
make.bottom.equalTo(self.contentView).offset(-4);
|
||||||
|
make.right.equalTo(self.contentView).offset(-12);
|
||||||
|
make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.7);
|
||||||
|
make.height.greaterThanOrEqualTo(@36);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.bubbleView).offset(8);
|
||||||
|
make.bottom.equalTo(self.bubbleView).offset(-8);
|
||||||
|
make.left.equalTo(self.bubbleView).offset(12);
|
||||||
|
make.right.equalTo(self.bubbleView).offset(-12);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)configureWithMessage:(KBChatMessage *)message {
|
||||||
|
self.messageLabel.text = message.text ?: @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)prepareForReuse {
|
||||||
|
[super prepareForReuse];
|
||||||
|
self.messageLabel.text = @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Lazy
|
||||||
|
|
||||||
|
- (UIView *)bubbleView {
|
||||||
|
if (!_bubbleView) {
|
||||||
|
_bubbleView = [[UIView alloc] init];
|
||||||
|
_bubbleView.backgroundColor = [UIColor colorWithHex:0x02BEAC];
|
||||||
|
_bubbleView.layer.cornerRadius = 12;
|
||||||
|
_bubbleView.layer.masksToBounds = YES;
|
||||||
|
}
|
||||||
|
return _bubbleView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)messageLabel {
|
||||||
|
if (!_messageLabel) {
|
||||||
|
_messageLabel = [[UILabel alloc] init];
|
||||||
|
_messageLabel.numberOfLines = 0;
|
||||||
|
_messageLabel.font = [UIFont systemFontOfSize:14];
|
||||||
|
_messageLabel.textColor = [UIColor whiteColor];
|
||||||
|
_messageLabel.lineBreakMode = NSLineBreakByWordWrapping;
|
||||||
|
}
|
||||||
|
return _messageLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -12,7 +12,6 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
@protocol KBEmojiPanelViewDelegate <NSObject>
|
@protocol KBEmojiPanelViewDelegate <NSObject>
|
||||||
- (void)emojiPanelView:(KBEmojiPanelView *)panel didSelectEmoji:(NSString *)emoji;
|
- (void)emojiPanelView:(KBEmojiPanelView *)panel didSelectEmoji:(NSString *)emoji;
|
||||||
- (void)emojiPanelViewDidRequestClose:(KBEmojiPanelView *)panel;
|
- (void)emojiPanelViewDidRequestClose:(KBEmojiPanelView *)panel;
|
||||||
- (void)emojiPanelViewDidTapSearch:(KBEmojiPanelView *)panel;
|
|
||||||
@optional
|
@optional
|
||||||
- (void)emojiPanelViewDidTapDelete:(KBEmojiPanelView *)panel;
|
- (void)emojiPanelViewDidTapDelete:(KBEmojiPanelView *)panel;
|
||||||
@end
|
@end
|
||||||
@@ -30,6 +29,9 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
/// 高亮指定分类
|
/// 高亮指定分类
|
||||||
- (void)selectCategoryAtIndex:(NSInteger)index;
|
- (void)selectCategoryAtIndex:(NSInteger)index;
|
||||||
|
|
||||||
|
/// 释放 emoji 数据缓存(隐藏面板时可用)
|
||||||
|
- (void)purgeEmojiCache;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
@property (nonatomic, strong) UIButton *backButton;
|
@property (nonatomic, strong) UIButton *backButton;
|
||||||
@property (nonatomic, strong) UICollectionView *collectionView;
|
@property (nonatomic, strong) UICollectionView *collectionView;
|
||||||
@property (nonatomic, strong) KBEmojiBottomBarView *bottomBar;
|
@property (nonatomic, strong) KBEmojiBottomBarView *bottomBar;
|
||||||
//@property (nonatomic, strong) UIButton *searchButton;
|
|
||||||
@property (nonatomic, strong) NSArray<UIButton *> *tabButtons;
|
@property (nonatomic, strong) NSArray<UIButton *> *tabButtons;
|
||||||
@property (nonatomic, strong) KBEmojiDataProvider *dataProvider;
|
@property (nonatomic, strong) KBEmojiDataProvider *dataProvider;
|
||||||
@property (nonatomic, copy) NSArray<KBEmojiCategory *> *categories;
|
@property (nonatomic, copy) NSArray<KBEmojiCategory *> *categories;
|
||||||
@@ -100,14 +99,6 @@
|
|||||||
[self addSubview:self.bottomBar];
|
[self addSubview:self.bottomBar];
|
||||||
[self.bottomBar.deleteButton addTarget:self action:@selector(onDelete) forControlEvents:UIControlEventTouchUpInside];
|
[self.bottomBar.deleteButton addTarget:self action:@selector(onDelete) forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
|
||||||
// self.searchButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
|
||||||
// self.searchButton.layer.cornerRadius = 20;
|
|
||||||
// self.searchButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightBold];
|
|
||||||
// [self.searchButton setTitle:KBLocalized(@"Search") forState:UIControlStateNormal];
|
|
||||||
// [self.searchButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
|
||||||
// [self.searchButton addTarget:self action:@selector(onSearch) forControlEvents:UIControlEventTouchUpInside];
|
|
||||||
// [self.bottomBar addSubview:self.searchButton];
|
|
||||||
|
|
||||||
[self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) {
|
[self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.left.equalTo(self.mas_left).offset(12);
|
make.left.equalTo(self.mas_left).offset(12);
|
||||||
make.right.equalTo(self.mas_right).offset(-12);
|
make.right.equalTo(self.mas_right).offset(-12);
|
||||||
@@ -185,6 +176,15 @@
|
|||||||
[self updateSelectionToIndex:preserved];
|
[self updateSelectionToIndex:preserved];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- (void)purgeEmojiCache {
|
||||||
|
[self.dataProvider purgeLargeCaches];
|
||||||
|
self.categories = @[];
|
||||||
|
self.currentIndex = NSNotFound;
|
||||||
|
self.titleLabel.text = @"";
|
||||||
|
[self rebuildTabButtons];
|
||||||
|
[self.collectionView reloadData];
|
||||||
|
}
|
||||||
|
|
||||||
- (void)rebuildTabButtons {
|
- (void)rebuildTabButtons {
|
||||||
UIStackView *stackView = self.bottomBar.tabStackView;
|
UIStackView *stackView = self.bottomBar.tabStackView;
|
||||||
if (!stackView) { return; }
|
if (!stackView) { return; }
|
||||||
@@ -260,12 +260,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)onSearch {
|
|
||||||
if ([self.delegate respondsToSelector:@selector(emojiPanelViewDidTapSearch:)]) {
|
|
||||||
[self.delegate emojiPanelViewDidTapSearch:self];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)onDelete {
|
- (void)onDelete {
|
||||||
if ([self.delegate respondsToSelector:@selector(emojiPanelViewDidTapDelete:)]) {
|
if ([self.delegate respondsToSelector:@selector(emojiPanelViewDidTapDelete:)]) {
|
||||||
[self.delegate emojiPanelViewDidTapDelete:self];
|
[self.delegate emojiPanelViewDidTapDelete:self];
|
||||||
@@ -294,7 +288,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
- (void)onLocalizationChanged {
|
- (void)onLocalizationChanged {
|
||||||
// [self.searchButton setTitle:KBLocalized(@"Search") forState:UIControlStateNormal];
|
|
||||||
[self reloadData];
|
[self reloadData];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,8 +298,6 @@
|
|||||||
self.backgroundColor = bg;
|
self.backgroundColor = bg;
|
||||||
self.collectionView.backgroundColor = [UIColor clearColor];
|
self.collectionView.backgroundColor = [UIColor clearColor];
|
||||||
self.titleLabel.textColor = theme.keyTextColor ?: [UIColor whiteColor];
|
self.titleLabel.textColor = theme.keyTextColor ?: [UIColor whiteColor];
|
||||||
UIColor *searchColor = theme.accentColor ?: [UIColor colorWithRed:0.35 green:0.35 blue:0.95 alpha:1];
|
|
||||||
// self.searchButton.backgroundColor = searchColor;
|
|
||||||
self.tabNormalColor = [UIColor colorWithWhite:1 alpha:0.08];
|
self.tabNormalColor = [UIColor colorWithWhite:1 alpha:0.08];
|
||||||
self.tabSelectedColor = theme.accentColor ?: [UIColor colorWithWhite:1 alpha:0.25];
|
self.tabSelectedColor = theme.accentColor ?: [UIColor colorWithWhite:1 alpha:0.25];
|
||||||
[self updateTabHighlightStates];
|
[self updateTabHighlightStates];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
#import "KBFunctionTagListView.h"
|
#import "KBFunctionTagListView.h"
|
||||||
#import "KBFunctionTagCell.h"
|
#import "KBFunctionTagCell.h"
|
||||||
|
#import "KBMaiPointReporter.h"
|
||||||
|
|
||||||
static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2";
|
static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2";
|
||||||
static CGFloat const kKBItemSpace = 4;
|
static CGFloat const kKBItemSpace = 4;
|
||||||
@@ -66,8 +67,25 @@ static CGFloat const kKBItemSpace = 4;
|
|||||||
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section { return kKBItemSpace; }
|
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section { return kKBItemSpace; }
|
||||||
|
|
||||||
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||||
|
// 有cell正在loading时,不允许点击其他cell
|
||||||
|
if (self.loadingIndexes.count > 0) { return; }
|
||||||
|
KBTagItemModel *model = (indexPath.item < self.items.count) ? self.items[indexPath.item] : [KBTagItemModel new];
|
||||||
|
NSInteger personaId = 0;
|
||||||
|
if ([model isKindOfClass:KBTagItemModel.class]) {
|
||||||
|
personaId = model.characterId > 0 ? model.characterId : model.tagId;
|
||||||
|
}
|
||||||
|
NSMutableDictionary *extra = [NSMutableDictionary dictionary];
|
||||||
|
extra[@"index"] = @(indexPath.item);
|
||||||
|
extra[@"id"] = @(personaId);
|
||||||
|
if ([model.characterName isKindOfClass:NSString.class] && model.characterName.length > 0) {
|
||||||
|
extra[@"name"] = model.characterName;
|
||||||
|
}
|
||||||
|
[[KBMaiPointReporter sharedReporter] reportClickWithEventName:@"click_keyboard_function_tag_item"
|
||||||
|
pageId:@"keyboard_function_panel"
|
||||||
|
elementId:@"renshe_item"
|
||||||
|
extra:extra.copy
|
||||||
|
completion:nil];
|
||||||
if ([self.delegate respondsToSelector:@selector(tagListView:didSelectIndex:title:)]) {
|
if ([self.delegate respondsToSelector:@selector(tagListView:didSelectIndex:title:)]) {
|
||||||
KBTagItemModel *model = (indexPath.item < self.items.count) ? self.items[indexPath.item] : [KBTagItemModel new];
|
|
||||||
[self.delegate tagListView:self didSelectIndex:indexPath.item title:model.characterName];
|
[self.delegate tagListView:self didSelectIndex:indexPath.item title:model.characterName];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
CustomKeyboard/View/KBChatMessageCell.h
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//
|
||||||
|
// KBChatMessageCell.h
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
@class KBChatMessage;
|
||||||
|
@class KBChatMessageCell;
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@protocol KBChatMessageCellDelegate <NSObject>
|
||||||
|
@optional
|
||||||
|
/// 点击语音播放按钮
|
||||||
|
- (void)chatMessageCell:(KBChatMessageCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface KBChatMessageCell : UITableViewCell
|
||||||
|
|
||||||
|
@property (nonatomic, weak) id<KBChatMessageCellDelegate> delegate;
|
||||||
|
|
||||||
|
- (void)kb_configureWithMessage:(KBChatMessage *)message;
|
||||||
|
|
||||||
|
/// 更新语音播放状态
|
||||||
|
- (void)kb_updateVoicePlayingState:(BOOL)isPlaying;
|
||||||
|
|
||||||
|
/// 显示语音加载动画
|
||||||
|
- (void)kb_showVoiceLoadingAnimation;
|
||||||
|
|
||||||
|
/// 隐藏语音加载动画
|
||||||
|
- (void)kb_hideVoiceLoadingAnimation;
|
||||||
|
|
||||||
|
/// 停止打字机效果
|
||||||
|
- (void)kb_stopTypewriterEffect;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
495
CustomKeyboard/View/KBChatMessageCell.m
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
//
|
||||||
|
// KBChatMessageCell.m
|
||||||
|
// CustomKeyboard
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "KBChatMessageCell.h"
|
||||||
|
#import "KBChatMessage.h"
|
||||||
|
#import "Masonry.h"
|
||||||
|
|
||||||
|
@interface KBChatMessageCell ()
|
||||||
|
|
||||||
|
@property (nonatomic, strong) UIImageView *avatarView;
|
||||||
|
@property (nonatomic, strong) UILabel *nameLabel;
|
||||||
|
@property (nonatomic, strong) UIView *bubbleView;
|
||||||
|
@property (nonatomic, strong) UILabel *messageLabel;
|
||||||
|
@property (nonatomic, strong) UIImageView *audioIconView;
|
||||||
|
@property (nonatomic, strong) UILabel *audioLabel;
|
||||||
|
|
||||||
|
/// 语音播放按钮
|
||||||
|
@property (nonatomic, strong) UIButton *voiceButton;
|
||||||
|
/// 语音时长标签
|
||||||
|
@property (nonatomic, strong) UILabel *durationLabel;
|
||||||
|
/// 语音加载指示器
|
||||||
|
@property (nonatomic, strong) UIActivityIndicatorView *voiceLoadingIndicator;
|
||||||
|
/// 消息加载指示器(AI 回复 loading)
|
||||||
|
@property (nonatomic, strong) UIActivityIndicatorView *messageLoadingIndicator;
|
||||||
|
|
||||||
|
/// 当前消息
|
||||||
|
@property (nonatomic, strong) KBChatMessage *currentMessage;
|
||||||
|
|
||||||
|
/// 打字机效果
|
||||||
|
@property (nonatomic, strong) NSTimer *typewriterTimer;
|
||||||
|
@property (nonatomic, copy) NSString *fullText;
|
||||||
|
@property (nonatomic, assign) NSInteger currentCharIndex;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation KBChatMessageCell
|
||||||
|
|
||||||
|
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
|
||||||
|
if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
|
||||||
|
self.backgroundColor = [UIColor clearColor];
|
||||||
|
self.contentView.backgroundColor = [UIColor clearColor];
|
||||||
|
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||||
|
|
||||||
|
[self.contentView addSubview:self.avatarView];
|
||||||
|
[self.contentView addSubview:self.nameLabel];
|
||||||
|
[self.contentView addSubview:self.voiceButton];
|
||||||
|
[self.contentView addSubview:self.durationLabel];
|
||||||
|
[self.contentView addSubview:self.voiceLoadingIndicator];
|
||||||
|
[self.contentView addSubview:self.messageLoadingIndicator];
|
||||||
|
[self.contentView addSubview:self.bubbleView];
|
||||||
|
[self.bubbleView addSubview:self.messageLabel];
|
||||||
|
[self.bubbleView addSubview:self.audioIconView];
|
||||||
|
[self.bubbleView addSubview:self.audioLabel];
|
||||||
|
}
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_configureWithMessage:(KBChatMessage *)message {
|
||||||
|
// 先停止之前的打字机效果
|
||||||
|
[self kb_stopTypewriterEffect];
|
||||||
|
|
||||||
|
self.currentMessage = message;
|
||||||
|
|
||||||
|
BOOL outgoing = message.outgoing;
|
||||||
|
BOOL audioMessage = (!outgoing && message.audioFilePath.length > 0);
|
||||||
|
UIColor *bubbleColor = outgoing ? [UIColor colorWithHex:0x02BEAC] : [UIColor colorWithWhite:1 alpha:0.95];
|
||||||
|
UIColor *incomingTextColor =
|
||||||
|
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0x1B1F1A]
|
||||||
|
darkColor:[UIColor whiteColor]];
|
||||||
|
UIColor *textColor = outgoing ? [UIColor whiteColor] : incomingTextColor;
|
||||||
|
UIColor *nameColor =
|
||||||
|
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0x6B6F7A]
|
||||||
|
darkColor:[UIColor colorWithHex:0xC7CBD4]];
|
||||||
|
|
||||||
|
self.bubbleView.backgroundColor = bubbleColor;
|
||||||
|
self.messageLabel.textColor = textColor;
|
||||||
|
self.audioLabel.textColor = textColor;
|
||||||
|
self.audioIconView.tintColor = textColor;
|
||||||
|
self.audioLabel.text =
|
||||||
|
(message.text.length > 0) ? message.text : KBLocalized(@"Voice reply");
|
||||||
|
self.messageLabel.hidden = audioMessage;
|
||||||
|
self.audioIconView.hidden = !audioMessage;
|
||||||
|
self.audioLabel.hidden = !audioMessage;
|
||||||
|
|
||||||
|
UIImage *avatarImage = message.avatarImage;
|
||||||
|
if (!avatarImage) {
|
||||||
|
avatarImage = [self kb_defaultAvatarImage];
|
||||||
|
}
|
||||||
|
self.avatarView.image = avatarImage;
|
||||||
|
self.avatarView.backgroundColor =
|
||||||
|
avatarImage ? [UIColor clearColor] : [UIColor colorWithWhite:0.9 alpha:1.0];
|
||||||
|
self.nameLabel.hidden = outgoing;
|
||||||
|
self.nameLabel.textColor = nameColor;
|
||||||
|
self.nameLabel.text =
|
||||||
|
(message.displayName.length > 0) ? message.displayName : KBLocalized(@"AI Assistant");
|
||||||
|
|
||||||
|
// 处理 loading 状态
|
||||||
|
if (message.isLoading && !outgoing) {
|
||||||
|
self.bubbleView.hidden = YES;
|
||||||
|
self.voiceButton.hidden = YES;
|
||||||
|
self.durationLabel.hidden = YES;
|
||||||
|
[self.messageLoadingIndicator startAnimating];
|
||||||
|
[self kb_layoutForOutgoing:outgoing audioMessage:NO];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 loading 状态
|
||||||
|
[self.messageLoadingIndicator stopAnimating];
|
||||||
|
self.bubbleView.hidden = NO;
|
||||||
|
|
||||||
|
// 语音按钮显示逻辑(仅 AI 消息且有 audioId 或 audioData)
|
||||||
|
BOOL hasAudio = (!outgoing) && (message.audioId.length > 0 || message.audioData.length > 0);
|
||||||
|
self.voiceButton.hidden = !hasAudio;
|
||||||
|
self.durationLabel.hidden = !hasAudio;
|
||||||
|
if (hasAudio && message.audioDuration > 0) {
|
||||||
|
NSInteger seconds = (NSInteger)ceil(message.audioDuration);
|
||||||
|
self.durationLabel.text = [NSString stringWithFormat:@"%ld\"", (long)seconds];
|
||||||
|
} else {
|
||||||
|
self.durationLabel.text = @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打字机效果
|
||||||
|
if (!outgoing && message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) {
|
||||||
|
[self kb_startTypewriterEffectWithText:message.text];
|
||||||
|
} else {
|
||||||
|
self.messageLabel.attributedText = nil;
|
||||||
|
self.messageLabel.text = message.text ?: @"";
|
||||||
|
}
|
||||||
|
|
||||||
|
[self kb_layoutForOutgoing:outgoing audioMessage:audioMessage];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_layoutForOutgoing:(BOOL)outgoing audioMessage:(BOOL)audioMessage {
|
||||||
|
CGFloat avatarSize = 28.0;
|
||||||
|
[self.avatarView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.height.mas_equalTo(avatarSize);
|
||||||
|
make.top.equalTo(self.contentView.mas_top).offset(6);
|
||||||
|
if (outgoing) {
|
||||||
|
make.right.equalTo(self.contentView.mas_right).offset(-8);
|
||||||
|
} else {
|
||||||
|
make.left.equalTo(self.contentView.mas_left).offset(8);
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (outgoing) {
|
||||||
|
[self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.top.equalTo(self.contentView.mas_top).offset(0);
|
||||||
|
make.left.equalTo(self.contentView.mas_left);
|
||||||
|
}];
|
||||||
|
// 用户消息不显示语音按钮
|
||||||
|
[self.voiceButton mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.height.mas_equalTo(0);
|
||||||
|
make.left.top.equalTo(self.contentView);
|
||||||
|
}];
|
||||||
|
[self.durationLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.height.mas_equalTo(0);
|
||||||
|
make.left.top.equalTo(self.contentView);
|
||||||
|
}];
|
||||||
|
} else {
|
||||||
|
[self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
||||||
|
make.top.equalTo(self.contentView.mas_top).offset(2);
|
||||||
|
make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12);
|
||||||
|
}];
|
||||||
|
// AI 消息语音按钮
|
||||||
|
[self.voiceButton mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
||||||
|
make.top.equalTo(self.nameLabel.mas_bottom).offset(4);
|
||||||
|
make.width.height.mas_equalTo(20);
|
||||||
|
}];
|
||||||
|
[self.durationLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.voiceButton.mas_right).offset(4);
|
||||||
|
make.centerY.equalTo(self.voiceButton);
|
||||||
|
}];
|
||||||
|
[self.voiceLoadingIndicator mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.center.equalTo(self.voiceButton);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息加载指示器
|
||||||
|
[self.messageLoadingIndicator mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
if (outgoing) {
|
||||||
|
make.right.equalTo(self.avatarView.mas_left).offset(-10);
|
||||||
|
} else {
|
||||||
|
make.left.equalTo(self.avatarView.mas_right).offset(10);
|
||||||
|
}
|
||||||
|
make.top.equalTo(self.nameLabel.mas_bottom).offset(8);
|
||||||
|
}];
|
||||||
|
|
||||||
|
[self.bubbleView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.lessThanOrEqualTo(self.contentView.mas_width).multipliedBy(0.65);
|
||||||
|
if (outgoing) {
|
||||||
|
make.top.equalTo(self.contentView.mas_top).offset(6);
|
||||||
|
make.bottom.equalTo(self.contentView.mas_bottom).offset(-6);
|
||||||
|
make.right.equalTo(self.avatarView.mas_left).offset(-6);
|
||||||
|
} else {
|
||||||
|
// AI 消息:气泡在语音按钮下方
|
||||||
|
make.top.equalTo(self.voiceButton.mas_bottom).offset(4);
|
||||||
|
make.bottom.equalTo(self.contentView.mas_bottom).offset(-6);
|
||||||
|
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
||||||
|
make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12);
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (audioMessage) {
|
||||||
|
[self.messageLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.height.mas_equalTo(0);
|
||||||
|
make.left.equalTo(self.bubbleView.mas_left);
|
||||||
|
make.top.equalTo(self.bubbleView.mas_top);
|
||||||
|
}];
|
||||||
|
[self.audioIconView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.bubbleView.mas_left).offset(10);
|
||||||
|
make.centerY.equalTo(self.bubbleView);
|
||||||
|
make.width.height.mas_equalTo(16);
|
||||||
|
}];
|
||||||
|
[self.audioLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.left.equalTo(self.audioIconView.mas_right).offset(6);
|
||||||
|
make.centerY.equalTo(self.bubbleView);
|
||||||
|
make.right.equalTo(self.bubbleView.mas_right).offset(-10);
|
||||||
|
make.top.greaterThanOrEqualTo(self.bubbleView.mas_top).offset(8);
|
||||||
|
make.bottom.lessThanOrEqualTo(self.bubbleView.mas_bottom).offset(-8);
|
||||||
|
}];
|
||||||
|
} else {
|
||||||
|
[self.audioIconView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.height.mas_equalTo(0);
|
||||||
|
make.left.equalTo(self.bubbleView.mas_left);
|
||||||
|
make.top.equalTo(self.bubbleView.mas_top);
|
||||||
|
}];
|
||||||
|
[self.audioLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.width.height.mas_equalTo(0);
|
||||||
|
make.left.equalTo(self.audioIconView.mas_right);
|
||||||
|
make.top.equalTo(self.bubbleView.mas_top);
|
||||||
|
}];
|
||||||
|
[self.messageLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||||
|
make.edges.equalTo(self.bubbleView).insets(UIEdgeInsetsMake(8, 10, 8, 10));
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Typewriter Effect
|
||||||
|
|
||||||
|
- (void)kb_startTypewriterEffectWithText:(NSString *)text {
|
||||||
|
if (text.length == 0) return;
|
||||||
|
|
||||||
|
self.fullText = text;
|
||||||
|
self.currentCharIndex = 0;
|
||||||
|
|
||||||
|
// 先设置完整文本让布局计算正确高度
|
||||||
|
self.messageLabel.text = text;
|
||||||
|
[self.contentView setNeedsLayout];
|
||||||
|
[self.contentView layoutIfNeeded];
|
||||||
|
|
||||||
|
// 应用打字机效果
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:[UIColor clearColor]
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
[attributedText addAttribute:NSFontAttributeName
|
||||||
|
value:self.messageLabel.font
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
self.messageLabel.attributedText = attributedText;
|
||||||
|
|
||||||
|
self.typewriterTimer = [NSTimer scheduledTimerWithTimeInterval:0.03
|
||||||
|
target:self
|
||||||
|
selector:@selector(kb_typewriterTick)
|
||||||
|
userInfo:nil
|
||||||
|
repeats:YES];
|
||||||
|
[[NSRunLoop currentRunLoop] addTimer:self.typewriterTimer forMode:NSRunLoopCommonModes];
|
||||||
|
[self kb_typewriterTick];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_typewriterTick {
|
||||||
|
NSString *text = self.fullText;
|
||||||
|
if (!text || text.length == 0) {
|
||||||
|
[self kb_stopTypewriterEffect];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.currentCharIndex < text.length) {
|
||||||
|
self.currentCharIndex++;
|
||||||
|
|
||||||
|
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||||
|
UIColor *textColor = self.messageLabel.textColor ?: [UIColor blackColor];
|
||||||
|
|
||||||
|
if (self.currentCharIndex > 0) {
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:textColor
|
||||||
|
range:NSMakeRange(0, self.currentCharIndex)];
|
||||||
|
}
|
||||||
|
if (self.currentCharIndex < text.length) {
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:[UIColor clearColor]
|
||||||
|
range:NSMakeRange(self.currentCharIndex, text.length - self.currentCharIndex)];
|
||||||
|
}
|
||||||
|
[attributedText addAttribute:NSFontAttributeName
|
||||||
|
value:self.messageLabel.font
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
|
||||||
|
self.messageLabel.attributedText = attributedText;
|
||||||
|
} else {
|
||||||
|
[self kb_stopTypewriterEffect];
|
||||||
|
|
||||||
|
// 显示完整文本
|
||||||
|
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||||
|
UIColor *textColor = self.messageLabel.textColor ?: [UIColor blackColor];
|
||||||
|
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||||
|
value:textColor
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
[attributedText addAttribute:NSFontAttributeName
|
||||||
|
value:self.messageLabel.font
|
||||||
|
range:NSMakeRange(0, text.length)];
|
||||||
|
self.messageLabel.attributedText = attributedText;
|
||||||
|
|
||||||
|
// 标记完成
|
||||||
|
if (self.currentMessage) {
|
||||||
|
self.currentMessage.isComplete = YES;
|
||||||
|
self.currentMessage.needsTypewriterEffect = NO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_stopTypewriterEffect {
|
||||||
|
if (self.typewriterTimer && self.typewriterTimer.isValid) {
|
||||||
|
[self.typewriterTimer invalidate];
|
||||||
|
}
|
||||||
|
self.typewriterTimer = nil;
|
||||||
|
self.currentCharIndex = 0;
|
||||||
|
self.fullText = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Voice Button
|
||||||
|
|
||||||
|
- (void)kb_updateVoicePlayingState:(BOOL)isPlaying {
|
||||||
|
UIImage *icon = nil;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
icon = isPlaying ? [UIImage systemImageNamed:@"pause.circle.fill"] : [UIImage systemImageNamed:@"play.circle.fill"];
|
||||||
|
}
|
||||||
|
[self.voiceButton setImage:icon forState:UIControlStateNormal];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_showVoiceLoadingAnimation {
|
||||||
|
[self.voiceButton setImage:nil forState:UIControlStateNormal];
|
||||||
|
[self.voiceLoadingIndicator startAnimating];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_hideVoiceLoadingAnimation {
|
||||||
|
[self.voiceLoadingIndicator stopAnimating];
|
||||||
|
UIImage *icon = nil;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
icon = [UIImage systemImageNamed:@"play.circle.fill"];
|
||||||
|
}
|
||||||
|
[self.voiceButton setImage:icon forState:UIControlStateNormal];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)kb_onVoiceButtonTapped {
|
||||||
|
if ([self.delegate respondsToSelector:@selector(chatMessageCell:didTapVoiceButtonForMessage:)]) {
|
||||||
|
[self.delegate chatMessageCell:self didTapVoiceButtonForMessage:self.currentMessage];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Reuse
|
||||||
|
|
||||||
|
- (void)prepareForReuse {
|
||||||
|
[super prepareForReuse];
|
||||||
|
[self kb_stopTypewriterEffect];
|
||||||
|
self.messageLabel.text = @"";
|
||||||
|
self.messageLabel.attributedText = nil;
|
||||||
|
[self.messageLoadingIndicator stopAnimating];
|
||||||
|
[self.voiceLoadingIndicator stopAnimating];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)dealloc {
|
||||||
|
[self kb_stopTypewriterEffect];
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark - Lazy
|
||||||
|
|
||||||
|
- (UIImageView *)avatarView {
|
||||||
|
if (!_avatarView) {
|
||||||
|
_avatarView = [[UIImageView alloc] init];
|
||||||
|
_avatarView.contentMode = UIViewContentModeScaleAspectFill;
|
||||||
|
_avatarView.layer.cornerRadius = 14;
|
||||||
|
_avatarView.layer.masksToBounds = YES;
|
||||||
|
_avatarView.backgroundColor = [UIColor colorWithWhite:0.9 alpha:1.0];
|
||||||
|
_avatarView.tintColor =
|
||||||
|
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0xB9BDC8]
|
||||||
|
darkColor:[UIColor colorWithHex:0x6B6F7A]];
|
||||||
|
}
|
||||||
|
return _avatarView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)nameLabel {
|
||||||
|
if (!_nameLabel) {
|
||||||
|
_nameLabel = [[UILabel alloc] init];
|
||||||
|
_nameLabel.font = [UIFont systemFontOfSize:11];
|
||||||
|
_nameLabel.textColor = [UIColor colorWithHex:0x6B6F7A];
|
||||||
|
_nameLabel.numberOfLines = 1;
|
||||||
|
}
|
||||||
|
return _nameLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIView *)bubbleView {
|
||||||
|
if (!_bubbleView) {
|
||||||
|
_bubbleView = [[UIView alloc] init];
|
||||||
|
_bubbleView.layer.cornerRadius = 12;
|
||||||
|
_bubbleView.layer.masksToBounds = YES;
|
||||||
|
}
|
||||||
|
return _bubbleView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)messageLabel {
|
||||||
|
if (!_messageLabel) {
|
||||||
|
_messageLabel = [[UILabel alloc] init];
|
||||||
|
_messageLabel.font = [UIFont systemFontOfSize:14];
|
||||||
|
_messageLabel.numberOfLines = 0;
|
||||||
|
}
|
||||||
|
return _messageLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIImageView *)audioIconView {
|
||||||
|
if (!_audioIconView) {
|
||||||
|
_audioIconView = [[UIImageView alloc] init];
|
||||||
|
_audioIconView.contentMode = UIViewContentModeScaleAspectFit;
|
||||||
|
_audioIconView.tintColor = [UIColor colorWithHex:0x1B1F1A];
|
||||||
|
UIImage *icon = nil;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
icon = [UIImage systemImageNamed:@"waveform"];
|
||||||
|
}
|
||||||
|
_audioIconView.image = icon;
|
||||||
|
}
|
||||||
|
return _audioIconView;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)audioLabel {
|
||||||
|
if (!_audioLabel) {
|
||||||
|
_audioLabel = [[UILabel alloc] init];
|
||||||
|
_audioLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
|
||||||
|
_audioLabel.numberOfLines = 1;
|
||||||
|
}
|
||||||
|
return _audioLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIButton *)voiceButton {
|
||||||
|
if (!_voiceButton) {
|
||||||
|
_voiceButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||||
|
UIImage *icon = nil;
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
icon = [UIImage systemImageNamed:@"play.circle.fill"];
|
||||||
|
}
|
||||||
|
[_voiceButton setImage:icon forState:UIControlStateNormal];
|
||||||
|
_voiceButton.tintColor = [UIColor whiteColor];
|
||||||
|
[_voiceButton addTarget:self action:@selector(kb_onVoiceButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||||
|
}
|
||||||
|
return _voiceButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UILabel *)durationLabel {
|
||||||
|
if (!_durationLabel) {
|
||||||
|
_durationLabel = [[UILabel alloc] init];
|
||||||
|
_durationLabel.font = [UIFont systemFontOfSize:11];
|
||||||
|
_durationLabel.textColor = [UIColor whiteColor];
|
||||||
|
}
|
||||||
|
return _durationLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIActivityIndicatorView *)voiceLoadingIndicator {
|
||||||
|
if (!_voiceLoadingIndicator) {
|
||||||
|
_voiceLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||||
|
_voiceLoadingIndicator.color = [UIColor whiteColor];
|
||||||
|
_voiceLoadingIndicator.hidesWhenStopped = YES;
|
||||||
|
}
|
||||||
|
return _voiceLoadingIndicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIActivityIndicatorView *)messageLoadingIndicator {
|
||||||
|
if (!_messageLoadingIndicator) {
|
||||||
|
_messageLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||||
|
_messageLoadingIndicator.color = [UIColor whiteColor];
|
||||||
|
_messageLoadingIndicator.hidesWhenStopped = YES;
|
||||||
|
}
|
||||||
|
return _messageLoadingIndicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (UIImage *)kb_defaultAvatarImage {
|
||||||
|
if (@available(iOS 13.0, *)) {
|
||||||
|
return [UIImage systemImageNamed:@"person.circle.fill"];
|
||||||
|
}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
#import "Masonry.h"
|
#import "Masonry.h"
|
||||||
#import "KBResponderUtils.h" // 统一查找 UIInputViewController 的工具
|
#import "KBResponderUtils.h" // 统一查找 UIInputViewController 的工具
|
||||||
#import "KBHUD.h"
|
#import "KBHUD.h"
|
||||||
#import "KBHostAppLauncher.h"
|
#import "../Utils/KBExtensionAppLauncher.h"
|
||||||
|
|
||||||
@interface KBFullAccessGuideView ()
|
@interface KBFullAccessGuideView ()
|
||||||
@property (nonatomic, strong) UIControl *backdrop;
|
@property (nonatomic, strong) UIControl *backdrop;
|
||||||
@@ -159,18 +159,34 @@
|
|||||||
// 工具方法已提取到 KBResponderUtils.h
|
// 工具方法已提取到 KBResponderUtils.h
|
||||||
// 打开主 App,引导用户去系统设置开启完全访问:通过宿主 UIApplication + 自定义 Scheme 拉起。
|
// 打开主 App,引导用户去系统设置开启完全访问:通过宿主 UIApplication + 自定义 Scheme 拉起。
|
||||||
- (void)onTapGoEnable {
|
- (void)onTapGoEnable {
|
||||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
UIInputViewController *ivc = self.ivc ?: KBFindInputViewController(self);
|
||||||
// 找不到键盘控制器也可以尝试从自身 responder 链出发
|
if (!ivc) {
|
||||||
UIResponder *start = ivc.view ?: (UIResponder *)self;
|
NSString *showInfo = [NSString stringWithFormat:KBLocalized(@"Follow: Settings -> General -> Keyboard -> Keyboards -> %@ -> Allow Full Access"), AppName];
|
||||||
|
|
||||||
// 自定义 Scheme(AppDelegate 中处理 kbkeyboardAppExtension://settings)
|
|
||||||
NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"%@@//settings?src=kb_extension", KB_APP_SCHEME]];
|
|
||||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:start];
|
|
||||||
if (ok) {
|
|
||||||
[self dismiss];
|
|
||||||
} else {
|
|
||||||
NSString *showInfo = [NSString stringWithFormat:KBLocalized(@"Follow: Settings → General → Keyboard → Keyboards → %@ → Allow Full Access"),AppName];
|
|
||||||
[KBHUD showInfo:showInfo];
|
[KBHUD showInfo:showInfo];
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优先用 Universal Link 拉起(更高成功率),失败再回退到自定义 Scheme。
|
||||||
|
NSURL *ul = [NSURL URLWithString:[NSString stringWithFormat:@"%@?src=kb_extension", KB_UL_SETTINGS]];
|
||||||
|
NSURL *scheme = [NSURL URLWithString:[NSString stringWithFormat:@"%@://settings?src=kb_extension", KB_APP_SCHEME]];
|
||||||
|
__weak typeof(self) weakSelf = self;
|
||||||
|
[KBExtensionAppLauncher openPrimaryURL:ul
|
||||||
|
fallbackURL:scheme
|
||||||
|
usingInputController:ivc
|
||||||
|
source:(ivc.view ?: (UIResponder *)weakSelf)
|
||||||
|
completion:^(BOOL success) {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
__strong typeof(weakSelf) self = weakSelf;
|
||||||
|
if (!self) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
[self dismiss];
|
||||||
|
} else {
|
||||||
|
NSString *showInfo = [NSString stringWithFormat:KBLocalized(@"Follow: Settings -> General -> Keyboard -> Keyboards -> %@ -> Allow Full Access"), AppName];
|
||||||
|
[KBHUD showInfo:showInfo];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
@end
|
@end
|
||||||
|
|
||||||
@implementation KBFunctionBarView
|
@implementation KBFunctionBarView
|
||||||
|
static const CGFloat kKBBackButtonWidth = 40;
|
||||||
|
|
||||||
- (instancetype)initWithFrame:(CGRect)frame{
|
- (instancetype)initWithFrame:(CGRect)frame{
|
||||||
if (self = [super initWithFrame:frame]) {
|
if (self = [super initWithFrame:frame]) {
|
||||||
@@ -83,14 +84,14 @@
|
|||||||
UIButton *appButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
UIButton *appButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||||
appButton.tag = 100; // 左侧 index = 0
|
appButton.tag = 100; // 左侧 index = 0
|
||||||
UIImage *appImage = [UIImage imageNamed:@"App_icon"];
|
UIImage *appImage = [UIImage imageNamed:@"App_icon"];
|
||||||
[appButton setImage:appImage forState:UIControlStateNormal];
|
[appButton setBackgroundImage:appImage forState:UIControlStateNormal];
|
||||||
appButton.imageView.contentMode = UIViewContentModeScaleAspectFit;
|
appButton.imageView.contentMode = UIViewContentModeScaleAspectFit;
|
||||||
appButton.adjustsImageWhenHighlighted = YES;
|
appButton.adjustsImageWhenHighlighted = YES;
|
||||||
[appButton addTarget:self action:@selector(onLeftTap:) forControlEvents:UIControlEventTouchUpInside];
|
[appButton addTarget:self action:@selector(onLeftTap:) forControlEvents:UIControlEventTouchUpInside];
|
||||||
[self.leftContainer addSubview:appButton];
|
[self.leftContainer addSubview:appButton];
|
||||||
[appButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
[appButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||||
make.center.equalTo(self.leftContainer);
|
make.center.equalTo(self.leftContainer);
|
||||||
make.width.height.mas_equalTo(34); // 设计图尺寸
|
make.width.height.mas_equalTo(kKBBackButtonWidth); // 设计图尺寸
|
||||||
}];
|
}];
|
||||||
self.leftButtonsInternal = @[appButton];
|
self.leftButtonsInternal = @[appButton];
|
||||||
|
|
||||||
|
|||||||