PLT-5717 Initial markdown support (#354)

* Added CommonMark-based post rendering

* Fixed text wrapping in most block elements

* Fixed paragraphs not being rendered as children of lists

* Replaced markdown images with a placeholder

* Fixed font colour in code spans
This commit is contained in:
Harrison Healey 2017-03-20 15:27:33 -04:00 committed by enahum
parent 70da78af69
commit 07af08b74a
11 changed files with 565 additions and 16 deletions

View file

@ -0,0 +1,195 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {Parser} from 'commonmark';
import Renderer from 'commonmark-react-renderer';
import React, {PropTypes, PureComponent} from 'react';
import {
StyleSheet,
Text,
View
} from 'react-native';
import CustomPropTypes from 'app/constants/custom_prop_types';
import {concatStyles} from 'app/utils/theme';
import MarkdownBlockQuote from './markdown_block_quote';
import MarkdownCodeBlock from './markdown_code_block';
import MarkdownLink from './markdown_link';
import MarkdownList from './markdown_list';
import MarkdownListItem from './markdown_list_item';
export default class Markdown extends PureComponent {
static propTypes = {
baseTextStyle: CustomPropTypes.Style,
textStyles: PropTypes.object,
blockStyles: PropTypes.object,
value: PropTypes.string.isRequired
};
static defaultProps = {
textStyles: {},
blockStyles: {}
};
constructor(props) {
super(props);
this.parser = new Parser();
this.renderer = this.createRenderer();
}
createRenderer = () => {
return new Renderer({
renderers: {
text: this.renderText,
emph: Renderer.forwardChildren,
strong: Renderer.forwardChildren,
code: this.renderCodeSpan,
link: MarkdownLink,
image: this.renderImage,
paragraph: this.renderParagraph,
heading: this.renderHeading,
codeBlock: this.renderCodeBlock,
blockQuote: this.renderBlockQuote,
list: this.renderList,
item: this.renderListItem,
hardBreak: this.renderHardBreak,
thematicBreak: this.renderThematicBreak,
softBreak: this.renderSoftBreak,
htmlBlock: this.renderHtml,
htmlInline: this.renderHtml
},
renderParagraphsInLists: true
});
}
computeTextStyle = (baseStyle, context) => {
return concatStyles(baseStyle, context.map((type) => this.props.textStyles[type]));
}
renderText = ({context, literal}) => {
// Construct the text style based off of the parents of this node since RN's inheritance is limited
return <Text style={this.computeTextStyle(this.props.baseTextStyle, context)}>{literal}</Text>;
}
renderCodeSpan = ({context, literal}) => {
return <Text style={this.computeTextStyle([this.props.baseTextStyle, this.props.textStyles.code], context)}>{literal}</Text>;
}
renderImage = ({children, context, src}) => {
// TODO This will be properly implemented for PLT-5736
return (
<Text style={this.computeTextStyle(this.props.baseTextStyle, context)}>
{'!['}
{children}
{']('}
{src}
{')'}
</Text>
);
}
renderParagraph = ({children}) => {
return (
<View style={style.block}>
<Text>
{children}
</Text>
</View>
);
}
renderHeading = ({children, level}) => {
return (
<View style={[style.block, this.props.blockStyles[`heading${level}`]]}>
<Text>
{children}
</Text>
</View>
);
}
renderCodeBlock = (props) => {
// These sometimes include a trailing newline
const contents = props.literal.replace(/\n$/, '');
return (
<MarkdownCodeBlock
blockStyle={this.props.blockStyles.codeBlock}
textStyle={concatStyles(this.props.baseTextStyle, this.props.textStyles.codeBlock)}
>
{contents}
</MarkdownCodeBlock>
);
}
renderBlockQuote = ({children, ...otherProps}) => {
return (
<MarkdownBlockQuote
bulletStyle={this.props.baseTextStyle}
{...otherProps}
>
{children}
</MarkdownBlockQuote>
);
}
renderList = ({children, start, tight, type}) => {
return (
<MarkdownList
ordered={type !== 'bullet'}
startAt={start}
tight={tight}
>
{children}
</MarkdownList>
);
}
renderListItem = ({children, ...otherProps}) => {
return (
<MarkdownListItem
bulletStyle={this.props.baseTextStyle}
{...otherProps}
>
{children}
</MarkdownListItem>
);
}
renderHardBreak = () => {
return <View/>;
}
renderThematicBreak = () => {
return <View style={this.props.blockStyles.horizontalRule}/>;
}
renderSoftBreak = () => {
return <Text>{'\n'}</Text>;
}
renderHtml = () => {
return null;
}
render() {
const ast = this.parser.parse(this.props.value);
return <View>{this.renderer.render(ast)}</View>;
}
}
const style = StyleSheet.create({
block: {
alignItems: 'flex-start',
flexDirection: 'row',
flexWrap: 'wrap'
}
});

View file

@ -0,0 +1,41 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import {
StyleSheet,
Text,
View
} from 'react-native';
import CustomPropTypes from 'app/constants/custom_prop_types';
export default class MarkdownBlockQuote extends PureComponent {
static propTypes = {
blockStyle: CustomPropTypes.Style,
bulletStyle: CustomPropTypes.Style,
children: CustomPropTypes.Children.isRequired
};
render() {
return (
<View style={style.container}>
<View>
<Text style={this.props.bulletStyle}>
{'> '}
</Text>
</View>
<View>
{this.props.children}
</View>
</View>
);
}
}
const style = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-start'
}
});

