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:
@@ -53,6 +53,33 @@ const PALETTE: string[] = [
|
||||
"#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 }) {
|
||||
return (
|
||||
<svg
|
||||
@@ -159,11 +186,15 @@ export const RichTextEditor = forwardRef<
|
||||
const [selImg, setSelImg] = useState<HTMLImageElement | 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.
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.innerHTML = initialHtml;
|
||||
addQuoteToggle(el, t("compose.showQuoted"));
|
||||
onReady?.(el);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -178,7 +209,7 @@ export const RichTextEditor = forwardRef<
|
||||
} else if (sig) {
|
||||
sig.remove();
|
||||
}
|
||||
onInput(el.innerHTML);
|
||||
onInput(contentHtml(el));
|
||||
},
|
||||
insertSignature: (html: string) => {
|
||||
const el = ref.current;
|
||||
@@ -197,7 +228,7 @@ export const RichTextEditor = forwardRef<
|
||||
el.insertBefore(spacerBefore, anchor);
|
||||
el.insertBefore(block, anchor);
|
||||
el.insertBefore(spacerAfter, anchor);
|
||||
onInput(el.innerHTML);
|
||||
onInput(contentHtml(el));
|
||||
},
|
||||
insertHtml: (html: string) => {
|
||||
const el = ref.current;
|
||||
@@ -210,7 +241,7 @@ export const RichTextEditor = forwardRef<
|
||||
block.innerHTML = html;
|
||||
el.insertBefore(block, el.querySelector(".wmc-signature, .wmc-quoted"));
|
||||
}
|
||||
onInput(el.innerHTML);
|
||||
onInput(contentHtml(el));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -269,7 +300,7 @@ export const RichTextEditor = forwardRef<
|
||||
}, [colorMenu]);
|
||||
|
||||
const emit = () => {
|
||||
if (ref.current) onInput(ref.current.innerHTML);
|
||||
if (ref.current) onInput(contentHtml(ref.current));
|
||||
};
|
||||
|
||||
const exec = (command: string, value?: string) => {
|
||||
@@ -369,6 +400,11 @@ export const RichTextEditor = forwardRef<
|
||||
|
||||
const onEditorClick = (e: React.MouseEvent) => {
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -547,13 +583,13 @@ export const RichTextEditor = forwardRef<
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-testid="rich-editor"
|
||||
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
|
||||
onInput={(e) => onInput(contentHtml(e.currentTarget))}
|
||||
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`}
|
||||
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 && (
|
||||
<>
|
||||
|
||||
@@ -183,6 +183,7 @@ export const en = {
|
||||
"mail.download": "Download",
|
||||
|
||||
"compose.newMessage": "New message",
|
||||
"compose.showQuoted": "Show or hide the quoted message",
|
||||
"compose.to": "To",
|
||||
"compose.cc": "Cc",
|
||||
"compose.bcc": "Bcc",
|
||||
|
||||
@@ -161,6 +161,30 @@ body {
|
||||
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. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
|
||||
Reference in New Issue
Block a user