compose: rich contenteditable body — inline images land at the caret/drop point
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -73,27 +73,7 @@ const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: st
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inline-image preview with a managed object URL (created/revoked with the file).
|
const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
const InlineThumb = ({ file, onRemove }: { file: File; onRemove: () => void }) => {
|
|
||||||
const [url, setUrl] = useState('');
|
|
||||||
useEffect(() => {
|
|
||||||
const u = URL.createObjectURL(file);
|
|
||||||
setUrl(u);
|
|
||||||
return () => URL.revokeObjectURL(u);
|
|
||||||
}, [file]);
|
|
||||||
return (
|
|
||||||
<div className="group relative h-16 w-16 overflow-hidden rounded border">
|
|
||||||
{url && <img src={url} alt={file.name} className="h-full w-full object-cover" />}
|
|
||||||
<button
|
|
||||||
onClick={onRemove}
|
|
||||||
className="absolute right-0.5 top-0.5 rounded-full bg-black/60 p-0.5 text-white opacity-0 group-hover:opacity-100 cursor-pointer"
|
|
||||||
title="Remove"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ComposeModal = () => {
|
export const ComposeModal = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
@@ -105,13 +85,29 @@ export const ComposeModal = () => {
|
|||||||
const [bcc, setBcc] = useState('');
|
const [bcc, setBcc] = useState('');
|
||||||
const [showBcc, setShowBcc] = useState(false);
|
const [showBcc, setShowBcc] = useState(false);
|
||||||
const [subject, setSubject] = useState('');
|
const [subject, setSubject] = useState('');
|
||||||
const [body, setBody] = useState('');
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [inlineImages, setInlineImages] = useState<File[]>([]);
|
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [bodyEmpty, setBodyEmpty] = useState(true);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const seeded = useRef<ComposeDraft | null>(null);
|
const seeded = useRef<ComposeDraft | null>(null);
|
||||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||||
|
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const inlineMap = useRef(new Map<string, File>()); // data-inline-id → File, for the images embedded in the body
|
||||||
|
const nextImgId = useRef(0);
|
||||||
|
|
||||||
|
const refreshEmpty = () => {
|
||||||
|
const el = editorRef.current;
|
||||||
|
setBodyEmpty(!el || (!el.textContent?.trim() && !el.querySelector('img')));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Free object URLs for any images still in the editor, and clear the id→File map.
|
||||||
|
const revokeInline = () => {
|
||||||
|
editorRef.current?.querySelectorAll('img[data-inline-id]').forEach((img) => {
|
||||||
|
const src = (img as HTMLImageElement).src;
|
||||||
|
if (src.startsWith('blob:')) URL.revokeObjectURL(src);
|
||||||
|
});
|
||||||
|
inlineMap.current.clear();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draft && draft !== seeded.current) {
|
if (draft && draft !== seeded.current) {
|
||||||
@@ -122,9 +118,12 @@ export const ComposeModal = () => {
|
|||||||
setBcc('');
|
setBcc('');
|
||||||
setShowBcc(false);
|
setShowBcc(false);
|
||||||
setSubject(draft.subject ?? '');
|
setSubject(draft.subject ?? '');
|
||||||
setBody(draft.body ?? '');
|
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setInlineImages([]);
|
inlineMap.current.clear();
|
||||||
|
nextImgId.current = 0;
|
||||||
|
// Seed the contenteditable body directly (uncontrolled — React never re-renders its content).
|
||||||
|
if (editorRef.current) editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
|
||||||
|
refreshEmpty();
|
||||||
}
|
}
|
||||||
if (!draft) seeded.current = null;
|
if (!draft) seeded.current = null;
|
||||||
}, [draft]);
|
}, [draft]);
|
||||||
@@ -132,33 +131,74 @@ export const ComposeModal = () => {
|
|||||||
// Clipboard images often come nameless — give them a sensible filename.
|
// Clipboard images often come nameless — give them a sensible filename.
|
||||||
const named = (f: File) => (f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type }));
|
const named = (f: File) => (f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type }));
|
||||||
|
|
||||||
// Attach button: everything goes as a regular attachment.
|
// Attach button (and non-image paste/drop): everything goes as a regular attachment.
|
||||||
const addAttachments = (incoming: FileList | File[]) => {
|
const addAttachments = (incoming: FileList | File[]) => {
|
||||||
const list = Array.from(incoming).map(named);
|
const list = Array.from(incoming).map(named);
|
||||||
if (list.length) setFiles((prev) => [...prev, ...list]);
|
if (list.length) setFiles((prev) => [...prev, ...list]);
|
||||||
};
|
};
|
||||||
// Paste / drop: images embed inline in the body, everything else is a regular attachment.
|
|
||||||
const routeFiles = (incoming: FileList | File[]) => {
|
// Insert an image into the body at the given range (or the current caret, or the end).
|
||||||
const list = Array.from(incoming).map(named);
|
const insertImage = (file: File, at?: Range | null) => {
|
||||||
const imgs = list.filter((f) => f.type.startsWith('image/'));
|
const el = editorRef.current;
|
||||||
const rest = list.filter((f) => !f.type.startsWith('image/'));
|
if (!el) return;
|
||||||
if (imgs.length) setInlineImages((prev) => [...prev, ...imgs]);
|
const id = `img-${nextImgId.current++}`;
|
||||||
if (rest.length) setFiles((prev) => [...prev, ...rest]);
|
inlineMap.current.set(id, file);
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
img.setAttribute('data-inline-id', id);
|
||||||
|
img.style.maxWidth = '100%';
|
||||||
|
img.style.height = 'auto';
|
||||||
|
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const caret = at ?? (sel && sel.rangeCount && el.contains(sel.anchorNode) ? sel.getRangeAt(0) : null);
|
||||||
|
if (caret && el.contains(caret.startContainer)) {
|
||||||
|
caret.deleteContents();
|
||||||
|
caret.insertNode(img);
|
||||||
|
caret.setStartAfter(img);
|
||||||
|
caret.collapse(true);
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(caret);
|
||||||
|
} else {
|
||||||
|
el.appendChild(img);
|
||||||
|
}
|
||||||
|
refreshEmpty();
|
||||||
};
|
};
|
||||||
const onPaste = (ev: React.ClipboardEvent) => {
|
|
||||||
const pasted = Array.from(ev.clipboardData?.items ?? [])
|
// Paste inside the body: images insert at the caret, other files attach, text pastes as plain text.
|
||||||
.filter((it) => it.kind === 'file')
|
const onEditorPaste = (ev: React.ClipboardEvent) => {
|
||||||
|
const fileItems = Array.from(ev.clipboardData?.items ?? []).filter((it) => it.kind === 'file');
|
||||||
|
if (fileItems.length) {
|
||||||
|
ev.preventDefault();
|
||||||
|
fileItems
|
||||||
.map((it) => it.getAsFile())
|
.map((it) => it.getAsFile())
|
||||||
.filter((f): f is File => !!f);
|
.filter((f): f is File => !!f)
|
||||||
if (pasted.length) routeFiles(pasted);
|
.map(named)
|
||||||
|
.forEach((f) => (f.type.startsWith('image/') ? insertImage(f) : addAttachments([f])));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = ev.clipboardData?.getData('text/plain');
|
||||||
|
if (text) {
|
||||||
|
ev.preventDefault();
|
||||||
|
document.execCommand('insertText', false, text);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDrop = (ev: React.DragEvent) => {
|
const onDrop = (ev: React.DragEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
if (ev.dataTransfer?.files?.length) routeFiles(ev.dataTransfer.files);
|
const dropped = Array.from(ev.dataTransfer?.files ?? []).map(named);
|
||||||
|
if (!dropped.length) return;
|
||||||
|
// Place images at the drop point if it lands inside the body, otherwise append at the end.
|
||||||
|
const el = editorRef.current;
|
||||||
|
const point = document.caretRangeFromPoint?.(ev.clientX, ev.clientY) ?? null;
|
||||||
|
const range = point && el?.contains(point.startContainer) ? point : null;
|
||||||
|
dropped.forEach((f) => (f.type.startsWith('image/') ? insertImage(f, range) : addAttachments([f])));
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = () => setDraft(null);
|
const close = () => {
|
||||||
|
revokeInline();
|
||||||
|
setDraft(null);
|
||||||
|
};
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
if (!to.trim()) {
|
if (!to.trim()) {
|
||||||
@@ -172,16 +212,30 @@ export const ComposeModal = () => {
|
|||||||
if (cc.trim()) fd.append('cc', cc);
|
if (cc.trim()) fd.append('cc', cc);
|
||||||
if (bcc.trim()) fd.append('bcc', bcc);
|
if (bcc.trim()) fd.append('bcc', bcc);
|
||||||
fd.append('subject', subject);
|
fd.append('subject', subject);
|
||||||
fd.append('body', body);
|
|
||||||
if (draft?.inReplyTo) fd.append('inReplyTo', draft.inReplyTo);
|
if (draft?.inReplyTo) fd.append('inReplyTo', draft.inReplyTo);
|
||||||
files.forEach((f) => fd.append('files', f));
|
files.forEach((f) => fd.append('files', f));
|
||||||
// Inline images: the backend gives each a cid `inline-<i>` in this order; the html references them.
|
|
||||||
if (inlineImages.length) {
|
// Serialize the body. Clone it, rewrite each inline <img> to a `cid:inline-N` ref (backend
|
||||||
const esc = body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
// matches by order), and collect those files. Plain text goes as the fallback part.
|
||||||
const imgs = inlineImages.map((_, i) => `<div><img src="cid:inline-${i}" style="max-width:100%" /></div>`).join('');
|
const el = editorRef.current;
|
||||||
fd.append('html', `${esc}${esc ? '<br>' : ''}${imgs}`);
|
const inlineFiles: File[] = [];
|
||||||
inlineImages.forEach((f) => fd.append('inline', f));
|
if (el) {
|
||||||
|
const clone = el.cloneNode(true) as HTMLElement;
|
||||||
|
clone.querySelectorAll('img[data-inline-id]').forEach((node) => {
|
||||||
|
const img = node as HTMLImageElement;
|
||||||
|
const file = inlineMap.current.get(img.getAttribute('data-inline-id') ?? '');
|
||||||
|
if (!file) return img.remove();
|
||||||
|
img.setAttribute('src', `cid:inline-${inlineFiles.length}`);
|
||||||
|
img.removeAttribute('data-inline-id');
|
||||||
|
inlineFiles.push(file);
|
||||||
|
});
|
||||||
|
fd.append('body', el.innerText);
|
||||||
|
if (inlineFiles.length) {
|
||||||
|
fd.append('html', clone.innerHTML);
|
||||||
|
inlineFiles.forEach((f) => fd.append('inline', f));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await client.post('/email/send', fd);
|
await client.post('/email/send', fd);
|
||||||
toast.success('Email sent');
|
toast.success('Email sent');
|
||||||
// Gmail files the sent copy in Sent, so refresh the list to surface it.
|
// Gmail files the sent copy in Sent, so refresh the list to surface it.
|
||||||
@@ -202,7 +256,6 @@ export const ComposeModal = () => {
|
|||||||
<Dialog open onOpenChange={(o) => !o && close()}>
|
<Dialog open onOpenChange={(o) => !o && close()}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="flex h-[70vh] max-w-2xl flex-col gap-0 overflow-hidden p-0"
|
className="flex h-[70vh] max-w-2xl flex-col gap-0 overflow-hidden p-0"
|
||||||
onPaste={onPaste}
|
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onDragOver={(ev) => {
|
onDragOver={(ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@@ -261,20 +314,16 @@ export const ComposeModal = () => {
|
|||||||
placeholder="Subject"
|
placeholder="Subject"
|
||||||
className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
|
className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<div className="relative flex-1 overflow-hidden">
|
||||||
value={body}
|
{bodyEmpty && <div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message…</div>}
|
||||||
onChange={(ev) => setBody(ev.target.value)}
|
<div
|
||||||
placeholder="Write your message…"
|
ref={editorRef}
|
||||||
className="flex-1 resize-none bg-transparent px-4 py-3 text-sm outline-none placeholder:opacity-40"
|
contentEditable
|
||||||
|
onInput={refreshEmpty}
|
||||||
|
onPaste={onEditorPaste}
|
||||||
|
className="h-full overflow-y-auto px-4 py-3 text-sm outline-none [&_img]:my-1 [&_img]:rounded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{inlineImages.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2 border-t px-4 py-2">
|
|
||||||
{inlineImages.map((f, i) => (
|
|
||||||
<InlineThumb key={`${f.name}-${i}`} file={f} onRemove={() => setInlineImages((prev) => prev.filter((_, idx) => idx !== i))} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 border-t px-4 py-2">
|
<div className="flex flex-wrap gap-1.5 border-t px-4 py-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user