Skip to content

Add MarkdownText component for preview #125

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

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
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
10 changes: 6 additions & 4 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import * as React from 'react';

import { Button, Platform, StyleSheet, Text, View } from 'react-native';
import {
MarkdownText,
MarkdownTextInput,
} from '@expensify/react-native-live-markdown';

import { MarkdownTextInput } from '@expensify/react-native-live-markdown';
import type { TextInput } from 'react-native';

const DEFAULT_TEXT = [
@@ -112,7 +115,7 @@ export default function App() {
onChangeText={setValue}
style={styles.input}
/> */}
<Text style={styles.text}>{JSON.stringify(value)}</Text>
<MarkdownText style={styles.text}>{value}</MarkdownText>
<Button title="Focus" onPress={() => ref.current?.focus()} />
<Button title="Blur" onPress={() => ref.current?.blur()} />
<Button title="Reset" onPress={() => setValue(DEFAULT_TEXT)} />
@@ -140,8 +143,7 @@ const styles = StyleSheet.create({
textAlignVertical: 'top',
},
text: {
fontFamily: 'Courier New',
marginTop: 10,
height: 100,
width: 300,
},
});
12 changes: 12 additions & 0 deletions ios/MarkdownTextDecoratorComponentView.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// This guard prevent this file to be compiled in the old architecture.
#ifdef RCT_NEW_ARCH_ENABLED
#import <React/RCTViewComponentView.h>

NS_ASSUME_NONNULL_BEGIN

@interface MarkdownTextDecoratorComponentView : RCTViewComponentView
@end

NS_ASSUME_NONNULL_END

#endif /* RCT_NEW_ARCH_ENABLED */
55 changes: 55 additions & 0 deletions ios/MarkdownTextDecoratorComponentView.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// This guard prevent this file to be compiled in the old architecture.
#ifdef RCT_NEW_ARCH_ENABLED
#import <react/renderer/components/RNLiveMarkdownSpec/ComponentDescriptors.h>
#import <react/renderer/components/RNLiveMarkdownSpec/Props.h>

#import <react-native-live-markdown/MarkdownTextDecoratorComponentView.h>
#import <react-native-live-markdown/MarkdownTextDecoratorView.h>
#import <react-native-live-markdown/RCTMarkdownStyle.h>

#import "RCTFabricComponentsPlugins.h"

using namespace facebook::react;

@implementation MarkdownTextDecoratorComponentView {
MarkdownTextDecoratorView *_view;
}

+ (ComponentDescriptorProvider)componentDescriptorProvider
{
return concreteComponentDescriptorProvider<MarkdownTextDecoratorViewComponentDescriptor>();
}

- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
static const auto defaultProps = std::make_shared<const MarkdownTextDecoratorViewProps>();
_props = defaultProps;

_view = [[MarkdownTextDecoratorView alloc] init];

self.contentView = _view;
}

return self;
}

- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
{
const auto &oldViewProps = *std::static_pointer_cast<MarkdownTextDecoratorViewProps const>(_props);
const auto &newViewProps = *std::static_pointer_cast<MarkdownTextDecoratorViewProps const>(props);

// TODO: if (oldViewProps.markdownStyle != newViewProps.markdownStyle)
RCTMarkdownStyle *markdownStyle = [[RCTMarkdownStyle alloc] initWithStruct:newViewProps.markdownStyle];
[_view setMarkdownStyle:markdownStyle];

[super updateProps:props oldProps:oldProps];
}

Class<RCTComponentViewProtocol> MarkdownTextDecoratorViewCls(void)
{
return MarkdownTextDecoratorComponentView.class;
}

@end
#endif
8 changes: 8 additions & 0 deletions ios/MarkdownTextDecoratorView.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#import <UIKit/UIKit.h>
#import <react-native-live-markdown/RCTMarkdownStyle.h>

@interface MarkdownTextDecoratorView : UIView

- (void)setMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle;

@end
93 changes: 93 additions & 0 deletions ios/MarkdownTextDecoratorView.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#import <React/RCTTextView.h>
#import <react/debug/react_native_assert.h>

//#import <react-native-live-markdown/MarkdownLayoutManager.h>
#import <react-native-live-markdown/MarkdownTextDecoratorView.h>
#import <react-native-live-markdown/RCTMarkdownUtils.h>
#import <react-native-live-markdown/RCTMarkdownStyle.h>
//#import <react-native-live-markdown/RCTBackedTextFieldDelegateAdapter+Markdown.h>
#import <react-native-live-markdown/RCTTextView+Markdown.h>

