chore: Монорепо с общими пакетами
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
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";
|
||||
Reference in New Issue
Block a user