diff --git a/src/workbench/focus.test.ts b/src/workbench/focus.test.ts
index af1ee43de..965176321 100644
--- a/src/workbench/focus.test.ts
+++ b/src/workbench/focus.test.ts
@@ -38,6 +38,42 @@ describe("focus after popups", () => {
dispose();
});
+ it("drops the editor's caret when focus moves to a menu, so focusing the editor again cannot scroll to it", () => {
+ document.body.innerHTML = `
first line
`;
+ const editor = document.querySelector(".cm-content")!;
+ const dispose = installFocusTracking(() => {});
+ editor.focus();
+ document.getSelection()!.collapse(editor.firstChild, 3);
+ // WebKit keeps the caret during a mouse press; jsdom moves the selection on focus, so check
+ // what is left once the editor has lost focus.
+ let caretLeft: Node | null = null;
+ const probe = () => (caretLeft = document.getSelection()!.anchorNode);
+ document.addEventListener("focusout", probe);
+ document.getElementById("other")!.focus();
+ document.removeEventListener("focusout", probe);
+ expect(caretLeft).toBeNull();
+ dispose();
+ });
+
+ it.each([
+ '
draft message
',
+ '
preview text
',
+ ])("keeps the selection in an unrelated editable or read-only preview: %s", (markup) => {
+ document.body.innerHTML = `${markup}`;
+ const content = document.body.firstElementChild as HTMLElement;
+ const dispose = installFocusTracking(() => {});
+ content.focus();
+ const selection = document.getSelection()!;
+ selection.setBaseAndExtent(content.firstChild!, 1, content.firstChild!, 5);
+ let selectedOnBlur = "";
+ const probe = () => (selectedOnBlur = selection.toString());
+ document.addEventListener("focusout", probe);
+ document.getElementById("other")!.focus();
+ document.removeEventListener("focusout", probe);
+ dispose();
+ expect(selectedOnBlur).toBe(content.textContent!.slice(1, 5));
+ });
+
it("leaves focus alone when the popup handed it to another component", () => {
vi.useFakeTimers();
setup();
diff --git a/src/workbench/focus.ts b/src/workbench/focus.ts
index fb824bb66..03b2426a7 100644
--- a/src/workbench/focus.ts
+++ b/src/workbench/focus.ts
@@ -26,6 +26,23 @@ function focusEditorNow() {
target?.focus({ preventScroll: true });
}
+/**
+ * WebKit keeps the DOM caret of an editor that loses focus during a mouse press, as when a
+ * right-click opens the context menu. The editor redraws its lines while unfocused, which moves
+ * that caret to an unrelated line, and focusing the editor again scrolls to it even with
+ * `preventScroll`. Without it, focusing puts the caret at the start of the content without
+ * scrolling and CodeMirror restores its own selection.
+ */
+function dropEditorCaret(e: FocusEvent) {
+ const editor = e.target;
+ if (!(editor instanceof HTMLElement) || !editor.matches('.cm-content[contenteditable="true"]')) return;
+ // The window lost focus and keeps the editor focused for its return.
+ if (document.activeElement === editor) return;
+ if (e.relatedTarget instanceof Node && editor.contains(e.relatedTarget)) return;
+ const selection = document.getSelection();
+ if (selection?.anchorNode && editor.contains(selection.anchorNode)) selection.removeAllRanges();
+}
+
/**
* Tracks which workbench part holds focus and publishes `toolWindowFocus`,
* `activeToolWindow` and `editorAreaFocus`. Returns an uninstaller.
@@ -38,6 +55,7 @@ export function installFocusTracking(onToolWindowFocus: (id: string | null) => v
setContext("editorAreaFocus", !!el?.closest?.(EDITOR_AREA_SELECTOR));
};
const onFocusOut = (e: FocusEvent) => {
+ dropEditorCaret(e);
if (e.relatedTarget) return;
// Focus left to nowhere (window blur keeps document.activeElement).
queueMicrotask(() => {