354 lines
10 KiB
TypeScript
354 lines
10 KiB
TypeScript
|
|
import type { ComponentProps, CSSProperties, ReactNode } from "react";
|
||
|
|
import type { EllipsisConfig } from "antd/lib/typography/Base";
|
||
|
|
import type {
|
||
|
|
ContentEntity,
|
||
|
|
LinkEntity,
|
||
|
|
MentionEntity,
|
||
|
|
TagEntity,
|
||
|
|
} from "../../core";
|
||
|
|
|
||
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
|
|
||
|
|
import { TachTypography } from "@hublib-web/tach-typography/react";
|
||
|
|
import { findAllEntities } from "../../core";
|
||
|
|
|
||
|
|
type CustomEllipsisConfig =
|
||
|
|
| ({
|
||
|
|
count: number;
|
||
|
|
rows?: never;
|
||
|
|
expandable?: boolean;
|
||
|
|
} & Partial<EllipsisConfig>)
|
||
|
|
| ({
|
||
|
|
rows: number;
|
||
|
|
count?: never;
|
||
|
|
expandable?: boolean;
|
||
|
|
} & Partial<EllipsisConfig>)
|
||
|
|
| false;
|
||
|
|
|
||
|
|
type ParagraphBodyProps = ComponentProps<typeof TachTypography.Paragraph.Body>;
|
||
|
|
type TextBodyProps = ComponentProps<typeof TachTypography.Text.Body>;
|
||
|
|
type TitleBodyProps = ComponentProps<typeof TachTypography.Title.Body>;
|
||
|
|
|
||
|
|
const joinClassName = (...values: Array<string | false | undefined>) =>
|
||
|
|
values.filter(Boolean).join(" ");
|
||
|
|
|
||
|
|
const baseSelectableStyle: CSSProperties = {
|
||
|
|
whiteSpace: "pre-wrap",
|
||
|
|
WebkitTouchCallout: "default",
|
||
|
|
WebkitUserSelect: "text",
|
||
|
|
KhtmlUserSelect: "text",
|
||
|
|
MozUserSelect: "text",
|
||
|
|
msUserSelect: "text",
|
||
|
|
userSelect: "text",
|
||
|
|
};
|
||
|
|
|
||
|
|
const blurStyle: CSSProperties = {
|
||
|
|
filter: "blur(3px)",
|
||
|
|
WebkitUserSelect: "none",
|
||
|
|
KhtmlUserSelect: "none",
|
||
|
|
MozUserSelect: "none",
|
||
|
|
msUserSelect: "none",
|
||
|
|
userSelect: "none",
|
||
|
|
pointerEvents: "none",
|
||
|
|
};
|
||
|
|
|
||
|
|
const ReadMoreButton: React.FC<{
|
||
|
|
onClick: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||
|
|
symbol?: EllipsisConfig["symbol"];
|
||
|
|
expanded?: boolean;
|
||
|
|
TitleComponent: React.ComponentType<TitleBodyProps>;
|
||
|
|
}> = ({ onClick, symbol = "Читать полностью", expanded = false, TitleComponent }) => (
|
||
|
|
<TitleComponent
|
||
|
|
onClick={event => {
|
||
|
|
event.preventDefault();
|
||
|
|
event.stopPropagation();
|
||
|
|
onClick(event);
|
||
|
|
}}
|
||
|
|
weight="bold"
|
||
|
|
level={5}
|
||
|
|
>
|
||
|
|
{typeof symbol === "function" ? symbol(expanded) : symbol}
|
||
|
|
</TitleComponent>
|
||
|
|
);
|
||
|
|
|
||
|
|
export interface ContentTextProps {
|
||
|
|
className?: string;
|
||
|
|
weight?: TextBodyProps["weight"];
|
||
|
|
text?: string | null;
|
||
|
|
ellipsis?: CustomEllipsisConfig;
|
||
|
|
blur?: boolean;
|
||
|
|
style?: TextBodyProps["style"];
|
||
|
|
onView?: () => void;
|
||
|
|
renderMention?: (entity: MentionEntity, index: number) => ReactNode;
|
||
|
|
renderTag?: (entity: TagEntity, index: number) => ReactNode;
|
||
|
|
renderLink?: (entity: LinkEntity, index: number) => ReactNode;
|
||
|
|
ParagraphComponent?: React.ComponentType<ParagraphBodyProps>;
|
||
|
|
TextComponent?: React.ComponentType<TextBodyProps>;
|
||
|
|
TitleComponent?: React.ComponentType<TitleBodyProps>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const ContentText: React.NamedExoticComponent<ContentTextProps> = React.memo(
|
||
|
|
({
|
||
|
|
text,
|
||
|
|
className,
|
||
|
|
ellipsis = false,
|
||
|
|
blur = false,
|
||
|
|
weight = "normal",
|
||
|
|
style,
|
||
|
|
onView,
|
||
|
|
renderMention,
|
||
|
|
renderTag,
|
||
|
|
renderLink,
|
||
|
|
ParagraphComponent = TachTypography.Paragraph.Body,
|
||
|
|
TextComponent = TachTypography.Text.Body,
|
||
|
|
TitleComponent = TachTypography.Title.Body,
|
||
|
|
...props
|
||
|
|
}: ContentTextProps) => {
|
||
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
|
|
const [expanded, setExpanded] = useState(false);
|
||
|
|
const [containerInsideView, setContainerInsideView] = useState(false);
|
||
|
|
const [viewed, setViewed] = useState(false);
|
||
|
|
const content = text || "";
|
||
|
|
|
||
|
|
const entities = useMemo<ContentEntity[]>(() => findAllEntities(content), [content]);
|
||
|
|
|
||
|
|
const wrapWithKey = (node: ReactNode, key: React.Key) => {
|
||
|
|
if (React.isValidElement(node)) {
|
||
|
|
return React.cloneElement(node, { key });
|
||
|
|
}
|
||
|
|
|
||
|
|
return <React.Fragment key={key}>{node}</React.Fragment>;
|
||
|
|
};
|
||
|
|
|
||
|
|
const buildMentionNode = (entity: MentionEntity, index: number) => {
|
||
|
|
const defaultNode = (
|
||
|
|
<TextComponent color="link" weight={weight}>
|
||
|
|
{entity.displayText}
|
||
|
|
</TextComponent>
|
||
|
|
);
|
||
|
|
const customNode = renderMention?.(entity, index) ?? defaultNode;
|
||
|
|
return wrapWithKey(customNode, `mention-${entity.start}-${index}`);
|
||
|
|
};
|
||
|
|
|
||
|
|
const buildTagNode = (entity: TagEntity, index: number) => {
|
||
|
|
const defaultNode = (
|
||
|
|
<TextComponent color="link" weight={weight}>
|
||
|
|
{entity.text}
|
||
|
|
</TextComponent>
|
||
|
|
);
|
||
|
|
const customNode = renderTag?.(entity, index) ?? defaultNode;
|
||
|
|
return wrapWithKey(customNode, `tag-${entity.start}-${index}`);
|
||
|
|
};
|
||
|
|
|
||
|
|
const buildLinkNode = (entity: LinkEntity, index: number) => {
|
||
|
|
const defaultNode = (
|
||
|
|
<TachTypography.Link.Body
|
||
|
|
target="_blank"
|
||
|
|
referrerPolicy="no-referrer"
|
||
|
|
color="link"
|
||
|
|
weight={weight}
|
||
|
|
href={entity.url}
|
||
|
|
>
|
||
|
|
{entity.text}
|
||
|
|
</TachTypography.Link.Body>
|
||
|
|
);
|
||
|
|
const customNode = renderLink?.(entity, index) ?? defaultNode;
|
||
|
|
return wrapWithKey(customNode, `link-${entity.start}-${index}`);
|
||
|
|
};
|
||
|
|
|
||
|
|
const buildParts = (upto: number | null = null): ReactNode[] => {
|
||
|
|
let lastIndex = 0;
|
||
|
|
const nodes: ReactNode[] = [];
|
||
|
|
|
||
|
|
for (const [i, 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) {
|
||
|
|
nodes.push(content.slice(lastIndex, textEnd));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (upto === null || entity.end <= upto) {
|
||
|
|
if (entity.type === "mention") {
|
||
|
|
nodes.push(buildMentionNode(entity, i));
|
||
|
|
} else if (entity.type === "tag") {
|
||
|
|
nodes.push(buildTagNode(entity, i));
|
||
|
|
} else if (entity.type === "link") {
|
||
|
|
nodes.push(buildLinkNode(entity, i));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
lastIndex = entity.end;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (upto === null) {
|
||
|
|
if (lastIndex < content.length) {
|
||
|
|
nodes.push(content.slice(lastIndex));
|
||
|
|
}
|
||
|
|
} else if (lastIndex < upto) {
|
||
|
|
nodes.push(content.slice(lastIndex, upto));
|
||
|
|
}
|
||
|
|
|
||
|
|
return nodes;
|
||
|
|
};
|
||
|
|
|
||
|
|
const ellipsisConfig = ellipsis && typeof ellipsis === "object" ? ellipsis : null;
|
||
|
|
const expandedFromProps =
|
||
|
|
ellipsisConfig && typeof ellipsisConfig.expanded === "boolean"
|
||
|
|
? ellipsisConfig.expanded
|
||
|
|
: undefined;
|
||
|
|
const isExpandedControlled = expandedFromProps !== undefined;
|
||
|
|
const mergedExpanded = isExpandedControlled ? expandedFromProps : expanded;
|
||
|
|
|
||
|
|
const handleExpand = useCallback(
|
||
|
|
(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||
|
|
if (!isExpandedControlled) {
|
||
|
|
setExpanded(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
ellipsisConfig?.onExpand?.(event, { expanded: true });
|
||
|
|
},
|
||
|
|
[ellipsisConfig, isExpandedControlled],
|
||
|
|
);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (isExpandedControlled) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (ellipsisConfig && "count" in ellipsisConfig) {
|
||
|
|
const count = ellipsisConfig.count ?? 0;
|
||
|
|
if (content.length <= count && !expanded) {
|
||
|
|
setExpanded(true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, [content.length, ellipsisConfig, expanded, isExpandedControlled]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (mergedExpanded && !viewed && containerInsideView) {
|
||
|
|
setViewed(true);
|
||
|
|
onView?.();
|
||
|
|
}
|
||
|
|
}, [mergedExpanded, viewed, containerInsideView, onView]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const ref = containerRef.current;
|
||
|
|
if (!ref) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const observer = new IntersectionObserver((entries) => {
|
||
|
|
const entry = entries[0];
|
||
|
|
if (!entry) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (entry.isIntersecting && !containerInsideView) {
|
||
|
|
setContainerInsideView(true);
|
||
|
|
}
|
||
|
|
}, { threshold: 0.5 });
|
||
|
|
|
||
|
|
observer.observe(ref);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
observer.unobserve(ref);
|
||
|
|
};
|
||
|
|
}, [containerInsideView]);
|
||
|
|
|
||
|
|
const mergedStyle: CSSProperties = {
|
||
|
|
...baseSelectableStyle,
|
||
|
|
...(blur ? blurStyle : undefined),
|
||
|
|
...(style as CSSProperties | undefined),
|
||
|
|
};
|
||
|
|
|
||
|
|
if (ellipsisConfig && "count" in ellipsisConfig) {
|
||
|
|
const { count, expandable } = ellipsisConfig;
|
||
|
|
|
||
|
|
if (!mergedExpanded && count && content.length > count) {
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const truncatedNodes = buildParts(cutoff);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<ParagraphComponent
|
||
|
|
ref={containerRef}
|
||
|
|
weight={weight}
|
||
|
|
className={joinClassName(className)}
|
||
|
|
style={mergedStyle}
|
||
|
|
{...(props as ParagraphBodyProps)}
|
||
|
|
>
|
||
|
|
{truncatedNodes}…
|
||
|
|
{expandable && (
|
||
|
|
<ReadMoreButton
|
||
|
|
symbol={ellipsisConfig.symbol}
|
||
|
|
onClick={handleExpand}
|
||
|
|
expanded={mergedExpanded}
|
||
|
|
TitleComponent={TitleComponent}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</ParagraphComponent>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (ellipsisConfig && "rows" in ellipsisConfig) {
|
||
|
|
const paragraphEllipsis = mergedExpanded
|
||
|
|
? false
|
||
|
|
: {
|
||
|
|
...ellipsisConfig,
|
||
|
|
symbol: ellipsisConfig.expandable ? (
|
||
|
|
<ReadMoreButton
|
||
|
|
symbol={ellipsisConfig.symbol}
|
||
|
|
onClick={handleExpand}
|
||
|
|
expanded={mergedExpanded}
|
||
|
|
TitleComponent={TitleComponent}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
ellipsisConfig.symbol
|
||
|
|
),
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<ParagraphComponent
|
||
|
|
ref={containerRef}
|
||
|
|
weight={weight}
|
||
|
|
className={joinClassName(className)}
|
||
|
|
style={mergedStyle}
|
||
|
|
ellipsis={paragraphEllipsis}
|
||
|
|
{...(props as ParagraphBodyProps)}
|
||
|
|
>
|
||
|
|
{buildParts()}
|
||
|
|
</ParagraphComponent>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<ParagraphComponent
|
||
|
|
ref={containerRef}
|
||
|
|
weight={weight}
|
||
|
|
className={joinClassName(className)}
|
||
|
|
style={mergedStyle}
|
||
|
|
{...(props as ParagraphBodyProps)}
|
||
|
|
>
|
||
|
|
{buildParts()}
|
||
|
|
</ParagraphComponent>
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
ContentText.displayName = "ContentText";
|