键盘添加撤销操作

This commit is contained in:
2025-12-19 19:21:08 +08:00
parent 68306aa07f
commit 1c8834caf6
10 changed files with 332 additions and 15 deletions

View File

@@ -19,6 +19,7 @@
#import "KBHostAppLauncher.h"
#import "KBKeyboardSubscriptionView.h"
#import "KBKeyboardSubscriptionProduct.h"
#import "KBBackspaceUndoManager.h"
// 使 static kb_consumePendingShopSkin
@interface KeyboardViewController (KBSkinShopBridge)
@@ -249,6 +250,9 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
// MARK: - KBKeyBoardMainViewDelegate
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapKey:(KBKey *)key {
if (key.type != KBKeyTypeShift && key.type != KBKeyTypeModeChange) {
[[KBBackspaceUndoManager shared] registerNonClearAction];
}
switch (key.type) {
case KBKeyTypeCharacter:
[self.textDocumentProxy insertText:key.output ?: key.title ?: @""]; break;
@@ -285,9 +289,14 @@ static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didSelectEmoji:(NSString *)emoji {
if (emoji.length == 0) { return; }
[[KBBackspaceUndoManager shared] registerNonClearAction];
[self.textDocumentProxy insertText:emoji];
}
- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView {
[[KBBackspaceUndoManager shared] performUndoFromResponder:self.view];
}
- (void)keyBoardMainViewDidTapEmojiSearch:(KBKeyBoardMainView *)keyBoardMainView {
[KBHUD showInfo:KBLocalized(@"Search coming soon")];
}

View File

@@ -6,6 +6,7 @@
#import "KBBackspaceLongPressHandler.h"
#import "KBResponderUtils.h"
#import "KBSkinManager.h"
#import "KBBackspaceUndoManager.h"
static const NSTimeInterval kKBBackspaceLongPressMinDuration = 0.35;
static const NSTimeInterval kKBBackspaceRepeatInterval = 0.06;
@@ -96,6 +97,7 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
}
switch (gr.state) {
case UIGestureRecognizerStateBegan: {
[[KBBackspaceUndoManager shared] registerNonClearAction];
self.backspaceHoldToken += 1;
NSUInteger token = self.backspaceHoldToken;
self.backspaceHoldActive = YES;
@@ -406,6 +408,12 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
#pragma mark - Clear
- (void)kb_clearAllInput {
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
UIInputViewController *ivc = KBFindInputViewController(start);
if (ivc) {
NSString *before = ivc.textDocumentProxy.documentContextBeforeInput ?: @"";
[[KBBackspaceUndoManager shared] recordClearWithContext:before];
}
self.backspaceClearToken += 1;
NSUInteger token = self.backspaceClearToken;
[self kb_clearAllInputStepForToken:token guard:0 emptyRounds:0];

View File

@@ -0,0 +1,29 @@
//
// KBBackspaceUndoManager.h
// CustomKeyboard
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
extern NSNotificationName const KBBackspaceUndoStateDidChangeNotification;
@interface KBBackspaceUndoManager : NSObject
@property (nonatomic, readonly) BOOL hasUndo;
+ (instancetype)shared;
/// 记录一次“立刻清空”删除的内容(基于 documentContextBeforeInput
- (void)recordClearWithContext:(NSString *)context;
/// 在指定 responder 处执行撤销(向光标处插回删除的内容)
- (void)performUndoFromResponder:(UIResponder *)responder;
/// 非清空行为触发时,清理撤销状态
- (void)registerNonClearAction;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,170 @@
//
// KBBackspaceUndoManager.m
// CustomKeyboard
//
#import "KBBackspaceUndoManager.h"
#import "KBResponderUtils.h"
NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspaceUndoStateDidChangeNotification";
@interface KBBackspaceUndoManager ()
@property (nonatomic, strong) NSMutableArray<NSString *> *segments; // deletion order (last -> first)
@property (nonatomic, assign) BOOL lastActionWasClear;
@property (nonatomic, assign) BOOL hasUndo;
@end
@implementation KBBackspaceUndoManager
+ (instancetype)shared {
static KBBackspaceUndoManager *mgr = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
mgr = [[KBBackspaceUndoManager alloc] init];
});
return mgr;
}
- (instancetype)init {
if (self = [super init]) {
_segments = [NSMutableArray array];
}
return self;
}
- (void)recordClearWithContext:(NSString *)context {
if (context.length == 0) { return; }
NSString *segment = [self kb_segmentForClearFromContext:context];
if (segment.length == 0) { return; }
if (!self.lastActionWasClear) {
[self.segments removeAllObjects];
}
[self.segments addObject:segment];
self.lastActionWasClear = YES;
[self kb_updateHasUndo:YES];
}
- (void)performUndoFromResponder:(UIResponder *)responder {
if (self.segments.count == 0) { return; }
UIInputViewController *ivc = KBFindInputViewController(responder);
if (!ivc) { return; }
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
NSString *text = [self kb_buildUndoText];
if (text.length == 0) { return; }
[proxy insertText:text];
[self.segments removeAllObjects];
self.lastActionWasClear = NO;
[self kb_updateHasUndo:NO];
}
- (void)registerNonClearAction {
self.lastActionWasClear = NO;
if (self.segments.count == 0) { return; }
[self.segments removeAllObjects];
[self kb_updateHasUndo:NO];
}
#pragma mark - Helpers
- (void)kb_updateHasUndo:(BOOL)hasUndo {
if (self.hasUndo == hasUndo) { return; }
self.hasUndo = hasUndo;
[[NSNotificationCenter defaultCenter] postNotificationName:KBBackspaceUndoStateDidChangeNotification object:self];
}
- (NSString *)kb_segmentForClearFromContext:(NSString *)context {
NSInteger length = context.length;
if (length == 0) { return @""; }
static NSCharacterSet *sentenceBoundarySet = nil;
static NSCharacterSet *whitespaceSet = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
});
NSInteger end = length;
while (end > 0) {
unichar ch = [context characterAtIndex:end - 1];
if ([whitespaceSet characterIsMember:ch]) {
end -= 1;
} else {
break;
}
}
NSInteger searchEnd = end;
while (searchEnd > 0) {
unichar ch = [context characterAtIndex:searchEnd - 1];
if ([sentenceBoundarySet characterIsMember:ch]) {
searchEnd -= 1;
} else {
break;
}
}
NSInteger boundaryIndex = NSNotFound;
for (NSInteger i = searchEnd - 1; i >= 0; i--) {
unichar ch = [context characterAtIndex:i];
if ([sentenceBoundarySet characterIsMember:ch]) {
boundaryIndex = i;
break;
}
}
NSInteger start = (boundaryIndex == NSNotFound) ? 0 : (boundaryIndex + 1);
if (start >= length) { return @""; }
return [context substringFromIndex:start];
}
- (NSString *)kb_buildUndoText {
if (self.segments.count == 0) { return @""; }
NSArray<NSString *> *ordered = [[self.segments reverseObjectEnumerator] allObjects];
NSMutableString *result = [NSMutableString string];
for (NSInteger i = 0; i < ordered.count; i++) {
NSString *segment = ordered[i] ?: @"";
if (segment.length == 0) { continue; }
if (i < ordered.count - 1) {
segment = [self kb_replaceTrailingBoundaryWithComma:segment];
}
[result appendString:segment];
}
return result;
}
- (NSString *)kb_replaceTrailingBoundaryWithComma:(NSString *)segment {
if (segment.length == 0) { return segment; }
static NSCharacterSet *boundarySet = nil;
static NSCharacterSet *englishBoundarySet = nil;
static NSCharacterSet *whitespaceSet = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
boundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
englishBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;"];
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
});
NSInteger idx = segment.length - 1;
while (idx >= 0) {
unichar ch = [segment characterAtIndex:idx];
if ([whitespaceSet characterIsMember:ch]) {
idx -= 1;
continue;
}
if (![boundarySet characterIsMember:ch]) {
return segment;
}
NSString *comma = [englishBoundarySet characterIsMember:ch] ? @"," : @"";
NSMutableString *mutable = [segment mutableCopy];
NSRange r = NSMakeRange(idx, 1);
[mutable replaceCharactersInRange:r withString:comma];
return mutable;
}
return segment;
}
@end

