ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a React 19/Vite SPA. Mail (conversation view, search operators, labels, sanitised HTML, privacy image proxy, invites, undo send, templates), calendar (month/week/day/agenda, invites, free/busy, categories, context menus), contacts (JSContact, groups, vCard), files, Sieve filter builder (incl. filter-from-message with retroactive apply), vacation, identities with default + Reply-To, PWA/mobile layout, push via SSE, in-memory mock Stalwart for dev, Docker + CI.
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
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);
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user