2025-12-19 19:21:08 +08:00
|
|
|
|
//
|
|
|
|
|
|
// KBBackspaceUndoManager.m
|
|
|
|
|
|
// CustomKeyboard
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
|
|
#import "KBBackspaceUndoManager.h"
|
|
|
|
|
|
#import "KBResponderUtils.h"
|
2025-12-26 11:47:44 +08:00
|
|
|
|
#import "KBInputBufferManager.h"
|
2025-12-19 19:21:08 +08:00
|
|
|
|
|
|
|
|
|
|
NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspaceUndoStateDidChangeNotification";
|
|
|
|
|
|
|
2025-12-26 11:47:44 +08:00
|
|
|
|
#if DEBUG
|
|
|
|
|
|
static NSString *KBLogString(NSString *tag, NSString *text) {
|
|
|
|
|
|
NSString *safeTag = tag ?: @"";
|
|
|
|
|
|
NSString *safeText = text ?: @"";
|
|
|
|
|
|
if (safeText.length <= 2000) {
|
|
|
|
|
|
return [NSString stringWithFormat:@"[%@] len=%lu text=%@", safeTag, (unsigned long)safeText.length, safeText];
|
|
|
|
|
|
}
|
|
|
|
|
|
NSString *head = [safeText substringToIndex:800];
|
|
|
|
|
|
NSString *tail = [safeText substringFromIndex:safeText.length - 800];
|
|
|
|
|
|
return [NSString stringWithFormat:@"[%@] len=%lu head=%@ ... tail=%@", safeTag, (unsigned long)safeText.length, head, tail];
|
|
|
|
|
|
}
|
|
|
|
|
|
#define KB_UNDO_LOG(tag, text) NSLog(@"%@", KBLogString((tag), (text)))
|
|
|
|
|
|
#else
|
|
|
|
|
|
#define KB_UNDO_LOG(tag, text) do {} while(0)
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
|
|
typedef NS_ENUM(NSInteger, KBUndoSnapshotSource) {
|
|
|
|
|
|
KBUndoSnapshotSourceNone = 0,
|
|
|
|
|
|
KBUndoSnapshotSourceDeletionSnapshot,
|
|
|
|
|
|
KBUndoSnapshotSourceClear
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-19 19:21:08 +08:00
|
|
|
|
@interface KBBackspaceUndoManager ()
|
2025-12-23 18:05:01 +08:00
|
|
|
|
@property (nonatomic, copy) NSString *undoText;
|
|
|
|
|
|
@property (nonatomic, assign) NSInteger undoAfterLength;
|
2025-12-19 19:21:08 +08:00
|
|
|
|
@property (nonatomic, assign) BOOL hasUndo;
|
2025-12-26 11:47:44 +08:00
|
|
|
|
@property (nonatomic, assign) KBUndoSnapshotSource snapshotSource;
|
|
|
|
|
|
@property (nonatomic, strong) NSMutableArray<NSString *> *undoDeletedPieces;
|
2025-12-19 19:21:08 +08:00
|
|
|
|
@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]) {
|
2025-12-23 18:05:01 +08:00
|
|
|
|
_undoText = @"";
|
|
|
|
|
|
_undoAfterLength = 0;
|
2025-12-26 11:47:44 +08:00
|
|
|
|
_snapshotSource = KBUndoSnapshotSourceNone;
|
|
|
|
|
|
_undoDeletedPieces = [NSMutableArray array];
|
2025-12-19 19:21:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
return self;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 11:47:44 +08:00
|
|
|
|
- (void)captureAndDeleteBackwardFromProxy:(id<UITextDocumentProxy>)proxy count:(NSUInteger)count {
|
|
|
|
|
|
if (!proxy || count == 0) { return; }
|
|
|
|
|
|
|
|
|
|
|
|
NSString *selected = proxy.selectedText ?: @"";
|
|
|
|
|
|
NSString *ctxBefore = proxy.documentContextBeforeInput ?: @"";
|
|
|
|
|
|
NSString *ctxAfter = proxy.documentContextAfterInput ?: @"";
|
|
|
|
|
|
NSUInteger ctxLen = ctxBefore.length + ctxAfter.length;
|
|
|
|
|
|
BOOL isSelectAllLike = (selected.length > 0 &&
|
|
|
|
|
|
(ctxLen == 0 || selected.length >= MAX((NSUInteger)40, ctxLen * 2)));
|
|
|
|
|
|
if (isSelectAllLike) {
|
|
|
|
|
|
// “全选删除”在微信/QQ中通常拿不到可靠的全文,因此禁用撤销,避免插回错误/不完整内容。
|
|
|
|
|
|
if (self.hasUndo) {
|
|
|
|
|
|
[self registerNonClearAction];
|
|
|
|
|
|
}
|
|
|
|
|
|
#if DEBUG
|
|
|
|
|
|
KB_UNDO_LOG(@"captureAndDelete/selectAllDisableUndo", selected);
|
|
|
|
|
|
#endif
|
|
|
|
|
|
[proxy deleteBackward];
|
|
|
|
|
|
[[KBInputBufferManager shared] resetWithText:@""];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!self.hasUndo) {
|
|
|
|
|
|
[self.undoDeletedPieces removeAllObjects];
|
|
|
|
|
|
self.undoText = @"";
|
|
|
|
|
|
self.undoAfterLength = 0;
|
|
|
|
|
|
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
|
|
|
|
|
[self kb_updateHasUndo:YES];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
BOOL didAppend = NO;
|
|
|
|
|
|
NSString *lastObservedBefore = nil;
|
|
|
|
|
|
for (NSUInteger i = 0; i < count; i++) {
|
|
|
|
|
|
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
|
|
|
|
|
if (before.length > 0) {
|
|
|
|
|
|
// 若宿主在同一 runloop 内不更新 context,则跳过记录,避免把同一个字符重复记录成“多句”。
|
|
|
|
|
|
if (lastObservedBefore && [before isEqualToString:lastObservedBefore]) {
|
|
|
|
|
|
// still delete, but don't record
|
|
|
|
|
|
} else {
|
|
|
|
|
|
NSString *piece = [self kb_lastComposedCharacterFromString:before];
|
|
|
|
|
|
if (piece.length > 0) {
|
|
|
|
|
|
[self.undoDeletedPieces addObject:piece];
|
|
|
|
|
|
didAppend = YES;
|
|
|
|
|
|
}
|
|
|
|
|
|
lastObservedBefore = before;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
[proxy deleteBackward];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#if DEBUG
|
|
|
|
|
|
if (didAppend) {
|
|
|
|
|
|
NSUInteger piecesCount = self.undoDeletedPieces.count;
|
|
|
|
|
|
if (piecesCount <= 20) {
|
|
|
|
|
|
KB_UNDO_LOG(@"captureAndDelete/undoInsertTextNow", [self kb_buildUndoInsertTextFromPieces]);
|
|
|
|
|
|
} else if (piecesCount % 50 == 0) {
|
|
|
|
|
|
NSString *lastPiece = self.undoDeletedPieces.lastObject ?: @"";
|
|
|
|
|
|
NSLog(@"[captureAndDelete/undoPieces] pieces=%lu lastPiece=%@",
|
|
|
|
|
|
(unsigned long)piecesCount,
|
|
|
|
|
|
lastPiece);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 18:05:01 +08:00
|
|
|
|
- (void)recordDeletionSnapshotBefore:(NSString *)before after:(NSString *)after {
|
2025-12-26 11:47:44 +08:00
|
|
|
|
if (self.hasUndo) { return; }
|
|
|
|
|
|
NSString *pending = [KBInputBufferManager shared].pendingClearSnapshot;
|
|
|
|
|
|
NSString *manual = [KBInputBufferManager shared].manualSnapshot;
|
|
|
|
|
|
NSString *fallbackText = (pending.length > 0) ? pending : ((manual.length > 0) ? manual : [KBInputBufferManager shared].liveText);
|
|
|
|
|
|
if (fallbackText.length > 0) {
|
|
|
|
|
|
self.undoText = fallbackText;
|
|
|
|
|
|
self.undoAfterLength = 0;
|
|
|
|
|
|
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
|
|
|
|
|
KB_UNDO_LOG(@"recordDeletionSnapshot/fallback", self.undoText);
|
|
|
|
|
|
[self kb_updateHasUndo:YES];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-12-23 18:05:01 +08:00
|
|
|
|
NSString *safeBefore = before ?: @"";
|
|
|
|
|
|
NSString *safeAfter = after ?: @"";
|
|
|
|
|
|
NSString *full = [safeBefore stringByAppendingString:safeAfter];
|
|
|
|
|
|
if (full.length == 0) { return; }
|
|
|
|
|
|
self.undoText = full;
|
|
|
|
|
|
self.undoAfterLength = (NSInteger)safeAfter.length;
|
2025-12-26 11:47:44 +08:00
|
|
|
|
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
|
|
|
|
|
KB_UNDO_LOG(@"recordDeletionSnapshot/context", self.undoText);
|
|
|
|
|
|
[self kb_updateHasUndo:YES];
|
2025-12-23 18:05:01 +08:00
|
|
|
|
}
|
2025-12-19 19:21:08 +08:00
|
|
|
|
|
2025-12-23 18:05:01 +08:00
|
|
|
|
- (void)recordClearWithContextBefore:(NSString *)before after:(NSString *)after {
|
2025-12-26 11:47:44 +08:00
|
|
|
|
NSString *pending = [KBInputBufferManager shared].pendingClearSnapshot;
|
|
|
|
|
|
NSString *manual = [KBInputBufferManager shared].manualSnapshot;
|
|
|
|
|
|
NSString *fallbackText = (pending.length > 0) ? pending : ((manual.length > 0) ? manual : [KBInputBufferManager shared].liveText);
|
|
|
|
|
|
|
|
|
|
|
|
NSString *safeBefore = before ?: @"";
|
|
|
|
|
|
NSString *safeAfter = after ?: @"";
|
|
|
|
|
|
NSString *contextText = [[safeBefore stringByAppendingString:safeAfter] copy];
|
|
|
|
|
|
|
|
|
|
|
|
NSString *candidate = (fallbackText.length > 0) ? fallbackText : contextText;
|
|
|
|
|
|
NSInteger candidateAfterLen = (fallbackText.length > 0) ? 0 : (NSInteger)safeAfter.length;
|
|
|
|
|
|
|
|
|
|
|
|
if (candidate.length == 0) { return; }
|
|
|
|
|
|
|
|
|
|
|
|
KB_UNDO_LOG(@"recordClear/candidate", candidate);
|
|
|
|
|
|
|
|
|
|
|
|
if (self.undoText.length > 0) {
|
|
|
|
|
|
if (self.snapshotSource == KBUndoSnapshotSourceClear) {
|
|
|
|
|
|
KB_UNDO_LOG(@"recordClear/ignored(alreadyClear)", self.undoText);
|
|
|
|
|
|
[self kb_updateHasUndo:YES];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (self.snapshotSource == KBUndoSnapshotSourceDeletionSnapshot) {
|
|
|
|
|
|
if (candidate.length > self.undoText.length) {
|
|
|
|
|
|
self.undoText = candidate;
|
|
|
|
|
|
self.undoAfterLength = candidateAfterLen;
|
|
|
|
|
|
KB_UNDO_LOG(@"recordClear/upgradedFromDeletion", self.undoText);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
KB_UNDO_LOG(@"recordClear/keepDeletionSnapshot", self.undoText);
|
|
|
|
|
|
}
|
|
|
|
|
|
self.snapshotSource = KBUndoSnapshotSourceClear;
|
|
|
|
|
|
[self kb_updateHasUndo:YES];
|
|
|
|
|
|
return;
|
2025-12-23 18:05:01 +08:00
|
|
|
|
}
|
2025-12-19 19:21:08 +08:00
|
|
|
|
}
|
2025-12-26 11:47:44 +08:00
|
|
|
|
|
|
|
|
|
|
self.undoText = candidate;
|
|
|
|
|
|
self.undoAfterLength = candidateAfterLen;
|
|
|
|
|
|
self.snapshotSource = KBUndoSnapshotSourceClear;
|
|
|
|
|
|
KB_UNDO_LOG(@"recordClear/set", self.undoText);
|
2025-12-19 19:21:08 +08:00
|
|
|
|
[self kb_updateHasUndo:YES];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)performUndoFromResponder:(UIResponder *)responder {
|
2025-12-26 11:47:44 +08:00
|
|
|
|
if (!self.hasUndo) { return; }
|
2025-12-19 19:21:08 +08:00
|
|
|
|
UIInputViewController *ivc = KBFindInputViewController(responder);
|
|
|
|
|
|
if (!ivc) { return; }
|
|
|
|
|
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
2025-12-26 11:47:44 +08:00
|
|
|
|
NSString *curBefore = proxy.documentContextBeforeInput ?: @"";
|
|
|
|
|
|
NSString *curAfter = proxy.documentContextAfterInput ?: @"";
|
|
|
|
|
|
KB_UNDO_LOG(@"performUndo/currentBefore", curBefore);
|
|
|
|
|
|
KB_UNDO_LOG(@"performUndo/currentAfter", curAfter);
|
|
|
|
|
|
NSString *insertText = [self kb_buildUndoInsertTextFromPieces];
|
|
|
|
|
|
if (insertText.length > 0) {
|
|
|
|
|
|
KB_UNDO_LOG(@"performUndo/insertDeletedText", insertText);
|
|
|
|
|
|
[proxy insertText:insertText];
|
|
|
|
|
|
[[KBInputBufferManager shared] appendText:insertText];
|
|
|
|
|
|
} else if (self.undoText.length > 0) {
|
|
|
|
|
|
KB_UNDO_LOG(@"performUndo/fallbackUndoText", self.undoText);
|
|
|
|
|
|
[self kb_clearAllTextForProxy:proxy];
|
|
|
|
|
|
[proxy insertText:self.undoText];
|
|
|
|
|
|
if (self.undoAfterLength > 0 &&
|
|
|
|
|
|
[proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) {
|
|
|
|
|
|
[proxy adjustTextPositionByCharacterOffset:-self.undoAfterLength];
|
|
|
|
|
|
}
|
|
|
|
|
|
[[KBInputBufferManager shared] resetWithText:self.undoText];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return;
|
2025-12-23 18:05:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
self.undoText = @"";
|
|
|
|
|
|
self.undoAfterLength = 0;
|
2025-12-26 11:47:44 +08:00
|
|
|
|
self.snapshotSource = KBUndoSnapshotSourceNone;
|
|
|
|
|
|
[self.undoDeletedPieces removeAllObjects];
|
2025-12-19 19:21:08 +08:00
|
|
|
|
[self kb_updateHasUndo:NO];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)registerNonClearAction {
|
2025-12-26 11:47:44 +08:00
|
|
|
|
if (!self.hasUndo) { return; }
|
|
|
|
|
|
if (self.undoText.length > 0) {
|
|
|
|
|
|
KB_UNDO_LOG(@"registerNonClearAction/clearUndoText", self.undoText);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (self.undoDeletedPieces.count > 0) {
|
|
|
|
|
|
KB_UNDO_LOG(@"registerNonClearAction/clearDeletedPieces", [self kb_buildUndoInsertTextFromPieces]);
|
|
|
|
|
|
}
|
2025-12-23 18:05:01 +08:00
|
|
|
|
self.undoText = @"";
|
|
|
|
|
|
self.undoAfterLength = 0;
|
2025-12-26 11:47:44 +08:00
|
|
|
|
self.snapshotSource = KBUndoSnapshotSourceNone;
|
|
|
|
|
|
[self.undoDeletedPieces removeAllObjects];
|
2025-12-19 19:21:08 +08:00
|
|
|
|
[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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 11:47:44 +08:00
|
|
|
|
- (NSString *)kb_lastComposedCharacterFromString:(NSString *)text {
|
|
|
|
|
|
if (text.length == 0) { return @""; }
|
|
|
|
|
|
__block NSString *last = @"";
|
|
|
|
|
|
[text enumerateSubstringsInRange:NSMakeRange(0, text.length)
|
|
|
|
|
|
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
|
|
|
|
|
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
|
|
|
|
|
last = substring ?: @"";
|
|
|
|
|
|
*stop = YES;
|
|
|
|
|
|
}];
|
|
|
|
|
|
return last ?: @"";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (NSString *)kb_buildUndoInsertTextFromPieces {
|
|
|
|
|
|
if (self.undoDeletedPieces.count == 0) { return @""; }
|
|
|
|
|
|
NSMutableString *result = [NSMutableString string];
|
|
|
|
|
|
for (NSInteger i = (NSInteger)self.undoDeletedPieces.count - 1; i >= 0; i--) {
|
|
|
|
|
|
NSString *piece = self.undoDeletedPieces[(NSUInteger)i] ?: @"";
|
|
|
|
|
|
if (piece.length == 0) { continue; }
|
|
|
|
|
|
[result appendString:piece];
|
|
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 18:05:01 +08:00
|
|
|
|
static const NSInteger kKBUndoClearMaxRounds = 200;
|
|
|
|
|
|
|
|
|
|
|
|
- (void)kb_clearAllTextForProxy:(id<UITextDocumentProxy>)proxy {
|
|
|
|
|
|
if (!proxy) { return; }
|
|
|
|
|
|
|
|
|
|
|
|
if ([proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) {
|
|
|
|
|
|
NSInteger guard = 0;
|
|
|
|
|
|
NSString *contextAfter = proxy.documentContextAfterInput ?: @"";
|
|
|
|
|
|
while (contextAfter.length > 0 && guard < kKBUndoClearMaxRounds) {
|
|
|
|
|
|
NSInteger offset = (NSInteger)contextAfter.length;
|
|
|
|
|
|
[proxy adjustTextPositionByCharacterOffset:offset];
|
|
|
|
|
|
for (NSUInteger i = 0; i < contextAfter.length; i++) {
|
|
|
|
|
|
[proxy deleteBackward];
|
|
|
|
|
|
}
|
|
|
|
|
|
guard += 1;
|
|
|
|
|
|
contextAfter = proxy.documentContextAfterInput ?: @"";
|
2025-12-19 19:21:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 18:05:01 +08:00
|
|
|
|
NSInteger guard = 0;
|
|
|
|
|
|
NSString *contextBefore = proxy.documentContextBeforeInput ?: @"";
|
|
|
|
|
|
while (contextBefore.length > 0 && guard < kKBUndoClearMaxRounds) {
|
|
|
|
|
|
for (NSUInteger i = 0; i < contextBefore.length; i++) {
|
|
|
|
|
|
[proxy deleteBackward];
|
2025-12-19 19:21:08 +08:00
|
|
|
|
}
|
2025-12-23 18:05:01 +08:00
|
|
|
|
guard += 1;
|
|
|
|
|
|
contextBefore = proxy.documentContextBeforeInput ?: @"";
|
2025-12-19 19:21:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@end
|