The body editor was told to focus itself with
autoFocus={d.to.length > 0 && Boolean(d.subject)}
and RichEditor ran that as an effect keyed on the prop. Typing the first
letter of a subject flipped Boolean(d.subject) false -> true, the effect fired,
and the caret jumped from the subject line into the message body.
autoFocus now means what it means on a DOM element: focus on mount. RichEditor
captures the prop in a ref and focuses once, and the composer decides where the
caret starts when it opens - recipients for a blank message, body for a reply
that already has recipients and a subject - instead of deriving it from state
that changes as the user types.
initialFocusTarget is extracted and exported so the rule is stated in one place
and tested. The regression test renders RichEditor and asserts it does not take
focus from a field being typed into; it fails against the previous effect.
290 lines
15 KiB
TypeScript
290 lines
15 KiB
TypeScript
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";
|
|
|
|
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<string>;
|
|
}
|
|
|
|
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<RichEditorHandle, Props>(function RichEditor({ html, onChange, placeholder, spellcheck = true, onFiles, toolbarExtra, showToolbar, autoFocus, imageUpload }, ref) {
|
|
const elRef = useRef<HTMLDivElement>(null);
|
|
const lastEmitted = useRef<string>("");
|
|
const [empty, setEmpty] = useState(!html);
|
|
const emojiMenu = useMenu();
|
|
const colorMenu = useMenu();
|
|
const hiliteMenu = useMenu();
|
|
const linkMenu = useMenu();
|
|
const [linkUrl, setLinkUrl] = useState("");
|
|
const savedRange = useRef<Range | null>(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<HTMLDivElement>) => {
|
|
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(/<meta[^>]*>/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(/>/g, ">").replace(/\r?\n/g, "<br>");
|
|
document.execCommand("insertHTML", false, escaped);
|
|
emit();
|
|
}
|
|
};
|
|
|
|
const insertImageFile = (f: File) => {
|
|
if (imageUpload) {
|
|
imageUpload(f)
|
|
.then((url) => insertHtml(`<img src="${url}" alt="${f.name.replace(/"/g, "")}" style="max-width:100%">`))
|
|
.catch(() => {
|
|
/* uploader reports its own errors */
|
|
});
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
insertHtml(`<img src="${reader.result as string}" alt="${f.name.replace(/"/g, "")}" style="max-width:100%">`);
|
|
};
|
|
reader.readAsDataURL(f);
|
|
};
|
|
|
|
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
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, `<a href="${href}" target="_blank" rel="noopener">${href}</a>`);
|
|
else document.execCommand("createLink", false, href);
|
|
emit();
|
|
setLinkUrl("");
|
|
};
|
|
|
|
return (
|
|
<div className="composer-editor">
|
|
<div
|
|
ref={elRef}
|
|
className="editor-area"
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
spellCheck={spellcheck}
|
|
data-placeholder={placeholder ?? ""}
|
|
data-empty={empty}
|
|
onInput={emit}
|
|
onBlur={saveRange}
|
|
onKeyUp={saveRange}
|
|
onMouseUp={saveRange}
|
|
onPaste={onPaste}
|
|
onDrop={onDrop}
|
|
onDragOver={(e) => 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="Message body"
|
|
/>
|
|
{showToolbar && (
|
|
<div className="editor-toolbar" role="toolbar" aria-label="Formatting">
|
|
<button type="button" className="icon-btn" title="Undo (Ctrl+Z)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("undo")}><Undo size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Redo" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("redo")}><Redo size={16} /></button>
|
|
<span className="tb-sep" />
|
|
<select title="Font size" onMouseDown={saveRange} onChange={(e) => { exec("fontSize", e.target.value); e.target.value = ""; }} defaultValue="">
|
|
<option value="" disabled>Size</option>
|
|
<option value="1">Small</option>
|
|
<option value="3">Normal</option>
|
|
<option value="5">Large</option>
|
|
<option value="7">Huge</option>
|
|
</select>
|
|
<button type="button" className="icon-btn" title="Bold (Ctrl+B)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("bold")}><Bold size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Italic (Ctrl+I)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("italic")}><Italic size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Underline (Ctrl+U)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("underline")}><Underline size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Strikethrough" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("strikeThrough")}><Strikethrough size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Text color" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={colorMenu.open}><Palette size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Highlight" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={hiliteMenu.open}><Highlighter size={16} /></button>
|
|
<span className="tb-sep" />
|
|
<button type="button" className="icon-btn" title="Align left" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyLeft")}><AlignLeft size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Center" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyCenter")}><AlignCenter size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Align right" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyRight")}><AlignRight size={16} /></button>
|
|
<span className="tb-sep" />
|
|
<button type="button" className="icon-btn" title="Bulleted list" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertUnorderedList")}><List size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Numbered list" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertOrderedList")}><ListOrdered size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Decrease indent" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("outdent")}><Outdent size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Increase indent" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("indent")}><Indent size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Quote" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "blockquote")}><Quote size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Code block" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "pre")}><Code size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Normal text" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "div")}><Type size={16} /></button>
|
|
<span className="tb-sep" />
|
|
<button type="button" className="icon-btn" title="Insert link (Ctrl+K)" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={linkMenu.open}><LinkIcon size={16} /></button>
|
|
<label className="icon-btn" title="Insert image" onMouseDown={saveRange}>
|
|
<ImageIcon size={16} />
|
|
<input type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) insertImageFile(f); e.target.value = ""; }} />
|
|
</label>
|
|
<button type="button" className="icon-btn" title="Emoji" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={emojiMenu.open}><Smile size={16} /></button>
|
|
<button type="button" className="icon-btn" title="Remove formatting" onMouseDown={(e) => e.preventDefault()} onClick={() => { exec("removeFormat"); exec("unlink"); }}><Eraser size={16} /></button>
|
|
{toolbarExtra}
|
|
</div>
|
|
)}
|
|
<Popover anchor={emojiMenu.anchor} onClose={emojiMenu.close} side="top" closeOnClick={false} width={290}>
|
|
<div className="emoji-grid">
|
|
{EMOJI.map((e) => (
|
|
<button key={e} type="button" onMouseDown={(ev) => ev.preventDefault()} onClick={() => { insertHtml(e); emojiMenu.close(); }}>{e}</button>
|
|
))}
|
|
</div>
|
|
</Popover>
|
|
<Popover anchor={colorMenu.anchor} onClose={colorMenu.close} side="top" closeOnClick={false} width={230}>
|
|
<div className="color-grid">
|
|
{COLORS.map((c) => <button key={c} type="button" style={{ background: c }} onMouseDown={(ev) => ev.preventDefault()} onClick={() => { exec("foreColor", c); colorMenu.close(); }} aria-label={c} />)}
|
|
</div>
|
|
</Popover>
|
|
<Popover anchor={hiliteMenu.anchor} onClose={hiliteMenu.close} side="top" closeOnClick={false} width={230}>
|
|
<div className="color-grid">
|
|
{COLORS.map((c) => <button key={c} type="button" style={{ background: c }} onMouseDown={(ev) => ev.preventDefault()} onClick={() => { exec("hiliteColor", c); hiliteMenu.close(); }} aria-label={c} />)}
|
|
</div>
|
|
</Popover>
|
|
<Popover anchor={linkMenu.anchor} onClose={linkMenu.close} side="top" closeOnClick={false} width={320}>
|
|
<form className="link-popup" onSubmit={(e) => { e.preventDefault(); applyLink(); }}>
|
|
<input className="input sm" autoFocus placeholder="https://…" value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} />
|
|
<button type="submit" className="btn btn-sm btn-primary">Link</button>
|
|
</form>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
});
|