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 <[email protected]>
Цей коміт міститься в:
lleviathe authored and GitHub committed 2026-11-09 06:14:54 +08:00
1 джерело 1dbebefa94
коміт ccd1f3aca1
2 files changed
+54

No files matched your search

+36
Переглянути файл
@@ -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 = `<div class="cm-content" contenteditable="true" tabindex="0">first line</div><button id="other">x</button>`;
const editor = document.querySelector<HTMLElement>(".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([
'<div contenteditable="true" tabindex="0">draft message</div>',
'<div class="cm-content" contenteditable="false" tabindex="0">preview text</div>',
])("keeps the selection in an unrelated editable or read-only preview: %s", (markup) => {
document.body.innerHTML = `${markup}<button id="other">x</button>`;
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();
+18
Переглянути файл
@@ -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(() => {