chore: Монорепо с общими пакетами

This commit is contained in:
2026-03-04 16:31:57 +03:00
parent 8f2c799235
commit 915c56351b
420 changed files with 13403 additions and 7071 deletions

View 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,
};
}
}

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

View 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,
};
};

View 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[];
}

View File

@@ -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} />
)
}
/>
);
};

View File

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

View File

@@ -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}
/>
);
};

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

View File

@@ -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>
);
},
};

View File

@@ -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,
},
};