import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type ClipboardEvent, type ReactNode } from "react"; import { AlignCenter, AlignLeft, AlignRight, Bold, Code, Eraser, Image as ImageIcon, Indent, Italic, Link as LinkIcon, List, ListOrdered, Outdent, Quote, Redo, Smile, Strikethrough, Underline, Undo, Palette, Highlighter, Type } from "lucide-react"; import { sanitizeEditorHtml } from "@/lib/html"; import { Popover, useMenu } from "@/ui/popover"; import { t as translate } from "@/lib/i18n"; export interface RichEditorHandle { focus(): void; insertHtml(html: string): void; insertText(text: string): void; getHtml(): string; } interface Props { html: string; onChange: (html: string) => void; placeholder?: string; spellcheck?: boolean; onFiles?: (files: File[]) => void; toolbarExtra?: ReactNode; showToolbar: boolean; autoFocus?: boolean; /** If provided, inserted images are uploaded and referenced by URL instead of embedded as data: URLs. */ imageUpload?: (file: File) => Promise; } const EMOJI = "๐Ÿ˜€ ๐Ÿ˜ƒ ๐Ÿ˜„ ๐Ÿ˜ ๐Ÿ˜† ๐Ÿ˜… ๐Ÿ˜‚ ๐Ÿคฃ ๐Ÿ™‚ ๐Ÿ˜‰ ๐Ÿ˜Š ๐Ÿ˜‡ ๐Ÿฅฐ ๐Ÿ˜ ๐Ÿ˜˜ ๐Ÿ˜‹ ๐Ÿ˜œ ๐Ÿคช ๐Ÿค— ๐Ÿค” ๐Ÿคซ ๐Ÿค ๐Ÿ˜ ๐Ÿ˜‘ ๐Ÿ˜ถ ๐Ÿ˜ ๐Ÿ˜’ ๐Ÿ™„ ๐Ÿ˜ฌ ๐Ÿ˜Œ ๐Ÿ˜” ๐Ÿ˜ช ๐Ÿ˜ด ๐Ÿ˜ท ๐Ÿค’ ๐Ÿค• ๐Ÿคข ๐Ÿคฎ ๐Ÿฅต ๐Ÿฅถ ๐Ÿฅด ๐Ÿ˜ต ๐Ÿคฏ ๐Ÿค  ๐Ÿฅณ ๐Ÿ˜Ž ๐Ÿค“ ๐Ÿง ๐Ÿ˜• ๐Ÿ˜Ÿ ๐Ÿ™ ๐Ÿ˜ฎ ๐Ÿ˜ฏ ๐Ÿ˜ฒ ๐Ÿ˜ณ ๐Ÿฅบ ๐Ÿ˜ฆ ๐Ÿ˜ง ๐Ÿ˜จ ๐Ÿ˜ฐ ๐Ÿ˜ฅ ๐Ÿ˜ข ๐Ÿ˜ญ ๐Ÿ˜ฑ ๐Ÿ˜– ๐Ÿ˜ฃ ๐Ÿ˜ž ๐Ÿ˜“ ๐Ÿ˜ฉ ๐Ÿ˜ซ ๐Ÿฅฑ ๐Ÿ˜ค ๐Ÿ˜ก ๐Ÿ˜  ๐Ÿคฌ ๐Ÿ‘ ๐Ÿ‘Ž ๐Ÿ‘Œ โœŒ๏ธ ๐Ÿคž ๐ŸคŸ ๐Ÿค˜ ๐Ÿค™ ๐Ÿ‘ˆ ๐Ÿ‘‰ ๐Ÿ‘† ๐Ÿ‘‡ โ˜๏ธ ๐Ÿ‘‹ ๐Ÿคš ๐Ÿ–๏ธ โœ‹ ๐Ÿ–– ๐Ÿ‘ ๐Ÿ™Œ ๐Ÿ‘ ๐Ÿคฒ ๐Ÿค ๐Ÿ™ ๐Ÿ’ช โค๏ธ ๐Ÿงก ๐Ÿ’› ๐Ÿ’š ๐Ÿ’™ ๐Ÿ’œ ๐Ÿ–ค ๐Ÿค ๐Ÿ’” โฃ๏ธ ๐Ÿ’• ๐Ÿ’ฏ ๐Ÿ’ฅ ๐Ÿ”ฅ โœจ ๐ŸŽ‰ ๐ŸŽŠ ๐ŸŽˆ ๐ŸŽ ๐Ÿ† โญ ๐ŸŒŸ โ˜€๏ธ ๐ŸŒ™ โšก โ˜• ๐Ÿ• ๐Ÿบ ๐Ÿš€ โœˆ๏ธ ๐Ÿ  ๐Ÿ’ผ ๐Ÿ“… ๐Ÿ“Ž ๐Ÿ“Œ โœ… โŒ โš ๏ธ โ“ โ— ๐Ÿ’ก ๐Ÿ”” ๐Ÿ“ง ๐Ÿ™ˆ ๐Ÿ™‰ ๐Ÿ™Š ๐Ÿฑ ๐Ÿถ ๐ŸฆŠ ๐Ÿผ".split(" "); const COLORS = ["#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", "#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79"]; export const RichEditor = forwardRef(function RichEditor({ html, onChange, placeholder, spellcheck = true, onFiles, toolbarExtra, showToolbar, autoFocus, imageUpload }, ref) { const elRef = useRef(null); const lastEmitted = useRef(""); const [empty, setEmpty] = useState(!html); const emojiMenu = useMenu(); const colorMenu = useMenu(); const hiliteMenu = useMenu(); const linkMenu = useMenu(); const [linkUrl, setLinkUrl] = useState(""); const savedRange = useRef(null); // Sync external html โ†’ DOM (only when it differs from what we emitted) useEffect(() => { const el = elRef.current; if (!el) return; if (html !== lastEmitted.current) { el.innerHTML = html; lastEmitted.current = html; setEmpty(!el.textContent?.trim() && !el.querySelector("img")); } }, [html]); // autoFocus means "focus on mount", as it does on a DOM element. Reacting to // the prop turning true later yanks the caret out of whatever the user is // typing in โ€” typing the first letter of a subject used to jump to the body. const autoFocusOnMount = useRef(autoFocus); useEffect(() => { if (!autoFocusOnMount.current) return; const el = elRef.current; if (!el) return; el.focus(); // caret at start const sel = window.getSelection(); const range = document.createRange(); range.setStart(el, 0); range.collapse(true); sel?.removeAllRanges(); sel?.addRange(range); }, []); const emit = useCallback(() => { const el = elRef.current; if (!el) return; const v = el.innerHTML; lastEmitted.current = v; setEmpty(!el.textContent?.trim() && !el.querySelector("img")); onChange(v); }, [onChange]); const exec = useCallback( (cmd: string, value?: string) => { elRef.current?.focus(); restoreRange(); document.execCommand(cmd, false, value); emit(); }, [emit], ); const saveRange = () => { const sel = window.getSelection(); if (sel && sel.rangeCount && elRef.current?.contains(sel.anchorNode)) savedRange.current = sel.getRangeAt(0).cloneRange(); }; const restoreRange = () => { const r = savedRange.current; if (!r) return; const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(r); }; const insertHtml = useCallback( (h: string) => { elRef.current?.focus(); restoreRange(); document.execCommand("insertHTML", false, h); emit(); }, [emit], ); useImperativeHandle(ref, () => ({ focus: () => elRef.current?.focus(), insertHtml, insertText: (t: string) => { elRef.current?.focus(); restoreRange(); document.execCommand("insertText", false, t); emit(); }, getHtml: () => elRef.current?.innerHTML ?? "", })); const onPaste = (e: ClipboardEvent) => { const items = Array.from(e.clipboardData.items); const imgItem = items.find((i) => i.type.startsWith("image/")); if (imgItem) { const f = imgItem.getAsFile(); if (f) { e.preventDefault(); insertImageFile(f); return; } } const htmlData = e.clipboardData.getData("text/html"); if (htmlData) { e.preventDefault(); const clean = sanitizeEditorHtml(htmlData).replace(/]*>/gi, ""); document.execCommand("insertHTML", false, clean); emit(); return; } // plain text: let browser handle (it inserts text nodes) but normalize newlines const text = e.clipboardData.getData("text/plain"); if (text && /\n/.test(text)) { e.preventDefault(); const escaped = text.replace(/&/g, "&").replace(//g, ">").replace(/\r?\n/g, "
"); document.execCommand("insertHTML", false, escaped); emit(); } }; const insertImageFile = (f: File) => { if (imageUpload) { imageUpload(f) .then((url) => insertHtml(`${f.name.replace(/`)) .catch(() => { /* uploader reports its own errors */ }); return; } const reader = new FileReader(); reader.onload = () => { insertHtml(`${f.name.replace(/`); }; reader.readAsDataURL(f); }; const onDrop = (e: React.DragEvent) => { const files = Array.from(e.dataTransfer.files); if (!files.length) return; e.preventDefault(); const images = files.filter((f) => f.type.startsWith("image/")); const others = files.filter((f) => !f.type.startsWith("image/")); images.forEach(insertImageFile); if (others.length) onFiles?.(others); }; const applyLink = () => { const url = linkUrl.trim(); linkMenu.close(); if (!url) return; const href = /^(https?:|mailto:|tel:)/i.test(url) ? url : `https://${url}`; elRef.current?.focus(); restoreRange(); const sel = window.getSelection(); if (sel && sel.isCollapsed) document.execCommand("insertHTML", false, `${href}`); else document.execCommand("createLink", false, href); emit(); setLinkUrl(""); }; return (
e.preventDefault()} onKeyDown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { e.preventDefault(); saveRange(); linkMenu.open(e.currentTarget); } if (e.key === "Tab") { e.preventDefault(); exec(e.shiftKey ? "outdent" : "indent"); } }} role="textbox" aria-multiline="true" aria-label={translate("Message body")} /> {showToolbar && (
{toolbarExtra}
)}
{EMOJI.map((e) => ( ))}
{COLORS.map((c) =>
{COLORS.map((c) =>
{ e.preventDefault(); applyLink(); }}> setLinkUrl(e.target.value)} />
); });