View File

@@ -26,6 +26,7 @@
#import <MJExtension/MJExtension.h>
#import "KBBizCode.h"
#import "KBBackspaceLongPressHandler.h"
#import "KBBackspaceUndoManager.h"
@interface KBFunctionView () <KBFunctionBarViewDelegate, KBStreamOverlayViewDelegate, KBFunctionTagListViewDelegate>
// UI
@@ -769,6 +770,7 @@ static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, C
}
- (void)onTapDelete {
NSLog(@"点击:删除");
[[KBBackspaceUndoManager shared] registerNonClearAction];
UIInputViewController *ivc = KBFindInputViewController(self);
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
[proxy deleteBackward];
@@ -779,6 +781,7 @@ static void KBULDarwinCallback(CFNotificationCenterRef center, void *observer, C
}
- (void)onTapSend {
NSLog(@"点击:发送");
[[KBBackspaceUndoManager shared] registerNonClearAction];
// App
UIInputViewController *ivc = KBFindInputViewController(self);
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;

View File

@@ -23,6 +23,8 @@ NS_ASSUME_NONNULL_BEGIN
/// 点击了右侧设置按钮
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView;
/// 点击了撤销删除按钮
- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView;
/// emoji 视图里选择了一个表情
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didSelectEmoji:(NSString *)emoji;

View File

@@ -115,6 +115,12 @@
}
}
- (void)toolBarDidTapUndo:(KBToolBar *)toolBar {
if ([self.delegate respondsToSelector:@selector(keyBoardMainViewDidTapUndo:)]) {
[self.delegate keyBoardMainViewDidTapUndo:self];
}
}
#pragma mark - KBKeyboardViewDelegate
- (void)keyboardView:(KBKeyboardView *)keyboard didTapKey:(KBKey *)key {

View File

@@ -17,6 +17,8 @@ NS_ASSUME_NONNULL_BEGIN
- (void)toolBar:(KBToolBar *)toolBar didTapActionAtIndex:(NSInteger)index;
/// 右侧设置按钮点击
- (void)toolBarDidTapSettings:(KBToolBar *)toolBar;
/// 右侧撤销删除按钮点击
- (void)toolBarDidTapUndo:(KBToolBar *)toolBar;
@end
/// 顶部工具栏:左侧 4 个按钮,右侧 1 个设置按钮。
@@ -30,6 +32,7 @@ NS_ASSUME_NONNULL_BEGIN
/// 暴露按钮以便外部定制(只读;首次访问时懒加载创建)
@property (nonatomic, strong, readonly) NSArray<UIButton *> *leftButtons;
@property (nonatomic, strong, readonly) UIButton *settingsButton;
@property (nonatomic, strong, readonly) UIButton *undoButton;
@end

View File

@@ -7,12 +7,16 @@
#import "KBToolBar.h"
#import "KBResponderUtils.h" // UIInputViewController
#import "KBBackspaceUndoManager.h"
@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) UIButton *undoButtonInternal; //
@property (nonatomic, assign) BOOL kbNeedsInputModeSwitchKey;
@property (nonatomic, assign) BOOL kbUndoVisible;
@end
@implementation KBToolBar
@@ -22,10 +26,18 @@
self.backgroundColor = [UIColor clearColor];
_leftButtonTitles = @[KBLocalized(@"Recharge Now")]; //
[self setupUI];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(kb_undoStateChanged)
name:KBBackspaceUndoStateDidChangeNotification
object:nil];
}
return self;
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - Public
@@ -33,6 +45,10 @@
return self.leftButtonsInternal;
}
- (UIButton *)undoButton {
return self.undoButtonInternal;
}
//- (UIButton *)settingsButton {
// return self.settingsButtonInternal;
//}
@@ -53,6 +69,7 @@
[self addSubview:self.leftContainer];
// [self addSubview:self.settingsButtonInternal];
[self addSubview:self.globeButtonInternal];
[self addSubview:self.undoButtonInternal];
//
// [self.settingsButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
@@ -68,14 +85,15 @@
make.width.height.mas_equalTo(32);
}];
//
[self.leftContainer mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.equalTo(self.globeButtonInternal.mas_right).offset(8);
make.right.equalTo(self).offset(-12);
//
[self.undoButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(self.mas_right).offset(-12);
make.centerY.equalTo(self.mas_centerY);
make.height.mas_equalTo(32);
}];
[self kb_updateLeftContainerConstraints];
// =
NSMutableArray<UIButton *> *buttons = [NSMutableArray arrayWithCapacity:_leftButtonTitles.count];
UIView *previous = nil;
@@ -110,6 +128,7 @@
//
[self kb_refreshGlobeVisibility];
[self kb_updateUndoVisibilityAnimated:NO];
}
- (UIButton *)buildActionButtonAtIndex:(NSInteger)idx {
@@ -147,6 +166,12 @@
}
}
- (void)onUndo {
if ([self.delegate respondsToSelector:@selector(toolBarDidTapUndo:)]) {
[self.delegate toolBarDidTapUndo:self];
}
}
#pragma mark - Lazy
- (UIView *)leftContainer {
@@ -182,6 +207,23 @@
return _globeButtonInternal;
}
- (UIButton *)undoButtonInternal {
if (!_undoButtonInternal) {
_undoButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem];
_undoButtonInternal.layer.cornerRadius = 16;
_undoButtonInternal.layer.masksToBounds = YES;
_undoButtonInternal.backgroundColor = [UIColor colorWithWhite:1 alpha:0.9];
_undoButtonInternal.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
[_undoButtonInternal setTitle:@"撤销删除" forState:UIControlStateNormal];
[_undoButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
_undoButtonInternal.contentEdgeInsets = UIEdgeInsetsMake(0, 10, 0, 10);
_undoButtonInternal.hidden = YES;
_undoButtonInternal.alpha = 0.0;
[_undoButtonInternal addTarget:self action:@selector(onUndo) forControlEvents:UIControlEventTouchUpInside];
}
return _undoButtonInternal;
}
#pragma mark - Globe (Input Mode Switch)
// 宿
@@ -193,18 +235,9 @@
}
self.globeButtonInternal.hidden = !needSwitchKey;
self.kbNeedsInputModeSwitchKey = needSwitchKey;
// leftContainer 12
[self.leftContainer mas_remakeConstraints:^(MASConstraintMaker *make) {
if (needSwitchKey) {
make.left.equalTo(self.globeButtonInternal.mas_right).offset(8);
} else {
make.left.equalTo(self.mas_left).offset(12);
}
make.right.equalTo(self).offset(-12);
make.centerY.equalTo(self.mas_centerY);
make.height.mas_equalTo(32);
}];
[self kb_updateLeftContainerConstraints];
//
//
@@ -220,6 +253,54 @@
}
}
- (void)kb_updateLeftContainerConstraints {
[self.leftContainer mas_remakeConstraints:^(MASConstraintMaker *make) {
if (self.kbNeedsInputModeSwitchKey) {
make.left.equalTo(self.globeButtonInternal.mas_right).offset(8);
} else {
make.left.equalTo(self.mas_left).offset(12);
}
if (self.kbUndoVisible) {
make.right.equalTo(self.undoButtonInternal.mas_left).offset(-8);
} else {
make.right.equalTo(self).offset(-12);
}
make.centerY.equalTo(self.mas_centerY);
make.height.mas_equalTo(32);
}];
}
- (void)kb_undoStateChanged {
[self kb_updateUndoVisibilityAnimated:YES];
}
- (void)kb_updateUndoVisibilityAnimated:(BOOL)animated {
BOOL visible = [KBBackspaceUndoManager shared].hasUndo;
if (self.kbUndoVisible == visible) { return; }
self.kbUndoVisible = visible;
self.undoButtonInternal.hidden = NO;
[self kb_updateLeftContainerConstraints];
void (^changes)(void) = ^{
self.undoButtonInternal.alpha = visible ? 1.0 : 0.0;
[self layoutIfNeeded];
};
void (^finish)(BOOL) = ^(BOOL finished) {
self.undoButtonInternal.hidden = !visible;
};
if (animated) {
[UIView animateWithDuration:0.18
delay:0
options:UIViewAnimationOptionCurveEaseInOut
animations:changes
completion:finish];
} else {
changes();
finish(YES);
}
}
- (void)didMoveToWindow {
[super didMoveToWindow];
[self kb_refreshGlobeVisibility];