diff --git a/components/email/__tests__/quoted-html-focus.test.ts b/components/email/__tests__/quoted-html-focus.test.ts index ec0987658..8d155eb6c 100644 --- a/components/email/__tests__/quoted-html-focus.test.ts +++ b/components/email/__tests__/quoted-html-focus.test.ts @@ -66,3 +66,54 @@ describe('QuotedHtml focus', () => { expect(editor.state.selection.eq(before)).toBe(true); }); }); + +describe('editor focus from the keyboard', () => { + // A new message is built with the signature in it, which leaves the + // selection after the signature. Tabbing in from Subject typed there. + function mountCompose(): Editor { + const e = new Editor({ + extensions: [Document, Paragraph, Text, QuotedHtml], + content: '

--

User A

', + }); + e.view.dispatch(e.state.tr.setSelection(TextSelection.atEnd(e.state.doc))); + return e; + } + + it('puts the caret at the start on the first focus', () => { + editor = mountCompose(); + + editor.view.dom.dispatchEvent(new FocusEvent('focus')); + + expect(editor.state.selection.from).toBe(1); + }); + + it('keeps the caret where a click put it', () => { + editor = mountCompose(); + const end = editor.state.selection.from; + + // ProseMirror's own click handling asks for the element under the + // pointer, which jsdom does not implement. + const doc = document as Document & { elementFromPoint: Document['elementFromPoint'] }; + const original = doc.elementFromPoint; + doc.elementFromPoint = () => null; + try { + editor.view.dom.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + editor.view.dom.dispatchEvent(new FocusEvent('focus')); + } finally { + doc.elementFromPoint = original; + } + + expect(editor.state.selection.from).toBe(end); + }); + + it('keeps the position when focus comes back later', () => { + editor = mountCompose(); + editor.view.dom.dispatchEvent(new FocusEvent('focus')); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + const end = editor.state.selection.from; + + editor.view.dom.dispatchEvent(new FocusEvent('focus')); + + expect(editor.state.selection.from).toBe(end); + }); +}); diff --git a/components/email/quoted-html.ts b/components/email/quoted-html.ts index 025711f7d..40d708bbc 100644 --- a/components/email/quoted-html.ts +++ b/components/email/quoted-html.ts @@ -76,27 +76,38 @@ export const QuotedHtml = TiptapNode.create({ }, addProseMirrorPlugins() { + // Set once the user put the caret somewhere (click, tap) or the editor + // already had focus, so later focus keeps that position. + let placedByUser = false; + const markPlaced = () => { + placedByUser = true; + return false; + }; return [ new Plugin({ key: new PluginKey("quotedHtmlFocus"), props: { handleDOMEvents: { - // A reply can open with the quote (or the signature) selected as - // a node. Tabbing into the body kept that selection, and the first - // key typed replaced the whole block. Focus puts the caret at the - // start instead; a click still lands where it was aimed, because + mousedown: markPlaced, + touchstart: markPlaced, + // Where the editor's selection rests before the user touched it + // is an accident of how the body was built: on the quote (or + // signature) as a node in a reply, where the first key typed + // replaced the whole block, or after the signature in a new + // message. Focus from the keyboard puts the caret at the start + // instead; a click still lands where it was aimed, because // ProseMirror handles the click after the focus event. focus: (view) => { const { selection, doc } = view.state; - if ( - selection instanceof NodeSelection && - ATOM_BLOCKS.has(selection.node.type.name) - ) { + const onAtom = selection instanceof NodeSelection + && ATOM_BLOCKS.has(selection.node.type.name); + if (onAtom || !placedByUser) { const start = Selection.atStart(doc); - if (start instanceof TextSelection) { + if (start instanceof TextSelection && !start.eq(selection)) { view.dispatch(view.state.tr.setSelection(start)); } } + placedByUser = true; return false; }, },