Skip to content

Commit 406f249

Browse files
committed
Make adaptive trimmer more resilient
Up to now, the adaptive trimmer needed to be inserted in a DOM element with a fixed width, and overflow-X set to hidden. (This was required so that it can find the best size to fill it, detecting overflow.) This was fragile, because it relied on all parent components being configured in a particular way. This changes makes it so that we can detect overflow recursively on any of the parent elements, which means that there are no restrictions on parent configuration. List of technical changes: - New method for detecting overflow (on all parent elements) - Check overflow status on short string as baseline, and then compare the situation with a longer string - Introduce debug logging (on demand) - Introduce a context-sensitive minimum length - Support shortener functions that return a different length than requested - Make sure to never elongate instead of shortening - Reduce jumpy behavior (by hiding content during size discovery) - Move the logic to a separate hook - Introduce singleton controller, injected as context - Set minimum lengths where required - Add more extensive debug output - Use more descriptive names for debugging - Solve some wrapping issues related to highlighting
1 parent fdd262e commit 406f249

File tree

15 files changed

+625
-161
lines changed

15 files changed

+625
-161
lines changed

.changelog/1963.trivial.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Make adaptive trimmer more resilient

src/app/components/Account/AccountLink.tsx

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,11 @@ const WithTypographyAndLink: FC<{
2222
mobile?: boolean
2323
children: ReactNode
2424
labelOnly?: boolean
25-
}> = ({ scope, address, children, mobile, labelOnly }) => {
25+
}> = ({ scope, address, children, labelOnly }) => {
2626
const to = RouteUtils.getAccountRoute(scope, address)
2727
return (
2828
<WithHighlighting address={address}>
29-
<Typography
30-
variant="mono"
31-
component="span"
32-
sx={{
33-
...(mobile
34-
? {
35-
maxWidth: '100%',
36-
overflow: 'hidden',
37-
}
38-
: {}),
39-
}}
40-
>
29+
<Typography variant="mono" component="span">
4130
{labelOnly ? (
4231
children
4332
) : (
@@ -186,13 +175,21 @@ export const AccountLink: FC<Props> = ({
186175
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
187176
<AccountMetadataSourceIndicator source={accountMetadata.source} />
188177
<AdaptiveHighlightedText
178+
idPrefix="account-name"
189179
text={accountName}
190180
pattern={highlightedPartOfName}
191181
extraTooltip={tooltipTitle}
182+
minLength={5}
192183
/>
193184
</Box>
194185
)}
195-
<AdaptiveTrimmer text={address} strategy="middle" tooltipOverride={tooltipTitle} />
186+
<AdaptiveTrimmer
187+
idPrefix="account-address"
188+
text={address}
189+
strategy="middle"
190+
tooltipOverride={tooltipTitle}
191+
minLength={13}
192+
/>
196193
</>
197194
</WithTypographyAndLink>
198195
)

src/app/components/AdaptiveTrimmer/AdaptiveDynamicTrimmer.tsx

Lines changed: 56 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,34 @@
1-
import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react'
1+
import { FC, ReactNode, useMemo } from 'react'
22
import Box from '@mui/material/Box'
33
import InfoIcon from '@mui/icons-material/Info'
44
import { MaybeWithTooltip } from '../Tooltip/MaybeWithTooltip'
5+
import { getAdaptiveId, ShorteningResult, useAdaptiveSizing } from './hooks'
56

67
type AdaptiveDynamicTrimmerProps = {
8+
/**
9+
* The ID (prefix) used for debugging
10+
*/
11+
idPrefix: string
12+
13+
/**
14+
* A function to return the full content
15+
*/
716
getFullContent: () => {
817
content: ReactNode
918
length: number
1019
}
11-
getShortenedContent: (wantedLength: number) => ReactNode
20+
21+
/**
22+
* A function to return shortened content
23+
*/
24+
getShortenedContent: (wantedLength: number) => ShorteningResult
25+
26+
/**
27+
* The minimum length we ever want to shorten to.
28+
*
29+
* Default is 2
30+
*/
31+
minLength?: number
1232

1333
/**
1434
* Normally, the tooltip will be the full content. Do you want to add something?
@@ -19,6 +39,11 @@ type AdaptiveDynamicTrimmerProps = {
1939
* Normally, the tooltip will be the full content. Do you want to replace it with something else?
2040
*/
2141
tooltipOverride?: ReactNode
42+
43+
/**
44+
* Do we want to see debug output about the adaptive trimming process?
45+
*/
46+
debugMode?: boolean
2247
}
2348

2449
/**
@@ -31,132 +56,44 @@ type AdaptiveDynamicTrimmerProps = {
3156
* expects a function to provide a shortened version of the components.
3257
*/
3358
export const AdaptiveDynamicTrimmer: FC<AdaptiveDynamicTrimmerProps> = ({
59+
idPrefix,
3460
getFullContent,
3561
getShortenedContent,
3662
extraTooltip,
3763
tooltipOverride,
64+
debugMode = false,
65+
minLength = 2,
3866
}) => {
39-
// Initial setup
40-
const textRef = useRef<HTMLDivElement | null>(null)
41-
const { content: fullContent, length: fullLength } = getFullContent()
42-
43-
// Data about the currently rendered version
44-
const [currentContent, setCurrentContent] = useState<ReactNode>()
45-
const [currentLength, setCurrentLength] = useState(0)
46-
47-
// Known good - this fits
48-
const [largestKnownGood, setLargestKnownGood] = useState(0)
49-
50-
// Known bad - this doesn't fit
51-
const [smallestKnownBad, setSmallestKnownBad] = useState(fullLength + 1)
52-
53-
// Are we exploring our possibilities now?
54-
const [inDiscovery, setInDiscovery] = useState(false)
55-
56-
const attemptContent = useCallback((content: ReactNode, length: number) => {
57-
setCurrentContent(content)
58-
setCurrentLength(length)
59-
}, [])
60-
61-
const attemptShortenedContent = useCallback(
62-
(length: number) => {
63-
const content = getShortenedContent(length)
64-
65-
attemptContent(content, length)
66-
},
67-
[attemptContent, getShortenedContent],
67+
const id = useMemo(() => getAdaptiveId(idPrefix), [idPrefix])
68+
69+
const { currentContent, fullContent, textRef, isTruncated, isFinal } = useAdaptiveSizing(
70+
id,
71+
getFullContent,
72+
getShortenedContent,
73+
debugMode,
74+
minLength,
6875
)
6976

70-
const initDiscovery = useCallback(() => {
71-
setLargestKnownGood(0)
72-
setSmallestKnownBad(fullLength + 1)
73-
attemptContent(fullContent, fullLength)
74-
setInDiscovery(true)
75-
}, [fullContent, fullLength, attemptContent])
76-
77-
useEffect(() => {
78-
initDiscovery()
79-
const handleResize = () => {
80-
initDiscovery()
81-
}
82-
83-
window.addEventListener('resize', handleResize)
84-
return () => window.removeEventListener('resize', handleResize)
85-
}, [initDiscovery])
86-
87-
useEffect(() => {
88-
if (inDiscovery) {
89-
if (!textRef.current) {
90-
return
91-
}
92-
const isOverflow = textRef.current.scrollWidth > textRef.current.clientWidth
93-
94-
if (isOverflow) {
95-
// This is too much
96-
97-
// Update known bad length
98-
const newSmallestKnownBad = Math.min(currentLength, smallestKnownBad)
99-
setSmallestKnownBad(newSmallestKnownBad)
100-
101-
// We should try something smaller
102-
attemptShortenedContent(Math.floor((largestKnownGood + newSmallestKnownBad) / 2))
103-
} else {
104-
// This is OK
105-
106-
// Update known good length
107-
const newLargestKnownGood = Math.max(currentLength, largestKnownGood)
108-
setLargestKnownGood(currentLength)
109-
110-
if (currentLength === fullLength) {
111-
// The whole thing fits, so we are good.
112-
setInDiscovery(false)
113-
} else {
114-
if (currentLength + 1 === smallestKnownBad) {
115-
// This the best we can do, for now
116-
setInDiscovery(false)
117-
} else {
118-
// So far, so good, but we should try something longer
119-
attemptShortenedContent(Math.floor((newLargestKnownGood + smallestKnownBad) / 2))
120-
}
121-
}
122-
}
123-
}
124-
}, [
125-
attemptShortenedContent,
126-
currentLength,
127-
fullContent,
128-
fullLength,
129-
inDiscovery,
130-
initDiscovery,
131-
largestKnownGood,
132-
smallestKnownBad,
133-
])
134-
135-
const title =
136-
currentLength !== fullLength ? (
137-
<Box>
138-
<Box>{tooltipOverride ?? fullContent}</Box>
139-
{extraTooltip && (
140-
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
141-
<InfoIcon />
142-
{extraTooltip}
143-
</Box>
144-
)}
145-
</Box>
146-
) : (
147-
extraTooltip
148-
)
77+
const title = isTruncated ? (
78+
<Box>
79+
<Box>{tooltipOverride ?? fullContent}</Box>
80+
{extraTooltip && (
81+
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
82+
<InfoIcon />
83+
{extraTooltip}
84+
</Box>
85+
)}
86+
</Box>
87+
) : (
88+
extraTooltip
89+
)
14990

15091
return (
151-
<Box
152-
ref={textRef}
153-
sx={{
154-
overflow: 'hidden',
155-
maxWidth: '100%',
156-
textWrap: 'nowrap',
157-
}}
158-
>
159-
<MaybeWithTooltip title={title} spanSx={{ whiteSpace: 'nowrap' }}>
92+
<Box component={'span'} ref={textRef} sx={{ maxWidth: '100%', overflowX: 'hidden' }}>
93+
<MaybeWithTooltip
94+
title={title}
95+
spanSx={{ textWrap: 'nowrap', whiteSpace: 'nowrap', opacity: isFinal ? 1 : 0 }}
96+
>
16097
{currentContent}
16198
</MaybeWithTooltip>
16299
</Box>

src/app/components/AdaptiveTrimmer/AdaptiveTrimmer.tsx

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,21 @@ import { AdaptiveDynamicTrimmer } from './AdaptiveDynamicTrimmer'
33
import { trimLongString } from 'app/utils/trimLongString'
44

55
type AdaptiveTrimmerProps = {
6+
/**
7+
* ID prefix to use (for debugging)
8+
*/
9+
idPrefix?: string
10+
611
text: string | undefined
712
strategy: 'middle' | 'end'
813

14+
/**
15+
* The minimum length we ever want to shorten to.
16+
*
17+
* Default is 2
18+
*/
19+
minLength?: number
20+
921
/**
1022
* Normally, the tooltip will be the text. Do you want to add something extra?
1123
*/
@@ -15,6 +27,11 @@ type AdaptiveTrimmerProps = {
1527
* Normally, the tooltip will be the text. Do you want to replace it with something else?
1628
*/
1729
tooltipOverride?: ReactNode
30+
31+
/**
32+
* De we want extra debug output about the sizing process?
33+
*/
34+
debugMode?: boolean
1835
}
1936

2037
/**
@@ -27,19 +44,37 @@ type AdaptiveTrimmerProps = {
2744
* supplying it with a generator function which simply trims the given text to the wanted length.
2845
*/
2946
export const AdaptiveTrimmer: FC<AdaptiveTrimmerProps> = ({
47+
idPrefix = 'adaptive-trimmer',
3048
text = '',
3149
strategy = 'end',
3250
extraTooltip,
3351
tooltipOverride,
34-
}) => (
35-
<AdaptiveDynamicTrimmer
36-
getFullContent={() => ({ content: text, length: text.length })}
37-
getShortenedContent={length =>
38-
strategy === 'middle'
39-
? trimLongString(text, Math.floor(length / 2) - 1, Math.floor(length / 2) - 1)!
40-
: trimLongString(text, length, 0)!
41-
}
42-
tooltipOverride={tooltipOverride}
43-
extraTooltip={extraTooltip}
44-
/>
45-
)
52+
minLength,
53+
debugMode,
54+
}) => {
55+
// console.log('Text', text)
56+
return (
57+
<AdaptiveDynamicTrimmer
58+
idPrefix={idPrefix}
59+
getFullContent={() => ({ content: text, length: text.length })}
60+
getShortenedContent={wantedLength => {
61+
if (wantedLength >= text.length) {
62+
return {
63+
content: text,
64+
length: text.length,
65+
}
66+
}
67+
const content =
68+
strategy === 'middle'
69+
? trimLongString(text, Math.floor(wantedLength / 2), Math.ceil(wantedLength / 2) - 1)!
70+
: trimLongString(text, wantedLength, 0)!
71+
const length = content.length
72+
return { content, length }
73+
}}
74+
minLength={minLength}
75+
tooltipOverride={tooltipOverride}
76+
extraTooltip={extraTooltip}
77+
debugMode={debugMode}
78+
/>
79+
)
80+
}

0 commit comments

Comments
 (0)