diff --git a/Hakawai/Core/HKWTextView.h b/Hakawai/Core/HKWTextView.h index c6e84bc..952a6c0 100644 --- a/Hakawai/Core/HKWTextView.h +++ b/Hakawai/Core/HKWTextView.h @@ -126,6 +126,12 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, readonly) BOOL inSingleLineViewportMode; +/*! + String that saves the state of the text in the text view so that it can be accessed in the NSTextStorageDelegate, which will + already have deleted the character by the time it's trying to process said deletion + */ +@property (nonatomic, strong, readonly) NSString *textStateBeforeDeletion; + @end NS_ASSUME_NONNULL_END diff --git a/Hakawai/Core/HKWTextView.m b/Hakawai/Core/HKWTextView.m index 806faaa..3aec6f2 100644 --- a/Hakawai/Core/HKWTextView.m +++ b/Hakawai/Core/HKWTextView.m @@ -24,10 +24,7 @@ @interface HKWTextView () @property (nonatomic) NSMutableDictionary *simplePluginsDictionary; -/*! - String that saves the state of the text in the text view so that it can be accessed in the NSTextStorageDelegate, which will - already have deleted the character by the time it's trying to process said deletion - */ + @property (nonatomic, strong, readwrite) NSString *textStateBeforeDeletion; @end diff --git a/Hakawai/Mentions/HKWMentionsCreationStateMachine.m b/Hakawai/Mentions/HKWMentionsCreationStateMachine.m index 0b6dd47..964c275 100644 --- a/Hakawai/Mentions/HKWMentionsCreationStateMachine.m +++ b/Hakawai/Mentions/HKWMentionsCreationStateMachine.m @@ -270,12 +270,33 @@ - (void)stringDeleted:(NSString *)deleteString { __strong __auto_type delegate = self.delegate; + /** + When mentions was originally triggered because the whitespace between a control character and a word was deleted, + the cursor is next to the control character (like "@|John", where '|' represents the cursor-state). If the user then deletes the control character, + the string buffer will not be empty (it will have "John" in it), but mentions has to stop, because control character is deleted. + We use the isControlCharacterDeleted flag to decide what to do in this case of control character deletion. + */ + BOOL isControlCharacterDeleted = NO; + if (deleteString.length == 1 + && [deleteString containsString:[NSString stringWithFormat:@"%C", self.explicitSearchControlCharacter]] + && self.stringBuffer.length > 0 + && [self.stringBuffer characterAtIndex:self.stringBuffer.length - 1] != self.explicitSearchControlCharacter) { + isControlCharacterDeleted = YES; + } + // Switch on the overall state switch (self.state) { case HKWMentionsCreationStateQuiescent: // User not creating a mention right now return; case HKWMentionsCreationStateCreatingMention: + if (isControlCharacterDeleted) { + // When user deletes control character during mention creation state, then end mention creation. + self.state = HKWMentionsCreationStateQuiescent; + [delegate cancelMentionFromStartingLocation:self.startingLocation]; + return; + } + if (deleteStringIsTransient) { // Delete was typed, but for some sort of transient state (e.g. keyboard suggestions); don't do anything return; diff --git a/Hakawai/Mentions/HKWMentionsPlugin.m b/Hakawai/Mentions/HKWMentionsPlugin.m index 3c6846f..99b15c5 100755 --- a/Hakawai/Mentions/HKWMentionsPlugin.m +++ b/Hakawai/Mentions/HKWMentionsPlugin.m @@ -845,15 +845,23 @@ - (BOOL)advanceStateForCharacterInsertion:(unichar)newChar BOOL isSecondSpace = (location > 1) && (precedingChar == ' ' && newChar == ' '); switch (self.state) { case HKWMentionsStateQuiescent: { + // Word following typed character would be used to trigger matching mentions menu when possible. + NSString *wordFollowingTypedCharacter; if (HKWTextView.enableKoreanMentionsFix) { // Update the location of the selected range for this insertion here // (since the text view will already be updated when utilizing the text storage delegate in the korean mentions fix) // This should replace other settings of this range when the fix is ramped parentTextView.selectedRange = NSMakeRange(location, parentTextView.selectedRange.length); + wordFollowingTypedCharacter = [HKWMentionsStartDetectionStateMachine wordAfterLocation:location text:parentTextView.textStateBeforeDeletion]; + } else { + wordFollowingTypedCharacter = [HKWMentionsStartDetectionStateMachine wordAfterLocation:location text:parentTextView.text]; } // Inform the start detection state machine that a character was inserted. Also, override the double space // to period auto-substitution if the substitution would place a period right after a preceding mention. - [self.startDetectionStateMachine characterTyped:newChar asInsertedCharacter:NO previousCharacter:precedingChar]; + [self.startDetectionStateMachine characterTyped:newChar + asInsertedCharacter:NO + previousCharacter:precedingChar + wordFollowingTypedCharacter:wordFollowingTypedCharacter]; NSRange r; id mentionTwoPreceding = [self mentionAttributePrecedingLocation:(location-1) range:&r]; BOOL shouldSuppress = (mentionTwoPreceding != nil) && (r.location + r.length == location-1); @@ -879,7 +887,10 @@ - (BOOL)advanceStateForCharacterInsertion:(unichar)newChar // insert a new character and continue in the quiescent state. Do not allow auto-substitution. self.state = HKWMentionsStateQuiescent; [self resetCurrentMentionsData]; - [self.startDetectionStateMachine characterTyped:newChar asInsertedCharacter:NO previousCharacter:precedingChar]; + [self.startDetectionStateMachine characterTyped:newChar + asInsertedCharacter:NO + previousCharacter:precedingChar + wordFollowingTypedCharacter:nil]; if (isSecondSpace) { [self manuallyInsertCharacter:newChar atLocation:location inTextView:parentTextView]; self.characterForAdvanceStateForCharacterInsertion = (unichar)0; @@ -901,7 +912,7 @@ - (BOOL)advanceStateForCharacterInsertion:(unichar)newChar [self resetCurrentMentionsData]; self.state = HKWMentionsStateQuiescent; self.characterForAdvanceStateForCharacterInsertion = (unichar)0; - [self.startDetectionStateMachine characterTyped:newChar asInsertedCharacter:YES previousCharacter:precedingChar]; + [self.startDetectionStateMachine characterTyped:newChar asInsertedCharacter:YES previousCharacter:precedingChar wordFollowingTypedCharacter:nil]; returnValue = NO; break; case HKWMentionsStateLosingFocus: @@ -932,7 +943,9 @@ - (BOOL)advanceStateForCharacterDeletion:(unichar)precedingChar switch (self.state) { case HKWMentionsStateQuiescent: { [self.startDetectionStateMachine deleteTypedCharacter:deletedChar - withCharacterNowPrecedingCursor:precedingChar]; + withCharacterNowPrecedingCursor:precedingChar + location:location + textViewText:parentTextView.text]; self.nextSelectionChangeShouldBeIgnored = YES; // Look for a mention @@ -1144,8 +1157,10 @@ - (BOOL)advanceStateForStringInsertionAtRange:(NSRange)range text:(NSString *)te self.previousSelectionRange = newSelectionRange; self.previousTextLength = [[parentTextView text] length]; - [self.startDetectionStateMachine characterTyped:[text characterAtIndex:0] asInsertedCharacter:YES previousCharacter:precedingChar]; - + [self.startDetectionStateMachine characterTyped:[text characterAtIndex:0] + asInsertedCharacter:YES + previousCharacter:precedingChar + wordFollowingTypedCharacter:nil]; // Manually notify external delegate that the textView changed id externalDelegate = parentTextView.externalDelegate; if ([externalDelegate respondsToSelector:@selector(textViewDidChange:)]) { @@ -1932,7 +1947,12 @@ - (void)createMention:(HKWMentionsAttribute *)mention startingLocation:(NSUInteg UIColor *parentColor = parentTextView.textColorSetByApp; NSAssert(self.mentionUnselectedAttributes != nil, @"Error! Mention attribute dictionaries should never be nil."); NSDictionary *unselectedAttributes = self.mentionUnselectedAttributes; - NSRange rangeToTransform = NSMakeRange(location, currentLocation - location); + + // When control character is inserted before word and user selects mention for that word, + // we want to replace word after control character with mention text. + // e.g "hey @|john" will be replaced as "hey John Doe". '|' indicates cursor. + NSString *const wordAfterCurrentLocation = [HKWMentionsStartDetectionStateMachine wordAfterLocation:currentLocation text:parentTextView.text]; + NSRange rangeToTransform = NSMakeRange(location, currentLocation + wordAfterCurrentLocation.length - location); /* When the textview text that matches the mention text is not the first part of the mention text, diff --git a/Hakawai/Mentions/HKWMentionsStartDetectionStateMachine.m b/Hakawai/Mentions/HKWMentionsStartDetectionStateMachine.m index 50f3109..aab2cae 100755 --- a/Hakawai/Mentions/HKWMentionsStartDetectionStateMachine.m +++ b/Hakawai/Mentions/HKWMentionsStartDetectionStateMachine.m @@ -27,6 +27,17 @@ typedef NS_ENUM(NSInteger, HKWMentionsStartDetectionState) { HKWMentionsStartDetectionStateCreatingMention }; +typedef NS_ENUM(NSInteger, CharacterType) { + // Characters of punctuation character set and whitespace and new line character set + CharacterTypeSeparator = 0, + + // Characters of control character set + CharacterTypeControlCharacter, + + // All characters other than characters of the other two character types + CharacterTypeNormal +}; + @interface HKWMentionsStartDetectionStateMachine () @property (nonatomic, weak) id delegate; @@ -112,37 +123,14 @@ - (void)validStringInserted:(NSString *)string } } -- (void)characterTyped:(unichar)c asInsertedCharacter:(BOOL)inserted previousCharacter:(unichar)previousCharacter { +- (void)characterTyped:(unichar)c + asInsertedCharacter:(BOOL)inserted + previousCharacter:(unichar)previousCharacter +wordFollowingTypedCharacter:(nullable NSString *)wordFollowingTypedCharacter { __strong __auto_type delegate = self.delegate; - // Determine the type of the character - enum CharacterType { - CharacterTypeSeparator = 0, - CharacterTypeControlCharacter, - CharacterTypeNormal - }; - enum CharacterType currentCharacterType = CharacterTypeNormal; - if ([HKWMentionsStartDetectionStateMachine.whitespaceSet characterIsMember:c]) { - currentCharacterType = CharacterTypeSeparator; - } - else { - // Get the control character set and see if the typed character is a control character - NSCharacterSet *controlCharacterSet = [delegate controlCharacterSet]; - if (controlCharacterSet && [controlCharacterSet characterIsMember:c]) { - currentCharacterType = CharacterTypeControlCharacter; - } else if ([HKWMentionsStartDetectionStateMachine.punctuationSet characterIsMember:c]) { - currentCharacterType = CharacterTypeSeparator; - } - } - enum CharacterType previousCharacterType = CharacterTypeNormal; - if ([HKWMentionsStartDetectionStateMachine.separatorSet characterIsMember:previousCharacter]) { - previousCharacterType = CharacterTypeSeparator; - } else { - // Get the control character set and see if the typed character is a control character - NSCharacterSet *controlCharacterSet = [delegate controlCharacterSet]; - if (controlCharacterSet && [controlCharacterSet characterIsMember:previousCharacter]) { - previousCharacterType = CharacterTypeControlCharacter; - } - } + // Determine character types + enum CharacterType currentCharacterType = [self characterTypeOfCharacter:c]; + enum CharacterType previousCharacterType = [self characterTypeOfCharacter:previousCharacter]; // State transition switch (self.state) { @@ -169,8 +157,9 @@ - (void)characterTyped:(unichar)c asInsertedCharacter:(BOOL)inserted previousCha && (previousCharacterType == CharacterTypeSeparator || previousCharacter == 0)) { if (previousCharacter == 0 || previousCharacterType == CharacterTypeSeparator) { // Start an EXPLICIT MENTION - if ([HKWMentionsStartDetectionStateMachine.punctuationSet characterIsMember:previousCharacter]) { - // Empty string buffer when previous character is a punctuation + if (wordFollowingTypedCharacter) { + self.stringBuffer = [wordFollowingTypedCharacter mutableCopy]; + } else if (previousCharacterType == CharacterTypeSeparator) { self.stringBuffer = [@"" mutableCopy]; } self.state = HKWMentionsStartDetectionStateCreatingMention; @@ -202,22 +191,41 @@ - (void)characterTyped:(unichar)c asInsertedCharacter:(BOOL)inserted previousCha } } -- (void)deleteTypedCharacter:(unichar)deletedChar withCharacterNowPrecedingCursor:(unichar)precedingChar { - // Determine the type of the character - enum CharacterType { - CharacterTypeSeparator = 0, - CharacterTypeNormal - }; - enum CharacterType deletedCharacterType = ([HKWMentionsStartDetectionStateMachine.separatorSet characterIsMember:deletedChar] || deletedChar == 0) - ? CharacterTypeSeparator - : CharacterTypeNormal; - enum CharacterType currentCharacterType = ([HKWMentionsStartDetectionStateMachine.separatorSet characterIsMember:precedingChar] || precedingChar == 0) - ? CharacterTypeSeparator - : CharacterTypeNormal; +- (void)deleteTypedCharacter:(unichar)deletedChar +withCharacterNowPrecedingCursor:(unichar)precedingChar + location:(NSUInteger)location + textViewText:(nonnull NSString *)textViewText { + // Determine the character types + enum CharacterType deletedCharacterType = [self characterTypeOfCharacter:deletedChar]; + enum CharacterType precedingCharacterType = [self characterTypeOfCharacter:precedingChar]; + + __strong __auto_type delegate = self.delegate; switch (self.state) { case HKWMentionsStartDetectionStateQuiescentReady: { - if (currentCharacterType == CharacterTypeNormal) { + // Mention can be triggered upon character deletion: + // 1. when deleted character location is greater than 1 -> When previous character is a control character and character before previous character is a separator. + // 2. when deleted character location is 1 -> If preceding character is a control character + // (NOTE: When deleted character location is 0, mentions is never triggered) + BOOL canCreateMention = NO; + if (location > 1 && textViewText.length > location - 2) { + const unichar characterBeforePrecedingChar = [textViewText characterAtIndex:location - 2]; + canCreateMention = [HKWMentionsStartDetectionStateMachine.separatorSet characterIsMember:characterBeforePrecedingChar] + && precedingCharacterType == CharacterTypeControlCharacter; + } else if (precedingCharacterType == CharacterTypeControlCharacter) { + canCreateMention = YES; + } + // If user deletes white-space or separators between control character and word, then query mention with word next to whitepace. + if ((deletedCharacterType == CharacterTypeSeparator || deletedCharacterType == CharacterTypeControlCharacter) && canCreateMention) { + if (location > 0 && location <= [textViewText length]) { + self.state = HKWMentionsStartDetectionStateCreatingMention; + NSString *const keyword = [HKWMentionsStartDetectionStateMachine wordAfterLocation:location + 1 text:textViewText]; + [delegate beginMentionsCreationWithString:keyword + atLocation:location - 1 + usingControlCharacter:YES + controlCharacter:precedingChar]; + } + } else if (precedingCharacterType == CharacterTypeNormal) { if (self.charactersSinceLastWhitespace == 0) { // Being here means the user deleted enough characters to move the cursor into the previous word. self.state = HKWMentionsStartDetectionStateQuiescentStalled; @@ -232,13 +240,14 @@ - (void)deleteTypedCharacter:(unichar)deletedChar withCharacterNowPrecedingCurso self.charactersSinceLastWhitespace = 0; self.stringBuffer = [@"" mutableCopy]; } + break; } case HKWMentionsStartDetectionStateQuiescentStalled: { // Change state to QuiescentReady when either: // 1. A whitespace character is encountered // 2. A NON-whitespace character is encountered and a whitespace character was deleted // 3. A punctuation character is encountered - if (currentCharacterType == CharacterTypeSeparator + if (precedingCharacterType == CharacterTypeSeparator || deletedCharacterType == CharacterTypeSeparator) { self.state = HKWMentionsStartDetectionStateQuiescentReady; } @@ -253,23 +262,16 @@ - (void)deleteTypedCharacter:(unichar)deletedChar withCharacterNowPrecedingCurso - (void)cursorMovedWithCharacterNowPrecedingCursor:(unichar)c { // Determine the type of the character - enum CharacterType { - CharacterTypeSeparator = 0, - CharacterTypeNormal - }; - enum CharacterType currentCharacterType = CharacterTypeNormal; - if ([HKWMentionsStartDetectionStateMachine.separatorSet characterIsMember:c] || c == 0) { - currentCharacterType = CharacterTypeSeparator; - } + enum CharacterType currentCharacterType = [self characterTypeOfCharacter:c]; switch (self.state) { case HKWMentionsStartDetectionStateQuiescentReady: case HKWMentionsStartDetectionStateQuiescentStalled: { // Reset the string buffer self.stringBuffer = [@"" mutableCopy]; self.charactersSinceLastWhitespace = 0; - if (currentCharacterType == CharacterTypeSeparator) { + if (currentCharacterType == CharacterTypeSeparator || currentCharacterType == CharacterTypeControlCharacter) { // The user moved the cursor to the beginning of the text region, or right after a newline or whitespace or punctuation - // character. This puts the user in the ready state. + // character or control character. This puts the user in the ready state. self.state = HKWMentionsStartDetectionStateQuiescentReady; } else if (currentCharacterType == CharacterTypeNormal) { @@ -298,6 +300,46 @@ - (void)mentionCreationResumed { self.state = HKWMentionsStartDetectionStateCreatingMention; } +#pragma mark - Public helper method + +/** + Returns a word after a certain location until a delimiter (whitespace or newline) is found. + Returns nil if no non-delimeter text is available. + */ ++ (nullable NSString *)wordAfterLocation:(NSUInteger)location text:(nonnull NSString *)text { + NSMutableString *const word = [[NSMutableString alloc] init]; + for(NSUInteger i = location; i < text.length ; i++) { + const unichar character = [text characterAtIndex:i]; + if ([HKWMentionsStartDetectionStateMachine.whitespaceSet characterIsMember:character]) { + break; + } + [word appendString:[NSString stringWithCharacters:&character length:1]]; + } + if (word.length == 0) { + return nil; + } + return [word copy]; +} + +#pragma mark - Private helper method + +- (CharacterType)characterTypeOfCharacter:(unichar)aCharacter { + __auto_type __strong delegate = self.delegate; + CharacterType characterType = CharacterTypeNormal; + if ([HKWMentionsStartDetectionStateMachine.whitespaceSet characterIsMember:aCharacter] || aCharacter == 0) { + characterType = CharacterTypeSeparator; + } else { + // Check first for control character because control character can be a separator. + // e.g "@" and "#" are both control character and separator. + NSCharacterSet *const controlCharacterSet = [delegate controlCharacterSet]; + if (controlCharacterSet && [controlCharacterSet characterIsMember:aCharacter]) { + characterType = CharacterTypeControlCharacter; + } else if ([HKWMentionsStartDetectionStateMachine.punctuationSet characterIsMember:aCharacter]) { + characterType = CharacterTypeSeparator; + } + } + return characterType; +} #pragma mark - Properties and Constants diff --git a/Hakawai/Mentions/_HKWMentionsStartDetectionStateMachine.h b/Hakawai/Mentions/_HKWMentionsStartDetectionStateMachine.h index 52464fa..a1f9f38 100644 --- a/Hakawai/Mentions/_HKWMentionsStartDetectionStateMachine.h +++ b/Hakawai/Mentions/_HKWMentionsStartDetectionStateMachine.h @@ -92,14 +92,27 @@ /*! Inform the state machine that a character was typed by the user into the text view. - \param inserted whether the character was already inserted into the text view's text buffer + \param c Character typed + \param inserted Whether the character was already inserted into the text view's text buffer + \param previousCharacter Character preceding typed character + \param wordFollowingTypedCharacter Word following the typed character */ -- (void)characterTyped:(unichar)c asInsertedCharacter:(BOOL)inserted previousCharacter:(unichar)previousCharacter; +- (void)characterTyped:(unichar)c + asInsertedCharacter:(BOOL)inserted + previousCharacter:(unichar)previousCharacter +wordFollowingTypedCharacter:(NSString *)wordFollowingTypedCharacter; /*! Inform the state machine that a character was deleted by the user from the text view. + \param deletedChar Character to be deleted + \param precedingChar Character before character to be deleted + \param location Location of character to be deleted + \param textViewText Text displayed by text view */ -- (void)deleteTypedCharacter:(unichar)deletedChar withCharacterNowPrecedingCursor:(unichar)precedingChar; +- (void)deleteTypedCharacter:(unichar)deletedChar +withCharacterNowPrecedingCursor:(unichar)precedingChar + location:(NSUInteger)location + textViewText:(NSString *)textViewText; /*! Inform the state machine that the cursor was moved from its prior position and is now in insertion mode. @@ -125,4 +138,9 @@ */ -(void) resetStateUsingString:(NSString *)string; +/*! + Return characters after given location till whitespace is encountered. + */ ++ (NSString *)wordAfterLocation:(NSUInteger)location text:(NSString *)text; + @end