diff --git a/content/show/highlight/index-en-US.md b/content/show/highlight/index-en-US.md index 2a3f5be3fd..4a8878d813 100644 --- a/content/show/highlight/index-en-US.md +++ b/content/show/highlight/index-en-US.md @@ -71,6 +71,32 @@ import { Highlight } from '@douyinfe/semi-ui'; }; ``` +### Use Different Styles for Different Texts +After v2.71.0, it supports using different highlight styles for different highlighted texts. +The `searchWords` is a string array by default. When an array of objects is passed in, the highlighted text can be specified through `text`, and the `className` and `style` can be specified separately at the same time. + +```jsx live=true dir="column" +import React from 'react'; +import { Highlight } from '@douyinfe/semi-ui'; + +() => { + return ( +

+ +

+ ); +}; +``` + ### Specify the highlight tag diff --git a/content/show/highlight/index.md b/content/show/highlight/index.md index 6100ac0752..9ee35a65cb 100644 --- a/content/show/highlight/index.md +++ b/content/show/highlight/index.md @@ -89,6 +89,32 @@ import { Highlight } from '@douyinfe/semi-ui'; }; ``` +### 不同文本使用差异化样式 +v2.71.0 后,支持针对不同的高亮文本使用不同的高亮样式 +searchWords 默认为字符串数组。当传入对象数组时,可以通过 text指定高亮文本,同时单独指定 className、style + +```jsx live=true dir="column" +import React from 'react'; +import { Highlight } from '@douyinfe/semi-ui'; + +() => { + return ( +

+ +