View file

@ -0,0 +1,28 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import {ScrollView, Text} from 'react-native';
import CustomPropTypes from 'app/constants/custom_prop_types';
export default class MarkdownCodeBlock extends PureComponent {
static propTypes = {
children: CustomPropTypes.Children,
blockStyle: CustomPropTypes.Style,
textStyle: CustomPropTypes.Style
};
render() {
return (
<ScrollView
style={this.props.blockStyle}
horizontal={true}
>
<Text style={this.props.textStyle}>
{this.props.children}
</Text>
</ScrollView>
);
}
}

View file

@ -0,0 +1,69 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PropTypes, PureComponent} from 'react';
import {Image} from 'react-native';
export default class MarkdownLink extends PureComponent {
static propTypes = {
src: PropTypes.string.isRequired
};
constructor(props) {
super(props);
this.state = {
width: 10000,
maxWidth: 10000,
height: 0
};
}
componentWillMount() {
Image.getSize(this.props.src, this.handleSizeReceived, this.handleSizeFailed);
}
componentWillReceiveProps(nextProps) {
if (this.props.src !== nextProps.src) {
Image.getSize(nextProps.src, this.handleSizeReceived, this.handleSizeFailed);
}
}
handleSizeReceived = (width, height) => {
this.setState({
width,
height
});
};
handleSizeFailed = () => {
this.setState({
width: 0,
height: 0
});
}
handleLayout = (event) => {
this.setState({
maxWidth: event.nativeEvent.layout.width
});
}
render() {
let {width, maxWidth, height} = this.state; // eslint-disable-line prefer-const
if (width > maxWidth) {
height = height * (maxWidth / width);
width = maxWidth;
}
// React Native complains if we try to pass resizeMode into a StyleSheet
return (
<Image
source={{uri: this.props.src}}
onLayout={this.handleLayout}
style={{width, height, flexShrink: 1, resizeMode: 'cover'}}
/>
);
}
}

View file

@ -0,0 +1,28 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PropTypes, PureComponent} from 'react';
import {Linking, Text} from 'react-native';
import CustomPropTypes from 'app/constants/custom_prop_types';
export default class MarkdownLink extends PureComponent {
static propTypes = {
children: CustomPropTypes.Children.isRequired,
href: PropTypes.string.isRequired
};
handlePress = () => {
const url = this.props.href;
Linking.canOpenURL(url).then((supported) => {
if (supported) {
Linking.openURL(url);
}
});
};
render() {
return <Text onPress={this.handlePress}>{this.props.children}</Text>;
}
}

View file

@ -0,0 +1,31 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PropTypes, PureComponent} from 'react';
import {View} from 'react-native';
export default class MarkdownList extends PureComponent {
static propTypes = {
children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf([PropTypes.node])]).isRequired,
ordered: PropTypes.bool.isRequired,
startAt: PropTypes.number,
tight: PropTypes.bool
};
render() {
const children = React.Children.map(this.props.children, (child, i) => {
return React.cloneElement(child, {
ordered: this.props.ordered,
startAt: this.props.startAt,
index: i,
tight: this.props.tight
});
});
return (
<View>
{children}
</View>
);
}
}

View file

@ -0,0 +1,55 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PropTypes, PureComponent} from 'react';
import {
StyleSheet,
Text,
View
} from 'react-native';
import CustomPropTypes from 'app/constants/custom_prop_types';
export default class MarkdownListItem extends PureComponent {
static propTypes = {
children: CustomPropTypes.Children.isRequired,
ordered: PropTypes.bool.isRequired,
startAt: PropTypes.number,
index: PropTypes.number.isRequired,
tight: PropTypes.bool,
bulletStyle: CustomPropTypes.Style
};
static defaultProps = {
startAt: 1
};
render() {
let bullet;
if (this.props.ordered) {
bullet = (this.props.startAt + this.props.index) + '. ';
} else {
bullet = '• ';
}
return (
<View style={style.container}>
<View>
<Text style={this.props.bulletStyle}>
{bullet}
</Text>
</View>
<View>
{this.props.children}
</View>
</View>
);
}
}
const style = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-start'
}
});

