MM-5957 Add mention highlighting on mobile (#2277)
* Switch pullOutImages to use a NodeWalker * Add markdown transformer to combine adjacent text nodes * MM-5957 Add mention highlighting on mobile
This commit is contained in:
parent
9e97fe160f
commit
78e5b99884
7 changed files with 1052 additions and 31 deletions
|
|
@ -5,12 +5,14 @@ import {connect} from 'react-redux';
|
|||
|
||||
import {getAutolinkedUrlSchemes} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getCurrentUserMentionKeys} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import Markdown from './markdown';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
autolinkedUrlSchemes: getAutolinkedUrlSchemes(state),
|
||||
mentionKeys: getCurrentUserMentionKeys(state),
|
||||
theme: getTheme(state),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,12 @@ 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';
|
||||
import {
|
||||
addListItemIndices,
|
||||
combineTextNodes,
|
||||
highlightMentions,
|
||||
pullOutImages,
|
||||
} from './transform';
|
||||
|
||||
export default class Markdown extends PureComponent {
|
||||
static propTypes = {
|
||||
|
|
@ -40,6 +45,7 @@ export default class Markdown extends PureComponent {
|
|||
isEdited: PropTypes.bool,
|
||||
isReplyPost: PropTypes.bool,
|
||||
isSearchResult: PropTypes.bool,
|
||||
mentionKeys: PropTypes.array.isRequired,
|
||||
navigator: PropTypes.object.isRequired,
|
||||
onChannelLinkPress: PropTypes.func,
|
||||
onHashtagPress: PropTypes.func,
|
||||
|
|
@ -111,6 +117,8 @@ export default class Markdown extends PureComponent {
|
|||
table_row: MarkdownTableRow,
|
||||
table_cell: MarkdownTableCell,
|
||||
|
||||
mention_highlight: Renderer.forwardChildren,
|
||||
|
||||
editedIndicator: this.renderEditedIndicator,
|
||||
},
|
||||
renderParagraphsInLists: true,
|
||||
|
|
@ -141,9 +149,10 @@ export default class Markdown extends PureComponent {
|
|||
// If this text is displayed, it will be styled by the image component
|
||||
return <Text>{literal}</Text>;
|
||||
}
|
||||
const style = this.computeTextStyle(this.props.baseTextStyle, context);
|
||||
|
||||
// Construct the text style based off of the parents of this node since RN's inheritance is limited
|
||||
const style = this.computeTextStyle(this.props.baseTextStyle, context);
|
||||
|
||||
return <Text style={style}>{literal}</Text>;
|
||||
}
|
||||
|
||||
|
|
@ -390,8 +399,10 @@ export default class Markdown extends PureComponent {
|
|||
render() {
|
||||
let ast = this.parser.parse(this.props.value);
|
||||
|
||||
ast = combineTextNodes(ast);
|
||||
ast = addListItemIndices(ast);
|
||||
ast = pullOutImages(ast);
|
||||
ast = highlightMentions(ast, this.props.mentionKeys);
|
||||
|
||||
if (this.props.isEdited) {
|
||||
const editIndicatorNode = new Node('edited_indicator');
|
||||
|
|
|
|||
|
|
@ -3,8 +3,45 @@
|
|||
|
||||
import {Node} from 'commonmark';
|
||||
|
||||
import {escapeRegex} from 'app/utils/markdown';
|
||||
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
|
||||
// Combines adjacent text nodes into a single text node to make further transformation easier
|
||||
export function combineTextNodes(ast) {
|
||||
const walker = ast.walker();
|
||||
|
||||
let e;
|
||||
while ((e = walker.next())) {
|
||||
if (!e.entering) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const node = e.node;
|
||||
|
||||
if (node.type !== 'text') {
|
||||
continue;
|
||||
}
|
||||
|
||||
while (node._next && node._next.type === 'text') {
|
||||
const next = node._next;
|
||||
|
||||
node.literal += next.literal;
|
||||
|
||||
node._next = next._next;
|
||||
if (node._next) {
|
||||
node._next._prev = node;
|
||||
}
|
||||
|
||||
if (node._parent._lastChild === next) {
|
||||
node._parent._lastChild = node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ast;
|
||||
}
|
||||
|
||||
// Add indices to the items of every list
|
||||
export function addListItemIndices(ast) {
|
||||
const walker = ast.walker();
|
||||
|
|
@ -29,37 +66,29 @@ export function addListItemIndices(ast) {
|
|||
return ast;
|
||||
}
|
||||
|
||||
// Take all images, that aren't inside of tables, and move them to be children of the root document node.
|
||||
// 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') {
|
||||
const walker = ast.walker();
|
||||
|
||||
let e;
|
||||
while ((e = walker.next())) {
|
||||
if (!e.entering) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let node = block.firstChild;
|
||||
const node = e.node;
|
||||
|
||||
let cameFromChild = false;
|
||||
// Skip tables because we'll render images inside of those as links
|
||||
if (node.type === 'table') {
|
||||
walker.resumeAt(node, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
while (node && node !== block) {
|
||||
if (node.type === 'image' && node.parent.type !== 'document') {
|
||||
pullOutImage(node);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
if (node.type === 'image' && node.parent.type !== 'document') {
|
||||
pullOutImage(node);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -219,3 +248,140 @@ function getLastSibling(node) {
|
|||
|
||||
return sibling;
|
||||
}
|
||||
|
||||
export function highlightMentions(ast, mentionKeys) {
|
||||
const walker = ast.walker();
|
||||
|
||||
// console.warn(mentionKeys);
|
||||
|
||||
let e;
|
||||
while ((e = walker.next())) {
|
||||
if (!e.entering) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const node = e.node;
|
||||
|
||||
if (node.type === 'text') {
|
||||
const {index, mention} = getFirstMention(node.literal, mentionKeys);
|
||||
|
||||
if (index === -1 || !mention) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mentionNode = highlightTextNode(node, index, index + mention.key.length, 'mention_highlight');
|
||||
|
||||
// Resume processing on the next node after the mention node which may include any remaining text
|
||||
// that was part of this one
|
||||
walker.resumeAt(mentionNode, false);
|
||||
} else if (node.type === 'at_mention') {
|
||||
const matches = mentionKeys.some((mention) => {
|
||||
const mentionName = '@' + node.mentionName;
|
||||
|
||||
if (mention.caseSensitive) {
|
||||
return mention.key === mentionName;
|
||||
}
|
||||
|
||||
return mention.key.toLowerCase() === mentionName.toLowerCase();
|
||||
});
|
||||
|
||||
if (!matches) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const wrapper = new Node('mention_highlight');
|
||||
wrapNode(wrapper, node);
|
||||
|
||||
// Skip processing the wrapper to prevent checking this node again as its child
|
||||
walker.resumeAt(wrapper, false);
|
||||
}
|
||||
}
|
||||
|
||||
return ast;
|
||||
}
|
||||
|
||||
// Given a string and an array of mention keys, returns the first mention that appears and its index.
|
||||
export function getFirstMention(str, mentionKeys) {
|
||||
let firstMention = null;
|
||||
let firstMentionIndex = -1;
|
||||
|
||||
for (const mention of mentionKeys) {
|
||||
const flags = mention.caseSensitive ? '' : 'i';
|
||||
const pattern = new RegExp(`\\b${escapeRegex(mention.key)}_*\\b`, flags);
|
||||
|
||||
const match = pattern.exec(str);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (firstMentionIndex === -1 || match.index < firstMentionIndex) {
|
||||
firstMentionIndex = match.index;
|
||||
firstMention = mention;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
index: firstMentionIndex,
|
||||
mention: firstMention,
|
||||
};
|
||||
}
|
||||
|
||||
// Given a text node, start/end indices, and a highlight node type, splits it into up to three nodes:
|
||||
// the text before the highlight (if any exists), the highlighted text, and the text after the highlight
|
||||
// the end of the highlight (if any exists). Returns a node containing the highlighted text.
|
||||
export function highlightTextNode(node, start, end, type) {
|
||||
const literal = node.literal;
|
||||
node.literal = literal.substring(start, end);
|
||||
|
||||
// Start by wrapping the node and then re-insert any non-highlighted code around it
|
||||
const highlighted = new Node(type);
|
||||
wrapNode(highlighted, node);
|
||||
|
||||
if (start !== 0) {
|
||||
const before = new Node('text');
|
||||
before.literal = literal.substring(0, start);
|
||||
|
||||
highlighted.insertBefore(before);
|
||||
}
|
||||
|
||||
if (end !== literal.length) {
|
||||
const after = new Node('text');
|
||||
after.literal = literal.substring(end);
|
||||
|
||||
highlighted.insertAfter(after);
|
||||
}
|
||||
|
||||
return highlighted;
|
||||
}
|
||||
|
||||
// Wraps a given node in another node of the given type. The wrapper will take the place of
|
||||
// the node in the AST relative to its parents and siblings, and it will have the node as
|
||||
// its only child.
|
||||
function wrapNode(wrapper, node) {
|
||||
// Set parent and update parent's children if necessary
|
||||
wrapper._parent = node._parent;
|
||||
if (node._parent._firstChild === node) {
|
||||
node._parent._firstChild = wrapper;
|
||||
}
|
||||
if (node._parent._lastChild === node) {
|
||||
node._parent._lastChild = wrapper;
|
||||
}
|
||||
|
||||
// Set siblings and update those if necessary
|
||||
wrapper._prev = node._prev;
|
||||
node._prev = null;
|
||||
if (wrapper._prev) {
|
||||
wrapper._prev._next = wrapper;
|
||||
}
|
||||
|
||||
wrapper._next = node._next;
|
||||
node._next = null;
|
||||
if (wrapper._next) {
|
||||
wrapper._next._prev = wrapper;
|
||||
}
|
||||
|
||||
// Make node a child of wrapper
|
||||
wrapper._firstChild = node;
|
||||
wrapper._lastChild = node;
|
||||
node._parent = wrapper;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,14 +6,186 @@ import {Node, Parser} from 'commonmark';
|
|||
|
||||
import {
|
||||
addListItemIndices,
|
||||
combineTextNodes,
|
||||
getFirstMention,
|
||||
highlightMentions,
|
||||
highlightTextNode,
|
||||
pullOutImages,
|
||||
} from 'app/components/markdown/transform';
|
||||
|
||||
/* eslint-disable max-nested-callbacks, no-console */
|
||||
/* eslint-disable no-console, no-underscore-dangle */
|
||||
|
||||
describe('Components.Markdown.transform', () => {
|
||||
const parser = new Parser();
|
||||
|
||||
describe('combineTextNodes', () => {
|
||||
const tests = [{
|
||||
name: 'no text nodes',
|
||||
input: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'thematic_break',
|
||||
}],
|
||||
},
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'thematic_break',
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'one text node',
|
||||
input: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'This is a sentence',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'This is a sentence',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'multiple text nodes',
|
||||
input: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'This ',
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: 'is a',
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: ' sen',
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: 'tence',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'This is a sentence',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'mixed formatting',
|
||||
input: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'This ',
|
||||
}, {
|
||||
type: 'emph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'is a',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: ' sen',
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: 'tence',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'This ',
|
||||
}, {
|
||||
type: 'emph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'is a',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: ' sentence',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'multiple paragraphs',
|
||||
input: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'This is a ',
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: 'paragraph',
|
||||
}],
|
||||
}, {
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'This is',
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: ' another ',
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: 'paragraph',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'This is a paragraph',
|
||||
}],
|
||||
}, {
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'This is another paragraph',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}];
|
||||
|
||||
for (const test of tests) {
|
||||
it(test.name, () => {
|
||||
const input = makeAst(test.input);
|
||||
const expected = makeAst(test.expected);
|
||||
const actual = combineTextNodes(input);
|
||||
|
||||
assert.ok(verifyAst(actual));
|
||||
assert.deepStrictEqual(actual, expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('addListItemIndices', () => {
|
||||
it('unordered list', () => {
|
||||
const input = makeAst({
|
||||
|
|
@ -2056,31 +2228,678 @@ describe('Components.Markdown.transform', () => {
|
|||
assert.deepStrictEqual(actual, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlightMentions', () => {
|
||||
const tests = [{
|
||||
name: 'no mentions',
|
||||
input: 'These are words',
|
||||
mentionKeys: [],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'These are words',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'not an at-mention',
|
||||
input: 'These are words',
|
||||
mentionKeys: [{key: 'words'}],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'These are ',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'words',
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'at-mention for another user',
|
||||
input: 'This is @user',
|
||||
mentionKeys: [{key: '@words'}],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'This is ',
|
||||
}, {
|
||||
type: 'at_mention',
|
||||
_mentionName: 'user',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: '@user',
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'at-mention',
|
||||
input: 'These are @words',
|
||||
mentionKeys: [{key: '@words'}],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'These are ',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'at_mention',
|
||||
_mentionName: 'words',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: '@words',
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'at-mention and non-at-mention for same word',
|
||||
input: 'These are @words',
|
||||
mentionKeys: [{key: 'words'}, {key: '@words'}],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'These are ',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'at_mention',
|
||||
_mentionName: 'words',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: '@words',
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'case insensitive mentions',
|
||||
input: 'These are Words and wORDS',
|
||||
mentionKeys: [{key: 'words'}],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'These are ',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'Words',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: ' and ',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'wORDS',
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'case sesitive mentions',
|
||||
input: 'These are Words and wORDS',
|
||||
mentionKeys: [{key: 'Words', caseSensitive: true}],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'These are ',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'Words',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: ' and wORDS',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'bold',
|
||||
input: 'These are **words** in a sentence',
|
||||
mentionKeys: [{key: 'words'}],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'These are ',
|
||||
}, {
|
||||
type: 'strong',
|
||||
children: [{
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'words',
|
||||
}],
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: ' in a sentence',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'italics',
|
||||
input: 'These _are Words in_ a sentence',
|
||||
mentionKeys: [{key: 'words'}],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'These ',
|
||||
}, {
|
||||
type: 'emph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'are ',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'Words',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: ' in',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: ' a sentence',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'code span',
|
||||
input: 'These are `words`',
|
||||
mentionKeys: [{key: 'words'}],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'These are ',
|
||||
}, {
|
||||
type: 'code',
|
||||
literal: 'words',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'code block',
|
||||
input: '```\nThese are\nwords\n```',
|
||||
mentionKeys: [{key: 'words'}],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'code_block',
|
||||
literal: 'These are\nwords\n',
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'link text',
|
||||
input: 'These are [words words](https://example.com)',
|
||||
mentionKeys: [{key: 'words'}],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'These are ',
|
||||
}, {
|
||||
type: 'link',
|
||||
destination: 'https://example.com',
|
||||
title: '',
|
||||
children: [{
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'words',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: ' ',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'words',
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'link url',
|
||||
input: 'This is [a link](https://example.com/words)',
|
||||
mentionKeys: [{key: 'example'}, {key: 'com'}, {key: 'https'}, {key: 'words'}],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'This is ',
|
||||
}, {
|
||||
type: 'link',
|
||||
destination: 'https://example.com/words',
|
||||
title: '',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'a link',
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'autolinked url',
|
||||
input: 'https://example.com/words',
|
||||
mentionKeys: [{key: 'example'}, {key: 'com'}, {key: 'https'}, {key: 'words'}],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'link',
|
||||
destination: 'https://example.com/words',
|
||||
title: '',
|
||||
children: [{
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'https',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: '://',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'example',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: '.',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'com',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: '/',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'words',
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
name: 'words with punctuation',
|
||||
input: 'words. (words) words/words/words words:words',
|
||||
mentionKeys: [{key: 'words'}],
|
||||
expected: {
|
||||
type: 'document',
|
||||
children: [{
|
||||
type: 'paragraph',
|
||||
children: [{
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'words',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: '. (',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'words',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: ') ',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'words',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: '/',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'words',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: '/',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'words',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: ' ',
|
||||
}, {
|
||||
type: 'link',
|
||||
destination: 'words:words',
|
||||
title: '',
|
||||
children: [{
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'words',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: ':',
|
||||
}, {
|
||||
type: 'mention_highlight',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'words',
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}];
|
||||
|
||||
for (const test of tests) {
|
||||
it(test.name, () => {
|
||||
const input = combineTextNodes(parser.parse(test.input));
|
||||
const expected = makeAst(test.expected);
|
||||
const actual = highlightMentions(input, test.mentionKeys);
|
||||
|
||||
assert.ok(verifyAst(actual));
|
||||
assert.deepStrictEqual(stripUnusedFields(actual), stripUnusedFields(expected));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('getFirstMention', () => {
|
||||
const tests = [{
|
||||
name: 'no mention keys',
|
||||
input: 'apple banana orange',
|
||||
mentionKeys: [],
|
||||
expected: {index: -1, mention: null},
|
||||
}, {
|
||||
name: 'single mention',
|
||||
input: 'apple banana orange',
|
||||
mentionKeys: [{key: 'banana'}],
|
||||
expected: {index: 6, mention: {key: 'banana'}},
|
||||
}, {
|
||||
name: 'multiple mentions',
|
||||
input: 'apple banana orange',
|
||||
mentionKeys: [{key: 'apple'}, {key: 'orange'}],
|
||||
expected: {index: 0, mention: {key: 'apple'}},
|
||||
}, {
|
||||
name: 'case sensitive',
|
||||
input: 'apple APPLE Apple aPPle',
|
||||
mentionKeys: [{key: 'Apple', caseSensitive: true}],
|
||||
expected: {index: 12, mention: {key: 'Apple', caseSensitive: true}},
|
||||
}, {
|
||||
name: 'followed by period',
|
||||
input: 'banana.',
|
||||
mentionKeys: [{key: 'banana'}],
|
||||
expected: {index: 0, mention: {key: 'banana'}},
|
||||
}, {
|
||||
name: 'followed by underscores',
|
||||
input: 'banana__',
|
||||
mentionKeys: [{key: 'banana'}],
|
||||
expected: {index: 0, mention: {key: 'banana'}},
|
||||
}, {
|
||||
name: 'in brackets',
|
||||
input: '(banana)',
|
||||
mentionKeys: [{key: 'banana'}],
|
||||
expected: {index: 1, mention: {key: 'banana'}},
|
||||
}, {
|
||||
name: 'following punctuation',
|
||||
input: ':banana',
|
||||
mentionKeys: [{key: 'banana'}],
|
||||
expected: {index: 1, mention: {key: 'banana'}},
|
||||
}, {
|
||||
name: 'not part of another word',
|
||||
input: 'pineapple',
|
||||
mentionKeys: [{key: 'apple'}],
|
||||
expected: {index: -1, mention: null},
|
||||
}, {
|
||||
name: 'no error from weird mention keys',
|
||||
input: 'apple banana orange',
|
||||
mentionKeys: [{key: '*\\3_.'}],
|
||||
expected: {index: -1, mention: null},
|
||||
}];
|
||||
|
||||
for (const test of tests) {
|
||||
it(test.name, () => {
|
||||
const actual = getFirstMention(test.input, test.mentionKeys);
|
||||
|
||||
assert.deepStrictEqual(actual, test.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('highlightTextNode', () => {
|
||||
const type = 'my_highlight';
|
||||
const literal = 'This is a sentence';
|
||||
|
||||
const tests = [{
|
||||
name: 'highlight entire text',
|
||||
start: 0,
|
||||
end: literal.length,
|
||||
expected: [{
|
||||
type,
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal,
|
||||
}],
|
||||
}],
|
||||
}, {
|
||||
name: 'highlight start of text',
|
||||
start: 0,
|
||||
end: 6,
|
||||
expected: [{
|
||||
type,
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'This i',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: 's a sentence',
|
||||
}],
|
||||
}, {
|
||||
name: 'highlight end of text',
|
||||
start: 8,
|
||||
end: literal.length,
|
||||
expected: [{
|
||||
type: 'text',
|
||||
literal: 'This is ',
|
||||
}, {
|
||||
type,
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'a sentence',
|
||||
}],
|
||||
}],
|
||||
}, {
|
||||
name: 'highlight middle of text',
|
||||
start: 5,
|
||||
end: 12,
|
||||
expected: [{
|
||||
type: 'text',
|
||||
literal: 'This ',
|
||||
}, {
|
||||
type,
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal: 'is a se',
|
||||
}],
|
||||
}, {
|
||||
type: 'text',
|
||||
literal: 'ntence',
|
||||
}],
|
||||
}];
|
||||
|
||||
for (const test of tests) {
|
||||
it(test.name + ', without siblings', () => {
|
||||
const actual = makeAst({
|
||||
type: 'parent',
|
||||
children: [{
|
||||
type: 'text',
|
||||
literal,
|
||||
}],
|
||||
});
|
||||
const expected = makeAst({
|
||||
type: 'parent',
|
||||
children: test.expected,
|
||||
});
|
||||
|
||||
const node = actual.firstChild;
|
||||
assert.equal(node.type, 'text');
|
||||
|
||||
const highlighted = highlightTextNode(node, test.start, test.end, type);
|
||||
assert.equal(highlighted.type, type);
|
||||
|
||||
assert.ok(verifyAst(actual));
|
||||
assert.deepStrictEqual(actual, expected);
|
||||
});
|
||||
|
||||
it(test.name + ', with siblings', () => {
|
||||
const actual = makeAst({
|
||||
type: 'parent',
|
||||
children: [{
|
||||
type: 'previous',
|
||||
}, {
|
||||
type: 'text',
|
||||
literal,
|
||||
}, {
|
||||
type: 'next',
|
||||
}],
|
||||
});
|
||||
const expected = makeAst({
|
||||
type: 'parent',
|
||||
children: [
|
||||
{
|
||||
type: 'previous',
|
||||
},
|
||||
...test.expected,
|
||||
{
|
||||
type: 'next',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const node = actual.firstChild.next;
|
||||
assert.equal(node.type, 'text');
|
||||
|
||||
const highlighted = highlightTextNode(node, test.start, test.end, type);
|
||||
assert.equal(highlighted.type, type);
|
||||
|
||||
assert.ok(verifyAst(actual));
|
||||
assert.deepStrictEqual(actual, expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Testing and debugging functions
|
||||
|
||||
// Confirms that all parent, child, and sibling linkages are correct and go both ways.
|
||||
function verifyAst(node) {
|
||||
if (node.prev && node.prev.next !== node) {
|
||||
console.error('node is not linked properly to prev');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.next && node.next.prev !== node) {
|
||||
console.error('node is not linked properly to prev');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!node.firstChild && node.lastChild) {
|
||||
console.error('node has children, but is not linked to first child');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.firstChild && !node.lastChild) {
|
||||
console.error('node has children, but is not linked to last child');
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let child = node.firstChild; child; child = child.next) {
|
||||
if (child.parent !== node) {
|
||||
console.error('node is not linked properly to child');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!verifyAst(child)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!child.next && child !== node.lastChild) {
|
||||
console.error('node children are not linked correctly');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (node.firstChild && node.firstChild.prev) {
|
||||
console.error('node\'s first child has previous sibling');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.lastChild && node.lastChild.next) {
|
||||
console.error('node\'s last child has next sibling');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -2206,3 +3025,23 @@ function makeAst(input) {
|
|||
|
||||
return node;
|
||||
}
|
||||
|
||||
// Remove any fields from the AST that are only used while parsing to make testing equality easier.
|
||||
function stripUnusedFields(node) {
|
||||
const walker = node.walker();
|
||||
|
||||
let e;
|
||||
while ((e = walker.next())) {
|
||||
e.node._open = false;
|
||||
e.node._size = null;
|
||||
e.node._sourcepos = null;
|
||||
|
||||
e.node._fenceChar = null;
|
||||
e.node._fenceLength = 0;
|
||||
e.node._fenceOffset = 0;
|
||||
e.node._info = '';
|
||||
e.node._isFenced = false;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,9 @@ export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme) => {
|
|||
table_header_row: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
mention_highlight: {
|
||||
backgroundColor: theme.mentionHighlightBg,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -5176,8 +5176,8 @@
|
|||
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs="
|
||||
},
|
||||
"commonmark": {
|
||||
"version": "github:mattermost/commonmark.js#95999d98559338f0249ffb3ab72984b48eccea42",
|
||||
"from": "github:mattermost/commonmark.js#95999d9",
|
||||
"version": "github:mattermost/commonmark.js#d5a7a7bfb373778d3bfd4575962c10fb3f3909c6",
|
||||
"from": "github:mattermost/commonmark.js#d5a7a7bfb373778d3bfd4575962c10fb3f3909c6",
|
||||
"requires": {
|
||||
"entities": "~ 1.1.1",
|
||||
"mdurl": "~ 1.0.1",
|
||||
|
|
@ -5187,8 +5187,8 @@
|
|||
}
|
||||
},
|
||||
"commonmark-react-renderer": {
|
||||
"version": "github:mattermost/commonmark-react-renderer#b560513b93357ee5fd33489fe311bc65d4658870",
|
||||
"from": "github:mattermost/commonmark-react-renderer#b560513b93357ee5fd33489fe311bc65d4658870",
|
||||
"version": "github:mattermost/commonmark-react-renderer#ea502501b1f2c31c6b3ff6d40eead2c808f40d42",
|
||||
"from": "github:mattermost/commonmark-react-renderer#ea502501b1f2c31c6b3ff6d40eead2c808f40d42",
|
||||
"requires": {
|
||||
"in-publish": "^2.0.0",
|
||||
"lodash.assign": "^4.2.0",
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
"@babel/polyfill": "7.0.0",
|
||||
"@babel/runtime": "7.0.0",
|
||||
"analytics-react-native": "1.2.0",
|
||||
"commonmark": "github:mattermost/commonmark.js#95999d9",
|
||||
"commonmark-react-renderer": "github:mattermost/commonmark-react-renderer#b560513b93357ee5fd33489fe311bc65d4658870",
|
||||
"commonmark": "github:mattermost/commonmark.js#d5a7a7bfb373778d3bfd4575962c10fb3f3909c6",
|
||||
"commonmark-react-renderer": "github:mattermost/commonmark-react-renderer#ea502501b1f2c31c6b3ff6d40eead2c808f40d42",
|
||||
"deep-equal": "1.0.1",
|
||||
"fuse.js": "3.2.1",
|
||||
"intl": "1.2.5",
|
||||
|
|
|
|||
Loading…
Reference in a new issue