// // 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 *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)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 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)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