Skip to content

Commit

Permalink
Merge pull request #1397 from oasisprotocol/csillag/adaptive-text-sho…
Browse files Browse the repository at this point in the history
…rtener-and-highlighter

Add dynamically resizing label with highlight, for adaptively  filling up horizontal space
  • Loading branch information
csillag authored May 10, 2024
2 parents a6f22f1 + a6f8cc8 commit 454f3eb
Show file tree
Hide file tree
Showing 9 changed files with 411 additions and 146 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
154 changes: 154 additions & 0 deletions src/app/components/AdaptiveTrimmer/AdaptiveDynamicTrimmer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import Box from '@mui/material/Box'
import InfoIcon from '@mui/icons-material/Info'
import { MaybeWithTooltip } from './MaybeWithTooltip'

type AdaptiveDynamicTrimmerProps = {
getFullContent: () => {
content: ReactNode
length: number
}
getShortenedContent: (wantedLength: number) => ReactNode
extraTooltip: ReactNode
}

/**
* Display content, potentially shortened as needed.
*
* This component will do automatic detection of available space,
* and determine the best way to display content accordingly.
*
* The difference compared to AdaptiveTrimmer is that this component
* expects a function to provide a shortened version of the components.
*/
export const AdaptiveDynamicTrimmer: FC<AdaptiveDynamicTrimmerProps> = ({
getFullContent,
getShortenedContent,
extraTooltip,
}) => {
// Initial setup
const textRef = useRef<HTMLDivElement | null>(null)
const { content: fullContent, length: fullLength } = getFullContent()

// Data about the currently rendered version
const [currentContent, setCurrentContent] = useState<ReactNode>()
const [currentLength, setCurrentLength] = useState(0)

// Known good - this fits
const [largestKnownGood, setLargestKnownGood] = useState(0)

// Known bad - this doesn't fit
const [smallestKnownBad, setSmallestKnownBad] = useState(fullLength + 1)

// Are we exploring our possibilities now?
const [inDiscovery, setInDiscovery] = useState(false)

const attemptContent = useCallback((content: ReactNode, length: number) => {
setCurrentContent(content)
setCurrentLength(length)
}, [])

const attemptShortenedContent = useCallback(
(length: number) => {
const content = getShortenedContent(length)

attemptContent(content, length)
},
[attemptContent, getShortenedContent],
)

const initDiscovery = useCallback(() => {
setLargestKnownGood(0)
setSmallestKnownBad(fullLength + 1)
attemptContent(fullContent, fullLength)
setInDiscovery(true)
}, [fullContent, fullLength, attemptContent])

useEffect(() => {
initDiscovery()
const handleResize = () => {
initDiscovery()
}

window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [initDiscovery])

useEffect(() => {
if (inDiscovery) {
if (!textRef.current) {
return
}
const isOverflow = textRef.current.scrollWidth > textRef.current.clientWidth

if (isOverflow) {
// This is too much

// Update known bad length
const newSmallestKnownBad = Math.min(currentLength, smallestKnownBad)
setSmallestKnownBad(newSmallestKnownBad)

// We should try something smaller
attemptShortenedContent(Math.floor((largestKnownGood + newSmallestKnownBad) / 2))
} else {
// This is OK

// Update known good length
const newLargestKnownGood = Math.max(currentLength, largestKnownGood)
setLargestKnownGood(currentLength)

if (currentLength === fullLength) {
// The whole thing fits, so we are good.
setInDiscovery(false)
} else {
if (currentLength + 1 === smallestKnownBad) {
// This the best we can do, for now
setInDiscovery(false)
} else {
// So far, so good, but we should try something longer
attemptShortenedContent(Math.floor((newLargestKnownGood + smallestKnownBad) / 2))
}
}
}
}
}, [
attemptShortenedContent,
currentLength,
fullContent,
fullLength,
inDiscovery,
initDiscovery,
largestKnownGood,
smallestKnownBad,
])

const title =
currentLength !== fullLength ? (
<Box>
<Box>{fullContent}</Box>
{extraTooltip && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
<InfoIcon />
{extraTooltip}
</Box>
)}
</Box>
) : (
extraTooltip
)

return (
<Box
ref={textRef}
sx={{
overflow: 'hidden',
maxWidth: '100%',
textWrap: 'nowrap',
}}
>
<MaybeWithTooltip title={title} spanSx={{ whiteSpace: 'nowrap' }}>
{currentContent}
</MaybeWithTooltip>
</Box>
)
}
142 changes: 16 additions & 126 deletions src/app/components/AdaptiveTrimmer/AdaptiveTrimmer.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import Box from '@mui/material/Box'
import InfoIcon from '@mui/icons-material/Info'
import { trimLongString } from '../../utils/trimLongString'
import { MaybeWithTooltip } from './MaybeWithTooltip'
import { FC, ReactNode } from 'react'
import { AdaptiveDynamicTrimmer } from './AdaptiveDynamicTrimmer'
import { trimLongString } from 'app/utils/trimLongString'

