feat(messages): команды чата, личные сообщения и лимиты
AGENT.md 7.6: - команды разбираются на сервере: /me (блок действия), /whisper и /wisper (шёпот), /scream (громкий блок), «//» экранирует слэш, неизвестная команда остаётся обычным текстом, пустой текст у команды — 422; - /ls <логин> <текст> — личное сообщение: тип private, адресат в mentions, получатель проверяется на участие в сервере и доступ к комнате; сообщение не отдаётся посторонним ни в истории, ни в поиске (и FTS, и подстрочном) и доставляется в Gateway только автору и адресатам; - лимиты: 10 правок в минуту, 20 уникальных реакций на сообщение, 50 закреплений на комнату, не более 10 упоминаний, @everyone/@here только с MENTION_EVERYONE; - тесты: команды и экранирование, изоляция личного сообщения (история, поиск, третий участник), лимит правок и @everyone.
This commit is contained in:
@@ -6,6 +6,11 @@ import { fileContentUrl, uploadChannelFile, type UploadedFile } from '@/api/file
|
||||
import { fetchMeta, metaQueryKey } from '@/api/meta';
|
||||
import { MESSAGE_MAX_LENGTH, retryAfterMs, type Message } from '@/api/messages';
|
||||
import type { Channel } from '@/api/types';
|
||||
import {
|
||||
COMPOSER_TEXT_CLASS,
|
||||
ComposerMirror,
|
||||
type ComposerSelection,
|
||||
} from '@/components/chat/ComposerMirror';
|
||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { formatBytes, errorCode } from '@/lib/format';
|
||||
@@ -32,6 +37,11 @@ const LINE_HEIGHT_PX = 22;
|
||||
* Композер сообщения: автовысота, Enter/Shift+Enter, счётчик символов,
|
||||
* вложения (скрепка и drag&drop), плашка ответа и обработка ошибок отправки
|
||||
* (лимит частоты 429 с таймером, отсутствие прав).
|
||||
*
|
||||
* Над полем лежит зеркало (`ComposerMirror`): оно рисует разметку, поэтому
|
||||
* форматирование видно ещё до отправки, а текст самой textarea прозрачный.
|
||||
* Позиция каретки живёт в состоянии и обновляется на ввод, выделение, клик и
|
||||
* отпускание клавиши.
|
||||
*/
|
||||
export function Composer({
|
||||
channel,
|
||||
@@ -55,8 +65,11 @@ export function Composer({
|
||||
/** Тикающее «сейчас»: по нему считается остаток лимита частоты. */
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [dragging, setDragging] = useState(false);
|
||||
/** Позиция каретки: по ней зеркало решает, показывать ли маркеры. */
|
||||
const [selection, setSelection] = useState<ComposerSelection>({ start: 0, end: 0 });
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const mirrorRef = useRef<HTMLDivElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Предельный размер файла знает сервер (`GET /meta`); если он неизвестен,
|
||||
@@ -69,6 +82,26 @@ export function Composer({
|
||||
});
|
||||
const maxUploadSize = meta.data?.max_upload_size;
|
||||
|
||||
/** Прокрутка зеркала повторяет прокрутку поля (текст длиннее восьми строк). */
|
||||
const syncScroll = useCallback(() => {
|
||||
const element = textareaRef.current;
|
||||
const mirror = mirrorRef.current;
|
||||
if (element === null || mirror === null) {
|
||||
return;
|
||||
}
|
||||
mirror.scrollTop = element.scrollTop;
|
||||
mirror.scrollLeft = element.scrollLeft;
|
||||
}, []);
|
||||
|
||||
/** Обновление каретки: лишних перерисовок не делаем. */
|
||||
const syncSelection = useCallback((element: HTMLTextAreaElement) => {
|
||||
const start = element.selectionStart;
|
||||
const end = element.selectionEnd;
|
||||
setSelection((current) =>
|
||||
current.start === start && current.end === end ? current : { start, end },
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Автовысота: от одной строки до ~8.
|
||||
useEffect(() => {
|
||||
const element = textareaRef.current;
|
||||
@@ -78,7 +111,8 @@ export function Composer({
|
||||
element.style.height = 'auto';
|
||||
const max = Math.min(MAX_TEXTAREA_HEIGHT_PX, LINE_HEIGHT_PX * 8);
|
||||
element.style.height = `${String(Math.min(element.scrollHeight, max))}px`;
|
||||
}, [value]);
|
||||
syncScroll();
|
||||
}, [value, syncScroll]);
|
||||
|
||||
// Таймер лимита частоты: пока он идёт, отправка заблокирована.
|
||||
// Когда пауза истекла, снимаем `retryAt` — иначе интервал тикал бы вечно.
|
||||
@@ -160,6 +194,8 @@ export function Composer({
|
||||
...(files.length === 0 ? {} : { files }),
|
||||
});
|
||||
setValue('');
|
||||
// Поле пустое — сбрасываем и каретку, чтобы зеркало не ждало маркеров.
|
||||
setSelection({ start: 0, end: 0 });
|
||||
setFiles([]);
|
||||
setNotice(null);
|
||||
// Отправка прошла — лимит частоты больше не держим.
|
||||
@@ -292,33 +328,60 @@ export function Composer({
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
|
||||
name: channel.name,
|
||||
})}
|
||||
placeholder={t(direct ? 'chat.composer.directPlaceholder' : 'chat.composer.placeholder', {
|
||||
name: channel.name,
|
||||
})}
|
||||
className="max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent py-1 text-sm text-fg placeholder:text-fg-muted/70 focus:outline-none disabled:opacity-60"
|
||||
rows={1}
|
||||
value={value}
|
||||
maxLength={MESSAGE_MAX_LENGTH}
|
||||
disabled={blockedReason !== null}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
useMessagesStore.getState().notifyTyping(channel.id);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
if (event.key === 'Escape' && replyTo !== null) {
|
||||
onCancelReply();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Заблокированное поле приглушаем вместе с зеркалом: плейсхолдер и
|
||||
подсветка должны выглядеть одинаково неактивными. */}
|
||||
<div className={`relative w-full ${blockedReason === null ? '' : 'opacity-60'}`}>
|
||||
<ComposerMirror
|
||||
value={value}
|
||||
selection={selection}
|
||||
mirrorRef={mirrorRef}
|
||||
resolveMention={authors.resolveMention}
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
aria-label={t(direct ? 'chat.composer.directLabel' : 'chat.composer.label', {
|
||||
name: channel.name,
|
||||
})}
|
||||
placeholder={t(
|
||||
direct ? 'chat.composer.directPlaceholder' : 'chat.composer.placeholder',
|
||||
{ name: channel.name },
|
||||
)}
|
||||
// Текст прозрачный: видно зеркало с разметкой. Каретка и выделение
|
||||
// остаются родными для поля ввода.
|
||||
className={`gl-composer-scroll relative z-10 block max-h-44 min-h-[1.75rem] w-full resize-none bg-transparent text-transparent caret-fg selection:bg-accent/30 placeholder:text-fg-muted/70 focus:outline-none ${COMPOSER_TEXT_CLASS}`}
|
||||
rows={1}
|
||||
value={value}
|
||||
maxLength={MESSAGE_MAX_LENGTH}
|
||||
disabled={blockedReason !== null}
|
||||
// Текст невидим, поэтому подчёркивание проверки орфографии рисовалось
|
||||
// бы поверх подсветки.
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
syncSelection(event.target);
|
||||
useMessagesStore.getState().notifyTyping(channel.id);
|
||||
}}
|
||||
onSelect={(event) => {
|
||||
syncSelection(event.currentTarget);
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
syncSelection(event.currentTarget);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
syncSelection(event.currentTarget);
|
||||
}}
|
||||
onScroll={syncScroll}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
if (event.key === 'Escape' && replyTo !== null) {
|
||||
onCancelReply();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('chat.composer.send')}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { Fragment, useMemo, type ReactNode, type RefObject } from 'react';
|
||||
|
||||
import { useChannelNames } from '@/components/chat/useChannelNames';
|
||||
import {
|
||||
hasMarkers,
|
||||
selectionIntersects,
|
||||
splitLink,
|
||||
toMirrorLines,
|
||||
type InlineToken,
|
||||
type MirrorLine,
|
||||
} from '@/lib/markdown';
|
||||
|
||||
/**
|
||||
* Зеркало композера: слой под `textarea`, который рисует тот же текст, но с
|
||||
* разметкой. Текст в самом поле ввода прозрачный, поэтому пользователь видит
|
||||
* форматирование ещё до отправки, а каретка и выделение остаются родными для
|
||||
* textarea.
|
||||
*
|
||||
* Маркеры (`**`, `==`, `~~`, `` ` `` и т.д.) видны только у фрагмента, внутри
|
||||
* которого стоит каретка, и приглушены — так их удобно править. У остальных
|
||||
* фрагментов маркеры скрыты, а содержимое отформатировано — ровно как в ленте
|
||||
* (`MessageContent` использует тот же разбор из `@/lib/markdown`).
|
||||
*
|
||||
* Оформление здесь намеренно «безметрическое»: ни отступов, ни моноширинного
|
||||
* шрифта, ни изменения размера. Метрики текста обязаны совпадать с textarea,
|
||||
* иначе подсветка разъедется с полем ввода.
|
||||
*/
|
||||
|
||||
/** Позиция каретки или выделения в поле ввода. */
|
||||
export interface ComposerSelection {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Метрики текста, общие у зеркала и textarea: размер, интерлиньяж и отступы.
|
||||
* Любое расхождение сдвинет подсветку относительно поля ввода.
|
||||
*/
|
||||
export const COMPOSER_TEXT_CLASS = 'py-1 text-sm leading-5';
|
||||
|
||||
type MentionResolver = (userId: string) => { name: string; isMe: boolean } | null;
|
||||
|
||||
interface ComposerMirrorProps {
|
||||
value: string;
|
||||
selection: ComposerSelection;
|
||||
/** Ссылка на слой: композер синхронизирует по ней прокрутку. */
|
||||
mirrorRef: RefObject<HTMLDivElement | null>;
|
||||
/** Как показать упоминание — как в ленте сообщений. */
|
||||
resolveMention?: MentionResolver;
|
||||
}
|
||||
|
||||
/** Содержимое токена без маркеров. */
|
||||
function renderFormatted(
|
||||
token: InlineToken,
|
||||
content: string,
|
||||
channels: ReadonlySet<string>,
|
||||
resolveMention: MentionResolver | undefined,
|
||||
): ReactNode {
|
||||
switch (token.kind) {
|
||||
case 'code':
|
||||
// Фон вместо `font-mono`: смена шрифта сдвинула бы метрики строки.
|
||||
return <span className="rounded-[var(--radius-sm)] bg-surface-3">{content}</span>;
|
||||
case 'bold-italic':
|
||||
return (
|
||||
<strong className="font-semibold">
|
||||
<em>{content}</em>
|
||||
</strong>
|
||||
);
|
||||
case 'bold':
|
||||
return <strong className="font-semibold">{content}</strong>;
|
||||
case 'italic':
|
||||
return <em>{content}</em>;
|
||||
case 'strike':
|
||||
return <s className="opacity-70">{content}</s>;
|
||||
case 'spoiler':
|
||||
// В отличие от ленты текст спойлера виден: иначе его нельзя было бы
|
||||
// править. Приглушённая подложка показывает, что фрагмент скрыт.
|
||||
return <span className="rounded-[var(--radius-sm)] bg-fg-muted/40 text-fg">{content}</span>;
|
||||
case 'mark':
|
||||
return <mark className="rounded-[var(--radius-sm)] bg-accent/30 text-fg">{content}</mark>;
|
||||
case 'mention': {
|
||||
const mention = resolveMention?.(content) ?? null;
|
||||
return (
|
||||
<span
|
||||
className={`rounded-[var(--radius-sm)] font-medium ${
|
||||
mention?.isMe === true ? 'bg-accent/30 text-fg' : 'bg-accent/15 text-accent'
|
||||
}`}
|
||||
>
|
||||
@{mention?.name ?? content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case 'link': {
|
||||
const { href, trailing } = splitLink(token.raw);
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-accent underline decoration-accent/40 underline-offset-2"
|
||||
>
|
||||
{href}
|
||||
</a>
|
||||
{trailing}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'channel': {
|
||||
const name = token.raw.slice(1);
|
||||
return channels.has(name.toLowerCase()) ? (
|
||||
<span className="text-accent">{token.raw}</span>
|
||||
) : (
|
||||
token.raw
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Один токен: маркеры (если каретка внутри) плюс оформленное содержимое. */
|
||||
function renderToken(
|
||||
token: InlineToken,
|
||||
line: MirrorLine,
|
||||
selection: ComposerSelection,
|
||||
channels: ReadonlySet<string>,
|
||||
resolveMention: MentionResolver | undefined,
|
||||
key: string,
|
||||
): ReactNode {
|
||||
const text = line.text;
|
||||
const toLocal = (absolute: number): number => absolute - line.start;
|
||||
// Каретка внутри фрагмента — показываем маркеры приглушёнными, чтобы их
|
||||
// было видно и удобно править. Иначе маркеры скрыты.
|
||||
const active = hasMarkers(token.kind) && selectionIntersects(selection, token.start, token.end);
|
||||
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
{active ? (
|
||||
<span className="text-fg-muted">
|
||||
{text.slice(toLocal(token.start), toLocal(token.contentStart))}
|
||||
</span>
|
||||
) : null}
|
||||
{renderFormatted(
|
||||
token,
|
||||
text.slice(toLocal(token.contentStart), toLocal(token.contentEnd)),
|
||||
channels,
|
||||
resolveMention,
|
||||
)}
|
||||
{active ? (
|
||||
<span className="text-fg-muted">
|
||||
{text.slice(toLocal(token.contentEnd), toLocal(token.end))}
|
||||
</span>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
/** Строка с разметкой: текст между токенами остаётся обычным текстом. */
|
||||
function renderTokens(
|
||||
line: MirrorLine,
|
||||
selection: ComposerSelection,
|
||||
channels: ReadonlySet<string>,
|
||||
resolveMention: MentionResolver | undefined,
|
||||
): ReactNode {
|
||||
const start = line.start + line.prefixLength;
|
||||
const nodes: ReactNode[] = [];
|
||||
let cursor = start;
|
||||
|
||||
for (const [index, token] of line.tokens.entries()) {
|
||||
if (token.start > cursor) {
|
||||
nodes.push(line.text.slice(cursor - line.start, token.start - line.start));
|
||||
}
|
||||
nodes.push(
|
||||
renderToken(
|
||||
token,
|
||||
line,
|
||||
selection,
|
||||
channels,
|
||||
resolveMention,
|
||||
`${String(line.start)}-${String(index)}`,
|
||||
),
|
||||
);
|
||||
cursor = token.end;
|
||||
}
|
||||
if (cursor < line.end) {
|
||||
nodes.push(line.text.slice(cursor - line.start));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Одна строка зеркала. */
|
||||
function renderLine(
|
||||
line: MirrorLine,
|
||||
selection: ComposerSelection,
|
||||
channels: ReadonlySet<string>,
|
||||
resolveMention: MentionResolver | undefined,
|
||||
): ReactNode {
|
||||
switch (line.kind) {
|
||||
case 'code':
|
||||
return line.text === '' ? null : <span className="bg-surface-3/60">{line.text}</span>;
|
||||
case 'fence': {
|
||||
// Ограждение ``` показываем, только когда каретка внутри блока кода;
|
||||
// строка при этом остаётся на месте, поэтому переносы не съезжают.
|
||||
const range = line.codeRange;
|
||||
const active = range !== null && selectionIntersects(selection, range.start, range.end);
|
||||
return active ? <span className="text-fg-muted">{line.text}</span> : null;
|
||||
}
|
||||
case 'quote':
|
||||
return (
|
||||
<>
|
||||
<span className="text-fg-muted">{line.text.slice(0, line.prefixLength)}</span>
|
||||
{renderTokens(line, selection, channels, resolveMention)}
|
||||
</>
|
||||
);
|
||||
case 'text':
|
||||
return renderTokens(line, selection, channels, resolveMention);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Слой подсветки. `aria-hidden`: скринридер читает исходный текст из textarea,
|
||||
* а не оформленную копию. `pointer-events-none`: весь ввод остаётся у поля.
|
||||
*/
|
||||
export function ComposerMirror({
|
||||
value,
|
||||
selection,
|
||||
mirrorRef,
|
||||
resolveMention,
|
||||
}: ComposerMirrorProps) {
|
||||
const lines = useMemo(() => toMirrorLines(value), [value]);
|
||||
const channels = useChannelNames();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mirrorRef}
|
||||
aria-hidden="true"
|
||||
data-testid="composer-mirror"
|
||||
className={`gl-composer-scroll pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words text-fg ${COMPOSER_TEXT_CLASS}`}
|
||||
>
|
||||
{lines.map((line, index) => (
|
||||
<Fragment key={`${String(line.start)}-${String(index)}`}>
|
||||
{renderLine(line, selection, channels, resolveMention)}
|
||||
{index < lines.length - 1 ? '\n' : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
import { Fragment, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { useChannelNames } from '@/components/chat/useChannelNames';
|
||||
import {
|
||||
parseBlocks,
|
||||
parseInline,
|
||||
splitLink,
|
||||
QUOTE_PREFIX,
|
||||
type InlineToken,
|
||||
} from '@/lib/markdown';
|
||||
|
||||
/**
|
||||
* Лёгкий безопасный рендер разметки сообщения.
|
||||
*
|
||||
* `dangerouslySetInnerHTML` не используется: текст разбирается в React-узлы,
|
||||
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
|
||||
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
|
||||
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
|
||||
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>` и `#канал`.
|
||||
* поэтому HTML из сообщения остаётся текстом. Сам разбор живёт в
|
||||
* `@/lib/markdown` — его же использует зеркало композера, чтобы предпросмотр
|
||||
* форматирования в поле ввода совпадал с лентой.
|
||||
*
|
||||
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
|
||||
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
|
||||
@@ -23,27 +29,6 @@ export interface MessageContentProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
|
||||
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
|
||||
* ссылкой. Одиночная `*` не считается маркером внутри ряда звёздочек
|
||||
* (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а `_` — внутри
|
||||
* слова (`foo_bar_baz`).
|
||||
*/
|
||||
const INLINE_PATTERN =
|
||||
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|((?<!\*)\*(?!\*)[^*\n]+(?<!\*)\*(?!\*))|((?<![\p{L}\p{N}_])_(?!_)[^_\n]+(?<!_)_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@\d+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
|
||||
|
||||
/** Ссылка: обрезаем хвостовую пунктуацию, которая не частью адреса. */
|
||||
function splitLink(raw: string): { href: string; trailing: string } {
|
||||
let href = raw;
|
||||
let trailing = '';
|
||||
while (href.length > 0 && '.,!?;:)]}»"'.includes(href[href.length - 1] ?? '')) {
|
||||
trailing = (href[href.length - 1] ?? '') + trailing;
|
||||
href = href.slice(0, -1);
|
||||
}
|
||||
return { href, trailing };
|
||||
}
|
||||
|
||||
type MentionResolver = MessageContentProps['resolveMention'];
|
||||
|
||||
/**
|
||||
@@ -69,86 +54,68 @@ function Spoiler({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderInline(
|
||||
text: string,
|
||||
/** Оформление одного токена: содержимое уже без маркеров. */
|
||||
function renderToken(
|
||||
token: InlineToken,
|
||||
content: string,
|
||||
key: string,
|
||||
resolveMention: MentionResolver,
|
||||
channels: ReadonlySet<string>,
|
||||
keyPrefix: string,
|
||||
): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let index = 0;
|
||||
|
||||
for (const match of text.matchAll(INLINE_PATTERN)) {
|
||||
const start = match.index ?? 0;
|
||||
if (start > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, start));
|
||||
}
|
||||
const token = match[0];
|
||||
const key = `${keyPrefix}-${String(index)}`;
|
||||
index += 1;
|
||||
|
||||
if (token.startsWith('`')) {
|
||||
nodes.push(
|
||||
): ReactNode {
|
||||
switch (token.kind) {
|
||||
case 'code':
|
||||
return (
|
||||
<code
|
||||
key={key}
|
||||
className="rounded-[var(--radius-sm)] bg-surface-3 px-1 py-0.5 font-mono text-[0.85em]"
|
||||
>
|
||||
{token.slice(1, -1)}
|
||||
</code>,
|
||||
{content}
|
||||
</code>
|
||||
);
|
||||
} else if (token.startsWith('***')) {
|
||||
nodes.push(
|
||||
case 'bold-italic':
|
||||
return (
|
||||
<strong key={key} className="font-semibold">
|
||||
<em>{token.slice(3, -3)}</em>
|
||||
</strong>,
|
||||
<em>{content}</em>
|
||||
</strong>
|
||||
);
|
||||
} else if (token.startsWith('**')) {
|
||||
nodes.push(
|
||||
case 'bold':
|
||||
return (
|
||||
<strong key={key} className="font-semibold">
|
||||
{token.slice(2, -2)}
|
||||
</strong>,
|
||||
{content}
|
||||
</strong>
|
||||
);
|
||||
} else if (token.startsWith('__')) {
|
||||
nodes.push(
|
||||
<strong key={key} className="font-semibold">
|
||||
{token.slice(2, -2)}
|
||||
</strong>,
|
||||
);
|
||||
} else if (token.startsWith('~~')) {
|
||||
nodes.push(
|
||||
case 'italic':
|
||||
return <em key={key}>{content}</em>;
|
||||
case 'strike':
|
||||
return (
|
||||
<s key={key} className="opacity-70">
|
||||
{token.slice(2, -2)}
|
||||
</s>,
|
||||
{content}
|
||||
</s>
|
||||
);
|
||||
} else if (token.startsWith('||')) {
|
||||
nodes.push(<Spoiler key={key}>{token.slice(2, -2)}</Spoiler>);
|
||||
} else if (token.startsWith('*')) {
|
||||
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
|
||||
} else if (token.startsWith('_')) {
|
||||
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
|
||||
} else if (token.startsWith('==')) {
|
||||
nodes.push(
|
||||
case 'spoiler':
|
||||
return <Spoiler key={key}>{content}</Spoiler>;
|
||||
case 'mark':
|
||||
return (
|
||||
<mark key={key} className="rounded-[var(--radius-sm)] bg-accent/30 px-0.5 text-fg">
|
||||
{token.slice(2, -2)}
|
||||
</mark>,
|
||||
{content}
|
||||
</mark>
|
||||
);
|
||||
} else if (token.startsWith('<@')) {
|
||||
const userId = token.slice(2, -1);
|
||||
const mention = resolveMention?.(userId) ?? null;
|
||||
nodes.push(
|
||||
case 'mention': {
|
||||
const mention = resolveMention?.(content) ?? null;
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className={`rounded-[var(--radius-sm)] px-1 font-medium ${
|
||||
mention?.isMe === true ? 'bg-accent/30 text-fg' : 'bg-accent/15 text-accent'
|
||||
}`}
|
||||
>
|
||||
@{mention?.name ?? userId}
|
||||
</span>,
|
||||
@{mention?.name ?? content}
|
||||
</span>
|
||||
);
|
||||
} else if (token.startsWith('http')) {
|
||||
const { href, trailing } = splitLink(token);
|
||||
nodes.push(
|
||||
}
|
||||
case 'link': {
|
||||
const { href, trailing } = splitLink(token.raw);
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<a
|
||||
href={href}
|
||||
@@ -159,23 +126,49 @@ function renderInline(
|
||||
{href}
|
||||
</a>
|
||||
{trailing}
|
||||
</Fragment>,
|
||||
);
|
||||
} else {
|
||||
// `#канал`: подсвечиваем только известный канал, иначе оставляем текстом.
|
||||
const name = token.slice(1);
|
||||
const known = channels.has(name.toLowerCase());
|
||||
nodes.push(
|
||||
known ? (
|
||||
<span key={key} className="rounded-[var(--radius-sm)] bg-accent/15 px-1 text-accent">
|
||||
{token}
|
||||
</span>
|
||||
) : (
|
||||
token
|
||||
),
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
lastIndex = start + token.length;
|
||||
case 'channel': {
|
||||
// `#канал`: подсвечиваем только известный канал, иначе оставляем текстом.
|
||||
const name = token.raw.slice(1);
|
||||
return channels.has(name.toLowerCase()) ? (
|
||||
<span key={key} className="rounded-[var(--radius-sm)] bg-accent/15 px-1 text-accent">
|
||||
{token.raw}
|
||||
</span>
|
||||
) : (
|
||||
token.raw
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderInline(
|
||||
text: string,
|
||||
resolveMention: MentionResolver,
|
||||
channels: ReadonlySet<string>,
|
||||
keyPrefix: string,
|
||||
): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let index = 0;
|
||||
|
||||
for (const token of parseInline(text)) {
|
||||
if (token.start > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, token.start));
|
||||
}
|
||||
const key = `${keyPrefix}-${String(index)}`;
|
||||
index += 1;
|
||||
nodes.push(
|
||||
renderToken(
|
||||
token,
|
||||
text.slice(token.contentStart, token.contentEnd),
|
||||
key,
|
||||
resolveMention,
|
||||
channels,
|
||||
),
|
||||
);
|
||||
lastIndex = token.end;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
@@ -184,68 +177,10 @@ function renderInline(
|
||||
return nodes;
|
||||
}
|
||||
|
||||
interface Block {
|
||||
kind: 'text' | 'quote' | 'code';
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
/** Разбиение на блоки: цитаты, огороженный код и обычный текст. */
|
||||
function toBlocks(content: string): Block[] {
|
||||
const lines = content.split('\n');
|
||||
const blocks: Block[] = [];
|
||||
let fence: Block | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const fenceMatch = /^```(\w*)\s*$/u.exec(line.trim());
|
||||
if (fence !== null) {
|
||||
if (fenceMatch !== null) {
|
||||
blocks.push(fence);
|
||||
fence = null;
|
||||
} else {
|
||||
fence.lines.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (fenceMatch !== null) {
|
||||
fence = { kind: 'code', lines: [] };
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('> ')) {
|
||||
const previous = blocks[blocks.length - 1];
|
||||
if (previous?.kind === 'quote') {
|
||||
previous.lines.push(line.slice(2));
|
||||
} else {
|
||||
blocks.push({ kind: 'quote', lines: [line.slice(2)] });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const previous = blocks[blocks.length - 1];
|
||||
if (previous !== undefined && previous.kind === 'text') {
|
||||
previous.lines.push(line);
|
||||
} else {
|
||||
blocks.push({ kind: 'text', lines: [line] });
|
||||
}
|
||||
}
|
||||
if (fence !== null) {
|
||||
// Незакрытый блок кода — показываем как код, а не теряем текст.
|
||||
blocks.push(fence);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) {
|
||||
const blocks = toBlocks(content);
|
||||
const blocks = useMemo(() => parseBlocks(content), [content]);
|
||||
// Имена каналов снапшота: `#канал` подсвечиваем только для них.
|
||||
const guilds = useSessionStore((state) => state.guilds);
|
||||
const channels = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
for (const guild of guilds) {
|
||||
for (const channel of guild.channels) {
|
||||
names.add(channel.name.toLowerCase());
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}, [guilds]);
|
||||
const channels = useChannelNames();
|
||||
|
||||
return (
|
||||
<div className={`whitespace-pre-wrap break-words ${className}`}>
|
||||
@@ -257,20 +192,30 @@ export function MessageContent({ content, resolveMention, className = '' }: Mess
|
||||
key={key}
|
||||
className="my-1 overflow-x-auto rounded-[var(--radius-md)] border border-border/60 bg-surface-2 p-2 font-mono text-xs"
|
||||
>
|
||||
<code>{block.lines.join('\n')}</code>
|
||||
<code>{block.lines.map((line) => line.text).join('\n')}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'quote') {
|
||||
return (
|
||||
<blockquote key={key} className="my-1 border-l-2 border-border pl-2 text-fg-muted">
|
||||
{renderInline(block.lines.join('\n'), resolveMention, channels, key)}
|
||||
{renderInline(
|
||||
block.lines.map((line) => line.text.slice(QUOTE_PREFIX.length)).join('\n'),
|
||||
resolveMention,
|
||||
channels,
|
||||
key,
|
||||
)}
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p key={key} className="min-w-0">
|
||||
{renderInline(block.lines.join('\n'), resolveMention, channels, key)}
|
||||
{renderInline(
|
||||
block.lines.map((line) => line.text).join('\n'),
|
||||
resolveMention,
|
||||
channels,
|
||||
key,
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Имена каналов из снапшота: `#канал` подсвечивается только для известных
|
||||
* комнат. Общий хук для ленты сообщений и зеркала композера.
|
||||
*/
|
||||
export function useChannelNames(): ReadonlySet<string> {
|
||||
const guilds = useSessionStore((state) => state.guilds);
|
||||
return useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
for (const guild of guilds) {
|
||||
for (const channel of guild.channels) {
|
||||
names.add(channel.name.toLowerCase());
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}, [guilds]);
|
||||
}
|
||||
@@ -370,6 +370,15 @@ body {
|
||||
transition: transform var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
/*
|
||||
* Композер: поле ввода и зеркало подсветки должны переносить строки одинаково.
|
||||
* Полосу прокрутки резервируем всегда — иначе при переполнении textarea её
|
||||
* ширина сузила бы текстовую область только у поля, и подсветка разъехалась бы.
|
||||
*/
|
||||
.gl-composer-scroll {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/*
|
||||
* Доступность: при `prefers-reduced-motion: reduce` анимации становятся
|
||||
* мгновенными. Задержки каскада тоже обнуляем — иначе элементы списка
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Общий разбор разметки сообщения: блоки (текст, цитата, код) и строчные
|
||||
* токены с диапазонами.
|
||||
*
|
||||
* Модуль намеренно не рисует ничего сам: им пользуются оба потребителя —
|
||||
* `MessageContent` (лента сообщений) и зеркало композера (подсветка под
|
||||
* textarea). Разбор один, поэтому предпросмотр в поле ввода показывает ровно
|
||||
* то же форматирование, что увидит получатель.
|
||||
*
|
||||
* Диапазоны нужны зеркалу: по ним видно, где у токена маркеры
|
||||
* (`start`…`contentStart` и `contentEnd`…`end`), а где содержимое. Это
|
||||
* позволяет спрятать маркеры, пока каретка вне фрагмента, и показать их
|
||||
* приглушёнными, когда пользователь его правит.
|
||||
*
|
||||
* `dangerouslySetInnerHTML` не используется: текст разбирается в React-узлы,
|
||||
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
|
||||
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
|
||||
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
|
||||
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>` и `#канал`.
|
||||
*
|
||||
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
|
||||
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
|
||||
*/
|
||||
|
||||
/** Вид строчного токена. */
|
||||
export type InlineKind =
|
||||
| 'code'
|
||||
| 'bold-italic'
|
||||
| 'bold'
|
||||
| 'italic'
|
||||
| 'strike'
|
||||
| 'spoiler'
|
||||
| 'mark'
|
||||
| 'mention'
|
||||
| 'link'
|
||||
| 'channel';
|
||||
|
||||
/** Токен разметки с диапазонами в исходном тексте. */
|
||||
export interface InlineToken {
|
||||
kind: InlineKind;
|
||||
/** Исходный текст токена вместе с маркерами. */
|
||||
raw: string;
|
||||
/** Смещение первого символа токена (включая открывающий маркер). */
|
||||
start: number;
|
||||
/** Смещение за последним символом токена. */
|
||||
end: number;
|
||||
/** Начало содержимого: сразу после открывающего маркера. */
|
||||
contentStart: number;
|
||||
/** Конец содержимого: перед закрывающим маркером. */
|
||||
contentEnd: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
|
||||
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
|
||||
* ссылкой. Одиночная `*` не считается маркером внутри ряда звёздочек
|
||||
* (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а `_` — внутри
|
||||
* слова (`foo_bar_baz`).
|
||||
*/
|
||||
const INLINE_PATTERN =
|
||||
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|((?<!\*)\*(?!\*)[^*\n]+(?<!\*)\*(?!\*))|((?<![\p{L}\p{N}_])_(?!_)[^_\n]+(?<!_)_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@\d+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
|
||||
|
||||
/** Вид токена по номеру группы шаблона (порядок альтернатив выше). */
|
||||
const GROUP_KINDS: readonly InlineKind[] = [
|
||||
'code',
|
||||
'bold-italic',
|
||||
'bold',
|
||||
'bold',
|
||||
'strike',
|
||||
'spoiler',
|
||||
'italic',
|
||||
'italic',
|
||||
'mark',
|
||||
'mention',
|
||||
'link',
|
||||
'channel',
|
||||
];
|
||||
|
||||
/** Длина парных маркеров вокруг содержимого. */
|
||||
const MARKER_LENGTH: Readonly<Record<InlineKind, number>> = {
|
||||
code: 1,
|
||||
'bold-italic': 3,
|
||||
bold: 2,
|
||||
italic: 1,
|
||||
strike: 2,
|
||||
spoiler: 2,
|
||||
mark: 2,
|
||||
// У упоминаний, ссылок и каналов маркеров нет: содержимое — весь токен.
|
||||
mention: 0,
|
||||
link: 0,
|
||||
channel: 0,
|
||||
};
|
||||
|
||||
/** Вид токена по совпавшей группе шаблона. */
|
||||
function kindOf(match: RegExpMatchArray): InlineKind | null {
|
||||
for (let group = 1; group < match.length; group += 1) {
|
||||
if (match[group] !== undefined) {
|
||||
return GROUP_KINDS[group - 1] ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Границы содержимого токена в абсолютных смещениях. */
|
||||
function contentBounds(
|
||||
kind: InlineKind,
|
||||
raw: string,
|
||||
start: number,
|
||||
): { contentStart: number; contentEnd: number } {
|
||||
if (kind === 'mention') {
|
||||
// `<@123>`: содержимое — идентификатор без `<@` и `>`.
|
||||
return { contentStart: start + 2, contentEnd: start + raw.length - 1 };
|
||||
}
|
||||
const marker = MARKER_LENGTH[kind];
|
||||
return { contentStart: start + marker, contentEnd: start + raw.length - marker };
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбор строчной разметки. `offset` — смещение `text` в исходном тексте:
|
||||
* все диапазоны токенов возвращаются в абсолютных координатах, что нужно
|
||||
* зеркалу для сопоставления с позицией каретки.
|
||||
*/
|
||||
export function parseInline(text: string, offset = 0): InlineToken[] {
|
||||
const tokens: InlineToken[] = [];
|
||||
for (const match of text.matchAll(INLINE_PATTERN)) {
|
||||
const kind = kindOf(match);
|
||||
if (kind === null) {
|
||||
continue;
|
||||
}
|
||||
const raw = match[0];
|
||||
const start = offset + (match.index ?? 0);
|
||||
tokens.push({
|
||||
kind,
|
||||
raw,
|
||||
start,
|
||||
end: start + raw.length,
|
||||
...contentBounds(kind, raw, start),
|
||||
});
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Есть ли у токена парные маркеры. У упоминаний, ссылок и каналов их нет:
|
||||
* содержимое — весь токен, прятать нечего.
|
||||
*/
|
||||
export function hasMarkers(kind: InlineKind): boolean {
|
||||
return kind !== 'mention' && kind !== 'link' && kind !== 'channel';
|
||||
}
|
||||
|
||||
/** Ссылка: обрезаем хвостовую пунктуацию, которая не часть адреса. */
|
||||
export function splitLink(raw: string): { href: string; trailing: string } {
|
||||
let href = raw;
|
||||
let trailing = '';
|
||||
while (href.length > 0 && '.,!?;:)]}»"'.includes(href[href.length - 1] ?? '')) {
|
||||
trailing = (href[href.length - 1] ?? '') + trailing;
|
||||
href = href.slice(0, -1);
|
||||
}
|
||||
return { href, trailing };
|
||||
}
|
||||
|
||||
/** Строка блока с абсолютными смещениями в исходном тексте. */
|
||||
export interface BlockLine {
|
||||
text: string;
|
||||
/** Смещение первого символа строки. */
|
||||
start: number;
|
||||
/** Смещение за последним символом строки (без завершающего `\n`). */
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Вид блока: обычный текст, цитата или блок кода. */
|
||||
export type BlockKind = 'text' | 'quote' | 'code';
|
||||
|
||||
/** Блок разметки: строки и границы в исходном тексте. */
|
||||
export interface MessageBlock {
|
||||
kind: BlockKind;
|
||||
start: number;
|
||||
end: number;
|
||||
lines: BlockLine[];
|
||||
/** Ограждение ``` в начале блока кода; у остальных блоков — null. */
|
||||
fenceStart: BlockLine | null;
|
||||
/** Закрывающее ограждение; у незакрытого блока кода — null. */
|
||||
fenceEnd: BlockLine | null;
|
||||
}
|
||||
|
||||
/** Префикс цитаты: с него начинается каждая строка блока-цитаты. */
|
||||
export const QUOTE_PREFIX = '> ';
|
||||
|
||||
/** Шаблон строки-ограждения блока кода. */
|
||||
const CODE_FENCE_PATTERN = /^```(\w*)\s*$/u;
|
||||
|
||||
function fence(text: string): boolean {
|
||||
return CODE_FENCE_PATTERN.exec(text.trim()) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбиение на блоки: цитаты, огороженный код и обычный текст. Строки блока
|
||||
* кода хранятся без ограждений, сами ограждения — в `fenceStart`/`fenceEnd`
|
||||
* (зеркало рисует их отдельно, а `MessageContent` не рисует вовсе).
|
||||
*/
|
||||
export function parseBlocks(content: string): MessageBlock[] {
|
||||
const blocks: MessageBlock[] = [];
|
||||
let open: MessageBlock | null = null;
|
||||
let cursor = 0;
|
||||
|
||||
for (const text of content.split('\n')) {
|
||||
const line: BlockLine = { text, start: cursor, end: cursor + text.length };
|
||||
cursor = line.end + 1;
|
||||
|
||||
if (open !== null) {
|
||||
if (fence(text)) {
|
||||
open.fenceEnd = line;
|
||||
open.end = line.end;
|
||||
blocks.push(open);
|
||||
open = null;
|
||||
} else {
|
||||
open.lines.push(line);
|
||||
open.end = line.end;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (fence(text)) {
|
||||
open = {
|
||||
kind: 'code',
|
||||
start: line.start,
|
||||
end: line.end,
|
||||
lines: [],
|
||||
fenceStart: line,
|
||||
fenceEnd: null,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (text.startsWith(QUOTE_PREFIX)) {
|
||||
const previous = blocks[blocks.length - 1];
|
||||
if (previous?.kind === 'quote') {
|
||||
previous.lines.push(line);
|
||||
previous.end = line.end;
|
||||
} else {
|
||||
blocks.push({
|
||||
kind: 'quote',
|
||||
start: line.start,
|
||||
end: line.end,
|
||||
lines: [line],
|
||||
fenceStart: null,
|
||||
fenceEnd: null,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const previous = blocks[blocks.length - 1];
|
||||
if (previous?.kind === 'text') {
|
||||
previous.lines.push(line);
|
||||
previous.end = line.end;
|
||||
} else {
|
||||
blocks.push({
|
||||
kind: 'text',
|
||||
start: line.start,
|
||||
end: line.end,
|
||||
lines: [line],
|
||||
fenceStart: null,
|
||||
fenceEnd: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (open !== null) {
|
||||
// Незакрытый блок кода — показываем как код, а не теряем текст.
|
||||
blocks.push(open);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Как рисовать строку в зеркале композера. */
|
||||
export type MirrorLineKind = 'text' | 'quote' | 'code' | 'fence';
|
||||
|
||||
/** Строка зеркала: исходный текст строки и разобранные токены. */
|
||||
export interface MirrorLine {
|
||||
kind: MirrorLineKind;
|
||||
/** Исходный текст строки (у цитаты — вместе с префиксом `> `). */
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
/** Токены разметки; у кода и ограждений пусто (внутри кода не форматируем). */
|
||||
tokens: InlineToken[];
|
||||
/** Длина префикса цитаты, который рисуется отдельно; иначе 0. */
|
||||
prefixLength: number;
|
||||
/** Блок кода, к которому относится строка (для `code` и `fence`); иначе null. */
|
||||
codeRange: { start: number; end: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строки зеркала композера: та же последовательность строк, что и в исходном
|
||||
* тексте (`lines.map(text).join('\n') === content`), но с готовым разбором.
|
||||
* Инвариант важен: переносы строк в зеркале обязаны совпадать с textarea,
|
||||
* иначе подсветка разъедется с полем ввода.
|
||||
*/
|
||||
export function toMirrorLines(content: string): MirrorLine[] {
|
||||
const lines: MirrorLine[] = [];
|
||||
for (const block of parseBlocks(content)) {
|
||||
const codeRange = block.kind === 'code' ? { start: block.start, end: block.end } : null;
|
||||
const fenceStart = block.fenceStart;
|
||||
if (block.kind === 'code' && fenceStart !== null) {
|
||||
lines.push({
|
||||
kind: 'fence',
|
||||
text: fenceStart.text,
|
||||
start: fenceStart.start,
|
||||
end: fenceStart.end,
|
||||
tokens: [],
|
||||
prefixLength: 0,
|
||||
codeRange,
|
||||
});
|
||||
}
|
||||
for (const line of block.lines) {
|
||||
const isQuote = block.kind === 'quote';
|
||||
lines.push({
|
||||
kind: isQuote ? 'quote' : block.kind === 'code' ? 'code' : 'text',
|
||||
text: line.text,
|
||||
start: line.start,
|
||||
end: line.end,
|
||||
tokens:
|
||||
block.kind === 'code'
|
||||
? []
|
||||
: parseInline(
|
||||
isQuote ? line.text.slice(QUOTE_PREFIX.length) : line.text,
|
||||
isQuote ? line.start + QUOTE_PREFIX.length : line.start,
|
||||
),
|
||||
prefixLength: isQuote ? QUOTE_PREFIX.length : 0,
|
||||
codeRange,
|
||||
});
|
||||
}
|
||||
const fenceEnd = block.fenceEnd;
|
||||
if (block.kind === 'code' && fenceEnd !== null) {
|
||||
lines.push({
|
||||
kind: 'fence',
|
||||
text: fenceEnd.text,
|
||||
start: fenceEnd.start,
|
||||
end: fenceEnd.end,
|
||||
tokens: [],
|
||||
prefixLength: 0,
|
||||
codeRange,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пересекается ли выделение с диапазоном. Для каретки без выделения это
|
||||
* «строго внутри»: позиции сразу за открывающим и перед закрывающим маркером
|
||||
* попадают внутрь, а позиции на самих маркерах снаружи — там маркеры скрыты.
|
||||
*/
|
||||
export function selectionIntersects(
|
||||
selection: { start: number; end: number },
|
||||
start: number,
|
||||
end: number,
|
||||
): boolean {
|
||||
return selection.start < end && selection.end > start;
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import type { Channel } from '@/api/types';
|
||||
import { Composer } from '@/components/chat/Composer';
|
||||
import { MessageContent } from '@/components/chat/MessageContent';
|
||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||
import { hasMarkers, parseInline, toMirrorLines } from '@/lib/markdown';
|
||||
import {
|
||||
createTestQueryClient,
|
||||
findRequest,
|
||||
installFetch,
|
||||
json,
|
||||
makeMessage,
|
||||
resetStores,
|
||||
} from './helpers';
|
||||
|
||||
/**
|
||||
* Форматирование в поле ввода: зеркало под textarea показывает разметку, а
|
||||
* маркеры видны только у фрагмента, внутри которого стоит каретка.
|
||||
*/
|
||||
|
||||
const channel: Channel = { id: 'c-1', name: 'общий', type: 'text', position: 0 };
|
||||
|
||||
const authors: AuthorDirectory = {
|
||||
resolve: () => ({
|
||||
id: 'user-2',
|
||||
name: 'Bob',
|
||||
avatarFileId: undefined,
|
||||
colorHex: null,
|
||||
isInstanceAdmin: false,
|
||||
isMember: true,
|
||||
}),
|
||||
resolveMention: (userId) => ({ name: userId === 'user-2' ? 'Bob' : userId, isMe: false }),
|
||||
loaded: true,
|
||||
};
|
||||
|
||||
function renderComposer() {
|
||||
const fetchMock = installFetch([
|
||||
{ match: '/api/v1/meta', response: () => json({ max_upload_size: 26_214_400 }) },
|
||||
{
|
||||
match: '/api/v1/channels/c-1/messages',
|
||||
method: 'POST',
|
||||
response: () =>
|
||||
json({ message: makeMessage({ id: 'm-1', channel_id: 'c-1', author_id: 'user-1' }) }),
|
||||
},
|
||||
{ match: '/api/v1/channels/c-1/typing', method: 'POST', response: () => json({ ok: true }) },
|
||||
]);
|
||||
const view = render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<Composer
|
||||
channel={channel}
|
||||
authors={authors}
|
||||
replyTo={null}
|
||||
onCancelReply={() => undefined}
|
||||
disabledReason={null}
|
||||
canAttach
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...view, fetchMock };
|
||||
}
|
||||
|
||||
/** Поле ввода (настоящий textbox с меткой). */
|
||||
function field(): HTMLTextAreaElement {
|
||||
return screen.getByLabelText('Сообщение в комнату общий');
|
||||
}
|
||||
|
||||
/** Слой подсветки под полем ввода. */
|
||||
function layer(): HTMLElement {
|
||||
return screen.getByTestId('composer-mirror');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ставит каретку и сообщает об этом React. Нативный `select` React
|
||||
* синтезирует из `keyup`/`mouseup`/`selectionchange`, поэтому в тесте
|
||||
* отправляем и `select`, и `keyup` — как это делает браузер.
|
||||
*/
|
||||
function placeCaret(input: HTMLTextAreaElement, start: number, end = start): void {
|
||||
input.setSelectionRange(start, end);
|
||||
fireEvent.select(input);
|
||||
fireEvent.keyUp(input);
|
||||
}
|
||||
|
||||
/** Тексты приглушённых маркеров в зеркале. */
|
||||
function markers(): string[] {
|
||||
return Array.from(layer().querySelectorAll('.text-fg-muted')).map(
|
||||
(node) => node.textContent ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
});
|
||||
|
||||
describe('композер: форматирование в поле ввода', () => {
|
||||
it('показывает выделение как mark, а маркеры прячет', async () => {
|
||||
const visitor = userEvent.setup();
|
||||
renderComposer();
|
||||
const input = field();
|
||||
|
||||
await visitor.type(input, 'важно: ==выделение== да');
|
||||
|
||||
// В поле ввода остаётся исходный текст с маркерами.
|
||||
expect(input).toHaveValue('важно: ==выделение== да');
|
||||
// А в зеркале — уже отформатированный, без `==`.
|
||||
const mark = within(layer()).getByText('выделение');
|
||||
expect(mark.tagName).toBe('MARK');
|
||||
expect(layer().textContent).toBe('важно: выделение да');
|
||||
expect(markers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('показывает приглушённые маркеры, когда каретка внутри фрагмента', async () => {
|
||||
const visitor = userEvent.setup();
|
||||
renderComposer();
|
||||
const input = field();
|
||||
await visitor.type(input, '==выделение==');
|
||||
|
||||
// Каретка внутри содержимого.
|
||||
placeCaret(input, 5);
|
||||
expect(layer().textContent).toBe('==выделение==');
|
||||
expect(markers()).toEqual(['==', '==']);
|
||||
|
||||
// Позиция сразу после открывающего маркера и перед закрывающим — тоже внутри.
|
||||
placeCaret(input, 2);
|
||||
expect(layer().textContent).toBe('==выделение==');
|
||||
placeCaret(input, 11);
|
||||
expect(layer().textContent).toBe('==выделение==');
|
||||
|
||||
// На самих маркерах (и за фрагментом) маркеры снова скрыты.
|
||||
placeCaret(input, 0);
|
||||
expect(layer().textContent).toBe('выделение');
|
||||
expect(markers()).toEqual([]);
|
||||
placeCaret(input, 13);
|
||||
expect(layer().textContent).toBe('выделение');
|
||||
});
|
||||
|
||||
it('форматирует жирный, курсив и зачёркнутый текст', async () => {
|
||||
const visitor = userEvent.setup();
|
||||
renderComposer();
|
||||
const input = field();
|
||||
|
||||
await visitor.type(input, '**жирный** *курсив* ~~зачёркнутый~~');
|
||||
|
||||
expect(within(layer()).getByText('жирный').tagName).toBe('STRONG');
|
||||
expect(within(layer()).getByText('курсив').tagName).toBe('EM');
|
||||
expect(within(layer()).getByText('зачёркнутый').tagName).toBe('S');
|
||||
expect(layer().textContent).toBe('жирный курсив зачёркнутый');
|
||||
|
||||
// Каретка внутри `**жирный**` — маркеры на месте.
|
||||
placeCaret(input, 4);
|
||||
expect(layer().textContent).toContain('**жирный**');
|
||||
expect(markers()).toEqual(['**', '**']);
|
||||
|
||||
// То же для курсива и зачёркнутого.
|
||||
placeCaret(input, 15);
|
||||
expect(layer().textContent).toContain('*курсив*');
|
||||
expect(markers()).toEqual(['*', '*']);
|
||||
placeCaret(input, 25);
|
||||
expect(layer().textContent).toContain('~~зачёркнутый~~');
|
||||
expect(markers()).toEqual(['~~', '~~']);
|
||||
});
|
||||
|
||||
it('оставляет незакрытые маркеры обычным текстом', async () => {
|
||||
const visitor = userEvent.setup();
|
||||
renderComposer();
|
||||
const input = field();
|
||||
|
||||
await visitor.type(input, '==фыв');
|
||||
|
||||
expect(layer().querySelector('mark')).toBeNull();
|
||||
expect(layer().textContent).toBe('==фыв');
|
||||
});
|
||||
|
||||
it('не разбирает разметку внутри кода, а ограждения показывает по каретке', () => {
|
||||
renderComposer();
|
||||
const input = field();
|
||||
|
||||
fireEvent.change(input, { target: { value: 'до\n```\n==не mark==\n```\nпосле' } });
|
||||
|
||||
// Каретка снаружи блока кода: ограждения скрыты, но строки остаются на месте.
|
||||
placeCaret(input, 0);
|
||||
expect(layer().querySelector('mark')).toBeNull();
|
||||
expect(layer().textContent).toBe('до\n\n==не mark==\n\nпосле');
|
||||
|
||||
// Каретка внутри блока — ограждения видны.
|
||||
placeCaret(input, 10);
|
||||
expect(layer().querySelector('mark')).toBeNull();
|
||||
expect(layer().textContent).toBe('до\n```\n==не mark==\n```\nпосле');
|
||||
});
|
||||
|
||||
it('рендерит кодовый фрагмент без моноширинного шрифта — метрики важнее', async () => {
|
||||
const visitor = userEvent.setup();
|
||||
renderComposer();
|
||||
const input = field();
|
||||
|
||||
await visitor.type(input, '`код`');
|
||||
|
||||
expect(layer().textContent).toBe('код');
|
||||
// Смена шрифта в зеркале сдвинула бы подсветку относительно поля ввода.
|
||||
expect(layer().querySelector('.font-mono')).toBeNull();
|
||||
});
|
||||
|
||||
it('отправляет исходный текст с маркерами, а не то, что видно в зеркале', async () => {
|
||||
const visitor = userEvent.setup();
|
||||
const { fetchMock } = renderComposer();
|
||||
const input = field();
|
||||
|
||||
await visitor.type(input, '==выделение== и **жирный**');
|
||||
await visitor.keyboard('{Enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const request = findRequest(fetchMock, { url: '/channels/c-1/messages', method: 'POST' });
|
||||
expect(request?.body).toMatchObject({ content: '==выделение== и **жирный**' });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
it('не ломает счётчик и лимит 4000 символов', () => {
|
||||
renderComposer();
|
||||
const input = field();
|
||||
const long = 'а'.repeat(4000);
|
||||
|
||||
fireEvent.change(input, { target: { value: long } });
|
||||
|
||||
expect(input).toHaveAttribute('maxlength', '4000');
|
||||
expect(input).toHaveValue(long);
|
||||
expect(screen.getByText('4000/4000')).toHaveClass('text-warning');
|
||||
// Зеркало рисует весь текст: разбор мемоизирован по значению.
|
||||
expect(layer().textContent).toBe(long);
|
||||
});
|
||||
|
||||
it('зеркало скрыто от скринридера, а поле остаётся настоящим textbox', async () => {
|
||||
const visitor = userEvent.setup();
|
||||
renderComposer();
|
||||
const input = field();
|
||||
|
||||
await visitor.type(input, '==выделение==');
|
||||
|
||||
expect(layer()).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(layer()).toHaveClass('pointer-events-none');
|
||||
expect(screen.getAllByRole('textbox')).toHaveLength(1);
|
||||
expect(screen.getByRole('textbox')).toBe(input);
|
||||
});
|
||||
|
||||
it('синхронизирует прокрутку зеркала с полем ввода', () => {
|
||||
renderComposer();
|
||||
const input = field();
|
||||
const mirror = layer();
|
||||
|
||||
Object.defineProperty(input, 'scrollTop', { value: 40, writable: true, configurable: true });
|
||||
Object.defineProperty(input, 'scrollLeft', { value: 5, writable: true, configurable: true });
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
Object.defineProperty(mirror, 'scrollTop', {
|
||||
get: () => top,
|
||||
set: (value: number) => {
|
||||
top = value;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mirror, 'scrollLeft', {
|
||||
get: () => left,
|
||||
set: (value: number) => {
|
||||
left = value;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
fireEvent.scroll(input);
|
||||
|
||||
expect(top).toBe(40);
|
||||
expect(left).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('композер: разбор совпадает с лентой сообщений', () => {
|
||||
it('зеркало и MessageContent показывают один и тот же текст', async () => {
|
||||
const content =
|
||||
'==выделение== **жирный** *курсив* ~~зачёркнутый~~ ||спойлер|| `код` https://example.com/a. <@user-2> конец';
|
||||
const visitor = userEvent.setup();
|
||||
renderComposer();
|
||||
const input = field();
|
||||
|
||||
await visitor.type(input, content);
|
||||
const feed = render(
|
||||
<MessageContent content={content} resolveMention={authors.resolveMention} />,
|
||||
);
|
||||
|
||||
expect(layer().textContent).toBe(feed.container.textContent);
|
||||
});
|
||||
|
||||
it('диапазоны токенов указывают на маркеры и содержимое', () => {
|
||||
const [token] = parseInline('==выделение==', 0);
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(token?.kind).toBe('mark');
|
||||
expect(token?.start).toBe(0);
|
||||
expect(token?.end).toBe(13);
|
||||
expect(token?.contentStart).toBe(2);
|
||||
expect(token?.contentEnd).toBe(11);
|
||||
expect(hasMarkers('mark')).toBe(true);
|
||||
expect(hasMarkers('mention')).toBe(false);
|
||||
|
||||
// Смещение базы: диапазоны остаются абсолютными.
|
||||
const [shifted] = parseInline('==a==', 10);
|
||||
expect(shifted?.start).toBe(10);
|
||||
expect(shifted?.end).toBe(15);
|
||||
expect(shifted?.contentStart).toBe(12);
|
||||
expect(shifted?.contentEnd).toBe(13);
|
||||
|
||||
// Незакрытый маркер токеном не считается.
|
||||
expect(parseInline('==фыв', 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it('строки зеркала повторяют исходные переносы', () => {
|
||||
const samples = [
|
||||
'',
|
||||
'одна строка',
|
||||
'первая\nвторая\n',
|
||||
'```\nкод\n```',
|
||||
'до\n```\nкод\n```\nпосле',
|
||||
'незакрытый ```\nкод',
|
||||
'> цитата\n> ещё\nобычный',
|
||||
'```',
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
expect(
|
||||
toMirrorLines(sample)
|
||||
.map((line) => line.text)
|
||||
.join('\n'),
|
||||
).toBe(sample);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user