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) | ({ rows: number; count?: never; expandable?: boolean; } & Partial) | false; type ParagraphBodyProps = ComponentProps; type TextBodyProps = ComponentProps; type TitleBodyProps = ComponentProps; const joinClassName = (...values: Array) => 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) => void; symbol?: EllipsisConfig["symbol"]; expanded?: boolean; TitleComponent: React.ComponentType; }> = ({ onClick, symbol = "Читать полностью", expanded = false, TitleComponent }) => ( { event.preventDefault(); event.stopPropagation(); onClick(event); }} weight="bold" level={5} > {typeof symbol === "function" ? symbol(expanded) : symbol} ); 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; TextComponent?: React.ComponentType; TitleComponent?: React.ComponentType; } export const ContentText: React.NamedExoticComponent = 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(null); const [expanded, setExpanded] = useState(false); const [containerInsideView, setContainerInsideView] = useState(false); const [viewed, setViewed] = useState(false); const content = text || ""; const entities = useMemo(() => findAllEntities(content), [content]); const wrapWithKey = (node: ReactNode, key: React.Key) => { if (React.isValidElement(node)) { return React.cloneElement(node, { key }); } return {node}; }; const buildMentionNode = (entity: MentionEntity, index: number) => { const defaultNode = ( {entity.displayText} ); const customNode = renderMention?.(entity, index) ?? defaultNode; return wrapWithKey(customNode, `mention-${entity.start}-${index}`); }; const buildTagNode = (entity: TagEntity, index: number) => { const defaultNode = ( {entity.text} ); const customNode = renderTag?.(entity, index) ?? defaultNode; return wrapWithKey(customNode, `tag-${entity.start}-${index}`); }; const buildLinkNode = (entity: LinkEntity, index: number) => { const defaultNode = ( {entity.text} ); 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) => { 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 ( {truncatedNodes}… {expandable && ( )} ); } } if (ellipsisConfig && "rows" in ellipsisConfig) { const paragraphEllipsis = mergedExpanded ? false : { ...ellipsisConfig, symbol: ellipsisConfig.expandable ? ( ) : ( ellipsisConfig.symbol ), }; return ( {buildParts()} ); } return ( {buildParts()} ); }, ); ContentText.displayName = "ContentText";