release(content-suggestions): v0.2.0
This commit is contained in:
@@ -0,0 +1,571 @@
|
||||
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"]`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user