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:
parent
ac2b38cb3b
commit
c71b116c71
12 changed files with 2460 additions and 72 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
{''}
|
||||
</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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
203
app/components/markdown/transform.js
Normal file
203
app/components/markdown/transform.js
Normal 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;
|
||||
}
|
||||
|
|
@ -64,6 +64,9 @@ export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme) => {
|
|||
},
|
||||
mention: {
|
||||
color: theme.linkColor
|
||||
},
|
||||
error: {
|
||||
color: theme.errorTextColor
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
1963
test/app/components/markdown/transform.test.js
Normal file
1963
test/app/components/markdown/transform.test.js
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue