diff --git a/components/email/__tests__/rich-text-editor-quote-caret.test.tsx b/components/email/__tests__/rich-text-editor-quote-caret.test.tsx new file mode 100644 index 000000000..37de365d6 --- /dev/null +++ b/components/email/__tests__/rich-text-editor-quote-caret.test.tsx @@ -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) => `
On Tuesday, C wrote:
Original text
Hel
')); + 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('A long line below the quote
'); + act(() => { editor.commands.setTextSelection(editor.state.doc.content.size - 2); }); + + setContent('Short
'); + type(editor, '!'); + + expect(editor.state.doc.textContent).toContain('Short'); + expect(editor.state.doc.textContent).toContain('!'); + }); +}); diff --git a/components/email/quoted-html.ts b/components/email/quoted-html.ts index d1aa476df..1cef8c354 100644 --- a/components/email/quoted-html.ts +++ b/components/email/quoted-html.ts @@ -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;"; diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 1eb7decca..3448897cd 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -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]);