Compare commits
84 Commits
a83fd918a8
...
dev_st
| 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 |
@@ -2,7 +2,16 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebSearch",
|
||||
"Bash(git checkout:*)"
|
||||
"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
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/
|
||||
@@ -7,7 +7,7 @@
|
||||
<string>kbkeyboardAppExtension</string>
|
||||
</array>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要使用麦克风进行语音输入</string>
|
||||
<string>Microphone access is required for voice input and speech transcription.</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
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
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
|
||||
@@ -7,9 +7,22 @@
|
||||
|
||||
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.
|
||||
@@ -18,6 +31,9 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
/// Record a selection to slightly boost ranking next time.
|
||||
- (void)recordSelection:(NSString *)word;
|
||||
|
||||
/// 设置联想引擎类型(根据 profileId 的 suggestionEngine 字段)
|
||||
- (void)setEngineTypeFromString:(NSString *)engineTypeString;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
@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
|
||||
@@ -25,49 +33,51 @@
|
||||
|
||||
- (instancetype)init {
|
||||
if (self = [super init]) {
|
||||
_engineType = KBSuggestionEngineTypeLatin;
|
||||
_selectionCounts = [NSMutableDictionary dictionary];
|
||||
NSArray<NSString *> *defaults = [self.class kb_defaultWords];
|
||||
_priorityWords = [NSSet setWithArray:defaults];
|
||||
_words = [self kb_loadWords];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (NSArray<NSString *> *)suggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit {
|
||||
if (prefix.length == 0 || limit == 0) { return @[]; }
|
||||
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) {
|
||||
// Avoid scanning too many matches for long lists.
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 为过滤留出候选空间,避免过滤后数量过少。
|
||||
NSUInteger fetchLimit = limit;
|
||||
if (fetchLimit < 80) {
|
||||
fetchLimit = MIN((NSUInteger)80, MAX(fetchLimit * 4, fetchLimit));
|
||||
}
|
||||
NSArray<NSString *> *raw = nil;
|
||||
|
||||
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)];
|
||||
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 matches.copy;
|
||||
return [self kb_filterSensitiveSuggestions:raw limit:limit];
|
||||
}
|
||||
|
||||
- (void)recordSelection:(NSString *)word {
|
||||
@@ -164,4 +174,801 @@
|
||||
];
|
||||
}
|
||||
|
||||
#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
|
||||
|
||||
@@ -73,7 +73,11 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
@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
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#import "KBConfig.h"
|
||||
|
||||
static NSString * const kKBKeyboardLayoutConfigFileName = @"kb_keyboard_layout_config";
|
||||
static NSString * const kKBKeyboardLayoutI18nFileName = @"kb_keyboard_layouts_i18n";
|
||||
|
||||
@implementation KBKeyboardLayoutMetrics
|
||||
@end
|
||||
@@ -81,7 +82,7 @@ static NSString * const kKBKeyboardLayoutConfigFileName = @"kb_keyboard_layout_c
|
||||
@implementation KBKeyboardLayout
|
||||
|
||||
+ (NSDictionary *)mj_objectClassInArray {
|
||||
return @{ @"rows": [KBKeyboardRowConfig class] };
|
||||
return @{ @"rows": [KBKeyboardRowConfig class], @"shiftRows": [KBKeyboardRowConfig class] };
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -92,13 +93,110 @@ static NSString * const kKBKeyboardLayoutConfigFileName = @"kb_keyboard_layout_c
|
||||
static KBKeyboardLayoutConfig *config = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
NSString *path = [[NSBundle mainBundle] pathForResource:kKBKeyboardLayoutConfigFileName ofType:@"json"];
|
||||
NSData *data = path.length ? [NSData dataWithContentsOfFile:path] : nil;
|
||||
config = data ? [KBKeyboardLayoutConfig configFromJSONData:data] : nil;
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -31,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
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>
|
||||
Binary file not shown.
Binary file not shown.
262
CustomKeyboard/Resource/KBSkinIconMap_es.strings
Normal file
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";
|
||||
250
CustomKeyboard/Resource/KBSkinIconMap_id.strings
Normal file
250
CustomKeyboard/Resource/KBSkinIconMap_id.strings
Normal file
@@ -0,0 +1,250 @@
|
||||
/* 印尼语键盘皮肤映射 */
|
||||
/* Indonesian 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";
|
||||
250
CustomKeyboard/Resource/KBSkinIconMap_pt.strings
Normal file
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
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";
|
||||
Binary file not shown.
345
CustomKeyboard/Resource/bopomofo_to_chinese.json
Normal file
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
48267
CustomKeyboard/Resource/english_words.json
Normal file
File diff suppressed because it is too large
Load Diff
50005
CustomKeyboard/Resource/indonesian_words.json
Normal file
50005
CustomKeyboard/Resource/indonesian_words.json
Normal file
File diff suppressed because it is too large
Load Diff
80
CustomKeyboard/Resource/kb_diacritics_map.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", "ü", "ǖ", "ǘ", "ǚ", "ǜ"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,9 +189,321 @@
|
||||
},
|
||||
{
|
||||
"__comment": "字母第二行 asdfghjkl",
|
||||
"align": "center",
|
||||
"align": "left",
|
||||
"__comment_align": "对齐方式:left/center",
|
||||
"insetLeft": 0,
|
||||
"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)",
|
||||
@@ -409,6 +721,228 @@
|
||||
"__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
1654
CustomKeyboard/Resource/kb_keyboard_layouts_i18n.json
Normal file
File diff suppressed because it is too large
Load Diff
405
CustomKeyboard/Resource/pinyin_to_traditional.json
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
42269
CustomKeyboard/Resource/portuguese_words.json
Normal file
File diff suppressed because it is too large
Load Diff
54839
CustomKeyboard/Resource/spanish_words.json
Normal file
54839
CustomKeyboard/Resource/spanish_words.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -392,6 +392,7 @@ typedef NS_ENUM(NSInteger, KBClearPhase) {
|
||||
[[KBInputBufferManager shared] clearPendingClearSnapshot];
|
||||
[[KBInputBufferManager shared] commitLiveToManual];
|
||||
}
|
||||
[self kb_refreshSuggestionsAfterLongPressClear:shouldClear];
|
||||
}
|
||||
|
||||
#pragma mark - Clear Label
|
||||
@@ -499,6 +500,7 @@ typedef NS_ENUM(NSInteger, KBClearPhase) {
|
||||
#pragma mark - Clear
|
||||
|
||||
- (void)kb_clearAllInput {
|
||||
[self kb_clearCurrentWordIfPossible];
|
||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||
if (ivc) {
|
||||
@@ -659,4 +661,36 @@ typedef NS_ENUM(NSInteger, KBClearPhase) {
|
||||
#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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
if (completion) {
|
||||
KBChatResponse *response = [[KBChatResponse alloc] init];
|
||||
response.success = NO;
|
||||
response.message = @"内容为空";
|
||||
response.message = KBLocalized(@"Content is empty");
|
||||
completion(response);
|
||||
}
|
||||
return;
|
||||
@@ -101,7 +101,7 @@
|
||||
if (completion) {
|
||||
KBAudioResponse *response = [[KBAudioResponse alloc] init];
|
||||
response.success = NO;
|
||||
response.errorMessage = @"audioId 为空";
|
||||
response.errorMessage = KBLocalized(@"audioId is empty");
|
||||
completion(response);
|
||||
}
|
||||
return;
|
||||
@@ -171,7 +171,7 @@
|
||||
// 达到最大重试次数
|
||||
KBAudioResponse *failResponse = [[KBAudioResponse alloc] init];
|
||||
failResponse.success = NO;
|
||||
failResponse.errorMessage = [NSString stringWithFormat:@"轮询失败,已重试 %ld 次", (long)maxRetries];
|
||||
failResponse.errorMessage = [NSString stringWithFormat:KBLocalized(@"Polling failed after %ld retries"), (long)maxRetries];
|
||||
if (completion) completion(failResponse);
|
||||
}
|
||||
}];
|
||||
@@ -183,7 +183,7 @@
|
||||
if (completion) {
|
||||
KBAudioResponse *response = [[KBAudioResponse alloc] init];
|
||||
response.success = NO;
|
||||
response.errorMessage = @"URL 为空";
|
||||
response.errorMessage = KBLocalized(@"URL is empty");
|
||||
completion(response);
|
||||
}
|
||||
return;
|
||||
@@ -198,7 +198,7 @@
|
||||
|
||||
if (error || !data || data.length == 0) {
|
||||
audioResponse.success = NO;
|
||||
audioResponse.errorMessage = error.localizedDescription ?: @"下载失败";
|
||||
audioResponse.errorMessage = error.localizedDescription ?: KBLocalized(@"Download failed");
|
||||
if (completion) completion(audioResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ static const NSUInteger kKBChatMessageLimit = 10;
|
||||
|
||||
// 创建 AI 消息
|
||||
KBChatMessage *msg = [KBChatMessage assistantMessageWithText:text audioId:audioId];
|
||||
msg.displayName = KBLocalized(@"AI助手");
|
||||
msg.displayName = KBLocalized(@"AI Assistant");
|
||||
NSLog(@"[Panel] 创建 AI 消息,needsTypewriter: %d", msg.needsTypewriterEffect);
|
||||
|
||||
// 使用批量更新,避免界面跳动
|
||||
@@ -323,7 +323,7 @@ static const NSUInteger kKBChatMessageLimit = 10;
|
||||
_titleLabel.textColor =
|
||||
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0x1B1F1A]
|
||||
darkColor:[UIColor whiteColor]];
|
||||
_titleLabel.text = KBLocalized(@"AI对话");
|
||||
_titleLabel.text = KBLocalized(@"AI Chat");
|
||||
}
|
||||
return _titleLabel;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -67,6 +67,8 @@ 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]) {
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
self.audioLabel.textColor = textColor;
|
||||
self.audioIconView.tintColor = textColor;
|
||||
self.audioLabel.text =
|
||||
(message.text.length > 0) ? message.text : KBLocalized(@"语音回复");
|
||||
(message.text.length > 0) ? message.text : KBLocalized(@"Voice reply");
|
||||
self.messageLabel.hidden = audioMessage;
|
||||
self.audioIconView.hidden = !audioMessage;
|
||||
self.audioLabel.hidden = !audioMessage;
|
||||
@@ -94,7 +94,7 @@
|
||||
self.nameLabel.hidden = outgoing;
|
||||
self.nameLabel.textColor = nameColor;
|
||||
self.nameLabel.text =
|
||||
(message.displayName.length > 0) ? message.displayName : KBLocalized(@"AI助手");
|
||||
(message.displayName.length > 0) ? message.displayName : KBLocalized(@"AI Assistant");
|
||||
|
||||
// 处理 loading 状态
|
||||
if (message.isLoading && !outgoing) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
// if (!_placeholderLabelInternal) {
|
||||
// _placeholderLabelInternal = [[UILabel alloc] init];
|
||||
// // 文案改为更贴近设计稿
|
||||
// _placeholderLabelInternal.text = KBLocalized(@"Paste Ta's Words");
|
||||
// _placeholderLabelInternal.textColor = [UIColor colorWithRed:0.20 green:0.64 blue:0.54 alpha:1.0];
|
||||
// _placeholderLabelInternal.font = [UIFont systemFontOfSize:16 weight:UIFontWeightMedium];
|
||||
// }
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
#import "KBFunctionPasteView.h"
|
||||
#import "KBFunctionTagCell.h"
|
||||
#import "KBFunctionTagListView.h"
|
||||
#import "KBHostAppLauncher.h"
|
||||
#import "KBExtensionAppLauncher.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
#import "KBResponderUtils.h" // 统一查找 UIInputViewController 的工具
|
||||
#import "KBSignUtils.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBStreamOverlayView.h" // 带关闭按钮的流式层
|
||||
#import "KBStreamTextView.h" // 流式文本视图
|
||||
@@ -141,76 +142,26 @@
|
||||
|
||||
/// 整体背景色:暗黑 #323232,浅色 #D0D3DA
|
||||
+ (UIColor *)kb_backgroundColor {
|
||||
if (@available(iOS 13.0, *)) {
|
||||
return [UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
|
||||
UITraitCollection *_Nonnull traitCollection) {
|
||||
if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
|
||||
return [UIColor colorWithHex:0x2B2B2B];
|
||||
} else {
|
||||
return [UIColor colorWithHex:0xD0D3DA];
|
||||
}
|
||||
}];
|
||||
}
|
||||
return [UIColor colorWithHex:0xD0D3DA];
|
||||
}
|
||||
|
||||
/// Cell 背景色:暗黑 #707070,浅色 白色90%透明度
|
||||
+ (UIColor *)kb_cellBackgroundColor {
|
||||
if (@available(iOS 13.0, *)) {
|
||||
return [UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
|
||||
UITraitCollection *_Nonnull traitCollection) {
|
||||
if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
|
||||
return [UIColor colorWithHex:0x707070];
|
||||
} else {
|
||||
return [UIColor colorWithWhite:1 alpha:0.9];
|
||||
}
|
||||
}];
|
||||
}
|
||||
return [UIColor colorWithWhite:1 alpha:0.9];
|
||||
}
|
||||
|
||||
/// Cell 文字颜色:暗黑 #FFFFFF,浅色 #1B1F1A
|
||||
+ (UIColor *)kb_cellTextColor {
|
||||
if (@available(iOS 13.0, *)) {
|
||||
return [UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
|
||||
UITraitCollection *_Nonnull traitCollection) {
|
||||
if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
|
||||
return [UIColor whiteColor];
|
||||
} else {
|
||||
return [UIColor colorWithHex:0x1B1F1A];
|
||||
}
|
||||
}];
|
||||
}
|
||||
return [UIColor colorWithHex:0x1B1F1A];
|
||||
}
|
||||
|
||||
/// Clear 按钮文字颜色:暗黑白色,浅色黑色
|
||||
+ (UIColor *)kb_clearButtonTextColor {
|
||||
if (@available(iOS 13.0, *)) {
|
||||
return [UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
|
||||
UITraitCollection *_Nonnull traitCollection) {
|
||||
if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
|
||||
return [UIColor whiteColor];
|
||||
} else {
|
||||
return [UIColor blackColor];
|
||||
}
|
||||
}];
|
||||
}
|
||||
return [UIColor blackColor];
|
||||
}
|
||||
|
||||
/// 删除按钮背景色:暗黑 #707070,浅色 #B9BDC8
|
||||
+ (UIColor *)kb_deleteButtonBackgroundColor {
|
||||
if (@available(iOS 13.0, *)) {
|
||||
return [UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
|
||||
UITraitCollection *_Nonnull traitCollection) {
|
||||
if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) {
|
||||
return [UIColor colorWithHex:0x707070];
|
||||
} else {
|
||||
return [UIColor colorWithHex:0xB9BDC8];
|
||||
}
|
||||
}];
|
||||
}
|
||||
return [UIColor colorWithHex:0xB9BDC8];
|
||||
}
|
||||
|
||||
@@ -326,13 +277,8 @@
|
||||
// self.itemsInternal = @[KBLocalized(@"Warm hearted man"),
|
||||
// KBLocalized(@"Warm2 hearted man"),
|
||||
// KBLocalized(@"Warm3 hearted man"),
|
||||
// KBLocalized(@"撩女生啊u发顺丰大师傅"),
|
||||
// KBLocalized(@"Warm = man"),
|
||||
// KBLocalized(@"Warm hearted man"),
|
||||
// KBLocalized(@"一枚暖男发放"),
|
||||
// KBLocalized(@"聊天搭子"),
|
||||
// KBLocalized(@"表达爱意"),
|
||||
// KBLocalized(@"更多话术")];
|
||||
// [self.tagListView setItems:self.itemsInternal];
|
||||
//}
|
||||
|
||||
@@ -435,11 +381,32 @@
|
||||
cachePolicy:NSURLRequestReloadIgnoringCacheData
|
||||
timeoutInterval:60];
|
||||
request.HTTPMethod = @"POST";
|
||||
[request setValue:@"text/event-stream" forHTTPHeaderField:@"Accept"];
|
||||
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
||||
// 签名头(后端要求,否则会 401:Missing sign headers)
|
||||
NSDictionary<NSString *, NSString *> *signHeaders =
|
||||
[KBSignUtils signHeadersWithBodyParams:payload];
|
||||
[signHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj,
|
||||
BOOL *stop) {
|
||||
if (key.length == 0 || obj.length == 0) {
|
||||
return;
|
||||
}
|
||||
[request setValue:obj forHTTPHeaderField:key];
|
||||
}];
|
||||
NSString *token = KBAuthManager.shared.current.accessToken ?: @"";
|
||||
if (token.length > 0) {
|
||||
[request setValue:token forHTTPHeaderField:@"auth-token"];
|
||||
}
|
||||
// 与主 App 的请求头保持一致(Bearer)
|
||||
NSDictionary<NSString *, NSString *> *authHeader =
|
||||
[[KBAuthManager shared] authorizationHeader];
|
||||
[authHeader enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString *obj,
|
||||
BOOL *stop) {
|
||||
if (key.length == 0 || obj.length == 0) {
|
||||
return;
|
||||
}
|
||||
[request setValue:obj forHTTPHeaderField:key];
|
||||
}];
|
||||
request.HTTPBody = bodyData;
|
||||
|
||||
self.streamHasOutput = NO;
|
||||
@@ -463,7 +430,7 @@
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self)
|
||||
return;
|
||||
[self kb_handleEventSourceError:event.error];
|
||||
// [self kb_handleEventSourceError:event.error];
|
||||
}
|
||||
forEvent:WJXEventNameError
|
||||
queue:NSOperationQueue.mainQueue];
|
||||
@@ -483,7 +450,10 @@
|
||||
if (event.data.length == 0) {
|
||||
return;
|
||||
}
|
||||
NSLog(@"[KBStream] SSE raw payload: %@", event.data);
|
||||
#if DEBUG
|
||||
NSLog(@"[KBStream] SSE raw payload len=%lu",
|
||||
(unsigned long)(event.data ?: @"").length);
|
||||
#endif
|
||||
NSData *jsonData = [event.data dataUsingEncoding:NSUTF8StringEncoding];
|
||||
if (!jsonData) {
|
||||
return;
|
||||
@@ -541,7 +511,7 @@
|
||||
}
|
||||
BOOL shouldShowError = (error != nil);
|
||||
if (shouldShowError) {
|
||||
[KBHUD showInfo:error.localizedDescription ?: KBLocalized(@"拉取失败")];
|
||||
[KBHUD showInfo:error.localizedDescription ?: KBLocalized(@"Fetch failed")];
|
||||
}
|
||||
if (self.streamOverlay) {
|
||||
[self.streamOverlay finish];
|
||||
@@ -558,7 +528,7 @@
|
||||
BOOL needSubscriptionGuide = (code == KBBizCodeQuotaExhausted);
|
||||
NSString *msg = KBBizMessageFromJSONObject(payload);
|
||||
if (msg.length == 0) {
|
||||
msg = KBLocalized(@"拉取失败");
|
||||
msg = KBLocalized(@"Fetch failed");
|
||||
}
|
||||
NSError *bizError =
|
||||
[NSError errorWithDomain:@"KBStreamBizError"
|
||||
@@ -746,7 +716,7 @@
|
||||
// 1) 先判断权限:未开启“完全访问”则走引导逻辑
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||
// 未开启完全访问:保持原有引导路径
|
||||
[KBHUD showInfo:KBLocalized(@"处理中…")];
|
||||
[KBHUD showInfo:KBLocalized(@"Processing...")];
|
||||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self];
|
||||
return;
|
||||
}
|
||||
@@ -756,12 +726,27 @@
|
||||
if (!KBAuthManager.shared.isLoggedIn) {
|
||||
|
||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
||||
if (!ivc) {
|
||||
[KBHUD showInfo:KBLocalized(@"Please return to the Home screen and open the app to sign in")];
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *schemeStr =
|
||||
[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:ivc.view];
|
||||
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:ivc
|
||||
source:(ivc.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;
|
||||
// if (!ivc) return;
|
||||
// NSString *encodedTitle = [title
|
||||
@@ -791,7 +776,6 @@
|
||||
// [ivc dismissKeyboard];
|
||||
// BOOL ok = [KBHostAppLauncher openHostAppURL:scheme
|
||||
// fromResponder:start]; if (!ok) {
|
||||
// [KBHUD showInfo:KBLocalized(@"请切换到主App完成登录")];
|
||||
// }else{
|
||||
//
|
||||
// }
|
||||
@@ -839,7 +823,7 @@ static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer,
|
||||
return;
|
||||
}
|
||||
|
||||
[KBHUD showInfo:KBLocalized(@"处理中…")];
|
||||
[KBHUD showInfo:KBLocalized(@"Processing...")];
|
||||
|
||||
UIInputViewController *ivc = KBFindInputViewController(self);
|
||||
if (!ivc)
|
||||
@@ -859,37 +843,34 @@ static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer,
|
||||
if (!ul)
|
||||
return;
|
||||
|
||||
dispatch_after(
|
||||
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
// 先尝试通过 extensionContext 打开 UL
|
||||
[ivc.extensionContext
|
||||
openURL:ul
|
||||
completionHandler:^(BOOL ok) {
|
||||
if (ok) {
|
||||
return;
|
||||
}
|
||||
// UL 失败时,再通过宿主 UIApplication + 自定义 Scheme 兜底
|
||||
NSURL *scheme = [NSURL
|
||||
URLWithString:
|
||||
[NSString
|
||||
stringWithFormat:
|
||||
@"%@@//login?src=functionView&index=%ld&title=%@",
|
||||
KB_APP_SCHEME, (long)indexPath.item,
|
||||
encodedTitle]];
|
||||
UIResponder *start = ivc.view ?: (UIResponder *)self;
|
||||
BOOL ok2 = [KBHostAppLauncher openHostAppURL:scheme
|
||||
fromResponder:start];
|
||||
if (!ok2) {
|
||||
// 两条路都失败:大概率未开完全访问或宿主拦截。统一交由 Manager
|
||||
// 引导。
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[[KBFullAccessManager shared]
|
||||
ensureFullAccessOrGuideInView:self];
|
||||
});
|
||||
}
|
||||
}];
|
||||
});
|
||||
NSURL *scheme = [NSURL
|
||||
URLWithString:
|
||||
[NSString stringWithFormat:
|
||||
@"%@://login?src=functionView&index=%ld&title=%@",
|
||||
KB_APP_SCHEME, (long)indexPath.item, encodedTitle]];
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
[KBExtensionAppLauncher
|
||||
openPrimaryURL:ul
|
||||
fallbackURL:scheme
|
||||
usingInputController:ivc
|
||||
source:(ivc.view ?: (UIResponder *)self)
|
||||
completion:^(BOOL success) {
|
||||
if (success) {
|
||||
return;
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[[KBFullAccessManager shared]
|
||||
ensureFullAccessOrGuideInView:self];
|
||||
});
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - Button Actions
|
||||
|
||||
@@ -21,17 +21,12 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
/// 需求:当 index == 0 时,由外部(KeyboardViewController)决定是否切换到功能面板
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapToolActionAtIndex:(NSInteger)index;
|
||||
|
||||
/// 点击了右侧设置按钮
|
||||
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView;
|
||||
/// 点击了撤销删除按钮
|
||||
- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView;
|
||||
|
||||
/// emoji 视图里选择了一个表情
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didSelectEmoji:(NSString *)emoji;
|
||||
|
||||
/// emoji 面板点击搜索
|
||||
- (void)keyBoardMainViewDidTapEmojiSearch:(KBKeyBoardMainView *)keyBoardMainView;
|
||||
|
||||
/// 选择了联想词
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didSelectSuggestion:(NSString *)suggestion;
|
||||
@end
|
||||
@@ -45,6 +40,9 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
/// 更新联想候选
|
||||
- (void)kb_setSuggestions:(NSArray<NSString *> *)suggestions;
|
||||
|
||||
/// 根据 profileId 重新加载键盘布局
|
||||
- (void)reloadLayoutWithProfileId:(NSString *)profileId;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
[self addSubview:self.suggestionBar];
|
||||
|
||||
// 键盘区域(高度按照设计值做等比缩放,避免不同机型上按键被压缩/拉伸)
|
||||
CGFloat keyboardAreaHeight = KBFit(200.0f);
|
||||
CGFloat keyboardAreaHeight = KBFit(215.0f);
|
||||
KBKeyboardLayoutConfig *layoutConfig = [KBKeyboardLayoutConfig sharedConfig];
|
||||
if (layoutConfig) {
|
||||
CGFloat configHeight = [layoutConfig keyboardAreaScaledHeight];
|
||||
@@ -66,14 +66,7 @@
|
||||
make.bottom.equalTo(self.mas_bottom).offset(-bottomInset);
|
||||
}];
|
||||
|
||||
self.emojiView = [[KBEmojiPanelView alloc] init];
|
||||
self.emojiView.hidden = YES;
|
||||
self.emojiView.alpha = 0.0;
|
||||
self.emojiView.delegate = self;
|
||||
[self addSubview:self.emojiView];
|
||||
[self.emojiView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self);
|
||||
}];
|
||||
// emoji 面板改为按需创建
|
||||
|
||||
// [self.topBar mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
// make.left.right.equalTo(self);
|
||||
@@ -95,6 +88,10 @@
|
||||
[self.keyboardView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.topBar.mas_bottom).offset(barSpacing);
|
||||
}];
|
||||
// 首次创建时立即生成按键,避免只出现 4 行容器但按键未渲染的情况。
|
||||
[self.keyboardView reloadKeys];
|
||||
// 同步应用一次主题,确保键盘背景与按键皮肤在首帧可见。
|
||||
[self kb_applyTheme];
|
||||
// 功能面板切换交由外部控制器处理;此处不直接创建/管理
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(kb_undoStateChanged)
|
||||
@@ -112,9 +109,10 @@
|
||||
if (self.emojiPanelVisible == visible) return;
|
||||
self.emojiPanelVisible = visible;
|
||||
if (visible) {
|
||||
[self.emojiView reloadData];
|
||||
self.emojiView.hidden = NO;
|
||||
[self bringSubviewToFront:self.emojiView];
|
||||
KBEmojiPanelView *emojiView = [self emojiView];
|
||||
[emojiView reloadData];
|
||||
emojiView.hidden = NO;
|
||||
[self bringSubviewToFront:emojiView];
|
||||
} else {
|
||||
self.keyboardView.hidden = NO;
|
||||
self.topBar.hidden = NO;
|
||||
@@ -122,19 +120,28 @@
|
||||
}
|
||||
|
||||
void (^changes)(void) = ^{
|
||||
self.emojiView.alpha = visible ? 1.0 : 0.0;
|
||||
if (self.emojiView) {
|
||||
self.emojiView.alpha = visible ? 1.0 : 0.0;
|
||||
}
|
||||
self.keyboardView.alpha = visible ? 0.0 : 1.0;
|
||||
self.topBar.alpha = visible ? 0.0 : 1.0;
|
||||
self.suggestionBar.alpha = visible ? 0.0 : ([self kb_shouldShowSuggestions] ? 1.0 : 0.0);
|
||||
};
|
||||
void (^completion)(BOOL) = ^(BOOL finished) {
|
||||
self.emojiView.hidden = !visible;
|
||||
if (self.emojiView) {
|
||||
self.emojiView.hidden = !visible;
|
||||
}
|
||||
self.keyboardView.hidden = visible;
|
||||
self.topBar.hidden = visible;
|
||||
if (visible) {
|
||||
self.suggestionBar.hidden = YES;
|
||||
} else {
|
||||
self.suggestionBar.hidden = ![self kb_shouldShowSuggestions];
|
||||
if (self.emojiView) {
|
||||
[self.emojiView purgeEmojiCache];
|
||||
[self.emojiView removeFromSuperview];
|
||||
self.emojiView = nil;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -150,6 +157,22 @@
|
||||
[self setEmojiPanelVisible:!self.emojiPanelVisible animated:YES];
|
||||
}
|
||||
|
||||
#pragma mark - Lazy Load
|
||||
|
||||
- (KBEmojiPanelView *)emojiView {
|
||||
if (!_emojiView) {
|
||||
_emojiView = [[KBEmojiPanelView alloc] init];
|
||||
_emojiView.hidden = YES;
|
||||
_emojiView.alpha = 0.0;
|
||||
_emojiView.delegate = self;
|
||||
[self addSubview:_emojiView];
|
||||
[_emojiView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self);
|
||||
}];
|
||||
}
|
||||
return _emojiView;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - KBToolBarDelegate
|
||||
|
||||
@@ -160,12 +183,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
- (void)toolBarDidTapSettings:(KBToolBar *)toolBar {
|
||||
if ([self.delegate respondsToSelector:@selector(keyBoardMainViewDidTapSettings:)]) {
|
||||
[self.delegate keyBoardMainViewDidTapSettings:self];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)toolBarDidTapUndo:(KBToolBar *)toolBar {
|
||||
if ([self.delegate respondsToSelector:@selector(keyBoardMainViewDidTapUndo:)]) {
|
||||
[self.delegate keyBoardMainViewDidTapUndo:self];
|
||||
@@ -238,12 +255,6 @@
|
||||
[self setEmojiPanelVisible:NO animated:YES];
|
||||
}
|
||||
|
||||
- (void)emojiPanelViewDidTapSearch:(KBEmojiPanelView *)panel {
|
||||
if ([self.delegate respondsToSelector:@selector(keyBoardMainViewDidTapEmojiSearch:)]) {
|
||||
[self.delegate keyBoardMainViewDidTapEmojiSearch:self];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)emojiPanelViewDidTapDelete:(KBEmojiPanelView *)panel {
|
||||
if ([self.delegate respondsToSelector:@selector(keyBoardMainView:didTapKey:)]) {
|
||||
KBKey *backspace = [KBKey keyWithTitle:@"" type:KBKeyTypeBackspace];
|
||||
@@ -289,10 +300,7 @@
|
||||
|
||||
- (BOOL)kb_shouldShowSuggestions {
|
||||
if (self.emojiPanelVisible) { return NO; }
|
||||
if (![KBBackspaceUndoManager shared].hasUndo && self.suggestionBarHasItems) {
|
||||
return YES;
|
||||
}
|
||||
return NO;
|
||||
return self.suggestionBarHasItems;
|
||||
}
|
||||
|
||||
- (void)kb_applySuggestionVisibility {
|
||||
@@ -301,4 +309,13 @@
|
||||
self.suggestionBar.alpha = shouldShow ? 1.0 : 0.0;
|
||||
}
|
||||
|
||||
- (void)reloadLayoutWithProfileId:(NSString *)profileId {
|
||||
if (profileId.length == 0) {
|
||||
NSLog(@"[KBKeyBoardMainView] reloadLayoutWithProfileId: empty profileId");
|
||||
return;
|
||||
}
|
||||
NSLog(@"[KBKeyBoardMainView] Reloading layout with profileId: %@", profileId);
|
||||
[self.keyboardView reloadLayoutWithProfileId:profileId];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
27
CustomKeyboard/View/KBKeyboardView/KBKeyboardInputHandler.h
Normal file
27
CustomKeyboard/View/KBKeyboardView/KBKeyboardInputHandler.h
Normal file
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// KBKeyboardInputHandler.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Key tap handling helper.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@class KBKey;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef void (^KBKeyboardActionHandler)(void);
|
||||
typedef void (^KBKeyboardKeyTapHandler)(KBKey *key);
|
||||
|
||||
@interface KBKeyboardInputHandler : NSObject
|
||||
|
||||
@property (nonatomic, copy, nullable) KBKeyboardActionHandler onToggleShift;
|
||||
@property (nonatomic, copy, nullable) KBKeyboardActionHandler onToggleSymbols;
|
||||
@property (nonatomic, copy, nullable) KBKeyboardKeyTapHandler onKeyTapped;
|
||||
|
||||
- (BOOL)handleKeyTap:(KBKey *)key;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
26
CustomKeyboard/View/KBKeyboardView/KBKeyboardInputHandler.m
Normal file
26
CustomKeyboard/View/KBKeyboardView/KBKeyboardInputHandler.m
Normal file
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// KBKeyboardInputHandler.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBKeyboardInputHandler.h"
|
||||
#import "KBKey.h"
|
||||
|
||||
@implementation KBKeyboardInputHandler
|
||||
|
||||
- (BOOL)handleKeyTap:(KBKey *)key {
|
||||
if (!key) { return NO; }
|
||||
switch (key.type) {
|
||||
case KBKeyTypeShift:
|
||||
if (self.onToggleShift) { self.onToggleShift(); }
|
||||
return YES;
|
||||
case KBKeyTypeSymbolsToggle:
|
||||
if (self.onToggleSymbols) { self.onToggleSymbols(); }
|
||||
return YES;
|
||||
default:
|
||||
if (self.onKeyTapped) { self.onKeyTapped(key); }
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// KBKeyboardInteractionHandler.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@class KBKeyButton;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBKeyboardInteractionHandler : NSObject
|
||||
|
||||
- (UIView *)resolveHitView:(UIView *)hitView
|
||||
point:(CGPoint)point
|
||||
container:(UIView *)container
|
||||
rowViews:(NSArray<UIView *> *)rowViews;
|
||||
|
||||
- (NSArray<KBKeyButton *> *)collectKeyButtonsInView:(UIView *)view;
|
||||
|
||||
- (void)showPreviewForButton:(KBKeyButton *)button inContainer:(UIView *)container;
|
||||
- (void)hidePreview;
|
||||
- (void)bringPreviewToFrontIfNeededInContainer:(UIView *)container;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// KBKeyboardInteractionHandler.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBKeyboardInteractionHandler.h"
|
||||
#import "KBKeyButton.h"
|
||||
#import "KBKey.h"
|
||||
#import "KBKeyPreviewView.h"
|
||||
|
||||
static const NSTimeInterval kKBPreviewShowDuration = 0.08;
|
||||
static const NSTimeInterval kKBPreviewHideDuration = 0.06;
|
||||
|
||||
@interface KBKeyboardInteractionHandler ()
|
||||
@property (nonatomic, strong) KBKeyPreviewView *previewView;
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardInteractionHandler
|
||||
|
||||
- (UIView *)resolveHitView:(UIView *)hitView
|
||||
point:(CGPoint)point
|
||||
container:(UIView *)container
|
||||
rowViews:(NSArray<UIView *> *)rowViews {
|
||||
if ([hitView isKindOfClass:[KBKeyButton class]]) {
|
||||
return hitView;
|
||||
}
|
||||
if ([self isHitInsideKeyRows:hitView rowViews:rowViews]) {
|
||||
KBKeyButton *btn = [self nearestKeyButtonForPoint:point
|
||||
container:container
|
||||
rowViews:rowViews];
|
||||
if (btn) { return btn; }
|
||||
}
|
||||
return hitView;
|
||||
}
|
||||
|
||||
- (NSArray<KBKeyButton *> *)collectKeyButtonsInView:(UIView *)view {
|
||||
if (!view) { return @[]; }
|
||||
NSMutableArray<KBKeyButton *> *buttons = [NSMutableArray array];
|
||||
[self collectKeyButtonsInView:view into:buttons];
|
||||
return buttons.copy;
|
||||
}
|
||||
|
||||
- (void)showPreviewForButton:(KBKeyButton *)button inContainer:(UIView *)container {
|
||||
if (!button || !container) { return; }
|
||||
KBKey *key = button.key;
|
||||
if (key.type != KBKeyTypeCharacter) return;
|
||||
|
||||
if (!self.previewView) {
|
||||
self.previewView = [[KBKeyPreviewView alloc] initWithFrame:CGRectZero];
|
||||
self.previewView.hidden = YES;
|
||||
[container addSubview:self.previewView];
|
||||
} else if (self.previewView.superview != container) {
|
||||
[container addSubview:self.previewView];
|
||||
}
|
||||
|
||||
[self.previewView configureWithKey:key icon:button.iconView.image];
|
||||
|
||||
// 计算预览视图位置:在按钮上方稍微偏上
|
||||
CGRect btnFrameInSelf = [button convertRect:button.bounds toView:container];
|
||||
CGFloat previewWidth = 42;
|
||||
CGFloat previewHeight = CGRectGetHeight(btnFrameInSelf) * 1.2;
|
||||
CGFloat centerX = CGRectGetMidX(btnFrameInSelf);
|
||||
CGFloat centerY = CGRectGetMinY(btnFrameInSelf) - previewHeight * 0.6;
|
||||
|
||||
self.previewView.frame = CGRectMake(0, 0, previewWidth, previewHeight);
|
||||
self.previewView.center = CGPointMake(centerX, centerY);
|
||||
self.previewView.alpha = 0.0;
|
||||
self.previewView.hidden = NO;
|
||||
|
||||
[UIView animateWithDuration:kKBPreviewShowDuration
|
||||
delay:0
|
||||
options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseOut
|
||||
animations:^{
|
||||
self.previewView.alpha = 1.0;
|
||||
}
|
||||
completion:nil];
|
||||
}
|
||||
|
||||
- (void)hidePreview {
|
||||
if (!self.previewView || self.previewView.isHidden) return;
|
||||
[UIView animateWithDuration:kKBPreviewHideDuration
|
||||
delay:0
|
||||
options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn
|
||||
animations:^{
|
||||
self.previewView.alpha = 0.0;
|
||||
}
|
||||
completion:^(BOOL finished) {
|
||||
self.previewView.hidden = YES;
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)bringPreviewToFrontIfNeededInContainer:(UIView *)container {
|
||||
if (!container) { return; }
|
||||
if (self.previewView && self.previewView.superview == container) {
|
||||
[container bringSubviewToFront:self.previewView];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Private
|
||||
|
||||
- (BOOL)isHitInsideKeyRows:(UIView *)hitView rowViews:(NSArray<UIView *> *)rowViews {
|
||||
if (!hitView) { return NO; }
|
||||
if ([rowViews containsObject:hitView]) { return YES; }
|
||||
for (UIView *row in rowViews) {
|
||||
if ([hitView isDescendantOfView:row]) { return YES; }
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (KBKeyButton *)nearestKeyButtonForPoint:(CGPoint)point
|
||||
container:(UIView *)container
|
||||
rowViews:(NSArray<UIView *> *)rowViews {
|
||||
if (!container) { return nil; }
|
||||
KBKeyButton *best = nil;
|
||||
CGFloat bestDistance = CGFLOAT_MAX;
|
||||
|
||||
UIView *targetRow = nil;
|
||||
for (UIView *row in rowViews) {
|
||||
CGRect rowFrame = [container convertRect:row.bounds fromView:row];
|
||||
if (CGRectContainsPoint(rowFrame, point)) {
|
||||
targetRow = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
NSArray<UIView *> *candidateRows = targetRow ? @[targetRow] : rowViews;
|
||||
for (UIView *row in candidateRows) {
|
||||
NSArray<KBKeyButton *> *buttons = [self collectKeyButtonsInView:row];
|
||||
for (KBKeyButton *btn in buttons) {
|
||||
CGRect frame = [container convertRect:btn.frame fromView:btn.superview];
|
||||
CGFloat dx = point.x - CGRectGetMidX(frame);
|
||||
CGFloat dy = point.y - CGRectGetMidY(frame);
|
||||
CGFloat dist = (dx * dx) + (dy * dy);
|
||||
if (dist < bestDistance) {
|
||||
bestDistance = dist;
|
||||
best = btn;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
- (void)collectKeyButtonsInView:(UIView *)view
|
||||
into:(NSMutableArray<KBKeyButton *> *)buttons {
|
||||
for (UIView *sub in view.subviews) {
|
||||
if ([sub isKindOfClass:[KBKeyButton class]]) {
|
||||
[buttons addObject:(KBKeyButton *)sub];
|
||||
continue;
|
||||
}
|
||||
if (sub.subviews.count > 0) {
|
||||
[self collectKeyButtonsInView:sub into:buttons];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
28
CustomKeyboard/View/KBKeyboardView/KBKeyboardKeyFactory.h
Normal file
28
CustomKeyboard/View/KBKeyboardView/KBKeyboardKeyFactory.h
Normal file
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// KBKeyboardKeyFactory.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Key creation helper for keyboard layouts.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@class KBKeyboardLayoutConfig;
|
||||
@class KBKeyboardKeyDef;
|
||||
@class KBKey;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBKeyboardKeyFactory : NSObject
|
||||
|
||||
- (instancetype)initWithLayoutConfig:(KBKeyboardLayoutConfig *)layoutConfig;
|
||||
|
||||
- (nullable KBKey *)keyForItemId:(NSString *)itemId shiftOn:(BOOL)shiftOn;
|
||||
- (nullable KBKey *)keyFromDef:(KBKeyboardKeyDef *)def
|
||||
identifier:(NSString *)identifier
|
||||
shiftOn:(BOOL)shiftOn;
|
||||
- (nullable KBKey *)letterKeyWithChar:(NSString *)charString shiftOn:(BOOL)shiftOn;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
186
CustomKeyboard/View/KBKeyboardView/KBKeyboardKeyFactory.m
Normal file
186
CustomKeyboard/View/KBKeyboardView/KBKeyboardKeyFactory.m
Normal file
@@ -0,0 +1,186 @@
|
||||
//
|
||||
// KBKeyboardKeyFactory.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBKeyboardKeyFactory.h"
|
||||
#import "KBKeyboardLayoutConfig.h"
|
||||
#import "KBKey.h"
|
||||
|
||||
@interface KBKeyboardKeyFactory ()
|
||||
@property (nonatomic, strong) KBKeyboardLayoutConfig *layoutConfig;
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardKeyFactory
|
||||
|
||||
- (instancetype)initWithLayoutConfig:(KBKeyboardLayoutConfig *)layoutConfig {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_layoutConfig = layoutConfig;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (KBKey *)keyForItemId:(NSString *)itemId shiftOn:(BOOL)shiftOn {
|
||||
if (itemId.length == 0) { return nil; }
|
||||
KBKeyboardKeyDef *def = [self.layoutConfig keyDefForIdentifier:itemId];
|
||||
if (def) {
|
||||
return [self keyFromDef:def identifier:itemId shiftOn:shiftOn];
|
||||
}
|
||||
|
||||
NSRange range = [itemId rangeOfString:@":"];
|
||||
if (range.location != NSNotFound) {
|
||||
NSString *prefix = [itemId substringToIndex:range.location];
|
||||
NSString *value = [itemId substringFromIndex:range.location + 1];
|
||||
if ([prefix isEqualToString:@"letter"]) {
|
||||
if (value.length >= 1) {
|
||||
return [self letterKeyWithChar:value shiftOn:shiftOn];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
if ([prefix isEqualToString:@"digit"]) {
|
||||
NSString *identifier = [NSString stringWithFormat:@"digit_%@", value];
|
||||
KBKey *k = [KBKey keyWithIdentifier:identifier title:value output:value type:KBKeyTypeCharacter];
|
||||
k.caseVariant = KBKeyCaseVariantNone;
|
||||
return k;
|
||||
}
|
||||
if ([prefix isEqualToString:@"sym"]) {
|
||||
NSString *identifier = [self kb_identifierForSymbol:value];
|
||||
KBKey *k = [KBKey keyWithIdentifier:identifier title:value output:value type:KBKeyTypeCharacter];
|
||||
k.caseVariant = KBKeyCaseVariantNone;
|
||||
return k;
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (KBKey *)keyFromDef:(KBKeyboardKeyDef *)def
|
||||
identifier:(NSString *)identifier
|
||||
shiftOn:(BOOL)shiftOn {
|
||||
KBKeyType type = [self kb_keyTypeForDef:def];
|
||||
NSString *title = def.title ?: @"";
|
||||
if (type == KBKeyTypeShift && shiftOn && def.selectedTitle.length > 0) {
|
||||
title = def.selectedTitle;
|
||||
}
|
||||
NSString *output = @"";
|
||||
switch (type) {
|
||||
case KBKeyTypeSpace:
|
||||
output = @" ";
|
||||
break;
|
||||
case KBKeyTypeReturn:
|
||||
output = @"\n";
|
||||
break;
|
||||
default:
|
||||
output = @"";
|
||||
break;
|
||||
}
|
||||
|
||||
NSString *finalId = identifier;
|
||||
if ([identifier isEqualToString:@"emoji"]) {
|
||||
finalId = KBKeyIdentifierEmojiPanel;
|
||||
} else if ([identifier isEqualToString:@"send"]) {
|
||||
finalId = @"return";
|
||||
}
|
||||
|
||||
KBKey *k = [KBKey keyWithIdentifier:finalId title:title output:output type:type];
|
||||
if (type == KBKeyTypeShift) {
|
||||
k.caseVariant = shiftOn ? KBKeyCaseVariantUpper : KBKeyCaseVariantLower;
|
||||
} else {
|
||||
k.caseVariant = KBKeyCaseVariantNone;
|
||||
}
|
||||
return k;
|
||||
}
|
||||
|
||||
- (KBKey *)letterKeyWithChar:(NSString *)charString shiftOn:(BOOL)shiftOn {
|
||||
if (charString.length == 0) { return nil; }
|
||||
NSString *lower = charString.lowercaseString;
|
||||
NSString *upper = charString.uppercaseString;
|
||||
|
||||
NSString *shown = shiftOn ? upper : lower;
|
||||
NSString *identifier = [NSString stringWithFormat:@"letter_%@", lower];
|
||||
|
||||
KBKey *k = [KBKey keyWithIdentifier:identifier
|
||||
title:shown
|
||||
output:shown
|
||||
type:KBKeyTypeCharacter];
|
||||
k.caseVariant = shiftOn ? KBKeyCaseVariantUpper : KBKeyCaseVariantLower;
|
||||
return k;
|
||||
}
|
||||
|
||||
#pragma mark - Private
|
||||
|
||||
- (KBKeyType)kb_keyTypeForDef:(KBKeyboardKeyDef *)def {
|
||||
NSString *type = def.type.lowercaseString;
|
||||
if ([type isEqualToString:@"shift"]) return KBKeyTypeShift;
|
||||
if ([type isEqualToString:@"backspace"]) return KBKeyTypeBackspace;
|
||||
if ([type isEqualToString:@"mode"]) return KBKeyTypeModeChange;
|
||||
if ([type isEqualToString:@"symbolstoggle"]) return KBKeyTypeSymbolsToggle;
|
||||
if ([type isEqualToString:@"space"]) return KBKeyTypeSpace;
|
||||
if ([type isEqualToString:@"return"]) return KBKeyTypeReturn;
|
||||
if ([type isEqualToString:@"globe"]) return KBKeyTypeGlobe;
|
||||
return KBKeyTypeCustom;
|
||||
}
|
||||
|
||||
- (NSString *)kb_identifierForSymbol:(NSString *)symbol {
|
||||
if (symbol.length == 0) { return nil; }
|
||||
static NSDictionary<NSString *, NSString *> *map = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
map = @{
|
||||
@"-": @"sym_minus",
|
||||
@"/": @"sym_slash",
|
||||
@":": @"sym_colon",
|
||||
@";": @"sym_semicolon",
|
||||
@"(": @"sym_paren_l",
|
||||
@")": @"sym_paren_r",
|
||||
@"¥": @"sym_money",
|
||||
@"¥": @"sym_money",
|
||||
@"&": @"sym_amp",
|
||||
@"@": @"sym_at",
|
||||
@"\"": @"sym_quote_double",
|
||||
@"“": @"sym_quote_double",
|
||||
@"”": @"sym_quote_double",
|
||||
@".": @"sym_dot",
|
||||
@"。": @"sym_chinese_dot",
|
||||
@",": @"sym_comma",
|
||||
@"、": @"sym_dun",
|
||||
@"?": @"sym_question",
|
||||
@"!": @"sym_exclam",
|
||||
@"'": @"sym_quote_single",
|
||||
@"‘": @"sym_quote_single",
|
||||
@"’": @"sym_quote_single",
|
||||
@"[": @"sym_bracket_l",
|
||||
@"]": @"sym_bracket_r",
|
||||
@"{": @"sym_brace_l",
|
||||
@"}": @"sym_brace_r",
|
||||
@"「": @"sym_corner_l",
|
||||
@"」": @"sym_corner_r",
|
||||
@"#": @"sym_hash",
|
||||
@"%": @"sym_percent",
|
||||
@"^": @"sym_caret",
|
||||
@"*": @"sym_asterisk",
|
||||
@"+": @"sym_plus",
|
||||
@"=": @"sym_equal",
|
||||
@"_": @"sym_underscore",
|
||||
@"\\": @"sym_backslash",
|
||||
@"|": @"sym_pipe",
|
||||
@"~": @"sym_tilde",
|
||||
@"<": @"sym_lt",
|
||||
@">": @"sym_gt",
|
||||
@"€": @"sym_euro",
|
||||
@"$": @"sym_dollar",
|
||||
@"£": @"sym_pound",
|
||||
@"·": @"sym_bullet",
|
||||
@"^_^": @"sym_face",
|
||||
@"—": @"sym_emdash",
|
||||
@"«": @"sym_guillemet_l",
|
||||
@"»": @"sym_guillemet_r",
|
||||
@"《": @"sym_book_title_l",
|
||||
@"》": @"sym_book_title_r",
|
||||
@"...": @"sym_ellipsis"
|
||||
};
|
||||
});
|
||||
return map[symbol];
|
||||
}
|
||||
|
||||
@end
|
||||
42
CustomKeyboard/View/KBKeyboardView/KBKeyboardLayoutEngine.h
Normal file
42
CustomKeyboard/View/KBKeyboardView/KBKeyboardLayoutEngine.h
Normal file
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// KBKeyboardLayoutEngine.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Layout metrics calculation helper.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@class KBKeyboardLayoutConfig;
|
||||
@class KBKeyboardLayout;
|
||||
@class KBKeyboardRowConfig;
|
||||
@class KBKeyboardRowItem;
|
||||
@class KBKeyboardKeyFactory;
|
||||
@class KBKey;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBKeyboardLayoutEngine : NSObject
|
||||
|
||||
- (instancetype)initWithLayoutConfig:(KBKeyboardLayoutConfig *)layoutConfig;
|
||||
|
||||
- (CGFloat)rowSpacingForLayout:(KBKeyboardLayout *)layout;
|
||||
- (CGFloat)topInsetForLayout:(KBKeyboardLayout *)layout;
|
||||
- (CGFloat)bottomInsetForLayout:(KBKeyboardLayout *)layout;
|
||||
|
||||
- (CGFloat)rowHeightForRow:(KBKeyboardRowConfig *)row;
|
||||
- (CGFloat)gapForRow:(KBKeyboardRowConfig *)row;
|
||||
- (CGFloat)insetLeftForRow:(KBKeyboardRowConfig *)row;
|
||||
- (CGFloat)insetRightForRow:(KBKeyboardRowConfig *)row;
|
||||
|
||||
- (CGFloat)widthForItem:(KBKeyboardRowItem *)item key:(KBKey *)key;
|
||||
- (CGFloat)fontSizeForItem:(KBKeyboardRowItem *)item key:(KBKey *)key;
|
||||
- (CGFloat)fontSizeForFontKey:(NSString *)fontKey;
|
||||
|
||||
- (CGFloat)calculateUniformCharKeyWidthForRows:(NSArray<KBKeyboardRowConfig *> *)rows
|
||||
keyFactory:(KBKeyboardKeyFactory *)keyFactory
|
||||
shiftOn:(BOOL)shiftOn;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
245
CustomKeyboard/View/KBKeyboardView/KBKeyboardLayoutEngine.m
Normal file
245
CustomKeyboard/View/KBKeyboardView/KBKeyboardLayoutEngine.m
Normal file
@@ -0,0 +1,245 @@
|
||||
//
|
||||
// KBKeyboardLayoutEngine.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBKeyboardLayoutEngine.h"
|
||||
#import "KBKeyboardLayoutConfig.h"
|
||||
#import "KBKeyboardKeyFactory.h"
|
||||
#import "KBKey.h"
|
||||
#import "KBConfig.h"
|
||||
|
||||
@interface KBKeyboardLayoutEngine ()
|
||||
@property (nonatomic, strong) KBKeyboardLayoutConfig *layoutConfig;
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardLayoutEngine
|
||||
|
||||
- (instancetype)initWithLayoutConfig:(KBKeyboardLayoutConfig *)layoutConfig {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_layoutConfig = layoutConfig;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (CGFloat)rowSpacingForLayout:(KBKeyboardLayout *)layout {
|
||||
KBKeyboardLayoutConfig *config = self.layoutConfig;
|
||||
NSNumber *layoutSpacing = layout.rowSpacing;
|
||||
return [self kb_metricValue:layoutSpacing fallback:config.metrics.rowSpacing defaultValue:8.0];
|
||||
}
|
||||
|
||||
- (CGFloat)topInsetForLayout:(KBKeyboardLayout *)layout {
|
||||
KBKeyboardLayoutConfig *config = self.layoutConfig;
|
||||
NSNumber *layoutInset = layout.topInset;
|
||||
return [self kb_metricValue:layoutInset fallback:config.metrics.topInset defaultValue:8.0];
|
||||
}
|
||||
|
||||
- (CGFloat)bottomInsetForLayout:(KBKeyboardLayout *)layout {
|
||||
KBKeyboardLayoutConfig *config = self.layoutConfig;
|
||||
NSNumber *layoutInset = layout.bottomInset;
|
||||
return [self kb_metricValue:layoutInset fallback:config.metrics.bottomInset defaultValue:6.0];
|
||||
}
|
||||
|
||||
- (CGFloat)rowHeightForRow:(KBKeyboardRowConfig *)row {
|
||||
KBKeyboardLayoutConfig *config = self.layoutConfig;
|
||||
NSNumber *height = row.height ?: config.metrics.keyHeight;
|
||||
CGFloat value = [self kb_numberValue:height defaultValue:40.0];
|
||||
return [self kb_scaledValue:value];
|
||||
}
|
||||
|
||||
- (CGFloat)gapForRow:(KBKeyboardRowConfig *)row {
|
||||
KBKeyboardLayoutConfig *config = self.layoutConfig;
|
||||
return [self kb_metricValue:row.gap fallback:config.metrics.gap defaultValue:5.0];
|
||||
}
|
||||
|
||||
- (CGFloat)insetLeftForRow:(KBKeyboardRowConfig *)row {
|
||||
KBKeyboardLayoutConfig *config = self.layoutConfig;
|
||||
return [self kb_metricValue:row.insetLeft fallback:config.metrics.edgeInset defaultValue:0.0];
|
||||
}
|
||||
|
||||
- (CGFloat)insetRightForRow:(KBKeyboardRowConfig *)row {
|
||||
KBKeyboardLayoutConfig *config = self.layoutConfig;
|
||||
return [self kb_metricValue:row.insetRight fallback:config.metrics.edgeInset defaultValue:0.0];
|
||||
}
|
||||
|
||||
- (CGFloat)widthForItem:(KBKeyboardRowItem *)item key:(KBKey *)key {
|
||||
CGFloat width = 0.0;
|
||||
if (item.widthValue.doubleValue > 0.0) {
|
||||
width = item.widthValue.doubleValue;
|
||||
} else if (item.width.length > 0) {
|
||||
if ([item.width.lowercaseString isEqualToString:@"flex"]) {
|
||||
return 0.0;
|
||||
}
|
||||
width = [self kb_metricWidthForKey:item.width];
|
||||
if (width <= 0.0) {
|
||||
width = item.width.doubleValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (width <= 0.0) {
|
||||
KBKeyboardLayoutMetrics *m = self.layoutConfig.metrics;
|
||||
if ([item.itemId hasPrefix:@"letter:"] ||
|
||||
[item.itemId hasPrefix:@"digit:"] ||
|
||||
[item.itemId hasPrefix:@"sym:"]) {
|
||||
width = m.letterWidth.doubleValue;
|
||||
} else if (key.type == KBKeyTypeReturn) {
|
||||
width = m.sendWidth.doubleValue;
|
||||
} else if (key.type == KBKeyTypeSpace) {
|
||||
return 0.0;
|
||||
} else {
|
||||
width = m.controlWidth.doubleValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (width <= 0.0) {
|
||||
if ([item.itemId hasPrefix:@"letter:"] ||
|
||||
[item.itemId hasPrefix:@"digit:"] ||
|
||||
[item.itemId hasPrefix:@"sym:"]) {
|
||||
width = 32.0;
|
||||
} else if (key.type == KBKeyTypeReturn) {
|
||||
width = 88.0;
|
||||
} else if (key.type == KBKeyTypeSpace) {
|
||||
return 0.0;
|
||||
} else {
|
||||
width = 41.0;
|
||||
}
|
||||
}
|
||||
|
||||
return width > 0.0 ? [self kb_scaledValue:width] : 0.0;
|
||||
}
|
||||
|
||||
- (CGFloat)fontSizeForItem:(KBKeyboardRowItem *)item key:(KBKey *)key {
|
||||
NSString *fontKey = nil;
|
||||
if ([item.itemId hasPrefix:@"letter:"]) {
|
||||
fontKey = @"letter";
|
||||
} else if ([item.itemId hasPrefix:@"digit:"]) {
|
||||
fontKey = @"digit";
|
||||
} else if ([item.itemId hasPrefix:@"sym:"]) {
|
||||
fontKey = @"symbol";
|
||||
} else {
|
||||
KBKeyboardKeyDef *def = [self.layoutConfig keyDefForIdentifier:item.itemId];
|
||||
fontKey = def.font;
|
||||
}
|
||||
|
||||
if (fontKey.length == 0) {
|
||||
switch (key.type) {
|
||||
case KBKeyTypeModeChange:
|
||||
case KBKeyTypeSymbolsToggle:
|
||||
fontKey = @"mode";
|
||||
break;
|
||||
case KBKeyTypeSpace:
|
||||
fontKey = @"space";
|
||||
break;
|
||||
case KBKeyTypeReturn:
|
||||
fontKey = @"send";
|
||||
break;
|
||||
default:
|
||||
fontKey = @"symbol";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [self fontSizeForFontKey:fontKey];
|
||||
}
|
||||
|
||||
- (CGFloat)fontSizeForFontKey:(NSString *)fontKey {
|
||||
KBKeyboardLayoutFonts *fonts = self.layoutConfig.fonts;
|
||||
CGFloat size = 0.0;
|
||||
if ([fontKey isEqualToString:@"letter"]) { size = fonts.letter.doubleValue; }
|
||||
else if ([fontKey isEqualToString:@"digit"]) { size = fonts.digit.doubleValue; }
|
||||
else if ([fontKey isEqualToString:@"symbol"]) { size = fonts.symbol.doubleValue; }
|
||||
else if ([fontKey isEqualToString:@"mode"]) { size = fonts.mode.doubleValue; }
|
||||
else if ([fontKey isEqualToString:@"space"]) { size = fonts.space.doubleValue; }
|
||||
else if ([fontKey isEqualToString:@"send"]) { size = fonts.send.doubleValue; }
|
||||
if (size <= 0.0) { size = 18.0; }
|
||||
return [self kb_scaledValue:size];
|
||||
}
|
||||
|
||||
/// 计算跨行统一字符键宽度:遍历每行各自的 insets/gap/非字符键宽度,
|
||||
/// 取各行可用字符键宽度的最小值,确保所有行都能容纳。
|
||||
/// 当各行有效宽度相同时返回 0(无需统一)。
|
||||
- (CGFloat)calculateUniformCharKeyWidthForRows:(NSArray<KBKeyboardRowConfig *> *)rows
|
||||
keyFactory:(KBKeyboardKeyFactory *)keyFactory
|
||||
shiftOn:(BOOL)shiftOn {
|
||||
CGFloat minWidth = CGFLOAT_MAX;
|
||||
CGFloat maxWidth = 0.0;
|
||||
BOOL hasCharRow = NO;
|
||||
CGFloat containerWidth = KBScreenWidth();
|
||||
|
||||
for (KBKeyboardRowConfig *row in rows) {
|
||||
if (row.segments) { continue; } // 跳过分段行
|
||||
NSArray<KBKeyboardRowItem *> *items = [row resolvedItems];
|
||||
NSUInteger charCount = 0;
|
||||
CGFloat nonCharWidth = 0.0;
|
||||
for (KBKeyboardRowItem *item in items) {
|
||||
BOOL isChar = [item.itemId hasPrefix:@"letter:"] ||
|
||||
[item.itemId hasPrefix:@"digit:"] ||
|
||||
[item.itemId hasPrefix:@"sym:"];
|
||||
if (isChar) {
|
||||
charCount++;
|
||||
} else {
|
||||
KBKey *key = [keyFactory keyForItemId:item.itemId shiftOn:shiftOn];
|
||||
CGFloat w = [self widthForItem:item key:key];
|
||||
nonCharWidth += w;
|
||||
}
|
||||
}
|
||||
if (charCount == 0) { continue; } // 跳过无字符键的行(如底部控制行)
|
||||
hasCharRow = YES;
|
||||
|
||||
// 使用每行各自配置的 insets 和 gap
|
||||
CGFloat gap = [self gapForRow:row];
|
||||
CGFloat insetLeft = [self insetLeftForRow:row];
|
||||
CGFloat insetRight = [self insetRightForRow:row];
|
||||
CGFloat totalGaps = (items.count > 1) ? (items.count - 1) * gap : 0.0;
|
||||
CGFloat available = containerWidth - insetLeft - insetRight - totalGaps - nonCharWidth;
|
||||
CGFloat width = available / charCount;
|
||||
|
||||
if (width < minWidth) { minWidth = width; }
|
||||
if (width > maxWidth) { maxWidth = width; }
|
||||
}
|
||||
|
||||
if (!hasCharRow || minWidth <= 0.0 || minWidth >= CGFLOAT_MAX) { return 0.0; }
|
||||
// 各行有效宽度相同时无需统一
|
||||
if (fabs(maxWidth - minWidth) < 0.5) { return 0.0; }
|
||||
return minWidth;
|
||||
}
|
||||
|
||||
#pragma mark - Helpers
|
||||
|
||||
- (CGFloat)kb_scaledValue:(CGFloat)designValue {
|
||||
if (self.layoutConfig) {
|
||||
return [self.layoutConfig scaledValue:designValue];
|
||||
}
|
||||
return KBFit(designValue);
|
||||
}
|
||||
|
||||
- (CGFloat)kb_numberValue:(NSNumber *)value defaultValue:(CGFloat)defaultValue {
|
||||
if ([value isKindOfClass:[NSNumber class]]) {
|
||||
return value.doubleValue;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
- (CGFloat)kb_metricValue:(NSNumber *)value fallback:(NSNumber *)fallback defaultValue:(CGFloat)defaultValue {
|
||||
CGFloat v = [self kb_numberValue:value defaultValue:-1.0];
|
||||
if (v < 0.0) {
|
||||
v = [self kb_numberValue:fallback defaultValue:defaultValue];
|
||||
}
|
||||
if (v < 0.0) {
|
||||
v = defaultValue;
|
||||
}
|
||||
return [self kb_scaledValue:v];
|
||||
}
|
||||
|
||||
- (CGFloat)kb_metricWidthForKey:(NSString *)key {
|
||||
KBKeyboardLayoutMetrics *m = self.layoutConfig.metrics;
|
||||
if ([key isEqualToString:@"letterWidth"]) { return m.letterWidth.doubleValue; }
|
||||
if ([key isEqualToString:@"controlWidth"]) { return m.controlWidth.doubleValue; }
|
||||
if ([key isEqualToString:@"sendWidth"]) { return m.sendWidth.doubleValue; }
|
||||
if ([key isEqualToString:@"symbolsWideWidth"]) { return m.symbolsWideWidth.doubleValue; }
|
||||
if ([key isEqualToString:@"symbolsSideWidth"]) { return m.symbolsSideWidth.doubleValue; }
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
@end
|
||||
28
CustomKeyboard/View/KBKeyboardView/KBKeyboardLegacyBuilder.h
Normal file
28
CustomKeyboard/View/KBKeyboardView/KBKeyboardLegacyBuilder.h
Normal file
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// KBKeyboardLegacyBuilder.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Legacy layout builder (non-config layout).
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@class KBKey;
|
||||
@class KBBackspaceLongPressHandler;
|
||||
@class UIView;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBKeyboardLegacyBuilder : NSObject
|
||||
|
||||
- (void)buildRow:(UIView *)row
|
||||
withKeys:(NSArray<KBKey *> *)keys
|
||||
edgeSpacerMultiplier:(CGFloat)edgeSpacerMultiplier
|
||||
shiftOn:(BOOL)shiftOn
|
||||
backspaceHandler:(KBBackspaceLongPressHandler *)backspaceHandler
|
||||
target:(id)target
|
||||
action:(SEL)action;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
291
CustomKeyboard/View/KBKeyboardView/KBKeyboardLegacyBuilder.m
Normal file
291
CustomKeyboard/View/KBKeyboardView/KBKeyboardLegacyBuilder.m
Normal file
@@ -0,0 +1,291 @@
|
||||
//
|
||||
// KBKeyboardLegacyBuilder.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBKeyboardLegacyBuilder.h"
|
||||
#import "KBKey.h"
|
||||
#import "KBKeyButton.h"
|
||||
#import "KBBackspaceLongPressHandler.h"
|
||||
#import "KBConfig.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
|
||||
static const CGFloat kKBSpecialKeySquareMultiplier = 1.2;
|
||||
static const CGFloat kKBReturnWidthMultiplier = 2.4;
|
||||
static const CGFloat kKBSpaceWidthMultiplier = 3.0;
|
||||
static inline CGFloat KBLegacyRowHorizontalInset(void) {
|
||||
return KBFit(6.0f);
|
||||
}
|
||||
|
||||
@implementation KBKeyboardLegacyBuilder
|
||||
|
||||
- (void)buildRow:(UIView *)row
|
||||
withKeys:(NSArray<KBKey *> *)keys
|
||||
edgeSpacerMultiplier:(CGFloat)edgeSpacerMultiplier
|
||||
shiftOn:(BOOL)shiftOn
|
||||
backspaceHandler:(KBBackspaceLongPressHandler *)backspaceHandler
|
||||
target:(id)target
|
||||
action:(SEL)action {
|
||||
if (!row || keys.count == 0) { return; }
|
||||
|
||||
// 第 4 行(底部控制行)使用单独的布局规则:
|
||||
// 123/ABC、Emoji、Send 给定尺寸,Space 自动吃掉剩余宽度。
|
||||
BOOL isBottomControlRow = [self kb_isBottomControlRowWithKeys:keys];
|
||||
|
||||
CGFloat spacing = 0; // 键与键之间的间距
|
||||
UIView *previous = nil;
|
||||
UIView *leftSpacer = nil;
|
||||
UIView *rightSpacer = nil;
|
||||
|
||||
if (edgeSpacerMultiplier > 0.0) {
|
||||
leftSpacer = [UIView new];
|
||||
rightSpacer = [UIView new];
|
||||
leftSpacer.backgroundColor = [UIColor clearColor];
|
||||
rightSpacer.backgroundColor = [UIColor clearColor];
|
||||
[row addSubview:leftSpacer];
|
||||
[row addSubview:rightSpacer];
|
||||
[leftSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(row.mas_left).offset(KBLegacyRowHorizontalInset());
|
||||
make.centerY.equalTo(row);
|
||||
make.height.mas_equalTo(1);
|
||||
}];
|
||||
[rightSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(row.mas_right).offset(-KBLegacyRowHorizontalInset());
|
||||
make.centerY.equalTo(row);
|
||||
make.height.mas_equalTo(1);
|
||||
}];
|
||||
}
|
||||
|
||||
for (NSInteger i = 0; i < keys.count; i++) {
|
||||
KBKey *key = keys[i];
|
||||
KBKeyButton *btn = [[KBKeyButton alloc] init];
|
||||
btn.key = key;
|
||||
[btn setTitle:key.title forState:UIControlStateNormal];
|
||||
// 在设置完标题后,按当前皮肤应用图标与文字显隐
|
||||
[btn applyThemeForCurrentKey];
|
||||
if (target && action) {
|
||||
[btn addTarget:target action:action forControlEvents:UIControlEventTouchDown];
|
||||
}
|
||||
[row addSubview:btn];
|
||||
|
||||
if (key.type == KBKeyTypeBackspace) {
|
||||
[backspaceHandler bindDeleteButton:btn showClearLabel:YES];
|
||||
}
|
||||
|
||||
// Shift 按钮选中态随大小写状态变化
|
||||
if (key.type == KBKeyTypeShift) {
|
||||
btn.selected = shiftOn;
|
||||
}
|
||||
|
||||
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.bottom.equalTo(row);
|
||||
if (previous) {
|
||||
make.left.equalTo(previous.mas_right).offset(spacing);
|
||||
} else {
|
||||
if (leftSpacer) {
|
||||
make.left.equalTo(leftSpacer.mas_right).offset(spacing);
|
||||
} else {
|
||||
make.left.equalTo(row.mas_left).offset(KBLegacyRowHorizontalInset());
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
// 字符键:等宽
|
||||
if (key.type == KBKeyTypeCharacter) {
|
||||
if (previous && [previous isKindOfClass:[KBKeyButton class]]) {
|
||||
KBKeyButton *prevBtn = (KBKeyButton *)previous;
|
||||
if (prevBtn.key.type == KBKeyTypeCharacter) {
|
||||
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.equalTo(previous);
|
||||
}];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// special keys: 宽度在第二遍统一设置
|
||||
}
|
||||
|
||||
previous = btn;
|
||||
}
|
||||
|
||||
// 右侧使用内边距或右占位
|
||||
if (previous) {
|
||||
[previous mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
if (rightSpacer) {
|
||||
make.right.equalTo(rightSpacer.mas_left).offset(-spacing);
|
||||
} else {
|
||||
make.right.equalTo(row.mas_right).offset(-KBLegacyRowHorizontalInset());
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// 底部控制行:在第一轮已完成左右约束的前提下,仅给 123/ABC、Emoji、Send 指定宽度,
|
||||
// Space 不加宽度约束,让其自动填充剩余空间。
|
||||
if (isBottomControlRow) {
|
||||
[self kb_applyBottomControlRowWidthInRow:row];
|
||||
return;
|
||||
}
|
||||
|
||||
// 第二遍:以首个字符键为基准,统一设置特殊键宽度倍数
|
||||
KBKeyButton *firstChar = nil;
|
||||
BOOL hasCharacterInRow = NO;
|
||||
for (UIView *v in row.subviews) {
|
||||
if (![v isKindOfClass:[KBKeyButton class]]) continue;
|
||||
KBKeyButton *b = (KBKeyButton *)v;
|
||||
if (b.key.type == KBKeyTypeCharacter) {
|
||||
firstChar = b;
|
||||
hasCharacterInRow = YES;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 若该行没有字符键(例如底部控制行之外的特殊行),则使用行内第一个按钮作为基准宽度
|
||||
if (!firstChar) {
|
||||
for (UIView *v in row.subviews) {
|
||||
if ([v isKindOfClass:[KBKeyButton class]]) {
|
||||
firstChar = (KBKeyButton *)v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (firstChar) {
|
||||
// 如果该行本身没有字符键(如底部控制行),且基准按钮是 123/ABC/#+= 等,
|
||||
// 也将其约束为 1:1,避免 123/ABC 不是正方形。
|
||||
if (!hasCharacterInRow &&
|
||||
(firstChar.key.type == KBKeyTypeModeChange ||
|
||||
firstChar.key.type == KBKeyTypeSymbolsToggle ||
|
||||
firstChar.key.type == KBKeyTypeCustom)) {
|
||||
[firstChar mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.equalTo(firstChar.mas_height).multipliedBy(kKBSpecialKeySquareMultiplier);
|
||||
}];
|
||||
}
|
||||
|
||||
for (UIView *v in row.subviews) {
|
||||
if (![v isKindOfClass:[KBKeyButton class]]) continue;
|
||||
KBKeyButton *b = (KBKeyButton *)v;
|
||||
|
||||
// 避免对基准按钮自身添加 self == self * k 的无效约束
|
||||
if (b == firstChar) continue;
|
||||
if (b.key.type == KBKeyTypeCharacter) continue;
|
||||
|
||||
BOOL isBottomModeKey = (b.key.type == KBKeyTypeModeChange) ||
|
||||
(b.key.type == KBKeyTypeSymbolsToggle) ||
|
||||
(b.key.type == KBKeyTypeCustom);
|
||||
// 一类键强制近似正方形(宽 ~ 高)
|
||||
if (b.key.type == KBKeyTypeShift ||
|
||||
b.key.type == KBKeyTypeBackspace ||
|
||||
isBottomModeKey) {
|
||||
[b mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.equalTo(b.mas_height).multipliedBy(kKBSpecialKeySquareMultiplier);
|
||||
}];
|
||||
continue;
|
||||
}
|
||||
|
||||
CGFloat multiplier = 1.5;
|
||||
|
||||
// Space:宽度更大
|
||||
if (b.key.type == KBKeyTypeSpace) {
|
||||
multiplier = kKBSpaceWidthMultiplier;
|
||||
}
|
||||
// Send 按钮:宽度为基准键的 2.4 倍
|
||||
else if (b.key.type == KBKeyTypeReturn) {
|
||||
multiplier = kKBReturnWidthMultiplier;
|
||||
}
|
||||
// 其它特殊键(如 Globe)保持适度放大
|
||||
else if (b.key.type == KBKeyTypeGlobe) {
|
||||
multiplier = 1.5;
|
||||
}
|
||||
|
||||
[b mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.equalTo(firstChar).multipliedBy(multiplier);
|
||||
}];
|
||||
}
|
||||
|
||||
// 如果有左右占位,则把占位宽度设置为字符键宽度的一定倍数,以实现整体居中;
|
||||
// 同时强约束左右占位宽度相等,避免在某些系统上由于布局冲突导致只压缩一侧,
|
||||
// 出现“左侧有空隙,右侧无空隙”的情况。
|
||||
if (leftSpacer && rightSpacer) {
|
||||
// 1) 左右占位宽度必须相等
|
||||
[leftSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.equalTo(rightSpacer);
|
||||
}];
|
||||
// 2) 同时都接近字符键宽度的 edgeSpacerMultiplier 倍数
|
||||
[rightSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.equalTo(firstChar).multipliedBy(edgeSpacerMultiplier);
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Row Helpers (Bottom Control Row)
|
||||
|
||||
// 判断是否为底部控制行:包含 Space + Return,且有 ModeChange/SymbolsToggle,
|
||||
// 并且不再含字符键。
|
||||
- (BOOL)kb_isBottomControlRowWithKeys:(NSArray<KBKey *> *)keys {
|
||||
BOOL hasSpace = NO;
|
||||
BOOL hasReturn = NO;
|
||||
BOOL hasModeOrSymbols = NO;
|
||||
BOOL hasCharacters = NO;
|
||||
|
||||
for (KBKey *k in keys) {
|
||||
if (k.type == KBKeyTypeSpace) {
|
||||
hasSpace = YES;
|
||||
} else if (k.type == KBKeyTypeReturn) {
|
||||
hasReturn = YES;
|
||||
} else if (k.type == KBKeyTypeModeChange || k.type == KBKeyTypeSymbolsToggle) {
|
||||
hasModeOrSymbols = YES;
|
||||
} else if (k.type == KBKeyTypeCharacter) {
|
||||
hasCharacters = YES;
|
||||
}
|
||||
}
|
||||
return (hasSpace && hasReturn && hasModeOrSymbols && !hasCharacters);
|
||||
}
|
||||
|
||||
- (void)kb_applyBottomControlRowWidthInRow:(UIView *)row {
|
||||
if (!row) { return; }
|
||||
|
||||
KBKeyButton *modeBtn = nil;
|
||||
KBKeyButton *spaceBtn = nil;
|
||||
KBKeyButton *retBtn = nil;
|
||||
NSMutableArray<KBKeyButton *> *customButtons = [NSMutableArray array];
|
||||
|
||||
for (UIView *v in row.subviews) {
|
||||
if (![v isKindOfClass:[KBKeyButton class]]) continue;
|
||||
KBKeyButton *b = (KBKeyButton *)v;
|
||||
switch (b.key.type) {
|
||||
case KBKeyTypeModeChange:
|
||||
case KBKeyTypeSymbolsToggle:
|
||||
modeBtn = b;
|
||||
break;
|
||||
case KBKeyTypeCustom:
|
||||
[customButtons addObject:b];
|
||||
break;
|
||||
case KBKeyTypeSpace:
|
||||
spaceBtn = b;
|
||||
break;
|
||||
case KBKeyTypeReturn:
|
||||
retBtn = b;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!modeBtn || customButtons.count == 0 || !spaceBtn || !retBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
[modeBtn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.equalTo(row.mas_height).multipliedBy(kKBSpecialKeySquareMultiplier);
|
||||
}];
|
||||
for (KBKeyButton *custom in customButtons) {
|
||||
[custom mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.equalTo(row.mas_height).multipliedBy(kKBSpecialKeySquareMultiplier);
|
||||
}];
|
||||
}
|
||||
[retBtn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.equalTo(modeBtn.mas_width).multipliedBy(2.0);
|
||||
}];
|
||||
// Space 不设置宽度;通过此前已建立的左右约束自动占满剩余宽度。
|
||||
(void)spaceBtn;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// KBKeyboardLegacyLayoutProvider.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@class KBKey;
|
||||
@class KBKeyboardKeyFactory;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBKeyboardLegacyLayoutProvider : NSObject
|
||||
|
||||
- (NSArray<NSArray<KBKey *> *> *)keysForLayoutStyleIsNumbers:(BOOL)isNumbersLayout
|
||||
shiftOn:(BOOL)shiftOn
|
||||
symbolsMoreOn:(BOOL)symbolsMoreOn
|
||||
keyFactory:(KBKeyboardKeyFactory *)keyFactory;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,193 @@
|
||||
//
|
||||
// KBKeyboardLegacyLayoutProvider.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBKeyboardLegacyLayoutProvider.h"
|
||||
#import "KBKeyboardKeyFactory.h"
|
||||
#import "KBKey.h"
|
||||
|
||||
@implementation KBKeyboardLegacyLayoutProvider
|
||||
|
||||
- (NSArray<NSArray<KBKey *> *> *)keysForLayoutStyleIsNumbers:(BOOL)isNumbersLayout
|
||||
shiftOn:(BOOL)shiftOn
|
||||
symbolsMoreOn:(BOOL)symbolsMoreOn
|
||||
keyFactory:(KBKeyboardKeyFactory *)keyFactory {
|
||||
if (isNumbersLayout) {
|
||||
return [self buildKeysForNumbersLayoutWithSymbolsMoreOn:symbolsMoreOn];
|
||||
}
|
||||
return [self buildKeysForLettersLayoutWithShiftOn:shiftOn keyFactory:keyFactory];
|
||||
}
|
||||
|
||||
#pragma mark - Letters Layout
|
||||
|
||||
- (NSArray<NSArray<KBKey *> *> *)buildKeysForLettersLayoutWithShiftOn:(BOOL)shiftOn
|
||||
keyFactory:(KBKeyboardKeyFactory *)keyFactory {
|
||||
// 字母布局(QWERTY)
|
||||
NSArray *r1Letters = @[ @"q", @"w", @"e", @"r", @"t", @"y", @"u", @"i", @"o", @"p" ];
|
||||
NSArray *r2Letters = @[ @"a", @"s", @"d", @"f", @"g", @"h", @"j", @"k", @"l" ];
|
||||
NSArray *r3Letters = @[ @"z", @"x", @"c", @"v", @"b", @"n", @"m" ];
|
||||
|
||||
NSMutableArray *row1 = [NSMutableArray arrayWithCapacity:r1Letters.count];
|
||||
for (NSString *s in r1Letters) {
|
||||
KBKey *key = [keyFactory letterKeyWithChar:s shiftOn:shiftOn];
|
||||
if (key) { [row1 addObject:key]; }
|
||||
}
|
||||
|
||||
NSMutableArray *row2 = [NSMutableArray arrayWithCapacity:r2Letters.count];
|
||||
for (NSString *s in r2Letters) {
|
||||
KBKey *key = [keyFactory letterKeyWithChar:s shiftOn:shiftOn];
|
||||
if (key) { [row2 addObject:key]; }
|
||||
}
|
||||
|
||||
// 第三行:Shift + Z...M + Backspace
|
||||
NSMutableArray *row3 = [NSMutableArray array];
|
||||
KBKey *shift = [KBKey keyWithIdentifier:@"shift"
|
||||
title:@"⇧"
|
||||
output:@""
|
||||
type:KBKeyTypeShift];
|
||||
// Shift 键也支持大小写两套皮肤图
|
||||
shift.caseVariant = shiftOn ? KBKeyCaseVariantUpper : KBKeyCaseVariantLower;
|
||||
[row3 addObject:shift];
|
||||
|
||||
for (NSString *s in r3Letters) {
|
||||
KBKey *key = [keyFactory letterKeyWithChar:s shiftOn:shiftOn];
|
||||
if (key) { [row3 addObject:key]; }
|
||||
}
|
||||
|
||||
KBKey *backspace = [KBKey keyWithIdentifier:@"backspace"
|
||||
title:@"⌫"
|
||||
output:@""
|
||||
type:KBKeyTypeBackspace];
|
||||
[row3 addObject:backspace];
|
||||
|
||||
NSArray *row4 = [self bottomControlRowKeysForLettersLayout];
|
||||
|
||||
return @[row1.copy, row2.copy, row3.copy, row4];
|
||||
}
|
||||
|
||||
#pragma mark - Numbers / Symbols Layout
|
||||
|
||||
- (NSArray<NSArray<KBKey *> *> *)buildKeysForNumbersLayoutWithSymbolsMoreOn:(BOOL)symbolsMoreOn {
|
||||
// 数字/符号布局:3 行主键 + 底部控制行
|
||||
NSArray *r1 = nil;
|
||||
NSArray *r2 = nil;
|
||||
NSArray *r3 = nil;
|
||||
|
||||
if (!symbolsMoreOn) {
|
||||
// 数字第一页(123)
|
||||
r1 = @[ [KBKey keyWithIdentifier:@"digit_1" title:@"1" output:@"1" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"digit_2" title:@"2" output:@"2" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"digit_3" title:@"3" output:@"3" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"digit_4" title:@"4" output:@"4" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"digit_5" title:@"5" output:@"5" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"digit_6" title:@"6" output:@"6" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"digit_7" title:@"7" output:@"7" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"digit_8" title:@"8" output:@"8" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"digit_9" title:@"9" output:@"9" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"digit_0" title:@"0" output:@"0" type:KBKeyTypeCharacter] ];
|
||||
r2 = @[ [KBKey keyWithIdentifier:@"sym_minus" title:@"-" output:@"-" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_slash" title:@"/" output:@"/" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_colon" title:@":" output:@":" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_semicolon" title:@";" output:@";" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_paren_l" title:@"(" output:@"(" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_paren_r" title:@")" output:@")" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_money" title:@"¥" output:@"¥" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_amp" title:@"&" output:@"&" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_at" title:@"@" output:@"@" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_quote_double" title:@"\"" output:@"\"" type:KBKeyTypeCharacter] ];
|
||||
|
||||
r3 = [self symbolsCommonThirdRowWithToggleIsMore:NO];
|
||||
} else {
|
||||
// 数字第二页(#+=):前两行替换为更多符号,左下角按钮文案改为“123”
|
||||
r1 = @[ [KBKey keyWithIdentifier:@"sym_bracket_l" title:@"[" output:@"[" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_bracket_r" title:@"]" output:@"]" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_brace_l" title:@"{" output:@"{" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_brace_r" title:@"}" output:@"}" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_hash" title:@"#" output:@"#" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_percent" title:@"%" output:@"%" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_caret" title:@"^" output:@"^" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_asterisk" title:@"*" output:@"*" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_plus" title:@"+" output:@"+" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_equal" title:@"=" output:@"=" type:KBKeyTypeCharacter] ];
|
||||
r2 = @[ [KBKey keyWithIdentifier:@"sym_underscore" title:@"_" output:@"_" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_backslash" title:@"\\" output:@"\\" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_pipe" title:@"|" output:@"|" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_tilde" title:@"~" output:@"~" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_lt" title:@"<" output:@"<" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_gt" title:@">" output:@">" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_dollar" title:@"$" output:@"$" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_euro" title:@"€" output:@"€" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_pound" title:@"£" output:@"£" type:KBKeyTypeCharacter],
|
||||
[KBKey keyWithIdentifier:@"sym_bullet" title:@"•" output:@"•" type:KBKeyTypeCharacter] ];
|
||||
|
||||
r3 = [self symbolsCommonThirdRowWithToggleIsMore:YES];
|
||||
}
|
||||
|
||||
NSArray *r4 = [self bottomControlRowKeysForNumbersLayout];
|
||||
return @[r1, r2, r3, r4];
|
||||
}
|
||||
|
||||
#pragma mark - Key Factories
|
||||
|
||||
- (NSArray<KBKey *> *)symbolsCommonThirdRowWithToggleIsMore:(BOOL)isMorePage {
|
||||
NSString *identifier = isMorePage ? @"symbols_toggle_123" : @"symbols_toggle_more";
|
||||
NSString *title = isMorePage ? @"123" : @"#+=";
|
||||
|
||||
KBKey *toggle = [KBKey keyWithIdentifier:identifier
|
||||
title:title
|
||||
output:@""
|
||||
type:KBKeyTypeSymbolsToggle];
|
||||
KBKey *comma = [KBKey keyWithIdentifier:@"sym_comma" title:@"," output:@"," type:KBKeyTypeCharacter];
|
||||
KBKey *dot = [KBKey keyWithIdentifier:@"sym_dot" title:@"." output:@"." type:KBKeyTypeCharacter];
|
||||
KBKey *q = [KBKey keyWithIdentifier:@"sym_question" title:@"?" output:@"?" type:KBKeyTypeCharacter];
|
||||
KBKey *ex = [KBKey keyWithIdentifier:@"sym_exclam" title:@"!" output:@"!" type:KBKeyTypeCharacter];
|
||||
KBKey *quote = [KBKey keyWithIdentifier:@"sym_quote_single" title:@"'" output:@"'" type:KBKeyTypeCharacter];
|
||||
KBKey *back = [KBKey keyWithIdentifier:@"backspace"
|
||||
title:@"⌫"
|
||||
output:@""
|
||||
type:KBKeyTypeBackspace];
|
||||
return @[ toggle, comma, dot, q, ex, quote, back ];
|
||||
}
|
||||
|
||||
- (NSArray<KBKey *> *)bottomControlRowKeysForLettersLayout {
|
||||
KBKey *mode123 = [KBKey keyWithIdentifier:@"mode_123"
|
||||
title:@"123"
|
||||
output:@""
|
||||
type:KBKeyTypeModeChange];
|
||||
KBKey *emoji = [KBKey keyWithIdentifier:KBKeyIdentifierEmojiPanel
|
||||
title:@"😊"
|
||||
output:@""
|
||||
type:KBKeyTypeCustom];
|
||||
KBKey *space = [KBKey keyWithIdentifier:@"space"
|
||||
title:@"space"
|
||||
output:@" "
|
||||
type:KBKeyTypeSpace];
|
||||
KBKey *ret = [KBKey keyWithIdentifier:@"return"
|
||||
title:KBLocalized(@"Send")
|
||||
output:@"\n"
|
||||
type:KBKeyTypeReturn];
|
||||
return @[ mode123, emoji, space, ret ];
|
||||
}
|
||||
|
||||
- (NSArray<KBKey *> *)bottomControlRowKeysForNumbersLayout {
|
||||
KBKey *modeABC = [KBKey keyWithIdentifier:@"mode_abc"
|
||||
title:@"abc"
|
||||
output:@""
|
||||
type:KBKeyTypeModeChange];
|
||||
KBKey *emoji = [KBKey keyWithIdentifier:KBKeyIdentifierEmojiPanel
|
||||
title:@"😊"
|
||||
output:@""
|
||||
type:KBKeyTypeCustom];
|
||||
KBKey *space = [KBKey keyWithIdentifier:@"space"
|
||||
title:@"space"
|
||||
output:@" "
|
||||
type:KBKeyTypeSpace];
|
||||
KBKey *ret = [KBKey keyWithIdentifier:@"return"
|
||||
title:KBLocalized(@"Send")
|
||||
output:@"\n"
|
||||
type:KBKeyTypeReturn];
|
||||
return @[ modeABC, emoji, space, ret ];
|
||||
}
|
||||
|
||||
@end
|
||||
33
CustomKeyboard/View/KBKeyboardView/KBKeyboardRowBuilder.h
Normal file
33
CustomKeyboard/View/KBKeyboardView/KBKeyboardRowBuilder.h
Normal file
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// KBKeyboardRowBuilder.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@class KBKeyboardLayoutConfig;
|
||||
@class KBKeyboardLayoutEngine;
|
||||
@class KBKeyboardKeyFactory;
|
||||
@class KBKeyboardRowConfig;
|
||||
@class KBBackspaceLongPressHandler;
|
||||
@class UIView;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBKeyboardRowBuilder : NSObject
|
||||
|
||||
- (instancetype)initWithLayoutConfig:(KBKeyboardLayoutConfig *)layoutConfig
|
||||
layoutEngine:(KBKeyboardLayoutEngine *)layoutEngine
|
||||
keyFactory:(KBKeyboardKeyFactory *)keyFactory;
|
||||
|
||||
- (void)buildRow:(UIView *)row
|
||||
withRowConfig:(KBKeyboardRowConfig *)rowConfig
|
||||
uniformCharWidth:(CGFloat)uniformCharWidth
|
||||
shiftOn:(BOOL)shiftOn
|
||||
backspaceHandler:(KBBackspaceLongPressHandler *)backspaceHandler
|
||||
target:(id)target
|
||||
action:(SEL)action;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
330
CustomKeyboard/View/KBKeyboardView/KBKeyboardRowBuilder.m
Normal file
330
CustomKeyboard/View/KBKeyboardView/KBKeyboardRowBuilder.m
Normal file
@@ -0,0 +1,330 @@
|
||||
//
|
||||
// KBKeyboardRowBuilder.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBKeyboardRowBuilder.h"
|
||||
#import "KBKeyboardLayoutConfig.h"
|
||||
#import "KBKeyboardLayoutEngine.h"
|
||||
#import "KBKeyboardKeyFactory.h"
|
||||
#import "KBKeyButton.h"
|
||||
#import "KBKey.h"
|
||||
#import "KBBackspaceLongPressHandler.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
|
||||
@interface KBKeyboardRowBuilder ()
|
||||
@property (nonatomic, strong) KBKeyboardLayoutConfig *layoutConfig;
|
||||
@property (nonatomic, strong) KBKeyboardLayoutEngine *layoutEngine;
|
||||
@property (nonatomic, strong) KBKeyboardKeyFactory *keyFactory;
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardRowBuilder
|
||||
|
||||
- (instancetype)initWithLayoutConfig:(KBKeyboardLayoutConfig *)layoutConfig
|
||||
layoutEngine:(KBKeyboardLayoutEngine *)layoutEngine
|
||||
keyFactory:(KBKeyboardKeyFactory *)keyFactory {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_layoutConfig = layoutConfig;
|
||||
_layoutEngine = layoutEngine;
|
||||
_keyFactory = keyFactory;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)buildRow:(UIView *)row
|
||||
withRowConfig:(KBKeyboardRowConfig *)rowConfig
|
||||
uniformCharWidth:(CGFloat)uniformCharWidth
|
||||
shiftOn:(BOOL)shiftOn
|
||||
backspaceHandler:(KBBackspaceLongPressHandler *)backspaceHandler
|
||||
target:(id)target
|
||||
action:(SEL)action {
|
||||
if (!row || !rowConfig) { return; }
|
||||
CGFloat gap = [self.layoutEngine gapForRow:rowConfig];
|
||||
CGFloat insetLeft = [self.layoutEngine insetLeftForRow:rowConfig];
|
||||
CGFloat insetRight = [self.layoutEngine insetRightForRow:rowConfig];
|
||||
|
||||
if (rowConfig.segments) {
|
||||
KBKeyboardRowSegments *segments = rowConfig.segments;
|
||||
NSArray<KBKeyboardRowItem *> *leftItems = [segments leftItems];
|
||||
NSArray<KBKeyboardRowItem *> *centerItems = [segments centerItems];
|
||||
NSArray<KBKeyboardRowItem *> *rightItems = [segments rightItems];
|
||||
UIView *leftContainer = [UIView new];
|
||||
UIView *centerContainer = [UIView new];
|
||||
UIView *rightContainer = [UIView new];
|
||||
[row addSubview:leftContainer];
|
||||
[row addSubview:centerContainer];
|
||||
[row addSubview:rightContainer];
|
||||
|
||||
[leftContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(row.mas_left).offset(insetLeft);
|
||||
make.top.bottom.equalTo(row);
|
||||
}];
|
||||
[rightContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(row.mas_right).offset(-insetRight);
|
||||
make.top.bottom.equalTo(row);
|
||||
}];
|
||||
[centerContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerX.equalTo(row);
|
||||
make.top.bottom.equalTo(row);
|
||||
make.left.greaterThanOrEqualTo(leftContainer.mas_right).offset(gap);
|
||||
make.right.lessThanOrEqualTo(rightContainer.mas_left).offset(-gap);
|
||||
}];
|
||||
|
||||
if (leftItems.count == 0) {
|
||||
[leftContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.mas_equalTo(0);
|
||||
}];
|
||||
}
|
||||
if (centerItems.count == 0) {
|
||||
[centerContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.mas_equalTo(0);
|
||||
}];
|
||||
}
|
||||
if (rightItems.count == 0) {
|
||||
[rightContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.mas_equalTo(0);
|
||||
}];
|
||||
}
|
||||
|
||||
[self kb_buildButtonsInContainer:leftContainer
|
||||
items:leftItems
|
||||
gap:gap
|
||||
insetLeft:0
|
||||
insetRight:0
|
||||
alignCenter:NO
|
||||
isTopLevelRow:NO
|
||||
uniformCharWidth:uniformCharWidth
|
||||
shiftOn:shiftOn
|
||||
backspaceHandler:backspaceHandler
|
||||
target:target
|
||||
action:action];
|
||||
[self kb_buildButtonsInContainer:centerContainer
|
||||
items:centerItems
|
||||
gap:gap
|
||||
insetLeft:0
|
||||
insetRight:0
|
||||
alignCenter:NO
|
||||
isTopLevelRow:NO
|
||||
uniformCharWidth:uniformCharWidth
|
||||
shiftOn:shiftOn
|
||||
backspaceHandler:backspaceHandler
|
||||
target:target
|
||||
action:action];
|
||||
[self kb_buildButtonsInContainer:rightContainer
|
||||
items:rightItems
|
||||
gap:gap
|
||||
insetLeft:0
|
||||
insetRight:0
|
||||
alignCenter:NO
|
||||
isTopLevelRow:NO
|
||||
uniformCharWidth:uniformCharWidth
|
||||
shiftOn:shiftOn
|
||||
backspaceHandler:backspaceHandler
|
||||
target:target
|
||||
action:action];
|
||||
return;
|
||||
}
|
||||
|
||||
BOOL alignCenter = [rowConfig.align.lowercaseString isEqualToString:@"center"];
|
||||
[self kb_buildButtonsInContainer:row
|
||||
items:[rowConfig resolvedItems]
|
||||
gap:gap
|
||||
insetLeft:insetLeft
|
||||
insetRight:insetRight
|
||||
alignCenter:alignCenter
|
||||
isTopLevelRow:YES
|
||||
uniformCharWidth:uniformCharWidth
|
||||
shiftOn:shiftOn
|
||||
backspaceHandler:backspaceHandler
|
||||
target:target
|
||||
action:action];
|
||||
}
|
||||
|
||||
#pragma mark - Private
|
||||
|
||||
- (void)kb_buildButtonsInContainer:(UIView *)container
|
||||
items:(NSArray<KBKeyboardRowItem *> *)items
|
||||
gap:(CGFloat)gap
|
||||
insetLeft:(CGFloat)insetLeft
|
||||
insetRight:(CGFloat)insetRight
|
||||
alignCenter:(BOOL)alignCenter
|
||||
isTopLevelRow:(BOOL)isTopLevelRow
|
||||
uniformCharWidth:(CGFloat)uniformCharWidth
|
||||
shiftOn:(BOOL)shiftOn
|
||||
backspaceHandler:(KBBackspaceLongPressHandler *)backspaceHandler
|
||||
target:(id)target
|
||||
action:(SEL)action {
|
||||
if (items.count == 0) { return; }
|
||||
|
||||
UIView *leftSpacer = nil;
|
||||
UIView *rightSpacer = nil;
|
||||
if (alignCenter) {
|
||||
leftSpacer = [UIView new];
|
||||
rightSpacer = [UIView new];
|
||||
[container addSubview:leftSpacer];
|
||||
[container addSubview:rightSpacer];
|
||||
[leftSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(container.mas_left).offset(insetLeft);
|
||||
make.top.bottom.equalTo(container);
|
||||
}];
|
||||
[rightSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(container.mas_right).offset(-insetRight);
|
||||
make.top.bottom.equalTo(container);
|
||||
}];
|
||||
[leftSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.equalTo(rightSpacer);
|
||||
}];
|
||||
}
|
||||
|
||||
BOOL usingUniformWidth = (uniformCharWidth > 0.0);
|
||||
BOOL allCharacterKeys = YES; // 跟踪该容器内是否全部为字符键
|
||||
|
||||
KBKeyButton *previous = nil;
|
||||
KBKeyButton *firstCharBtn = nil; // 用于非统一模式下的行内等宽约束
|
||||
for (KBKeyboardRowItem *item in items) {
|
||||
KBKeyButton *btn = [self kb_buttonForItem:item
|
||||
shiftOn:shiftOn
|
||||
backspaceHandler:backspaceHandler
|
||||
target:target
|
||||
action:action];
|
||||
if (!btn) { continue; }
|
||||
[container addSubview:btn];
|
||||
|
||||
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.bottom.equalTo(container);
|
||||
if (previous) {
|
||||
make.left.equalTo(previous.mas_right).offset(gap);
|
||||
} else {
|
||||
if (leftSpacer) {
|
||||
make.left.equalTo(leftSpacer.mas_right).offset(gap);
|
||||
} else {
|
||||
make.left.equalTo(container.mas_left).offset(insetLeft);
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
// 字符键(letter/digit/sym)使用等宽约束,自动分配可用空间;
|
||||
// 功能键(shift/backspace/mode 等)使用固定宽度。
|
||||
BOOL isCharacterKey = [item.itemId hasPrefix:@"letter:"] ||
|
||||
[item.itemId hasPrefix:@"digit:"] ||
|
||||
[item.itemId hasPrefix:@"sym:"];
|
||||
|
||||
if (!isCharacterKey) { allCharacterKeys = NO; }
|
||||
|
||||
if (isCharacterKey && usingUniformWidth) {
|
||||
// 使用跨行统一的固定宽度
|
||||
CGFloat w = uniformCharWidth;
|
||||
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.mas_equalTo(w);
|
||||
}];
|
||||
} else if (isCharacterKey) {
|
||||
// 行内等宽:所有字符键与第一个字符键等宽
|
||||
if (firstCharBtn) {
|
||||
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.equalTo(firstCharBtn);
|
||||
}];
|
||||
} else {
|
||||
firstCharBtn = btn;
|
||||
}
|
||||
} else {
|
||||
CGFloat width = [self.layoutEngine widthForItem:item key:btn.key];
|
||||
if (width > 0.0) {
|
||||
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.mas_equalTo(width);
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
previous = btn;
|
||||
}
|
||||
|
||||
if (!previous) { return; }
|
||||
|
||||
// 当使用统一宽度且顶层行全部为字符键时,跳过右锚约束以实现左对齐(列对齐)
|
||||
BOOL skipRightAnchor = isTopLevelRow && usingUniformWidth && allCharacterKeys;
|
||||
|
||||
if (!skipRightAnchor) {
|
||||
[previous mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
if (rightSpacer) {
|
||||
make.right.equalTo(rightSpacer.mas_left).offset(-gap);
|
||||
} else {
|
||||
make.right.equalTo(container.mas_right).offset(-insetRight);
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
- (KBKeyButton *)kb_buttonForItem:(KBKeyboardRowItem *)item
|
||||
shiftOn:(BOOL)shiftOn
|
||||
backspaceHandler:(KBBackspaceLongPressHandler *)backspaceHandler
|
||||
target:(id)target
|
||||
action:(SEL)action {
|
||||
if (item.itemId.length == 0) { return nil; }
|
||||
KBKeyboardKeyDef *def = [self.layoutConfig keyDefForIdentifier:item.itemId];
|
||||
KBKey *key = [self.keyFactory keyForItemId:item.itemId shiftOn:shiftOn];
|
||||
if (!key) { return nil; }
|
||||
|
||||
KBKeyButton *btn = [[KBKeyButton alloc] init];
|
||||
btn.key = key;
|
||||
[btn setTitle:key.title forState:UIControlStateNormal];
|
||||
|
||||
UIColor *bgColor = [self kb_backgroundColorForItem:item keyDef:def];
|
||||
if (bgColor) {
|
||||
btn.customBackgroundColor = bgColor;
|
||||
}
|
||||
|
||||
CGFloat fontSize = [self.layoutEngine fontSizeForItem:item key:key];
|
||||
if (fontSize > 0.0) {
|
||||
btn.titleLabel.font = [UIFont systemFontOfSize:fontSize weight:UIFontWeightSemibold];
|
||||
}
|
||||
|
||||
[btn applyThemeForCurrentKey];
|
||||
if (target && action) {
|
||||
[btn addTarget:target action:action forControlEvents:UIControlEventTouchDown];
|
||||
}
|
||||
|
||||
if (key.type == KBKeyTypeBackspace) {
|
||||
[backspaceHandler bindDeleteButton:btn showClearLabel:YES];
|
||||
}
|
||||
if (key.type == KBKeyTypeShift) {
|
||||
btn.selected = shiftOn;
|
||||
}
|
||||
[self kb_applySymbolIfNeededForButton:btn keyDef:def fontSize:fontSize];
|
||||
return btn;
|
||||
}
|
||||
|
||||
- (UIColor *)kb_backgroundColorForItem:(KBKeyboardRowItem *)item keyDef:(KBKeyboardKeyDef *)def {
|
||||
NSString *hex = def.backgroundColor;
|
||||
if (hex.length == 0) {
|
||||
hex = self.layoutConfig.defaultKeyBackground;
|
||||
}
|
||||
if (hex.length == 0) { return nil; }
|
||||
return [KBSkinManager colorFromHexString:hex defaultColor:nil];
|
||||
}
|
||||
|
||||
- (void)kb_applySymbolIfNeededForButton:(KBKeyButton *)button
|
||||
keyDef:(KBKeyboardKeyDef *)def
|
||||
fontSize:(CGFloat)fontSize {
|
||||
if (!button || !def) { return; }
|
||||
if (button.iconView.image != nil) { return; }
|
||||
NSString *symbolName = button.isSelected ? def.selectedSymbolName : def.symbolName;
|
||||
if (symbolName.length == 0) { return; }
|
||||
|
||||
UIImage *image = [UIImage systemImageNamed:symbolName];
|
||||
if (!image) { return; }
|
||||
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:fontSize weight:UIFontWeightSemibold];
|
||||
image = [image imageWithConfiguration:config];
|
||||
|
||||
button.iconView.image = image;
|
||||
button.iconView.hidden = NO;
|
||||
button.iconView.contentMode = UIViewContentModeCenter;
|
||||
button.titleLabel.hidden = YES;
|
||||
|
||||
UIColor *textColor = [KBSkinManager shared].current.keyTextColor ?: [UIColor blackColor];
|
||||
button.iconView.tintColor = button.isSelected ? [UIColor blackColor] : textColor;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// KBKeyboardRowContainerBuilder.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@class KBKeyboardRowConfig;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBKeyboardRowContainerBuilder : NSObject
|
||||
|
||||
- (void)rebuildRowContainersForRows:(NSArray<KBKeyboardRowConfig *> *)rowConfigs
|
||||
inContainer:(UIView *)container
|
||||
rowViews:(NSMutableArray<UIView *> *)rowViews
|
||||
rowSpacing:(CGFloat)rowSpacing
|
||||
topInset:(CGFloat)topInset
|
||||
bottomInset:(CGFloat)bottomInset;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// KBKeyboardRowContainerBuilder.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBKeyboardRowContainerBuilder.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
|
||||
@implementation KBKeyboardRowContainerBuilder
|
||||
|
||||
- (void)rebuildRowContainersForRows:(NSArray<KBKeyboardRowConfig *> *)rowConfigs
|
||||
inContainer:(UIView *)container
|
||||
rowViews:(NSMutableArray<UIView *> *)rowViews
|
||||
rowSpacing:(CGFloat)rowSpacing
|
||||
topInset:(CGFloat)topInset
|
||||
bottomInset:(CGFloat)bottomInset {
|
||||
if (!container || !rowViews) { return; }
|
||||
|
||||
for (UIView *row in rowViews) {
|
||||
[row removeFromSuperview];
|
||||
}
|
||||
[rowViews removeAllObjects];
|
||||
|
||||
NSUInteger rowCount = rowConfigs.count;
|
||||
if (rowCount == 0) { return; }
|
||||
|
||||
UIView *firstRow = nil;
|
||||
UIView *previousRow = nil;
|
||||
for (NSUInteger i = 0; i < rowCount; i++) {
|
||||
UIView *rowView = [UIView new];
|
||||
[container addSubview:rowView];
|
||||
[rowViews addObject:rowView];
|
||||
|
||||
[rowView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
if (previousRow) {
|
||||
make.top.equalTo(previousRow.mas_bottom).offset(rowSpacing);
|
||||
} else {
|
||||
make.top.equalTo(container.mas_top).offset(topInset);
|
||||
}
|
||||
make.left.right.equalTo(container);
|
||||
// 所有行等高,自动根据可用空间分配行高
|
||||
if (firstRow) {
|
||||
make.height.equalTo(firstRow);
|
||||
}
|
||||
}];
|
||||
|
||||
// 最后一行锚定到底部
|
||||
if (i == rowCount - 1) {
|
||||
[rowView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.bottom.equalTo(container.mas_bottom).offset(-bottomInset);
|
||||
}];
|
||||
}
|
||||
|
||||
if (!firstRow) { firstRow = rowView; }
|
||||
previousRow = rowView;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -27,7 +27,9 @@ typedef NS_ENUM(NSInteger, KBKeyboardLayoutStyle) {
|
||||
@property (nonatomic, assign, getter=isShiftOn) BOOL shiftOn; // 大小写状态
|
||||
// 在数字布局中,是否显示“更多符号”(#+=)页
|
||||
@property (nonatomic, assign) BOOL symbolsMoreOn;
|
||||
@property (nonatomic, copy) NSString *currentLayoutJsonId; // 当前使用的布局 JSON ID
|
||||
|
||||
- (void)reloadKeys; // 当布局样式/大小写变化时调用
|
||||
- (void)reloadLayoutWithProfileId:(NSString *)profileId; // 根据 profileId 重新加载布局
|
||||
|
||||
@end
|
||||
853
CustomKeyboard/View/KBKeyboardView/KBKeyboardView.m
Normal file
853
CustomKeyboard/View/KBKeyboardView/KBKeyboardView.m
Normal file
@@ -0,0 +1,853 @@
|
||||
//
|
||||
// KBKeyboardView.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBKeyboardView.h"
|
||||
#import "KBKeyButton.h"
|
||||
#import "KBKey.h"
|
||||
#import "KBBackspaceLongPressHandler.h"
|
||||
#import "KBKeyboardLayoutConfig.h"
|
||||
#import "KBKeyboardLayoutResolver.h"
|
||||
#import "KBKeyboardKeyFactory.h"
|
||||
#import "KBKeyboardLayoutEngine.h"
|
||||
#import "KBKeyboardRowBuilder.h"
|
||||
#import "KBKeyboardInputHandler.h"
|
||||
#import "KBKeyboardLegacyBuilder.h"
|
||||
#import "KBKeyboardLegacyLayoutProvider.h"
|
||||
#import "KBKeyboardInteractionHandler.h"
|
||||
#import "KBKeyboardRowContainerBuilder.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import <Masonry/Masonry.h>
|
||||
#import <objc/runtime.h>
|
||||
|
||||
// 第二行字母行的左右占位比例(用于居中)
|
||||
static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
|
||||
static const NSTimeInterval kKBKeyVariantLongPressMinDuration = 0.35;
|
||||
static const CGFloat kKBKeyVariantPopupPaddingX = 8.0;
|
||||
static const CGFloat kKBKeyVariantPopupPaddingY = 6.0;
|
||||
static const CGFloat kKBKeyVariantItemWidth = 34.0;
|
||||
static const CGFloat kKBKeyVariantItemHeight = 40.0;
|
||||
static const CGFloat kKBKeyVariantPopupAnchorGap = 6.0;
|
||||
static NSString * const kKBDiacriticsConfigFileName = @"kb_diacritics_map";
|
||||
static const void *kKBDiacriticsLongPressBoundKey = &kKBDiacriticsLongPressBoundKey;
|
||||
|
||||
static inline NSString *kb_normalizeLanguageCode(NSString *languageCode) {
|
||||
NSString *lc = (languageCode ?: @"").lowercaseString;
|
||||
return lc.length > 0 ? lc : @"en";
|
||||
}
|
||||
|
||||
static inline NSString *kb_baseLanguageCode(NSString *languageCode) {
|
||||
NSString *lc = kb_normalizeLanguageCode(languageCode);
|
||||
NSRange r = [lc rangeOfString:@"-"];
|
||||
if (r.location == NSNotFound) { return lc; }
|
||||
if (r.location == 0) { return lc; }
|
||||
return [lc substringToIndex:r.location];
|
||||
}
|
||||
|
||||
static NSDictionary<NSString *, NSDictionary<NSString *, NSArray<NSString *> *> *> *kb_diacriticsLanguagesMap(void) {
|
||||
static NSDictionary<NSString *, NSDictionary<NSString *, NSArray<NSString *> *> *> *cached = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
NSString *path = [[NSBundle mainBundle] pathForResource:kKBDiacriticsConfigFileName ofType:@"json"];
|
||||
NSData *data = path.length ? [NSData dataWithContentsOfFile:path] : nil;
|
||||
if (data.length == 0) { return; }
|
||||
|
||||
NSError *error = nil;
|
||||
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
|
||||
if (error || ![json isKindOfClass:[NSDictionary class]]) {
|
||||
NSLog(@"[KBKeyboardView] Failed to parse %@.json: %@", kKBDiacriticsConfigFileName, error);
|
||||
return;
|
||||
}
|
||||
|
||||
NSDictionary *dict = (NSDictionary *)json;
|
||||
id languages = dict[@"languages"];
|
||||
if (![languages isKindOfClass:[NSDictionary class]]) { return; }
|
||||
cached = (NSDictionary *)languages;
|
||||
});
|
||||
return cached ?: @{};
|
||||
}
|
||||
|
||||
static inline NSUInteger kb_composedCharacterCount(NSString *string) {
|
||||
if (string.length == 0) { return 0; }
|
||||
__block NSUInteger count = 0;
|
||||
[string enumerateSubstringsInRange:NSMakeRange(0, string.length)
|
||||
options:NSStringEnumerationByComposedCharacterSequences
|
||||
usingBlock:^(__unused NSString *substring,
|
||||
__unused NSRange substringRange,
|
||||
__unused NSRange enclosingRange,
|
||||
__unused BOOL *stop) {
|
||||
count += 1;
|
||||
}];
|
||||
return count;
|
||||
}
|
||||
|
||||
@interface KBKeyVariantPopupView : UIView
|
||||
@property (nonatomic, copy) NSArray<NSString *> *variants;
|
||||
@property (nonatomic, assign) NSInteger selectedIndex;
|
||||
- (void)configureWithVariants:(NSArray<NSString *> *)variants selectedIndex:(NSInteger)selectedIndex;
|
||||
- (void)updateSelectionWithPointInSelf:(CGPoint)point;
|
||||
- (nullable NSString *)selectedVariant;
|
||||
- (CGSize)preferredSize;
|
||||
- (void)applyTheme;
|
||||
@end
|
||||
|
||||
#pragma mark - KBKeyVariantPopupView
|
||||
|
||||
@interface KBKeyVariantPopupView ()
|
||||
@property (nonatomic, strong) UIView *contentView;
|
||||
@property (nonatomic, strong) NSMutableArray<UILabel *> *itemLabels;
|
||||
@end
|
||||
|
||||
@implementation KBKeyVariantPopupView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.layer.cornerRadius = 10.0;
|
||||
self.layer.masksToBounds = NO;
|
||||
self.layer.borderWidth = 0.5;
|
||||
self.layer.borderColor = [UIColor colorWithWhite:0 alpha:0.15].CGColor;
|
||||
self.layer.shadowColor = [UIColor colorWithWhite:0 alpha:0.28].CGColor;
|
||||
self.layer.shadowOpacity = 0.7;
|
||||
self.layer.shadowOffset = CGSizeMake(0, 2);
|
||||
self.layer.shadowRadius = 6.0;
|
||||
|
||||
[self addSubview:self.contentView];
|
||||
[self.contentView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self).insets(UIEdgeInsetsMake(kKBKeyVariantPopupPaddingY,
|
||||
kKBKeyVariantPopupPaddingX,
|
||||
kKBKeyVariantPopupPaddingY,
|
||||
kKBKeyVariantPopupPaddingX));
|
||||
}];
|
||||
|
||||
[self applyTheme];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)applyTheme {
|
||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
||||
UIColor *bg = t.keyBackground ?: [UIColor whiteColor];
|
||||
self.backgroundColor = bg;
|
||||
}
|
||||
|
||||
- (UIView *)contentView {
|
||||
if (!_contentView) {
|
||||
_contentView = [UIView new];
|
||||
_contentView.backgroundColor = [UIColor clearColor];
|
||||
}
|
||||
return _contentView;
|
||||
}
|
||||
|
||||
- (NSMutableArray<UILabel *> *)itemLabels {
|
||||
if (!_itemLabels) {
|
||||
_itemLabels = [NSMutableArray array];
|
||||
}
|
||||
return _itemLabels;
|
||||
}
|
||||
|
||||
- (CGSize)preferredSize {
|
||||
NSUInteger count = self.variants.count;
|
||||
if (count == 0) { return CGSizeMake(0, 0); }
|
||||
CGFloat width = kKBKeyVariantPopupPaddingX * 2 + kKBKeyVariantItemWidth * (CGFloat)count;
|
||||
CGFloat height = kKBKeyVariantPopupPaddingY * 2 + kKBKeyVariantItemHeight;
|
||||
return CGSizeMake(width, height);
|
||||
}
|
||||
|
||||
- (void)configureWithVariants:(NSArray<NSString *> *)variants selectedIndex:(NSInteger)selectedIndex {
|
||||
self.variants = variants ?: @[];
|
||||
self.selectedIndex = MAX(0, MIN(selectedIndex, (NSInteger)self.variants.count - 1));
|
||||
|
||||
[self.itemLabels makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
||||
[self.itemLabels removeAllObjects];
|
||||
|
||||
UILabel *previous = nil;
|
||||
for (NSInteger i = 0; i < (NSInteger)self.variants.count; i++) {
|
||||
UILabel *label = [UILabel new];
|
||||
label.textAlignment = NSTextAlignmentCenter;
|
||||
label.text = self.variants[i];
|
||||
label.font = [UIFont systemFontOfSize:20 weight:UIFontWeightSemibold];
|
||||
label.layer.cornerRadius = 8.0;
|
||||
label.layer.masksToBounds = YES;
|
||||
|
||||
[self.contentView addSubview:label];
|
||||
[self.itemLabels addObject:label];
|
||||
|
||||
[label mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.bottom.equalTo(self.contentView);
|
||||
make.width.mas_equalTo(kKBKeyVariantItemWidth);
|
||||
if (previous) {
|
||||
make.left.equalTo(previous.mas_right);
|
||||
} else {
|
||||
make.left.equalTo(self.contentView.mas_left);
|
||||
}
|
||||
}];
|
||||
previous = label;
|
||||
}
|
||||
|
||||
if (previous) {
|
||||
[previous mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(self.contentView.mas_right);
|
||||
}];
|
||||
}
|
||||
|
||||
[self kb_refreshSelectionAppearance];
|
||||
}
|
||||
|
||||
- (void)updateSelectionWithPointInSelf:(CGPoint)point {
|
||||
if (self.variants.count == 0) { return; }
|
||||
CGPoint p = [self convertPoint:point toView:self.contentView];
|
||||
CGFloat contentWidth = CGRectGetWidth(self.contentView.bounds);
|
||||
if (contentWidth <= 0) { return; }
|
||||
|
||||
NSInteger count = (NSInteger)self.variants.count;
|
||||
CGFloat unit = contentWidth / (CGFloat)count;
|
||||
NSInteger index = (NSInteger)floor(p.x / MAX(unit, 1.0));
|
||||
index = MAX(0, MIN(index, count - 1));
|
||||
if (index == self.selectedIndex) { return; }
|
||||
self.selectedIndex = index;
|
||||
[self kb_refreshSelectionAppearance];
|
||||
}
|
||||
|
||||
- (nullable NSString *)selectedVariant {
|
||||
if (self.selectedIndex < 0 || self.selectedIndex >= (NSInteger)self.variants.count) { return nil; }
|
||||
return self.variants[self.selectedIndex];
|
||||
}
|
||||
|
||||
- (void)kb_refreshSelectionAppearance {
|
||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
||||
UIColor *textColor = t.keyTextColor ?: [UIColor blackColor];
|
||||
UIColor *selectedBg = t.keyHighlightBackground ?: (t.accentColor ?: [UIColor colorWithWhite:0 alpha:0.12]);
|
||||
for (NSInteger i = 0; i < (NSInteger)self.itemLabels.count; i++) {
|
||||
UILabel *label = self.itemLabels[i];
|
||||
label.textColor = textColor;
|
||||
label.backgroundColor = (i == self.selectedIndex) ? selectedBg : [UIColor clearColor];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@interface KBKeyboardView ()
|
||||
@property (nonatomic, strong) NSMutableArray<UIView *> *rowViews;
|
||||
@property (nonatomic, strong) NSArray<NSArray<KBKey *> *> *keysForRows;
|
||||
@property (nonatomic, strong) KBBackspaceLongPressHandler *backspaceHandler;
|
||||
@property (nonatomic, strong) KBKeyboardLayoutConfig *layoutConfig;
|
||||
@property (nonatomic, strong) KBKeyboardKeyFactory *keyFactory;
|
||||
@property (nonatomic, strong) KBKeyboardLayoutEngine *layoutEngine;
|
||||
@property (nonatomic, strong) KBKeyboardRowBuilder *rowBuilder;
|
||||
@property (nonatomic, strong) KBKeyboardInputHandler *inputHandler;
|
||||
@property (nonatomic, strong) KBKeyboardLegacyBuilder *legacyBuilder;
|
||||
@property (nonatomic, strong) KBKeyboardLegacyLayoutProvider *legacyLayoutProvider;
|
||||
@property (nonatomic, strong) KBKeyboardInteractionHandler *interactionHandler;
|
||||
@property (nonatomic, strong) KBKeyboardRowContainerBuilder *rowContainerBuilder;
|
||||
/// 跨行统一字符键宽度(按最多字符键的行计算),0 表示不启用
|
||||
@property (nonatomic, assign) CGFloat kb_uniformCharKeyWidth;
|
||||
/// 记录当前行间距,便于切换布局时判断是否需要重建容器
|
||||
@property (nonatomic, assign) CGFloat kb_currentRowSpacing;
|
||||
/// 记录当前顶/底间距,便于切换布局时判断是否需要重建容器
|
||||
@property (nonatomic, assign) CGFloat kb_currentTopInset;
|
||||
@property (nonatomic, assign) CGFloat kb_currentBottomInset;
|
||||
|
||||
/// 长按字符变体(如葡语重音字符)
|
||||
@property (nonatomic, strong) KBKeyVariantPopupView *kb_variantPopupView;
|
||||
@property (nonatomic, weak) KBKeyButton *kb_variantAnchorButton;
|
||||
@property (nonatomic, copy) NSString *kb_variantBaseOutput;
|
||||
@property (nonatomic, assign) BOOL kb_variantBaseDeleted;
|
||||
@property (nonatomic, assign) NSUInteger kb_variantBaseDeleteCount;
|
||||
@property (nonatomic, copy) NSString *kb_currentLanguageCode;
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
_layoutStyle = KBKeyboardLayoutStyleLetters;
|
||||
// 默认小写:与需求一致,初始不开启 Shift
|
||||
_shiftOn = NO;
|
||||
_symbolsMoreOn = NO; // 数字面板默认第一页(123)
|
||||
|
||||
// 从 App Group 读取当前 profileId 并设置布局
|
||||
NSString *profileId = [[KBKeyboardLayoutResolver sharedResolver] currentProfileId];
|
||||
if (profileId.length > 0) {
|
||||
_currentLayoutJsonId = [[KBKeyboardLayoutResolver sharedResolver] layoutJsonIdForProfileId:profileId];
|
||||
NSLog(@"[KBKeyboardView] Loaded profileId: %@, layoutJsonId: %@", profileId, _currentLayoutJsonId);
|
||||
} else {
|
||||
_currentLayoutJsonId = @"letters";
|
||||
NSLog(@"[KBKeyboardView] No profileId found, using default 'letters'");
|
||||
}
|
||||
|
||||
self.layoutConfig = [KBKeyboardLayoutConfig sharedConfig];
|
||||
self.backspaceHandler = [[KBBackspaceLongPressHandler alloc] initWithContainerView:self];
|
||||
[self buildBase];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
// 当切换大布局(字母/数字)时,重置数字二级页状态
|
||||
- (void)setLayoutStyle:(KBKeyboardLayoutStyle)layoutStyle {
|
||||
_layoutStyle = layoutStyle;
|
||||
if (_layoutStyle != KBKeyboardLayoutStyleNumbers) {
|
||||
_symbolsMoreOn = NO;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Base Layout
|
||||
|
||||
- (void)buildBase {
|
||||
KBKeyboardLayout *layout = [self kb_currentLayout];
|
||||
NSArray<KBKeyboardRowConfig *> *rows = layout.rows ?: @[];
|
||||
if (rows.count == 0) {
|
||||
// Fallback: 至少创建 4 行容器
|
||||
rows = @[[KBKeyboardRowConfig new], [KBKeyboardRowConfig new],
|
||||
[KBKeyboardRowConfig new], [KBKeyboardRowConfig new]];
|
||||
}
|
||||
CGFloat rowSpacing = [self.layoutEngine rowSpacingForLayout:layout];
|
||||
CGFloat topInset = [self.layoutEngine topInsetForLayout:layout];
|
||||
CGFloat bottomInset = [self.layoutEngine bottomInsetForLayout:layout];
|
||||
self.kb_currentRowSpacing = rowSpacing;
|
||||
self.kb_currentTopInset = topInset;
|
||||
self.kb_currentBottomInset = bottomInset;
|
||||
[self.rowContainerBuilder rebuildRowContainersForRows:rows
|
||||
inContainer:self
|
||||
rowViews:self.rowViews
|
||||
rowSpacing:rowSpacing
|
||||
topInset:topInset
|
||||
bottomInset:bottomInset];
|
||||
[self.interactionHandler bringPreviewToFrontIfNeededInContainer:self];
|
||||
}
|
||||
|
||||
#pragma mark - Public
|
||||
|
||||
- (void)reloadKeys {
|
||||
[self.backspaceHandler bindDeleteButton:nil showClearLabel:NO];
|
||||
[self kb_hideVariantPopupIfNeeded];
|
||||
self.kb_currentLanguageCode = [[KBKeyboardLayoutResolver sharedResolver] currentLanguageCode] ?: @"en";
|
||||
|
||||
KBKeyboardLayout *layout = [self kb_currentLayout];
|
||||
CGFloat rowSpacing = [self.layoutEngine rowSpacingForLayout:layout];
|
||||
CGFloat topInset = [self.layoutEngine topInsetForLayout:layout];
|
||||
CGFloat bottomInset = [self.layoutEngine bottomInsetForLayout:layout];
|
||||
NSLog(@"[KBKeyboardView] reloadKeys: layoutName=%@ rows=%lu shiftRows=%lu shiftOn=%d",
|
||||
self.currentLayoutJsonId, (unsigned long)layout.rows.count, (unsigned long)layout.shiftRows.count, self.shiftOn);
|
||||
|
||||
NSArray<KBKeyboardRowConfig *> *rows = nil;
|
||||
|
||||
if (self.shiftOn && layout.shiftRows.count > 0) {
|
||||
rows = layout.shiftRows;
|
||||
} else {
|
||||
rows = layout.rows ?: @[];
|
||||
}
|
||||
|
||||
NSLog(@"[KBKeyboardView] reloadKeys: usingRows=%lu currentContainers=%lu",
|
||||
(unsigned long)rows.count, (unsigned long)self.rowViews.count);
|
||||
|
||||
// 行数变化时(如从 4 行布局切到 5 行注音布局),重建行容器
|
||||
if (rows.count >= 4 &&
|
||||
(rows.count != self.rowViews.count ||
|
||||
fabs(self.kb_currentRowSpacing - rowSpacing) > 0.1 ||
|
||||
fabs(self.kb_currentTopInset - topInset) > 0.1 ||
|
||||
fabs(self.kb_currentBottomInset - bottomInset) > 0.1)) {
|
||||
self.kb_currentRowSpacing = rowSpacing;
|
||||
self.kb_currentTopInset = topInset;
|
||||
self.kb_currentBottomInset = bottomInset;
|
||||
[self.rowContainerBuilder rebuildRowContainersForRows:rows
|
||||
inContainer:self
|
||||
rowViews:self.rowViews
|
||||
rowSpacing:rowSpacing
|
||||
topInset:topInset
|
||||
bottomInset:bottomInset];
|
||||
[self.interactionHandler bringPreviewToFrontIfNeededInContainer:self];
|
||||
}
|
||||
|
||||
// 移除旧按钮
|
||||
for (UIView *row in self.rowViews) {
|
||||
[row.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
||||
}
|
||||
|
||||
if (rows.count < 4) {
|
||||
NSLog(@"[KBKeyboardView] reloadKeys: rows.count < 4, fallback to legacy");
|
||||
self.kb_uniformCharKeyWidth = 0.0;
|
||||
[self kb_buildLegacyLayout];
|
||||
[self kb_bindVariantLongPressIfNeeded];
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算跨行统一字符键宽度(若各行字符键数量不同,则按最多键的行为基准)
|
||||
self.kb_uniformCharKeyWidth = [self.layoutEngine calculateUniformCharKeyWidthForRows:rows
|
||||
keyFactory:self.keyFactory
|
||||
shiftOn:self.shiftOn];
|
||||
|
||||
for (NSUInteger i = 0; i < rows.count && i < self.rowViews.count; i++) {
|
||||
[self.rowBuilder buildRow:self.rowViews[i]
|
||||
withRowConfig:rows[i]
|
||||
uniformCharWidth:self.kb_uniformCharKeyWidth
|
||||
shiftOn:self.shiftOn
|
||||
backspaceHandler:self.backspaceHandler
|
||||
target:self
|
||||
action:@selector(onKeyTapped:)];
|
||||
}
|
||||
|
||||
NSUInteger totalButtons = [self kb_totalKeyButtonCount];
|
||||
NSLog(@"[KBKeyboardView] reloadKeys: totalButtons=%lu", (unsigned long)totalButtons);
|
||||
if (totalButtons == 0) {
|
||||
NSLog(@"[KBKeyboardView] config layout produced no keys, fallback to legacy.");
|
||||
for (UIView *row in self.rowViews) {
|
||||
[row.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
||||
}
|
||||
[self kb_buildLegacyLayout];
|
||||
[self kb_bindVariantLongPressIfNeeded];
|
||||
return;
|
||||
}
|
||||
|
||||
[self kb_bindVariantLongPressIfNeeded];
|
||||
}
|
||||
|
||||
- (void)didMoveToWindow {
|
||||
[super didMoveToWindow];
|
||||
if (!self.window) { return; }
|
||||
if ([self kb_totalKeyButtonCount] > 0) { return; }
|
||||
// 兜底:系统编辑菜单切出切回等场景下,若按键丢失则自动重建。
|
||||
[self reloadKeys];
|
||||
// 自动重建后再触发一次上层主题应用,避免“按键恢复了但皮肤背景没恢复”。
|
||||
UIView *container = self.superview;
|
||||
if ([container respondsToSelector:@selector(kb_applyTheme)]) {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
[container performSelector:@selector(kb_applyTheme)];
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
}
|
||||
|
||||
- (NSUInteger)kb_totalKeyButtonCount {
|
||||
NSUInteger total = 0;
|
||||
for (UIView *row in self.rowViews) {
|
||||
total += [self.interactionHandler collectKeyButtonsInView:row].count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
#pragma mark - Hit Test
|
||||
|
||||
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
|
||||
UIView *hit = [super hitTest:point withEvent:event];
|
||||
return [self.interactionHandler resolveHitView:hit
|
||||
point:point
|
||||
container:self
|
||||
rowViews:self.rowViews];
|
||||
}
|
||||
|
||||
#pragma mark - Config Helpers
|
||||
|
||||
- (KBKeyboardLayoutConfig *)kb_layoutConfig {
|
||||
if (!self.layoutConfig) {
|
||||
self.layoutConfig = [KBKeyboardLayoutConfig sharedConfig];
|
||||
}
|
||||
return self.layoutConfig;
|
||||
}
|
||||
|
||||
- (KBKeyboardKeyFactory *)keyFactory {
|
||||
if (!_keyFactory) {
|
||||
_keyFactory = [[KBKeyboardKeyFactory alloc] initWithLayoutConfig:[self kb_layoutConfig]];
|
||||
}
|
||||
return _keyFactory;
|
||||
}
|
||||
|
||||
- (KBKeyboardLayoutEngine *)layoutEngine {
|
||||
if (!_layoutEngine) {
|
||||
_layoutEngine = [[KBKeyboardLayoutEngine alloc] initWithLayoutConfig:[self kb_layoutConfig]];
|
||||
}
|
||||
return _layoutEngine;
|
||||
}
|
||||
|
||||
- (KBKeyboardRowBuilder *)rowBuilder {
|
||||
if (!_rowBuilder) {
|
||||
_rowBuilder = [[KBKeyboardRowBuilder alloc] initWithLayoutConfig:[self kb_layoutConfig]
|
||||
layoutEngine:self.layoutEngine
|
||||
keyFactory:self.keyFactory];
|
||||
}
|
||||
return _rowBuilder;
|
||||
}
|
||||
|
||||
- (KBKeyboardInputHandler *)inputHandler {
|
||||
if (!_inputHandler) {
|
||||
_inputHandler = [[KBKeyboardInputHandler alloc] init];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
_inputHandler.onToggleShift = ^{
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) { return; }
|
||||
self.shiftOn = !self.shiftOn;
|
||||
[self reloadKeys];
|
||||
};
|
||||
_inputHandler.onToggleSymbols = ^{
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) { return; }
|
||||
self.symbolsMoreOn = !self.symbolsMoreOn;
|
||||
[self reloadKeys];
|
||||
};
|
||||
_inputHandler.onKeyTapped = ^(KBKey *key) {
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) { return; }
|
||||
if ([self.delegate respondsToSelector:@selector(keyboardView:didTapKey:)]) {
|
||||
[self.delegate keyboardView:self didTapKey:key];
|
||||
}
|
||||
};
|
||||
}
|
||||
return _inputHandler;
|
||||
}
|
||||
|
||||
- (KBKeyboardLegacyBuilder *)legacyBuilder {
|
||||
if (!_legacyBuilder) {
|
||||
_legacyBuilder = [[KBKeyboardLegacyBuilder alloc] init];
|
||||
}
|
||||
return _legacyBuilder;
|
||||
}
|
||||
|
||||
- (KBKeyboardLegacyLayoutProvider *)legacyLayoutProvider {
|
||||
if (!_legacyLayoutProvider) {
|
||||
_legacyLayoutProvider = [[KBKeyboardLegacyLayoutProvider alloc] init];
|
||||
}
|
||||
return _legacyLayoutProvider;
|
||||
}
|
||||
|
||||
- (KBKeyboardInteractionHandler *)interactionHandler {
|
||||
if (!_interactionHandler) {
|
||||
_interactionHandler = [[KBKeyboardInteractionHandler alloc] init];
|
||||
}
|
||||
return _interactionHandler;
|
||||
}
|
||||
|
||||
- (KBKeyboardRowContainerBuilder *)rowContainerBuilder {
|
||||
if (!_rowContainerBuilder) {
|
||||
_rowContainerBuilder = [[KBKeyboardRowContainerBuilder alloc] init];
|
||||
}
|
||||
return _rowContainerBuilder;
|
||||
}
|
||||
|
||||
- (KBKeyboardLayout *)kb_layoutForName:(NSString *)name {
|
||||
return [[self kb_layoutConfig] layoutForName:name];
|
||||
}
|
||||
|
||||
- (KBKeyboardLayout *)kb_currentLayout {
|
||||
NSString *baseLayoutName = self.currentLayoutJsonId.length > 0 ? self.currentLayoutJsonId : @"letters";
|
||||
|
||||
if (self.layoutStyle == KBKeyboardLayoutStyleNumbers) {
|
||||
// 优先查找当前语言的数字/符号布局,如 letters_es_numbers / letters_es_symbols
|
||||
// 如果不存在,回退到通用布局 numbers / symbolsMore
|
||||
NSString *numbersName = [NSString stringWithFormat:@"%@_numbers", baseLayoutName];
|
||||
NSString *symbolsName = [NSString stringWithFormat:@"%@_symbols", baseLayoutName];
|
||||
NSString *targetName = self.symbolsMoreOn ? symbolsName : numbersName;
|
||||
|
||||
KBKeyboardLayout *layout = [self kb_layoutForName:targetName];
|
||||
if (layout && layout.rows.count >= 4) {
|
||||
return layout;
|
||||
}
|
||||
// 回退到通用布局
|
||||
return [self kb_layoutForName:(self.symbolsMoreOn ? @"symbolsMore" : @"numbers")];
|
||||
}
|
||||
|
||||
return [self kb_layoutForName:baseLayoutName];
|
||||
}
|
||||
|
||||
- (void)reloadLayoutWithProfileId:(NSString *)profileId {
|
||||
if (profileId.length == 0) {
|
||||
NSLog(@"[KBKeyboardView] reloadLayoutWithProfileId: empty profileId, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *newLayoutJsonId = [[KBKeyboardLayoutResolver sharedResolver] layoutJsonIdForProfileId:profileId];
|
||||
if ([newLayoutJsonId isEqualToString:self.currentLayoutJsonId]) {
|
||||
NSLog(@"[KBKeyboardView] Layout already loaded: %@", newLayoutJsonId);
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[KBKeyboardView] Switching layout from %@ to %@", self.currentLayoutJsonId, newLayoutJsonId);
|
||||
self.currentLayoutJsonId = newLayoutJsonId;
|
||||
|
||||
// 重新加载键盘布局
|
||||
[self reloadKeys];
|
||||
}
|
||||
|
||||
- (void)kb_buildLegacyLayout {
|
||||
self.keysForRows = [self.legacyLayoutProvider keysForLayoutStyleIsNumbers:(self.layoutStyle == KBKeyboardLayoutStyleNumbers)
|
||||
shiftOn:self.shiftOn
|
||||
symbolsMoreOn:self.symbolsMoreOn
|
||||
keyFactory:self.keyFactory];
|
||||
if (self.keysForRows.count < 4) { return; }
|
||||
if (self.rowViews.count < 4) { return; }
|
||||
|
||||
[self.legacyBuilder buildRow:self.rowViews[0]
|
||||
withKeys:self.keysForRows[0]
|
||||
edgeSpacerMultiplier:0.0
|
||||
shiftOn:self.shiftOn
|
||||
backspaceHandler:self.backspaceHandler
|
||||
target:self
|
||||
action:@selector(onKeyTapped:)];
|
||||
|
||||
CGFloat row2Spacer = (self.layoutStyle == KBKeyboardLayoutStyleLetters)
|
||||
? kKBLettersRow2EdgeSpacerMultiplier : 0.0;
|
||||
[self.legacyBuilder buildRow:self.rowViews[1]
|
||||
withKeys:self.keysForRows[1]
|
||||
edgeSpacerMultiplier:row2Spacer
|
||||
shiftOn:self.shiftOn
|
||||
backspaceHandler:self.backspaceHandler
|
||||
target:self
|
||||
action:@selector(onKeyTapped:)];
|
||||
|
||||
[self.legacyBuilder buildRow:self.rowViews[2]
|
||||
withKeys:self.keysForRows[2]
|
||||
edgeSpacerMultiplier:0.0
|
||||
shiftOn:self.shiftOn
|
||||
backspaceHandler:self.backspaceHandler
|
||||
target:self
|
||||
action:@selector(onKeyTapped:)];
|
||||
[self.legacyBuilder buildRow:self.rowViews[3]
|
||||
withKeys:self.keysForRows[3]
|
||||
edgeSpacerMultiplier:0.0
|
||||
shiftOn:self.shiftOn
|
||||
backspaceHandler:self.backspaceHandler
|
||||
target:self
|
||||
action:@selector(onKeyTapped:)];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)onKeyTapped:(KBKeyButton *)sender {
|
||||
[self.inputHandler handleKeyTap:sender.key];
|
||||
}
|
||||
|
||||
// 在字符键按下时,显示一个上方气泡预览(类似系统键盘)。
|
||||
- (void)showPreviewForButton:(KBKeyButton *)button {
|
||||
[self.interactionHandler showPreviewForButton:button inContainer:self];
|
||||
}
|
||||
|
||||
- (void)hidePreview {
|
||||
[self.interactionHandler hidePreview];
|
||||
}
|
||||
|
||||
#pragma mark - Key Variant (Diacritics)
|
||||
|
||||
- (void)kb_hideVariantPopupIfNeeded {
|
||||
if (!self.kb_variantPopupView || self.kb_variantPopupView.hidden) { return; }
|
||||
self.kb_variantPopupView.hidden = YES;
|
||||
self.kb_variantPopupView.alpha = 1.0;
|
||||
self.kb_variantAnchorButton = nil;
|
||||
self.kb_variantBaseOutput = nil;
|
||||
self.kb_variantBaseDeleted = NO;
|
||||
self.kb_variantBaseDeleteCount = 0;
|
||||
}
|
||||
|
||||
- (void)kb_bindVariantLongPressIfNeeded {
|
||||
NSString *lc = kb_normalizeLanguageCode(self.kb_currentLanguageCode);
|
||||
NSString *baseLc = kb_baseLanguageCode(self.kb_currentLanguageCode);
|
||||
NSDictionary<NSString *, NSDictionary<NSString *, NSArray<NSString *> *> *> *languages = kb_diacriticsLanguagesMap();
|
||||
if (languages.count == 0) { return; }
|
||||
NSDictionary *commonMap = [languages[@"common"] isKindOfClass:[NSDictionary class]] ? languages[@"common"] : nil;
|
||||
if (!commonMap && ![languages[lc] isKindOfClass:[NSDictionary class]] && ![languages[baseLc] isKindOfClass:[NSDictionary class]]) { return; }
|
||||
|
||||
for (UIView *row in self.rowViews) {
|
||||
NSArray<KBKeyButton *> *buttons = [self.interactionHandler collectKeyButtonsInView:row];
|
||||
for (KBKeyButton *btn in buttons) {
|
||||
if (![btn isKindOfClass:[KBKeyButton class]]) { continue; }
|
||||
if (btn.key.type != KBKeyTypeCharacter) { continue; }
|
||||
NSArray<NSString *> *variants = [self kb_variantsForKey:btn.key languageCode:lc];
|
||||
if (variants.count <= 1) { continue; }
|
||||
|
||||
NSNumber *bound = objc_getAssociatedObject(btn, kKBDiacriticsLongPressBoundKey);
|
||||
if (bound.boolValue) { continue; }
|
||||
objc_setAssociatedObject(btn, kKBDiacriticsLongPressBoundKey, @(YES), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
|
||||
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self
|
||||
action:@selector(onKeyVariantLongPress:)];
|
||||
longPress.minimumPressDuration = kKBKeyVariantLongPressMinDuration;
|
||||
longPress.allowableMovement = CGFLOAT_MAX;
|
||||
longPress.cancelsTouchesInView = NO;
|
||||
[btn addGestureRecognizer:longPress];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onKeyVariantLongPress:(UILongPressGestureRecognizer *)gr {
|
||||
KBKeyButton *button = (KBKeyButton *)gr.view;
|
||||
if (![button isKindOfClass:[KBKeyButton class]]) { return; }
|
||||
if (button.key.type != KBKeyTypeCharacter) { return; }
|
||||
|
||||
NSString *lc = kb_normalizeLanguageCode(self.kb_currentLanguageCode);
|
||||
NSArray<NSString *> *variants = [self kb_variantsForKey:button.key languageCode:lc];
|
||||
if (variants.count <= 1) { return; }
|
||||
|
||||
switch (gr.state) {
|
||||
case UIGestureRecognizerStateBegan: {
|
||||
[self hidePreview];
|
||||
self.kb_variantAnchorButton = button;
|
||||
self.kb_variantBaseOutput = (button.key.output.length > 0 ? button.key.output : button.key.title) ?: @"";
|
||||
self.kb_variantBaseDeleted = NO;
|
||||
self.kb_variantBaseDeleteCount = kb_composedCharacterCount(self.kb_variantBaseOutput);
|
||||
if (@available(iOS 10.0, *)) {
|
||||
UIImpactFeedbackGenerator *gen = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
[gen prepare];
|
||||
[gen impactOccurred];
|
||||
}
|
||||
|
||||
if (!self.kb_variantPopupView) {
|
||||
self.kb_variantPopupView = [[KBKeyVariantPopupView alloc] initWithFrame:CGRectZero];
|
||||
self.kb_variantPopupView.hidden = YES;
|
||||
[self addSubview:self.kb_variantPopupView];
|
||||
} else if (self.kb_variantPopupView.superview != self) {
|
||||
[self addSubview:self.kb_variantPopupView];
|
||||
}
|
||||
[self.kb_variantPopupView applyTheme];
|
||||
[self.kb_variantPopupView configureWithVariants:variants selectedIndex:0];
|
||||
|
||||
CGSize preferred = [self.kb_variantPopupView preferredSize];
|
||||
CGRect anchorFrame = [button convertRect:button.bounds toView:self];
|
||||
CGFloat x = CGRectGetMidX(anchorFrame) - preferred.width * 0.5;
|
||||
CGFloat maxX = CGRectGetWidth(self.bounds) - 6.0 - preferred.width;
|
||||
x = MIN(MAX(6.0, x), MAX(6.0, maxX));
|
||||
CGFloat y = CGRectGetMinY(anchorFrame) - preferred.height - kKBKeyVariantPopupAnchorGap;
|
||||
y = MAX(2.0, y);
|
||||
self.kb_variantPopupView.frame = CGRectMake(x, y, preferred.width, preferred.height);
|
||||
|
||||
self.kb_variantPopupView.alpha = 0.0;
|
||||
self.kb_variantPopupView.hidden = NO;
|
||||
[self bringSubviewToFront:self.kb_variantPopupView];
|
||||
[UIView animateWithDuration:0.12
|
||||
delay:0
|
||||
options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseOut
|
||||
animations:^{
|
||||
self.kb_variantPopupView.alpha = 1.0;
|
||||
}
|
||||
completion:nil];
|
||||
} break;
|
||||
case UIGestureRecognizerStateChanged: {
|
||||
if (self.kb_variantPopupView.hidden) { return; }
|
||||
CGPoint p = [gr locationInView:self.kb_variantPopupView];
|
||||
[self.kb_variantPopupView updateSelectionWithPointInSelf:p];
|
||||
if (!self.kb_variantBaseDeleted && self.kb_variantPopupView.selectedIndex != 0) {
|
||||
[self kb_emitBackspaceCountForVariantReplacement:self.kb_variantBaseDeleteCount];
|
||||
self.kb_variantBaseDeleted = YES;
|
||||
}
|
||||
} break;
|
||||
case UIGestureRecognizerStateEnded: {
|
||||
if (self.kb_variantPopupView.hidden) { return; }
|
||||
CGPoint p = [gr locationInView:self.kb_variantPopupView];
|
||||
[self.kb_variantPopupView updateSelectionWithPointInSelf:p];
|
||||
[self kb_commitSelectedVariantAndCleanupWithIdentifier:button.key.identifier];
|
||||
} break;
|
||||
case UIGestureRecognizerStateCancelled:
|
||||
case UIGestureRecognizerStateFailed: {
|
||||
if (self.kb_variantPopupView.hidden) { return; }
|
||||
// 取消时尽量保持“原本已经插入的 base 字符”不变:若已删除则补回
|
||||
if (self.kb_variantBaseDeleted && self.kb_variantBaseOutput.length > 0) {
|
||||
[self kb_emitCharacterText:self.kb_variantBaseOutput identifier:button.key.identifier];
|
||||
}
|
||||
[self kb_hideVariantPopupIfNeeded];
|
||||
} break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_emitBackspaceCountForVariantReplacement:(NSUInteger)count {
|
||||
if (count == 0) { count = 1; }
|
||||
if (![self.delegate respondsToSelector:@selector(keyboardView:didTapKey:)]) { return; }
|
||||
KBKey *backspace = [KBKey keyWithTitle:@"" type:KBKeyTypeBackspace];
|
||||
for (NSUInteger i = 0; i < count; i++) {
|
||||
[self.delegate keyboardView:self didTapKey:backspace];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_emitCharacterText:(NSString *)text identifier:(NSString *)identifier {
|
||||
if (text.length == 0) { return; }
|
||||
if (![self.delegate respondsToSelector:@selector(keyboardView:didTapKey:)]) { return; }
|
||||
KBKey *key = [KBKey keyWithIdentifier:identifier title:text output:text type:KBKeyTypeCharacter];
|
||||
[self.delegate keyboardView:self didTapKey:key];
|
||||
}
|
||||
|
||||
- (void)kb_commitSelectedVariantAndCleanupWithIdentifier:(NSString *)identifier {
|
||||
NSInteger index = self.kb_variantPopupView.selectedIndex;
|
||||
NSString *selected = [self.kb_variantPopupView selectedVariant] ?: @"";
|
||||
NSString *base = self.kb_variantBaseOutput ?: @"";
|
||||
|
||||
if (index <= 0 || selected.length == 0) {
|
||||
if (self.kb_variantBaseDeleted && base.length > 0) {
|
||||
[self kb_emitCharacterText:base identifier:identifier];
|
||||
}
|
||||
[self kb_hideVariantPopupIfNeeded];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self.kb_variantBaseDeleted) {
|
||||
[self kb_emitBackspaceCountForVariantReplacement:self.kb_variantBaseDeleteCount];
|
||||
self.kb_variantBaseDeleted = YES;
|
||||
}
|
||||
[self kb_emitCharacterText:selected identifier:identifier];
|
||||
[self kb_hideVariantPopupIfNeeded];
|
||||
}
|
||||
|
||||
- (NSArray<NSString *> *)kb_variantsForKey:(KBKey *)key languageCode:(NSString *)languageCode {
|
||||
if (!key || key.type != KBKeyTypeCharacter) { return @[]; }
|
||||
NSString *base = (key.output.length > 0 ? key.output : key.title) ?: @"";
|
||||
if (base.length == 0) { return @[]; }
|
||||
|
||||
NSDictionary<NSString *, NSDictionary<NSString *, NSArray<NSString *> *> *> *languages = kb_diacriticsLanguagesMap();
|
||||
NSString *lc = kb_normalizeLanguageCode(languageCode);
|
||||
NSDictionary<NSString *, NSArray<NSString *> *> *langMap = [languages[lc] isKindOfClass:[NSDictionary class]] ? languages[lc] : nil;
|
||||
if (langMap.count == 0) {
|
||||
NSString *baseLc = kb_baseLanguageCode(languageCode);
|
||||
langMap = [languages[baseLc] isKindOfClass:[NSDictionary class]] ? languages[baseLc] : nil;
|
||||
}
|
||||
NSDictionary<NSString *, NSArray<NSString *> *> *commonMap = [languages[@"common"] isKindOfClass:[NSDictionary class]] ? languages[@"common"] : nil;
|
||||
if (langMap.count == 0 && commonMap.count == 0) { return @[]; }
|
||||
|
||||
BOOL hasLetter = NO;
|
||||
NSCharacterSet *letters = [NSCharacterSet letterCharacterSet];
|
||||
for (NSUInteger i = 0; i < base.length; i++) {
|
||||
unichar c = [base characterAtIndex:i];
|
||||
if ([letters characterIsMember:c]) {
|
||||
hasLetter = YES;
|
||||
break;
|
||||
}
|
||||
}
|
||||
BOOL upper = hasLetter && [base isEqualToString:base.uppercaseString] && ![base isEqualToString:base.lowercaseString];
|
||||
NSString *queryKey = upper ? base.lowercaseString : base;
|
||||
|
||||
id raw = langMap[queryKey];
|
||||
if (!(raw && [raw isKindOfClass:[NSArray class]] && ((NSArray *)raw).count > 0)) {
|
||||
raw = commonMap[queryKey];
|
||||
}
|
||||
if (![raw isKindOfClass:[NSArray class]]) { return @[]; }
|
||||
|
||||
NSMutableArray<NSString *> *variants = [NSMutableArray array];
|
||||
for (id obj in (NSArray *)raw) {
|
||||
if (![obj isKindOfClass:[NSString class]]) { continue; }
|
||||
NSString *s = (NSString *)obj;
|
||||
if (s.length == 0) { continue; }
|
||||
[variants addObject:s];
|
||||
}
|
||||
|
||||
if (variants.count == 0) { return @[]; }
|
||||
if (![[variants firstObject] isEqualToString:queryKey]) {
|
||||
[variants insertObject:queryKey atIndex:0];
|
||||
}
|
||||
if (variants.count <= 1) { return @[]; }
|
||||
|
||||
if (!upper) {
|
||||
return variants.copy;
|
||||
}
|
||||
|
||||
NSMutableArray<NSString *> *upperVariants = [NSMutableArray arrayWithCapacity:variants.count];
|
||||
for (NSString *s in variants) {
|
||||
[upperVariants addObject:s.uppercaseString];
|
||||
}
|
||||
return upperVariants.copy;
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (NSMutableArray<UIView *> *)rowViews {
|
||||
if (!_rowViews) _rowViews = [NSMutableArray array];
|
||||
return _rowViews;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,20 +0,0 @@
|
||||
//
|
||||
// KBSettingView.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// 简单的设置页面:左上角返回箭头按钮 + 占位内容区域。
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBSettingView : UIView
|
||||
|
||||
/// 左上角返回按钮(外部添加 target 实现返回)
|
||||
@property (nonatomic, strong, readonly) UIButton *backButton;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
//
|
||||
// KBSettingView.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBSettingView.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@interface KBSettingView ()
|
||||
@property (nonatomic, strong) UIButton *backButtonInternal;
|
||||
@property (nonatomic, strong) UILabel *titleLabel;
|
||||
@end
|
||||
|
||||
@implementation KBSettingView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
// 背景做成淡色,和主界面区分
|
||||
self.backgroundColor = [UIColor colorWithWhite:1 alpha:0.96];
|
||||
|
||||
[self addSubview:self.backButtonInternal];
|
||||
[self.backButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.mas_left).offset(10);
|
||||
make.top.equalTo(self.mas_top).offset(8);
|
||||
make.width.height.mas_equalTo(32);
|
||||
}];
|
||||
|
||||
self.titleLabel = [[UILabel alloc] init];
|
||||
self.titleLabel.text = KBLocalized(@"设置");
|
||||
self.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
|
||||
self.titleLabel.textColor = [UIColor blackColor];
|
||||
[self addSubview:self.titleLabel];
|
||||
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerY.equalTo(self.backButtonInternal.mas_centerY);
|
||||
make.centerX.equalTo(self.mas_centerX);
|
||||
}];
|
||||
|
||||
// 占位内容
|
||||
UILabel *place = [[UILabel alloc] init];
|
||||
place.text = KBLocalized(@"这里是设置内容占位");
|
||||
place.textColor = [UIColor darkGrayColor];
|
||||
place.font = [UIFont systemFontOfSize:14];
|
||||
[self addSubview:place];
|
||||
[place mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self);
|
||||
}];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIButton *)backButtonInternal {
|
||||
if (!_backButtonInternal) {
|
||||
_backButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
_backButtonInternal.layer.cornerRadius = 16;
|
||||
_backButtonInternal.layer.masksToBounds = YES;
|
||||
_backButtonInternal.backgroundColor = [UIColor colorWithWhite:0.95 alpha:1.0];
|
||||
[_backButtonInternal setTitle:@"←" forState:UIControlStateNormal]; // 返回箭头
|
||||
[_backButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
|
||||
_backButtonInternal.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
|
||||
}
|
||||
return _backButtonInternal;
|
||||
}
|
||||
|
||||
#pragma mark - Expose
|
||||
|
||||
- (UIButton *)backButton { return self.backButtonInternal; }
|
||||
|
||||
@end
|
||||
@@ -8,15 +8,37 @@
|
||||
#import "KBSkinManager.h"
|
||||
|
||||
@interface KBSuggestionBarView ()
|
||||
@property (nonatomic, strong) UIScrollView *scrollView;
|
||||
@property (nonatomic, strong) UIStackView *stackView;
|
||||
@property (nonatomic, strong) UIView *topLineView;
|
||||
@property (nonatomic, strong) UIView *bottomLineView;
|
||||
@property (nonatomic, copy) NSArray<NSString *> *items;
|
||||
@property (nonatomic, strong) UIColor *pillColor;
|
||||
@property (nonatomic, strong) UIColor *textColor;
|
||||
@property (nonatomic, strong) UIColor *separatorColor;
|
||||
@property (nonatomic, strong) UIColor *highlightColor;
|
||||
@end
|
||||
|
||||
@implementation KBSuggestionBarView
|
||||
|
||||
- (NSInteger)kb_buttonTag {
|
||||
return 10001;
|
||||
}
|
||||
|
||||
- (NSInteger)kb_separatorTag {
|
||||
return 10002;
|
||||
}
|
||||
|
||||
- (UIImage *)kb_imageWithColor:(UIColor *)color {
|
||||
if (!color) { color = [UIColor clearColor]; }
|
||||
CGRect rect = CGRectMake(0, 0, 1, 1);
|
||||
UIGraphicsBeginImageContextWithOptions(rect.size, NO, 0);
|
||||
CGContextRef ctx = UIGraphicsGetCurrentContext();
|
||||
CGContextSetFillColorWithColor(ctx, color.CGColor);
|
||||
CGContextFillRect(ctx, rect);
|
||||
UIImage *img = UIGraphicsGetImageFromCurrentImageContext();
|
||||
UIGraphicsEndImageContext();
|
||||
return img;
|
||||
}
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
@@ -26,15 +48,23 @@
|
||||
}
|
||||
|
||||
- (void)setupUI {
|
||||
[self addSubview:self.scrollView];
|
||||
[self.scrollView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self);
|
||||
[self addSubview:self.stackView];
|
||||
[self.stackView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self);
|
||||
make.top.equalTo(self);
|
||||
make.bottom.equalTo(self);
|
||||
}];
|
||||
|
||||
[self.scrollView addSubview:self.stackView];
|
||||
[self.stackView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.scrollView).insets(UIEdgeInsetsMake(0, 8, 0, 8));
|
||||
make.height.equalTo(self.scrollView);
|
||||
[self addSubview:self.topLineView];
|
||||
[self.topLineView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.top.equalTo(self);
|
||||
make.height.mas_equalTo(0.5);
|
||||
}];
|
||||
|
||||
[self addSubview:self.bottomLineView];
|
||||
[self.bottomLineView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.bottom.equalTo(self);
|
||||
make.height.mas_equalTo(0.5);
|
||||
}];
|
||||
|
||||
[self applyTheme:[KBSkinManager shared].current];
|
||||
@@ -48,35 +78,75 @@
|
||||
[view removeFromSuperview];
|
||||
}
|
||||
|
||||
for (NSString *item in self.items) {
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
btn.layer.cornerRadius = 12.0;
|
||||
NSUInteger count = self.items.count;
|
||||
for (NSUInteger idx = 0; idx < count; idx++) {
|
||||
NSString *item = self.items[idx];
|
||||
UIView *container = [UIView new];
|
||||
container.backgroundColor = UIColor.clearColor;
|
||||
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
btn.tag = [self kb_buttonTag];
|
||||
btn.backgroundColor = UIColor.clearColor;
|
||||
btn.layer.cornerRadius = 8.0;
|
||||
btn.layer.masksToBounds = YES;
|
||||
btn.backgroundColor = self.pillColor ?: [UIColor colorWithWhite:1 alpha:0.9];
|
||||
btn.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
|
||||
btn.adjustsImageWhenHighlighted = NO;
|
||||
btn.titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightRegular];
|
||||
btn.titleLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||
btn.titleLabel.adjustsFontSizeToFitWidth = YES;
|
||||
btn.titleLabel.minimumScaleFactor = 0.85;
|
||||
btn.contentEdgeInsets = UIEdgeInsetsMake(0, 12, 0, 12);
|
||||
[btn setTitle:item forState:UIControlStateNormal];
|
||||
[btn setTitleColor:self.textColor ?: [UIColor blackColor] forState:UIControlStateNormal];
|
||||
btn.contentEdgeInsets = UIEdgeInsetsMake(4, 10, 4, 10);
|
||||
[btn setTitleColor:self.textColor ?: UIColor.blackColor forState:UIControlStateNormal];
|
||||
if (self.highlightColor) {
|
||||
[btn setBackgroundImage:[self kb_imageWithColor:self.highlightColor] forState:UIControlStateHighlighted];
|
||||
}
|
||||
[btn addTarget:self action:@selector(onTapSuggestion:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.stackView addArrangedSubview:btn];
|
||||
[container addSubview:btn];
|
||||
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(container);
|
||||
}];
|
||||
|
||||
if (idx + 1 < count) {
|
||||
UIView *sep = [UIView new];
|
||||
sep.tag = [self kb_separatorTag];
|
||||
sep.userInteractionEnabled = NO;
|
||||
sep.backgroundColor = self.separatorColor ?: [UIColor colorWithWhite:0 alpha:0.12];
|
||||
[container addSubview:sep];
|
||||
[sep mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(container.mas_right);
|
||||
make.centerY.equalTo(container.mas_centerY);
|
||||
make.width.mas_equalTo(0.5);
|
||||
make.height.equalTo(container.mas_height).multipliedBy(0.55);
|
||||
}];
|
||||
}
|
||||
|
||||
[self.stackView addArrangedSubview:container];
|
||||
}
|
||||
|
||||
self.hidden = (self.items.count == 0);
|
||||
}
|
||||
|
||||
- (void)applyTheme:(KBSkinTheme *)theme {
|
||||
UIColor *bg = theme.keyBackground ?: [UIColor whiteColor];
|
||||
UIColor *text = theme.keyTextColor ?: [UIColor blackColor];
|
||||
UIColor *barBg = [UIColor colorWithHex:0xD1D3DB];
|
||||
UIColor *barBg = theme.keyboardBackground ?: [UIColor colorWithWhite:0.95 alpha:1.0];
|
||||
UIColor *text = theme.keyTextColor ?: UIColor.blackColor;
|
||||
UIColor *highlight = theme.keyHighlightBackground ?: [UIColor colorWithWhite:0.85 alpha:1.0];
|
||||
self.backgroundColor = barBg;
|
||||
self.pillColor = bg;
|
||||
self.textColor = text;
|
||||
self.separatorColor = [text colorWithAlphaComponent:0.12];
|
||||
self.highlightColor = [highlight colorWithAlphaComponent:0.25];
|
||||
self.topLineView.backgroundColor = [text colorWithAlphaComponent:0.08];
|
||||
self.bottomLineView.backgroundColor = [text colorWithAlphaComponent:0.06];
|
||||
|
||||
for (UIView *view in self.stackView.arrangedSubviews) {
|
||||
if (![view isKindOfClass:[UIButton class]]) { continue; }
|
||||
UIButton *btn = (UIButton *)view;
|
||||
btn.backgroundColor = self.pillColor;
|
||||
[btn setTitleColor:self.textColor forState:UIControlStateNormal];
|
||||
UIButton *btn = (UIButton *)[view viewWithTag:[self kb_buttonTag]];
|
||||
if ([btn isKindOfClass:UIButton.class]) {
|
||||
[btn setTitleColor:self.textColor ?: UIColor.blackColor forState:UIControlStateNormal];
|
||||
[btn setBackgroundImage:[self kb_imageWithColor:self.highlightColor] forState:UIControlStateHighlighted];
|
||||
}
|
||||
UIView *sep = [view viewWithTag:[self kb_separatorTag]];
|
||||
if (sep) {
|
||||
sep.backgroundColor = self.separatorColor ?: [UIColor colorWithWhite:0 alpha:0.12];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,23 +162,33 @@
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIScrollView *)scrollView {
|
||||
if (!_scrollView) {
|
||||
_scrollView = [[UIScrollView alloc] init];
|
||||
_scrollView.showsHorizontalScrollIndicator = NO;
|
||||
_scrollView.alwaysBounceHorizontal = YES;
|
||||
}
|
||||
return _scrollView;
|
||||
}
|
||||
|
||||
- (UIStackView *)stackView {
|
||||
if (!_stackView) {
|
||||
_stackView = [[UIStackView alloc] init];
|
||||
_stackView.axis = UILayoutConstraintAxisHorizontal;
|
||||
_stackView.alignment = UIStackViewAlignmentCenter;
|
||||
_stackView.spacing = 8.0;
|
||||
_stackView.alignment = UIStackViewAlignmentFill;
|
||||
_stackView.distribution = UIStackViewDistributionFillEqually;
|
||||
_stackView.spacing = 0.0;
|
||||
_stackView.layoutMarginsRelativeArrangement = YES;
|
||||
_stackView.layoutMargins = UIEdgeInsetsMake(0, 8, 0, 8);
|
||||
}
|
||||
return _stackView;
|
||||
}
|
||||
|
||||
- (UIView *)topLineView {
|
||||
if (!_topLineView) {
|
||||
_topLineView = [UIView new];
|
||||
_topLineView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.06];
|
||||
}
|
||||
return _topLineView;
|
||||
}
|
||||
|
||||
- (UIView *)bottomLineView {
|
||||
if (!_bottomLineView) {
|
||||
_bottomLineView = [UIView new];
|
||||
_bottomLineView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.04];
|
||||
}
|
||||
return _bottomLineView;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -15,8 +15,6 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
@optional
|
||||
/// 左侧功能按钮点击(index 从 0 开始)
|
||||
- (void)toolBar:(KBToolBar *)toolBar didTapActionAtIndex:(NSInteger)index;
|
||||
/// 右侧设置按钮点击
|
||||
- (void)toolBarDidTapSettings:(KBToolBar *)toolBar;
|
||||
/// 右侧撤销删除按钮点击
|
||||
- (void)toolBarDidTapUndo:(KBToolBar *)toolBar;
|
||||
@end
|
||||
@@ -31,7 +29,6 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 暴露按钮以便外部定制(只读;首次访问时懒加载创建)
|
||||
@property (nonatomic, strong, readonly) NSArray<UIButton *> *leftButtons;
|
||||
@property (nonatomic, strong, readonly) UIButton *settingsButton;
|
||||
@property (nonatomic, strong, readonly) UIButton *undoButton;
|
||||
|
||||
/// 应用皮肤(更新 AI 按钮背景图)
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
@interface KBToolBar ()
|
||||
@property (nonatomic, strong) UIView *leftContainer;
|
||||
@property (nonatomic, strong) NSArray<UIButton *> *leftButtonsInternal;
|
||||
//@property (nonatomic, strong) UIButton *settingsButtonInternal;
|
||||
@property (nonatomic, strong) UIButton *globeButtonInternal; // 可选:系统“切换输入法”键
|
||||
@property (nonatomic, strong) UIImageView *avatarImageView; // 右侧头像(AppGroup persona_cover.jpg)
|
||||
@property (nonatomic, strong) UIButton *undoButtonInternal; // 右侧撤销删除
|
||||
@@ -42,6 +41,10 @@
|
||||
selector:@selector(kb_undoStateChanged)
|
||||
name:KBBackspaceUndoStateDidChangeNotification
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(kb_skinDidChange)
|
||||
name:KBSkinDidChangeNotification
|
||||
object:nil];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
@@ -61,10 +64,6 @@
|
||||
return self.undoButtonInternal;
|
||||
}
|
||||
|
||||
//- (UIButton *)settingsButton {
|
||||
// return self.settingsButtonInternal;
|
||||
//}
|
||||
|
||||
- (void)setLeftButtonTitles:(NSArray<NSString *> *)leftButtonTitles {
|
||||
_leftButtonTitles = [leftButtonTitles copy];
|
||||
// Update titles if buttons already exist
|
||||
@@ -80,18 +79,10 @@
|
||||
|
||||
- (void)setupUI {
|
||||
[self addSubview:self.leftContainer];
|
||||
// [self addSubview:self.settingsButtonInternal];
|
||||
[self addSubview:self.globeButtonInternal];
|
||||
[self addSubview:self.undoButtonInternal];
|
||||
[self addSubview:self.avatarImageView];
|
||||
|
||||
// 右侧设置按钮
|
||||
// [self.settingsButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
// make.right.equalTo(self.mas_right).offset(-12);
|
||||
// make.centerY.equalTo(self.mas_centerY);
|
||||
// make.width.height.mas_equalTo(32);
|
||||
// }];
|
||||
|
||||
// 左侧地球键(按需显示)
|
||||
[self.globeButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.mas_left).offset(12);
|
||||
@@ -183,9 +174,14 @@
|
||||
KBSkinManager *skinManager = [KBSkinManager shared];
|
||||
UIImage *icon = [skinManager iconImageForKeyIdentifier:kKBAIKeyIdentifier caseVariant:0];
|
||||
NSString *skinId = skinManager.current.skinId ?: @"";
|
||||
BOOL usingDefaultSkin = (skinId.length == 0 || [skinId isEqualToString:@"default"]);
|
||||
if (!icon && usingDefaultSkin) {
|
||||
|
||||
NSLog(@"[KBToolBar] kb_updateAIButtonAppearance: skinId=%@ icon=%@",
|
||||
skinId, icon ? @"有" : @"nil");
|
||||
|
||||
// 如果当前皮肤没有 ai 图标,使用默认图标
|
||||
if (!icon) {
|
||||
icon = [UIImage imageNamed:@"ai_key_icon"];
|
||||
NSLog(@"[KBToolBar] fallback to bundled ai_key_icon: %@", icon ? @"有" : @"nil");
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
@@ -304,12 +300,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onSettings {
|
||||
if ([self.delegate respondsToSelector:@selector(toolBarDidTapSettings:)]) {
|
||||
[self.delegate toolBarDidTapSettings:self];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onUndo {
|
||||
if ([self.delegate respondsToSelector:@selector(toolBarDidTapUndo:)]) {
|
||||
[self.delegate toolBarDidTapUndo:self];
|
||||
@@ -353,19 +343,6 @@
|
||||
return _avatarImageView;
|
||||
}
|
||||
|
||||
//- (UIButton *)settingsButtonInternal {
|
||||
// if (!_settingsButtonInternal) {
|
||||
// _settingsButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
// _settingsButtonInternal.layer.cornerRadius = 16;
|
||||
// _settingsButtonInternal.layer.masksToBounds = YES;
|
||||
// _settingsButtonInternal.backgroundColor = [UIColor colorWithWhite:1 alpha:0.9];
|
||||
// [_settingsButtonInternal setTitle:@"⚙︎" forState:UIControlStateNormal]; // 简单的齿轮符号
|
||||
// [_settingsButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
|
||||
// [_settingsButtonInternal addTarget:self action:@selector(onSettings) forControlEvents:UIControlEventTouchUpInside];
|
||||
// }
|
||||
// return _settingsButtonInternal;
|
||||
//}
|
||||
|
||||
- (UIButton *)globeButtonInternal {
|
||||
if (!_globeButtonInternal) {
|
||||
_globeButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
@@ -472,6 +449,10 @@
|
||||
[self kb_updateUndoVisibilityAnimated:YES];
|
||||
}
|
||||
|
||||
- (void)kb_skinDidChange {
|
||||
[self kb_applyTheme];
|
||||
}
|
||||
|
||||
- (void)kb_updateUndoVisibilityAnimated:(BOOL)animated {
|
||||
BOOL visible = [KBBackspaceUndoManager shared].hasUndo;
|
||||
if (self.kbUndoVisible == visible) { return; }
|
||||
|
||||
13
Podfile
13
Podfile
@@ -6,7 +6,8 @@ target 'keyBoard' do
|
||||
# Comment the next line if you don't want to use dynamic frameworks
|
||||
use_frameworks!
|
||||
|
||||
pod 'AFNetworking','4.0.1'
|
||||
# pod 'AFNetworking','4.0.1'
|
||||
pod 'AFNetworking', :git => 'https://github.com/gzx543097079/AppStorePrivacyInfo.git', :tag => 'AFNetworking-4.0.1.1'
|
||||
pod 'Bugly', :configurations => ['Release']
|
||||
pod 'DZNEmptyDataSet', '1.8.1'
|
||||
pod 'FLAnimatedImage', '~> 1.0.17'
|
||||
@@ -17,7 +18,8 @@ target 'keyBoard' do
|
||||
pod 'LookinServer', :configurations => ['Debug']
|
||||
pod 'LYEmptyView', '~> 1.3.1'
|
||||
pod 'Masonry', '1.1.0'
|
||||
pod 'MBProgressHUD', '1.2.0'
|
||||
pod 'MBProgressHUD',:git => 'https://github.com/jdg/MBProgressHUD.git', :commit => '18c442d57398cee5ef57f852df10fc5ff65f0763'
|
||||
# pod 'MBProgressHUD', '1.2.0'
|
||||
pod 'MJExtension', '3.4.2'
|
||||
pod 'MJRefresh', '3.7.9'
|
||||
pod 'SDWebImage', '5.21.1'
|
||||
@@ -28,11 +30,14 @@ target 'CustomKeyboard' do
|
||||
# Comment the next line if you don't want to use dynamic frameworks
|
||||
use_frameworks!
|
||||
|
||||
pod 'AFNetworking','4.0.1'
|
||||
# pod 'AFNetworking','4.0.1'
|
||||
pod 'AFNetworking', :git => 'https://github.com/gzx543097079/AppStorePrivacyInfo.git', :tag => 'AFNetworking-4.0.1.1'
|
||||
pod 'SDWebImage', '5.21.1'
|
||||
|
||||
pod 'Masonry', '1.1.0'
|
||||
pod 'MBProgressHUD', '1.2.0'
|
||||
# pod 'MBProgressHUD', '1.2.0'
|
||||
pod 'MBProgressHUD',:git => 'https://github.com/jdg/MBProgressHUD.git', :commit => '18c442d57398cee5ef57f852df10fc5ff65f0763'
|
||||
|
||||
pod 'MJExtension', '3.4.2'
|
||||
pod 'DZNEmptyDataSet', '1.8.1'
|
||||
pod 'SSZipArchive', '~> 2.4.3'
|
||||
|
||||
28
Podfile.lock
28
Podfile.lock
@@ -40,7 +40,7 @@ PODS:
|
||||
- SSZipArchive (2.4.3)
|
||||
|
||||
DEPENDENCIES:
|
||||
- AFNetworking (= 4.0.1)
|
||||
- AFNetworking (from `https://github.com/gzx543097079/AppStorePrivacyInfo.git`, tag `AFNetworking-4.0.1.1`)
|
||||
- Bugly
|
||||
- DZNEmptyDataSet (= 1.8.1)
|
||||
- FLAnimatedImage (~> 1.0.17)
|
||||
@@ -51,7 +51,7 @@ DEPENDENCIES:
|
||||
- LSTPopView (~> 0.3.10)
|
||||
- LYEmptyView (~> 1.3.1)
|
||||
- Masonry (= 1.1.0)
|
||||
- MBProgressHUD (= 1.2.0)
|
||||
- MBProgressHUD (from `https://github.com/jdg/MBProgressHUD.git`, commit `18c442d57398cee5ef57f852df10fc5ff65f0763`)
|
||||
- MJExtension (= 3.4.2)
|
||||
- MJRefresh (= 3.7.9)
|
||||
- SDWebImage (= 5.21.1)
|
||||
@@ -59,7 +59,6 @@ DEPENDENCIES:
|
||||
|
||||
SPEC REPOS:
|
||||
https://github.com/CocoaPods/Specs.git:
|
||||
- AFNetworking
|
||||
- Bugly
|
||||
- DZNEmptyDataSet
|
||||
- FLAnimatedImage
|
||||
@@ -71,14 +70,29 @@ SPEC REPOS:
|
||||
- LSTTimer
|
||||
- LYEmptyView
|
||||
- Masonry
|
||||
- MBProgressHUD
|
||||
- MJExtension
|
||||
- MJRefresh
|
||||
- SDWebImage
|
||||
- SSZipArchive
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
AFNetworking:
|
||||
:git: https://github.com/gzx543097079/AppStorePrivacyInfo.git
|
||||
:tag: AFNetworking-4.0.1.1
|
||||
MBProgressHUD:
|
||||
:commit: 18c442d57398cee5ef57f852df10fc5ff65f0763
|
||||
:git: https://github.com/jdg/MBProgressHUD.git
|
||||
|
||||
CHECKOUT OPTIONS:
|
||||
AFNetworking:
|
||||
:git: https://github.com/gzx543097079/AppStorePrivacyInfo.git
|
||||
:tag: AFNetworking-4.0.1.1
|
||||
MBProgressHUD:
|
||||
:commit: 18c442d57398cee5ef57f852df10fc5ff65f0763
|
||||
:git: https://github.com/jdg/MBProgressHUD.git
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
AFNetworking: 3bd23d814e976cd148d7d44c3ab78017b744cd58
|
||||
AFNetworking: 5dc31d980b5c60e63d2852bdd75252c3fd85a6d0
|
||||
Bugly: 217ac2ce5f0f2626d43dbaa4f70764c953a26a31
|
||||
DZNEmptyDataSet: 9525833b9e68ac21c30253e1d3d7076cc828eaa7
|
||||
FLAnimatedImage: bbf914596368867157cc71b38a8ec834b3eeb32b
|
||||
@@ -90,12 +104,12 @@ SPEC CHECKSUMS:
|
||||
LSTTimer: caf8f02ff366ca175cf4c1778d26c166183c1b6f
|
||||
LYEmptyView: b6d418cfa38b78df0cf243f9a9c25ccbdc399922
|
||||
Masonry: 678fab65091a9290e40e2832a55e7ab731aad201
|
||||
MBProgressHUD: 3ee5efcc380f6a79a7cc9b363dd669c5e1ae7406
|
||||
MBProgressHUD: 1b0fb447e80a0fda94808180750e8b78a07b3cd2
|
||||
MJExtension: e97d164cb411aa9795cf576093a1fa208b4a8dd8
|
||||
MJRefresh: ff9e531227924c84ce459338414550a05d2aea78
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
|
||||
|
||||
PODFILE CHECKSUM: 890d1710715c017d7364a19c871e9bdf0d685fbf
|
||||
PODFILE CHECKSUM: f79dfcf9dc400fbf6dc41f60d4b726fee361d430
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -227,7 +227,7 @@ static void AFNetworkReachabilityReleaseCallback(const void *info) {
|
||||
SCNetworkReachabilitySetCallback(self.networkReachability, AFNetworkReachabilityCallback, &context);
|
||||
SCNetworkReachabilityScheduleWithRunLoop(self.networkReachability, CFRunLoopGetMain(), kCFRunLoopCommonModes);
|
||||
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0),^{
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0),^{
|
||||
SCNetworkReachabilityFlags flags;
|
||||
if (SCNetworkReachabilityGetFlags(self.networkReachability, &flags)) {
|
||||
AFPostReachabilityStatusChange(flags, callback);
|
||||
@@ -278,7 +278,7 @@ forHTTPHeaderField:(NSString *)field;
|
||||
#pragma mark -
|
||||
|
||||
/**
|
||||
The `AFMultipartFormData` protocol defines the methods supported by the parameter in the block argument of `AFHTTPRequestSerializer -multipartFormRequestWithMethod:URLString:parameters:constructingBodyWithBlock:`.
|
||||
The `AFMultipartFormData` protocol defines the methods supported by the parameter in the block argument of `AFHTTPRequestSerializer -multipartFormRequestWithMethod:URLString:parameters:constructingBodyWithBlock:error:`.
|
||||
*/
|
||||
@protocol AFMultipartFormData
|
||||
|
||||
@@ -368,10 +368,8 @@ forHTTPHeaderField:(NSString *)field
|
||||
NSMutableURLRequest *mutableRequest = [[NSMutableURLRequest alloc] initWithURL:url];
|
||||
mutableRequest.HTTPMethod = method;
|
||||
|
||||
for (NSString *keyPath in AFHTTPRequestSerializerObservedKeyPaths()) {
|
||||
if ([self.mutableObservedChangedKeyPaths containsObject:keyPath]) {
|
||||
[mutableRequest setValue:[self valueForKeyPath:keyPath] forKey:keyPath];
|
||||
}
|
||||
for (NSString *keyPath in self.mutableObservedChangedKeyPaths) {
|
||||
[mutableRequest setValue:[self valueForKeyPath:keyPath] forKey:keyPath];
|
||||
}
|
||||
|
||||
mutableRequest = [[self requestBySerializingRequest:mutableRequest withParameters:parameters error:error] mutableCopy];
|
||||
@@ -316,10 +316,10 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@param block A block object to be executed when a connection level authentication challenge has occurred. The block returns the disposition of the authentication challenge, and takes three arguments: the session, the authentication challenge, and a pointer to the credential that should be used to resolve the challenge.
|
||||
|
||||
@warning Implementing a session authentication challenge handler yourself totally bypasses AFNetworking's security policy defined in `AFSecurityPolicy`. Make sure you fully understand the implications before implementing a custom session authentication challenge handler. If you do not want to bypass AFNetworking's security policy, use `setTaskDidReceiveAuthenticationChallengeBlock:` instead.
|
||||
@warning Implementing a session authentication challenge handler yourself totally bypasses AFNetworking's security policy defined in `AFSecurityPolicy`. Make sure you fully understand the implications before implementing a custom session authentication challenge handler. If you do not want to bypass AFNetworking's security policy, use `-setAuthenticationChallengeHandler:` instead.
|
||||
|
||||
@see -securityPolicy
|
||||
@see -setTaskDidReceiveAuthenticationChallengeBlock:
|
||||
@see -setAuthenticationChallengeHandler:
|
||||
*/
|
||||
- (void)setSessionDidReceiveAuthenticationChallengeBlock:(nullable NSURLSessionAuthChallengeDisposition (^)(NSURLSession *session, NSURLAuthenticationChallenge *challenge, NSURLCredential * _Nullable __autoreleasing * _Nullable credential))block;
|
||||
|
||||
@@ -416,7 +416,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
- (void)setDataTaskWillCacheResponseBlock:(nullable NSCachedURLResponse * (^)(NSURLSession *session, NSURLSessionDataTask *dataTask, NSCachedURLResponse *proposedResponse))block;
|
||||
|
||||
/**
|
||||
Sets a block to be executed once all messages enqueued for a session have been delivered, as handled by the `NSURLSessionDataDelegate` method `URLSessionDidFinishEventsForBackgroundURLSession:`.
|
||||
Sets a block to be executed once all messages enqueued for a session have been delivered, as handled by the `NSURLSessionDelegate` method `URLSessionDidFinishEventsForBackgroundURLSession:`.
|
||||
|
||||
@param block A block object to be executed once all messages enqueued for a session have been delivered. The block has no return value and takes a single argument: the session.
|
||||
*/
|
||||
@@ -2,9 +2,9 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>logFormatVersion</key>
|
||||
<integer>11</integer>
|
||||
<key>logs</key>
|
||||
<dict/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user