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