+ ); +}; +``` + ### 指定高亮标签 @@ -112,16 +138,17 @@ import { Highlight } from '@douyinfe/semi-ui'; }; ``` + ## API 参考 ### Highlight | 属性 | 说明 | 类型 | 默认值 | | ------------ | -------------------------------------------------------- | -------------------------------- | ---------- | -| searchWords | 期望高亮显示的文本 | string[] | '' | +| searchWords | 期望高亮显示的文本(对象数组在v2.71后支持) | string[]\|object[] | [] | | sourceString | 源文本 | string | | | component | 高亮标签 | string | `mark` | -| highlightClassName | 高亮标签的样式类名 | ReactNode | - | -| highlightStyle | 高亮标签的内联样式 | ReactNode | - | +| highlightClassName | 高亮标签的样式类名 | string | - | +| highlightStyle | 高亮标签的内联样式 | CSSProperties | - | | caseSensitive | 是否大小写敏感 | false | - | | autoEscape | 是否自动转义 | true | - | diff --git a/packages/semi-foundation/highlight/foundation.ts b/packages/semi-foundation/highlight/foundation.ts new file mode 100644 index 0000000000..a88681d53c --- /dev/null +++ b/packages/semi-foundation/highlight/foundation.ts @@ -0,0 +1,211 @@ +// Modified version based on 'highlight-words-core' +import { isString } from 'lodash'; +import BaseFoundation, { DefaultAdapter } from '../base/foundation'; + +interface HighlightAdapter extends Partial {} + +interface ChunkQuery { + autoEscape?: boolean; + caseSensitive?: boolean; + searchWords: SearchWords; + sourceString: string +} +export interface Chunk { + start: number; + end: number; + highlight: boolean; + className: string; + style: Record +} + +export interface ComplexSearchWord { + text: string; + className?: string; + style?: Record +} + +export type SearchWord = string | ComplexSearchWord | undefined; +export type SearchWords = SearchWord[]; + +const escapeRegExpFn = (string: string) => string.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); + +export default class HighlightFoundation extends BaseFoundation { + + constructor(adapter?: HighlightAdapter) { + super({ + ...adapter, + }); + } + + /** + * Creates an array of chunk objects representing both higlightable and non highlightable pieces of text that match each search word. + * + findAll ['z'], 'aaazaaazaaa' + result #=> [ + { start: 0, end: 3, highlight: false } + { start: 3, end: 4, highlight: true } + { start: 4, end: 7, highlight: false } + { start: 7, end: 8, highlight: true } + { start: 8, end: 11, highlight: false } + ] + + findAll ['do', 'dollar'], 'aaa do dollar aaa' + #=> chunks: [ + { start: 4, end: 6 }, + { start: 7, end: 9 }, + { start: 7, end: 13 }, + ] + #=> chunksToHight: [ + { start: 4, end: 6 }, + { start: 7, end: 13 }, + ] + #=> result: [ + { start: 0, end: 4, highlight: false }, + { start: 4, end: 6, highlight: true }, + { start: 6, end: 7, highlight: false }, + { start: 7, end: 13, highlight: true }, + { start: 13, end: 17, highlight: false }, + ] + + * @return Array of "chunks" (where a Chunk is { start:number, end:number, highlight:boolean }) + */ + findAll = ({ + autoEscape = true, + caseSensitive = false, + searchWords, + sourceString + }: ChunkQuery) => { + if (isString(searchWords)) { + searchWords = [searchWords]; + } + + const chunks = this.findChunks({ + autoEscape, + caseSensitive, + searchWords, + sourceString + }); + const chunksToHighlight = this.combineChunks({ chunks }); + const result = this.fillInChunks({ + chunksToHighlight, + totalLength: sourceString ? sourceString.length : 0 + }); + return result; + }; + + /** + * Examine text for any matches. + * If we find matches, add them to the returned array as a "chunk" object ({start:number, end:number}). + * @return { start:number, end:number }[] + */ + findChunks = ({ + autoEscape, + caseSensitive, + searchWords, + sourceString + }: ChunkQuery): Chunk[] => ( + searchWords + .map(searchWord => typeof searchWord === 'string' ? { text: searchWord } : searchWord) + .filter(searchWord => searchWord.text) // Remove empty words + .reduce((chunks, searchWord) => { + let searchText = searchWord.text; + if (autoEscape) { + searchText = escapeRegExpFn(searchText); + } + const regex = new RegExp(searchText, caseSensitive ? 'g' : 'gi'); + + let match; + while ((match = regex.exec(sourceString))) { + const start = match.index; + const end = regex.lastIndex; + if (end > start) { + chunks.push({ + highlight: true, + start, + end, + className: searchWord.className, + style: searchWord.style + }); + } + if (match.index === regex.lastIndex) { + regex.lastIndex++; + } + } + return chunks; + }, []) + ); + + /** + * Takes an array of {start:number, end:number} objects and combines chunks that overlap into single chunks. + * @return {start:number, end:number}[] + */ + combineChunks = ({ chunks }: { chunks: Chunk[] }): Chunk[] => { + return chunks + .sort((first, second) => first.start - second.start) + .reduce((processedChunks, nextChunk) => { + // First chunk just goes straight in the array... + if (processedChunks.length === 0) { + return [nextChunk]; + } else { + // ... subsequent chunks get checked to see if they overlap... + const prevChunk = processedChunks.pop(); + if (nextChunk.start <= prevChunk.end) { + // It may be the case that prevChunk completely surrounds nextChunk, so take the + // largest of the end indeces. + const endIndex = Math.max(prevChunk.end, nextChunk.end); + processedChunks.push({ + highlight: true, + start: prevChunk.start, + end: endIndex, + className: prevChunk.className || nextChunk.className, + style: { ...prevChunk.style, ...nextChunk.style } + }); + } else { + processedChunks.push(prevChunk, nextChunk); + } + return processedChunks; + } + }, []); + }; + + /** + * Given a set of chunks to highlight, create an additional set of chunks + * to represent the bits of text between the highlighted text. + * @param chunksToHighlight {start:number, end:number}[] + * @param totalLength number + * @return {start:number, end:number, highlight:boolean}[] + */ + fillInChunks = ({ chunksToHighlight, totalLength }: { chunksToHighlight: Chunk[]; totalLength: number }): Chunk[] => { + const allChunks: Chunk[] = []; + const append = (start: number, end: number, highlight: boolean, className?: string, style?: Record) => { + if (end - start > 0) { + allChunks.push({ + start, + end, + highlight, + className, + style + }); + } + }; + + if (chunksToHighlight.length === 0) { + append(0, totalLength, false); + } else { + let lastIndex = 0; + chunksToHighlight.forEach(chunk => { + append(lastIndex, chunk.start, false); + append(chunk.start, chunk.end, true, chunk.className, chunk.style); + lastIndex = chunk.end; + }); + append(lastIndex, totalLength, false); + } + return allChunks; + }; + +} + + + + + diff --git a/packages/semi-foundation/tree/tree.scss b/packages/semi-foundation/tree/tree.scss index f1f8ac9fbd..ffc53fdce2 100644 --- a/packages/semi-foundation/tree/tree.scss +++ b/packages/semi-foundation/tree/tree.scss @@ -137,6 +137,8 @@ $module: #{$prefix}-tree; &-highlight { font-weight: $font-tree_option_hightlight-fontWeight; color: $color-tree_option_hightlight-text; + // set inherit to override highlight component default bgc + background-color: inherit; } &-hidden { diff --git a/packages/semi-foundation/utils/getHighlight.ts b/packages/semi-foundation/utils/getHighlight.ts deleted file mode 100644 index f08d5e4bc9..0000000000 --- a/packages/semi-foundation/utils/getHighlight.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Modified version based on 'highlight-words-core' -import { isString } from 'lodash'; - -const escapeRegExpFn = (string: string) => string.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); -interface ChunkQuery { - autoEscape?: boolean; - caseSensitive?: boolean; - searchWords: string[]; - sourceString: string -} -interface Chunk { - start: number; - end: number; - highlight: boolean -} -/** - * Examine text for any matches. - * If we find matches, add them to the returned array as a "chunk" object ({start:number, end:number}). - * @return { start:number, end:number }[] - */ -const findChunks = ({ - autoEscape, - caseSensitive, - searchWords, - sourceString -}: ChunkQuery): Chunk[] => ( - searchWords - .filter(searchWord => searchWord) // Remove empty words - .reduce((chunks, searchWord) => { - if (autoEscape) { - searchWord = escapeRegExpFn(searchWord); - } - const regex = new RegExp(searchWord, caseSensitive ? 'g' : 'gi'); - - let match; - while ((match = regex.exec(sourceString))) { - const start = match.index; - const end = regex.lastIndex; - // We do not return zero-length matches - if (end > start) { - chunks.push({ highlight: false, start, end }); - } - // Prevent browsers like Firefox from getting stuck in an infinite loop - // See http://www.regexguru.com/2008/04/watch-out-for-zero-length-matches/ - if (match.index === regex.lastIndex) { - regex.lastIndex++; - } - } - return chunks; - }, []) -); - -/** - * Takes an array of {start:number, end:number} objects and combines chunks that overlap into single chunks. - * @return {start:number, end:number}[] - */ -const combineChunks = ({ chunks }: { chunks: Chunk[] }) => { - chunks = chunks - .sort((first, second) => first.start - second.start) - .reduce((processedChunks, nextChunk) => { - // First chunk just goes straight in the array... - if (processedChunks.length === 0) { - return [nextChunk]; - } else { - // ... subsequent chunks get checked to see if they overlap... - const prevChunk = processedChunks.pop(); - if (nextChunk.start <= prevChunk.end) { - // It may be the case that prevChunk completely surrounds nextChunk, so take the - // largest of the end indeces. - const endIndex = Math.max(prevChunk.end, nextChunk.end); - processedChunks.push({ - highlight: false, - start: prevChunk.start, - end: endIndex - }); - } else { - processedChunks.push(prevChunk, nextChunk); - } - return processedChunks; - } - }, []); - - return chunks; -}; - - -/** - * Given a set of chunks to highlight, create an additional set of chunks - * to represent the bits of text between the highlighted text. - * @param chunksToHighlight {start:number, end:number}[] - * @param totalLength number - * @return {start:number, end:number, highlight:boolean}[] - */ -const fillInChunks = ({ chunksToHighlight, totalLength }: { chunksToHighlight: Chunk[]; totalLength: number }) => { - const allChunks: Chunk[] = []; - const append = (start: number, end: number, highlight: boolean) => { - if (end - start > 0) { - allChunks.push({ - start, - end, - highlight - }); - } - }; - - if (chunksToHighlight.length === 0) { - append(0, totalLength, false); - } else { - let lastIndex = 0; - chunksToHighlight.forEach(chunk => { - append(lastIndex, chunk.start, false); - append(chunk.start, chunk.end, true); - lastIndex = chunk.end; - }); - append(lastIndex, totalLength, false); - } - return allChunks; -}; - - -/** - * Creates an array of chunk objects representing both higlightable and non highlightable pieces of text that match each search word. - * - findAll ['z'], 'aaazaaazaaa' - result #=> [ - { start: 0, end: 3, highlight: false } - { start: 3, end: 4, highlight: true } - { start: 4, end: 7, highlight: false } - { start: 7, end: 8, highlight: true } - { start: 8, end: 11, highlight: false } - ] - - findAll ['do', 'dollar'], 'aaa do dollar aaa' - #=> chunks: [ - { start: 4, end: 6 }, - { start: 7, end: 9 }, - { start: 7, end: 13 }, - ] - #=> chunksToHight: [ - { start: 4, end: 6 }, - { start: 7, end: 13 }, - ] - #=> result: [ - { start: 0, end: 4, highlight: false }, - { start: 4, end: 6, highlight: true }, - { start: 6, end: 7, highlight: false }, - { start: 7, end: 13, highlight: true }, - { start: 13, end: 17, highlight: false }, - ] - - * @return Array of "chunks" (where a Chunk is { start:number, end:number, highlight:boolean }) - */ - -const findAll = ({ - autoEscape = true, - caseSensitive = false, - searchWords, - sourceString -}: ChunkQuery) => { - if (isString(searchWords)) { - searchWords = [searchWords]; - } - - const chunks = findChunks({ - autoEscape, - caseSensitive, - searchWords, - sourceString - }); - const chunksToHighlight = combineChunks({ chunks }); - const result = fillInChunks({ - chunksToHighlight, - totalLength: sourceString ? sourceString.length : 0 - }); - return result; -}; - -export { findAll }; \ No newline at end of file diff --git a/packages/semi-ui/_utils/index.tsx b/packages/semi-ui/_utils/index.tsx index 8f6d6dd0d5..d40892eae2 100644 --- a/packages/semi-ui/_utils/index.tsx +++ b/packages/semi-ui/_utils/index.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { cloneDeepWith, set, get } from 'lodash'; import warning from '@douyinfe/semi-foundation/utils/warning'; -import { findAll } from '@douyinfe/semi-foundation/utils/getHighlight'; import { isHTMLElement } from '@douyinfe/semi-foundation/utils/dom'; import semiGlobal from "./semi-global"; /** @@ -66,48 +65,6 @@ export function cloneDeep(value: any, customizer?: (value: any) => any) { return undefined; }); } - -/** - * [getHighLightTextHTML description] - * - * @param {string} sourceString [source content text] - * @param {Array} searchWords [keywords to be highlighted] - * @param {object} option - * @param {true} option.highlightTag [The tag wrapped by the highlighted content, mark is used by default] - * @param {true} option.highlightClassName - * @param {true} option.highlightStyle - * @param {boolean} option.caseSensitive - * - * @return {Array} - */ -export const getHighLightTextHTML = ({ - sourceString = '', - searchWords = [], - option = { autoEscape: true, caseSensitive: false } -}: GetHighLightTextHTMLProps) => { - const chunks: HighLightTextHTMLChunk[] = findAll({ sourceString, searchWords, ...option }); - const markEle = option.highlightTag || 'mark'; - const highlightClassName = option.highlightClassName || ''; - const highlightStyle = option.highlightStyle || {}; - return chunks.map((chunk: HighLightTextHTMLChunk, index: number) => { - const { end, start, highlight } = chunk; - const text = sourceString.substr(start, end - start); - if (highlight) { - return React.createElement( - markEle, - { - style: highlightStyle, - className: highlightClassName, - key: text + index - }, - text - ); - } else { - return text; - } - }); -}; - export interface RegisterMediaQueryOption { match?: (e: MediaQueryList | MediaQueryListEvent) => void; unmatch?: (e: MediaQueryList | MediaQueryListEvent) => void; @@ -140,25 +97,6 @@ export const registerMediaQuery = (media: string, { match, unmatch, callInInit = } return () => undefined; }; -export interface GetHighLightTextHTMLProps { - sourceString?: string; - searchWords?: string[]; - option: HighLightTextHTMLOption -} - -export interface HighLightTextHTMLOption { - highlightTag?: string; - highlightClassName?: string; - highlightStyle?: React.CSSProperties; - caseSensitive: boolean; - autoEscape: boolean -} - -export interface HighLightTextHTMLChunk { - start?: number; - end?: number; - highlight?: any -} /** * Determine whether the incoming element is a built-in icon diff --git a/packages/semi-ui/autoComplete/option.tsx b/packages/semi-ui/autoComplete/option.tsx index 6fd07a9843..76158f4075 100644 --- a/packages/semi-ui/autoComplete/option.tsx +++ b/packages/semi-ui/autoComplete/option.tsx @@ -5,7 +5,7 @@ import { isString } from 'lodash'; import { cssClasses } from '@douyinfe/semi-foundation/autoComplete/constants'; import LocaleConsumer from '../locale/localeConsumer'; import { IconTick } from '@douyinfe/semi-icons'; -import { getHighLightTextHTML } from '../_utils/index'; +import Highlight from '../highlight'; import { Locale } from '../locale/interface'; import { BasicOptionProps } from '@douyinfe/semi-foundation/autoComplete/optionFoundation'; @@ -19,15 +19,6 @@ export interface OptionProps extends BasicOptionProps { className?: string; style?: React.CSSProperties } -interface renderOptionContentArgument { - config: { - searchWords: any; - sourceString: React.ReactNode - }; - children: React.ReactNode; - inputValue: string; - prefixCls: string -} class Option extends PureComponent { static isSelectOption = true; @@ -62,9 +53,13 @@ class Option extends PureComponent { } } - renderOptionContent({ config, children, inputValue, prefixCls }: renderOptionContentArgument) { + renderOptionContent({ children, inputValue, prefixCls }) { if (isString(children) && inputValue) { - return getHighLightTextHTML(config as any); + return (); } return children; } @@ -129,13 +124,6 @@ class Option extends PureComponent { }); } - const config = { - searchWords: inputValue, - sourceString: children, - option: { - highlightClassName: `${prefixCls}-keyword` - } - }; return ( // eslint-disable-next-line jsx-a11y/interactive-supports-focus,jsx-a11y/click-events-have-key-events
{
) : null} - {isString(children) ?
{this.renderOptionContent({ children, config, inputValue, prefixCls })}
: children} + {isString(children) ?
{this.renderOptionContent({ children, inputValue, prefixCls })}
: children} ); } diff --git a/packages/semi-ui/highlight/_story/highlight.stories.jsx b/packages/semi-ui/highlight/_story/highlight.stories.jsx index c776022186..e412256bec 100644 --- a/packages/semi-ui/highlight/_story/highlight.stories.jsx +++ b/packages/semi-ui/highlight/_story/highlight.stories.jsx @@ -1,8 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; - -import { Skeleton, Avatar, Button, ButtonGroup, Spin, Highlight } from '../../index'; - +import { Highlight } from '../../index'; const searchWords = ['do', 'dollar']; const sourceString = 'aaa do dollar aaa'; @@ -11,7 +9,7 @@ export default { title: 'Highlight' } -export const HighlightTag = () => ( +export const HighlightBase = () => (

(

); -HighlightTag.story = { - name: 'different tag', -}; export const HighlightStyle = () => (

@@ -36,6 +31,29 @@ export const HighlightStyle = () => (

); -HighlightStyle.story = { - name: 'custom style', +export const HighlightClassName = () => ( +

+ +

+); + +export const MutilpleSearchWords = () => { + return ( + + ) }; + diff --git a/packages/semi-ui/highlight/index.tsx b/packages/semi-ui/highlight/index.tsx index 6a6d7e8c18..25a8a59513 100644 --- a/packages/semi-ui/highlight/index.tsx +++ b/packages/semi-ui/highlight/index.tsx @@ -2,14 +2,32 @@ import React, { PureComponent } from 'react'; import cls from 'classnames'; import PropTypes, { string } from 'prop-types'; import { cssClasses } from '@douyinfe/semi-foundation/highlight/constants'; -import { getHighLightTextHTML } from '../_utils/index'; +import HighlightFoundation from '@douyinfe/semi-foundation/highlight/foundation'; +import type { SearchWords, Chunk } from '@douyinfe/semi-foundation/highlight/foundation'; + import '@douyinfe/semi-foundation/highlight/highlight.scss'; +interface GetHighLightTextHTMLProps { + sourceString?: string; + searchWords?: SearchWords; + option: HighLightTextHTMLOption +} + +interface HighLightTextHTMLOption { + highlightTag?: string; + highlightClassName?: string; + highlightStyle?: React.CSSProperties; + caseSensitive: boolean; + autoEscape: boolean +} + +interface HighLightTextHTMLChunk extends Chunk { } + export interface HighlightProps { autoEscape?: boolean; caseSensitive?: boolean; sourceString?: string; - searchWords?: Array; + searchWords?: SearchWords; highlightStyle?: React.CSSProperties; highlightClassName?: string; component?: string @@ -38,6 +56,34 @@ class Highlight extends PureComponent { sourceString: '', }; + getHighLightTextHTML = ({ + sourceString = '', + searchWords = [], + option = { autoEscape: true, caseSensitive: false } + }: GetHighLightTextHTMLProps) => { + const chunks: HighLightTextHTMLChunk[] = new HighlightFoundation().findAll({ sourceString, searchWords, ...option }); + const markEle = option.highlightTag || 'mark'; + const highlightClassName = option.highlightClassName || ''; + const highlightStyle = option.highlightStyle || {}; + return chunks.map((chunk: HighLightTextHTMLChunk, index: number) => { + const { end, start, highlight, style, className } = chunk; + const text = sourceString.substr(start, end - start); + if (highlight) { + return React.createElement( + markEle, + { + style: { ...highlightStyle, ...style }, + className: `${highlightClassName} ${className || ''}`.trim(), + key: text + index + }, + text + ); + } else { + return text; + } + }); + }; + render() { const { searchWords, @@ -62,7 +108,7 @@ class Highlight extends PureComponent { }; return ( - getHighLightTextHTML({ sourceString, searchWords, option }) + this.getHighLightTextHTML({ sourceString, searchWords, option }) ); } } diff --git a/packages/semi-ui/select/option.tsx b/packages/semi-ui/select/option.tsx index 379c9bb1ff..fa6d0963e4 100644 --- a/packages/semi-ui/select/option.tsx +++ b/packages/semi-ui/select/option.tsx @@ -5,7 +5,7 @@ import { isString } from 'lodash'; import { cssClasses } from '@douyinfe/semi-foundation/select/constants'; import LocaleConsumer from '../locale/localeConsumer'; import { IconTick } from '@douyinfe/semi-icons'; -import { getHighLightTextHTML } from '../_utils/index'; +import Highlight, { HighlightProps } from '../highlight'; import { Locale } from '../locale/interface'; import getDataAttr from '@douyinfe/semi-foundation/utils/getDataAttr'; import type { BasicOptionProps } from '@douyinfe/semi-foundation/select/optionFoundation'; @@ -20,15 +20,6 @@ export interface OptionProps extends BasicOptionProps { className?: string; style?: React.CSSProperties } -interface renderOptionContentArgument { - config: { - searchWords: any; - sourceString: React.ReactNode - }; - children: React.ReactNode; - inputValue: string; - prefixCls: string -} class Option extends PureComponent { static isSelectOption = true; @@ -63,9 +54,15 @@ class Option extends PureComponent { } } - renderOptionContent({ config, children, inputValue, prefixCls }: renderOptionContentArgument) { + renderOptionContent({ config, children, inputValue, prefixCls }) { if (isString(children) && inputValue) { - return getHighLightTextHTML(config as any); + return ( + + ); } return children; } @@ -139,12 +136,11 @@ class Option extends PureComponent { } const config = { - searchWords: inputValue, + searchWords: [inputValue], sourceString: children, - option: { - highlightClassName: `${prefixCls}-keyword` - } + highlightClassName: `${prefixCls}-keyword` }; + return ( // eslint-disable-next-line jsx-a11y/interactive-supports-focus,jsx-a11y/click-events-have-key-events
+ ); } else { return label; }