From 18d69616d2ba480599b52bc7591b49649839cd82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 24 Jul 2026 08:05:40 +0000 Subject: [PATCH] =?UTF-8?q?compose:=20rich=20contenteditable=20body=20?= =?UTF-8?q?=E2=80=94=20inline=20images=20land=20at=20the=20caret/drop=20po?= =?UTF-8?q?int?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../Screens/Dashboard/Email/Compose.tsx | 175 +++++++++++------- 1 file changed, 112 insertions(+), 63 deletions(-) diff --git a/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx b/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx index aee07617..6e5bd903 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/Compose.tsx @@ -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 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 ( -
- {url && {file.name}} - -
- ); -}; +const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); export const ComposeModal = () => { const client = useClient(); @@ -105,13 +85,29 @@ export const ComposeModal = () => { const [bcc, setBcc] = useState(''); const [showBcc, setShowBcc] = useState(false); const [subject, setSubject] = useState(''); - const [body, setBody] = useState(''); const [files, setFiles] = useState([]); - const [inlineImages, setInlineImages] = useState([]); const [dragging, setDragging] = useState(false); + const [bodyEmpty, setBodyEmpty] = useState(true); const [sending, setSending] = useState(false); const seeded = useRef(null); const fileInput = useRef(null); + const editorRef = useRef(null); + const inlineMap = useRef(new Map()); // 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(() => { if (draft && draft !== seeded.current) { @@ -122,9 +118,12 @@ export const ComposeModal = () => { setBcc(''); setShowBcc(false); setSubject(draft.subject ?? ''); - setBody(draft.body ?? ''); 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, '
') : ''; + refreshEmpty(); } if (!draft) seeded.current = null; }, [draft]); @@ -132,33 +131,74 @@ export const ComposeModal = () => { // 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 })); - // 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 list = Array.from(incoming).map(named); 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[]) => { - const list = Array.from(incoming).map(named); - const imgs = list.filter((f) => f.type.startsWith('image/')); - const rest = list.filter((f) => !f.type.startsWith('image/')); - if (imgs.length) setInlineImages((prev) => [...prev, ...imgs]); - if (rest.length) setFiles((prev) => [...prev, ...rest]); + + // Insert an image into the body at the given range (or the current caret, or the end). + const insertImage = (file: File, at?: Range | null) => { + const el = editorRef.current; + if (!el) return; + const id = `img-${nextImgId.current++}`; + 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 ?? []) - .filter((it) => it.kind === 'file') - .map((it) => it.getAsFile()) - .filter((f): f is File => !!f); - if (pasted.length) routeFiles(pasted); + + // Paste inside the body: images insert at the caret, other files attach, text pastes as plain text. + 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()) + .filter((f): f is File => !!f) + .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) => { ev.preventDefault(); 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 () => { if (!to.trim()) { @@ -172,16 +212,30 @@ export const ComposeModal = () => { if (cc.trim()) fd.append('cc', cc); if (bcc.trim()) fd.append('bcc', bcc); fd.append('subject', subject); - fd.append('body', body); if (draft?.inReplyTo) fd.append('inReplyTo', draft.inReplyTo); files.forEach((f) => fd.append('files', f)); - // Inline images: the backend gives each a cid `inline-` in this order; the html references them. - if (inlineImages.length) { - const esc = body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); - const imgs = inlineImages.map((_, i) => `
`).join(''); - fd.append('html', `${esc}${esc ? '
' : ''}${imgs}`); - inlineImages.forEach((f) => fd.append('inline', f)); + + // Serialize the body. Clone it, rewrite each inline to a `cid:inline-N` ref (backend + // matches by order), and collect those files. Plain text goes as the fallback part. + const el = editorRef.current; + const inlineFiles: File[] = []; + 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); toast.success('Email sent'); // Gmail files the sent copy in Sent, so refresh the list to surface it. @@ -202,7 +256,6 @@ export const ComposeModal = () => { !o && close()}> { ev.preventDefault(); @@ -261,20 +314,16 @@ export const ComposeModal = () => { placeholder="Subject" className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40" /> -