diff --git a/src/components/Highlight.tsx b/src/components/Highlight.tsx index 820a294..cb91e29 100644 --- a/src/components/Highlight.tsx +++ b/src/components/Highlight.tsx @@ -1,6 +1,9 @@ import * as React from 'react' -export function Highlight(props: { text: string; match?: RegExp | string }) { +export const Highlight = React.memo(function Highlight(props: { + text: string + match?: RegExp | string +}) { const { text, match } = props const $match = React.useMemo(() => { if (match) { @@ -13,23 +16,32 @@ export function Highlight(props: { text: string; match?: RegExp | string }) { return null }, [match]) - if (!$match) return <>{text} + const chunks = useChunks(text, $match) + + return <>{chunks.map(([type, text], key) => React.createElement(type, { key }, text))} +}) + +function useChunks(text: string, $match: RegExp | null) { + const contents: [string, string][] = [] + if ($match === null) return [['span', text]] const matchedPieces = Array.from(text.matchAll($match)).map( ([text, highlightText = text]) => highlightText, ) const preservedPieces = text.split($match) - const content = [] - let i = 0 - while (matchedPieces.length || preservedPieces.length) { - if (preservedPieces.length) { - content.push({preservedPieces.shift()}) - } - if (matchedPieces.length) { - content.push({matchedPieces.shift()}) - } + const push = (type: string, text: string) => { + if (!text) return + const last = contents[contents.length - 1] + if (last && last[0] === type) last[1] += text + else contents.push([type, text]) } - return <>{content} + let i = 0 + while (i < Math.max(matchedPieces.length, preservedPieces.length)) { + push('span', preservedPieces[i]) + push('mark', matchedPieces[i]) + ++i + } + return contents } diff --git a/src/components/HighlightOnIndexes.test.tsx b/src/components/HighlightOnIndexes.test.tsx new file mode 100644 index 0000000..1ffd64f --- /dev/null +++ b/src/components/HighlightOnIndexes.test.tsx @@ -0,0 +1,27 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { is } from 'utils/is' +import { HighlightOnIndexes } from './HighlightOnIndexes' + +function test(title: string, text: string, indexes?: number[]) { + it(title, () => { + expect(render().container.textContent).toBe( + text, + ) + }) +} + +const text = 'abcdef' +for (let i = 0; i < Math.pow(2, text.length); i++) { + const bitwise = i.toString(2) + const indexes = bitwise + .split('') + .reverse() + .map((bit, index) => (bit === '1' ? index : false)) + .filter(is.not.false) + test( + `renders properly when highlight ${bitwise.padStart(text.length, '0')}, indexes [${indexes}]`, + text, + indexes, + ) +} diff --git a/src/components/HighlightOnIndexes.tsx b/src/components/HighlightOnIndexes.tsx index f8a21f4..c31ac2b 100644 --- a/src/components/HighlightOnIndexes.tsx +++ b/src/components/HighlightOnIndexes.tsx @@ -1,17 +1,28 @@ -import * as React from 'react' - -export function HighlightOnIndexes(props: { text: string; indexes?: number[] }) { - const { text, indexes } = props - - if (!indexes?.length) return <>{text} +import * as React from 'react'; +export function HighlightOnIndexes({ text, indexes = [] }: { text: string; indexes?: number[] }) { return ( <> - {text - .split('') - .map((char, i) => - indexes.includes(i) ? {char} : {char}, - )} + {[-1] + .concat(indexes) + .map((index, i, arr) => [ + index === -1 ? '' : text.slice(index, index + 1), + text.slice(index + 1, arr[i + 1]), + ]) + .reduce((arr, pair) => { + const last = arr[arr.length - 1] + if (last && !last[1]) { + last[0] += pair[0] + last[1] += pair[1] + } else { + arr.push(pair) + } + return arr + }, [] as string[][]) + .map(([chunk, nextChunk], i) => [ + chunk && {chunk}, + nextChunk && {nextChunk}, + ])} ) } diff --git a/src/utils/general.ts b/src/utils/general.ts index 850244b..0b6925f 100644 --- a/src/utils/general.ts +++ b/src/utils/general.ts @@ -1,5 +1,6 @@ import { ReactElement } from 'react' import * as ReactDOM from 'react-dom' +import { is } from './is' export function pick(source: T, keys: string[]): Partial { if (keys && typeof keys === 'object') { @@ -129,12 +130,20 @@ export async function JSONRequest( ).json() } -export function searchKeyToRegexp(searchKey: string) { - if (!searchKey) return null - - return safeRegexp(searchKey, hasUpperCase(searchKey) ? 'g' : 'gi') +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function memoize(fn: (...args: Args) => R): (...args: Args) => R { + let lastArgs: Args | null = null + let lastR: R | null = null + return (...args) => { + if (lastArgs && is.shallowEqual.array(lastArgs, args)) return lastR as R + return (lastR = fn(...(lastArgs = args))) + } } +export const searchKeyToRegexp = memoize((searchKey: string) => + searchKey ? safeRegexp(searchKey, hasUpperCase(searchKey) ? 'g' : 'gi') : null, +) + export function hasUpperCase(input: string) { return /[A-Z]/.test(input) }