Skip to content

Commit 454f3eb

Browse files
authored
Merge pull request #1397 from oasisprotocol/csillag/adaptive-text-shortener-and-highlighter
Add dynamically resizing label with highlight, for adaptively filling up horizontal space
2 parents a6f22f1 + a6f8cc8 commit 454f3eb

9 files changed

Lines changed: 411 additions & 146 deletions

File tree

.changelog/1397.trivial.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Use adaptive method when trimming texts with highlights
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react'
2+
import Box from '@mui/material/Box'
3+
import InfoIcon from '@mui/icons-material/Info'
4+
import { MaybeWithTooltip } from './MaybeWithTooltip'
5+
6+
type AdaptiveDynamicTrimmerProps = {
7+
getFullContent: () => {
8+
content: ReactNode
9+
length: number
10+
}
11+
getShortenedContent: (wantedLength: number) => ReactNode
12+
extraTooltip: ReactNode
13+
}
14+
15+
/**
16+
* Display content, potentially shortened as needed.
17+
*
18+
* This component will do automatic detection of available space,
19+
* and determine the best way to display content accordingly.
20+
*
21+
* The difference compared to AdaptiveTrimmer is that this component
22+
* expects a function to provide a shortened version of the components.
23+
*/
24+
export const AdaptiveDynamicTrimmer: FC<AdaptiveDynamicTrimmerProps> = ({
25+
getFullContent,
26+
getShortenedContent,
27+
extraTooltip,
28+
}) => {
29+
// Initial setup
30+
const textRef = useRef<HTMLDivElement | null>(null)
31+
const { content: fullContent, length: fullLength } = getFullContent()
32+
33+
// Data about the currently rendered version
34+
const [currentContent, setCurrentContent] = useState<ReactNode>()
35+
const [currentLength, setCurrentLength] = useState(0)
36+
37+
// Known good - this fits
38+
const [largestKnownGood, setLargestKnownGood] = useState(0)
39+
40+
// Known bad - this doesn't fit
41+
const [smallestKnownBad, setSmallestKnownBad] = useState(fullLength + 1)
42+
43+
// Are we exploring our possibilities now?
44+
const [inDiscovery, setInDiscovery] = useState(false)
45+
46+
const attemptContent = useCallback((content: ReactNode, length: number) => {
47+
setCurrentContent(content)
48+
setCurrentLength(length)
49+
}, [])
50+
51+
const attemptShortenedContent = useCallback(
52+
(length: number) => {
53+
const content = getShortenedContent(length)
54+
55+
attemptContent(content, length)
56+
},
57+
[attemptContent, getShortenedContent],
58+
)
59+
60+
const initDiscovery = useCallback(() => {
61+
setLargestKnownGood(0)
62+
setSmallestKnownBad(fullLength + 1)
63+
attemptContent(fullContent, fullLength)
64+
setInDiscovery(true)
65+
}, [fullContent, fullLength, attemptContent])
66+
67+
useEffect(() => {
68+
initDiscovery()
69+
const handleResize = () => {
70+
initDiscovery()
71+
}
72+
73+
window.addEventListener('resize', handleResize)
74+
return () => window.removeEventListener('resize', handleResize)
75+
}, [initDiscovery])
76+
77+
useEffect(() => {
78+
if (inDiscovery) {
79+
if (!textRef.current) {
80+
return
81+
}
82+
const isOverflow = textRef.current.scrollWidth > textRef.current.clientWidth
83+
84+
if (isOverflow) {
85+
// This is too much
86+
87+
// Update known bad length
88+
const newSmallestKnownBad = Math.min(currentLength, smallestKnownBad)
89+
setSmallestKnownBad(newSmallestKnownBad)
90+
91+
// We should try something smaller
92+
attemptShortenedContent(Math.floor((largestKnownGood + newSmallestKnownBad) / 2))
93+
} else {
94+
// This is OK
95+
96+
// Update known good length
97+
const newLargestKnownGood = Math.max(currentLength, largestKnownGood)
98+
setLargestKnownGood(currentLength)
99+
100+
if (currentLength === fullLength) {
101+
// The whole thing fits, so we are good.
102+
setInDiscovery(false)
103+
} else {
104+
if (currentLength + 1 === smallestKnownBad) {
105+
// This the best we can do, for now
106+
setInDiscovery(false)
107+
} else {
108+
// So far, so good, but we should try something longer
109+
attemptShortenedContent(Math.floor((newLargestKnownGood + smallestKnownBad) / 2))
110+
}
111+
}
112+
}
113+
}
114+
}, [
115+
attemptShortenedContent,
116+
currentLength,
117+
fullContent,
118+
fullLength,
119+
inDiscovery,
120+
initDiscovery,
121+
largestKnownGood,
122+
smallestKnownBad,
123+
])
124+
125+
const title =
126+
currentLength !== fullLength ? (
127+
<Box>
128+
<Box>{fullContent}</Box>
129+
{extraTooltip && (
130+
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
131+
<InfoIcon />
132+
{extraTooltip}
133+
</Box>
134+
)}
135+
</Box>
136+
) : (
137+
extraTooltip
138+
)
139+
140+
return (
141+
<Box
142+
ref={textRef}
143+
sx={{
144+
overflow: 'hidden',
145+
maxWidth: '100%',
146+
textWrap: 'nowrap',
147+
}}
148+
>
149+
<MaybeWithTooltip title={title} spanSx={{ whiteSpace: 'nowrap' }}>
150+
{currentContent}
151+
</MaybeWithTooltip>
152+
</Box>
153+
)
154+
}
Lines changed: 16 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react'
2-
import Box from '@mui/material/Box'
3-
import InfoIcon from '@mui/icons-material/Info'
4-
import { trimLongString } from '../../utils/trimLongString'
5-
import { MaybeWithTooltip } from './MaybeWithTooltip'
1+
import { FC, ReactNode } from 'react'
2+
import { AdaptiveDynamicTrimmer } from './AdaptiveDynamicTrimmer'
3+
import { trimLongString } from 'app/utils/trimLongString'
64