//#ifdef RCT_NEW_ARCH_ENABLED
//#import <react-native-live-markdown/RCTTextComponentView+Markdown.h>
//#else
//#import <react-native-live-markdown/RCTBaseTextView+Markdown.h>
//#endif /* RCT_NEW_ARCH_ENABLED */

#import <objc/runtime.h>

@implementation MarkdownTextDecoratorView {
RCTMarkdownUtils *_markdownUtils;
RCTMarkdownStyle *_markdownStyle;
// #ifdef RCT_NEW_ARCH_ENABLED
// __weak RCTTextComponentView *_text;
// #else
__weak RCTTextView *_text;
// #endif /* RCT_NEW_ARCH_ENABLED */
// __weak RCTBackedTextFieldDelegateAdapter *_adapter;
// __weak RCTUITextView *_textView;
}

- (void)didMoveToWindow {
#ifdef RCT_NEW_ARCH_ENABLED
if (self.superview.superview == nil) {
return;
}
#else
if (self.superview == nil) {
return;
}
#endif /* RCT_NEW_ARCH_ENABLED */

#ifdef RCT_NEW_ARCH_ENABLED
NSArray *viewsArray = self.superview.superview.subviews;
NSUInteger currentIndex = [viewsArray indexOfObject:self.superview];
#else
NSArray *viewsArray = self.superview.subviews;
NSUInteger currentIndex = [viewsArray indexOfObject:self];
#endif /* RCT_NEW_ARCH_ENABLED */

react_native_assert(currentIndex != 0 && currentIndex != NSNotFound && "Error while finding current component.");
UIView *view = [viewsArray objectAtIndex:currentIndex - 1];

#ifdef RCT_NEW_ARCH_ENABLED
// react_native_assert([view isKindOfClass:[RCTTextComponentView class]] && "Previous sibling component is not an instance of RCTTextComponentView.");
// _text = (RCTTextComponentView *)view;
// UIView<RCTBackedTextViewProtocol> *backedTextView = [_text valueForKey:@"_backedTextView"];
#else
react_native_assert([view isKindOfClass:[RCTTextView class]] && "Previous sibling component is not an instance of RCTTextView.");
_text = (RCTTextView *)view;
#endif /* RCT_NEW_ARCH_ENABLED */

_markdownUtils = [[RCTMarkdownUtils alloc] initWithTextView:_text];
react_native_assert(_markdownStyle != nil);
[_markdownUtils setMarkdownStyle:_markdownStyle];

[_text setMarkdownUtils:_markdownUtils];
}

- (void)willMoveToWindow:(UIWindow *)newWindow
{
// if (_text != nil) {
// [_text setMarkdownUtils:nil];
// }
// if (_adapter != nil) {
// [_adapter setMarkdownUtils:nil];
// }
// if (_textView != nil) {
// [_textView setMarkdownUtils:nil];
// if (_textView.layoutManager != nil && [object_getClass(_textView.layoutManager) isEqual:[MarkdownLayoutManager class]]) {
// [_textView.layoutManager setValue:nil forKey:@"markdownUtils"];
// object_setClass(_textView.layoutManager, [NSLayoutManager class]);
// }
// }
}

- (void)setMarkdownStyle:(nonnull RCTMarkdownStyle *)markdownStyle
{
_markdownStyle = markdownStyle;
[_markdownUtils setMarkdownStyle:markdownStyle];
// [_text textDidChange]; // trigger attributed text update
}

@end
5 changes: 5 additions & 0 deletions ios/MarkdownTextDecoratorViewManager.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#import <React/RCTViewManager.h>

@interface MarkdownTextDecoratorViewManager : RCTViewManager

@end
23 changes: 23 additions & 0 deletions ios/MarkdownTextDecoratorViewManager.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#import <react-native-live-markdown/MarkdownTextDecoratorViewManager.h>
#import <react-native-live-markdown/MarkdownTextDecoratorView.h>

@implementation MarkdownTextDecoratorViewManager

RCT_EXPORT_MODULE(MarkdownTextDecoratorView)

- (UIView *)view
{
return [[MarkdownTextDecoratorView alloc] init];
}

RCT_CUSTOM_VIEW_PROPERTY(markdownStyle, NSDictionary, MarkdownTextDecoratorView)
{
#ifdef RCT_NEW_ARCH_ENABLED
// implemented in MarkdownTextDecoratorView updateProps:
#else
RCTMarkdownStyle *markdownStyle = [[RCTMarkdownStyle alloc] initWithDictionary:json];
[view setMarkdownStyle:markdownStyle];
#endif /* RCT_NEW_ARCH_ENABLED */
}

