@@ -5,53 +5,84 @@ import React, {
55 memo ,
66 useImperativeHandle ,
77 useLayoutEffect ,
8- useMemo ,
98 useRef ,
109 useState ,
1110} from "react" ;
12- import debounce from "debounce" ;
1311import classnames from "classnames" ;
1412
1513import { useComponentProps } from "../../providers" ;
1614
17- import { Highlight , HighlightProps } from "../Highlight" ;
18-
1915import styles from "./truncate.module.scss" ;
2016
2117export interface TruncateProps extends ComponentProps < "span" > {
2218 text ?: string ;
2319 middle ?: boolean ;
2420 separator ?: string ;
25- highlight ?: Omit < HighlightProps , "textToHighlight" > ;
21+ contentClassname ?: string ;
22+ render ?: ( text : string ) => React . ReactNode ;
2623}
2724
28- const trimMiddle = ( el : HTMLElement , text : string , separator : string ) => {
29- const measure = ( txt : string ) => {
30- el . textContent = txt ;
31- return el . scrollWidth <= el . clientWidth ;
32- } ;
25+ const MAX_CACHE_SIZE = 1000 ;
26+ const cache = new Map < string , string > ( ) ;
27+ let canvas : HTMLCanvasElement | null = null ;
28+
29+ const addToCache = ( key : string , value : string ) => {
30+ if ( cache . size >= MAX_CACHE_SIZE ) {
31+ const oldestKey = cache . keys ( ) . next ( ) . value ;
32+ if ( oldestKey !== undefined ) {
33+ cache . delete ( oldestKey ) ;
34+ }
35+ }
36+ cache . set ( key , value ) ;
37+ } ;
38+
39+ const calculateMiddleTruncate = (
40+ text : string ,
41+ maxWidth : number ,
42+ font : string ,
43+ letterSpacing : string ,
44+ separator : string
45+ ) => {
46+ const cacheKey = `${ text } -${ maxWidth } -${ font } -${ letterSpacing } -${ separator } ` ;
47+ if ( cache . has ( cacheKey ) ) return cache . get ( cacheKey ) ! ;
48+
49+ if ( ! canvas ) {
50+ canvas = document . createElement ( "canvas" ) ;
51+ }
52+ const context = canvas . getContext ( "2d" ) ;
53+ if ( ! context ) return text ;
54+ context . font = font ;
55+ context . letterSpacing = letterSpacing ;
56+
57+ const measure = ( txt : string ) => context . measureText ( txt ) . width ;
3358
34- if ( measure ( text ) ) return text ;
59+ if ( measure ( text ) <= maxWidth ) {
60+ addToCache ( cacheKey , text ) ;
61+ return text ;
62+ }
3563
3664 let low = 0 ;
37- let high = text . length - 2 ;
65+ let high = text . length ;
3866 let result = "" ;
3967
4068 while ( low <= high ) {
41- const size = Math . floor ( ( low + high ) / 2 ) ;
42- const left = text . slice ( 0 , Math . ceil ( size / 2 ) ) ;
43- const right = text . slice ( text . length - Math . floor ( size / 2 ) ) ;
44- const trimmed = left + separator + right ;
69+ const mid = Math . floor ( ( low + high ) / 2 ) ;
70+ const leftHalf = Math . ceil ( mid / 2 ) ;
71+ const rightHalf = Math . floor ( mid / 2 ) ;
72+
73+ const trimmed = text . slice ( 0 , leftHalf ) + separator + text . slice ( text . length - rightHalf ) ;
4574
46- if ( measure ( trimmed ) ) {
75+ if ( measure ( trimmed ) <= maxWidth ) {
4776 result = trimmed ;
48- low = size + 1 ;
77+ low = mid + 1 ;
4978 } else {
50- high = size - 1 ;
79+ high = mid - 1 ;
5180 }
5281 }
5382
54- return result || text . charAt ( 0 ) + separator + text . charAt ( text . length - 1 ) ;
83+ const finalResult = result || text [ 0 ] + separator + text . slice ( - 1 ) ;
84+ addToCache ( cacheKey , finalResult ) ;
85+ return finalResult ;
5586} ;
5687
5788const Truncate : ForwardRefRenderFunction < HTMLSpanElement , TruncateProps > = ( props , ref ) => {
@@ -60,55 +91,67 @@ const Truncate: ForwardRefRenderFunction<HTMLSpanElement, TruncateProps> = (prop
6091 middle,
6192 separator = "..." ,
6293 className,
63- highlight,
94+ contentClassname,
95+ render,
6496 ...other
6597 } = { ...useComponentProps ( "truncate" ) , ...props } ;
6698
67- const innerRef = useRef < HTMLSpanElement | null > ( null ) ;
99+ const containerRef = useRef < HTMLSpanElement > ( null ) ;
68100 const [ displayedText , setDisplayedText ] = useState ( text ) ;
69101
70- const finalText = useMemo ( ( ) => {
71- return middle ? displayedText : text ;
72- } , [ displayedText , text , middle ] ) ;
73-
74- useImperativeHandle ( ref , ( ) => innerRef . current ! , [ ] ) ;
102+ useImperativeHandle ( ref , ( ) => containerRef . current ! , [ ] ) ;
75103
76104 useLayoutEffect ( ( ) => {
77- const el = innerRef . current ;
78- if ( ! el || ! middle ) return ;
105+ const el = containerRef . current ;
106+
107+ if ( ! middle || ! el ) {
108+ setDisplayedText ( text ) ;
109+ return ;
110+ }
111+
112+ const observer = new ResizeObserver ( entries => {
113+ const entry = entries [ 0 ] ;
114+ if ( ! entry ) return ;
79115
80- let observer : ResizeObserver | null = null ;
116+ const maxWidth = entry . contentRect . width ;
117+ const { fontWeight, fontSize, fontFamily, letterSpacing} = window . getComputedStyle ( el ) ;
81118
82- const measureAndTrim = debounce ( ( ) => {
83- setDisplayedText ( trimMiddle ( el , text , separator ) ) ;
84- } , 150 ) ;
119+ const font = [ fontWeight , fontSize , fontFamily ] . join ( " " ) ;
85120
86- measureAndTrim ( ) ;
121+ const truncated = calculateMiddleTruncate ( text , maxWidth , font , letterSpacing , separator ) ;
87122
88- observer = new ResizeObserver ( ( ) => measureAndTrim ( ) ) ;
123+ setDisplayedText ( prev => ( prev !== truncated ? truncated : prev ) ) ;
124+ } ) ;
89125
90126 observer . observe ( el ) ;
127+ return ( ) => observer . disconnect ( ) ;
128+ } , [ text , middle , separator ] ) ;
91129
92- return ( ) => {
93- measureAndTrim . clear ( ) ;
94- observer ?. disconnect ( ) ;
95- } ;
96- } , [ text , separator , middle ] ) ;
130+ const content = render ? render ( displayedText ) : displayedText ;
97131
98132 return (
99133 < span
134+ ref = { containerRef }
100135 className = { classnames (
101136 styles [ "truncate" ] ,
102137 {
103138 [ styles [ "truncate--middle" ] ] : middle ,
104139 } ,
105140 className
106141 ) }
142+ title = { text }
107143 { ...other }
108144 >
109- < span ref = { innerRef } className = { styles [ "truncate__hidden" ] } />
110-
111- { highlight ? < Highlight { ...highlight } textToHighlight = { finalText } /> : finalText }
145+ { middle ? (
146+ < >
147+ < span className = { styles [ "truncate__hidden" ] } aria-hidden = "true" >
148+ { text }
149+ </ span >
150+ < span className = { classnames ( styles [ "truncate__content" ] , contentClassname ) } > { content } </ span >
151+ </ >
152+ ) : (
153+ content
154+ ) }
112155 </ span >
113156 ) ;
114157} ;
0 commit comments