Files
_hublib-web/packages/content-suggestions/src/angular/content-text.component.ts

572 lines
16 KiB
TypeScript
Raw Normal View History

2026-04-03 16:10:45 +03:00
import type { LinkEntity, MentionEntity, TagEntity } from "../core";
import type {
AngularContentEllipsisConfig,
AngularCountEllipsisConfig,
AngularLinkRenderer,
AngularRenderResult,
AngularRowsEllipsisConfig,
AngularTagRenderer,
AngularMentionRenderer,
} from "./types";
import { NgFor, NgIf, NgSwitch, NgSwitchCase, NgSwitchDefault } from "@angular/common";
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
ViewChild,
} from "@angular/core";
import {
TachTypographyComponent,
type TachTypographyHostProps,
} from "@hublib-web/tach-typography/angular";
import type { TypographyWeight } from "@hublib-web/tach-typography/core";
import { findAllEntities } from "../core";
import { ContentSuggestionsRenderResultDirective } from "./render-result.directive";
const READ_MORE_TEXT = "Читать полностью";
const BODY_DATA_ATTR = "data-content-suggestions-body";
const BASE_SELECTABLE_STYLE: Record<string, string> = {
whiteSpace: "pre-wrap",
WebkitTouchCallout: "default",
WebkitUserSelect: "text",
KhtmlUserSelect: "text",
MozUserSelect: "text",
msUserSelect: "text",
userSelect: "text",
};
const BLUR_STYLE: Record<string, string> = {
filter: "blur(3px)",
WebkitUserSelect: "none",
KhtmlUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
userSelect: "none",
pointerEvents: "none",
};
type TextPart = {
kind: "text";
key: string;
text: string;
};
type MentionPart = {
kind: "mention";
key: string;
entity: MentionEntity;
index: number;
customRender: AngularRenderResult | null;
};
type TagPart = {
kind: "tag";
key: string;
entity: TagEntity;
index: number;
customRender: AngularRenderResult | null;
};
type LinkPart = {
kind: "link";
key: string;
entity: LinkEntity;
index: number;
customRender: AngularRenderResult | null;
};
type ContentPart = TextPart | MentionPart | TagPart | LinkPart;
@Component({
selector: "content-text",
standalone: true,
imports: [
NgFor,
NgIf,
NgSwitch,
NgSwitchCase,
NgSwitchDefault,
TachTypographyComponent,
ContentSuggestionsRenderResultDirective,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div #container>
<tach-typography
as="p"
variant="Body"
[weight]="weight"
[className]="className"
[preserveStyle]="mergedStyle"
[ellipsis]="rowsEllipsis"
[hostProps]="paragraphHostProps"
>
<ng-container *ngFor="let part of visibleParts; trackBy: trackByKey">
<ng-container [ngSwitch]="part.kind">
<ng-container *ngSwitchCase="'text'">{{ part.text }}</ng-container>
<ng-container *ngSwitchCase="'mention'">
<ng-container *ngIf="part.customRender !== null; else contentSuggestionsDefaultMention">
<span [contentSuggestionsRenderResult]="part.customRender"></span>
</ng-container>
<ng-template #contentSuggestionsDefaultMention>
<tach-typography as="span" variant="Body" color="link" [weight]="weight">
{{ part.entity.displayText }}
</tach-typography>
</ng-template>
</ng-container>
<ng-container *ngSwitchCase="'tag'">
<ng-container *ngIf="part.customRender !== null; else contentSuggestionsDefaultTag">
<span [contentSuggestionsRenderResult]="part.customRender"></span>
</ng-container>
<ng-template #contentSuggestionsDefaultTag>
<tach-typography as="span" variant="Body" color="link" [weight]="weight">
{{ part.entity.text }}
</tach-typography>
</ng-template>
</ng-container>
<ng-container *ngSwitchCase="'link'">
<ng-container *ngIf="part.customRender !== null; else contentSuggestionsDefaultLink">
<span [contentSuggestionsRenderResult]="part.customRender"></span>
</ng-container>
<ng-template #contentSuggestionsDefaultLink>
<tach-typography
as="a"
variant="Body"
color="link"
[weight]="weight"
[hostProps]="buildDefaultLinkHostProps(part.entity.url)"
>
{{ part.entity.text }}
</tach-typography>
</ng-template>
</ng-container>
<ng-container *ngSwitchDefault></ng-container>
</ng-container>
</ng-container>
<ng-container *ngIf="showTrailingEllipsis"></ng-container>
<button
*ngIf="showInlineReadMore"
type="button"
(click)="handleExpand($event)"
style="border: 0; background: none; padding: 0; margin-left: 6px; cursor: pointer;"
>
<tach-typography as="span" variant="Body" color="link" weight="bold">
{{ readMoreLabel }}
</tach-typography>
</button>
</tach-typography>
<button
*ngIf="showBlockReadMore"
type="button"
(click)="handleExpand($event)"
style="border: 0; background: none; padding: 0; margin-top: 6px; cursor: pointer;"
>
<tach-typography as="span" variant="Body" color="link" weight="bold">
{{ readMoreLabel }}
</tach-typography>
</button>
</div>
`,
})
export class ContentTextComponent implements OnChanges, AfterViewInit, OnDestroy {
@Input() className: string | undefined;
@Input() weight: TypographyWeight = "normal";
@Input() text: string | null | undefined;
@Input() ellipsis: AngularContentEllipsisConfig = false;
@Input() blur = false;
@Input() style: Record<string, string | number> | null | undefined;
@Input() onView: (() => void) | undefined;
@Input() renderMention: AngularMentionRenderer | undefined;
@Input() renderTag: AngularTagRenderer | undefined;
@Input() renderLink: AngularLinkRenderer | undefined;
@Output() readonly viewed = new EventEmitter<void>();
@Output() readonly expandedChange = new EventEmitter<boolean>();
@ViewChild("container", { static: true })
private readonly containerRef!: ElementRef<HTMLElement>;
visibleParts: ContentPart[] = [];
mergedStyle: Record<string, string | number> = { ...BASE_SELECTABLE_STYLE };
rowsEllipsis: { rows: number } | undefined;
showTrailingEllipsis = false;
showInlineReadMore = false;
showBlockReadMore = false;
readMoreLabel = READ_MORE_TEXT;
paragraphHostProps: TachTypographyHostProps = { [BODY_DATA_ATTR]: "true" };
private expanded = false;
private mergedExpanded = false;
private isExpandedControlled = false;
private isInsideView = false;
private viewedFired = false;
private pendingRowsOverflowCheck = false;
private intersectionObserver: IntersectionObserver | null = null;
constructor(
private readonly cdr: ChangeDetectorRef,
) {}
ngOnChanges(_changes: SimpleChanges): void {
this.recomputeContent();
}
ngAfterViewInit(): void {
this.initObserver();
this.scheduleRowsOverflowCheck();
}
ngOnDestroy(): void {
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
this.intersectionObserver = null;
}
}
trackByKey(_index: number, part: ContentPart): string {
return part.key;
}
buildDefaultLinkHostProps(url: string): TachTypographyHostProps {
return {
href: url,
target: "_blank",
referrerPolicy: "no-referrer",
};
}
handleExpand(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
if (!this.isExpandedControlled) {
this.expanded = true;
}
this.notifyExpand(event, true);
this.recomputeContent();
}
private recomputeContent(): void {
const content = this.text ?? "";
const entities = findAllEntities(content);
const ellipsisConfig = this.resolveEllipsisConfig();
this.isExpandedControlled = Boolean(
ellipsisConfig && typeof ellipsisConfig.expanded === "boolean",
);
if (!this.isExpandedControlled && this.isCountConfig(ellipsisConfig)) {
const count = ellipsisConfig.count ?? 0;
if (content.length <= count && !this.expanded) {
this.expanded = true;
}
}
this.mergedExpanded = this.getMergedExpanded(ellipsisConfig, content);
this.readMoreLabel = this.resolveEllipsisSymbol(
ellipsisConfig?.symbol,
this.mergedExpanded,
);
this.mergedStyle = {
...BASE_SELECTABLE_STYLE,
...(this.blur ? BLUR_STYLE : {}),
...(this.style ?? {}),
};
this.visibleParts = this.buildParts(content, entities, null);
this.rowsEllipsis = undefined;
this.showTrailingEllipsis = false;
this.showInlineReadMore = false;
this.showBlockReadMore = false;
this.pendingRowsOverflowCheck = false;
if (this.isCountConfig(ellipsisConfig)) {
const count = ellipsisConfig.count ?? 0;
const shouldTruncate = !this.mergedExpanded && count > 0 && content.length > count;
if (shouldTruncate) {
const cutoff = this.computeEntitySafeCutoff(count, entities);
this.visibleParts = this.buildParts(content, entities, cutoff);
this.showTrailingEllipsis = true;
this.showInlineReadMore = Boolean(ellipsisConfig.expandable);
}
} else if (this.isRowsConfig(ellipsisConfig) && !this.mergedExpanded) {
this.rowsEllipsis = {
rows: ellipsisConfig.rows,
};
if (ellipsisConfig.expandable) {
this.pendingRowsOverflowCheck = true;
}
}
this.emitViewIfNeeded();
this.scheduleRowsOverflowCheck();
this.cdr.markForCheck();
}
private buildParts(
content: string,
entities: ReturnType<typeof findAllEntities>,
upto: number | null,
): ContentPart[] {
const parts: ContentPart[] = [];
let lastIndex = 0;
for (const [index, entity] of entities.entries()) {
if (upto !== null && entity.start >= upto) {
break;
}
const textEnd = upto !== null ? Math.min(entity.start, upto) : entity.start;
if (entity.start > lastIndex && lastIndex < textEnd) {
parts.push({
kind: "text",
key: `text-${lastIndex}-${textEnd}`,
text: content.slice(lastIndex, textEnd),
});
}
if (upto === null || entity.end <= upto) {
if (entity.type === "mention") {
parts.push({
kind: "mention",
key: `mention-${entity.start}-${index}`,
entity,
index,
customRender: this.resolveCustomRender(this.renderMention, entity, index),
});
} else if (entity.type === "tag") {
parts.push({
kind: "tag",
key: `tag-${entity.start}-${index}`,
entity,
index,
customRender: this.resolveCustomRender(this.renderTag, entity, index),
});
} else {
parts.push({
kind: "link",
key: `link-${entity.start}-${index}`,
entity,
index,
customRender: this.resolveCustomRender(this.renderLink, entity, index),
});
}
}
lastIndex = entity.end;
}
if (upto === null) {
if (lastIndex < content.length) {
parts.push({
kind: "text",
key: `text-${lastIndex}-${content.length}`,
text: content.slice(lastIndex),
});
}
} else if (lastIndex < upto) {
parts.push({
kind: "text",
key: `text-${lastIndex}-${upto}`,
text: content.slice(lastIndex, upto),
});
}
return parts;
}
private resolveCustomRender<T>(
rendererFn: ((entity: T, index: number) => AngularRenderResult) | undefined,
entity: T,
index: number,
): AngularRenderResult | null {
const rendered = rendererFn?.(entity, index);
return rendered ?? null;
}
private resolveEllipsisConfig(): AngularCountEllipsisConfig | AngularRowsEllipsisConfig | null {
if (!this.ellipsis || typeof this.ellipsis !== "object") {
return null;
}
return this.ellipsis;
}
private isCountConfig(
config: AngularCountEllipsisConfig | AngularRowsEllipsisConfig | null,
): config is AngularCountEllipsisConfig {
return Boolean(config && "count" in config);
}
private isRowsConfig(
config: AngularCountEllipsisConfig | AngularRowsEllipsisConfig | null,
): config is AngularRowsEllipsisConfig {
return Boolean(config && "rows" in config);
}
private getMergedExpanded(
config: AngularCountEllipsisConfig | AngularRowsEllipsisConfig | null,
text: string,
): boolean {
const controlledExpanded =
config && typeof config.expanded === "boolean"
? config.expanded
: undefined;
if (controlledExpanded !== undefined) {
return controlledExpanded;
}
if (this.isCountConfig(config)) {
const count = config.count ?? 0;
if (count <= 0 || text.length <= count) {
return true;
}
}
return this.expanded;
}
private resolveEllipsisSymbol(
symbol: AngularCountEllipsisConfig["symbol"] | AngularRowsEllipsisConfig["symbol"],
expanded: boolean,
): string {
if (typeof symbol === "function") {
return symbol(expanded);
}
return symbol ?? READ_MORE_TEXT;
}
private notifyExpand(event: MouseEvent, expanded: boolean): void {
const config = this.resolveEllipsisConfig();
const onExpand = config?.onExpand;
if (onExpand) {
if (onExpand.length >= 2) {
(onExpand as (e: MouseEvent, info: { expanded: boolean }) => void)(event, { expanded });
} else {
(onExpand as (nextExpanded: boolean) => void)(expanded);
}
}
this.expandedChange.emit(expanded);
}
private computeEntitySafeCutoff(
count: number,
entities: ReturnType<typeof findAllEntities>,
): number {
let cutoff = count;
let extended = true;
while (extended) {
extended = false;
for (const entity of entities) {
if (entity.start < cutoff && entity.end > cutoff) {
cutoff = entity.end;
extended = true;
}
}
}
return cutoff;
}
private initObserver(): void {
const host = this.containerRef?.nativeElement;
if (!host) {
return;
}
if (typeof IntersectionObserver === "undefined") {
this.isInsideView = true;
this.emitViewIfNeeded();
return;
}
this.intersectionObserver = new IntersectionObserver(
entries => {
const entry = entries[0];
if (!entry) {
return;
}
if (entry.isIntersecting && !this.isInsideView) {
this.isInsideView = true;
this.emitViewIfNeeded();
}
},
{ threshold: 0.5 },
);
this.intersectionObserver.observe(host);
}
private emitViewIfNeeded(): void {
if (!this.mergedExpanded || this.viewedFired || !this.isInsideView) {
return;
}
this.viewedFired = true;
this.onView?.();
this.viewed.emit();
}
private scheduleRowsOverflowCheck(): void {
if (!this.pendingRowsOverflowCheck) {
return;
}
queueMicrotask(() => {
this.refreshRowsOverflow();
});
}
private refreshRowsOverflow(): void {
const paragraph = this.getParagraphElement();
if (!paragraph) {
return;
}
const hasOverflow = paragraph.scrollHeight - paragraph.clientHeight > 1;
if (this.showBlockReadMore !== hasOverflow) {
this.showBlockReadMore = hasOverflow;
this.cdr.markForCheck();
}
}
private getParagraphElement(): HTMLElement | null {
const host = this.containerRef?.nativeElement;
if (!host) {
return null;
}
return host.querySelector(`[${BODY_DATA_ATTR}="true"]`);
}
}