diff --git a/frontend/src/components/compose/RichTextEditor.tsx b/frontend/src/components/compose/RichTextEditor.tsx index b198eec..e5bccca 100644 --- a/frontend/src/components/compose/RichTextEditor.tsx +++ b/frontend/src/components/compose/RichTextEditor.tsx @@ -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 ( (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 && ( <> diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 9bacd91..4a241fe 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -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", diff --git a/frontend/src/index.css b/frontend/src/index.css index 6cdd8e2..c6bdfc8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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;