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,232 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertTriangle, ChevronDown, FileText, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react";
|
||||
import { useCompose, type Draft } from "@/store/compose";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { RecipientInput } from "./RecipientInput";
|
||||
import { RichEditor, type RichEditorHandle } from "./RichEditor";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { formatSize, formatRelative } from "@/lib/format";
|
||||
import { htmlToText, textToHtml } from "@/lib/text";
|
||||
import { isValidEmail } from "@/lib/address";
|
||||
import { attachmentIcon } from "../mail/MessageView";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { useIsMobile } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
export function Composer({ draft }: { draft: Draft }) {
|
||||
const update = useCompose((s) => s.update);
|
||||
const close = useCompose((s) => s.close);
|
||||
const send = useCompose((s) => s.send);
|
||||
const saveDraft = useCompose((s) => s.saveDraft);
|
||||
const addFiles = useCompose((s) => s.addFiles);
|
||||
const removeAttachment = useCompose((s) => s.removeAttachment);
|
||||
const setIdentity = useCompose((s) => s.setIdentity);
|
||||
const insertTemplate = useCompose((s) => s.insertTemplate);
|
||||
const focus = useCompose((s) => s.focus);
|
||||
const identities = useMail((s) => s.identities);
|
||||
const settings = useSettings((s) => s.settings);
|
||||
const updateSettings = useSettings((s) => s.update);
|
||||
const isMobile = useIsMobile();
|
||||
const editorRef = useRef<RichEditorHandle>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [dropping, setDropping] = useState(false);
|
||||
const moreMenu = useMenu();
|
||||
const sendMenu = useMenu();
|
||||
const templateMenu = useMenu();
|
||||
const [showToolbar, setShowToolbar] = useState(true);
|
||||
const d = draft;
|
||||
const key = d.key;
|
||||
|
||||
const patch = useCallback((p: Partial<Draft>) => update(key, p), [update, key]);
|
||||
const onHtml = useCallback((html: string) => update(key, { html }), [update, key]);
|
||||
|
||||
// Esc closes (saves draft); Ctrl+Enter sends
|
||||
useEffect(() => {
|
||||
if (d.minimized) return;
|
||||
return keyboard.pushScope("composer", [
|
||||
{ keys: "mod+enter", description: "Send message", group: "Compose", handler: () => void doSend(), allowInInput: true },
|
||||
{ keys: "esc", description: "Close composer (saves draft)", group: "Compose", handler: () => { if (document.activeElement?.closest(".composer")) { void close(key); return true; } return false; }, allowInInput: true },
|
||||
{ keys: "mod+s", description: "Save draft", group: "Compose", handler: () => { void saveDraft(key); }, allowInInput: true },
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, d.minimized]);
|
||||
|
||||
const bodyText = useMemo(() => (d.format === "html" ? htmlToText(d.html.replace(/<div class="ihm-quote">[\s\S]*$/, "")) : d.text), [d.html, d.text, d.format]);
|
||||
|
||||
const doSend = async () => {
|
||||
const all = [...d.to, ...d.cc, ...d.bcc];
|
||||
if (!all.length) {
|
||||
toast.error("Please add at least one recipient");
|
||||
return;
|
||||
}
|
||||
const bad = all.filter((a) => !isValidEmail(a.email));
|
||||
if (bad.length) {
|
||||
toast.error(`Invalid address: ${bad[0]!.email}`);
|
||||
return;
|
||||
}
|
||||
if (d.attachments.some((a) => a.error)) {
|
||||
toast.error("Remove attachments that failed to upload first");
|
||||
return;
|
||||
}
|
||||
if (d.attachments.some((a) => !a.blobId)) {
|
||||
toast.error("Attachments are still uploading");
|
||||
return;
|
||||
}
|
||||
if (!d.subject.trim()) {
|
||||
const ok = await confirmDialog({ title: "Send without a subject?", confirmLabel: "Send anyway" });
|
||||
if (!ok) return;
|
||||
}
|
||||
if (settings.attachmentReminder && !d.attachments.length && /\b(attach(ed|ment|ing)?|enclosed|anbei|ci-joint|adjunto)\b/i.test(bodyText) ) {
|
||||
const ok = await confirmDialog({ title: "Did you forget the attachment?", message: "Your message mentions an attachment, but nothing is attached.", confirmLabel: "Send anyway" });
|
||||
if (!ok) return;
|
||||
}
|
||||
await send(key);
|
||||
};
|
||||
|
||||
const toggleFormat = () => {
|
||||
if (d.format === "html") {
|
||||
patch({ format: "text", text: htmlToText(d.html) });
|
||||
} else {
|
||||
patch({ format: "html", html: textToHtml(d.text, { linkify: false, quoteColors: false }).replace(/\n/g, "<br>") });
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDropping(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length) addFiles(key, files);
|
||||
};
|
||||
|
||||
const ident = identities.find((i) => i.id === d.identityId) ?? identities[0];
|
||||
const title = d.subject || (d.replyMode ? (d.replyMode === "forward" ? "Forward" : "Reply") : "New message");
|
||||
const status = d.sending ? "Sending…" : d.saving ? "Saving…" : d.error ? "Error" : d.savedAt ? `Saved ${formatRelative(new Date(d.savedAt).toISOString())}` : d.dirty ? "Unsaved" : "";
|
||||
const totalSize = d.attachments.reduce((n, a) => n + a.size, 0);
|
||||
|
||||
if (d.minimized) {
|
||||
return (
|
||||
<div className="composer minimized" onClick={() => focus(key)}>
|
||||
<div className="composer-head">
|
||||
<span className="title">{title}</span>
|
||||
<button className="icon-btn sm" aria-label="Restore" onClick={(e) => { e.stopPropagation(); focus(key); }}><Maximize2 size={16} /></button>
|
||||
<button className="icon-btn sm" aria-label="Close" onClick={(e) => { e.stopPropagation(); void close(key); }}><X size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`composer ${d.maximized ? "maximized" : ""} ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop} role="dialog" aria-label="Compose message">
|
||||
<div className="composer-head" onDoubleClick={() => patch({ maximized: !d.maximized })}>
|
||||
<span className="title">{title}</span>
|
||||
<span className="status">{status}</span>
|
||||
{!isMobile && <button className="icon-btn sm" aria-label="Minimize" title="Minimize" onClick={() => patch({ minimized: true })}><Minus size={16} /></button>}
|
||||
{!isMobile && <button className="icon-btn sm" aria-label={d.maximized ? "Restore" : "Maximize"} title={d.maximized ? "Restore" : "Full screen"} onClick={() => patch({ maximized: !d.maximized })}>{d.maximized ? <Minimize2 size={16} /> : <Maximize2 size={16} />}</button>}
|
||||
<button className="icon-btn sm" aria-label="Close" title="Save & close (Esc)" onClick={() => void close(key)}><X size={18} /></button>
|
||||
</div>
|
||||
<div className="composer-body">
|
||||
<div className="composer-fields">
|
||||
{identities.length > 1 && (
|
||||
<div className="composer-field">
|
||||
<label>From</label>
|
||||
<select className="from-select" value={ident?.id ?? ""} onChange={(e) => setIdentity(key, e.target.value)}>
|
||||
{identities.map((i) => <option key={i.id} value={i.id}>{i.name ? `${i.name} <${i.email}>` : i.email}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-to`}>To</label>
|
||||
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={!d.to.length} />
|
||||
<span className="field-extra">
|
||||
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>}
|
||||
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>}
|
||||
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title="Set a Reply-To address">Reply-To</button>}
|
||||
</span>
|
||||
</div>
|
||||
{d.showReplyTo && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-rt`} title="Replies will go to this address instead of the From address">Reply-To</label>
|
||||
<RecipientInput id={`${key}-rt`} value={d.replyTo} onChange={(replyTo) => patch({ replyTo })} placeholder="Replies go to…" />
|
||||
</div>
|
||||
)}
|
||||
{d.showCc && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-cc`}>Cc</label>
|
||||
<RecipientInput id={`${key}-cc`} value={d.cc} onChange={(cc) => patch({ cc })} />
|
||||
</div>
|
||||
)}
|
||||
{d.showBcc && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-bcc`}>Bcc</label>
|
||||
<RecipientInput id={`${key}-bcc`} value={d.bcc} onChange={(bcc) => patch({ bcc })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-subj`} className="sr-only">Subject</label>
|
||||
<input id={`${key}-subj`} className="plain" placeholder="Subject" value={d.subject} onChange={(e) => patch({ subject: e.target.value })} autoFocus={d.to.length > 0 && !d.subject} />
|
||||
{d.priority !== "normal" && <span className="tag" style={{ background: d.priority === "high" ? "var(--danger)" : "var(--fg-faint)" }}>{d.priority === "high" ? "High priority" : "Low priority"}</span>}
|
||||
{d.requestReceipt && <span className="tag" style={{ background: "var(--accent)" }} title="Read receipt requested"><CheckCheck size={12} /></span>}
|
||||
</div>
|
||||
</div>
|
||||
{d.format === "html" ? (
|
||||
<RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder="Write your message…" spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={d.to.length > 0 && Boolean(d.subject)} />
|
||||
) : (
|
||||
<textarea className="editor-textarea" value={d.text} onChange={(e) => patch({ text: e.target.value })} placeholder="Write your message…" spellCheck={settings.spellcheck} />
|
||||
)}
|
||||
{d.attachments.some((a) => !a.inline) && (
|
||||
<div className="composer-attachments">
|
||||
{d.attachments.filter((a) => !a.inline).map((a) => (
|
||||
<div key={a.id} className={`attachment ${a.error ? "error" : ""}`} title={a.error ?? a.name}>
|
||||
<span className="att-icon">{attachmentIcon(a.type, a.name)}</span>
|
||||
<span className="att-text">
|
||||
<span className="att-name">{a.name}</span>
|
||||
<span className="att-size">{a.error ? <span style={{ color: "var(--danger)" }}>{a.error}</span> : a.blobId ? formatSize(a.size) : `${a.progress}%`}{a.inline ? " · inline" : ""}</span>
|
||||
</span>
|
||||
<button className="icon-btn xs" aria-label="Remove attachment" onClick={() => removeAttachment(key, a.id)}><X size={14} /></button>
|
||||
{!a.blobId && !a.error && <span className="att-progress" style={{ width: `${a.progress}%` }} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-foot">
|
||||
<span className="send-group">
|
||||
<button className="btn btn-primary" onClick={() => void doSend()} disabled={d.sending} title="Send (Ctrl+Enter)"><Send size={16} /> Send</button>
|
||||
<button className="btn btn-primary" onClick={sendMenu.open} aria-label="Send options"><ChevronDown size={16} /></button>
|
||||
</span>
|
||||
<Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={240}>
|
||||
<MenuItem icon={<Send size={16} />} label="Send" kbd="Ctrl+↵" onClick={() => void doSend()} />
|
||||
<MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
|
||||
</Popover>
|
||||
<span className="more-actions">
|
||||
<button className="icon-btn" title="Attach files" onClick={() => fileRef.current?.click()}><Paperclip size={18} /></button>
|
||||
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { const files = Array.from(e.target.files ?? []); if (files.length) addFiles(key, files); e.target.value = ""; }} />
|
||||
{d.format === "html" && <button className={`icon-btn ${showToolbar ? "active" : ""}`} title="Formatting options" onClick={() => setShowToolbar((v) => !v)}><Type size={18} /></button>}
|
||||
{settings.templates.length > 0 && <button className="icon-btn" title="Insert template" onClick={templateMenu.open}><FileText size={18} /></button>}
|
||||
<Popover anchor={templateMenu.anchor} onClose={templateMenu.close} side="top" width={260}>
|
||||
<MenuTitle>Templates</MenuTitle>
|
||||
{settings.templates.map((t) => <MenuItem key={t.id} label={t.name} onClick={() => insertTemplate(key, t.html, t.subject)} />)}
|
||||
</Popover>
|
||||
<button className="icon-btn" onClick={moreMenu.open} aria-label="More options"><MoreVertical size={18} /></button>
|
||||
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} side="top" width={260}>
|
||||
<MenuItem icon={<Type size={16} />} label={d.format === "html" ? "Switch to plain text" : "Switch to rich text"} onClick={toggleFormat} />
|
||||
<MenuItem icon={<CheckCheck size={16} />} label="Request read receipt" checked={d.requestReceipt} onClick={() => patch({ requestReceipt: !d.requestReceipt })} />
|
||||
<MenuSep />
|
||||
<MenuTitle>Priority</MenuTitle>
|
||||
<MenuItem label="High" checked={d.priority === "high"} onClick={() => patch({ priority: "high" })} />
|
||||
<MenuItem label="Normal" checked={d.priority === "normal"} onClick={() => patch({ priority: "normal" })} />
|
||||
<MenuItem label="Low" checked={d.priority === "low"} onClick={() => patch({ priority: "low" })} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<ChevronsDown size={16} />} label="Save as template" onClick={async () => { const name = await promptDialog({ title: "Save as template", defaultValue: d.subject || "Template", placeholder: "Template name" }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text) }] }); }} />
|
||||
<MenuItem icon={<FileText size={16} />} label="Save draft now" onClick={() => void saveDraft(key)} />
|
||||
</Popover>
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
{totalSize > 20 * 1024 * 1024 && <span className="hint row gap-4" title="Large attachments may be rejected by some servers"><AlertTriangle size={14} /> {formatSize(totalSize)}</span>}
|
||||
<button className="icon-btn danger" title="Discard draft" aria-label="Discard draft" onClick={async () => { if (!d.dirty && !d.draftId) { void close(key, { discard: true }); return; } if (await confirmDialog({ title: "Discard this draft?", confirmLabel: "Discard", danger: true })) void close(key, { discard: true }); }}><Trash2 size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { Composer } from "./Composer";
|
||||
import { useIsMobile } from "@/ui/misc";
|
||||
|
||||
export function ComposerDock() {
|
||||
const drafts = useCompose((s) => s.drafts);
|
||||
const activeKey = useCompose((s) => s.activeKey);
|
||||
const isMobile = useIsMobile();
|
||||
if (!drafts.length) return null;
|
||||
// On mobile only the active composer is shown (full screen); others are minimized bars.
|
||||
const visible = isMobile ? drafts.filter((d) => d.key === activeKey || d.minimized) : drafts;
|
||||
return (
|
||||
<div className="composer-dock">
|
||||
{visible.map((d) => (
|
||||
<Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type ClipboardEvent } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import type { EmailAddress } from "@/jmap/types";
|
||||
import { isValidEmail, parseAddressList, displayName } from "@/lib/address";
|
||||
import { useContacts, type Suggestion } from "@/store/contacts";
|
||||
import { Avatar } from "@/ui/misc";
|
||||
|
||||
interface Props {
|
||||
value: EmailAddress[];
|
||||
onChange: (v: EmailAddress[]) => void;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function RecipientInput({ value, onChange, placeholder, autoFocus, id }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [sugg, setSugg] = useState<Suggestion[]>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const suggest = useContacts((s) => s.suggest);
|
||||
const reqId = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const q = text.trim();
|
||||
if (!q) {
|
||||
setSugg([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const id = ++reqId.current;
|
||||
const t = window.setTimeout(() => {
|
||||
void suggest(q).then((list) => {
|
||||
if (id !== reqId.current) return;
|
||||
const existing = new Set(value.map((v) => v.email.toLowerCase()));
|
||||
const filtered = list.filter((s) => !existing.has(s.email.toLowerCase()));
|
||||
setSugg(filtered);
|
||||
setActive(0);
|
||||
setOpen(filtered.length > 0);
|
||||
});
|
||||
}, 120);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [text, suggest, value]);
|
||||
|
||||
const commit = (raw?: string) => {
|
||||
const s = (raw ?? text).trim().replace(/[,;]+$/, "");
|
||||
if (!s) return;
|
||||
const parsed = parseAddressList(s);
|
||||
if (!parsed.length) return;
|
||||
onChange([...value, ...parsed.filter((p) => !value.some((v) => v.email.toLowerCase() === p.email.toLowerCase()))]);
|
||||
setText("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const pick = (s: Suggestion) => {
|
||||
onChange([...value, { name: s.name, email: s.email }]);
|
||||
setText("");
|
||||
setOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (open && sugg.length) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => (a + 1) % sugg.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => (a - 1 + sugg.length) % sugg.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
if (sugg[active]) {
|
||||
e.preventDefault();
|
||||
pick(sugg[active]!);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "," || e.key === ";") {
|
||||
if (text.trim()) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
} else if (e.key === "Enter") e.preventDefault();
|
||||
} else if (e.key === "Tab" && text.trim()) {
|
||||
commit();
|
||||
} else if (e.key === "Backspace" && !text && value.length) {
|
||||
const last = value[value.length - 1]!;
|
||||
onChange(value.slice(0, -1));
|
||||
setText(last.name ? `${last.name} <${last.email}>` : last.email);
|
||||
}
|
||||
};
|
||||
|
||||
const onPaste = (e: ClipboardEvent<HTMLInputElement>) => {
|
||||
const t = e.clipboardData.getData("text");
|
||||
if (t && /[,;\n]|<.+@.+>/.test(t)) {
|
||||
e.preventDefault();
|
||||
commit(text + t);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="recipients" onClick={() => inputRef.current?.focus()}>
|
||||
{value.map((a, i) => (
|
||||
<span key={`${a.email}-${i}`} className={`chip ${isValidEmail(a.email) ? "" : "invalid"}`} title={a.email}>
|
||||
<span className="truncate" style={{ maxWidth: 220 }}>{a.name ? displayName(a) : a.email}</span>
|
||||
<button type="button" className="chip-x" aria-label={`Remove ${a.email}`} onClick={(e) => { e.stopPropagation(); onChange(value.filter((_, j) => j !== i)); }}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
id={id}
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
placeholder={value.length ? "" : placeholder}
|
||||
autoFocus={autoFocus}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
onPaste={onPaste}
|
||||
onBlur={() => { window.setTimeout(() => { setOpen(false); if (text.trim()) commit(); }, 150); }}
|
||||
onFocus={() => sugg.length && setOpen(true)}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={open}
|
||||
/>
|
||||
{open && (
|
||||
<div className="suggest-list" role="listbox">
|
||||
{sugg.map((s, i) => (
|
||||
<div key={s.email} className={`suggest-item ${i === active ? "active" : ""}`} role="option" aria-selected={i === active} onMouseDown={(e) => { e.preventDefault(); pick(s); }} onMouseEnter={() => setActive(i)}>
|
||||
<Avatar who={s} size="sm" />
|
||||
<div className="col" style={{ minWidth: 0 }}>
|
||||
<span className="s-name truncate">{s.name ?? s.email}</span>
|
||||
{s.name && <span className="s-email truncate">{s.email}</span>}
|
||||
</div>
|
||||
<span className="s-src">{s.source === "gal" ? "Directory" : s.source === "recent" ? "Recent" : ""}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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