1+ import { useState , useEffect , useRef } from "preact/hooks"
2+
3+ const agentStyle = {
4+ position : "absolute" ,
5+ bottom : 0 ,
6+ left : 0 ,
7+ height : 0 ,
8+ overflow : "hidden" ,
9+ paddingTop : 0 ,
10+ paddingBottom : 0 ,
11+ border : "none" ,
12+ }
13+
14+ const mirrorProps = [
15+ "box-sizing" ,
16+ "width" ,
17+ "font-size" ,
18+ "font-weight" ,
19+ "font-family" ,
20+ "font-style" ,
21+ "letter-spacing" ,
22+ "text-indent" ,
23+ "white-space" ,
24+ "word-break" ,
25+ "overflow-wrap" ,
26+ "padding-left" ,
27+ "padding-right" ,
28+ ]
29+
30+ function prevSibling ( node , count ) {
31+ while ( node && count -- ) {
32+ node = node . previousElementSibling
33+ }
34+ return node
35+ }
36+
37+ const LinesEllipsis = props => {
38+ const { component : Component = "div" , ellipsis, trimRight = true , basedOn, maxLine = 1 , text, className, onReflow, ...rest } = props
39+
40+ const [ displayedText , setDisplayedText ] = useState ( text )
41+ const [ clamped , setClamped ] = useState ( false )
42+
43+ const units = useRef ( [ ] )
44+ const shadowRef = useRef < HTMLElement > ( )
45+ const targetRef = useRef ( )
46+ const ellipsisRef = useRef ( )
47+
48+ useEffect ( ( ) => {
49+ const handleSizeChanged = entries => {
50+ if ( targetRef . current ) {
51+ copyStyleToShadow ( )
52+ reflow ( { basedOn, text, maxLine } )
53+ }
54+ }
55+ const resizeObserver = new ResizeObserver ( handleSizeChanged )
56+ resizeObserver . observe ( targetRef . current )
57+
58+ return ( ) => {
59+ if ( targetRef . current ) {
60+ resizeObserver && resizeObserver . unobserve ( targetRef . current )
61+ }
62+ }
63+ } , [ basedOn , text , maxLine ] )
64+
65+ const copyStyleToShadow = ( ) => {
66+ const targetStyle = window . getComputedStyle ( targetRef . current )
67+ mirrorProps . forEach ( key => {
68+ shadowRef . current . style [ key ] = targetStyle [ key ]
69+ } )
70+ }
71+
72+ const reflow = props => {
73+ /* eslint-disable no-control-regex */
74+ const basedOn = props . basedOn || ( / ^ [ \x00 - \x7F ] + $ / . test ( props . text ) ? "words" : "letters" )
75+
76+ if ( basedOn === "words" ) {
77+ units . current = props . text . split ( / \b | (? = \W ) / )
78+ } else if ( basedOn === "letters" ) {
79+ units . current = Array . from ( props . text )
80+ } else {
81+ // default
82+ units . current = props . text . split ( / \b | (? = \W ) / )
83+ }
84+ shadowRef . current . innerHTML = units . current
85+ . map ( c => {
86+ return `<span class='LinesEllipsis-unit'>${ c } </span>`
87+ } )
88+ . join ( "" )
89+ const ellipsisIndex = putEllipsis ( calcIndexes ( ) )
90+ const nextClamped = ellipsisIndex > - 1
91+ const nextDisplayedText = nextClamped ? units . current . slice ( 0 , ellipsisIndex ) . join ( "" ) : props . text
92+ setClamped ( nextClamped )
93+ setDisplayedText ( nextDisplayedText )
94+ onReflow ( { clamped : nextClamped , text : nextDisplayedText } )
95+ }
96+
97+ // return the index of the first letter/word of each line
98+ // row count: maxLine + 1
99+ const calcIndexes = ( ) => {
100+ const indexes = [ 0 ]
101+ let spanNode = shadowRef . current . firstElementChild
102+ if ( ! spanNode ) return indexes
103+
104+ let index = 0
105+ let line = 1
106+ let offsetTop = spanNode . offsetTop
107+ while ( ( spanNode = spanNode . nextElementSibling ) ) {
108+ if ( spanNode . offsetTop > offsetTop ) {
109+ line ++
110+ indexes . push ( index )
111+ offsetTop = spanNode . offsetTop
112+ }
113+ index ++
114+ if ( line > maxLine ) {
115+ break
116+ }
117+ }
118+ return indexes
119+ }
120+
121+ const putEllipsis = indexes => {
122+ // no ellipsis
123+ if ( indexes . length <= maxLine ) return - 1
124+ const lastIndex = indexes [ maxLine ]
125+ const truncatedUnits = units . current . slice ( 0 , lastIndex )
126+
127+ // the first letter/word of maxLine + 1 row
128+ const maxOffsetTop = shadowRef . current . children [ lastIndex ] . offsetTop
129+ shadowRef . current . innerHTML =
130+ truncatedUnits
131+ . map ( ( c , i ) => {
132+ return `<span class='LinesEllipsis-unit'>${ c } </span>`
133+ } )
134+ . join ( "" ) + `<span class='LinesEllipsis-ellipsis'>${ ellipsisRef . current . innerHTML } </span>`
135+ const ellipsisNode = shadowRef . current . lastElementChild
136+ let lastTextNode = prevSibling ( ellipsisNode , 1 )
137+ while (
138+ lastTextNode &&
139+ ( ellipsisNode . offsetTop > maxOffsetTop ||
140+ ellipsisNode . offsetHeight > lastTextNode . offsetHeight ||
141+ ellipsisNode . offsetTop > lastTextNode . offsetTop )
142+ ) {
143+ shadowRef . current . removeChild ( lastTextNode )
144+ lastTextNode = prevSibling ( ellipsisNode , 1 )
145+ truncatedUnits . pop ( )
146+ }
147+ return truncatedUnits . length
148+ }
149+
150+ return (
151+ < >
152+ < Component className = { `LinesEllipsis ${ clamped ? "LinesEllipsis--clamped" : "" } ${ className } ` } ref = { targetRef } { ...rest } >
153+ { trimRight ? displayedText . trimRight ( ) : displayedText }
154+ { clamped && < span className = "LinesEllipsis-ellipsis" > { ellipsis } </ span > }
155+ </ Component >
156+ < div style = { agentStyle } ref = { shadowRef } className = { `LinesEllipsis-shadow ${ className } ` } > </ div >
157+ < span style = { agentStyle } ref = { ellipsisRef } >
158+ { ellipsis }
159+ </ span >
160+ </ >
161+ )
162+ }
163+
164+ export default LinesEllipsis
0 commit comments