2026-09-20 11:32:50 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Общий разбор разметки сообщения: блоки (текст, цитата, код) и строчные
|
|
|
|
|
|
* токены с диапазонами.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Модуль намеренно не рисует ничего сам: им пользуются оба потребителя —
|
|
|
|
|
|
* `MessageContent` (лента сообщений) и зеркало композера (подсветка под
|
|
|
|
|
|
* textarea). Разбор один, поэтому предпросмотр в поле ввода показывает ровно
|
|
|
|
|
|
* то же форматирование, что увидит получатель.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Диапазоны нужны зеркалу: по ним видно, где у токена маркеры
|
|
|
|
|
|
* (`start`…`contentStart` и `contentEnd`…`end`), а где содержимое. Это
|
|
|
|
|
|
* позволяет спрятать маркеры, пока каретка вне фрагмента, и показать их
|
|
|
|
|
|
* приглушёнными, когда пользователь его правит.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `dangerouslySetInnerHTML` не используется: текст разбирается в React-узлы,
|
|
|
|
|
|
* поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`,
|
|
|
|
|
|
* `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``,
|
|
|
|
|
|
* ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`,
|
2026-09-20 12:06:22 +03:00
|
|
|
|
* `> цитата`, ссылки `http(s)://…`, упоминания `<@id>`, ссылки на комнаты
|
|
|
|
|
|
* `<#id>` и `#канал`.
|
2026-09-20 11:32:50 +03:00
|
|
|
|
*
|
|
|
|
|
|
* Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую
|
|
|
|
|
|
* пару, поэтому `==фыв` и `**фыв` рендерятся как есть.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
/** Вид строчного токена. */
|
|
|
|
|
|
export type InlineKind =
|
|
|
|
|
|
| 'code'
|
|
|
|
|
|
| 'bold-italic'
|
|
|
|
|
|
| 'bold'
|
|
|
|
|
|
| 'italic'
|
|
|
|
|
|
| 'strike'
|
|
|
|
|
|
| 'spoiler'
|
|
|
|
|
|
| 'mark'
|
|
|
|
|
|
| 'mention'
|
2026-09-20 12:06:22 +03:00
|
|
|
|
| 'channel-link'
|
2026-09-20 11:32:50 +03:00
|
|
|
|
| 'link'
|
|
|
|
|
|
| 'channel';
|
|
|
|
|
|
|
|
|
|
|
|
/** Токен разметки с диапазонами в исходном тексте. */
|
|
|
|
|
|
export interface InlineToken {
|
|
|
|
|
|
kind: InlineKind;
|
|
|
|
|
|
/** Исходный текст токена вместе с маркерами. */
|
|
|
|
|
|
raw: string;
|
|
|
|
|
|
/** Смещение первого символа токена (включая открывающий маркер). */
|
|
|
|
|
|
start: number;
|
|
|
|
|
|
/** Смещение за последним символом токена. */
|
|
|
|
|
|
end: number;
|
|
|
|
|
|
/** Начало содержимого: сразу после открывающего маркера. */
|
|
|
|
|
|
contentStart: number;
|
|
|
|
|
|
/** Конец содержимого: перед закрывающим маркером. */
|
|
|
|
|
|
contentEnd: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
|
|
|
|
|
|
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
|
2026-09-20 12:06:22 +03:00
|
|
|
|
* ссылкой, а `<#id>` — раньше `#канала`, иначе угловые скобки остались бы
|
|
|
|
|
|
* текстом. Идентификатор в `<@id>`/`<#id>` — снежинка; допускаем и другую
|
|
|
|
|
|
* запись без пробелов и угловых скобок, чтобы ссылка не разваливалась на
|
|
|
|
|
|
* «текст со скобками». Одиночная `*` не считается маркером внутри ряда
|
|
|
|
|
|
* звёздочек (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а
|
|
|
|
|
|
* `_` — внутри слова (`foo_bar_baz`).
|
2026-09-20 11:32:50 +03:00
|
|
|
|
*/
|
|
|
|
|
|
const INLINE_PATTERN =
|
2026-09-20 12:06:22 +03:00
|
|
|
|
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|((?<!\*)\*(?!\*)[^*\n]+(?<!\*)\*(?!\*))|((?<![\p{L}\p{N}_])_(?!_)[^_\n]+(?<!_)_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@[^\s<>]+>)|(<#[^\s<>]+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
|
2026-09-20 11:32:50 +03:00
|
|
|
|
|
|
|
|
|
|
/** Вид токена по номеру группы шаблона (порядок альтернатив выше). */
|
|
|
|
|
|
const GROUP_KINDS: readonly InlineKind[] = [
|
|
|
|
|
|
'code',
|
|
|
|
|
|
'bold-italic',
|
|
|
|
|
|
'bold',
|
|
|
|
|
|
'bold',
|
|
|
|
|
|
'strike',
|
|
|
|
|
|
'spoiler',
|
|
|
|
|
|
'italic',
|
|
|
|
|
|
'italic',
|
|
|
|
|
|
'mark',
|
|
|
|
|
|
'mention',
|
2026-09-20 12:06:22 +03:00
|
|
|
|
'channel-link',
|
2026-09-20 11:32:50 +03:00
|
|
|
|
'link',
|
|
|
|
|
|
'channel',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/** Длина парных маркеров вокруг содержимого. */
|
|
|
|
|
|
const MARKER_LENGTH: Readonly<Record<InlineKind, number>> = {
|
|
|
|
|
|
code: 1,
|
|
|
|
|
|
'bold-italic': 3,
|
|
|
|
|
|
bold: 2,
|
|
|
|
|
|
italic: 1,
|
|
|
|
|
|
strike: 2,
|
|
|
|
|
|
spoiler: 2,
|
|
|
|
|
|
mark: 2,
|
2026-09-20 12:06:22 +03:00
|
|
|
|
// У упоминаний, ссылок на комнаты, ссылок и каналов маркеров нет:
|
|
|
|
|
|
// содержимое — весь токен.
|
2026-09-20 11:32:50 +03:00
|
|
|
|
mention: 0,
|
2026-09-20 12:06:22 +03:00
|
|
|
|
'channel-link': 0,
|
2026-09-20 11:32:50 +03:00
|
|
|
|
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 } {
|
2026-09-20 12:06:22 +03:00
|
|
|
|
if (kind === 'mention' || kind === 'channel-link') {
|
|
|
|
|
|
// `<@123>` и `<#123>`: содержимое — идентификатор без `<@`/`<#` и `>`.
|
2026-09-20 11:32:50 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-20 12:06:22 +03:00
|
|
|
|
* Есть ли у токена парные маркеры. У упоминаний, ссылок на комнаты, ссылок и
|
|
|
|
|
|
* каналов их нет: содержимое — весь токен, прятать нечего.
|
2026-09-20 11:32:50 +03:00
|
|
|
|
*/
|
|
|
|
|
|
export function hasMarkers(kind: InlineKind): boolean {
|
2026-09-20 12:06:22 +03:00
|
|
|
|
return kind !== 'mention' && kind !== 'channel-link' && kind !== 'link' && kind !== 'channel';
|
2026-09-20 11:32:50 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Ссылка: обрезаем хвостовую пунктуацию, которая не часть адреса. */
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|