import { type ReactNode, useEffect, useRef, useState } from "react"; const EMOJI = ["🏆", "📈", "🛠️", "🍂", "⚔️", "💀", "🔥", "🎲", "📅", "❗"]; const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); // Кнопка панели. mousedown гасим, чтобы фокус и выделение остались в поле текста. function Tool({ title, className, onClick, children, }: { title: string; className?: string; onClick: () => void; children: ReactNode; }) { return ( ); } /** * Редактор текста объявления (вариант 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(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); }); // Акцент: выделенный текст целиком оборачивается в (золотой) или // . Оформление внутри выделения при этом снимается — для коротких // акцентов это проще, чем вложенные теги. const accent = (red: boolean) => run(() => { const range = rangeInEditor(); if (!range || range.collapsed) return; const inner = range.toString().split("\n").map(escapeHtml).join("
"); document.execCommand("insertHTML", false, `${inner}
`); }); const clearFormat = () => run(() => { if (!rangeInEditor()) return; document.execCommand("removeFormat"); // removeFormat не знает про — акценты в выделении снимаем сами. 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 (
format("bold")}> Ж format("italic")}> К accent(false)}> A accent(true)}> A setEmojiOpen((v) => !v)}> 😀 ✕ формат
, а не
— абзацы сразу в том виде, что хранит сервер. onFocus={() => document.execCommand("defaultParagraphSeparator", false, "p")} onPaste={(e) => { e.preventDefault(); document.execCommand("insertText", false, e.clipboardData.getData("text/plain")); }} onDrop={(e) => e.preventDefault()} /> {emojiOpen && (
{EMOJI.map((emoji) => ( ))}
)}
); }