@end
4 changes: 4 additions & 0 deletions ios/RCTMarkdownUtils.h
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
#import <React/RCTBackedTextInputViewProtocol.h>
#import <React/RCTTextView.h>
#import <react-native-live-markdown/RCTMarkdownStyle.h>

@interface RCTMarkdownUtils : NSObject

@property (nonatomic) RCTMarkdownStyle *markdownStyle;
@property (nonatomic) NSMutableArray<NSValue *> *quoteRanges;
@property (weak, nonatomic) UIView<RCTBackedTextInputViewProtocol> *backedTextInputView;
@property (weak, nonatomic) RCTTextView *textView;

- (instancetype)initWithBackedTextInputView:(UIView<RCTBackedTextInputViewProtocol> *)backedTextInputView;

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

- (NSAttributedString *)parseMarkdown:(NSAttributedString *)input;

@end
20 changes: 18 additions & 2 deletions ios/RCTMarkdownUtils.mm
Original file line number Diff line number Diff line change
@@ -18,6 +18,14 @@ - (instancetype)initWithBackedTextInputView:(UIView<RCTBackedTextInputViewProtoc
return self;
}

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

- (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input
{
RCTAssertMainQueue();
@@ -46,7 +54,13 @@ - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input
JSValue *result = [function callWithArguments:@[inputString]];
NSArray *ranges = [result toArray];

NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:inputString attributes:_backedTextInputView.defaultTextAttributes];
// NSDictionary<NSAttributedStringKey, id> *defaultTextAttributes = _backedTextInputView.defaultTextAttributes;
// if (defaultTextAttributes == nil) {
// defaultTextAttributes = @{};
// }
// assert(defaultTextAttributes != nil);
// NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:inputString attributes:defaultTextAttributes];
NSMutableAttributedString *attributedString = [input mutableCopy];
[attributedString beginEditing];

// If the attributed string ends with underlined text, blurring the single-line input imprints the underline style across the whole string.
@@ -80,7 +94,9 @@ - (NSAttributedString *)parseMarkdown:(nullable NSAttributedString *)input
size = _markdownStyle.h1FontSize;
}
UIFont *newFont = [UIFont fontWithDescriptor:newFontDescriptor size:size];
[attributedString addAttribute:NSFontAttributeName value:newFont range:range];
if (newFont != nil) {
[attributedString addAttribute:NSFontAttributeName value:newFont range:range];
}
}

if ([type isEqualToString:@"syntax"]) {
17 changes: 17 additions & 0 deletions ios/RCTTextView+Markdown.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#import <UIKit/UIKit.h>
#import <React/RCTTextView.h>
#import <react-native-live-markdown/RCTMarkdownUtils.h>

NS_ASSUME_NONNULL_BEGIN

@interface RCTTextView (Markdown)

@property(nonatomic, nullable, getter=getMarkdownUtils) RCTMarkdownUtils *markdownUtils;

- (void)markdown_setTextStorage:(NSTextStorage *)textStorage
contentFrame:(CGRect)contentFrame
descendantViews:(NSArray<UIView *> *)descendantViews;

@end

NS_ASSUME_NONNULL_END
44 changes: 44 additions & 0 deletions ios/RCTTextView+Markdown.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#import <react-native-live-markdown/RCTTextView+Markdown.h>
#import <react-native-live-markdown/RCTMarkdownUtils.h>
#import <objc/message.h>

@implementation RCTTextView (Markdown)

- (void)setMarkdownUtils:(RCTMarkdownUtils *)markdownUtils {
objc_setAssociatedObject(self, @selector(getMarkdownUtils), markdownUtils, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (RCTMarkdownUtils *)getMarkdownUtils {
return objc_getAssociatedObject(self, @selector(getMarkdownUtils));
}

- (void)markdown_setTextStorage:(NSTextStorage *)textStorage
contentFrame:(CGRect)contentFrame
descendantViews:(NSArray<UIView *> *)descendantViews
{
RCTMarkdownUtils *markdownUtils = [self getMarkdownUtils];
if (markdownUtils != nil) {
NSRange range = NSMakeRange(0, [textStorage length]);
NSAttributedString *input = [textStorage attributedSubstringFromRange:range];
NSAttributedString *output = [markdownUtils parseMarkdown:input];
[textStorage replaceCharactersInRange:range withAttributedString:output];
}

// Call the original method
[self markdown_setTextStorage:textStorage contentFrame:contentFrame descendantViews:descendantViews];
}

+ (void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class cls = [self class];
SEL originalSelector = @selector(setTextStorage:contentFrame:descendantViews:);
SEL swizzledSelector = @selector(markdown_setTextStorage:contentFrame:descendantViews:);
Method originalMethod = class_getInstanceMethod(cls, originalSelector);
Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector);
method_exchangeImplementations(originalMethod, swizzledMethod);
});
}

