forked from Ivasoft/mattermost-mobile
Initial attempt at search highlighting for Markdown
This commit is contained in:
committed by
Elias Nahum
parent
e6aaa586ad
commit
b9376ad429
@@ -26,11 +26,11 @@ import MarkdownTable from './markdown_table';
|
||||
import MarkdownTableCell, {MarkdownTableCellProps} from './markdown_table_cell';
|
||||
import MarkdownTableImage from './markdown_table_image';
|
||||
import MarkdownTableRow, {MarkdownTableRowProps} from './markdown_table_row';
|
||||
import {addListItemIndices, combineTextNodes, highlightMentions, pullOutImages} from './transform';
|
||||
import {addListItemIndices, combineTextNodes, highlightMentions, highlightSearchPatterns, pullOutImages} from './transform';
|
||||
|
||||
import type {
|
||||
MarkdownAtMentionRenderer, MarkdownBaseRenderer, MarkdownBlockStyles, MarkdownChannelMentionRenderer,
|
||||
MarkdownEmojiRenderer, MarkdownImageRenderer, MarkdownLatexRenderer, MarkdownTextStyles, UserMentionKey,
|
||||
MarkdownEmojiRenderer, MarkdownImageRenderer, MarkdownLatexRenderer, MarkdownTextStyles, SearchPattern, UserMentionKey,
|
||||
} from '@typings/global/markdown';
|
||||
|
||||
type MarkdownProps = {
|
||||
@@ -55,6 +55,7 @@ type MarkdownProps = {
|
||||
minimumHashtagLength?: number;
|
||||
onPostPress?: (event: GestureResponderEvent) => void;
|
||||
postId?: string;
|
||||
searchPatterns?: SearchPattern[];
|
||||
textStyles?: MarkdownTextStyles;
|
||||
theme: Theme;
|
||||
value?: string | number;
|
||||
@@ -114,7 +115,7 @@ const Markdown = ({
|
||||
disableAtChannelMentionHighlight = false, disableAtMentions = false, disableChannelLink = false,
|
||||
disableGallery = false, disableHashtags = false, enableInlineLatex, enableLatex,
|
||||
imagesMetadata, isEdited, isReplyPost, isSearchResult, layoutWidth,
|
||||
location, mentionKeys, minimumHashtagLength = 3, onPostPress, postId,
|
||||
location, mentionKeys, minimumHashtagLength = 3, onPostPress, postId, searchPatterns,
|
||||
textStyles = {}, theme, value = '',
|
||||
}: MarkdownProps) => {
|
||||
const style = getStyleSheet(theme);
|
||||
@@ -464,6 +465,7 @@ const Markdown = ({
|
||||
table_cell: renderTableCell,
|
||||
|
||||
mention_highlight: Renderer.forwardChildren,
|
||||
search_highlight: Renderer.forwardChildren,
|
||||
|
||||
editedIndicator: renderEditedIndicator,
|
||||
};
|
||||
@@ -485,6 +487,9 @@ const Markdown = ({
|
||||
if (mentionKeys) {
|
||||
ast = highlightMentions(ast, mentionKeys);
|
||||
}
|
||||
if (searchPatterns) {
|
||||
ast = highlightSearchPatterns(ast, searchPatterns);
|
||||
}
|
||||
|
||||
if (isEdited) {
|
||||
const editIndicatorNode = new Node('edited_indicator');
|
||||
|
||||
@@ -8,12 +8,15 @@ import {Node, Parser} from 'commonmark';
|
||||
import {
|
||||
addListItemIndices,
|
||||
combineTextNodes,
|
||||
getFirstMention,
|
||||
getFirstMatch,
|
||||
highlightMentions,
|
||||
highlightTextNode,
|
||||
mentionKeysToPatterns,
|
||||
pullOutImages,
|
||||
} from '@components/markdown/transform';
|
||||
|
||||
import type {UserMentionKey} from '@typings/global/markdown';
|
||||
|
||||
/* eslint-disable max-lines, no-console, no-underscore-dangle */
|
||||
|
||||
describe('Components.Markdown.transform', () => {
|
||||
@@ -2623,64 +2626,69 @@ describe('Components.Markdown.transform', () => {
|
||||
name: 'no mention keys',
|
||||
input: 'apple banana orange',
|
||||
mentionKeys: [],
|
||||
expected: {index: -1, mention: null},
|
||||
expected: {index: -1, length: -1},
|
||||
}, {
|
||||
name: 'single mention',
|
||||
input: 'apple banana orange',
|
||||
mentionKeys: [{key: 'banana'}],
|
||||
expected: {index: 6, mention: {key: 'banana'}},
|
||||
expected: {index: 6, length: 6},
|
||||
}, {
|
||||
name: 'multiple mentions',
|
||||
input: 'apple banana orange',
|
||||
mentionKeys: [{key: 'apple'}, {key: 'orange'}],
|
||||
expected: {index: 0, mention: {key: 'apple'}},
|
||||
expected: {index: 0, length: 5},
|
||||
}, {
|
||||
name: 'case sensitive',
|
||||
input: 'apple APPLE Apple aPPle',
|
||||
mentionKeys: [{key: 'Apple', caseSensitive: true}],
|
||||
expected: {index: 12, mention: {key: 'Apple', caseSensitive: true}},
|
||||
expected: {index: 12, length: 5},
|
||||
}, {
|
||||
name: 'followed by period',
|
||||
input: 'banana.',
|
||||
mentionKeys: [{key: 'banana'}],
|
||||
expected: {index: 0, mention: {key: 'banana'}},
|
||||
expected: {index: 0, length: 6},
|
||||
}, {
|
||||
name: 'followed by underscores',
|
||||
input: 'banana__',
|
||||
mentionKeys: [{key: 'banana'}],
|
||||
expected: {index: 0, mention: {key: 'banana'}},
|
||||
expected: {index: 0, length: 6},
|
||||
}, {
|
||||
name: 'in brackets',
|
||||
input: '(banana)',
|
||||
mentionKeys: [{key: 'banana'}],
|
||||
expected: {index: 1, mention: {key: 'banana'}},
|
||||
expected: {index: 1, length: 6},
|
||||
}, {
|
||||
name: 'following punctuation',
|
||||
input: ':banana',
|
||||
mentionKeys: [{key: 'banana'}],
|
||||
expected: {index: 1, mention: {key: 'banana'}},
|
||||
expected: {index: 1, length: 6},
|
||||
}, {
|
||||
name: 'not part of another word',
|
||||
input: 'pineapple',
|
||||
mentionKeys: [{key: 'apple'}],
|
||||
expected: {index: -1, mention: null},
|
||||
expected: {index: -1, length: -1},
|
||||
}, {
|
||||
name: 'no error from weird mention keys',
|
||||
input: 'apple banana orange',
|
||||
mentionKeys: [{key: '*\\3_.'}],
|
||||
expected: {index: -1, mention: null},
|
||||
expected: {index: -1, length: -1},
|
||||
}, {
|
||||
name: 'no blank mention keys',
|
||||
input: 'apple banana orange',
|
||||
mentionKeys: [{key: ''}],
|
||||
expected: {index: -1, mention: null},
|
||||
expected: {index: -1, length: -1},
|
||||
}, {
|
||||
name: 'multibyte key',
|
||||
input: '좋은 하루 되세요.',
|
||||
mentionKeys: [{key: '하루'}],
|
||||
expected: {index: 3, mention: {key: '하루'}},
|
||||
expected: {index: 3, length: 2},
|
||||
}];
|
||||
|
||||
function getFirstMention(str: string, mentionKeys: UserMentionKey[]) {
|
||||
const patterns = mentionKeysToPatterns(mentionKeys);
|
||||
return getFirstMatch(str, patterns);
|
||||
}
|
||||
|
||||
for (const test of tests) {
|
||||
it(test.name, () => {
|
||||
const actual = getFirstMention(test.input, test.mentionKeys);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {Node, NodeType} from 'commonmark';
|
||||
|
||||
import {escapeRegex} from '@utils/markdown';
|
||||
|
||||
import type {UserMentionKey} from '@typings/global/markdown';
|
||||
import type {SearchPattern, UserMentionKey} from '@typings/global/markdown';
|
||||
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
|
||||
@@ -114,6 +114,8 @@ function pullOutImage(image: any) {
|
||||
export function highlightMentions(ast: Node, mentionKeys: UserMentionKey[]) {
|
||||
const walker = ast.walker();
|
||||
|
||||
const patterns = mentionKeysToPatterns(mentionKeys);
|
||||
|
||||
let e;
|
||||
while ((e = walker.next())) {
|
||||
if (!e.entering) {
|
||||
@@ -123,13 +125,13 @@ export function highlightMentions(ast: Node, mentionKeys: UserMentionKey[]) {
|
||||
const node = e.node;
|
||||
|
||||
if (node.type === 'text' && node.literal) {
|
||||
const {index, mention} = getFirstMention(node.literal, mentionKeys);
|
||||
const {index, length} = getFirstMatch(node.literal, patterns);
|
||||
|
||||
if (index === -1 || !mention) {
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mentionNode = highlightTextNode(node, index, index + mention.key.length, 'mention_highlight');
|
||||
const mentionNode = highlightTextNode(node, index, index + 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
|
||||
@@ -158,38 +160,71 @@ export function highlightMentions(ast: Node, mentionKeys: UserMentionKey[]) {
|
||||
return ast;
|
||||
}
|
||||
|
||||
// Given a string and an array of mention keys, returns the first mention that appears and its index.
|
||||
export function getFirstMention(str: string, mentionKeys: UserMentionKey[]) {
|
||||
let firstMention = null;
|
||||
let firstMentionIndex = -1;
|
||||
|
||||
for (const mention of mentionKeys) {
|
||||
if (mention.key.trim() === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
export function mentionKeysToPatterns(mentionKeys: UserMentionKey[]) {
|
||||
return mentionKeys.filter((mention) => mention.key.trim() !== '').map((mention) => {
|
||||
const flags = mention.caseSensitive ? '' : 'i';
|
||||
let pattern;
|
||||
if (cjkPattern.test(mention.key)) {
|
||||
pattern = new RegExp(`${escapeRegex(mention.key)}`, flags);
|
||||
} else {
|
||||
pattern = new RegExp(`\\b${escapeRegex(mention.key)}_*\\b`, flags);
|
||||
pattern = new RegExp(`\\b${escapeRegex(mention.key)}(?=_*\\b)`, flags);
|
||||
}
|
||||
|
||||
return pattern;
|
||||
});
|
||||
}
|
||||
|
||||
export function highlightSearchPatterns(ast: Node, searchPatterns: SearchPattern[]) {
|
||||
const walker = ast.walker();
|
||||
|
||||
let e;
|
||||
while ((e = walker.next())) {
|
||||
if (!e.entering) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const node = e.node;
|
||||
|
||||
if (node.type === 'text' && node.literal) {
|
||||
const {index, length} = getFirstMatch(node.literal, searchPatterns.map((pattern) => pattern.pattern));
|
||||
|
||||
// TODO we might need special handling here for if the search term is part of a hashtag
|
||||
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchNode = highlightTextNode(node, index, index + length, 'search_highlight');
|
||||
|
||||
// Resume processing on the next node after the match node which may include any remaining text
|
||||
// that was part of this one
|
||||
walker.resumeAt(matchNode, false);
|
||||
}
|
||||
}
|
||||
|
||||
return ast;
|
||||
}
|
||||
|
||||
// Given a string and an array of regexess, returns the index and length of the first match.
|
||||
export function getFirstMatch(str: string, patterns: RegExp[]) {
|
||||
let firstMatchIndex = -1;
|
||||
let firstMatchLength = -1;
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = pattern.exec(str);
|
||||
if (!match || match[0] === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (firstMentionIndex === -1 || match.index < firstMentionIndex) {
|
||||
firstMentionIndex = match.index;
|
||||
firstMention = mention;
|
||||
if (firstMatchIndex === -1 || match.index < firstMatchIndex) {
|
||||
firstMatchIndex = match.index;
|
||||
firstMatchLength = match[0].length;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
index: firstMentionIndex,
|
||||
mention: firstMention,
|
||||
index: firstMatchIndex,
|
||||
length: firstMatchLength,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,21 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
};
|
||||
});
|
||||
|
||||
const searchPatterns = [
|
||||
{
|
||||
pattern: /\bMattermost\b/,
|
||||
term: 'Mattermost',
|
||||
},
|
||||
{
|
||||
pattern: /\bbug\b/i,
|
||||
term: 'bug',
|
||||
},
|
||||
{
|
||||
pattern: /\bboat/,
|
||||
term: 'boat',
|
||||
},
|
||||
];
|
||||
|
||||
const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPost, layoutWidth, location, post, theme}: MessageProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [height, setHeight] = useState<number|undefined>();
|
||||
@@ -94,6 +109,7 @@ const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPo
|
||||
textStyles={textStyles}
|
||||
value={post.message}
|
||||
mentionKeys={mentionKeys}
|
||||
searchPatterns={searchPatterns}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -108,6 +108,11 @@ export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
backgroundColor: theme.mentionHighlightBg,
|
||||
color: theme.mentionHighlightLink,
|
||||
},
|
||||
search_highlight: {
|
||||
fontFamily: 'OpenSans',
|
||||
backgroundColor: theme.mentionHighlightBg,
|
||||
color: theme.mentionHighlightLink,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ index 35e9ed6..382cf98 100755
|
||||
export type NodeType =
|
||||
- 'text' |'softbreak' | 'linebreak' | 'emph' | 'strong' | 'html_inline' | 'link' | 'image' | 'code' | 'document' | 'paragraph' |
|
||||
- 'block_quote' | 'item' | 'list' | 'heading' | 'code_block' | 'html_block' | 'thematic_break' | 'custom_inline' | 'custom_block';
|
||||
+ 'text' |'softbreak' | 'linebreak' | 'emph' | 'strong' | 'html_inline' | 'link' | 'image' | 'code' | 'document' | 'paragraph' | 'mention_highlight' | 'at_mention' |
|
||||
+ 'text' |'softbreak' | 'linebreak' | 'emph' | 'strong' | 'html_inline' | 'link' | 'image' | 'code' | 'document' | 'paragraph' | 'mention_highlight' | 'search_highlight' | 'at_mention' |
|
||||
+ 'block_quote' | 'item' | 'list' | 'heading' | 'code_block' | 'html_block' | 'thematic_break' | 'custom_inline' | 'custom_block' | 'table' | 'edited_indicator';
|
||||
|
||||
export class Node {
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
import {TextStyle, ViewStyle} from 'react-native';
|
||||
|
||||
export type UserMentionKey= {
|
||||
export type SearchPattern = {
|
||||
pattern: RegExp;
|
||||
term: string;
|
||||
};
|
||||
|
||||
export type UserMentionKey = {
|
||||
key: string;
|
||||
caseSensitive?: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user