From ccd1f3aca15f4885c1df3761672a7d9f980b44ed Mon Sep 17 00:00:00 2001 From: lleviathe <95321986+lleviathe@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:14:54 +0400 Subject: [PATCH] fix(editor): keep the scroll position when the editor gets focus back on macOS (#2201) On macOS, closing the editor's context menu could scroll the editor by a screenful and put the caret on an unrelated line. A right-click moves focus to the menu during the mouse press, so WebKit keeps the editor's DOM caret; redraws while the menu is open move that caret, and refocusing scrolls to it despite `preventScroll`. Focus tracking now drops the DOM caret left in a CodeMirror editor when focus moves elsewhere in the window. CodeMirror restores its own selection on refocus, and other editable elements are untouched. Refs #1215 Co-authored-by: lleviathe <95321986+lleviathe@users.noreply.github.com> --- src/workbench/focus.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/workbench/focus.ts | 18 ++++++++++++++++++ 2 files changed, 54 insertions(+) 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(() => {