@end
121 changes: 121 additions & 0 deletions src/MarkdownText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { StyleSheet, Text, processColor } from 'react-native';

import type { MarkdownStyle } from './MarkdownTextDecoratorViewNativeComponent';
import MarkdownTextDecoratorViewNativeComponent from './MarkdownTextDecoratorViewNativeComponent';
import React from 'react';
import type { TextProps } from 'react-native';

function makeDefaultMarkdownStyle(): MarkdownStyle {
return {
syntax: {
color: 'gray',
},
link: {
color: 'blue',
},
h1: {
fontSize: 25,
},
quote: {
borderColor: 'gray',
borderWidth: 6,
marginLeft: 6,
paddingLeft: 6,
},
code: {
color: 'black',
backgroundColor: 'lightgray',
},
pre: {
color: 'black',
backgroundColor: 'lightgray',
},
mentionHere: {
backgroundColor: 'yellow',
},
mentionUser: {
backgroundColor: 'cyan',
},
};
}

export type PartialMarkdownStyle = Partial<{
[K in keyof MarkdownStyle]: Partial<MarkdownStyle[K]>;
}>;

function mergeMarkdownStyleWithDefault(
input: PartialMarkdownStyle | undefined
): MarkdownStyle {
const output = makeDefaultMarkdownStyle();

if (input !== undefined) {
for (const key in input) {
if (key in output) {
Object.assign(
output[key as keyof MarkdownStyle],
input[key as keyof MarkdownStyle]
);
}
}
}

return output;
}

function processColorsInMarkdownStyle(input: MarkdownStyle): MarkdownStyle {
const output = JSON.parse(JSON.stringify(input));

for (const key in output) {
const obj = output[key];
for (const prop in obj) {
// TODO: use ReactNativeStyleAttributes from 'react-native/Libraries/Components/View/ReactNativeStyleAttributes'
if (prop === 'color' || prop.endsWith('Color')) {
obj[prop] = processColor(obj[prop]);
}
}
}

return output;
}

function processMarkdownStyle(
input: PartialMarkdownStyle | undefined
): MarkdownStyle {
return processColorsInMarkdownStyle(mergeMarkdownStyleWithDefault(input));
}

export interface MarkdownTextProps extends TextProps {
markdownStyle?: PartialMarkdownStyle;
}

const MarkdownText = React.forwardRef<Text, MarkdownTextProps>((props, ref) => {
const IS_FABRIC = 'nativeFabricUIManager' in global;

const markdownStyle = React.useMemo(
() => processMarkdownStyle(props.markdownStyle),
[props.markdownStyle]
);

return (
<>
<Text {...props} ref={ref} />
<MarkdownTextDecoratorViewNativeComponent
style={IS_FABRIC ? styles.farAway : styles.displayNone}
markdownStyle={markdownStyle}
/>
</>
);
});

const styles = StyleSheet.create({
displayNone: {
display: 'none',
},
farAway: {
position: 'absolute',
top: 1e8,
left: 1e8,
},
});

export default MarkdownText;
42 changes: 42 additions & 0 deletions src/MarkdownTextDecoratorViewNativeComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { ColorValue, ViewProps } from 'react-native';

import type { Float } from 'react-native/Libraries/Types/CodegenTypes';
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';

export interface MarkdownStyle {
syntax: {
color: ColorValue;
};
link: {
color: ColorValue;
};
h1: {
fontSize: Float;
};
quote: {
borderColor: ColorValue;
borderWidth: Float;
marginLeft: Float;
paddingLeft: Float;
};
code: {
color: ColorValue;
backgroundColor: ColorValue;
};
pre: {
color: ColorValue;
backgroundColor: ColorValue;
};
mentionHere: {
backgroundColor: ColorValue;
};
mentionUser: {
backgroundColor: ColorValue;
};
}

interface NativeProps extends ViewProps {
markdownStyle: MarkdownStyle;
}

export default codegenNativeComponent<NativeProps>('MarkdownTextDecoratorView');
2 changes: 2 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export { default as MarkdownTextInput } from './MarkdownTextInput';
export { default as MarkdownText } from './MarkdownText';
export type { MarkdownTextProps } from './MarkdownText';
export type {
MarkdownTextInputProps,
PartialMarkdownStyle as MarkdownStyle,