Окно — вариант E из макетов: заголовок в плашке-шапке, текст, точки очереди и «Понятно», крестика нет. Очередь идёт от старого к новому; закрытое сразу уходит из кэша, следующее открывается без ожидания сети. У повторно показанного объявления под заголовком — красная пометка «обновлено». AppShell показывает окно на любой странице и только когда пароль задан: обязательное окно пароля всегда первое. Новые и изменённые объявления приходят SSE-событием announcements, отложенное начало показа — перезапросом раз в 5 минут. Админка — вкладка «Объявления» с редактором R1: contenteditable и панель (жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления), вставка и перетаскивание только простым текстом, счётчик символов, период показа по МСК, переключатели «показывать новым игрокам» и «показать заново». Предпросмотр — тем же окном, что у игрока. Список со статусом, счётчиком «закрыли N из M» и действиями: изменить, снять с показа, дублировать, удалить. Выделение текста в форме — полупрозрачный оранжевый вместо синего. schema.d.ts пересобран из OpenAPI. #84 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
6.6 KiB
TypeScript
184 lines
6.6 KiB
TypeScript
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||
|
||
const EMOJI = ["🏆", "📈", "🛠️", "🍂", "⚔️", "💀", "🔥", "🎲", "📅", "❗"];
|
||
|
||
const escapeHtml = (s: string) =>
|
||
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
|
||
// Кнопка панели. mousedown гасим, чтобы фокус и выделение остались в поле текста.
|
||
function Tool({
|
||
title,
|
||
className,
|
||
onClick,
|
||
children,
|
||
}: {
|
||
title: string;
|
||
className?: string;
|
||
onClick: () => void;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={"rte-tool" + (className ? ` ${className}` : "")}
|
||
title={title}
|
||
aria-label={title}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={onClick}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Редактор текста объявления (вариант R1 из #84): поле contenteditable и панель —
|
||
* жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления. Набор узкий
|
||
* намеренно: сервер всё равно оставит только b / em / mark / p / br
|
||
* (announcement_service.sanitize_body). Вставка и перетаскивание — только простым текстом.
|
||
*
|
||
* Поле неуправляемое: начальный HTML ставится при монтировании (для другого объявления
|
||
* родитель пересоздаёт редактор через key), дальше каждое изменение уходит в onChange
|
||
* вместе с длиной видимого текста в символах — так же её считает сервер.
|
||
*/
|
||
export function RichTextEditor({
|
||
initialHtml,
|
||
onChange,
|
||
}: {
|
||
initialHtml: string;
|
||
onChange: (html: string, length: number) => void;
|
||
}) {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||
|
||
const emit = () => {
|
||
const el = ref.current;
|
||
if (el) onChange(el.innerHTML, [...(el.textContent ?? "").trim()].length);
|
||
};
|
||
|
||
// Только при монтировании: дальше содержимым владеет сам contenteditable.
|
||
useEffect(() => {
|
||
if (ref.current) ref.current.innerHTML = initialHtml;
|
||
emit();
|
||
}, []);
|
||
|
||
// Выделение внутри поля — кнопки панели работают только с ним.
|
||
const rangeInEditor = (): Range | null => {
|
||
const sel = document.getSelection();
|
||
if (!sel || sel.rangeCount === 0) return null;
|
||
const range = sel.getRangeAt(0);
|
||
return ref.current?.contains(range.commonAncestorContainer) ? range : null;
|
||
};
|
||
|
||
// Курсор в конец поля, если в нём ещё не кликали (эмодзи до первого клика в текст).
|
||
const ensureCaret = () => {
|
||
const el = ref.current;
|
||
if (!el) return;
|
||
el.focus();
|
||
if (rangeInEditor()) return;
|
||
const range = document.createRange();
|
||
range.selectNodeContents(el);
|
||
range.collapse(false);
|
||
const sel = document.getSelection();
|
||
sel?.removeAllRanges();
|
||
sel?.addRange(range);
|
||
};
|
||
|
||
const run = (fn: () => void) => {
|
||
fn();
|
||
emit();
|
||
};
|
||
|
||
const format = (cmd: "bold" | "italic") =>
|
||
run(() => {
|
||
if (rangeInEditor()) document.execCommand(cmd);
|
||
});
|
||
|
||
// Акцент: выделенный текст целиком оборачивается в <mark> (золотой) или
|
||
// <mark class="red">. Оформление внутри выделения при этом снимается — для коротких
|
||
// акцентов это проще, чем вложенные теги.
|
||
const accent = (red: boolean) =>
|
||
run(() => {
|
||
const range = rangeInEditor();
|
||
if (!range || range.collapsed) return;
|
||
const inner = range.toString().split("\n").map(escapeHtml).join("<br>");
|
||
document.execCommand("insertHTML", false, `<mark${red ? ' class="red"' : ""}>${inner}</mark>`);
|
||
});
|
||
|
||
const clearFormat = () =>
|
||
run(() => {
|
||
if (!rangeInEditor()) return;
|
||
document.execCommand("removeFormat");
|
||
// removeFormat не знает про <mark> — акценты в выделении снимаем сами.
|
||
const range = rangeInEditor();
|
||
ref.current?.querySelectorAll("mark").forEach((m) => {
|
||
if (range?.intersectsNode(m)) m.replaceWith(...Array.from(m.childNodes));
|
||
});
|
||
});
|
||
|
||
const insertEmoji = (emoji: string) =>
|
||
run(() => {
|
||
ensureCaret();
|
||
document.execCommand("insertText", false, emoji);
|
||
});
|
||
|
||
return (
|
||
<div>
|
||
<div className="rte-toolbar">
|
||
<Tool title="Жирный" onClick={() => format("bold")}>
|
||
<b>Ж</b>
|
||
</Tool>
|
||
<Tool title="Курсив" onClick={() => format("italic")}>
|
||
<em>К</em>
|
||
</Tool>
|
||
<Tool title="Золотой акцент" className="rte-gold" onClick={() => accent(false)}>
|
||
A
|
||
</Tool>
|
||
<Tool title="Красный акцент" className="rte-red" onClick={() => accent(true)}>
|
||
A
|
||
</Tool>
|
||
<span className="rte-sep" />
|
||
<Tool title="Эмодзи" onClick={() => setEmojiOpen((v) => !v)}>
|
||
😀
|
||
</Tool>
|
||
<span className="rte-sep" />
|
||
<Tool title="Убрать оформление" onClick={clearFormat}>
|
||
✕ формат
|
||
</Tool>
|
||
</div>
|
||
<div
|
||
ref={ref}
|
||
className="rte-editor ann-body"
|
||
contentEditable
|
||
suppressContentEditableWarning
|
||
role="textbox"
|
||
aria-multiline="true"
|
||
aria-label="Текст объявления"
|
||
data-placeholder="Короткий текст объявления…"
|
||
onInput={emit}
|
||
// Enter создаёт <p>, а не <div> — абзацы сразу в том виде, что хранит сервер.
|
||
onFocus={() => document.execCommand("defaultParagraphSeparator", false, "p")}
|
||
onPaste={(e) => {
|
||
e.preventDefault();
|
||
document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
|
||
}}
|
||
onDrop={(e) => e.preventDefault()}
|
||
/>
|
||
{emojiOpen && (
|
||
<div className="rte-emoji">
|
||
{EMOJI.map((emoji) => (
|
||
<button
|
||
key={emoji}
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => insertEmoji(emoji)}
|
||
>
|
||
{emoji}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|