Files
_hublib-web/packages/content-suggestions/src/react/components/content-text.tsx

354 lines
10 KiB
TypeScript
Raw Normal View History

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";