75
type AdaptiveTrimmerProps = {
86
text: string | undefined
@@ -15,126 +13,18 @@ type AdaptiveTrimmerProps = {
1513
*
1614
* This component will do automatic detection of available space,
1715
* and determine the best way to display content accordingly.
16+
*
17+
* The implementation is based on AdaptiveDynamicTrimmer,
18+
* supplying it with a generator function which simply trims the given text to the wanted length.
1819
*/
19-
export const AdaptiveTrimmer: FC<AdaptiveTrimmerProps> = ({ text = '', strategy = 'end', extraTooltip }) => {
20-
// Initial setup
21-
const fullLength = text.length
22-
const textRef = useRef<HTMLDivElement | null>(null)
23-
24-
// Data about the currently rendered version
25-
const [currentContent, setCurrentContent] = useState('')
26-
const [currentLength, setCurrentLength] = useState(0)
27-
28-
// Known good - this fits
29-
const [largestKnownGood, setLargestKnownGood] = useState(0)
30-
31-
// Known bad - this doesn't fit
32-
const [smallestKnownBad, setSmallestKnownBad] = useState(fullLength + 1)
33-
34-
// Are we exploring our possibilities now?
35-
const [inDiscovery, setInDiscovery] = useState(false)
36-
37-
const attemptContent = useCallback((content: string, length: number) => {
38-
setCurrentContent(content)
39-
setCurrentLength(length)
40-
}, [])
41-
42-
const attemptShortenedContent = useCallback(
43-
(length: number) => {
44-
const content =
45-
strategy === 'middle'
46-
? trimLongString(text, Math.floor(length / 2) - 1, Math.floor(length / 2) - 1)!
47-
: trimLongString(text, length, 0)!
48-
49-
attemptContent(content, length)
50-
},
51-
[strategy, text, attemptContent],
52-
)
53-
54-
const initDiscovery = useCallback(() => {
55-
setLargestKnownGood(0)
56-
setSmallestKnownBad(fullLength + 1)
57-
attemptContent(text, fullLength)
58-
setInDiscovery(true)
59-
}, [text, fullLength, attemptContent])
60-
61-
useEffect(() => {
62-
initDiscovery()
63-
const handleResize = () => {
64-
initDiscovery()
65-
}
66-
67-
window.addEventListener('resize', handleResize)
68-
return () => window.removeEventListener('resize', handleResize)
69-
}, [initDiscovery])
70-
71-
useEffect(() => {
72-
if (inDiscovery) {
73-
if (!textRef.current) {
74-
return
75-
}
76-
const isOverflow = textRef.current.scrollWidth > textRef.current.clientWidth
77-
78-
if (isOverflow) {
79-
// This is too much
80-
81-
// Update known bad length
82-
const newSmallestKnownBad = Math.min(currentLength, smallestKnownBad)
83-
setSmallestKnownBad(newSmallestKnownBad)
84-
85-
// We should try something smaller
86-
attemptShortenedContent(Math.floor((largestKnownGood + newSmallestKnownBad) / 2))
87-
} else {
88-
// This is OK
89-
90-
// Update known good length
91-
const newLargestKnownGood = Math.max(currentLength, largestKnownGood)
92-
setLargestKnownGood(currentLength)
93-
94-
if (currentLength === fullLength) {
95-
// The whole thing fits, so we are good.
96-
setInDiscovery(false)
97-
} else {
98-
if (currentLength + 1 === smallestKnownBad) {
99-
// This the best we can do, for now
100-
setInDiscovery(false)
101-
} else {
102-
// So far, so good, but we should try something longer
103-
attemptShortenedContent(Math.floor((newLargestKnownGood + smallestKnownBad) / 2))
104-
}
105-
}
106-
}
20+
export const AdaptiveTrimmer: FC<AdaptiveTrimmerProps> = ({ text = '', strategy = 'end', extraTooltip }) => (
21+
<AdaptiveDynamicTrimmer
22+
getFullContent={() => ({ content: text, length: text.length })}
23+
getShortenedContent={length =>
24+
strategy === 'middle'
25+
? trimLongString(text, Math.floor(length / 2) - 1, Math.floor(length / 2) - 1)!
26+
: trimLongString(text, length, 0)!
10727
}
108-
}, [inDiscovery, currentLength, largestKnownGood, smallestKnownBad, attemptShortenedContent, fullLength])
109-
110-
if (!text) return null
111-
112-
const title =
113-
currentLength !== fullLength ? (
114-
<Box>
115-
<Box>{text}</Box>
116-
{extraTooltip && (
117-
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
118-
<InfoIcon />
119-
{extraTooltip}
120-
</Box>
121-
)}
122-
</Box>
123-
) : (
124-
extraTooltip
125-
)
126-
127-
return (
128-
<Box
129-
ref={textRef}
130-
sx={{
131-
overflow: 'hidden',
132-
maxWidth: '100%',
133-
}}
134-
>
135-
<MaybeWithTooltip title={title} spanSx={{ whiteSpace: 'nowrap' }}>
136-
{currentContent}
137-
</MaybeWithTooltip>
138-
</Box>
139-
)
140-
}
28+
extraTooltip={extraTooltip}
29+
/>
30+
)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { FC, ReactNode } from 'react'
2+
import InfoIcon from '@mui/icons-material/Info'
3+
import { HighlightedText, HighlightOptions } from './index'
4+
import { AdaptiveDynamicTrimmer } from '../AdaptiveTrimmer/AdaptiveDynamicTrimmer'
5+
import { HighlightedTrimmedText } from './HighlightedTrimmedText'
6+
7+
type AdaptiveHighlightedTextProps = {
8+
/**
9+
* The text to display
10+
*/
11+
text: string | undefined
12+
13+
/**
14+
* The pattern to search for (and highlight)
15+
*/
16+
pattern: string | undefined
17+
18+
/**
19+
* Options for highlighting (case sensitivity, styling, etc.)
20+
*
21+
* (This is optional, sensible defaults are provided.)
22+
*/
23+
options?: HighlightOptions
24+
25+
/**
26+
* Extra content to put into the tooltip
27+
*/
28+
extraTooltip?: ReactNode
29+
}
30+
31+
/**
32+
* Display a text with a part highlighted, adaptively trimmed to the maximum length around the highlight
33+
*/
34+
export const AdaptiveHighlightedText: FC<AdaptiveHighlightedTextProps> = ({
35+
text,
36+
pattern,
37+
options,
38+
extraTooltip,
39+
}) => {
40+
const fullContent = <HighlightedText text={text} pattern={pattern} options={options} />
41+
42+
return text ? (
43+
<AdaptiveDynamicTrimmer
44+
getFullContent={() => ({
45+
content: fullContent,
46+
length: text.length,
47+
})}
48+
getShortenedContent={wantedLength => (
49+
<HighlightedTrimmedText
50+
fragmentLength={wantedLength}
51+
text={text}
52+
pattern={pattern}
53+
options={options}
54+
/>
55+
)}
56+
extraTooltip={
57+
extraTooltip ? (
58+
<>
59+
<InfoIcon />
60+
{extraTooltip}
61+
</>
62+
) : undefined
63+
}
64+
/>
65+
) : undefined
66+
}

0 commit comments

Comments
 (0)