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",
|
"#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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user