attachments preview overlay, docx/xlsx/odf rendering, composer drag-drop & undo-send UX
Frontend: - Full-screen attachment preview overlay (AttachmentPreview) for image/pdf/txt plus office docs: .docx via docx-preview (rendered live into the sandboxed iframe so header/footer VML images survive), .xlsx via xlsx-preview, and .odt/.ods/.odp/.odg via a JSZip+DOMParser ODF walker (officeConvert). - Type-aware attachment icons (green spreadsheet, blue document, red PDF, image/archive); new document/image/spreadsheet/print SVGs. - Composer: drag-drop attachments (body drop inserts images inline, else attaches); inserted images are selectable/resizable/deletable (RichTextEditor). - Undo-send: "Sending…" banner with live "N seconds to Undo" countdown. - Search result list: pin/archive/delete update correctly; grey-out only for search hits; scoped "Search in Trash/Spam". Backend: - Search: prefix (search-as-you-type) matching, email-address search, and address-first result ordering; Trash/Junk excluded with scoped folder search. - Attachments: RFC 6266 Content-Disposition for non-ASCII filenames; one-time inline image loading. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
This commit is contained in:
@@ -142,7 +142,17 @@ export function Composer({
|
||||
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
|
||||
const [attachError, setAttachError] = useState("");
|
||||
const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">("");
|
||||
const [undo, setUndo] = useState<{ outboxId: string } | null>(null);
|
||||
const [undo, setUndo] = useState<{ outboxId: string; until: number } | null>(null);
|
||||
const [undoLeft, setUndoLeft] = useState(0);
|
||||
// Tick the undo countdown while the window is open.
|
||||
useEffect(() => {
|
||||
if (!undo) return;
|
||||
const id = window.setInterval(
|
||||
() => setUndoLeft(Math.max(0, Math.ceil((undo.until - Date.now()) / 1000))),
|
||||
500,
|
||||
);
|
||||
return () => window.clearInterval(id);
|
||||
}, [undo]);
|
||||
const [showClosePrompt, setShowClosePrompt] = useState(false);
|
||||
const draftId = useRef<string | null>(init.draftId ?? null);
|
||||
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close
|
||||
@@ -224,7 +234,7 @@ export function Composer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const addFiles = async (files: FileList) => {
|
||||
const addFiles = async (files: FileList | File[]) => {
|
||||
markDirty();
|
||||
setAttachError("");
|
||||
for (const file of Array.from(files)) {
|
||||
@@ -246,6 +256,77 @@ export function Composer({
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
// Drag & drop: files dropped on the writing area insert inline (images) or
|
||||
// attach (other types); dropped anywhere above it, everything attaches.
|
||||
const editorAreaRef = useRef<HTMLDivElement>(null);
|
||||
const [dragKind, setDragKind] = useState<null | "body" | "attach">(null);
|
||||
|
||||
const readDataUrl = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => resolve(String(r.result));
|
||||
r.onerror = () => reject(r.error);
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const setCaretToPoint = (x: number, y: number) => {
|
||||
const d = document as Document & {
|
||||
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
|
||||
};
|
||||
let range: Range | null = null;
|
||||
if (d.caretRangeFromPoint) range = d.caretRangeFromPoint(x, y);
|
||||
else if (d.caretPositionFromPoint) {
|
||||
const p = d.caretPositionFromPoint(x, y);
|
||||
if (p) {
|
||||
range = document.createRange();
|
||||
range.setStart(p.offsetNode, p.offset);
|
||||
range.collapse(true);
|
||||
}
|
||||
}
|
||||
if (range && editorAreaRef.current?.contains(range.startContainer)) {
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (e: React.DragEvent) => {
|
||||
if (!Array.from(e.dataTransfer.types).includes("Files")) return;
|
||||
e.preventDefault();
|
||||
setDragKind(editorAreaRef.current?.contains(e.target as Node) ? "body" : "attach");
|
||||
};
|
||||
|
||||
const onDragLeave = (e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragKind(null);
|
||||
};
|
||||
|
||||
const onDrop = async (e: React.DragEvent) => {
|
||||
const files = e.dataTransfer.files;
|
||||
if (!files || !files.length) return;
|
||||
e.preventDefault();
|
||||
const inBody = editorAreaRef.current?.contains(e.target as Node);
|
||||
setDragKind(null);
|
||||
if (!inBody) {
|
||||
void addFiles(files);
|
||||
return;
|
||||
}
|
||||
setCaretToPoint(e.clientX, e.clientY);
|
||||
const others: File[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (file.type.startsWith("image/")) {
|
||||
try {
|
||||
editorRef.current?.insertHtml(`<img src="${await readDataUrl(file)}" style="max-width:100%">`);
|
||||
markDirty();
|
||||
} catch {
|
||||
others.push(file); // fall back to attaching if the read fails
|
||||
}
|
||||
} else {
|
||||
others.push(file);
|
||||
}
|
||||
}
|
||||
if (others.length) void addFiles(others);
|
||||
};
|
||||
|
||||
// The editor's seed content. New messages get the account signature (2 blank
|
||||
// lines above it); replies keep the quoted original in a marked block so the
|
||||
// signature can later be inserted above it.
|
||||
@@ -325,8 +406,12 @@ export function Composer({
|
||||
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
|
||||
else await api.updateDraft(draftId.current, payload());
|
||||
const res = await api.send(draftId.current);
|
||||
setUndo({ outboxId: res.outbox_id });
|
||||
undoTimer.current = window.setTimeout(onClose, 10000);
|
||||
// Drive the countdown + auto-close off the server's undo deadline.
|
||||
const until = new Date(res.undo_until).getTime();
|
||||
const remaining = Math.max(0, until - Date.now());
|
||||
setUndo({ outboxId: res.outbox_id, until });
|
||||
setUndoLeft(Math.ceil(remaining / 1000));
|
||||
undoTimer.current = window.setTimeout(onClose, remaining);
|
||||
} catch (e) {
|
||||
sentRef.current = false;
|
||||
setStatus("error");
|
||||
@@ -403,22 +488,37 @@ export function Composer({
|
||||
if (undo) {
|
||||
return (
|
||||
<div className="m-3 flex items-center justify-between rounded-lg bg-app-fg px-4 py-2.5 text-sm text-app-surface shadow-lg">
|
||||
<span>{t("compose.sent")}</span>
|
||||
<button className="font-medium text-app-accent" onClick={doUndo}>
|
||||
{t("compose.undo")}
|
||||
</button>
|
||||
<span>{t("compose.sending")}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{undoLeft > 0 && (
|
||||
<span className="tabular-nums text-app-surface/70">
|
||||
{t("compose.undoIn", { count: undoLeft })}
|
||||
</span>
|
||||
)}
|
||||
<button className="font-medium text-app-accent hover:underline" onClick={doUndo}>
|
||||
{t("compose.undo")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className={`relative flex flex-col overflow-hidden border-app-border bg-app-elevated ${
|
||||
fullWindow
|
||||
? "h-full"
|
||||
: `rounded-xl border shadow-2xl ${expanded ? "h-full" : ""}`
|
||||
}`}
|
||||
>
|
||||
{dragKind && (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center border-2 border-dashed border-app-accent bg-app-accent/10 text-sm font-medium text-app-accent">
|
||||
{dragKind === "body" ? t("compose.dropBody") : t("compose.dropAttach")}
|
||||
</div>
|
||||
)}
|
||||
{showClosePrompt && (
|
||||
<div
|
||||
className="absolute inset-0 z-40 flex items-center justify-center bg-black/40 p-4"
|
||||
@@ -672,20 +772,22 @@ export function Composer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{bodyReady ? (
|
||||
<RichTextEditor
|
||||
ref={editorRef}
|
||||
initialHtml={initialHtml}
|
||||
onInput={(html) => {
|
||||
markDirty();
|
||||
setBodyHtml(html);
|
||||
}}
|
||||
showToolbar={showToolbar}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
|
||||
)}
|
||||
{/* Body — wrapped so a drop here is detected as the writing area. */}
|
||||
<div ref={editorAreaRef} className="flex min-h-0 flex-1 flex-col">
|
||||
{bodyReady ? (
|
||||
<RichTextEditor
|
||||
ref={editorRef}
|
||||
initialHtml={initialHtml}
|
||||
onInput={(html) => {
|
||||
markDirty();
|
||||
setBodyHtml(html);
|
||||
}}
|
||||
showToolbar={showToolbar}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface RichTextEditorHandle {
|
||||
replaceSignature: (html: string) => void;
|
||||
// Insert the signature below the body but above quoted content (reply menu).
|
||||
insertSignature: (html: string) => void;
|
||||
// Insert HTML (e.g. a dropped inline image) at the caret, or append it.
|
||||
insertHtml: (html: string) => void;
|
||||
}
|
||||
|
||||
// A lightweight WYSIWYG editor over a contenteditable div. The DOM is the source
|
||||
@@ -153,6 +155,9 @@ export const RichTextEditor = forwardRef<
|
||||
const paletteRef = useRef<HTMLDivElement>(null);
|
||||
const [active, setActive] = useState<Record<string, boolean>>({});
|
||||
const [colorMenu, setColorMenu] = useState<null | "fore" | "hilite">(null);
|
||||
// Currently-selected inline image + its handle box (px, relative to the editor).
|
||||
const [selImg, setSelImg] = useState<HTMLImageElement | null>(null);
|
||||
const [box, setBox] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
|
||||
|
||||
// Seed the editable region exactly once.
|
||||
useEffect(() => {
|
||||
@@ -194,6 +199,19 @@ export const RichTextEditor = forwardRef<
|
||||
el.insertBefore(spacerAfter, anchor);
|
||||
onInput(el.innerHTML);
|
||||
},
|
||||
insertHtml: (html: string) => {
|
||||
const el = ref.current;
|
||||
if (!el || !html) return;
|
||||
el.focus();
|
||||
// Insert at the current caret; if that fails (no selection in the editor),
|
||||
// append before the signature/quoted block.
|
||||
if (!document.execCommand("insertHTML", false, html)) {
|
||||
const block = document.createElement("div");
|
||||
block.innerHTML = html;
|
||||
el.insertBefore(block, el.querySelector(".wmc-signature, .wmc-quoted"));
|
||||
}
|
||||
onInput(el.innerHTML);
|
||||
},
|
||||
}));
|
||||
|
||||
// Remember the caret/selection so native pickers (color/file) that steal focus
|
||||
@@ -294,6 +312,75 @@ export const RichTextEditor = forwardRef<
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
// ── Inline-image select / resize / delete ─────────────────────────────────
|
||||
const computeBox = useCallback(() => {
|
||||
const img = selImg;
|
||||
const ed = ref.current;
|
||||
if (!img || !ed) return null;
|
||||
const ir = img.getBoundingClientRect();
|
||||
const er = ed.getBoundingClientRect();
|
||||
return { left: ir.left - er.left, top: ir.top - er.top, width: ir.width, height: ir.height };
|
||||
}, [selImg]);
|
||||
|
||||
useEffect(() => setBox(computeBox()), [selImg, computeBox]);
|
||||
|
||||
// Reposition handles on window resize; deselect on an outside click.
|
||||
useEffect(() => {
|
||||
if (!selImg) return;
|
||||
const onWin = () => setBox(computeBox());
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const el = e.target as HTMLElement;
|
||||
if (el === selImg || el.closest("[data-img-handle]")) return;
|
||||
setSelImg(null);
|
||||
};
|
||||
window.addEventListener("resize", onWin);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => {
|
||||
window.removeEventListener("resize", onWin);
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
};
|
||||
}, [selImg, computeBox]);
|
||||
|
||||
const startResize = (corner: "nw" | "ne" | "sw" | "se") => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const img = selImg;
|
||||
const ed = ref.current;
|
||||
if (!img || !ed) return;
|
||||
const startX = e.clientX;
|
||||
const startW = img.getBoundingClientRect().width;
|
||||
const maxW = ed.clientWidth - 24; // minus px-3 padding
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const dx = ev.clientX - startX;
|
||||
const grow = corner === "se" || corner === "ne" ? dx : -dx;
|
||||
img.style.width = `${Math.round(Math.max(24, Math.min(startW + grow, maxW)))}px`;
|
||||
img.style.height = "auto";
|
||||
setBox(computeBox());
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
emit();
|
||||
setBox(computeBox());
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
const onEditorClick = (e: React.MouseEvent) => {
|
||||
const el = e.target as HTMLElement;
|
||||
setSelImg(el.tagName === "IMG" ? (el as HTMLImageElement) : null);
|
||||
};
|
||||
|
||||
const onEditorKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (selImg && (e.key === "Delete" || e.key === "Backspace")) {
|
||||
e.preventDefault();
|
||||
selImg.remove();
|
||||
setSelImg(null);
|
||||
emit();
|
||||
}
|
||||
};
|
||||
|
||||
const Btn = ({
|
||||
onClick,
|
||||
title,
|
||||
@@ -452,18 +539,44 @@ export const RichTextEditor = forwardRef<
|
||||
{ICONS.clear}
|
||||
</Btn>
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-testid="rich-editor"
|
||||
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
|
||||
onKeyUp={refreshActive}
|
||||
onMouseUp={refreshActive}
|
||||
className={`wmc-editor ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`}
|
||||
/>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-testid="rich-editor"
|
||||
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
|
||||
onKeyUp={refreshActive}
|
||||
onMouseUp={refreshActive}
|
||||
onClick={onEditorClick}
|
||||
onKeyDown={onEditorKeyDown}
|
||||
onScroll={() => setBox(computeBox())}
|
||||
className={`wmc-editor ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`}
|
||||
/>
|
||||
{selImg && box && (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 border-2 border-app-accent"
|
||||
style={{ left: box.left - 1, top: box.top - 1, width: box.width + 2, height: box.height + 2 }}
|
||||
/>
|
||||
{(["nw", "ne", "sw", "se"] as const).map((c) => (
|
||||
<div
|
||||
key={c}
|
||||
data-img-handle
|
||||
onMouseDown={startResize(c)}
|
||||
className="absolute z-10 h-2.5 w-2.5 rounded-sm border border-white bg-app-accent shadow"
|
||||
style={{
|
||||
left: (c[1] === "w" ? box.left : box.left + box.width) - 5,
|
||||
top: (c[0] === "n" ? box.top : box.top + box.height) - 5,
|
||||
cursor: c === "nw" || c === "se" ? "nwse-resize" : "nesw-resize",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user