305 lines
12 KiB
Objective-C
305 lines
12 KiB
Objective-C
//
|
||
// KBBackspaceUndoManager.m
|
||
// CustomKeyboard
|
||
//
|
||
|
||
#import "KBBackspaceUndoManager.h"
|
||
#import "KBResponderUtils.h"
|
||
#import "KBInputBufferManager.h"
|
||
|
||
NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspaceUndoStateDidChangeNotification";
|
||
|
||
#if DEBUG
|
||
static NSString *KBLogString(NSString *tag, NSString *text) {
|
||
NSString *safeTag = tag ?: @"";
|
||
NSString *safeText = text ?: @"";
|
||
if (safeText.length <= 2000) {
|
||
return [NSString stringWithFormat:@"[%@] len=%lu text=%@", safeTag, (unsigned long)safeText.length, safeText];
|
||
}
|
||
NSString *head = [safeText substringToIndex:800];
|
||
NSString *tail = [safeText substringFromIndex:safeText.length - 800];
|
||
return [NSString stringWithFormat:@"[%@] len=%lu head=%@ ... tail=%@", safeTag, (unsigned long)safeText.length, head, tail];
|
||
}
|
||
#define KB_UNDO_LOG(tag, text) NSLog(@"%@", KBLogString((tag), (text)))
|
||
#else
|
||
#define KB_UNDO_LOG(tag, text) do {} while(0)
|
||
#endif
|
||
|
||
typedef NS_ENUM(NSInteger, KBUndoSnapshotSource) {
|
||
KBUndoSnapshotSourceNone = 0,
|
||
KBUndoSnapshotSourceDeletionSnapshot,
|
||
KBUndoSnapshotSourceClear
|
||
};
|
||
|
||
@interface KBBackspaceUndoManager ()
|
||
@property (nonatomic, copy) NSString *undoText;
|
||
@property (nonatomic, assign) NSInteger undoAfterLength;
|
||
@property (nonatomic, assign) BOOL hasUndo;
|
||
@property (nonatomic, assign) KBUndoSnapshotSource snapshotSource;
|
||
@property (nonatomic, strong) NSMutableArray<NSString *> *undoDeletedPieces;
|
||
@end
|
||
|
||
@implementation KBBackspaceUndoManager
|
||
|
||
+ (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]) {
|
||
_undoText = @"";
|
||
_undoAfterLength = 0;
|
||
_snapshotSource = KBUndoSnapshotSourceNone;
|
||
_undoDeletedPieces = [NSMutableArray array];
|
||
}
|
||
return self;
|
||
}
|
||
|
||
- (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
|
||
}
|
||
|
||
- (void)recordDeletionSnapshotBefore:(NSString *)before after:(NSString *)after {
|
||
if (self.hasUndo) { return; }
|
||
NSString *pending = [KBInputBufferManager shared].pendingClearSnapshot;
|
||
NSString *manual = [KBInputBufferManager shared].manualSnapshot;
|
||
NSString *fallbackText = (pending.length > 0) ? pending : ((manual.length > 0) ? manual : [KBInputBufferManager shared].liveText);
|
||
if (fallbackText.length > 0) {
|
||
self.undoText = fallbackText;
|
||
self.undoAfterLength = 0;
|
||
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
||
KB_UNDO_LOG(@"recordDeletionSnapshot/fallback", self.undoText);
|
||
[self kb_updateHasUndo:YES];
|
||
return;
|
||
}
|
||
NSString *safeBefore = before ?: @"";
|
||
NSString *safeAfter = after ?: @"";
|
||
NSString *full = [safeBefore stringByAppendingString:safeAfter];
|
||
if (full.length == 0) { return; }
|
||
self.undoText = full;
|
||
self.undoAfterLength = (NSInteger)safeAfter.length;
|
||
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
||
KB_UNDO_LOG(@"recordDeletionSnapshot/context", self.undoText);
|
||
[self kb_updateHasUndo:YES];
|
||
}
|
||
|
||
- (void)recordClearWithContextBefore:(NSString *)before after:(NSString *)after {
|
||
NSString *pending = [KBInputBufferManager shared].pendingClearSnapshot;
|
||
NSString *manual = [KBInputBufferManager shared].manualSnapshot;
|
||
NSString *fallbackText = (pending.length > 0) ? pending : ((manual.length > 0) ? manual : [KBInputBufferManager shared].liveText);
|
||
|
||
NSString *safeBefore = before ?: @"";
|
||
NSString *safeAfter = after ?: @"";
|
||
NSString *contextText = [[safeBefore stringByAppendingString:safeAfter] copy];
|
||
|
||
NSString *candidate = (fallbackText.length > 0) ? fallbackText : contextText;
|
||
NSInteger candidateAfterLen = (fallbackText.length > 0) ? 0 : (NSInteger)safeAfter.length;
|
||
|
||
if (candidate.length == 0) { return; }
|
||
|
||
KB_UNDO_LOG(@"recordClear/candidate", candidate);
|
||
|
||
if (self.undoText.length > 0) {
|
||
if (self.snapshotSource == KBUndoSnapshotSourceClear) {
|
||
KB_UNDO_LOG(@"recordClear/ignored(alreadyClear)", self.undoText);
|
||
[self kb_updateHasUndo:YES];
|
||
return;
|
||
}
|
||
if (self.snapshotSource == KBUndoSnapshotSourceDeletionSnapshot) {
|
||
if (candidate.length > self.undoText.length) {
|
||
self.undoText = candidate;
|
||
self.undoAfterLength = candidateAfterLen;
|
||
KB_UNDO_LOG(@"recordClear/upgradedFromDeletion", self.undoText);
|
||
} else {
|
||
KB_UNDO_LOG(@"recordClear/keepDeletionSnapshot", self.undoText);
|
||
}
|
||
self.snapshotSource = KBUndoSnapshotSourceClear;
|
||
[self kb_updateHasUndo:YES];
|
||
return;
|
||
}
|
||
}
|
||
|
||
self.undoText = candidate;
|
||
self.undoAfterLength = candidateAfterLen;
|
||
self.snapshotSource = KBUndoSnapshotSourceClear;
|
||
KB_UNDO_LOG(@"recordClear/set", self.undoText);
|
||
[self kb_updateHasUndo:YES];
|
||
}
|
||
|
||
- (void)performUndoFromResponder:(UIResponder *)responder {
|
||
if (!self.hasUndo) { return; }
|
||
UIInputViewController *ivc = KBFindInputViewController(responder);
|
||
if (!ivc) { return; }
|
||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||
NSString *curBefore = proxy.documentContextBeforeInput ?: @"";
|
||
NSString *curAfter = proxy.documentContextAfterInput ?: @"";
|
||
KB_UNDO_LOG(@"performUndo/currentBefore", curBefore);
|
||
KB_UNDO_LOG(@"performUndo/currentAfter", curAfter);
|
||
NSString *insertText = [self kb_buildUndoInsertTextFromPieces];
|
||
if (insertText.length > 0) {
|
||
KB_UNDO_LOG(@"performUndo/insertDeletedText", insertText);
|
||
[proxy insertText:insertText];
|
||
[[KBInputBufferManager shared] appendText:insertText];
|
||
} else if (self.undoText.length > 0) {
|
||
KB_UNDO_LOG(@"performUndo/fallbackUndoText", self.undoText);
|
||
[self kb_clearAllTextForProxy:proxy];
|
||
[proxy insertText:self.undoText];
|
||
if (self.undoAfterLength > 0 &&
|
||
[proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) {
|
||
[proxy adjustTextPositionByCharacterOffset:-self.undoAfterLength];
|
||
}
|
||
[[KBInputBufferManager shared] resetWithText:self.undoText];
|
||
} else {
|
||
return;
|
||
}
|
||
self.undoText = @"";
|
||
self.undoAfterLength = 0;
|
||
self.snapshotSource = KBUndoSnapshotSourceNone;
|
||
[self.undoDeletedPieces removeAllObjects];
|
||
[self kb_updateHasUndo:NO];
|
||
}
|
||
|
||
- (void)registerNonClearAction {
|
||
if (!self.hasUndo) { return; }
|
||
if (self.undoText.length > 0) {
|
||
KB_UNDO_LOG(@"registerNonClearAction/clearUndoText", self.undoText);
|
||
}
|
||
if (self.undoDeletedPieces.count > 0) {
|
||
KB_UNDO_LOG(@"registerNonClearAction/clearDeletedPieces", [self kb_buildUndoInsertTextFromPieces]);
|
||
}
|
||
self.undoText = @"";
|
||
self.undoAfterLength = 0;
|
||
self.snapshotSource = KBUndoSnapshotSourceNone;
|
||
[self.undoDeletedPieces removeAllObjects];
|
||
[self kb_updateHasUndo:NO];
|
||
}
|
||
|
||
#pragma mark - Helpers
|
||
|
||
- (void)kb_updateHasUndo:(BOOL)hasUndo {
|
||
if (self.hasUndo == hasUndo) { return; }
|
||
self.hasUndo = hasUndo;
|
||
[[NSNotificationCenter defaultCenter] postNotificationName:KBBackspaceUndoStateDidChangeNotification object:self];
|
||
}
|
||
|
||
- (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;
|
||
}
|
||
|
||
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 ?: @"";
|
||
}
|
||
}
|
||
|
||
NSInteger guard = 0;
|
||
NSString *contextBefore = proxy.documentContextBeforeInput ?: @"";
|
||
while (contextBefore.length > 0 && guard < kKBUndoClearMaxRounds) {
|
||
for (NSUInteger i = 0; i < contextBefore.length; i++) {
|
||
[proxy deleteBackward];
|
||
}
|
||
guard += 1;
|
||
contextBefore = proxy.documentContextBeforeInput ?: @"";
|
||
}
|
||
}
|
||
|
||
@end
|