View file

@ -3,8 +3,8 @@
import React, {Component, PropTypes} from 'react';
import {
Platform,
Image,
Platform,
StyleSheet,
Text,
TouchableHighlight,
@ -15,9 +15,10 @@ import {
import FormattedText from 'app/components/formatted_text';
import FormattedTime from 'app/components/formatted_time';
import MattermostIcon from 'app/components/mattermost_icon';
import Markdown from 'app/components/markdown/markdown';
import ProfilePicture from 'app/components/profile_picture';
import FileAttachmentList from 'app/components/file_attachment_list/file_attachment_list_container';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {isSystemMessage} from 'mattermost-redux/utils/post_utils.js';
@ -117,15 +118,25 @@ export default class Post extends Component {
};
renderMessage = (style, messageStyle, replyBar = false) => {
let contents;
if (this.props.post.message.length > 0) {
const theme = this.props.theme;
contents = (
<Markdown
baseTextStyle={messageStyle}
textStyles={getMarkdownTextStyles(theme)}
blockStyles={getMarkdownBlockStyles(theme)}
value={this.props.post.message}
/>
);
}
return (
<TouchableHighlight onPress={this.handlePress}>
<View style={{flex: 1}}>
{replyBar && this.renderReplyBar(style)}
{this.props.post.message.length > 0 &&
<Text style={messageStyle}>
{this.props.post.message}
</Text>
}
{contents}
{this.renderFileAttachments()}
</View>
</TouchableHighlight>
@ -133,7 +144,9 @@ export default class Post extends Component {
};
render() {
const style = getStyleSheet(this.props.theme);
const theme = this.props.theme;
const style = getStyleSheet(theme);
const PROFILE_PICTURE_SIZE = 32;
let profilePicture;
@ -178,7 +191,8 @@ export default class Post extends Component {
{this.props.post.props.override_username}
</Text>
);
messageStyle = [style.message, style.webhookMessage];
messageStyle = style.message;
} else {
profilePicture = (
<TouchableOpacity onPress={this.viewUserProfile}>
@ -323,16 +337,81 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
message: {
color: theme.centerChannelColor,
fontSize: 14,
lineHeight: 21,
marginBottom: 10
lineHeight: 21
},
systemMessage: {
opacity: 0.5
},
webhookMessage: {
color: theme.centerChannelColor,
fontSize: 16,
fontWeight: '600'
}
});
});
const getMarkdownTextStyles = makeStyleSheetFromTheme((theme) => {
const codeFont = Platform.OS === 'ios' ? 'Courier New' : 'monospace';
return StyleSheet.create({
emph: {
fontStyle: 'italic'
},
strong: {
fontWeight: 'bold'
},
link: {
color: theme.linkColor
},
heading1: {
fontSize: 30,
lineHeight: 45
},
heading2: {
fontSize: 24,
lineHeight: 36
},
heading3: {
fontSize: 20,
lineHeight: 30
},
heading4: {
fontSize: 16,
lineHeight: 24
},
heading5: {
fontSize: 14,
lineHeight: 21
},
heading6: {
fontSize: 14,
lineHeight: 21,
opacity: 0.8
},
code: {
alignSelf: 'center',
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
fontFamily: codeFont,
paddingHorizontal: 4,
paddingVertical: 2
},
codeBlock: {
fontFamily: codeFont
},
horizontalRule: {
backgroundColor: theme.centerChannelColor,
height: StyleSheet.hairlineWidth,
flex: 1,
marginVertical: 10
}
});
});
const getMarkdownBlockStyles = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
codeBlock: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
borderRadius: 4,
paddingHorizontal: 4,
paddingVertical: 2
},
horizontalRule: {
backgroundColor: theme.centerChannelColor
}
});
});

View file

@ -0,0 +1,17 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {PropTypes} from 'react';
export const Children = PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf([PropTypes.node])]);
export const Style = PropTypes.oneOfType([
PropTypes.object, // inline style
PropTypes.number, // style sheet entry
PropTypes.array
]);
export default {
Children,
Style
};

View file

@ -36,3 +36,7 @@ export function changeOpacity(oldColor, opacity) {
return 'rgba(' + r + ',' + g + ',' + b + ',' + opacity + ')';
}
export function concatStyles(...styles) {
return [].concat(styles);
}

View file

@ -3,6 +3,8 @@
"version": "0.0.1",
"private": true,
"dependencies": {
"commonmark": "0.27.0",
"commonmark-react-renderer": "hmhealey/commonmark-react-renderer#2e8ea6ac67b683b44782f403b69a06bb0e5c63e6",
"deep-equal": "1.0.1",
"harmony-reflect": "1.5.1",
"intl": "1.2.5",