chore: Монорепо с общими пакетами
This commit is contained in:
106
packages/content-suggestions/src/angular/index.ts
Normal file
106
packages/content-suggestions/src/angular/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { ContentEntity, LinkEntity, MentionEntity, TagEntity } from "../core";
|
||||
|
||||
import { findAllEntities } from "../core";
|
||||
|
||||
export interface AngularTextToken {
|
||||
kind: "text";
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface AngularMentionToken {
|
||||
kind: "mention";
|
||||
entity: MentionEntity;
|
||||
}
|
||||
|
||||
export interface AngularTagToken {
|
||||
kind: "tag";
|
||||
entity: TagEntity;
|
||||
}
|
||||
|
||||
export interface AngularLinkToken {
|
||||
kind: "link";
|
||||
entity: LinkEntity;
|
||||
}
|
||||
|
||||
export type AngularContentToken =
|
||||
| AngularTextToken
|
||||
| AngularMentionToken
|
||||
| AngularTagToken
|
||||
| AngularLinkToken;
|
||||
|
||||
export interface AngularContentSnapshot {
|
||||
text: string;
|
||||
entities: ContentEntity[];
|
||||
tokens: AngularContentToken[];
|
||||
}
|
||||
|
||||
export const buildAngularTagHref = (entity: TagEntity): string => {
|
||||
return `/search/?query=${encodeURIComponent(entity.tag.toLowerCase())}`;
|
||||
};
|
||||
|
||||
export const createAngularContentTokens = (
|
||||
inputText: string | null | undefined,
|
||||
): AngularContentToken[] => {
|
||||
const text = inputText ?? "";
|
||||
const entities = findAllEntities(text);
|
||||
|
||||
let cursor = 0;
|
||||
const tokens: AngularContentToken[] = [];
|
||||
|
||||
for (const entity of entities) {
|
||||
if (entity.start > cursor) {
|
||||
tokens.push({
|
||||
kind: "text",
|
||||
text: text.slice(cursor, entity.start),
|
||||
start: cursor,
|
||||
end: entity.start,
|
||||
});
|
||||
}
|
||||
|
||||
if (entity.type === "mention") {
|
||||
tokens.push({
|
||||
kind: "mention",
|
||||
entity,
|
||||
});
|
||||
} else if (entity.type === "tag") {
|
||||
tokens.push({
|
||||
kind: "tag",
|
||||
entity,
|
||||
});
|
||||
} else {
|
||||
tokens.push({
|
||||
kind: "link",
|
||||
entity,
|
||||
});
|
||||
}
|
||||
|
||||
cursor = entity.end;
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
tokens.push({
|
||||
kind: "text",
|
||||
text: text.slice(cursor),
|
||||
start: cursor,
|
||||
end: text.length,
|
||||
});
|
||||
}
|
||||
|
||||
return tokens;
|
||||
};
|
||||
|
||||
export class AngularContentSuggestionsAdapter {
|
||||
snapshot(inputText: string | null | undefined): AngularContentSnapshot {
|
||||
const text = inputText ?? "";
|
||||
const entities = findAllEntities(text);
|
||||
const tokens = createAngularContentTokens(text);
|
||||
|
||||
return {
|
||||
text,
|
||||
entities,
|
||||
tokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
19
packages/content-suggestions/src/core/index.ts
Normal file
19
packages/content-suggestions/src/core/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type {
|
||||
BaseEntity,
|
||||
ContentEntity,
|
||||
LinkEntity,
|
||||
MentionEntity,
|
||||
ParsedMention,
|
||||
ProcessedContent,
|
||||
TagEntity,
|
||||
} from "./types";
|
||||
|
||||
export {
|
||||
findAllEntities,
|
||||
findLinks,
|
||||
findMentions,
|
||||
findTags,
|
||||
mentionLinkRegexp,
|
||||
parseMention,
|
||||
processContent,
|
||||
} from "./parser";
|
||||
115
packages/content-suggestions/src/core/parser.ts
Normal file
115
packages/content-suggestions/src/core/parser.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
ContentEntity,
|
||||
LinkEntity,
|
||||
MentionEntity,
|
||||
ParsedMention,
|
||||
ProcessedContent,
|
||||
TagEntity,
|
||||
} from "./types";
|
||||
|
||||
export const mentionLinkRegexp =
|
||||
/@\[[^\]]+]\([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\)/g;
|
||||
|
||||
export const parseMention = (mention: string): ParsedMention | null => {
|
||||
const regex = /@\[([^\]]+)\]\(([\w-]{36})\)/;
|
||||
const match = mention.match(regex);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mentionText = match[1];
|
||||
const mentionId = match[2];
|
||||
if (!mentionText || !mentionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mention: `@${mentionText}`,
|
||||
id: mentionId,
|
||||
};
|
||||
};
|
||||
|
||||
export const findMentions = (text: string): MentionEntity[] => {
|
||||
let match: RegExpExecArray | null;
|
||||
const matches: MentionEntity[] = [];
|
||||
|
||||
while ((match = mentionLinkRegexp.exec(text)) !== null) {
|
||||
const parsed = parseMention(match[0]);
|
||||
const baseMention: Omit<MentionEntity, "userId"> = {
|
||||
start: match.index,
|
||||
end: mentionLinkRegexp.lastIndex,
|
||||
text: match[0],
|
||||
type: "mention",
|
||||
displayText: parsed?.mention ?? match[0],
|
||||
};
|
||||
|
||||
matches.push(parsed?.id ? { ...baseMention, userId: parsed.id } : baseMention);
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
export const findTags = (content: string): TagEntity[] => {
|
||||
const regex = /#[^\s]{1,201}/g;
|
||||
const results: TagEntity[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const value = match[0];
|
||||
results.push({
|
||||
start: match.index,
|
||||
end: match.index + value.length,
|
||||
text: value,
|
||||
type: "tag",
|
||||
tag: value.replace("#", ""),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const findLinks = (content: string): LinkEntity[] => {
|
||||
const regex =
|
||||
/\b((https?:\/\/)?(?:[\w-]+\.)+[a-z]{2,}(\/[\w\-._~:/?#[\]@!$&'()*+,;=]*)?)/gi;
|
||||
const results: LinkEntity[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const rawUrl = match[0];
|
||||
const hasProtocol = /^https?:\/\//i.test(rawUrl);
|
||||
const fullUrl = hasProtocol ? rawUrl : `https://${rawUrl}`;
|
||||
|
||||
results.push({
|
||||
start: match.index,
|
||||
end: match.index + rawUrl.length,
|
||||
text: rawUrl,
|
||||
url: fullUrl,
|
||||
type: "link",
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const findAllEntities = (content: string): ContentEntity[] => {
|
||||
const mentions = findMentions(content);
|
||||
const tags = findTags(content);
|
||||
const links = findLinks(content);
|
||||
|
||||
return [...mentions, ...tags, ...links].sort((a, b) => a.start - b.start);
|
||||
};
|
||||
|
||||
export const processContent = (content: string): ProcessedContent => {
|
||||
const processedText = content.replace(mentionLinkRegexp, match => {
|
||||
const parsed = parseMention(match);
|
||||
return parsed ? parsed.mention : match;
|
||||
});
|
||||
|
||||
const tags = findTags(content).map(tag => tag.tag);
|
||||
|
||||
return {
|
||||
processedText,
|
||||
tags,
|
||||
};
|
||||
};
|
||||
33
packages/content-suggestions/src/core/types.ts
Normal file
33
packages/content-suggestions/src/core/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface BaseEntity {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MentionEntity extends BaseEntity {
|
||||
type: "mention";
|
||||
displayText: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface TagEntity extends BaseEntity {
|
||||
type: "tag";
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export interface LinkEntity extends BaseEntity {
|
||||
type: "link";
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type ContentEntity = MentionEntity | TagEntity | LinkEntity;
|
||||
|
||||
export interface ParsedMention {
|
||||
mention: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ProcessedContent {
|
||||
processedText: string;
|
||||
tags: string[];
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import type { MentionEntity, TagEntity } from "../../core";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { TachTypography } from "@hublib-web/tach-typography/react";
|
||||
import { ContentText } from "./content-text";
|
||||
|
||||
type BaseContentTextProps = Omit<
|
||||
ComponentProps<typeof ContentText>,
|
||||
"renderMention" | "renderTag"
|
||||
> & {
|
||||
renderMention?: (entity: MentionEntity, index: number) => React.ReactNode;
|
||||
renderTag?: (entity: TagEntity, index: number) => React.ReactNode;
|
||||
};
|
||||
|
||||
const DefaultMention = ({ entity }: { entity: MentionEntity }) => (
|
||||
<TachTypography.Text.Body color="link">{entity.displayText}</TachTypography.Text.Body>
|
||||
);
|
||||
|
||||
const DefaultTag = ({ entity }: { entity: TagEntity }) => (
|
||||
<TachTypography.Text.Body color="link">
|
||||
{entity.text}
|
||||
</TachTypography.Text.Body>
|
||||
);
|
||||
|
||||
export const ContentTextWithSuggestions = ({
|
||||
renderMention,
|
||||
renderTag,
|
||||
...props
|
||||
}: BaseContentTextProps) => {
|
||||
return (
|
||||
<ContentText
|
||||
{...props}
|
||||
renderMention={(entity, index) =>
|
||||
renderMention ? renderMention(entity, index) : <DefaultMention entity={entity} />
|
||||
}
|
||||
renderTag={(entity, index) =>
|
||||
renderTag ? (
|
||||
renderTag(entity, index)
|
||||
) : (
|
||||
<DefaultTag entity={entity} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { ContentTextWithSuggestions } from "./content-text-with-suggestions";
|
||||
|
||||
interface ContentTitleWithSuggestionsProps
|
||||
extends Omit<ComponentProps<typeof ContentTextWithSuggestions>, "weight"> {}
|
||||
|
||||
export const ContentTitleWithSuggestions = ({
|
||||
text,
|
||||
ellipsis,
|
||||
blur = false,
|
||||
...rest
|
||||
}: ContentTitleWithSuggestionsProps) => {
|
||||
const normalizedEllipsis = ellipsis === undefined ? { rows: 2 } : ellipsis;
|
||||
const textProps = text === undefined ? {} : { text };
|
||||
|
||||
return (
|
||||
<ContentTextWithSuggestions
|
||||
weight="bold"
|
||||
blur={blur}
|
||||
ellipsis={normalizedEllipsis}
|
||||
{...textProps}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
27
packages/content-suggestions/src/react/index.tsx
Normal file
27
packages/content-suggestions/src/react/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
export { ContentText, type ContentTextProps } from "./components/content-text";
|
||||
export {
|
||||
ContentTextWithSuggestions,
|
||||
} from "./components/content-text-with-suggestions";
|
||||
export {
|
||||
ContentTitleWithSuggestions,
|
||||
} from "./components/content-title-with-suggestions";
|
||||
|
||||
export type {
|
||||
BaseEntity,
|
||||
ContentEntity,
|
||||
LinkEntity,
|
||||
MentionEntity,
|
||||
ParsedMention,
|
||||
ProcessedContent,
|
||||
TagEntity,
|
||||
} from "../core";
|
||||
|
||||
export {
|
||||
findAllEntities,
|
||||
findLinks,
|
||||
findMentions,
|
||||
findTags,
|
||||
mentionLinkRegexp,
|
||||
parseMention,
|
||||
processContent,
|
||||
} from "../core";
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import type { MentionEntity, TagEntity } from "../core";
|
||||
import React from "react";
|
||||
|
||||
import { TachTypography } from "@hublib-web/tach-typography/react";
|
||||
import { ContentTextWithSuggestions } from "../react";
|
||||
|
||||
const DEMO_TEXT =
|
||||
"Пример текста с упоминанием @[Иван Петров](123e4567-e89b-12d3-a456-426614174000), тегом #frontend и ссылкой docs.example.com";
|
||||
|
||||
const meta: Meta<typeof ContentTextWithSuggestions> = {
|
||||
title: "React/ContentTextWithSuggestions",
|
||||
component: ContentTextWithSuggestions,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
text: DEMO_TEXT,
|
||||
blur: false,
|
||||
},
|
||||
argTypes: {
|
||||
text: { control: "text" },
|
||||
blur: { control: "boolean" },
|
||||
ellipsis: { control: false },
|
||||
renderMention: { control: false },
|
||||
renderTag: { control: false },
|
||||
renderLink: { control: false },
|
||||
ParagraphComponent: { control: false },
|
||||
TextComponent: { control: false },
|
||||
TitleComponent: { control: false },
|
||||
},
|
||||
render: args => (
|
||||
<div style={{ maxWidth: 780, width: "100%" }}>
|
||||
<ContentTextWithSuggestions {...args} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const WithExpandableEllipsis: Story = {
|
||||
args: {
|
||||
ellipsis: {
|
||||
rows: 2,
|
||||
expandable: true,
|
||||
symbol: "Читать полностью",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomMentionAndTagRender: Story = {
|
||||
render: args => {
|
||||
const renderMention = (entity: MentionEntity) => (
|
||||
<TachTypography.Link.Body href={`https://example.com/users/${entity.userId ?? "unknown"}`} color="link">
|
||||
{entity.displayText}
|
||||
</TachTypography.Link.Body>
|
||||
);
|
||||
|
||||
const renderTag = (entity: TagEntity) => (
|
||||
<TachTypography.Link.Body href={`https://example.com/tags/${entity.tag}`} color="link">
|
||||
{entity.text}
|
||||
</TachTypography.Link.Body>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 780, width: "100%" }}>
|
||||
<ContentTextWithSuggestions
|
||||
{...args}
|
||||
renderMention={renderMention}
|
||||
renderTag={renderTag}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
|
||||
import { ContentTitleWithSuggestions } from "../react";
|
||||
|
||||
const DEMO_TITLE =
|
||||
"Заголовок с @[Мария](123e4567-e89b-12d3-a456-426614174001), тегом #release и ссылкой github.com";
|
||||
|
||||
const meta: Meta<typeof ContentTitleWithSuggestions> = {
|
||||
title: "React/ContentTitleWithSuggestions",
|
||||
component: ContentTitleWithSuggestions,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
text: DEMO_TITLE,
|
||||
blur: false,
|
||||
ellipsis: {
|
||||
rows: 2,
|
||||
expandable: true,
|
||||
symbol: "Читать полностью",
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
text: { control: "text" },
|
||||
blur: { control: "boolean" },
|
||||
ellipsis: { control: false },
|
||||
renderMention: { control: false },
|
||||
renderTag: { control: false },
|
||||
renderLink: { control: false },
|
||||
ParagraphComponent: { control: false },
|
||||
TextComponent: { control: false },
|
||||
TitleComponent: { control: false },
|
||||
},
|
||||
render: args => (
|
||||
<div style={{ maxWidth: 780, width: "100%" }}>
|
||||
<ContentTitleWithSuggestions {...args} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const Blurred: Story = {
|
||||
args: {
|
||||
blur: true,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user