Я написал угловой компонент, который решает проблему. Он разбивает данный текст на элементы span. После рендеринга удаляет все переполненные элементы и помещает многоточие сразу после последнего видимого элемента.
Пример использования:
<app-text-overflow-ellipsis [text]="someText" style="max-height: 50px"></app-text-overflow-ellipsis>
Демонстрация Stackblitz: https://stackblitz.com/edit/angular-wfdqtd
Компонент:
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef, HostListener,
Input,
OnChanges,
ViewChild
} from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-text-overflow-ellipsis',
template: `
<span *ngFor="let word of words; let i = index" [innerHTML]="word + (!endsWithHyphen(i) ? ' ' : '')"></span>
<span #ellipsis [hidden]="!showEllipsis && !initializing" [class.initializing]="initializing" [innerHTML]="'...' + (initializing ? ' ' : '')"></span>
`,
styles: [`
:host {
display: block;
position: relative;
}
.initializing {
opacity: 0;
}
`
]
})
export class TextOverflowEllipsisComponent implements OnChanges {
@Input()
text: string;
showEllipsis: boolean;
initializing: boolean;
words: string[];
@ViewChild('ellipsis')
ellipsisElement: ElementRef;
constructor(private element: ElementRef, private cdRef: ChangeDetectorRef) {}
ngOnChanges(){
this.init();
}
@HostListener('window:resize')
init(){
// add space after hyphens
let text = this.text.replace(/-/g, '- ') ;
this.words = text.split(' ');
this.initializing = true;
this.showEllipsis = false;
this.cdRef.detectChanges();
setTimeout(() => {
this.initializing = false;
let containerElement = this.element.nativeElement;
let containerWidth = containerElement.clientWidth;
let wordElements = (<HTMLElement[]>Array.from(containerElement.childNodes)).filter((element) =>
element.getBoundingClientRect && element !== this.ellipsisElement.nativeElement
);
let lines = this.getLines(wordElements, containerWidth);
let indexOfLastLine = lines.length - 1;
let lineHeight = this.deductLineHeight(lines);
if (!lineHeight) {
return;
}
let indexOfLastVisibleLine = Math.floor(containerElement.clientHeight / lineHeight) - 1;
if (indexOfLastVisibleLine < indexOfLastLine) {
// remove overflowing lines
for (let i = indexOfLastLine; i > indexOfLastVisibleLine; i--) {
for (let j = 0; j < lines[i].length; j++) {
this.words.splice(-1, 1);
}
}
// make ellipsis fit into last visible line
let lastVisibleLine = lines[indexOfLastVisibleLine];
let indexOfLastWord = lastVisibleLine.length - 1;
let lastVisibleLineWidth = lastVisibleLine.map(
(element) => element.getBoundingClientRect().width
).reduce(
(width, sum) => width + sum, 0
);
let ellipsisWidth = this.ellipsisElement.nativeElement.getBoundingClientRect().width;
for (let i = indexOfLastWord; lastVisibleLineWidth + ellipsisWidth >= containerWidth; i--) {
let wordWidth = lastVisibleLine[i].getBoundingClientRect().width;
lastVisibleLineWidth -= wordWidth;
this.words.splice(-1, 1);
}
this.showEllipsis = true;
}
this.cdRef.detectChanges();
// delay is to prevent from font loading issues
}, 1000);
}
deductLineHeight(lines: HTMLElement[][]): number {
try {
let rect0 = lines[0][0].getBoundingClientRect();
let y0 = rect0['y'] || rect0['top'] || 0;
let rect1 = lines[1][0].getBoundingClientRect();
let y1 = rect1['y'] || rect1['top'] || 0;
let lineHeight = y1 - y0;
if (lineHeight > 0){
return lineHeight;
}
} catch (e) {}
return null;
}
getLines(nodes: HTMLElement[], clientWidth: number): HTMLElement[][] {
let lines = [];
let currentLine = [];
let currentLineWidth = 0;
nodes.forEach((node) => {
if (!node.getBoundingClientRect){
return;
}
let nodeWidth = node.getBoundingClientRect().width;
if (currentLineWidth + nodeWidth > clientWidth){
lines.push(currentLine);
currentLine = [];
currentLineWidth = 0;
}
currentLine.push(node);
currentLineWidth += nodeWidth;
});
lines.push(currentLine);
return lines;
}
endsWithHyphen(index: number): boolean {
let length = this.words[index].length;
return this.words[index][length - 1] === '-' && this.words[index + 1] && this.words[index + 1][0];
}
}