248 lines
8.9 KiB
TypeScript
248 lines
8.9 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|