572 lines
16 KiB
TypeScript
572 lines
16 KiB
TypeScript
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"]`);
|
|
}
|
|
}
|