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 = { whiteSpace: "pre-wrap", WebkitTouchCallout: "default", WebkitUserSelect: "text", KhtmlUserSelect: "text", MozUserSelect: "text", msUserSelect: "text", userSelect: "text", }; const BLUR_STYLE: Record = { 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: `
{{ part.text }} {{ part.entity.displayText }} {{ part.entity.text }} {{ part.entity.text }}
`, }) 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 | null | undefined; @Input() onView: (() => void) | undefined; @Input() renderMention: AngularMentionRenderer | undefined; @Input() renderTag: AngularTagRenderer | undefined; @Input() renderLink: AngularLinkRenderer | undefined; @Output() readonly viewed = new EventEmitter(); @Output() readonly expandedChange = new EventEmitter(); @ViewChild("container", { static: true }) private readonly containerRef!: ElementRef; visibleParts: ContentPart[] = []; mergedStyle: Record = { ...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, 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( 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, ): 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"]`); } }