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:
parent
70da78af69
commit
07af08b74a
11 changed files with 565 additions and 16 deletions
195
app/components/markdown/markdown.js
Normal file
195
app/components/markdown/markdown.js
Normal 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)}>
|
||||
{''}
|
||||
</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'
|
||||
}
|
||||
});
|
||||
41
app/components/markdown/markdown_block_quote.js
Normal file
41
app/components/markdown/markdown_block_quote.js
Normal 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'
|
||||
}
|
||||
});
|
||||
28
app/components/markdown/markdown_code_block.js
Normal file
28
app/components/markdown/markdown_code_block.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
69
app/components/markdown/markdown_image.js
Normal file
69
app/components/markdown/markdown_image.js
Normal 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'}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
28
app/components/markdown/markdown_link.js
Normal file
28
app/components/markdown/markdown_link.js
Normal 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>;
|
||||
}
|
||||
}
|
||||
31
app/components/markdown/markdown_list.js
Normal file
31
app/components/markdown/markdown_list.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
55
app/components/markdown/markdown_list_item.js
Normal file
55
app/components/markdown/markdown_list_item.js
Normal 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'
|
||||
}
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
17
app/constants/custom_prop_types.js
Normal file
17
app/constants/custom_prop_types.js
Normal 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
|
||||
};
|
||||
|
|
@ -36,3 +36,7 @@ export function changeOpacity(oldColor, opacity) {
|
|||
|
||||
return 'rgba(' + r + ',' + g + ',' + b + ',' + opacity + ')';
|
||||
}
|
||||
|
||||
export function concatStyles(...styles) {
|
||||
return [].concat(styles);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue