fix: start at the top of the body when tabbing into a new message

This commit is contained in:
Linus Rath
2026-10-11 20:26:43 +02:00
parent e3f0f6cb38
commit 46a1adbb2d
2 changed files with 71 additions and 9 deletions
@@ -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: '<p></p><p>-- </p><p>User A</p>',
});
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);
});
});
+20 -9
View File
@@ -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;
},
},