diff --git a/native/markdown/rules.react.js b/native/markdown/rules.react.js index 33b03122a..b4961d612 100644 --- a/native/markdown/rules.react.js +++ b/native/markdown/rules.react.js @@ -1,317 +1,343 @@ // @flow import type { StyleSheetOf } from '../themes/colors'; import type { MarkdownStyles } from './styles'; import * as React from 'react'; import { Text, Linking, Alert, View } from 'react-native'; import * as SimpleMarkdown from 'simple-markdown'; import * as SharedMarkdown from 'lib/shared/markdown'; import { normalizeURL } from 'lib/utils/url-utils'; type MarkdownRuleSpec = {| +simpleMarkdownRules: SimpleMarkdown.ParserRules, +emojiOnlyFactor: ?number, // We need to use a Text container for Entry because it needs to match up // exactly with TextInput. However, if we use a Text container, we can't // support styles for things like blockQuote, which rely on rendering as a // View, and Views can't be nested inside Texts without explicit height and // width +container: 'View' | 'Text', |}; export type MarkdownRules = ( styles: StyleSheetOf, ) => MarkdownRuleSpec; function displayLinkPrompt(inputURL: string) { const url = normalizeURL(inputURL); const onConfirm = () => { Linking.openURL(url); }; let displayURL = url.substring(0, 64); if (url.length > displayURL.length) { displayURL += '…'; } Alert.alert( 'External link', `You sure you want to open this link?\n\n${displayURL}`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Open', onPress: onConfirm }, ], { cancelable: true }, ); } // Entry requires a seamless transition between Markdown and TextInput // components, so we can't do anything that would change the position of text function inlineMarkdownRules( styles: StyleSheetOf, ): MarkdownRuleSpec { const simpleMarkdownRules = { // Matches 'https://google.com' during parse phase and returns a 'link' node url: { ...SimpleMarkdown.defaultRules.url, // simple-markdown is case-sensitive, but we don't want to be match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex), }, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...SimpleMarkdown.defaultRules.link, match: () => null, react( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) { const onPressLink = () => displayLinkPrompt(node.target); return ( {output(node.content, state)} ); }, }, // Each line gets parsed into a 'paragraph' node. The AST returned by the // parser will be an array of one or more 'paragraph' nodes paragraph: { ...SimpleMarkdown.defaultRules.paragraph, // simple-markdown's default RegEx collapses multiple newlines into one. // We want to keep the newlines, but when rendering within a View, we // strip just one trailing newline off, since the View adds vertical // spacing between its children match: (source: string, state: SimpleMarkdown.State) => { if (state.inline) { return null; } else if (state.container === 'View') { return SharedMarkdown.paragraphStripTrailingNewlineRegex.exec(source); } else { return SharedMarkdown.paragraphRegex.exec(source); } }, parse( capture: SimpleMarkdown.Capture, parse: SimpleMarkdown.Parser, state: SimpleMarkdown.State, ) { let content = capture[1]; if (state.container === 'View') { // React Native renders empty lines with less height. We want to // preserve the newline characters, so we replace empty lines with a // single space content = content.replace(/^$/m, ' '); } return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, // This is the leaf node in the AST returned by the parse phase text: SimpleMarkdown.defaultRules.text, }; return { simpleMarkdownRules, emojiOnlyFactor: null, container: 'Text', }; } // We allow the most markdown features for TextMessage, which doesn't have the // same requirements as Entry function fullMarkdownRules( styles: StyleSheetOf, ): MarkdownRuleSpec { const inlineRules = inlineMarkdownRules(styles); const simpleMarkdownRules = { ...inlineRules.simpleMarkdownRules, // Matches '' during parse phase and returns a 'link' // node autolink: SimpleMarkdown.defaultRules.autolink, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...inlineRules.simpleMarkdownRules.link, match: SimpleMarkdown.defaultRules.link.match, }, mailto: SimpleMarkdown.defaultRules.mailto, em: { ...SimpleMarkdown.defaultRules.em, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, strong: { ...SimpleMarkdown.defaultRules.strong, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, u: { ...SimpleMarkdown.defaultRules.u, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, del: { ...SimpleMarkdown.defaultRules.del, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, inlineCode: { ...SimpleMarkdown.defaultRules.inlineCode, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {node.content} ), }, heading: { ...SimpleMarkdown.defaultRules.heading, match: SimpleMarkdown.blockRegex( SharedMarkdown.headingStripFollowingNewlineRegex, ), // eslint-disable-next-line react/display-name react( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) { const headingStyle = styles['h' + node.level]; return ( {output(node.content, state)} ); }, }, blockQuote: { ...SimpleMarkdown.defaultRules.blockQuote, // match end of blockQuote by either \n\n or end of string match: SimpleMarkdown.blockRegex( SharedMarkdown.blockQuoteStripFollowingNewlineRegex, ), parse( capture: SimpleMarkdown.Capture, parse: SimpleMarkdown.Parser, state: SimpleMarkdown.State, ) { const content = capture[1].replace(/^ *> ?/gm, ''); return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, codeBlock: { ...SimpleMarkdown.defaultRules.codeBlock, match: SimpleMarkdown.blockRegex( SharedMarkdown.codeBlockStripTrailingNewlineRegex, ), parse(capture: SimpleMarkdown.Capture) { return { content: capture[1].replace(/^ {4}/gm, ''), }; }, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {node.content} ), }, fence: { ...SimpleMarkdown.defaultRules.fence, match: SimpleMarkdown.blockRegex( SharedMarkdown.fenceStripTrailingNewlineRegex, ), parse: (capture: SimpleMarkdown.Capture) => ({ type: 'codeBlock', content: capture[2], }), }, json: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: (source: string, state: SimpleMarkdown.State) => { if (state.inline) { return null; } return SharedMarkdown.jsonMatch(source); }, parse: (capture: SimpleMarkdown.Capture) => ({ type: 'codeBlock', content: SharedMarkdown.jsonPrint(capture), }), }, + list: { + ...SimpleMarkdown.defaultRules.list, + match: SharedMarkdown.matchList, + parse: SharedMarkdown.parseList, + react( + node: SimpleMarkdown.SingleASTNode, + output: SimpleMarkdown.Output, + state: SimpleMarkdown.State, + ) { + const children = node.items.map((item, i) => { + const content = output(item, state); + const bulletValue = node.ordered ? node.start + i + '. ' : '\u2022 '; + return ( + + + {bulletValue} + + {content} + + ); + }); + + return {children}; + }, + }, + escape: SimpleMarkdown.defaultRules.escape, }; return { ...inlineRules, simpleMarkdownRules, emojiOnlyFactor: 2, container: 'View', }; } export { inlineMarkdownRules, fullMarkdownRules }; diff --git a/native/markdown/styles.js b/native/markdown/styles.js index 9c67a1a8f..30cf11bad 100644 --- a/native/markdown/styles.js +++ b/native/markdown/styles.js @@ -1,92 +1,101 @@ // @flow import type { GlobalTheme } from '../types/themes'; import { Platform } from 'react-native'; import { getStylesForTheme } from '../themes/colors'; const unboundStyles = { link: { color: 'link', textDecorationLine: 'underline', }, italics: { fontStyle: 'italic', }, bold: { fontWeight: 'bold', }, underline: { textDecorationLine: 'underline', }, strikethrough: { textDecorationLine: 'line-through', textDecorationStyle: 'solid', }, inlineCode: { backgroundColor: 'codeBackground', fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace', }), fontSize: Platform.select({ ios: 17, default: 18, }), }, h1: { fontSize: 32, fontWeight: 'bold', }, h2: { fontSize: 24, fontWeight: 'bold', }, h3: { fontSize: 18, fontWeight: 'bold', }, h4: { fontSize: 16, fontWeight: 'bold', }, h5: { fontSize: 13, fontWeight: 'bold', }, h6: { fontSize: 11, fontWeight: 'bold', }, blockQuote: { backgroundColor: 'blockQuoteBackground', borderLeftColor: 'blockQuoteBorder', borderLeftWidth: 5, padding: 10, marginBottom: 6, marginVertical: 6, }, codeBlock: { backgroundColor: 'codeBackground', padding: 10, borderRadius: 5, marginVertical: 6, }, codeBlockText: { fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace', }), fontSize: Platform.select({ ios: 17, default: 18, }), }, + listBulletStyle: { + fontWeight: 'bold', + }, + listRow: { + flexDirection: 'row', + }, + insideListView: { + flexShrink: 1, + }, }; export type MarkdownStyles = typeof unboundStyles; export function getMarkdownStyles(theme: GlobalTheme) { return getStylesForTheme(unboundStyles, theme); }