11// src/core/lineClamp.js
22
33/**
4- * Apply line-clamp to element AND all descendants that have -webkit-line-clamp.
5- * Fixes #386: ellipsis now renders for nested elements, not just the root.
4+ * Bake text truncation for the element AND all descendants that CSS would
5+ * truncate: multi-line `-webkit-line-clamp` and single-line
6+ * `text-overflow: ellipsis`. Firefox and Safari don't honour either inside a
7+ * `<foreignObject>`, so we resolve the ellipsis into the text up front.
8+ * Fixes #386 (nested clamp) and #431 (single-line ellipsis on Safari/Firefox).
69 *
710 * @param {Element } el - Root element (and its subtree) to process
811 * @returns {() => void } Combined undo function
@@ -11,8 +14,12 @@ export function lineClampTree(el) {
1114 if ( ! el ) return ( ) => { }
1215 const undos = [ ]
1316 function walk ( node ) {
14- const undo = lineClamp ( node )
15- if ( undo ) undos . push ( undo )
17+ // One computed-style read per node, shared by both passes (hot path).
18+ const cs = getComputedStyle ( node )
19+ const u1 = lineClamp ( node , cs )
20+ if ( u1 ) undos . push ( u1 )
21+ const u2 = textEllipsis ( node , cs )
22+ if ( u2 ) undos . push ( u2 )
1623 for ( const child of node . children || [ ] ) walk ( child )
1724 }
1825 walk ( el )
@@ -26,18 +33,18 @@ export function lineClampTree(el) {
2633 * then returns an undo() that restores everything right after cloning.
2734 *
2835 * @param {Element } el
36+ * @param {CSSStyleDeclaration } [cs]
2937 * @returns {() => void } undo function (no-op if nothing changed)
3038 */
31- export function lineClamp ( el ) {
39+ export function lineClamp ( el , cs ) {
3240 if ( ! el ) return ( ) => { }
41+ cs = cs || getComputedStyle ( el )
3342
34- const lines = getClamp ( el )
43+ const lines = getClamp ( cs )
3544 if ( lines <= 0 ) return ( ) => { }
3645
3746 if ( ! isPlainTextContainer ( el ) ) return ( ) => { }
3847
39- const cs = getComputedStyle ( el )
40-
4148 const original = el . textContent ?? ''
4249 // Guarda para restaurar
4350 const prevText = original
@@ -81,10 +88,54 @@ export function lineClamp(el) {
8188 }
8289}
8390
91+ /**
92+ * Bake a single-line `text-overflow: ellipsis`. Same strategy as lineClamp but
93+ * on the horizontal axis: only when the element is a nowrap, overflow-clipped
94+ * plain-text container whose content overflows. Firefox/Safari skip this in a
95+ * <foreignObject>, so we resolve it here for every engine (#431).
96+ *
97+ * @param {Element } el
98+ * @param {CSSStyleDeclaration } [cs]
99+ * @returns {() => void } undo function (no-op if nothing changed)
100+ */
101+ export function textEllipsis ( el , cs ) {
102+ if ( ! el ) return ( ) => { }
103+ cs = cs || getComputedStyle ( el )
104+
105+ if ( cs . textOverflow !== 'ellipsis' ) return ( ) => { }
106+ // Single-line ellipsis: content must not wrap and must be clipped.
107+ if ( cs . whiteSpace !== 'nowrap' && cs . whiteSpace !== 'pre' ) return ( ) => { }
108+ if ( cs . overflowX !== 'hidden' && cs . overflowX !== 'clip' ) return ( ) => { }
109+
110+ if ( ! isPlainTextContainer ( el ) ) return ( ) => { }
111+
112+ // Ya entra completo → el clamp nativo tampoco haría nada.
113+ if ( el . scrollWidth <= el . clientWidth + 0.5 ) return ( ) => { }
114+
115+ const original = el . textContent ?? ''
116+ const prevText = original
117+
118+ let lo = 0 , hi = original . length , best = - 1
119+ while ( lo <= hi ) {
120+ const mid = ( lo + hi ) >> 1
121+ el . textContent = original . slice ( 0 , mid ) + '…'
122+ if ( el . scrollWidth <= el . clientWidth + 0.5 ) {
123+ best = mid ; lo = mid + 1
124+ } else {
125+ hi = mid - 1
126+ }
127+ }
128+
129+ el . textContent = ( best >= 0 ? original . slice ( 0 , best ) : '' ) + '…'
130+
131+ return ( ) => {
132+ el . textContent = prevText
133+ }
134+ }
135+
84136/* ---------------- helpers: idénticos a tu snippet ---------------- */
85137
86- function getClamp ( el ) {
87- const cs = getComputedStyle ( el )
138+ function getClamp ( cs ) {
88139 let v = cs . getPropertyValue ( '-webkit-line-clamp' ) || cs . getPropertyValue ( 'line-clamp' )
89140 v = ( v || '' ) . trim ( )
90141 const n = parseInt ( v , 10 )
0 commit comments