Files
ForbiddenStarsApp/frontend/src/pages/admin/RichTextEditor.tsx
T
NotBigGhostandClaude Opus 5 ab1fc3f00f Объявления: окно у игрока и редактор в админке
Окно — вариант E из макетов: заголовок в плашке-шапке, текст, точки очереди
и «Понятно», крестика нет. Очередь идёт от старого к новому; закрытое сразу
уходит из кэша, следующее открывается без ожидания сети. У повторно
показанного объявления под заголовком — красная пометка «обновлено».
AppShell показывает окно на любой странице и только когда пароль задан:
обязательное окно пароля всегда первое. Новые и изменённые объявления
приходят SSE-событием announcements, отложенное начало показа — перезапросом
раз в 5 минут.

Админка — вкладка «Объявления» с редактором R1: contenteditable и панель
(жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления),
вставка и перетаскивание только простым текстом, счётчик символов, период
показа по МСК, переключатели «показывать новым игрокам» и «показать заново».
Предпросмотр — тем же окном, что у игрока. Список со статусом, счётчиком
«закрыли N из M» и действиями: изменить, снять с показа, дублировать,
удалить. Выделение текста в форме — полупрозрачный оранжевый вместо синего.

schema.d.ts пересобран из OpenAPI. #84

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:26:38 +03:00

184 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type ReactNode, useEffect, useRef, useState } from "react";
const EMOJI = ["🏆", "📈", "🛠️", "🍂", "⚔️", "💀", "🔥", "🎲", "📅", "❗"];
const escapeHtml = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// Кнопка панели. 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}>
✕&nbsp;формат
</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>
);
}