Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use NSTextStorageDelegate instead of method swizzling #520

Open
wants to merge 47 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
edce693
Use `NSTextStorageDelegate` instead of method swizzling
tomekzaw Oct 17, 2024
1ea65e4
Fix spellcheck
tomekzaw Oct 17, 2024
4214989
Merge branch 'main' into @tomekzaw/NSTextStorageDelegate
tomekzaw Oct 21, 2024
20166dd
Add singleline implementation
tomekzaw Oct 21, 2024
b089830
Improve multiline cleanup
tomekzaw Oct 21, 2024
d5cc035
Add TODO about spellcheck
tomekzaw Oct 21, 2024
80c0686
Add missing imports
tomekzaw Oct 21, 2024
0d57eeb
Add support for old architecture
tomekzaw Oct 21, 2024
5675e04
Add #ifdef for imports
tomekzaw Oct 21, 2024
e1fca80
Restore original App.tsx
tomekzaw Oct 23, 2024
5a1651f
Don't mix styles
tomekzaw Oct 23, 2024
1b016bf
Merge branch 'main' into @tomekzaw/NSTextStorageDelegate
tomekzaw Nov 5, 2024
e9e0e60
Update Podfile.lock
tomekzaw Nov 5, 2024
d0ef4ef
Assert that delegate is nil
tomekzaw Nov 5, 2024
8c06be0
Add comment with link to the issue
tomekzaw Nov 5, 2024
9ff01d5
Reformat singleline input on `markdownStyle` change
tomekzaw Nov 5, 2024
40abeba
Add nice constructors for `MarkdownTextStorageDelegate` and `Markdown…
tomekzaw Nov 5, 2024
67968ec
Fix emoji not visible inside text (seriously, this took like 3 hours)
tomekzaw Nov 5, 2024
d377043
Fix updating `style` without updating `markdownStyle` for multiline i…
tomekzaw Nov 5, 2024
6e40703
Add comments
tomekzaw Nov 5, 2024
9fc1d8e
Merge branch 'main' into @tomekzaw/NSTextStorageDelegate
tomekzaw Nov 6, 2024
1fa1b54
Update Podfile.lock
tomekzaw Nov 6, 2024
2518c5f
Merge branch 'main' into @tomekzaw/NSTextStorageDelegate
tomekzaw Nov 20, 2024
7b475de
Update Podfile.lock
tomekzaw Nov 20, 2024
1c432fe
Fix flickering and missing formatting when toggling multiline prop
tomekzaw Nov 20, 2024
2a37b85
Merge branch 'main' into @tomekzaw/NSTextStorageDelegate
tomekzaw Nov 21, 2024
7a13eac
Update Podfile.lock
tomekzaw Nov 21, 2024
3a6a58a
Update Podfile.lock
tomekzaw Nov 21, 2024
003ed5e
Update Podfile.lock
tomekzaw Nov 21, 2024
d402ca9
Merge branch 'main' into @tomekzaw/NSTextStorageDelegate
tomekzaw Nov 26, 2024
5cf9b9b
Update Podfile.lock
tomekzaw Nov 26, 2024
c00c440
Fix cursor position in new line after blockquote
tomekzaw Nov 26, 2024
7425d76
Remove trailing whitespace
tomekzaw Nov 27, 2024
511d885
Optimize updating paragraph style in typing attributes
tomekzaw Nov 27, 2024
ebd9813
Merge branch 'main' into @tomekzaw/NSTextStorageDelegate
tomekzaw Dec 9, 2024
b5c0e11
Remove outdated TODO
tomekzaw Dec 9, 2024
8400a91
Merge branch 'main' into @tomekzaw/NSTextStorageDelegate
tomekzaw Dec 10, 2024
4b05c62
Merge branch 'main' into @tomekzaw/NSTextStorageDelegate
tomekzaw Dec 10, 2024
0c381f6
Update Podfile.lock
tomekzaw Dec 10, 2024
1363312
Fix applying underline to the whole text on singleline input blur
tomekzaw Dec 10, 2024
1f53ff2
Eliminate underline blinks while typing if previous text ends with a …
tomekzaw Dec 10, 2024
7977d83
Introduce `applyMarkdownFormatting` method in `MarkdownTextFieldObser…
tomekzaw Dec 10, 2024
a3e9169
Fix underline blinks while typing after a link
tomekzaw Dec 11, 2024
1fb4a00
Merge branch 'main' into @tomekzaw/NSTextStorageDelegate
tomekzaw Dec 12, 2024
edfa822
Fix zombie blockquote ribbon and indent
tomekzaw Dec 12, 2024
03ec16f
Add comment
tomekzaw Dec 12, 2024
4c70fba
Fix regression with spellcheck disappearing immediately
tomekzaw Dec 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apple/MarkdownBackedTextInputDelegate.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#import <React/RCTBackedTextInputDelegate.h>
#import <React/RCTUITextView.h>