type AdaptiveTrimmerProps = {
text: string | undefined
Expand All @@ -15,126 +13,18 @@ type AdaptiveTrimmerProps = {
*
* This component will do automatic detection of available space,
* and determine the best way to display content accordingly.
*
* The implementation is based on AdaptiveDynamicTrimmer,
* supplying it with a generator function which simply trims the given text to the wanted length.
*/
export const AdaptiveTrimmer: FC<AdaptiveTrimmerProps> = ({ text = '', strategy = 'end', extraTooltip }) => {
// Initial setup
const fullLength = text.length
const textRef = useRef<HTMLDivElement | null>(null)

// Data about the currently rendered version
const [currentContent, setCurrentContent] = useState('')
const [currentLength, setCurrentLength] = useState(0)

// Known good - this fits
const [largestKnownGood, setLargestKnownGood] = useState(0)

// Known bad - this doesn't fit
const [smallestKnownBad, setSmallestKnownBad] = useState(fullLength + 1)

// Are we exploring our possibilities now?
const [inDiscovery, setInDiscovery] = useState(false)

const attemptContent = useCallback((content: string, length: number) => {
setCurrentContent(content)
setCurrentLength(length)
}, [])

const attemptShortenedContent = useCallback(
(length: number) => {
const content =
strategy === 'middle'
? trimLongString(text, Math.floor(length / 2) - 1, Math.floor(length / 2) - 1)!
: trimLongString(text, length, 0)!

attemptContent(content, length)
},
[strategy, text, attemptContent],
)

const initDiscovery = useCallback(() => {
setLargestKnownGood(0)
setSmallestKnownBad(fullLength + 1)
attemptContent(text, fullLength)
setInDiscovery(true)
}, [text, fullLength, attemptContent])

useEffect(() => {
initDiscovery()
const handleResize = () => {
initDiscovery()
}

window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [initDiscovery])

useEffect(() => {
if (inDiscovery) {
if (!textRef.current) {
return
}
const isOverflow = textRef.current.scrollWidth > textRef.current.clientWidth

if (isOverflow) {
// This is too much

// Update known bad length
const newSmallestKnownBad = Math.min(currentLength, smallestKnownBad)
setSmallestKnownBad(newSmallestKnownBad)

// We should try something smaller
attemptShortenedContent(Math.floor((largestKnownGood + newSmallestKnownBad) / 2))
} else {
// This is OK

// Update known good length
const newLargestKnownGood = Math.max(currentLength, largestKnownGood)
setLargestKnownGood(currentLength)

if (currentLength === fullLength) {
// The whole thing fits, so we are good.
setInDiscovery(false)
} else {
if (currentLength + 1 === smallestKnownBad) {
// This the best we can do, for now
setInDiscovery(false)
} else {
// So far, so good, but we should try something longer
attemptShortenedContent(Math.floor((newLargestKnownGood + smallestKnownBad) / 2))
}
}
}
export const AdaptiveTrimmer: FC<AdaptiveTrimmerProps> = ({ text = '', strategy = 'end', extraTooltip }) => (
<AdaptiveDynamicTrimmer
getFullContent={() => ({ content: text, length: text.length })}
getShortenedContent={length =>
strategy === 'middle'
? trimLongString(text, Math.floor(length / 2) - 1, Math.floor(length / 2) - 1)!
: trimLongString(text, length, 0)!
}
}, [inDiscovery, currentLength, largestKnownGood, smallestKnownBad, attemptShortenedContent, fullLength])

if (!text) return null

const title =
currentLength !== fullLength ? (
<Box>
<Box>{text}</Box>
{extraTooltip && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
<InfoIcon />
{extraTooltip}
</Box>
)}
</Box>
) : (
extraTooltip
)

return (
<Box
ref={textRef}
sx={{
overflow: 'hidden',
maxWidth: '100%',
}}
>
<MaybeWithTooltip title={title} spanSx={{ whiteSpace: 'nowrap' }}>
{currentContent}
</MaybeWithTooltip>
</Box>
)
}
extraTooltip={extraTooltip}
/>
)
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
}
Loading

0 comments on commit 454f3eb

Please sign in to comment.