RN-173 Added support for Markdown images (#1202)

* RN-173 Added support for Markdown images

* RN-173 Added proper alt text when image fails to load

* Changed (edited) indicator not add itself as a child of an image

* RN-173 Removed space between an image and the following text

* Made sure MarkdownImage is mounted when setState is called

* Fixed images in links having their location overwritten

* Made images work in links

* Fixed uppercase links not working on Android

* Added vertical margin around images

* RN-173 Added styling for markdown image error text

* RN-173 Added error when image exceeds max dimensions on Android
This commit is contained in:
Harrison Healey 2017-11-28 09:00:28 -05:00 committed by enahum
parent ac2b38cb3b
commit c71b116c71
12 changed files with 2460 additions and 72 deletions

View file

@ -138,7 +138,7 @@ export default class Emoji extends React.PureComponent {
let marginTop = 0;
if (fontSize) {
// Center the image vertically on iOS (does nothing on Android)
marginTop = (height - fontSize) / 2;
marginTop = (height - 16) / 2;
// hack to get the vertical alignment looking better
if (fontSize === 17) {

View file

@ -21,9 +21,11 @@ import {concatStyles} from 'app/utils/theme';
import MarkdownBlockQuote from './markdown_block_quote';
import MarkdownCodeBlock from './markdown_code_block';
import MarkdownImage from './markdown_image';
import MarkdownLink from './markdown_link';
import MarkdownList from './markdown_list';
import MarkdownListItem from './markdown_list_item';
import {addListItemIndices, pullOutImages} from './transform';
export default class Markdown extends PureComponent {
static propTypes = {
@ -116,15 +118,35 @@ export default class Markdown extends PureComponent {
editedIndicator: this.renderEditedIndicator
},
renderParagraphsInLists: true
renderParagraphsInLists: true,
getExtraPropsForNode: this.getExtraPropsForNode
});
}
getExtraPropsForNode = (node) => {
const extraProps = {
continue: node.continue,
index: node.index
};
if (node.type === 'image') {
extraProps.reactChildren = node.react.children;
extraProps.linkDestination = node.linkDestination;
}
return extraProps;
}
computeTextStyle = (baseStyle, context) => {
return concatStyles(baseStyle, context.map((type) => this.props.textStyles[type]));
}
renderText = ({context, literal}) => {
if (context.indexOf('image') !== -1) {
// If this text is displayed, it will be styled by the image component
return <Text>{literal}</Text>;
}
// 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>;
}
@ -133,16 +155,16 @@ export default class Markdown extends PureComponent {
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
renderImage = ({linkDestination, reactChildren, context, src}) => {
return (
<Text style={this.computeTextStyle(this.props.baseTextStyle, context)}>
{'!['}
{children}
{']('}
{src}
{')'}
</Text>
<MarkdownImage
linkDestination={linkDestination}
onLongPress={this.props.onLongPress}
source={src}
errorTextStyle={[this.computeTextStyle(this.props.baseTextStyle, context), this.props.textStyles.error]}
>
{reactChildren}
</MarkdownImage>
);
}
@ -194,6 +216,10 @@ export default class Markdown extends PureComponent {
}
renderParagraph = ({children, first}) => {
if (!children || children.length === 0) {
return null;
}
const blockStyle = [style.block];
if (!first) {
blockStyle.push(this.props.blockStyles.adjacentParagraph);
@ -244,11 +270,10 @@ export default class Markdown extends PureComponent {
);
}
renderList = ({children, start, tight, type}) => {
renderList = ({children, tight, type}) => {
return (
<MarkdownList
ordered={type !== 'bullet'}
startAt={start}
tight={tight}
>
{children}
@ -332,14 +357,17 @@ export default class Markdown extends PureComponent {
}
render() {
const ast = this.parser.parse(this.props.value);
let ast = this.parser.parse(this.props.value);
ast = addListItemIndices(ast);
ast = pullOutImages(ast);
if (this.props.isEdited) {
const editIndicatorNode = new Node('edited_indicator');
if (['code_block', 'thematic_break', 'block_quote'].includes(ast.lastChild.type)) {
ast.appendChild(editIndicatorNode);
} else {
if (['heading', 'paragraph'].includes(ast.lastChild.type)) {
ast.lastChild.appendChild(editIndicatorNode);
} else {
ast.appendChild(editIndicatorNode);
}
}

View file

@ -2,6 +2,7 @@
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
StyleSheet,
View
@ -12,19 +13,27 @@ import CustomPropTypes from 'app/constants/custom_prop_types';
export default class MarkdownBlockQuote extends PureComponent {
static propTypes = {
continue: PropTypes.bool,
iconStyle: CustomPropTypes.Style,
children: CustomPropTypes.Children.isRequired
};
render() {
let icon;
if (!this.props.continue) {
icon = (
<Icon
name='quote-left'
style={this.props.iconStyle}
size={14}
/>
);
}
return (
<View style={style.container}>
<View>
<Icon
name='quote-left'
style={this.props.iconStyle}
size={14}
/>
<View style={style.icon}>
{icon}
</View>
<View style={style.childContainer}>
{this.props.children}
@ -41,5 +50,8 @@ const style = StyleSheet.create({
},
childContainer: {
flex: 1
},
icon: {
width: 23
}
});

View file

@ -1,36 +1,90 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Image} from 'react-native';
import React from 'react';
import {intlShape} from 'react-intl';
import {
Clipboard,
Image,
Linking,
Platform,
StyleSheet,
Text,
TouchableHighlight,
View
} from 'react-native';
export default class MarkdownLink extends PureComponent {
import FormattedText from 'app/components/formatted_text';
import CustomPropTypes from 'app/constants/custom_prop_types';
import mattermostManaged from 'app/mattermost_managed';
import {normalizeProtocol} from 'app/utils/url';
const MAX_IMAGE_HEIGHT = 150;
const ANDROID_MAX_HEIGHT = 4096;
const ANDROID_MAX_WIDTH = 4096;
export default class MarkdownImage extends React.Component {
static propTypes = {
src: PropTypes.string.isRequired
children: PropTypes.node,
linkDestination: PropTypes.string,
onLongPress: PropTypes.func,
source: PropTypes.string.isRequired,
errorTextStyle: CustomPropTypes.Style
};
static contextTypes = {
intl: intlShape.isRequired
};
constructor(props) {
super(props);
this.state = {
width: 10000,
maxWidth: 10000,
height: 0
width: 0,
height: 0,
maxWidth: Math.MAX_INT,
failed: false
};
this.mounted = false;
}
componentWillMount() {
Image.getSize(this.props.src, this.handleSizeReceived, this.handleSizeFailed);
this.loadImageSize(this.props.source);
}
componentDidMount() {
this.mounted = true;
}
componentWillReceiveProps(nextProps) {
if (this.props.src !== nextProps.src) {
Image.getSize(nextProps.src, this.handleSizeReceived, this.handleSizeFailed);
if (this.props.source !== nextProps.source) {
this.setState({
width: 0,
height: 0,
failed: false
});
this.loadImageSize(nextProps.source);
}
}
componentWillUnmount() {
this.mounted = false;
}
loadImageSize = (source) => {
Image.getSize(source, this.handleSizeReceived, this.handleSizeFailed);
};
handleSizeReceived = (width, height) => {
if (!this.mounted) {
return;
}
this.setState({
width,
height
@ -38,33 +92,140 @@ export default class MarkdownLink extends PureComponent {
};
handleSizeFailed = () => {
if (!this.mounted) {
return;
}
this.setState({
width: 0,
height: 0
failed: true
});
}
};
handleLayout = (event) => {
if (!this.mounted) {
return;
}
this.setState({
maxWidth: event.nativeEvent.layout.width
});
}
};
render() {
let {width, maxWidth, height} = this.state; // eslint-disable-line prefer-const
handleLinkPress = () => {
const url = normalizeProtocol(this.props.linkDestination);
if (width > maxWidth) {
height = height * (maxWidth / width);
width = maxWidth;
Linking.canOpenURL(url).then((supported) => {
if (supported) {
Linking.openURL(url);
}
});
};
handleLinkLongPress = async () => {
const {formatMessage} = this.context.intl;
const config = await mattermostManaged.getLocalConfig();
let action;
if (config.copyAndPasteProtection !== 'true') {
action = {
text: formatMessage({id: 'mobile.markdown.link.copy_url', defaultMessage: 'Copy URL'}),
onPress: this.handleLinkCopy
};
}
this.props.onLongPress(action);
};
handleLinkCopy = () => {
Clipboard.setString(this.props.linkDestination);
};
render() {
let image = null;
if (this.state.width && this.state.height && this.state.maxWidth) {
let {width, height} = this.state;
if (Platform.OS === 'android' && (width > ANDROID_MAX_WIDTH || height > ANDROID_MAX_HEIGHT)) {
// Android has a cap on the max image size that can be displayed
image = (
<Text style={this.props.errorTextStyle}>
<FormattedText
id='mobile.markdown.image.too_large'
defaultMessage='Image exceeds max dimensions of {maxWidth} by {maxHeight}:'
values={{
maxWidth: ANDROID_MAX_WIDTH,
maxHeight: ANDROID_MAX_HEIGHT
}}
/>
{' '}
{this.props.children}
</Text>
);
} else {
const maxWidth = this.state.maxWidth;
if (width > maxWidth) {
height = height * (maxWidth / width);
width = maxWidth;
}
const maxHeight = MAX_IMAGE_HEIGHT;
if (height > maxHeight) {
width = width * (maxHeight / height);
height = maxHeight;
}
// React Native complains if we try to pass resizeMode as a style
image = (
<Image
source={{uri: this.props.source}}
resizeMode='contain'
style={[{width, height}, style.image]}
/>
);
}
} else if (this.state.failed) {
image = (
<Text style={this.props.errorTextStyle}>
<FormattedText
id='mobile.markdown.image.error'
defaultMessage='Image failed to load:'
/>
{' '}
{this.props.children}
</Text>
);
}
if (image && this.props.linkDestination) {
image = (
<TouchableHighlight
onPress={this.handleLinkPress}
onLongPress={this.handleLinkLongPress}
>
{image}
</TouchableHighlight>
);
}
// React Native complains if we try to pass resizeMode into a StyleSheet
return (
<Image
source={{uri: this.props.src}}
<View
style={style.container}
onLayout={this.handleLayout}
style={{width, height, flexShrink: 1, resizeMode: 'cover'}}
/>
>
{image}
</View>
);
}
}
const style = StyleSheet.create({
container: {
flex: 1
},
image: {
marginVertical: 5
}
});

View file

@ -5,27 +5,32 @@ import React, {Children, PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Clipboard, Linking, Text} from 'react-native';
import urlParse from 'url-parse';
import {injectIntl, intlShape} from 'react-intl';
import {intlShape} from 'react-intl';
import CustomPropTypes from 'app/constants/custom_prop_types';
import Config from 'assets/config';
import mattermostManaged from 'app/mattermost_managed';
class MarkdownLink extends PureComponent {
import Config from 'assets/config';
import {normalizeProtocol} from 'app/utils/url';
export default class MarkdownLink extends PureComponent {
static propTypes = {
children: CustomPropTypes.Children.isRequired,
href: PropTypes.string.isRequired,
intl: intlShape.isRequired,
onLongPress: PropTypes.func
};
static defaultProps = {
onLongPress: () => true
}
};
static contextTypes = {
intl: intlShape.isRequired
};
handlePress = () => {
// Android doesn't like the protocol being upper case
const url = this.props.href;
const url = normalizeProtocol(this.props.href);
Linking.canOpenURL(url).then((supported) => {
if (supported) {
@ -69,7 +74,7 @@ class MarkdownLink extends PureComponent {
}
handleLongPress = async () => {
const {formatMessage} = this.props.intl;
const {formatMessage} = this.context.intl;
const config = await mattermostManaged.getLocalConfig();
@ -101,5 +106,3 @@ class MarkdownLink extends PureComponent {
);
}
}
export default injectIntl(MarkdownLink);

View file

@ -3,30 +3,33 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {View} from 'react-native';
import {StyleSheet, View} from 'react-native';
export default class MarkdownList extends PureComponent {
static propTypes = {
children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf([PropTypes.node])]).isRequired,
children: PropTypes.node.isRequired,
ordered: PropTypes.bool.isRequired,
startAt: PropTypes.number,
tight: PropTypes.bool
};
render() {
const children = React.Children.map(this.props.children, (child, i) => {
const children = React.Children.map(this.props.children, (child) => {
return React.cloneElement(child, {
ordered: this.props.ordered,
startAt: this.props.startAt,
index: i,
tight: this.props.tight
});
});
return (
<View style={{marginRight: 20}}>
<View style={style.indent}>
{children}
</View>
);
}
}
const style = StyleSheet.create({
indent: {
marginRight: 20
}
});

View file

@ -15,21 +15,18 @@ export default class MarkdownListItem extends PureComponent {
static propTypes = {
children: CustomPropTypes.Children.isRequired,
ordered: PropTypes.bool.isRequired,
startAt: PropTypes.number,
continue: PropTypes.bool,
index: PropTypes.number.isRequired,
tight: PropTypes.bool,
bulletStyle: CustomPropTypes.Style,
level: PropTypes.number
};
static defaultProps = {
startAt: 1
};
render() {
let bullet;
if (this.props.ordered) {
bullet = (this.props.startAt + this.props.index) + '. ';
if (this.props.continue) {
bullet = '';
} else if (this.props.ordered) {
bullet = this.props.index + '. ';
} else if (this.props.level % 2 === 0) {
bullet = '◦';
} else {

View file

@ -0,0 +1,203 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
/* eslint-disable no-underscore-dangle */
// Add indices to the items of every list
export function addListItemIndices(ast) {
const walker = ast.walker();
let e;
while ((e = walker.next())) {
if (e.entering) {
const node = e.node;
if (node.type === 'list') {
let i = node.listStart || 1; // List indices match what would be displayed in the UI
for (let child = node.firstChild; child; child = child.next) {
child.index = i;
i += 1;
}
}
}
}
return ast;
}
// Take all images and move them to be children of the root document node. When this happens, their
// parent nodes are split into two, if necessary, with the version that follows the image having its
// "continue" field set to true to indicate that things like bullet points don't need to be rendered.
export function pullOutImages(ast) {
for (let block = ast.firstChild; block !== null; block = block.next) {
let node = block.firstChild;
let cameFromChild = false;
while (node && node !== block) {
if (node.type === 'image' && node.parent.type !== 'document') {
const image = node;
let parent = image.parent;
let prev = image.prev;
let next = image.next;
// Remove image from its siblings
if (prev) {
prev._next = next;
}
if (next) {
next._prev = prev;
// Since the following text will be on a new line, a preceding space would cause the
// alignment to be incorrect
if (next.type === 'text' && next.literal.startsWith(' ')) {
next.literal = next.literal.substring(1);
}
}
// And from its parents
if (parent._firstChild === image) {
// image was the first child (ie prev is null), so the next sibling is now the first child
parent._firstChild = next;
}
if (parent._lastChild === image) {
// image was the last child (ie next is null), so the previous sibling is now the last child
parent._lastChild = prev;
}
// Split the tree between the previous and next siblings, where the image would've been
while (parent && parent.type !== 'document') {
// We only need to split the parent if there's anything on the right of where we're splitting
// in the current branch
let parentCopy = null;
// Split if we have children to the right of the split (next) or if we have any siblings to the
// right of the parent (parent.next)
if (next) {
parentCopy = copyNodeWithoutNeighbors(parent);
// Set an additional flag so we know not to re-render things like bullet points
parentCopy.continue = true;
// Re-assign the children to the right of the split to belong to the copy
parentCopy._firstChild = next;
parentCopy._lastChild = getLastSibling(next);
if (parent._firstChild === next) {
parent._firstChild = null;
parent._lastChild = null;
} else {
parent._lastChild = prev;
}
// And re-assign the parent of all of those to be the copy
for (let child = parentCopy.firstChild; child; child = child.next) {
child._parent = parentCopy;
}
// Insert the copy as parent's next sibling
if (parent.next) {
parent.next._prev = parentCopy;
parentCopy._next = parent.next;
parent._next = parentCopy;
} else /* if (parent.parent.lastChild === parent) */ {
// Since parent has no next sibling, parent is the last child of its parent, so
// we need to set the copy as the last child
parent.parent.lastChild = parentCopy;
}
}
// Change prev and next to no longer be siblings
if (prev) {
prev._next = null;
}
if (next) {
next._prev = null;
}
// This image is part of a link so include the destination along with it
if (parent.type === 'link') {
image.linkDestination = parent.destination;
}
// Move up the tree
next = parentCopy || parent.next;
prev = parent;
parent = parent.parent;
}
// Re-insert the image now that we have a tree split down to the root with the image's ancestors.
// Note that parent is the root node, prev is the ancestor of image, and next is the ancestor of the copy
// Add image to its parent
image._parent = parent;
if (next) {
parent._lastChild = next;
} else {
// image is the last child of the root node now
parent._lastChild = image;
}
// Add image to its siblings
image._prev = prev;
prev._next = image;
image._next = next;
if (next) {
next._prev = image;
}
// The copy still needs its parent set to the root node
if (next) {
next._parent = parent;
}
}
// Walk through tree to next node
if (node.firstChild && !cameFromChild) {
node = node.firstChild;
cameFromChild = false;
} else if (node.next) {
node = node.next;
cameFromChild = false;
} else {
node = node.parent;
cameFromChild = true;
}
}
}
return ast;
}
// Copies a Node without its parent, children, or siblings
function copyNodeWithoutNeighbors(node) {
// commonmark uses classes so it takes a bit of work to copy them
const copy = Object.assign(Object.create(Reflect.getPrototypeOf(node)), node);
copy._parent = null;
copy._firstChild = null;
copy._lastChild = null;
copy._prev = null;
copy._next = null;
// Deep copy list data since it's an object
copy._listData = {...node._listData};
return copy;
}
// Gets the last sibling of a given node
function getLastSibling(node) {
let sibling = node;
while (sibling && sibling.next) {
sibling = sibling.next;
}
return sibling;
}

View file

@ -64,6 +64,9 @@ export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme) => {
},
mention: {
color: theme.linkColor
},
error: {
color: theme.errorTextColor
}
};
});

View file

@ -43,3 +43,16 @@ export function isImageLink(link) {
const match = link.trim().match(imgRegex);
return Boolean(match && match[1]);
}
// Converts the protocol of a link (eg. http, ftp) to be lower case since
// Android doesn't handle uppercase links.
export function normalizeProtocol(url) {
const index = url.indexOf(':');
if (index === -1) {
// There's no protocol on the link to be normalized
return url;
}
const protocol = url.substring(0, index);
return protocol.toLowerCase() + url.substring(index);
}

View file

@ -2008,6 +2008,8 @@
"mobile.managed.secured_by": "Secured by {vendor}",
"mobile.markdown.code.copy_code": "Copy Code",
"mobile.markdown.code.plusMoreLines": "+{count, number} more lines",
"mobile.markdown.image.error": "Image failed to load:",
"mobile.markdown.image.too_large": "Image exceeds max dimensions of {maxWidth} by {maxHeight}:",
"mobile.markdown.link.copy_url": "Copy URL",
"mobile.mention.copy_mention": "Copy Mention",
"mobile.more_dms.start": "Start",

File diff suppressed because it is too large Load diff