NS_ASSUME_NONNULL_BEGIN

@interface MarkdownBackedTextInputDelegate : NSObject <RCTBackedTextInputDelegate>

- (instancetype)initWithTextView:(RCTUITextView *)textView;

@end

NS_ASSUME_NONNULL_END
75 changes: 75 additions & 0 deletions apple/MarkdownBackedTextInputDelegate.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#import "MarkdownBackedTextInputDelegate.h"

@implementation MarkdownBackedTextInputDelegate {
__weak RCTUITextView *_textView;
id<RCTBackedTextInputDelegate> _originalTextInputDelegate;
}

- (instancetype)initWithTextView:(RCTUITextView *)textView
{
if (self = [super init]) {
_textView = textView;
_originalTextInputDelegate = _textView.textInputDelegate;
_textView.textInputDelegate = self;
}
return self;
}

- (void)dealloc
{
// Restore original text input delegate
_textView.textInputDelegate = _originalTextInputDelegate;
}

- (void)textInputDidChange
{
// After adding a newline at the end of the blockquote, the typing attributes in the new line
// still contain NSParagraphStyle with non-zero firstLineHeadIndent and headIntent.
// This causes the cursor to be shifted to the right instead of being located at the beginning of the line.
// Also, if the previous line of the text ends with a link, there will be underline blinks visible while typing.
// The following code resets typing attributes with default text attributes to fix both problems at once.
_textView.typingAttributes = _textView.defaultTextAttributes;

// Delegate the call to the original text input delegate
[_originalTextInputDelegate textInputDidChange];
}

// Delegate all remaining calls to the original text input delegate

- (void)textInputDidBeginEditing {
[_originalTextInputDelegate textInputDidBeginEditing];
}

- (void)textInputDidChangeSelection {
[_originalTextInputDelegate textInputDidChangeSelection];
}

- (void)textInputDidEndEditing {
[_originalTextInputDelegate textInputDidEndEditing];
}

- (void)textInputDidReturn {
[_originalTextInputDelegate textInputDidReturn];
}

- (BOOL)textInputShouldBeginEditing {
return [_originalTextInputDelegate textInputShouldBeginEditing];
}

- (nonnull NSString *)textInputShouldChangeText:(nonnull NSString *)text inRange:(NSRange)range {
return [_originalTextInputDelegate textInputShouldChangeText:text inRange:range];
}

- (BOOL)textInputShouldEndEditing {
return [_originalTextInputDelegate textInputShouldEndEditing];
}

- (BOOL)textInputShouldReturn {
return [_originalTextInputDelegate textInputShouldReturn];
}

- (BOOL)textInputShouldSubmitOnReturn {
return [_originalTextInputDelegate textInputShouldSubmitOnReturn];
}

@end
8 changes: 4 additions & 4 deletions apple/MarkdownCommitHook.mm
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,8 @@
}

// apply markdown
auto newString = [usedUtils parseMarkdown:nsAttributedString
withDefaultTextAttributes:defaultNSTextAttributes];
NSMutableAttributedString *newString = [nsAttributedString mutableCopy];
tomekzaw marked this conversation as resolved.
Show resolved Hide resolved
[usedUtils applyMarkdownFormatting:newString withDefaultTextAttributes:defaultNSTextAttributes];

// create a clone of the old TextInputState and update the
// attributed string box to point to the string with markdown
Expand Down Expand Up @@ -246,8 +246,8 @@
stateData.attributedStringBox);

// apply markdown
auto newString = [usedUtils parseMarkdown:nsAttributedString
withDefaultTextAttributes:defaultNSTextAttributes];
NSMutableAttributedString *newString = [nsAttributedString mutableCopy];
[usedUtils applyMarkdownFormatting:newString withDefaultTextAttributes:defaultNSTextAttributes];

// create a clone of the old TextInputState and update the
// attributed string box to point to the string with markdown
Expand Down
8 changes: 4 additions & 4 deletions apple/MarkdownFormatter.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ const NSAttributedStringKey RCTLiveMarkdownBlockquoteDepthAttributeName = @"RCTL

@interface MarkdownFormatter : NSObject

- (nonnull NSAttributedString *)format:(nonnull NSString *)text
withDefaultTextAttributes:(nonnull NSDictionary<NSAttributedStringKey, id> *)defaultTextAttributes
withMarkdownRanges:(nonnull NSArray<MarkdownRange *> *)markdownRanges
withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle;
- (void)formatAttributedString:(nonnull NSMutableAttributedString *)attributedString
withDefaultTextAttributes:(nonnull NSDictionary<NSAttributedStringKey, id> *)defaultTextAttributes
withMarkdownRanges:(nonnull NSArray<MarkdownRange *> *)markdownRanges
withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle;

NS_ASSUME_NONNULL_END

Expand Down
43 changes: 30 additions & 13 deletions apple/MarkdownFormatter.mm
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,22 @@

@implementation MarkdownFormatter

- (nonnull NSAttributedString *)format:(nonnull NSString *)text
withDefaultTextAttributes:(nonnull NSDictionary<NSAttributedStringKey, id> *)defaultTextAttributes
withMarkdownRanges:(nonnull NSArray<MarkdownRange *> *)markdownRanges
withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle
- (void)formatAttributedString:(nonnull NSMutableAttributedString *)attributedString
withDefaultTextAttributes:(nonnull NSDictionary<NSAttributedStringKey, id> *)defaultTextAttributes
withMarkdownRanges:(nonnull NSArray<MarkdownRange *> *)markdownRanges
withMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle
{
NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:text attributes:defaultTextAttributes];
NSRange fullRange = NSMakeRange(0, attributedString.length);

[attributedString beginEditing];

// If the attributed string ends with underlined text, blurring the single-line input imprints the underline style across the whole string.
// It looks like a bug in iOS, as there is no underline style to be found in the attributed string, especially after formatting.
// This is a workaround that applies the NSUnderlineStyleNone to the string before iterating over ranges which resolves this problem.
[attributedString addAttribute:NSUnderlineStyleAttributeName
value:[NSNumber numberWithInteger:NSUnderlineStyleNone]
range:NSMakeRange(0, attributedString.length)];
// We cannot simply call `[attributedString setAttributes:@{} range:fullRange];`
// because it makes spellcheck disappear immediately and also makes cursor lag behind while typing fast.

[attributedString removeAttribute:NSParagraphStyleAttributeName range:fullRange];
[attributedString removeAttribute:RCTLiveMarkdownBlockquoteDepthAttributeName range:fullRange];

[attributedString addAttributes:defaultTextAttributes range:fullRange];

