Skip to content

Commit

Permalink
Components for displaying shortened text with highlights
Browse files Browse the repository at this point in the history
  • Loading branch information
csillag committed May 9, 2024
1 parent d11b78f commit 3b22b90
Show file tree
Hide file tree
Showing 4 changed files with 184 additions and 0 deletions.
1 change: 1 addition & 0 deletions .changelog/1397.trivial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use adaptive method when trimming texts with highlights
66 changes: 66 additions & 0 deletions src/app/components/HighlightedText/AdaptiveHighlightedText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { FC, ReactNode } from 'react'
import InfoIcon from '@mui/icons-material/Info'
import { HighlightedText, HighlightOptions } from './index'
import { AdaptiveDynamicTrimmer } from '../AdaptiveTrimmer/AdaptiveDynamicTrimmer'
import { HighlightedTrimmedText } from './HighlightedTrimmedText'

type AdaptiveHighlightedTextProps = {
/**
* The text to display
*/
text: string | undefined

/**
* The pattern to search for (and highlight)
*/
pattern: string | undefined

/**
* Options for highlighting (case sensitivity, styling, etc.)
*
* (This is optional, sensible defaults are provided.)
*/
options?: HighlightOptions

/**
* Extra content to put into the tooltip
*/
extraTooltip?: ReactNode
}

/**
* Display a text with a part highlighted, adaptively trimmed to the maximum length around the highlight
*/
export const AdaptiveHighlightedText: FC<AdaptiveHighlightedTextProps> = ({
text,
pattern,
options,
extraTooltip,
}) => {
const fullContent = <HighlightedText text={text} pattern={pattern} options={options} />

return text ? (
<AdaptiveDynamicTrimmer
getFullContent={() => ({
content: fullContent,
length: text.length,
})}
getShortenedContent={wantedLength => (
<HighlightedTrimmedText
fragmentLength={wantedLength}
text={text}
pattern={pattern}
options={options}
/>
)}
extraTooltip={
extraTooltip ? (
<>
<InfoIcon />
{extraTooltip}
</>
) : undefined
}
/>
) : undefined
}
43 changes: 43 additions & 0 deletions src/app/components/HighlightedText/HighlightedTrimmedText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { FC } from 'react'

import { HighlightedText, HighlightOptions } from './index'
import { cutAroundMatch } from './text-cutting'

type HighlightedTrimmedTextProps = {
/**
* The text to display
*/
text: string | undefined

/**
* The pattern to search for (and highlight)
*/
pattern: string | undefined

/**
* Options for highlighting (case sensitivity, styling, etc.)
*
* (This is optional, sensible defaults are provided.)
*/
options?: HighlightOptions

/**
* What should be the length of the fragment delivered, which
* has the pattern inside it?
*/
fragmentLength: number
}

/**
* Display a text with a part highlighted, trimmed to a specific length around the highlight
*/
export const HighlightedTrimmedText: FC<HighlightedTrimmedTextProps> = props => {
const { text, pattern, fragmentLength, options } = props
return (
<HighlightedText
text={cutAroundMatch(text, pattern, { fragmentLength }).part}
pattern={pattern}
options={options}
/>
)
}
74 changes: 74 additions & 0 deletions src/app/components/HighlightedText/text-cutting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { findTextMatch, NormalizerOptions } from './text-matching'

export interface CutAroundOptions extends NormalizerOptions {
/**
* What should be the length of the fragment delivered, which
* has the pattern inside it?
*
* The default value is 80.
*/
fragmentLength?: number
}

/**
* Return a part of the corpus that contains the match to the pattern, if any
*
* If the corpus is undefined or empty, undefined is returned.
*
* If either the pattern is undefined or empty, or there is no match,
* an adequately sized part from the beginning of the corpus is returned.
*
* If there is a match, but the corpus is at most as long as the desired fragment length,
* the whole corpus is returned.
*
* If there is a match, and the corpus is longer than the desired fragment length,
* then a part of a corpus is returned, so that the match is within the returned part,
* around the middle.
*/
export function cutAroundMatch(
corpus: string | undefined,
pattern: string | undefined,
options: CutAroundOptions = {},
): {
hasMatch: boolean
part: string | undefined
} {
const { fragmentLength = 80, ...matchOptions } = options

if (!corpus) {
// there is nothing to see here
return {
hasMatch: false,
part: undefined,
}
}

// do we have a match?
const match = pattern ? findTextMatch(corpus, [pattern], matchOptions) : undefined

if (corpus.length <= fragmentLength) {
// the whole corpus fits into the max size, no need to cut.
return {
hasMatch: !!match,
part: corpus,
}
}

// how much extra space do we have?
const buffer = fragmentLength - (pattern || '').length

const matchStart = match?.startPos ?? 0

// We will start before the start of the match, by buffer / 2 chars
const startPos = Math.max(Math.min(matchStart - Math.floor(buffer / 2), corpus.length - fragmentLength), 0)
const endPos = Math.min(startPos + fragmentLength, corpus.length)

// compile the result
const part =
(startPos ? '…' : '') + corpus.substring(startPos, endPos) + (endPos < corpus.length - 1 ? '…' : '')

return {
hasMatch: true,
part,
}
}

0 comments on commit 3b22b90

Please sign in to comment.