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; /** Как показать упоминание — как в ленте сообщений. */ resolveMention?: MentionResolver; } /** Содержимое токена без маркеров. */ function renderFormatted( token: InlineToken, content: string, channels: ReadonlySet, resolveMention: MentionResolver | undefined, ): ReactNode { switch (token.kind) { case 'code': // Фон вместо `font-mono`: смена шрифта сдвинула бы метрики строки. return {content}; case 'bold-italic': return ( {content} ); case 'bold': return {content}; case 'italic': return {content}; case 'strike': return {content}; case 'spoiler': // В отличие от ленты текст спойлера виден: иначе его нельзя было бы // править. Приглушённая подложка показывает, что фрагмент скрыт. return {content}; case 'mark': return {content}; case 'mention': { const mention = resolveMention?.(content) ?? null; return ( @{mention?.name ?? content} ); } case 'link': { const { href, trailing } = splitLink(token.raw); return ( <> {href} {trailing} ); } case 'channel': { const name = token.raw.slice(1); return channels.has(name.toLowerCase()) ? ( {token.raw} ) : ( token.raw ); } } } /** Один токен: маркеры (если каретка внутри) плюс оформленное содержимое. */ function renderToken( token: InlineToken, line: MirrorLine, selection: ComposerSelection, channels: ReadonlySet, 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 ( {active ? ( {text.slice(toLocal(token.start), toLocal(token.contentStart))} ) : null} {renderFormatted( token, text.slice(toLocal(token.contentStart), toLocal(token.contentEnd)), channels, resolveMention, )} {active ? ( {text.slice(toLocal(token.contentEnd), toLocal(token.end))} ) : null} ); } /** Строка с разметкой: текст между токенами остаётся обычным текстом. */ function renderTokens( line: MirrorLine, selection: ComposerSelection, channels: ReadonlySet, 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, resolveMention: MentionResolver | undefined, ): ReactNode { switch (line.kind) { case 'code': return line.text === '' ? null : {line.text}; case 'fence': { // Ограждение ``` показываем, только когда каретка внутри блока кода; // строка при этом остаётся на месте, поэтому переносы не съезжают. const range = line.codeRange; const active = range !== null && selectionIntersects(selection, range.start, range.end); return active ? {line.text} : null; } case 'quote': return ( <> {line.text.slice(0, line.prefixLength)} {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 ( ); }