for (MarkdownRange *markdownRange in markdownRanges) {
[self applyRangeToAttributedString:attributedString
Expand All @@ -29,9 +30,25 @@ - (nonnull NSAttributedString *)format:(nonnull NSString *)text

RCTApplyBaselineOffset(attributedString);

[attributedString endEditing];
/*
Calling `[attributedString addAttributes:defaultTextAttributes range:fullRange]` breaks the font for emojis.
Before, NSFont attribute is ".SFUI-Regular" and NSOriginalFont attribute is ".AppleColorEmoji".
After the call, both are set to ".SFUI-Regular" which makes emoji invisible and zero-width.
Calling `fixAttributesInRange:` fixes this problem.
*/
[attributedString fixAttributesInRange:fullRange];

/*
When updating MarkdownTextInput's `style` property without changing `markdownStyle`,
React Native calls `[RCTTextInputComponentView _setAttributedString:]` which skips update if strings are equal.
See https://github.com/facebook/react-native/blob/287e20033207df5e59d199a347b7ae2b4cd7a59e/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm#L680-L684
The attributed strings are compared using `[RCTTextInputComponentView _textOf:equals:]` which compares only raw strings
if NSOriginalFont attribute is present. So we purposefully remove this attribute to force update.
See https://github.com/facebook/react-native/blob/287e20033207df5e59d199a347b7ae2b4cd7a59e/packages/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm#L751-L784
*/
[attributedString removeAttribute:@"NSOriginalFont" range:fullRange];

return attributedString;
[attributedString endEditing];
}

- (void)applyRangeToAttributedString:(NSMutableAttributedString *)attributedString
Expand Down
6 changes: 3 additions & 3 deletions apple/MarkdownParser.mm
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ @implementation MarkdownParser {
output = markdownRuntime->runGuarded(markdownWorklet, input);
} catch (const jsi::JSError &error) {
// Skip formatting, runGuarded will show the error in LogBox
_prevText = text;
_prevText = [NSString stringWithString:text];
_prevParserId = parserId;
_prevMarkdownRanges = @[];
return _prevMarkdownRanges;
Expand All @@ -58,13 +58,13 @@ @implementation MarkdownParser {
}
} catch (const jsi::JSError &error) {
RCTLogWarn(@"[react-native-live-markdown] Incorrect schema of worklet parser output: %s", error.getMessage().c_str());
_prevText = text;
_prevText = [NSString stringWithString:text];
_prevParserId = parserId;
_prevMarkdownRanges = @[];
return _prevMarkdownRanges;
}

_prevText = text;
_prevText = [NSString stringWithString:text];
_prevParserId = parserId;
_prevMarkdownRanges = markdownRanges;
return _prevMarkdownRanges;
Expand Down
17 changes: 17 additions & 0 deletions apple/MarkdownTextFieldObserver.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#import <UIKit/UIKit.h>
#import <React/RCTUITextField.h>
#import <RNLiveMarkdown/RCTMarkdownUtils.h>

NS_ASSUME_NONNULL_BEGIN

@interface MarkdownTextFieldObserver : NSObject

- (instancetype)initWithTextField:(nonnull RCTUITextField *)textField markdownUtils:(nonnull RCTMarkdownUtils *)markdownUtils;

- (void)textFieldDidChange:(UITextField *)textField;

- (void)textFieldDidEndEditing:(UITextField *)textField;

@end

NS_ASSUME_NONNULL_END
73 changes: 73 additions & 0 deletions apple/MarkdownTextFieldObserver.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#import <RNLiveMarkdown/MarkdownTextFieldObserver.h>
#import "react_native_assert.h"

@implementation MarkdownTextFieldObserver {
RCTUITextField *_textField;
RCTMarkdownUtils *_markdownUtils;
BOOL _active;
}

- (instancetype)initWithTextField:(nonnull RCTUITextField *)textField markdownUtils:(nonnull RCTMarkdownUtils *)markdownUtils
{
if ((self = [super init])) {
react_native_assert(textField != nil);
react_native_assert(markdownUtils != nil);

_textField = textField;
_markdownUtils = markdownUtils;
_active = YES;
}
return self;
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
if (_active && ([keyPath isEqualToString:@"text"] || [keyPath isEqualToString:@"attributedText"])) {
[self applyMarkdownFormatting];
}
}

- (void)textFieldDidChange:(__unused UITextField *)textField
{
[self applyMarkdownFormatting];
}

- (void)textFieldDidEndEditing:(__unused UITextField *)textField
{
// In order to prevent iOS from applying underline to the whole text if text ends with a link on blur,
// we need to update `defaultTextAttributes` which at this point doesn't contain NSUnderline attribute yet.
// It seems like the setter performs deep comparision, so we differentiate the new value using a counter,
// otherwise this trick would work only once.
static NSAttributedStringKey RCTLiveMarkdownForceUpdateAttributeName = @"RCTLiveMarkdownForceUpdate";
static NSUInteger counter = 0;
NSMutableDictionary *defaultTextAttributes = [_textField.defaultTextAttributes mutableCopy];
defaultTextAttributes[RCTLiveMarkdownForceUpdateAttributeName] = @(counter++);
_textField.defaultTextAttributes = defaultTextAttributes;
[self applyMarkdownFormatting];
}

- (void)applyMarkdownFormatting
{
react_native_assert(_textField.defaultTextAttributes != nil);

if (_textField.markedTextRange != nil) {
return; // skip formatting during multi-stage input to avoid breaking internal state
}

NSMutableAttributedString *attributedText = [_textField.attributedText mutableCopy];
[_markdownUtils applyMarkdownFormatting:attributedText withDefaultTextAttributes:_textField.defaultTextAttributes];

UITextRange *textRange = _textField.selectedTextRange;

_active = NO; // prevent recursion
_textField.attributedText = attributedText;
_active = YES;

// Restore cursor position
[_textField setSelectedTextRange:textRange notifyDelegate:NO];

// Eliminate underline blinks while typing if previous text ends with a link
_textField.typingAttributes = _textField.defaultTextAttributes;
}

@end
Loading
Loading