From ede4d22f75dd5855117f74f07b3f9fc9fd69d8c0 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Fri, 16 Feb 2018 17:31:14 -0500 Subject: [PATCH] ICU-624 Added markdown tables (#1434) * ICU-624 Added initial table rendering support * ICU-624 Added full table view * Added proper pluralization to +X more lines in code blocks * ICU-624 Added support for images in tables * Stopped using injectIntl for tables * ICU-624 Updated commonmark-react-renderer * ICU-624 Hid scroll indicator in collapsed table view * Addressed feedback --- app/components/markdown/markdown.js | 34 ++- .../markdown_code_block.js | 2 +- .../markdown/markdown_table/index.js | 16 ++ .../markdown/markdown_table/markdown_table.js | 146 +++++++++++ .../markdown/markdown_table_cell/index.js | 16 ++ .../markdown_table_cell.js | 46 ++++ .../markdown/markdown_table_image/index.js | 18 ++ .../markdown_table_image.js | 69 +++++ .../markdown/markdown_table_row/index.js | 16 ++ .../markdown_table_row/markdown_table_row.js | 41 +++ app/components/markdown/transform.js | 248 +++++++++--------- app/screens/index.js | 4 + app/screens/table/index.js | 6 + app/screens/table/table.js | 32 +++ app/screens/table_image/index.js | 16 ++ app/screens/table_image/table_image.js | 102 +++++++ app/utils/markdown.js | 3 + assets/base/i18n/en.json | 4 +- package.json | 4 +- .../app/components/markdown/transform.test.js | 169 +++++++++++- yarn.lock | 8 +- 21 files changed, 869 insertions(+), 131 deletions(-) create mode 100644 app/components/markdown/markdown_table/index.js create mode 100644 app/components/markdown/markdown_table/markdown_table.js create mode 100644 app/components/markdown/markdown_table_cell/index.js create mode 100644 app/components/markdown/markdown_table_cell/markdown_table_cell.js create mode 100644 app/components/markdown/markdown_table_image/index.js create mode 100644 app/components/markdown/markdown_table_image/markdown_table_image.js create mode 100644 app/components/markdown/markdown_table_row/index.js create mode 100644 app/components/markdown/markdown_table_row/markdown_table_row.js create mode 100644 app/screens/table/index.js create mode 100644 app/screens/table/table.js create mode 100644 app/screens/table_image/index.js create mode 100644 app/screens/table_image/table_image.js diff --git a/app/components/markdown/markdown.js b/app/components/markdown/markdown.js index 0a4af2921..25a914b01 100644 --- a/app/components/markdown/markdown.js +++ b/app/components/markdown/markdown.js @@ -24,6 +24,10 @@ import MarkdownImage from './markdown_image'; import MarkdownLink from './markdown_link'; import MarkdownList from './markdown_list'; import MarkdownListItem from './markdown_list_item'; +import MarkdownTable from './markdown_table'; +import MarkdownTableImage from './markdown_table_image'; +import MarkdownTableRow from './markdown_table_row'; +import MarkdownTableCell from './markdown_table_cell'; import {addListItemIndices, pullOutImages} from './transform'; export default class Markdown extends PureComponent { @@ -83,6 +87,10 @@ export default class Markdown extends PureComponent { htmlBlock: this.renderHtml, htmlInline: this.renderHtml, + table: this.renderTable, + table_row: MarkdownTableRow, + table_cell: MarkdownTableCell, + editedIndicator: this.renderEditedIndicator }, renderParagraphsInLists: true, @@ -124,6 +132,21 @@ export default class Markdown extends PureComponent { } renderImage = ({linkDestination, reactChildren, context, src}) => { + if (context.indexOf('table') !== -1) { + // We have enough problems rendering images as is, so just render a link inside of a table + return ( + + {reactChildren} + + ); + } + return ( { + return ( + + {children} + + ); + } + renderLink = ({children, href}) => { return ( {this.renderer.render(ast)}; + + return this.renderer.render(ast); } } diff --git a/app/components/markdown/markdown_code_block/markdown_code_block.js b/app/components/markdown/markdown_code_block/markdown_code_block.js index 66c3f2cc3..976a7d570 100644 --- a/app/components/markdown/markdown_code_block/markdown_code_block.js +++ b/app/components/markdown/markdown_code_block/markdown_code_block.js @@ -145,7 +145,7 @@ class MarkdownCodeBlock extends React.PureComponent { { + const {navigator, theme} = this.props; + + navigator.push({ + screen: 'Table', + title: this.context.intl.formatMessage({ + id: 'mobile.routes.table', + defaultMessage: 'Table' + }), + animated: true, + backButtonTitle: '', + passProps: { + renderRows: this.renderRows + }, + navigatorStyle: { + navBarTextColor: theme.sidebarHeaderTextColor, + navBarBackgroundColor: theme.sidebarHeaderBg, + navBarButtonColor: theme.sidebarHeaderTextColor, + screenBackgroundColor: theme.centerChannelBg + } + }); + }); + + handleContentHeightChange = (contentWidth, contentHeight) => { + this.setState({ + contentCutOff: contentHeight >= MAX_HEIGHT + }); + } + + renderRows = (drawBottomBorder = true) => { + const style = getStyleSheet(this.props.theme); + + const tableStyle = [style.table]; + if (drawBottomBorder) { + tableStyle.push(style.tableBottomBorder); + } + + // Add an extra prop to the last row of the table so that it knows not to render a bottom border + // since the container should be rendering that + const rows = React.Children.toArray(this.props.children); + rows[rows.length - 1] = React.cloneElement(rows[rows.length - 1], { + isLastRow: true + }); + + return ( + + {rows} + + ); + } + + render() { + const style = getStyleSheet(this.props.theme); + + let moreIndicator = null; + if (this.state.contentCutOff) { + moreIndicator = ( + + ); + } + + return ( + + + {this.renderRows(false)} + + {moreIndicator} + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + container: { + borderBottomWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + maxHeight: MAX_HEIGHT + }, + table: { + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + borderLeftWidth: 1, // The right border is drawn by the MarkdownTableCell component + borderTopWidth: 1, + flex: 1 + }, + tableBottomBorder: { + borderBottomWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.2) + }, + moreIndicator: { + bottom: 0, + height: 20, + position: 'absolute', + right: 0, + width: '100%' + } + }; +}); diff --git a/app/components/markdown/markdown_table_cell/index.js b/app/components/markdown/markdown_table_cell/index.js new file mode 100644 index 000000000..83ec705f8 --- /dev/null +++ b/app/components/markdown/markdown_table_cell/index.js @@ -0,0 +1,16 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; + +import MarkdownTableCell from './markdown_table_cell'; + +function mapStateToProps(state) { + return { + theme: getTheme(state) + }; +} + +export default connect(mapStateToProps)(MarkdownTableCell); diff --git a/app/components/markdown/markdown_table_cell/markdown_table_cell.js b/app/components/markdown/markdown_table_cell/markdown_table_cell.js new file mode 100644 index 000000000..445700cce --- /dev/null +++ b/app/components/markdown/markdown_table_cell/markdown_table_cell.js @@ -0,0 +1,46 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PropTypes from 'prop-types'; +import React from 'react'; +import {Text, View} from 'react-native'; + +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +export default class MarkdownTableCell extends React.PureComponent { + static propTypes = { + children: PropTypes.node, + isHeading: PropTypes.bool.isRequired, + theme: PropTypes.object.isRequired + }; + + render() { + const style = getStyleSheet(this.props.theme); + + const cellStyle = [style.cell]; + if (this.props.isHeading) { + cellStyle.push(style.heading); + } + + return ( + + + {this.props.children} + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + cell: { + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + borderRightWidth: 1, + flex: 1, + justifyContent: 'flex-start', + paddingHorizontal: 13, + paddingVertical: 6 + } + }; +}); diff --git a/app/components/markdown/markdown_table_image/index.js b/app/components/markdown/markdown_table_image/index.js new file mode 100644 index 000000000..4e7400b3f --- /dev/null +++ b/app/components/markdown/markdown_table_image/index.js @@ -0,0 +1,18 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getCurrentUrl} from 'mattermost-redux/selectors/entities/general'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; + +import MarkdownTableImage from './markdown_table_image'; + +function mapStateToProps(state) { + return { + serverURL: getCurrentUrl(state), + theme: getTheme(state) + }; +} + +export default connect(mapStateToProps)(MarkdownTableImage); diff --git a/app/components/markdown/markdown_table_image/markdown_table_image.js b/app/components/markdown/markdown_table_image/markdown_table_image.js new file mode 100644 index 000000000..6b02951e2 --- /dev/null +++ b/app/components/markdown/markdown_table_image/markdown_table_image.js @@ -0,0 +1,69 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {PropTypes} from 'prop-types'; +import React from 'react'; +import {intlShape} from 'react-intl'; +import {Text} from 'react-native'; + +import CustomPropTypes from 'app/constants/custom_prop_types'; +import {wrapWithPreventDoubleTap} from 'app/utils/tap'; + +export default class MarkdownTableImage extends React.PureComponent { + static propTypes = { + children: PropTypes.node.isRequired, + source: PropTypes.string.isRequired, + textStyle: CustomPropTypes.Style.isRequired, + navigator: PropTypes.object.isRequired, + serverURL: PropTypes.string.isRequired, + theme: PropTypes.object.isRequired + }; + + static contextTypes = { + intl: intlShape.isRequired + }; + + handlePress = wrapWithPreventDoubleTap(() => { + const {navigator, theme} = this.props; + + navigator.push({ + screen: 'TableImage', + title: this.context.intl.formatMessage({ + id: 'mobile.routes.tableImage', + defaultMessage: 'Image' + }), + animated: true, + backButtonTitle: '', + passProps: { + imageSource: this.getImageSource() + }, + navigatorStyle: { + navBarTextColor: theme.sidebarHeaderTextColor, + navBarBackgroundColor: theme.sidebarHeaderBg, + navBarButtonColor: theme.sidebarHeaderTextColor, + screenBackgroundColor: theme.centerChannelBg + } + }); + }); + + getImageSource = () => { + let source = this.props.source; + + if (source.startsWith('/')) { + source = `${this.props.serverURL}/${source}`; + } + + return source; + }; + + render() { + return ( + + {this.props.children} + + ); + } +} diff --git a/app/components/markdown/markdown_table_row/index.js b/app/components/markdown/markdown_table_row/index.js new file mode 100644 index 000000000..9cae21d02 --- /dev/null +++ b/app/components/markdown/markdown_table_row/index.js @@ -0,0 +1,16 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; + +import MarkdownTableRow from './markdown_table_row'; + +function mapStateToProps(state) { + return { + theme: getTheme(state) + }; +} + +export default connect(mapStateToProps)(MarkdownTableRow); diff --git a/app/components/markdown/markdown_table_row/markdown_table_row.js b/app/components/markdown/markdown_table_row/markdown_table_row.js new file mode 100644 index 000000000..8ff62657d --- /dev/null +++ b/app/components/markdown/markdown_table_row/markdown_table_row.js @@ -0,0 +1,41 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PropTypes from 'prop-types'; +import React from 'react'; +import {View} from 'react-native'; + +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +export default class MarkdownTableRow extends React.PureComponent { + static propTypes = { + children: PropTypes.node, + isLastRow: PropTypes.bool, + theme: PropTypes.object.isRequired + }; + + render() { + const style = getStyleSheet(this.props.theme); + + const rowStyle = [style.row]; + if (!this.props.isLastRow) { + rowStyle.push(style.rowBottomBorder); + } + + return {this.props.children}; + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + row: { + flex: 1, + flexDirection: 'row', + justifyContent: 'flex-start' + }, + rowBottomBorder: { + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + borderBottomWidth: 1 + } + }; +}); diff --git a/app/components/markdown/transform.js b/app/components/markdown/transform.js index fb68ed315..0b01ddd4e 100644 --- a/app/components/markdown/transform.js +++ b/app/components/markdown/transform.js @@ -27,134 +27,24 @@ export function addListItemIndices(ast) { 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. +// Take all images, that aren't inside of tables, 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) { + // Skip tables because we'll render images inside of those as links + if (block.type === 'table') { + continue; + } + 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; - } + pullOutImage(node); } // Walk through tree to next node @@ -174,6 +64,124 @@ export function pullOutImages(ast) { return ast; } +function pullOutImage(image) { + 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; + } +} + // 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 diff --git a/app/screens/index.js b/app/screens/index.js index 053d1f428..4d2619ff3 100644 --- a/app/screens/index.js +++ b/app/screens/index.js @@ -40,6 +40,8 @@ import Search from 'app/screens/search'; import SelectServer from 'app/screens/select_server'; import SelectTeam from 'app/screens/select_team'; import Settings from 'app/screens/settings/general'; +import Table from 'app/screens/table'; +import TableImage from 'app/screens/table_image'; import Thread from 'app/screens/thread'; import UserProfile from 'app/screens/user_profile'; @@ -95,6 +97,8 @@ export function registerScreens(store, Provider) { Navigation.registerComponent('SelectTeam', () => wrapWithContextProvider(SelectTeam), store, Provider); Navigation.registerComponent('Settings', () => wrapWithContextProvider(Settings), store, Provider); Navigation.registerComponent('SSO', () => wrapWithContextProvider(SSO), store, Provider); + Navigation.registerComponent('Table', () => wrapWithContextProvider(Table), store, Provider); + Navigation.registerComponent('TableImage', () => wrapWithContextProvider(TableImage), store, Provider); Navigation.registerComponent('Thread', () => wrapWithContextProvider(Thread), store, Provider); Navigation.registerComponent('UserProfile', () => wrapWithContextProvider(UserProfile), store, Provider); } diff --git a/app/screens/table/index.js b/app/screens/table/index.js new file mode 100644 index 000000000..c2442f17a --- /dev/null +++ b/app/screens/table/index.js @@ -0,0 +1,6 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Table from './table'; + +export default Table; diff --git a/app/screens/table/table.js b/app/screens/table/table.js new file mode 100644 index 000000000..6e4cc6d8a --- /dev/null +++ b/app/screens/table/table.js @@ -0,0 +1,32 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PropTypes from 'prop-types'; +import React from 'react'; +import {ScrollView, StyleSheet} from 'react-native'; + +export default class Table extends React.PureComponent { + static propTypes = { + renderRows: PropTypes.func.isRequired + }; + + render() { + return ( + + {this.props.renderRows()} + + ); + } +} + +const style = StyleSheet.create({ + scrollContainer: { + flex: 1 + }, + container: { + flexDirection: 'row' + } +}); diff --git a/app/screens/table_image/index.js b/app/screens/table_image/index.js new file mode 100644 index 000000000..53359b43e --- /dev/null +++ b/app/screens/table_image/index.js @@ -0,0 +1,16 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getDimensions} from 'app/selectors/device'; + +import TableImage from './table_image'; + +function mapStateToProps(state) { + return { + deviceWidth: getDimensions(state).deviceWidth + }; +} + +export default connect(mapStateToProps)(TableImage); diff --git a/app/screens/table_image/table_image.js b/app/screens/table_image/table_image.js new file mode 100644 index 000000000..575502a25 --- /dev/null +++ b/app/screens/table_image/table_image.js @@ -0,0 +1,102 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PropTypes from 'prop-types'; +import React from 'react'; +import { + ActivityIndicator, + Image, + ScrollView, + StyleSheet, + View +} from 'react-native'; + +export default class TableImage extends React.PureComponent { + static propTypes = { + deviceWidth: PropTypes.number.isRequired, + imageSource: PropTypes.string.isRequired + }; + + constructor(props) { + super(props); + + this.state = { + width: -1, + height: -1 + }; + } + + componentWillMount() { + this.getImageSize(this.props.imageSource); + } + + componentWillReceiveProps(nextProps) { + if (this.props.imageSource !== nextProps.imageSource) { + this.setState({ + width: -1, + height: -1 + }); + + this.getImageSize(nextProps.imageSource); + } + } + + getImageSize = (imageSource) => { + Image.getSize(imageSource, (width, height) => { + this.setState({ + width, + height + }); + }); + } + + render() { + let {width, height} = this.state; + + if (width === -1 || height === -1) { + return ( + + + + ); + } + + if (width > this.props.deviceWidth) { + height = (height / width) * this.props.deviceWidth; + width = this.props.deviceWidth; + } + + return ( + + + + ); + } +} + +const style = StyleSheet.create({ + scrollContainer: { + flex: 1 + }, + container: { + flex: 1, + flexDirection: 'column' + }, + image: { + resizeMode: 'contain' + }, + loadingContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center' + } +}); diff --git a/app/utils/markdown.js b/app/utils/markdown.js index 73e0ca58e..7940fd8c7 100644 --- a/app/utils/markdown.js +++ b/app/utils/markdown.js @@ -92,6 +92,9 @@ export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme) => { }, error: { color: theme.errorTextColor + }, + table_header_row: { + fontWeight: '700' } }; }); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index b2df740b8..5c8c11bdb 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -2058,7 +2058,7 @@ "mobile.managed.jailbreak": "Jailbroken devices are not trusted by {vendor}, please exit the app.", "mobile.managed.secured_by": "Secured by {vendor}", "mobile.markdown.code.copy_code": "Copy Code", - "mobile.markdown.code.plusMoreLines": "+{count, number} more lines", + "mobile.markdown.code.plusMoreLines": "+{count, number} more {count, plural, one {line} other {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", @@ -2147,6 +2147,8 @@ "mobile.routes.selectTeam": "Select Team", "mobile.routes.settings": "Settings", "mobile.routes.sso": "Single Sign-On", + "mobile.routes.table": "Table", + "mobile.routes.tableImage": "Image", "mobile.routes.thread": "{channelName} Thread", "mobile.routes.thread_dm": "Direct Message Thread", "mobile.routes.user_profile": "Profile", diff --git a/package.json b/package.json index 97eb71abd..f9b4f131c 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "dependencies": { "analytics-react-native": "1.2.0", "babel-polyfill": "6.26.0", - "commonmark": "hmhealey/commonmark.js#dda6d89198252d5fd4bb04c4cc0668766c22fd77", - "commonmark-react-renderer": "hmhealey/commonmark-react-renderer#1f078d53b7993d30b2762dd7d78fdd21ee84dd21", + "commonmark": "hmhealey/commonmark.js", + "commonmark-react-renderer": "hmhealey/commonmark-react-renderer", "deep-equal": "1.0.1", "fuse.js": "^3.2.0", "intl": "1.2.5", diff --git a/test/app/components/markdown/transform.test.js b/test/app/components/markdown/transform.test.js index 7bdb0da7f..12befa5a1 100644 --- a/test/app/components/markdown/transform.test.js +++ b/test/app/components/markdown/transform.test.js @@ -338,6 +338,7 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); assert.deepStrictEqual(actual, expected); }); @@ -348,6 +349,7 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); assert.deepStrictEqual(actual, expected); }); @@ -383,6 +385,7 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); assert.deepStrictEqual(actual, expected); }); @@ -434,7 +437,8 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); - assert.deepEqual(actual, expected); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); }); it('paragraph with multiple images', () => { @@ -487,6 +491,7 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); assert.deepStrictEqual(actual, expected); }); @@ -643,6 +648,7 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); assert.deepStrictEqual(actual, expected); }); @@ -684,6 +690,7 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); assert.deepStrictEqual(actual, expected); }); @@ -745,6 +752,7 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); assert.deepStrictEqual(actual, expected); }); @@ -844,6 +852,7 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); assert.deepStrictEqual(actual, expected); }); @@ -917,6 +926,7 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); assert.deepStrictEqual(actual, expected); }); @@ -1131,6 +1141,7 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); assert.deepStrictEqual(actual, expected); }); @@ -1397,6 +1408,7 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); assert.deepStrictEqual(actual, expected); }); @@ -1699,7 +1711,8 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); - assert.deepEqual(actual, expected); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); }); it('simple link', () => { @@ -1743,6 +1756,7 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); assert.deepStrictEqual(actual, expected); }); @@ -1808,6 +1822,157 @@ describe('Components.Markdown.transform', () => { const actual = pullOutImages(input); assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('table', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'table', + children: [{ + type: 'table_row', + isHeading: true, + children: [{ + type: 'table_cell', + isHeading: true, + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1' + }] + }] + }, { + type: 'table_cell', + isHeading: true, + children: [{ + type: 'text', + literal: 'This is ' + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1' + }] + }, { + type: 'text', + literal: ' in a sentence.' + }] + }] + }, { + type: 'table_row', + isHeading: true, + children: [{ + type: 'table_cell', + isHeading: true, + children: [{ + type: 'text', + literal: 'This is ' + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1' + }] + }, { + type: 'text', + literal: ' in a sentence.' + }] + }, { + type: 'table_cell', + isHeading: true, + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1' + }] + }] + }] + }] + }] + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'table', + children: [{ + type: 'table_row', + isHeading: true, + children: [{ + type: 'table_cell', + isHeading: true, + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1' + }] + }] + }, { + type: 'table_cell', + isHeading: true, + children: [{ + type: 'text', + literal: 'This is ' + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1' + }] + }, { + type: 'text', + literal: ' in a sentence.' + }] + }] + }, { + type: 'table_row', + isHeading: true, + children: [{ + type: 'table_cell', + isHeading: true, + children: [{ + type: 'text', + literal: 'This is ' + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1' + }] + }, { + type: 'text', + literal: ' in a sentence.' + }] + }, { + type: 'table_cell', + isHeading: true, + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1' + }] + }] + }] + }] + }] + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); assert.deepStrictEqual(actual, expected); }); }); diff --git a/yarn.lock b/yarn.lock index 83ff5b6aa..6da55b474 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1708,9 +1708,9 @@ commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" -commonmark-react-renderer@hmhealey/commonmark-react-renderer#1f078d53b7993d30b2762dd7d78fdd21ee84dd21: +commonmark-react-renderer@hmhealey/commonmark-react-renderer: version "4.3.3" - resolved "https://codeload.github.com/hmhealey/commonmark-react-renderer/tar.gz/1f078d53b7993d30b2762dd7d78fdd21ee84dd21" + resolved "https://codeload.github.com/hmhealey/commonmark-react-renderer/tar.gz/86fa63f898802953842526c2030f3b63c5d1ae7a" dependencies: in-publish "^2.0.0" lodash.assign "^4.2.0" @@ -1718,9 +1718,9 @@ commonmark-react-renderer@hmhealey/commonmark-react-renderer#1f078d53b7993d30b27 pascalcase "^0.1.1" xss-filters "^1.2.6" -commonmark@hmhealey/commonmark.js#dda6d89198252d5fd4bb04c4cc0668766c22fd77: +commonmark@hmhealey/commonmark.js: version "0.28.0" - resolved "https://codeload.github.com/hmhealey/commonmark.js/tar.gz/dda6d89198252d5fd4bb04c4cc0668766c22fd77" + resolved "https://codeload.github.com/hmhealey/commonmark.js/tar.gz/f7bc747151e6d856ceffa196044112e23d04e541" dependencies: entities "~ 1.1.1" mdurl "~ 1.0.1"