mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
Merge pull request #1421 from pcatapan/codex/composer-quote-collapse
This commit is contained in:
@@ -780,6 +780,11 @@ optgroup {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* View-only quote decorations must survive a node view refreshing its inline styles. */
|
||||
.tiptap [data-reply-quote-collapsed] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tiptap p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
@@ -316,3 +316,37 @@ describe('reply signature above the quote (#842)', () => {
|
||||
expect(sent.body).toMatch(/Original text[\s\S]*Sales team$/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('reply quote attribution markers', () => {
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState({ plainTextMode: false, signaturePosition: 'below_quote' });
|
||||
});
|
||||
|
||||
it.each(['reply', 'replyAll'] as const)('marks the HTML original in %s mode', (mode) => {
|
||||
render(<EmailComposer mode={mode} replyTo={TO_ME} />);
|
||||
const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
|
||||
expect(html).toContain('data-reply-quote-start="true"');
|
||||
expect(html).toContain('Original text');
|
||||
});
|
||||
|
||||
it('marks the attribution of a plain-text original composed in rich-text mode', () => {
|
||||
render(<EmailComposer mode="reply" replyTo={{ ...TO_ME, htmlBody: undefined }} />);
|
||||
const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
|
||||
expect(html).toContain('data-reply-quote-start="true"');
|
||||
expect(html).toContain('<blockquote');
|
||||
});
|
||||
|
||||
it('leaves forwarding attribution unmarked', () => {
|
||||
render(<EmailComposer mode="forward" replyTo={TO_ME} />);
|
||||
const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
|
||||
expect(html).not.toContain('data-reply-quote-start');
|
||||
expect(html).toContain('Original text');
|
||||
});
|
||||
|
||||
it('preserves a simple plugin attribution', () => {
|
||||
render(<EmailComposer mode="reply" replyTo={{ ...TO_ME, quoteHeaderHtml: '<p><b>From:</b> Bob</p>', quoteWrapInBlockquote: false }} />);
|
||||
const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
|
||||
expect(html).toContain('<p data-reply-quote-start="true"><b>From:</b> Bob</p>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import { trailingParagraphBreakRule } from '../paragraph-breaks';
|
||||
import { QuotedHtml, buildQuotedHtmlBlock, serializeEditorContent } from '../quoted-html';
|
||||
import { SignatureBlock, buildSignatureBlock } from '../signature-block';
|
||||
import { ReplyQuoteCollapse, markReplyQuoteHeader } from '../reply-quote-collapse';
|
||||
|
||||
const HEADER = markReplyQuoteHeader('<div>On Monday, Bob wrote:<br></div>');
|
||||
const ORIGINAL = '<p>Original message</p><img src="cid:logo"><blockquote>Earlier reply</blockquote>';
|
||||
const QUOTE = buildQuotedHtmlBlock(ORIGINAL);
|
||||
const ReplyParagraph = Paragraph.extend({
|
||||
parseHTML() {
|
||||
return [...(this.parent?.() ?? []), trailingParagraphBreakRule];
|
||||
},
|
||||
});
|
||||
let editor: Editor | undefined;
|
||||
|
||||
function mount(html = `<p>My response</p>${HEADER}${QUOTE}`) {
|
||||
editor = new Editor({
|
||||
extensions: [StarterKit.configure({ paragraph: false }), ReplyParagraph, QuotedHtml, SignatureBlock, ReplyQuoteCollapse],
|
||||
content: html,
|
||||
});
|
||||
// Settle StarterKit's trailing paragraph before comparing message data.
|
||||
editor.view.dispatch(editor.state.tr);
|
||||
document.body.appendChild(editor.view.dom);
|
||||
return editor;
|
||||
}
|
||||
function toggle() {
|
||||
return editor!.view.dom.querySelector<HTMLButtonElement>('[data-reply-quote-toggle]')!;
|
||||
}
|
||||
function quote() {
|
||||
return editor!.view.dom.querySelector<HTMLElement>('.quoted-html-island, blockquote')!;
|
||||
}
|
||||
afterEach(() => {
|
||||
editor?.destroy();
|
||||
editor = undefined;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('reply quote visibility', () => {
|
||||
it('starts collapsed, hides attribution too, and toggles without changing serialized content', () => {
|
||||
const e = mount();
|
||||
const saved = serializeEditorContent(e);
|
||||
expect(toggle()).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(toggle()).toHaveAccessibleName('Show quoted text');
|
||||
expect(quote()).toHaveAttribute('data-reply-quote-collapsed', 'true');
|
||||
expect(e.view.dom.querySelector('[data-reply-quote-start]')).toHaveAttribute('data-reply-quote-collapsed', 'true');
|
||||
expect(saved).toContain('On Monday, Bob wrote:');
|
||||
expect(saved).toContain(ORIGINAL);
|
||||
expect(saved).not.toContain('data-reply-quote-toggle');
|
||||
expect(saved).not.toContain('display: none');
|
||||
expect(saved).not.toContain('data-reply-quote-collapsed');
|
||||
toggle().click();
|
||||
expect(toggle()).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(quote()).not.toHaveAttribute('data-reply-quote-collapsed');
|
||||
expect(serializeEditorContent(e)).toBe(saved);
|
||||
toggle().click();
|
||||
expect(toggle()).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(serializeEditorContent(e)).toBe(saved);
|
||||
});
|
||||
|
||||
it('round-trips a collapsed quote through a native draft without dropping inline images', () => {
|
||||
const e = mount();
|
||||
const saved = serializeEditorContent(e);
|
||||
e.commands.setContent(saved);
|
||||
expect(toggle()).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(serializeEditorContent(e)).toBe(saved);
|
||||
expect(saved).toContain('src="cid:logo"');
|
||||
});
|
||||
|
||||
it('collapses a plain-text original in rich-text mode and leaves an authored blockquote visible', () => {
|
||||
mount(`<p>Response</p><blockquote>My own quotation</blockquote>${HEADER}<blockquote>Original plain-text message</blockquote>`);
|
||||
const quotes = editor!.view.dom.querySelectorAll<HTMLElement>('blockquote');
|
||||
expect(quotes[0]).not.toHaveAttribute('data-reply-quote-collapsed');
|
||||
expect(quotes[1]).toHaveAttribute('data-reply-quote-collapsed', 'true');
|
||||
});
|
||||
|
||||
it.each(['above', 'below'])('keeps the signature visible %s the quoted original', (position) => {
|
||||
const signature = buildSignatureBlock('<b>My signature</b>');
|
||||
const e = mount(`<p>Response</p>${position === 'above' ? signature : ''}${HEADER}${QUOTE}${position === 'below' ? signature : ''}`);
|
||||
const signatureNode = e.view.dom.querySelector<HTMLElement>('[data-signature-html]');
|
||||
expect(serializeEditorContent(e)).toContain('My signature');
|
||||
// Every hidden top-level block belongs to the attribution/original pair.
|
||||
const hidden = e.view.dom.querySelectorAll('[data-reply-quote-collapsed]');
|
||||
expect(hidden).toHaveLength(2);
|
||||
expect(signatureNode?.style.display ?? '').toBe('');
|
||||
});
|
||||
|
||||
it('keeps edited quote content when collapsed and serialized', async () => {
|
||||
const e = mount();
|
||||
toggle().click();
|
||||
const inner = quote().shadowRoot!.firstElementChild as HTMLElement;
|
||||
inner.querySelector('p')!.textContent = 'Edited original';
|
||||
inner.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
toggle().click();
|
||||
expect(serializeEditorContent(e)).toContain('Edited original');
|
||||
expect(serializeEditorContent(e)).toContain('src="cid:logo"');
|
||||
});
|
||||
|
||||
it('keeps the quote hidden when its node view updates while collapsed', () => {
|
||||
const e = mount();
|
||||
let quotePos = 0;
|
||||
e.state.doc.forEach((node, pos) => { if (node.type.name === 'quotedHtml') quotePos = pos; });
|
||||
const node = e.state.doc.nodeAt(quotePos)!;
|
||||
e.view.dispatch(e.state.tr.setNodeMarkup(quotePos, undefined, { ...node.attrs, html: '<p>Updated original</p>' }));
|
||||
expect(toggle()).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(quote()).toHaveAttribute('data-reply-quote-collapsed', 'true');
|
||||
expect(serializeEditorContent(e)).toContain('Updated original');
|
||||
});
|
||||
|
||||
it('does not add visibility toggles to Undo history', () => {
|
||||
const e = mount();
|
||||
e.commands.insertContentAt(1, 'Added ');
|
||||
toggle().click();
|
||||
toggle().click();
|
||||
e.commands.undo();
|
||||
expect(serializeEditorContent(e)).not.toContain('Added ');
|
||||
expect(serializeEditorContent(e)).toContain(ORIGINAL);
|
||||
e.commands.redo();
|
||||
expect(serializeEditorContent(e)).toContain('Added ');
|
||||
});
|
||||
|
||||
it('reveals a quote reached with a text selection and moves the caret out when collapsed', () => {
|
||||
const e = mount();
|
||||
const start = Number(toggle().dataset.replyQuoteToggle);
|
||||
e.commands.setTextSelection(start + 1);
|
||||
expect(toggle()).toHaveAttribute('aria-expanded', 'true');
|
||||
toggle().click();
|
||||
expect(toggle()).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(e.state.selection.to).toBeLessThanOrEqual(start);
|
||||
});
|
||||
|
||||
it('retains keyboard focus on the toggle when expanding or collapsing', () => {
|
||||
mount();
|
||||
toggle().focus();
|
||||
toggle().click();
|
||||
expect(toggle()).toHaveFocus();
|
||||
toggle().click();
|
||||
expect(toggle()).toHaveFocus();
|
||||
});
|
||||
|
||||
it('preserves expansion when the authored response changes before the quote', () => {
|
||||
const e = mount();
|
||||
toggle().click();
|
||||
e.commands.insertContentAt(1, 'More text. ');
|
||||
expect(toggle()).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(quote()).not.toHaveAttribute('data-reply-quote-collapsed');
|
||||
});
|
||||
|
||||
it.each([
|
||||
`<p>Forwarded message</p>${QUOTE}`,
|
||||
'<p>Old draft</p><blockquote>Unknown attribution</blockquote>',
|
||||
`${HEADER}<p>New authored paragraph</p><blockquote>Authored quote</blockquote>`,
|
||||
`${HEADER}<blockquote></blockquote>`,
|
||||
])('leaves unmarked, ambiguous or empty quote content visible', (html) => {
|
||||
mount(html);
|
||||
expect(toggle()).toBeNull();
|
||||
expect(editor!.view.dom.querySelector('[data-reply-quote-collapsed]')).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves an unrecognized plugin header and recognizes a simple plugin paragraph', () => {
|
||||
const unknown = '<section><div><p>Custom header</p></div></section>';
|
||||
expect(markReplyQuoteHeader(unknown)).toBe(unknown);
|
||||
mount(`<p>Response</p>${markReplyQuoteHeader('<p><b>From:</b> Bob</p>')}${QUOTE}`);
|
||||
expect(toggle()).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHt
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import { markReplyQuoteHeader } from "@/components/email/reply-quote-collapse";
|
||||
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { containsEmbeddedSignature } from "@/components/email/signature-block";
|
||||
import {
|
||||
@@ -729,6 +730,10 @@ export function EmailComposer({
|
||||
const { externalContentPolicy, isSenderTrusted } = useSettingsStore.getState();
|
||||
const quoteRemoteAllowed = externalContentPolicy === 'allow' || (!!from?.email && isSenderTrusted(from.email));
|
||||
|
||||
const replyHeader = (html: string) => mode === 'reply' || mode === 'replyAll'
|
||||
? markReplyQuoteHeader(html)
|
||||
: html;
|
||||
|
||||
// Plugin override (resolved at composer open via onBuildQuoteHeader).
|
||||
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
if (replyTo.htmlBody) {
|
||||
@@ -739,7 +744,7 @@ export function EmailComposer({
|
||||
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)),
|
||||
{ remoteAllowed: quoteRemoteAllowed, showQuoteBorder: replyTo.quoteWrapInBlockquote !== false },
|
||||
);
|
||||
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${island}`;
|
||||
return `${prefix}${signatureBlock}<br>${replyHeader(replyTo.quoteHeaderHtml)}${island}`;
|
||||
}
|
||||
const escaped = replyTo.body
|
||||
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
||||
@@ -748,7 +753,7 @@ export function EmailComposer({
|
||||
const bodyHtml = wrap
|
||||
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escaped}</blockquote>`
|
||||
: escaped;
|
||||
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`;
|
||||
return `${prefix}${signatureBlock}<br>${replyHeader(replyTo.quoteHeaderHtml)}${bodyHtml}`;
|
||||
}
|
||||
|
||||
// Build quoted content as HTML
|
||||
@@ -766,7 +771,7 @@ export function EmailComposer({
|
||||
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)),
|
||||
{ remoteAllowed: quoteRemoteAllowed },
|
||||
);
|
||||
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div>${island}`;
|
||||
return `${prefix}${signatureBlock}<br>${replyHeader(`<div>${quoteHeader}</div>`)}${island}`;
|
||||
}
|
||||
|
||||
if (replyTo.body) {
|
||||
@@ -774,7 +779,7 @@ export function EmailComposer({
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>${escapedOriginal}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
return `${prefix}${signatureBlock}<br><br>${replyHeader(`<p>${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br></p>`)}<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { Extension } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { Plugin, PluginKey, Selection } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
|
||||
const START_ATTRIBUTE = 'data-reply-quote-start';
|
||||
const HEADER_BLOCKS = 'p, h1, h2, h3, h4, h5, h6, table, ul, ol, pre, blockquote';
|
||||
|
||||
/** Mark only attribution generated for a reply. Unknown plugin markup stays visible. */
|
||||
export function markReplyQuoteHeader(html: string): string {
|
||||
if (typeof document === 'undefined') return html;
|
||||
// A template is inert: attribution containing an image must not load it here.
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = html;
|
||||
const first = template.content.firstElementChild;
|
||||
if (!first) return `<p ${START_ATTRIBUTE}="true">${html}</p>`;
|
||||
// The default attribution is a div of inline content, parsed as a paragraph
|
||||
// by the editor. Make that paragraph explicit so its marker round-trips.
|
||||
if (first.tagName === 'DIV' && !first.querySelector(HEADER_BLOCKS)) {
|
||||
const paragraph = document.createElement('p');
|
||||
for (const attribute of first.attributes) paragraph.setAttribute(attribute.name, attribute.value);
|
||||
paragraph.innerHTML = first.innerHTML;
|
||||
first.replaceWith(paragraph);
|
||||
paragraph.setAttribute(START_ATTRIBUTE, 'true');
|
||||
} else if (first.matches(HEADER_BLOCKS)) {
|
||||
first.setAttribute(START_ATTRIBUTE, 'true');
|
||||
} else {
|
||||
return html;
|
||||
}
|
||||
// A leading text node would otherwise remain visible outside the group.
|
||||
if (template.content.firstChild?.nodeType === Node.TEXT_NODE
|
||||
&& template.content.firstChild.textContent?.trim()) return html;
|
||||
return template.innerHTML;
|
||||
}
|
||||
|
||||
interface QuoteRange { from: number; to: number }
|
||||
interface QuoteState { expanded: Set<number> }
|
||||
const quoteKey = new PluginKey<QuoteState>('replyQuoteCollapse');
|
||||
|
||||
function quoteRanges(doc: PMNode): QuoteRange[] {
|
||||
const ranges: QuoteRange[] = [];
|
||||
let start: number | null = null;
|
||||
doc.forEach((node, pos) => {
|
||||
if (node.type.name === 'signatureBlock' || node.attrs['data-signature-block']) start = null;
|
||||
if (node.attrs.replyQuoteStart) start = pos;
|
||||
else if (start !== null && (node.type.name === 'quotedHtml' || node.type.name === 'blockquote')) {
|
||||
if (node.textContent.trim() || String(node.attrs.html ?? '').trim()) {
|
||||
ranges.push({ from: start, to: pos + node.nodeSize });
|
||||
}
|
||||
start = null;
|
||||
} else if (start !== null) {
|
||||
// Do not hide unrelated authored blocks while searching for a quote.
|
||||
start = null;
|
||||
}
|
||||
});
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/** Visibility is a view decoration, never a document edit or an undo step. */
|
||||
export const ReplyQuoteCollapse = Extension.create<{ show: string; hide: string }>({
|
||||
name: 'replyQuoteCollapse',
|
||||
|
||||
addOptions() {
|
||||
return { show: 'Show quoted text', hide: 'Hide quoted text' };
|
||||
},
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [{
|
||||
types: ['paragraph', 'heading', 'table', 'bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
||||
attributes: {
|
||||
replyQuoteStart: {
|
||||
default: false,
|
||||
parseHTML: (el) => el.getAttribute(START_ATTRIBUTE) === 'true',
|
||||
renderHTML: (attrs) => attrs.replyQuoteStart ? { [START_ATTRIBUTE]: 'true' } : {},
|
||||
},
|
||||
},
|
||||
}];
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const labels = this.options;
|
||||
return [new Plugin<QuoteState>({
|
||||
key: quoteKey,
|
||||
state: {
|
||||
init: () => ({ expanded: new Set<number>() }),
|
||||
apply: (tr, previous) => {
|
||||
const expanded = new Set<number>();
|
||||
for (const pos of previous.expanded) {
|
||||
const mapped = tr.mapping.mapResult(pos);
|
||||
if (!mapped.deleted) expanded.add(mapped.pos);
|
||||
}
|
||||
const toggle = tr.getMeta(quoteKey) as { from: number; expanded: boolean } | undefined;
|
||||
if (toggle) {
|
||||
if (toggle.expanded) expanded.add(toggle.from);
|
||||
else expanded.delete(toggle.from);
|
||||
} else if (tr.selectionSet) {
|
||||
// Arrow keys, Find and editor commands must not leave the caret
|
||||
// inside invisible text. Revealing it changes no message content.
|
||||
for (const range of quoteRanges(tr.doc)) {
|
||||
if (tr.selection.from < range.to && tr.selection.to > range.from) expanded.add(range.from);
|
||||
}
|
||||
}
|
||||
return { expanded };
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
const expanded = quoteKey.getState(state)?.expanded ?? new Set<number>();
|
||||
const decorations: Decoration[] = [];
|
||||
for (const range of quoteRanges(state.doc)) {
|
||||
const open = expanded.has(range.from);
|
||||
decorations.push(Decoration.widget(range.from, (view) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = '•••';
|
||||
button.className = 'my-1 inline-block cursor-pointer rounded-full bg-foreground/10 px-3 py-[3px] text-[11px] leading-none tracking-[2px] text-muted-foreground hover:bg-foreground/20 hover:text-foreground';
|
||||
button.title = open ? labels.hide : labels.show;
|
||||
button.setAttribute('aria-label', button.title);
|
||||
button.setAttribute('aria-expanded', String(open));
|
||||
button.addEventListener('click', () => {
|
||||
const tr = view.state.tr.setMeta(quoteKey, { from: range.from, expanded: !open });
|
||||
if (open) tr.setSelection(Selection.near(tr.doc.resolve(range.from), -1));
|
||||
const restoreFocus = document.activeElement === button;
|
||||
view.dispatch(tr);
|
||||
if (restoreFocus) {
|
||||
const toggles = view.dom.querySelectorAll<HTMLButtonElement>('[data-reply-quote-toggle]');
|
||||
toggles.forEach((toggle) => {
|
||||
if (Number(toggle.dataset.replyQuoteToggle) === range.from) toggle.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
button.dataset.replyQuoteToggle = String(range.from);
|
||||
return button;
|
||||
}, { key: `reply-quote-${range.from}-${open}`, side: -1, stopEvent: () => true }));
|
||||
if (!open) {
|
||||
state.doc.nodesBetween(range.from, range.to, (node, pos, parent) => {
|
||||
if (parent === state.doc) {
|
||||
decorations.push(Decoration.node(pos, pos + node.nodeSize, { 'data-reply-quote-collapsed': 'true' }));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
return DecorationSet.create(state.doc, decorations);
|
||||
},
|
||||
},
|
||||
})];
|
||||
},
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { Table } from "@tiptap/extension-table";
|
||||
import { TableRow } from "@tiptap/extension-table-row";
|
||||
import { TableHeader } from "@tiptap/extension-table-header";
|
||||
import { TableCell } from "@tiptap/extension-table-cell";
|
||||
import { ReplyQuoteCollapse } from "@/components/email/reply-quote-collapse";
|
||||
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
import { keepLoadedParagraphSpacing, paragraphStyleWithoutSpacing, trailingParagraphBreakRule } from "@/components/email/paragraph-breaks";
|
||||
import { SignatureBlock } from "@/components/email/signature-block";
|
||||
@@ -225,6 +226,7 @@ export function RichTextEditor({
|
||||
}: RichTextEditorProps) {
|
||||
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
|
||||
const tComposer = useTranslations("email_composer");
|
||||
const tQuote = useTranslations("email_viewer");
|
||||
const onImageUploadRef = React.useRef(onImageUpload);
|
||||
onImageUploadRef.current = onImageUpload;
|
||||
const onEditorReadyRef = React.useRef(onEditorReady);
|
||||
@@ -287,6 +289,7 @@ export function RichTextEditor({
|
||||
// Quoted/forwarded original email body - held verbatim as an atomic
|
||||
// node so layout-heavy HTML survives 1:1 (see quoted-html.ts).
|
||||
QuotedHtml,
|
||||
ReplyQuoteCollapse.configure({ show: tQuote("show_quoted_text"), hide: tQuote("hide_quoted_text") }),
|
||||
// Identity signature - held verbatim as a non-editable atomic node so
|
||||
// rich/branded signatures keep their inline styling in the editor and
|
||||
// in the sent mail (see signature-block.ts). Double-click unlocks it
|
||||
|
||||
Reference in New Issue
Block a user