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>
|
||||
<string>kbkeyboardAppExtension</string>
|
||||
</array>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Microphone access is required for voice input and speech transcription.</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "切图 270@2x.png",
|
||||
"filename" : "切图 271@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "切图 270@3x.png",
|
||||
"filename" : "切图 271@3x.png",
|
||||
"idiom" : "universal",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"filename" : "key_123@2x.png",
|
||||
"filename" : "close_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "key_123@3x.png",
|
||||
"filename" : "close_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"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",
|
||||
"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",
|
||||
"idiom" : "universal",
|
||||
"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",
|
||||
"idiom" : "universal",
|
||||
"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" : {
|
||||
|
||||
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"
|
||||
},
|
||||
{
|
||||
"filename" : "key_ai@2x.png",
|
||||
"filename" : "key_revoke@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "key_ai@3x.png",
|
||||
"filename" : "key_revoke@3x.png",
|
||||
"idiom" : "universal",
|
||||
"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.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController.h"
|
||||
#import "KBKeyBoardMainView.h"
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
#import "KBKey.h"
|
||||
#import "KBFunctionView.h"
|
||||
#import "KBSettingView.h"
|
||||
#import "Masonry.h"
|
||||
#import "KBAuthManager.h"
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBChatLimitPopView.h"
|
||||
#import "KBChatPanelView.h"
|
||||
#import "KBFullAccessManager.h"
|
||||
#import "KBFunctionView.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
#import "KBKeyBoardMainView.h"
|
||||
#import "KBKeyboardSubscriptionView.h"
|
||||
#import "KBLocalizationManager.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBSkinInstallBridge.h"
|
||||
#import "KBHostAppLauncher.h"
|
||||
#import "KBKeyboardSubscriptionView.h"
|
||||
#import "KBKeyboardSubscriptionProduct.h"
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBSuggestionEngine.h"
|
||||
#import "KBKeyboardLayoutResolver.h"
|
||||
#import <SDWebImage/SDWebImage.h>
|
||||
|
||||
// 提前声明一个类别,使编译器在 static 回调中识别 kb_consumePendingShopSkin 方法。
|
||||
@interface KeyboardViewController (KBSkinShopBridge)
|
||||
- (void)kb_consumePendingShopSkin;
|
||||
@end
|
||||
#if DEBUG
|
||||
#import <mach/mach.h>
|
||||
#endif
|
||||
|
||||
// 以 375 宽设计稿为基准的键盘总高度(包括顶部工具栏)
|
||||
static const CGFloat kKBKeyboardDesignHeight = 250.0f;
|
||||
#if DEBUG
|
||||
static NSInteger sKBKeyboardVCAliveCount = 0;
|
||||
|
||||
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];
|
||||
}
|
||||
});
|
||||
static uint64_t KBPhysFootprintBytes(void) {
|
||||
task_vm_info_data_t vmInfo;
|
||||
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
|
||||
kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO,
|
||||
(task_info_t)&vmInfo, &count);
|
||||
if (kr != KERN_SUCCESS) {
|
||||
return 0;
|
||||
}
|
||||
return (uint64_t)vmInfo.phys_footprint;
|
||||
}
|
||||
|
||||
@interface KeyboardViewController () <KBKeyBoardMainViewDelegate, KBFunctionViewDelegate, KBKeyboardSubscriptionViewDelegate>
|
||||
@property (nonatomic, strong) UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选)
|
||||
@property (nonatomic, strong) KBKeyBoardMainView *keyBoardMainView; // 功能面板视图(点击工具栏第0个时显示)
|
||||
@property (nonatomic, strong) KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示)
|
||||
@property (nonatomic, strong) KBSettingView *settingView; // 设置页
|
||||
@property (nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层)
|
||||
@property (nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView;
|
||||
@end
|
||||
static NSString *KBFormatMB(uint64_t bytes) {
|
||||
double mb = (double)bytes / 1024.0 / 1024.0;
|
||||
return [NSString stringWithFormat:@"%.1fMB", mb];
|
||||
}
|
||||
#endif
|
||||
|
||||
@implementation KeyboardViewController
|
||||
|
||||
{
|
||||
BOOL _kb_didTriggerLoginDeepLinkOnce;
|
||||
BOOL _kb_didTriggerLoginDeepLinkOnce;
|
||||
NSString *_kb_lastLoadedProfileId; // 记录上次加载的 profileId
|
||||
#if DEBUG
|
||||
BOOL _kb_debugDidCountAlive;
|
||||
#endif
|
||||
}
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
[self setupUI];
|
||||
// 指定 HUD 的承载视图(扩展里无法取到 App 的 KeyWindow)
|
||||
[KBHUD setContainerView:self.view];
|
||||
// 绑定完全访问管理器,便于统一感知和联动网络开关
|
||||
[[KBFullAccessManager shared] bindInputController:self];
|
||||
__unused id token = [[NSNotificationCenter defaultCenter] addObserverForName:KBFullAccessChangedNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(__unused NSNotification * _Nonnull note) {
|
||||
// 如需,可在此刷新与完全访问相关的 UI
|
||||
}];
|
||||
[super viewDidLoad];
|
||||
#if DEBUG
|
||||
if (!_kb_debugDidCountAlive) {
|
||||
_kb_debugDidCountAlive = YES;
|
||||
sKBKeyboardVCAliveCount += 1;
|
||||
}
|
||||
NSLog(@"[Keyboard] KeyboardViewController viewDidLoad alive=%ld self=%p mem=%@",
|
||||
(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) {
|
||||
[self kb_applyTheme];
|
||||
}];
|
||||
[self kb_applyTheme];
|
||||
CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
(__bridge const void *)(self),
|
||||
KBSkinInstallNotificationCallback,
|
||||
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification,
|
||||
NULL,
|
||||
CFNotificationSuspensionBehaviorDeliverImmediately);
|
||||
[self kb_consumePendingShopSkin];
|
||||
|
||||
// 皮肤变化时,立即应用
|
||||
__weak typeof(self) weakSelf = self;
|
||||
self.kb_skinObserverToken = [[NSNotificationCenter defaultCenter]
|
||||
addObserverForName:KBSkinDidChangeNotification
|
||||
object:nil
|
||||
queue:[NSOperationQueue mainQueue]
|
||||
usingBlock:^(__unused NSNotification *_Nonnull note) {
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
[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{
|
||||
[super viewWillAppear:animated];
|
||||
[[KBLocalizationManager shared] reloadFromSharedStorageIfNeeded];
|
||||
- (void)didReceiveMemoryWarning {
|
||||
[super didReceiveMemoryWarning];
|
||||
// 扩展进程内存上限较小:在系统发出内存警告时主动清理可重建的缓存,降低被系统杀死概率。
|
||||
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 {
|
||||
self.view.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
|
||||
// 按屏幕宽度对设计值做等比缩放,避免在不同机型上键盘整体高度失真导致皮肤被压缩/拉伸
|
||||
CGFloat keyboardHeight = KBFit(kKBKeyboardDesignHeight);
|
||||
CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;
|
||||
CGFloat outerVerticalInset = KBFit(4.0f);
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
[super viewWillDisappear:animated];
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
[self kb_releaseMemoryWhenKeyboardHidden];
|
||||
#if DEBUG
|
||||
NSLog(@"[Keyboard] viewWillDisappear self=%p mem=%@",
|
||||
self, KBFormatMB(KBPhysFootprintBytes()));
|
||||
#endif
|
||||
}
|
||||
|
||||
NSLayoutConstraint *h = [self.view.heightAnchor constraintEqualToConstant:keyboardHeight];
|
||||
NSLayoutConstraint *w = [self.view.widthAnchor constraintEqualToConstant:screenWidth];
|
||||
- (void)viewDidDisappear:(BOOL)animated {
|
||||
[super viewDidDisappear:animated];
|
||||
// 再兜底一次,防止某些宿主只触发 willDisappear 而未触发 didDisappear。
|
||||
[self kb_releaseMemoryWhenKeyboardHidden];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
|
||||
[super traitCollectionDidChange:previousTraitCollection];
|
||||
if (@available(iOS 13.0, *)) {
|
||||
if (previousTraitCollection.userInterfaceStyle !=
|
||||
self.traitCollection.userInterfaceStyle) {
|
||||
self.kb_cachedGradientImage = nil;
|
||||
[self kb_applyDefaultSkinIfNeeded];
|
||||
}
|
||||
// 背景图铺底
|
||||
[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);
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#pragma mark - Private
|
||||
|
||||
/// 切换显示功能面板/键盘主视图
|
||||
- (void)showFunctionPanel:(BOOL)show {
|
||||
// 简单显隐切换,复用相同的布局区域
|
||||
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];
|
||||
}
|
||||
- (void)textDidChange:(id<UITextInput>)textInput {
|
||||
[super textDidChange:textInput];
|
||||
[[KBInputBufferManager shared]
|
||||
updateFromExternalContextBefore:self.textDocumentProxy
|
||||
.documentContextBeforeInput
|
||||
after:self.textDocumentProxy
|
||||
.documentContextAfterInput];
|
||||
}
|
||||
|
||||
/// 显示/隐藏设置页(高度与 keyBoardMainView 一致),右侧滑入/滑出
|
||||
- (void)showSettingView:(BOOL)show {
|
||||
if (show) {
|
||||
// if (!self.settingView) {
|
||||
self.settingView = [[KBSettingView alloc] init];
|
||||
self.settingView.hidden = YES;
|
||||
[self.view addSubview:self.settingView];
|
||||
[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)viewDidAppear:(BOOL)animated {
|
||||
[super viewDidAppear:animated];
|
||||
// if (!_kb_didTriggerLoginDeepLinkOnce) {
|
||||
// _kb_didTriggerLoginDeepLinkOnce = YES;
|
||||
// // 仅在未登录时尝试拉起主App登录
|
||||
// if (!KBAuthManager.shared.isLoggedIn) {
|
||||
// [self kb_tryOpenContainerForLoginIfNeeded];
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
- (void)showSubscriptionPanel {
|
||||
// 1) 先判断权限:未开启“完全访问”则走引导逻辑
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||
// 未开启完全访问:保持原有引导路径
|
||||
// [KBHUD showInfo:KBLocalized(@"处理中…")];
|
||||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view];
|
||||
return;
|
||||
}
|
||||
// 点击充值要先判断是否登录
|
||||
// 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App 负责完成登录
|
||||
if (!KBAuthManager.shared.isLoggedIn) {
|
||||
NSString *schemeStr = [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||
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)viewDidLayoutSubviews {
|
||||
[super viewDidLayoutSubviews];
|
||||
// [self kb_updateKeyboardLayoutIfNeeded];
|
||||
|
||||
// 首次布局完成后显示,避免闪烁
|
||||
if (self.contentView.hidden) {
|
||||
self.contentView.hidden = NO;
|
||||
}
|
||||
if (self.kb_defaultGradientLayer) {
|
||||
self.kb_defaultGradientLayer.frame = self.bgImageView.bounds;
|
||||
}
|
||||
|
||||
// 每次布局时检查是否需要切换键盘布局
|
||||
[self kb_checkAndApplyLayoutIfNeeded];
|
||||
}
|
||||
|
||||
- (void)hideSubscriptionPanel {
|
||||
if (!self.subscriptionView || self.subscriptionView.hidden) { return; }
|
||||
CGFloat height = CGRectGetHeight(self.subscriptionView.bounds);
|
||||
if (height <= 0) { height = CGRectGetHeight(self.view.bounds); }
|
||||
KBKeyboardSubscriptionView *panel = self.subscriptionView;
|
||||
[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;
|
||||
}];
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 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)viewWillTransitionToSize:(CGSize)size
|
||||
withTransitionCoordinator:
|
||||
(id<UIViewControllerTransitionCoordinator>)coordinator {
|
||||
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[coordinator
|
||||
animateAlongsideTransition:^(
|
||||
id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
|
||||
[weakSelf kb_updateKeyboardLayoutIfNeeded];
|
||||
}
|
||||
completion:^(
|
||||
__unused id<
|
||||
UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
|
||||
[weakSelf kb_updateKeyboardLayoutIfNeeded];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
(__bridge const void *)(self),
|
||||
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification,
|
||||
NULL);
|
||||
if (self.kb_fullAccessObserverToken) {
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
removeObserver:self.kb_fullAccessObserverToken];
|
||||
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)viewDidAppear:(BOOL)animated {
|
||||
[super viewDidAppear:animated];
|
||||
// if (!_kb_didTriggerLoginDeepLinkOnce) {
|
||||
// _kb_didTriggerLoginDeepLinkOnce = YES;
|
||||
// // 仅在未登录时尝试拉起主App登录
|
||||
// if (!KBAuthManager.shared.isLoggedIn) {
|
||||
// [self kb_tryOpenContainerForLoginIfNeeded];
|
||||
// }
|
||||
// }
|
||||
- (void)kb_reloadUIForLocalizationChange {
|
||||
if (![NSThread isMainThread]) {
|
||||
__weak typeof(self) weakSelf = self;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[weakSelf kb_reloadUIForLocalizationChange];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录当前面板状态,重建后尽量恢复。
|
||||
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 {
|
||||
// // 使用与主 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 - Layout Switching
|
||||
|
||||
#pragma mark - Theme
|
||||
- (void)kb_checkAndApplyLayoutIfNeeded {
|
||||
NSString *currentProfileId = [[KBKeyboardLayoutResolver sharedResolver] currentProfileId];
|
||||
if (currentProfileId.length == 0) {
|
||||
currentProfileId = @"en_US_qwerty";
|
||||
}
|
||||
|
||||
- (void)kb_applyTheme {
|
||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
||||
UIImage *img = [[KBSkinManager shared] currentBackgroundImage];
|
||||
self.bgImageView.image = img;
|
||||
BOOL hasImg = (img != nil);
|
||||
self.view.backgroundColor = hasImg ? [UIColor clearColor] : t.keyboardBackground;
|
||||
self.keyBoardMainView.backgroundColor = hasImg ? [UIColor clearColor] : t.keyboardBackground;
|
||||
// 触发键区按主题重绘
|
||||
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
|
||||
}
|
||||
if ([self.functionView respondsToSelector:@selector(kb_applyTheme)]) {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
[self.functionView performSelector:@selector(kb_applyTheme)];
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
if ([currentProfileId isEqualToString:_kb_lastLoadedProfileId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[KeyboardViewController] Detected profileId change: %@ -> %@", _kb_lastLoadedProfileId, currentProfileId);
|
||||
_kb_lastLoadedProfileId = currentProfileId;
|
||||
|
||||
if (self.keyBoardMainView && [self.keyBoardMainView respondsToSelector:@selector(reloadLayoutWithProfileId:)]) {
|
||||
[self.keyBoardMainView performSelector:@selector(reloadLayoutWithProfileId:) withObject:currentProfileId];
|
||||
}
|
||||
|
||||
NSString *suggestionEngine = [[KBKeyboardLayoutResolver sharedResolver] suggestionEngineForProfileId:currentProfileId];
|
||||
if (suggestionEngine.length > 0) {
|
||||
[self kb_updateSuggestionEngineType:suggestionEngine];
|
||||
}
|
||||
|
||||
NSString *languageCode = [[KBKeyboardLayoutResolver sharedResolver] currentLanguageCode];
|
||||
if (languageCode.length > 0) {
|
||||
NSLog(@"[KeyboardViewController] Reloading skin icon map for language: %@", languageCode);
|
||||
[KBSkinInstallBridge reloadCurrentSkinIconMapForLanguageCode:languageCode];
|
||||
}
|
||||
}
|
||||
|
||||
- (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(@"皮肤资源准备失败,请稍后再试")];
|
||||
}
|
||||
return;
|
||||
}
|
||||
[weakSelf kb_applyTheme];
|
||||
[KBHUD showInfo:KBLocalized(@"皮肤已更新,立即体验吧")];
|
||||
}];
|
||||
- (void)kb_updateSuggestionEngineType:(NSString *)engineType {
|
||||
NSLog(@"[KeyboardViewController] Switching suggestion engine to: %@", engineType);
|
||||
[[KBSuggestionEngine shared] setEngineTypeFromString:engineType];
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
#pragma mark - App Group KVO
|
||||
|
||||
- (UIImageView *)bgImageView {
|
||||
if (!_bgImageView) {
|
||||
_bgImageView = [[UIImageView alloc] init];
|
||||
_bgImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
_bgImageView.clipsToBounds = YES;
|
||||
}
|
||||
return _bgImageView;
|
||||
- (void)kb_startObservingAppGroupChanges {
|
||||
NSUserDefaults *appGroup = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
self.kb_appGroupObserverToken = [[NSNotificationCenter defaultCenter]
|
||||
addObserverForName:NSUserDefaultsDidChangeNotification
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// 释放大块缓存(emoji 分类与索引),下次访问会重新加载。
|
||||
- (void)purgeLargeCaches;
|
||||
|
||||
@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 {
|
||||
[self refreshLocalizedTitles];
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:KBEmojiRecentsDidChangeNotification object:nil];
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
// KBFullAccessManager.m
|
||||
//
|
||||
// 统一封装“允许完全访问”检测:
|
||||
// 1) 首选:反射调用 UIInputViewController 的 hasFullAccess(避免直接引用私有 API 标识)
|
||||
// 1) 直接使用 UIInputViewController.hasFullAccess(公开 API)
|
||||
// 2) 兜底:无法判断时返回 Unknown(上层可按需降级为 Denied 并提示)
|
||||
//
|
||||
|
||||
#import "KBFullAccessManager.h"
|
||||
#import <objc/message.h>
|
||||
#if __has_include("KBNetworkManager.h")
|
||||
#import "KBNetworkManager.h"
|
||||
#endif
|
||||
@@ -62,7 +61,10 @@ NSNotificationName const KBFullAccessChangedNotification = @"KBFullAccessChanged
|
||||
Class guideCls = NSClassFromString(@"KBFullAccessGuideView");
|
||||
if (guideCls && [guideCls respondsToSelector: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
|
||||
return NO;
|
||||
@@ -74,13 +76,9 @@ NSNotificationName const KBFullAccessChangedNotification = @"KBFullAccessChanged
|
||||
- (KBFullAccessState)p_detectFullAccessState {
|
||||
UIInputViewController *ivc = self.ivc;
|
||||
if (!ivc) return KBFullAccessStateUnknown;
|
||||
|
||||
SEL sel = NSSelectorFromString(@"hasFullAccess");
|
||||
if ([ivc respondsToSelector:sel]) {
|
||||
BOOL granted = ((BOOL (*)(id, SEL))objc_msgSend)(ivc, sel);
|
||||
return granted ? KBFullAccessStateGranted : KBFullAccessStateDenied;
|
||||
if ([ivc respondsToSelector:@selector(hasFullAccess)]) {
|
||||
return ivc.hasFullAccess ? KBFullAccessStateGranted : KBFullAccessStateDenied;
|
||||
}
|
||||
// 无法判断时标记 Unknown(上层可按需处理为未开启)
|
||||
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
|
||||
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
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -41,38 +41,10 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
}
|
||||
|
||||
- (void)getSignWithParare:(NSDictionary *)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];
|
||||
|
||||
// 将签名相关字段合并进默认请求头
|
||||
NSDictionary<NSString *, NSString *> *signHeaders = [KBSignUtils signHeadersWithBodyParams:bodyParams];
|
||||
NSMutableDictionary<NSString *, NSString *> *headers =
|
||||
[self.defaultHeaders mutableCopy] ?: [NSMutableDictionary dictionary];
|
||||
|
||||
if (sign.length > 0) {
|
||||
headers[@"X-Sign"] = sign;
|
||||
}
|
||||
headers[@"X-App-Id"] = appId;
|
||||
headers[@"X-Timestamp"] = timestamp;
|
||||
headers[@"X-Nonce"] = nonce;
|
||||
|
||||
// 触发 copy 语义,确保对外仍是不可变字典
|
||||
[headers addEntriesFromDictionary:signHeaders ?: @{}];
|
||||
self.defaultHeaders = headers;
|
||||
}
|
||||
|
||||
@@ -124,6 +96,84 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
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
|
||||
parameters:(NSDictionary *)parameters
|
||||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// - 兼容后端“/t”作为分段标记:可自动替换为制表符“\t”
|
||||
// - 首段去首个“\t”:若首次正文以一个制表符起始(允许前导空白),可只移除“一个”\t
|
||||
//
|
||||
|
||||
// 暂未使用
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@@ -225,7 +225,10 @@ static NSString * const kKBStreamSplitToken = @"<SPLIT>";
|
||||
}
|
||||
if (payload.length > 0) {
|
||||
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;
|
||||
if ([self processLLMChunkPayload:payload output:&llmText]) {
|
||||
@@ -278,7 +281,10 @@ static NSString * const kKBStreamSplitToken = @"<SPLIT>";
|
||||
}
|
||||
if (payload.length > 0) {
|
||||
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;
|
||||
if ((NSInteger)payload.length >= self.deliveredCharCount) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// Created by Mac on 2025/11/12.
|
||||
//
|
||||
|
||||
// 暂未使用
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//
|
||||
|
||||
#import "NetworkStreamHandler.h"
|
||||
#import <Security/Security.h>
|
||||
#import "KBLocalizationManager.h"
|
||||
|
||||
@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:@"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:@"1" forHTTPHeaderField:@"Upgrade-Insecure-Requests"];
|
||||
|
||||
@@ -243,8 +249,26 @@ didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
|
||||
|
||||
// 处理 SSL 认证挑战
|
||||
if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
|
||||
NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
|
||||
completionHandler(NSURLSessionAuthChallengeUseCredential, credential);
|
||||
SecTrustRef trust = challenge.protectionSpace.serverTrust;
|
||||
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 {
|
||||
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
#import "Masonry.h"
|
||||
#import "KBHUD.h" // 复用 App 内的 HUD 封装
|
||||
#import "KBLocalizationManager.h" // 复用多语言封装(可在扩展内使用)
|
||||
#import "KBMaiPointReporter.h"
|
||||
//#import "KBLog.h"
|
||||
|
||||
|
||||
// 通用链接(Universal Links)统一配置
|
||||
// 配置好 AASA 与 Associated Domains 后,只需修改这里即可切换域名/path。
|
||||
@@ -28,10 +31,16 @@
|
||||
#define KB_UL_LOGIN KB_UL_BASE @"/login"
|
||||
#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
|
||||
#if DEBUG
|
||||
#define KB_URL_BRIDGE_ENABLE 1
|
||||
#else
|
||||
#define KB_URL_BRIDGE_ENABLE 1
|
||||
#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(小写) */
|
||||
"letter_g_lower" = "key_g";
|
||||
/* 字母 G(大写) */
|
||||
"letter_g_upper" = "key_f_up";
|
||||
"letter_g_upper" = "key_g_up";
|
||||
|
||||
/* 字母 h(小写) */
|
||||
"letter_h_lower" = "key_h";
|
||||
@@ -242,7 +242,7 @@
|
||||
/* 自定义 AI 功能键 */
|
||||
"ai" = "key_ai";
|
||||
/* Emoji功能键 */
|
||||
"emoji" = "key_emoji";
|
||||
//"emoji" = "key_emoji";
|
||||
"emoji_panel" = "key_emoji";
|
||||
/* 发送/换行键 */
|
||||
"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(小写) */
|
||||
"letter_q_lower" = "key_q";
|
||||
/* 字母 Q(大写) */
|
||||
"letter_q_upper" = "key_q";
|
||||
"letter_q_upper" = "key_q_up";
|
||||
|
||||
/* 字母 w(小写) */
|
||||
"letter_w_lower" = "key_w";
|
||||
/* 字母 W(大写) */
|
||||
"letter_w_upper" = "key_w";
|
||||
"letter_w_upper" = "key_w_up";
|
||||
|
||||
/* 字母 e(小写) */
|
||||
"letter_e_lower" = "key_e";
|
||||
/* 字母 E(大写) */
|
||||
"letter_e_upper" = "key_e";
|
||||
"letter_e_upper" = "key_e_up";
|
||||
|
||||
/* 字母 r(小写) */
|
||||
"letter_r_lower" = "key_r";
|
||||
/* 字母 R(大写) */
|
||||
"letter_r_upper" = "key_r";
|
||||
"letter_r_upper" = "key_r_up";
|
||||
|
||||
/* 字母 t(小写) */
|
||||
"letter_t_lower" = "key_t";
|
||||
/* 字母 T(大写) */
|
||||
"letter_t_upper" = "key_t";
|
||||
"letter_t_upper" = "key_t_up";
|
||||
|
||||
/* 字母 y(小写) */
|
||||
"letter_y_lower" = "key_y";
|
||||
/* 字母 Y(大写) */
|
||||
"letter_y_upper" = "key_y";
|
||||
"letter_y_upper" = "key_y_up";
|
||||
|
||||
/* 字母 u(小写) */
|
||||
"letter_u_lower" = "key_u";
|
||||
/* 字母 U(大写) */
|
||||
"letter_u_upper" = "key_u";
|
||||
"letter_u_upper" = "key_u_up";
|
||||
|
||||
/* 字母 i(小写) */
|
||||
"letter_i_lower" = "key_i";
|
||||
/* 字母 I(大写) */
|
||||
"letter_i_upper" = "key_i";
|
||||
"letter_i_upper" = "key_i_up";
|
||||
|
||||
/* 字母 o(小写) */
|
||||
"letter_o_lower" = "key_o";
|
||||
/* 字母 O(大写) */
|
||||
"letter_o_upper" = "key_o";
|
||||
"letter_o_upper" = "key_o_up";
|
||||
|
||||
/* 字母 p(小写) */
|
||||
"letter_p_lower" = "key_p";
|
||||
/* 字母 P(大写) */
|
||||
"letter_p_upper" = "key_p";
|
||||
"letter_p_upper" = "key_p_up";
|
||||
|
||||
/* 字母 a(小写) */
|
||||
"letter_a_lower" = "key_a";
|
||||
/* 字母 A(大写) */
|
||||
"letter_a_upper" = "key_a";
|
||||
"letter_a_upper" = "key_a_up";
|
||||
|
||||
/* 字母 s(小写) */
|
||||
"letter_s_lower" = "key_s";
|
||||
/* 字母 S(大写) */
|
||||
"letter_s_upper" = "key_s";
|
||||
"letter_s_upper" = "key_s_up";
|
||||
|
||||
/* 字母 d(小写) */
|
||||
"letter_d_lower" = "key_d";
|
||||
/* 字母 D(大写) */
|
||||
"letter_d_upper" = "key_d";
|
||||
"letter_d_upper" = "key_d_up";
|
||||
|
||||
/* 字母 f(小写) */
|
||||
"letter_f_lower" = "key_f";
|
||||
/* 字母 F(大写) */
|
||||
"letter_f_upper" = "key_f";
|
||||
"letter_f_upper" = "key_f_up";
|
||||
|
||||
/* 字母 g(小写) */
|
||||
"letter_g_lower" = "key_g";
|
||||
/* 字母 G(大写) */
|
||||
"letter_g_upper" = "key_g";
|
||||
"letter_g_upper" = "key_g_up";
|
||||
|
||||
/* 字母 h(小写) */
|
||||
"letter_h_lower" = "key_h";
|
||||
/* 字母 H(大写) */
|
||||
"letter_h_upper" = "key_h";
|
||||
"letter_h_upper" = "key_h_up";
|
||||
|
||||
/* 字母 j(小写) */
|
||||
"letter_j_lower" = "key_j";
|
||||
/* 字母 J(大写) */
|
||||
"letter_j_upper" = "key_j";
|
||||
"letter_j_upper" = "key_j_up";
|
||||
|
||||
/* 字母 k(小写) */
|
||||
"letter_k_lower" = "key_k";
|
||||
/* 字母 K(大写) */
|
||||
"letter_k_upper" = "key_k";
|
||||
"letter_k_upper" = "key_k_up";
|
||||
|
||||
/* 字母 l(小写) */
|
||||
"letter_l_lower" = "key_l";
|
||||
/* 字母 L(大写) */
|
||||
"letter_l_upper" = "key_l";
|
||||
"letter_l_upper" = "key_l_up";
|
||||
|
||||
/* 字母 z(小写) */
|
||||
"letter_z_lower" = "key_z";
|
||||
/* 字母 Z(大写) */
|
||||
"letter_z_upper" = "key_z";
|
||||
"letter_z_upper" = "key_z_up";
|
||||
|
||||
/* 字母 x(小写) */
|
||||
"letter_x_lower" = "key_x";
|
||||
/* 字母 X(大写) */
|
||||
"letter_x_upper" = "key_x";
|
||||
"letter_x_upper" = "key_x_up";
|
||||
|
||||
/* 字母 c(小写) */
|
||||
"letter_c_lower" = "key_c";
|
||||
/* 字母 C(大写) */
|
||||
"letter_c_upper" = "key_c";
|
||||
"letter_c_upper" = "key_c_up";
|
||||
|
||||
/* 字母 v(小写) */
|
||||
"letter_v_lower" = "key_v";
|
||||
/* 字母 V(大写) */
|
||||
"letter_v_upper" = "key_v";
|
||||
"letter_v_upper" = "key_v_up";
|
||||
|
||||
/* 字母 b(小写) */
|
||||
"letter_b_lower" = "key_b";
|
||||
/* 字母 B(大写) */
|
||||
"letter_b_upper" = "key_b";
|
||||
"letter_b_upper" = "key_b_up";
|
||||
|
||||
/* 字母 n(小写) */
|
||||
"letter_n_lower" = "key_n";
|
||||
/* 字母 N(大写) */
|
||||
"letter_n_upper" = "key_n";
|
||||
"letter_n_upper" = "key_n_up";
|
||||
|
||||
/* 字母 m(小写) */
|
||||
"letter_m_lower" = "key_m";
|
||||
/* 字母 M(大写) */
|
||||
"letter_m_upper" = "key_m";
|
||||
"letter_m_upper" = "key_m_up";
|
||||
|
||||
/* 数字 1 */
|
||||
"digit_1" = "key_1";
|
||||
@@ -229,6 +232,8 @@
|
||||
"backspace" = "key_del";
|
||||
/* Shift(⇧) */
|
||||
"shift" = "key_up";
|
||||
/* Shift(⇧)大写 */
|
||||
"shift_upper" = "key_up_upper";
|
||||
/* 字母面板左下角 "123" */
|
||||
"mode_123" = "key_123";
|
||||
/* 数字面板左下角 "abc" */
|
||||
@@ -239,6 +244,7 @@
|
||||
"symbols_toggle_123" = "key_symbols_123";
|
||||
/* 自定义 AI 功能键 */
|
||||
"ai" = "key_ai";
|
||||
/* Emoji功能键 */
|
||||
"emoji_panel" = "key_emoji";
|
||||
/* 发送/换行键 */
|
||||
"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;
|
||||
|
||||
/// 配置删除按钮(包含长按删除;可选是否显示“立刻清空”提示)
|
||||
/// 配置删除按钮(包含长按删除;可选是否显示“上滑清空”提示)
|
||||
- (void)bindDeleteButton:(nullable UIView *)button showClearLabel:(BOOL)showClearLabel;
|
||||
|
||||
/// 触发“立刻清空”逻辑(可用于功能面板的清空按钮)
|
||||
/// 触发“上滑清空”逻辑(可用于功能面板的清空按钮)
|
||||
- (void)performClearAction;
|
||||
|
||||
@end
|
||||
|
||||
@@ -7,24 +7,25 @@
|
||||
#import "KBResponderUtils.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
|
||||
static const NSTimeInterval kKBBackspaceLongPressMinDuration = 0.35;
|
||||
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 kKBBackspaceChunkFastDelay = 1.4;
|
||||
static const NSInteger kKBBackspaceChunkSize = 6;
|
||||
static const NSInteger kKBBackspaceChunkSizeFast = 12;
|
||||
static const NSTimeInterval kKBBackspaceChunkFastDelay = 1.2;
|
||||
static const NSInteger kKBBackspaceChunkSize = 8;
|
||||
static const NSInteger kKBBackspaceChunkSizeFast = 16;
|
||||
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 kKBBackspaceClearLabelTopGap = 6.0;
|
||||
static const CGFloat kKBBackspaceClearLabelHorizontalInset = 6.0;
|
||||
static const NSInteger kKBBackspaceClearBatchSize = 24;
|
||||
static const NSTimeInterval kKBBackspaceClearBatchInterval = 0.005;
|
||||
static const NSTimeInterval kKBBackspaceClearBatchInterval = 0.02;
|
||||
static const NSInteger kKBBackspaceClearMaxDeletes = 10000;
|
||||
static const NSInteger kKBBackspaceClearEmptyContextMaxRounds = 40;
|
||||
static const NSInteger kKBBackspaceClearMaxStep = 80;
|
||||
static const NSInteger kKBBackspaceClearDeletesPerTick = 10;
|
||||
|
||||
typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
KBBackspaceChunkClassUnknown = 0,
|
||||
@@ -34,6 +35,12 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
KBBackspaceChunkClassOther
|
||||
};
|
||||
|
||||
typedef NS_ENUM(NSInteger, KBClearPhase) {
|
||||
KBClearPhaseSkipWhitespace = 0,
|
||||
KBClearPhaseSkipTrailingBoundary,
|
||||
KBClearPhaseDeleteUntilBoundary
|
||||
};
|
||||
|
||||
@interface KBBackspaceLongPressHandler ()
|
||||
@property (nonatomic, weak) UIView *containerView;
|
||||
@property (nonatomic, weak) UIView *backspaceButton;
|
||||
@@ -48,6 +55,9 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
@property (nonatomic, assign) CGPoint backspaceLastTouchPointInSelf;
|
||||
@property (nonatomic, assign) NSUInteger backspaceClearToken;
|
||||
@property (nonatomic, strong) UILabel *backspaceClearLabel;
|
||||
@property (nonatomic, copy) NSString *pendingClearBefore;
|
||||
@property (nonatomic, copy) NSString *pendingClearAfter;
|
||||
@property (nonatomic, assign) KBClearPhase backspaceClearPhase;
|
||||
@end
|
||||
|
||||
@implementation KBBackspaceLongPressHandler
|
||||
@@ -55,6 +65,7 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
- (instancetype)initWithContainerView:(UIView *)containerView {
|
||||
if (self = [super init]) {
|
||||
_containerView = containerView;
|
||||
_backspaceClearPhase = KBClearPhaseSkipWhitespace;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
@@ -73,6 +84,8 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
self.backspaceHasLastTouchPoint = NO;
|
||||
self.backspaceHoldToken += 1;
|
||||
[self kb_hideBackspaceClearLabel];
|
||||
self.pendingClearBefore = nil;
|
||||
self.pendingClearAfter = nil;
|
||||
|
||||
if (!button) { return; }
|
||||
|
||||
@@ -99,7 +112,18 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
}
|
||||
switch (gr.state) {
|
||||
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;
|
||||
NSUInteger token = self.backspaceHoldToken;
|
||||
self.backspaceHoldActive = YES;
|
||||
@@ -134,6 +158,7 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
if (!ivc) { self.backspaceHoldActive = NO; return; }
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||
if (before.length == 0) { before = [KBInputBufferManager shared].liveText ?: @""; }
|
||||
NSTimeInterval elapsed = [NSDate date].timeIntervalSinceReferenceDate - self.backspaceHoldStartTime;
|
||||
NSInteger deleteCount = 1;
|
||||
if (before.length > 0) {
|
||||
@@ -145,9 +170,8 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
[self kb_showBackspaceClearLabelIfNeeded];
|
||||
}
|
||||
}
|
||||
for (NSInteger i = 0; i < deleteCount; i++) {
|
||||
[proxy deleteBackward];
|
||||
}
|
||||
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:(NSUInteger)deleteCount];
|
||||
[[KBInputBufferManager shared] applyHoldDeleteCount:(NSUInteger)deleteCount];
|
||||
|
||||
NSTimeInterval interval = [self kb_backspaceRepeatIntervalForElapsed:elapsed];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
@@ -186,34 +210,77 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||
asciiWordSet = [NSCharacterSet characterSetWithCharactersInString:
|
||||
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"];
|
||||
punctuationSet = [NSCharacterSet punctuationCharacterSet];
|
||||
NSMutableCharacterSet *punct = [[NSCharacterSet punctuationCharacterSet] mutableCopy];
|
||||
// 补齐常见中文/全角标点(避免 chunk 总是只删 1 个符号)
|
||||
[punct addCharactersInString:@",。!?;:、()【】《》“”‘’·…—"];
|
||||
punctuationSet = [punct copy];
|
||||
});
|
||||
|
||||
__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)
|
||||
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
||||
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
||||
if (substring.length == 0) { return; }
|
||||
KBBackspaceChunkClass currentClass = KBBackspaceChunkClassOther;
|
||||
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) {
|
||||
if (deleteCount >= maxCount) {
|
||||
*stop = YES;
|
||||
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) {
|
||||
*stop = YES;
|
||||
return;
|
||||
}
|
||||
}];
|
||||
|
||||
@@ -222,13 +289,16 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
|
||||
- (NSInteger)kb_clearDeleteCountForContext:(NSString *)context
|
||||
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 *whitespaceSet = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
|
||||
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?"];
|
||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||
});
|
||||
|
||||
@@ -303,6 +373,12 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
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.backspaceChunkModeActive = NO;
|
||||
self.backspaceHoldToken += 1;
|
||||
@@ -310,7 +386,13 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
[self kb_hideBackspaceClearLabel];
|
||||
if (shouldClear) {
|
||||
[self kb_clearAllInput];
|
||||
} else {
|
||||
self.pendingClearBefore = nil;
|
||||
self.pendingClearAfter = nil;
|
||||
[[KBInputBufferManager shared] clearPendingClearSnapshot];
|
||||
[[KBInputBufferManager shared] commitLiveToManual];
|
||||
}
|
||||
[self kb_refreshSuggestionsAfterLongPressClear:shouldClear];
|
||||
}
|
||||
|
||||
#pragma mark - Clear Label
|
||||
@@ -401,9 +483,9 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
- (UILabel *)backspaceClearLabel {
|
||||
if (!_backspaceClearLabel) {
|
||||
UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
|
||||
label.text = @"立刻清空";
|
||||
label.text = KBLocalized(@"Clear");
|
||||
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.backgroundColor = [self kb_backspaceClearLabelNormalColor];
|
||||
label.layer.cornerRadius = kKBBackspaceClearLabelCornerRadius;
|
||||
@@ -418,13 +500,18 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
#pragma mark - Clear
|
||||
|
||||
- (void)kb_clearAllInput {
|
||||
[self kb_clearCurrentWordIfPossible];
|
||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||
if (ivc) {
|
||||
NSString *before = ivc.textDocumentProxy.documentContextBeforeInput ?: @"";
|
||||
[[KBBackspaceUndoManager shared] recordClearWithContext:before];
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
[[KBInputBufferManager shared] refreshFromProxyIfPossible:proxy];
|
||||
}
|
||||
self.pendingClearBefore = nil;
|
||||
self.pendingClearAfter = nil;
|
||||
[[KBInputBufferManager shared] clearPendingClearSnapshot];
|
||||
self.backspaceClearToken += 1;
|
||||
self.backspaceClearPhase = KBClearPhaseSkipWhitespace;
|
||||
NSUInteger token = self.backspaceClearToken;
|
||||
[self kb_clearAllInputStepForToken:token guard:0 emptyRounds:0];
|
||||
}
|
||||
@@ -437,40 +524,101 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||
if (!ivc) { return; }
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||
NSInteger count = before.length;
|
||||
NSInteger batch = 0;
|
||||
NSInteger nextEmptyRounds = emptyRounds;
|
||||
BOOL hitBoundary = NO;
|
||||
if (count > 0) {
|
||||
batch = [self kb_clearDeleteCountForContext:before hitBoundary:&hitBoundary];
|
||||
nextEmptyRounds = 0;
|
||||
} else {
|
||||
batch = kKBBackspaceClearBatchSize;
|
||||
nextEmptyRounds = emptyRounds + 1;
|
||||
}
|
||||
if (batch <= 0) { batch = 1; }
|
||||
static NSCharacterSet *stopBoundarySet = nil;
|
||||
static NSCharacterSet *trailingBoundarySet = nil;
|
||||
static NSCharacterSet *trailingWhitespaceSet = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
// stopBoundary: 遇到这些符号就停(不删除它)
|
||||
// - 句末符号:. ! ? 。!?
|
||||
// - 省略号:…(中文里“……”常用作句/段落的停顿)
|
||||
// - 换行:\n \r(段落边界,避免一次“清空”跨段把全文删完)
|
||||
stopBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?…\n\r\u2028\u2029"];
|
||||
|
||||
if (guard >= kKBBackspaceClearMaxDeletes ||
|
||||
nextEmptyRounds > kKBBackspaceClearEmptyContextMaxRounds) {
|
||||
// trailingBoundary: 允许作为“尾部句末符号”先删掉,再继续删上一句(更接近微信体验)
|
||||
// 注意:不要把换行/省略号放进来,否则可能跨段/跨停顿继续删。
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
||||
(int64_t)(kKBBackspaceClearBatchInterval * NSEC_PER_SEC)),
|
||||
@@ -489,4 +637,60 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
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
|
||||
|
||||
@@ -15,13 +15,19 @@ extern NSNotificationName const KBBackspaceUndoStateDidChangeNotification;
|
||||
|
||||
+ (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 处执行撤销(向光标处插回删除的内容)
|
||||
- (void)performUndoFromResponder:(UIResponder *)responder;
|
||||
|
||||
/// 非清空行为触发时,清理撤销状态
|
||||
/// 非删除行为触发时,清理撤销状态
|
||||
- (void)registerNonClearAction;
|
||||
|
||||
@end
|
||||
|
||||
@@ -5,13 +5,38 @@
|
||||
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBResponderUtils.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
|
||||
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 ()
|
||||
@property (nonatomic, strong) NSMutableArray<NSString *> *segments; // deletion order (last -> first)
|
||||
@property (nonatomic, assign) BOOL lastActionWasClear;
|
||||
@property (nonatomic, copy) NSString *undoText;
|
||||
@property (nonatomic, assign) NSInteger undoAfterLength;
|
||||
@property (nonatomic, assign) BOOL hasUndo;
|
||||
@property (nonatomic, assign) KBUndoSnapshotSource snapshotSource;
|
||||
@property (nonatomic, strong) NSMutableArray<NSString *> *undoDeletedPieces;
|
||||
@end
|
||||
|
||||
@implementation KBBackspaceUndoManager
|
||||
@@ -27,42 +52,191 @@ NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspa
|
||||
|
||||
- (instancetype)init {
|
||||
if (self = [super init]) {
|
||||
_segments = [NSMutableArray array];
|
||||
_undoText = @"";
|
||||
_undoAfterLength = 0;
|
||||
_snapshotSource = KBUndoSnapshotSourceNone;
|
||||
_undoDeletedPieces = [NSMutableArray array];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)recordClearWithContext:(NSString *)context {
|
||||
if (context.length == 0) { return; }
|
||||
NSString *segment = [self kb_segmentForClearFromContext:context];
|
||||
if (segment.length == 0) { return; }
|
||||
- (void)captureAndDeleteBackwardFromProxy:(id<UITextDocumentProxy>)proxy count:(NSUInteger)count {
|
||||
if (!proxy || count == 0) { return; }
|
||||
|
||||
if (!self.lastActionWasClear) {
|
||||
[self.segments removeAllObjects];
|
||||
NSString *selected = proxy.selectedText ?: @"";
|
||||
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];
|
||||
}
|
||||
|
||||
- (void)performUndoFromResponder:(UIResponder *)responder {
|
||||
if (self.segments.count == 0) { return; }
|
||||
if (!self.hasUndo) { return; }
|
||||
UIInputViewController *ivc = KBFindInputViewController(responder);
|
||||
if (!ivc) { return; }
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
NSString *text = [self kb_buildUndoText];
|
||||
if (text.length == 0) { return; }
|
||||
[proxy insertText:text];
|
||||
|
||||
[self.segments removeAllObjects];
|
||||
self.lastActionWasClear = NO;
|
||||
NSString *curBefore = proxy.documentContextBeforeInput ?: @"";
|
||||
NSString *curAfter = proxy.documentContextAfterInput ?: @"";
|
||||
KB_UNDO_LOG(@"performUndo/currentBefore", curBefore);
|
||||
KB_UNDO_LOG(@"performUndo/currentAfter", curAfter);
|
||||
NSString *insertText = [self kb_buildUndoInsertTextFromPieces];
|
||||
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];
|
||||
}
|
||||
|
||||
- (void)registerNonClearAction {
|
||||
self.lastActionWasClear = NO;
|
||||
if (self.segments.count == 0) { return; }
|
||||
[self.segments removeAllObjects];
|
||||
if (!self.hasUndo) { return; }
|
||||
if (self.undoText.length > 0) {
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -74,97 +248,57 @@ NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspa
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:KBBackspaceUndoStateDidChangeNotification object:self];
|
||||
}
|
||||
|
||||
- (NSString *)kb_segmentForClearFromContext:(NSString *)context {
|
||||
NSInteger length = context.length;
|
||||
if (length == 0) { return @""; }
|
||||
- (NSString *)kb_lastComposedCharacterFromString:(NSString *)text {
|
||||
if (text.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;
|
||||
static NSCharacterSet *whitespaceSet = nil;
|
||||
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];
|
||||
- (NSString *)kb_buildUndoInsertTextFromPieces {
|
||||
if (self.undoDeletedPieces.count == 0) { return @""; }
|
||||
NSMutableString *result = [NSMutableString string];
|
||||
for (NSInteger i = 0; i < ordered.count; i++) {
|
||||
NSString *segment = ordered[i] ?: @"";
|
||||
if (segment.length == 0) { continue; }
|
||||
if (i < ordered.count - 1) {
|
||||
segment = [self kb_replaceTrailingBoundaryWithComma:segment];
|
||||
}
|
||||
[result appendString:segment];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
for (NSInteger i = (NSInteger)self.undoDeletedPieces.count - 1; i >= 0; i--) {
|
||||
NSString *piece = self.undoDeletedPieces[(NSUInteger)i] ?: @"";
|
||||
if (piece.length == 0) { continue; }
|
||||
[result appendString:piece];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
- (NSString *)kb_replaceTrailingBoundaryWithComma:(NSString *)segment {
|
||||
if (segment.length == 0) { return segment; }
|
||||
static const NSInteger kKBUndoClearMaxRounds = 200;
|
||||
|
||||
static NSCharacterSet *boundarySet = nil;
|
||||
static NSCharacterSet *englishBoundarySet = nil;
|
||||
static NSCharacterSet *whitespaceSet = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
boundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
|
||||
englishBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;"];
|
||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||
});
|
||||
- (void)kb_clearAllTextForProxy:(id<UITextDocumentProxy>)proxy {
|
||||
if (!proxy) { return; }
|
||||
|
||||
NSInteger idx = segment.length - 1;
|
||||
while (idx >= 0) {
|
||||
unichar ch = [segment characterAtIndex:idx];
|
||||
if ([whitespaceSet characterIsMember:ch]) {
|
||||
idx -= 1;
|
||||
continue;
|
||||
if ([proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) {
|
||||
NSInteger guard = 0;
|
||||
NSString *contextAfter = proxy.documentContextAfterInput ?: @"";
|
||||
while (contextAfter.length > 0 && guard < kKBUndoClearMaxRounds) {
|
||||
NSInteger offset = (NSInteger)contextAfter.length;
|
||||
[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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// KBExtensionAppLauncher.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// 封装:在键盘扩展中拉起主 App(Scheme / Universal Link + 响应链兜底)。
|
||||
// 封装:在键盘扩展中拉起主 App(Scheme / Universal Link)。
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
@@ -12,23 +12,24 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
@interface KBExtensionAppLauncher : NSObject
|
||||
|
||||
/// 通用入口:优先尝试 primaryURL,失败后尝试 fallbackURL,
|
||||
/// 两者都失败时再通过响应链(openURL:)做兜底。
|
||||
/// 均通过 `extensionContext openURL` 发起跳转(避免使用扩展禁用 API/响应链绕行)。
|
||||
/// 若开启 `KB_URL_BRIDGE_ENABLE=1`,会在两次 `extensionContext openURL` 均失败时,
|
||||
/// - Parameters:
|
||||
/// - primaryURL: 第一优先尝试的 URL(可为 Scheme 或 UL)
|
||||
/// - fallbackURL: 失败时的备用 URL(可为 nil)
|
||||
/// - ivc: 当前的 UIInputViewController(用于 extensionContext openURL)
|
||||
/// - source: 兜底时用作起点的 responder(通常传 self 或 self.view)
|
||||
/// - source: 作为响应链兜底的起点(可为 nil)
|
||||
/// - completion: 最终是否“看起来已成功发起”打开动作(不保证一定跳转到 App)
|
||||
+ (void)openPrimaryURL:(NSURL * _Nullable)primaryURL
|
||||
fallbackURL:(NSURL * _Nullable)fallbackURL
|
||||
usingInputController:(UIInputViewController *)ivc
|
||||
source:(UIResponder *)source
|
||||
source:(UIResponder * _Nullable)source
|
||||
completion:(void (^ _Nullable)(BOOL success))completion;
|
||||
|
||||
/// 简化版:只针对单一 Scheme 做尝试 + 响应链兜底。
|
||||
/// 简化版:只针对单一 Scheme 做尝试。
|
||||
+ (void)openScheme:(NSURL *)scheme
|
||||
usingInputController:(UIInputViewController *)ivc
|
||||
source:(UIResponder *)source
|
||||
source:(UIResponder * _Nullable)source
|
||||
completion:(void (^ _Nullable)(BOOL success))completion;
|
||||
|
||||
@end
|
||||
|
||||
@@ -4,15 +4,86 @@
|
||||
//
|
||||
|
||||
#import "KBExtensionAppLauncher.h"
|
||||
|
||||
#if KB_URL_BRIDGE_ENABLE
|
||||
#import <objc/message.h>
|
||||
#endif
|
||||
|
||||
@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
|
||||
fallbackURL:(NSURL * _Nullable)fallbackURL
|
||||
usingInputController:(UIInputViewController *)ivc
|
||||
source:(UIResponder *)source
|
||||
source:(UIResponder * _Nullable)source
|
||||
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 (completion) { completion(NO); }
|
||||
return;
|
||||
@@ -48,19 +119,37 @@
|
||||
finish(YES);
|
||||
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 {
|
||||
BOOL bridged = [self p_bridgeFirst:first second:nil from:source];
|
||||
finish(bridged);
|
||||
#if KB_URL_BRIDGE_ENABLE
|
||||
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
|
||||
usingInputController:(UIInputViewController *)ivc
|
||||
source:(UIResponder *)source
|
||||
source:(UIResponder * _Nullable)source
|
||||
completion:(void (^ _Nullable)(BOOL success))completion {
|
||||
[self openPrimaryURL:scheme
|
||||
fallbackURL:nil
|
||||
@@ -69,53 +158,4 @@
|
||||
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
|
||||
|
||||
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
|
||||
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view;
|
||||
- (void)subscriptionView:(KBKeyboardSubscriptionView *)view didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product;
|
||||
- (void)subscriptionViewDidTapAgreement:(KBKeyboardSubscriptionView *)view;
|
||||
@end
|
||||
|
||||
/// 键盘内的订阅弹层
|
||||
|
||||
@@ -157,7 +157,7 @@ static id KBKeyboardSubscriptionSanitizeJSON(id obj) {
|
||||
|
||||
- (void)setupFeatureItems {
|
||||
NSArray *titles = @[
|
||||
KBLocalized(@"Wireless Sub-ai\nDialogue"),
|
||||
KBLocalized(@"Wireless Sub-ai Dialogue"),
|
||||
KBLocalized(@"Personalized\nKeyboard"),
|
||||
KBLocalized(@"Chat\nPersona"),
|
||||
KBLocalized(@"Emotional\nCounseling")
|
||||
@@ -192,7 +192,11 @@ static id KBKeyboardSubscriptionSanitizeJSON(id obj) {
|
||||
}
|
||||
|
||||
- (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
|
||||
@@ -200,7 +204,7 @@ static id KBKeyboardSubscriptionSanitizeJSON(id obj) {
|
||||
- (void)fetchProducts {
|
||||
if (self.isLoading) { return; }
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||
[KBHUD showInfo:KBLocalized(@"Enable Full Access to continue")];
|
||||
[KBHUD showInfo:KBLocalized(@"Please enable Full Access to continue")];
|
||||
return;
|
||||
}
|
||||
self.loading = YES;
|
||||
@@ -405,7 +409,7 @@ static id KBKeyboardSubscriptionSanitizeJSON(id obj) {
|
||||
- (UILabel *)agreementLabel {
|
||||
if (!_agreementLabel) {
|
||||
_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.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>
|
||||
- (void)emojiPanelView:(KBEmojiPanelView *)panel didSelectEmoji:(NSString *)emoji;
|
||||
- (void)emojiPanelViewDidRequestClose:(KBEmojiPanelView *)panel;
|
||||
- (void)emojiPanelViewDidTapSearch:(KBEmojiPanelView *)panel;
|
||||
@optional
|
||||
- (void)emojiPanelViewDidTapDelete:(KBEmojiPanelView *)panel;
|
||||
@end
|
||||
@@ -30,6 +29,9 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
/// 高亮指定分类
|
||||
- (void)selectCategoryAtIndex:(NSInteger)index;
|
||||
|
||||
/// 释放 emoji 数据缓存(隐藏面板时可用)
|
||||
- (void)purgeEmojiCache;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
@property (nonatomic, strong) UIButton *backButton;
|
||||
@property (nonatomic, strong) UICollectionView *collectionView;
|
||||
@property (nonatomic, strong) KBEmojiBottomBarView *bottomBar;
|
||||
//@property (nonatomic, strong) UIButton *searchButton;
|
||||
@property (nonatomic, strong) NSArray<UIButton *> *tabButtons;
|
||||
@property (nonatomic, strong) KBEmojiDataProvider *dataProvider;
|
||||
@property (nonatomic, copy) NSArray<KBEmojiCategory *> *categories;
|
||||
@@ -100,14 +99,6 @@
|
||||
[self addSubview:self.bottomBar];
|
||||
[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) {
|
||||
make.left.equalTo(self.mas_left).offset(12);
|
||||
make.right.equalTo(self.mas_right).offset(-12);
|
||||
@@ -185,6 +176,15 @@
|
||||
[self updateSelectionToIndex:preserved];
|
||||
}
|
||||
|
||||
- (void)purgeEmojiCache {
|
||||
[self.dataProvider purgeLargeCaches];
|
||||
self.categories = @[];
|
||||
self.currentIndex = NSNotFound;
|
||||
self.titleLabel.text = @"";
|
||||
[self rebuildTabButtons];
|
||||
[self.collectionView reloadData];
|
||||
}
|
||||
|
||||
- (void)rebuildTabButtons {
|
||||
UIStackView *stackView = self.bottomBar.tabStackView;
|
||||
if (!stackView) { return; }
|
||||
@@ -260,12 +260,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onSearch {
|
||||
if ([self.delegate respondsToSelector:@selector(emojiPanelViewDidTapSearch:)]) {
|
||||
[self.delegate emojiPanelViewDidTapSearch:self];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onDelete {
|
||||
if ([self.delegate respondsToSelector:@selector(emojiPanelViewDidTapDelete:)]) {
|
||||
[self.delegate emojiPanelViewDidTapDelete:self];
|
||||
@@ -294,7 +288,6 @@
|
||||
}
|
||||
|
||||
- (void)onLocalizationChanged {
|
||||
// [self.searchButton setTitle:KBLocalized(@"Search") forState:UIControlStateNormal];
|
||||
[self reloadData];
|
||||
}
|
||||
|
||||
@@ -305,8 +298,6 @@
|
||||
self.backgroundColor = bg;
|
||||
self.collectionView.backgroundColor = [UIColor clearColor];
|
||||
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.tabSelectedColor = theme.accentColor ?: [UIColor colorWithWhite:1 alpha:0.25];
|
||||
[self updateTabHighlightStates];
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#import "KBFunctionTagListView.h"
|
||||
#import "KBFunctionTagCell.h"
|
||||
#import "KBMaiPointReporter.h"
|
||||
|
||||
static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2";
|
||||
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; }
|
||||
|
||||
- (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:)]) {
|
||||
KBTagItemModel *model = (indexPath.item < self.items.count) ? self.items[indexPath.item] : [KBTagItemModel new];
|
||||
[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 "KBResponderUtils.h" // 统一查找 UIInputViewController 的工具
|
||||
#import "KBHUD.h"
|
||||
#import "KBHostAppLauncher.h"
|
||||
#import "../Utils/KBExtensionAppLauncher.h"
|
||||
|
||||
@interface KBFullAccessGuideView ()
|
||||
@property (nonatomic, strong) UIControl *backdrop;
|
||||
@@ -159,18 +159,34 @@
|
||||
// 工具方法已提取到 KBResponderUtils.h
|
||||
// 打开主 App,引导用户去系统设置开启完全访问:通过宿主 UIApplication + 自定义 Scheme 拉起。
|
||||
- (void)onTapGoEnable {
|
||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
||||
// 找不到键盘控制器也可以尝试从自身 responder 链出发
|
||||
UIResponder *start = ivc.view ?: (UIResponder *)self;
|
||||
|
||||
// 自定义 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];
|
||||
UIInputViewController *ivc = self.ivc ?: KBFindInputViewController(self);
|
||||
if (!ivc) {
|
||||
NSString *showInfo = [NSString stringWithFormat:KBLocalized(@"Follow: Settings -> General -> Keyboard -> Keyboards -> %@ -> Allow Full Access"), AppName];
|
||||
[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
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
@end
|
||||
|
||||
@implementation KBFunctionBarView
|
||||
static const CGFloat kKBBackButtonWidth = 40;
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame{
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
@@ -83,14 +84,14 @@
|
||||
UIButton *appButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
appButton.tag = 100; // 左侧 index = 0
|
||||
UIImage *appImage = [UIImage imageNamed:@"App_icon"];
|
||||
[appButton setImage:appImage forState:UIControlStateNormal];
|
||||
[appButton setBackgroundImage:appImage forState:UIControlStateNormal];
|
||||
appButton.imageView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
appButton.adjustsImageWhenHighlighted = YES;
|
||||
[appButton addTarget:self action:@selector(onLeftTap:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.leftContainer addSubview:appButton];
|
||||
[appButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self.leftContainer);
|
||||
make.width.height.mas_equalTo(34); // 设计图尺寸
|
||||
make.width.height.mas_equalTo(kKBBackButtonWidth); // 设计图尺寸
|
||||
}];
|
||||
self.leftButtonsInternal = @[appButton];
|
||||
|
||||
|
||||