fix: keep the quote when typing in a reply as its inline images load

This commit is contained in:
Linus Rath
2026-11-03 22:08:18 +01:00
parent 7040a6a049
commit 514a9ba1d9
3 changed files with 91 additions and 1 deletions
@@ -0,0 +1,72 @@
import { act, render, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import React from 'react';
import type { Editor } from '@tiptap/core';
import { RichTextEditor } from '../rich-text-editor';
import { serializeEditorContent } from '../quoted-html';
// A reply's quoted inline images load while the caret already waits in the
// body, and swapping them in re-sets the editor's content. That threw the caret
// onto the quote as a node, where the first key typed replaced the whole quote
// and the reply went out without the original or its images, or below the quote.
const PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const IMAGE = 'data:image/png;base64,iVBORw0KGgo=';
const body = (src: string, below: string) => `<p></p><p data-reply-quote-start="true">On Tuesday, C wrote:</p><div data-quoted-html=""><p>Original text</p><img src="${src}" alt="logo" data-cid="logo@x"></div>${below}`;
async function openReply(below: string) {
let editor: Editor | null = null;
const element = (content: string) => (
<RichTextEditor content={content} onChange={() => {}} onEditorReady={(ed) => { editor = ed as unknown as Editor; }} />
);
const view = render(element(body(PLACEHOLDER, below)));
await waitFor(() => expect(editor).not.toBeNull());
const ed = editor as unknown as Editor;
// Where focus('start') puts it; focus itself waits for a frame.
act(() => { ed.commands.setTextSelection(1); });
return { editor: ed, setContent: (content: string) => view.rerender(element(content)) };
}
const type = (editor: Editor, text: string) => act(() => { editor.view.dispatch(editor.state.tr.insertText(text)); });
describe.each([
['with a line below the quote', '<p></p>'],
['with the quote at the very end', ''],
])('typing in a reply once its inline images have loaded, %s', (_name, below) => {
it('goes above the quote and keeps it and its image', async () => {
const { editor, setContent } = await openReply(below);
setContent(body(IMAGE, below));
type(editor, 'My answer');
const sent = serializeEditorContent(editor);
expect(editor.state.doc.child(0).textContent).toBe('My answer');
expect(sent).toContain('Original text');
expect(sent).toContain(IMAGE);
});
it('carries on where the user was already typing', async () => {
const { editor, setContent } = await openReply(below);
type(editor, 'Hel');
setContent(body(IMAGE, `${below}`).replace('<p></p>', '<p>Hel</p>'));
type(editor, 'lo');
expect(editor.state.doc.child(0).textContent).toBe('Hello');
expect(serializeEditorContent(editor)).toContain('Original text');
});
});
describe('a body replaced by a shorter one', () => {
it('leaves the caret inside it', async () => {
const { editor, setContent } = await openReply('<p>A long line below the quote</p>');
act(() => { editor.commands.setTextSelection(editor.state.doc.content.size - 2); });
setContent('<p>Short</p>');
type(editor, '!');
expect(editor.state.doc.textContent).toContain('Short');
expect(editor.state.doc.textContent).toContain('!');
});
});
+16
View File
@@ -23,6 +23,22 @@ const QUOTED_REMOTE_ALLOWED_ATTR = "data-quoted-remote";
// Atom blocks typing must never replace just because they were left selected.
const ATOM_BLOCKS = new Set(["quotedHtml", "signatureBlock"]);
/**
* Replacing the editor's content throws the caret to where the old selection
* maps to: the quote as a node, where the next key typed replaces the whole
* quote, or the line below it. There is no focus event for the guard below to
* catch: a reply's inline images finish loading while the caret already waits
* in the body. It goes back to where it was, or to the start if that is gone.
*/
export function restoreCaretAfterContentSync(editor: Editor, caret: number): void {
const { doc } = editor.state;
const near = Selection.near(doc.resolve(Math.min(caret, doc.content.size)));
const target = near instanceof TextSelection ? near : Selection.atStart(doc);
if (target instanceof TextSelection && !target.eq(editor.state.selection)) {
editor.view.dispatch(editor.state.tr.setSelection(target));
}
}
// Reusable style for the quote bar when quoting email text (like in a reply).
const QUOTE_BAR_STYLE =
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
+3 -1
View File
@@ -24,7 +24,7 @@ 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 { QuotedHtml, restoreCaretAfterContentSync, serializeEditorContent } from "@/components/email/quoted-html";
import { keepLoadedParagraphSpacing, paragraphStyleWithoutSpacing, trailingParagraphBreakRule } from "@/components/email/paragraph-breaks";
import { SignatureBlock } from "@/components/email/signature-block";
import { styledBlockAttributes } from "@/components/email/styled-block-attributes";
@@ -614,7 +614,9 @@ export function RichTextEditor({
if (!editor) return;
const loaded = keepLoadedParagraphSpacing(content);
if (loaded !== serializeEditorContent(editor)) {
const caret = editor.state.selection.from;
editor.commands.setContent(loaded, { emitUpdate: false });
restoreCaretAfterContentSync(editor, caret);
}
}, [content, editor]);