Compose: fold the quoted original behind a "•••" pill

A reply or forward now opens with the quote hidden, as the reading pane
shows it; the pill toggles it. The pill lives in the editor but is stripped
from everything reported out, and the quote is hidden by a class on the
editor rather than in its markup, so drafts and sent mail carry the full
quote. Only a quote the pill directly precedes is hidden, so deleting the
pill cannot strand it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 08:09:09 +02:00
co-authored by Claude Opus 5.5
parent fe3fd94165
commit 37bb6ef78d
3 changed files with 67 additions and 6 deletions
@@ -53,6 +53,33 @@ const PALETTE: string[] = [
"#5b0f00", "#660000", "#783f04", "#7f6000", "#274e13", "#0c343d", "#1c4587", "#073763", "#20124d", "#4c1130", "#5b0f00", "#660000", "#783f04", "#7f6000", "#274e13", "#0c343d", "#1c4587", "#073763", "#20124d", "#4c1130",
]; ];
// The "•••" pill that folds a reply's quoted original, as the reading pane
// does. It lives in the editable DOM (so it sits where the quote is) but is
// never part of the message: `contentHtml` strips it from everything reported
// out, and the quote is hidden by a class on the editor, not by its own markup,
// so the sent and saved HTML always carry the full quote.
const QUOTE_TOGGLE = "wmc-quote-toggle";
function addQuoteToggle(el: HTMLElement, title: string) {
const quote = el.querySelector(".wmc-quoted > blockquote");
if (!quote || el.querySelector(`.${QUOTE_TOGGLE}`)) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = QUOTE_TOGGLE;
btn.contentEditable = "false";
btn.title = title;
btn.setAttribute("aria-label", title);
btn.textContent = "•••";
quote.parentNode?.insertBefore(btn, quote);
}
function contentHtml(el: HTMLElement): string {
if (!el.querySelector(`.${QUOTE_TOGGLE}`)) return el.innerHTML;
const copy = el.cloneNode(true) as HTMLElement;
copy.querySelectorAll(`.${QUOTE_TOGGLE}`).forEach((n) => n.remove());
return copy.innerHTML;
}
function Svg({ children }: { children: React.ReactNode }) { function Svg({ children }: { children: React.ReactNode }) {
return ( return (
<svg <svg
@@ -159,11 +186,15 @@ export const RichTextEditor = forwardRef<
const [selImg, setSelImg] = useState<HTMLImageElement | null>(null); const [selImg, setSelImg] = useState<HTMLImageElement | null>(null);
const [box, setBox] = useState<{ left: number; top: number; width: number; height: number } | null>(null); const [box, setBox] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
// A reply's quoted original starts folded away behind the "•••" pill.
const [quoteOpen, setQuoteOpen] = useState(false);
// Seed the editable region exactly once. // Seed the editable region exactly once.
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
el.innerHTML = initialHtml; el.innerHTML = initialHtml;
addQuoteToggle(el, t("compose.showQuoted"));
onReady?.(el); onReady?.(el);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -178,7 +209,7 @@ export const RichTextEditor = forwardRef<
} else if (sig) { } else if (sig) {
sig.remove(); sig.remove();
} }
onInput(el.innerHTML); onInput(contentHtml(el));
}, },
insertSignature: (html: string) => { insertSignature: (html: string) => {
const el = ref.current; const el = ref.current;
@@ -197,7 +228,7 @@ export const RichTextEditor = forwardRef<
el.insertBefore(spacerBefore, anchor); el.insertBefore(spacerBefore, anchor);
el.insertBefore(block, anchor); el.insertBefore(block, anchor);
el.insertBefore(spacerAfter, anchor); el.insertBefore(spacerAfter, anchor);
onInput(el.innerHTML); onInput(contentHtml(el));
}, },
insertHtml: (html: string) => { insertHtml: (html: string) => {
const el = ref.current; const el = ref.current;
@@ -210,7 +241,7 @@ export const RichTextEditor = forwardRef<
block.innerHTML = html; block.innerHTML = html;
el.insertBefore(block, el.querySelector(".wmc-signature, .wmc-quoted")); el.insertBefore(block, el.querySelector(".wmc-signature, .wmc-quoted"));
} }
onInput(el.innerHTML); onInput(contentHtml(el));
}, },
})); }));
@@ -269,7 +300,7 @@ export const RichTextEditor = forwardRef<
}, [colorMenu]); }, [colorMenu]);
const emit = () => { const emit = () => {
if (ref.current) onInput(ref.current.innerHTML); if (ref.current) onInput(contentHtml(ref.current));
}; };
const exec = (command: string, value?: string) => { const exec = (command: string, value?: string) => {
@@ -369,6 +400,11 @@ export const RichTextEditor = forwardRef<
const onEditorClick = (e: React.MouseEvent) => { const onEditorClick = (e: React.MouseEvent) => {
const el = e.target as HTMLElement; const el = e.target as HTMLElement;
if (el.closest(`.${QUOTE_TOGGLE}`)) {
e.preventDefault();
setQuoteOpen((v) => !v);
return;
}
setSelImg(el.tagName === "IMG" ? (el as HTMLImageElement) : null); setSelImg(el.tagName === "IMG" ? (el as HTMLImageElement) : null);
}; };
@@ -547,13 +583,13 @@ export const RichTextEditor = forwardRef<
role="textbox" role="textbox"
aria-multiline="true" aria-multiline="true"
data-testid="rich-editor" data-testid="rich-editor"
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)} onInput={(e) => onInput(contentHtml(e.currentTarget))}
onKeyUp={refreshActive} onKeyUp={refreshActive}
onMouseUp={refreshActive} onMouseUp={refreshActive}
onClick={onEditorClick} onClick={onEditorClick}
onKeyDown={onEditorKeyDown} onKeyDown={onEditorKeyDown}
onScroll={() => setBox(computeBox())} onScroll={() => setBox(computeBox())}
className={`wmc-editor ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`} className={`wmc-editor ${quoteOpen ? "" : "wmc-quote-collapsed"} ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`}
/> />
{selImg && box && ( {selImg && box && (
<> <>
+1
View File
@@ -183,6 +183,7 @@ export const en = {
"mail.download": "Download", "mail.download": "Download",
"compose.newMessage": "New message", "compose.newMessage": "New message",
"compose.showQuoted": "Show or hide the quoted message",
"compose.to": "To", "compose.to": "To",
"compose.cc": "Cc", "compose.cc": "Cc",
"compose.bcc": "Bcc", "compose.bcc": "Bcc",
+24
View File
@@ -161,6 +161,30 @@ body {
background: rgba(180, 150, 100, 0.06); background: rgba(180, 150, 100, 0.06);
} }
/* The reply's quoted original folds behind a "•••" pill, as in the reading
pane. Only a quote the pill directly precedes is hidden, so deleting the pill
can never leave a quote hidden with no way back to it. */
.wmc-editor .wmc-quote-toggle {
display: inline-block;
margin: 6px 0;
padding: 1px 9px;
border: none;
border-radius: 8px;
background: rgb(var(--app-hover));
color: rgb(var(--app-muted));
cursor: pointer;
font: inherit;
font-size: 15px;
line-height: 1.5;
user-select: none;
}
.wmc-editor .wmc-quote-toggle:hover {
color: rgb(var(--app-fg));
}
.wmc-editor.wmc-quote-collapsed .wmc-quote-toggle + blockquote {
display: none;
}
/* Thin, unobtrusive scrollbars that match the theme. */ /* Thin, unobtrusive scrollbars that match the theme. */
* { * {
scrollbar-width: thin; scrollbar-width: thin;