mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-02 15:09:26 +00:00
fix(editor): suppress global hotkeys while the code editor is focused (#1413)
Typing a single-key shortcut (a/h/u/p/b) in the compose editor opened that shortcut's action instead of inserting the character, but only in Chrome. The shared isInputFocused() guard recognized inputs, textareas, and contentEditable elements; in Chromium the Monaco editor uses the EditContext API whose focused surface is a plain focusable div, so the guard missed it and the hotkey handlers fired. Safari falls back to a hidden textarea, which the guard already caught. Match any focus inside the .monaco-editor container so the guard covers the EditContext surface, bringing Chrome to parity with Safari for both global hotkey systems. Add a unit test for the guard. Closes #1410
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Unit tests for isInputFocused, the shared guard both global hotkey systems
|
||||
* use to bail when the user is typing in a field.
|
||||
*
|
||||
* Regression guard for #1410: Monaco's Chromium EditContext input surface is a
|
||||
* plain focusable <div class="native-edit-context"> inside .monaco-editor, not a
|
||||
* <textarea> or contentEditable element, so the tag/contentEditable checks miss
|
||||
* it and single-key hotkeys (a/h/u/p/b) fired while editing a compose file in
|
||||
* Chrome (Safari falls back to a hidden <textarea> and was unaffected).
|
||||
*
|
||||
* activeElement is stubbed with real DOM nodes so tagName, isContentEditable, and
|
||||
* closest() all run their real logic, while staying free of jsdom's unreliable
|
||||
* .focus() / isContentEditable handling on detached elements.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { isInputFocused } from '../keyboard-guards';
|
||||
|
||||
function stubActiveElement(el: Element | null): void {
|
||||
Object.defineProperty(document, 'activeElement', { configurable: true, get: () => el });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// Drop the own-property shadow so jsdom's native activeElement getter returns.
|
||||
Reflect.deleteProperty(document, 'activeElement');
|
||||
});
|
||||
|
||||
describe('isInputFocused', () => {
|
||||
it('returns false when nothing is focused', () => {
|
||||
stubActiveElement(null);
|
||||
expect(isInputFocused()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a focused <input>', () => {
|
||||
stubActiveElement(document.createElement('input'));
|
||||
expect(isInputFocused()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a focused <textarea>', () => {
|
||||
stubActiveElement(document.createElement('textarea'));
|
||||
expect(isInputFocused()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a contentEditable element', () => {
|
||||
const div = document.createElement('div');
|
||||
Object.defineProperty(div, 'isContentEditable', { value: true });
|
||||
stubActiveElement(div);
|
||||
expect(isInputFocused()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a focused non-editable element', () => {
|
||||
stubActiveElement(document.createElement('button'));
|
||||
expect(isInputFocused()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for focus inside the Monaco editor container (#1410)', () => {
|
||||
const editor = document.createElement('div');
|
||||
editor.className = 'monaco-editor';
|
||||
const editContext = document.createElement('div');
|
||||
editContext.className = 'native-edit-context';
|
||||
editor.appendChild(editContext);
|
||||
document.body.appendChild(editor);
|
||||
stubActiveElement(editContext);
|
||||
try {
|
||||
expect(isInputFocused()).toBe(true);
|
||||
} finally {
|
||||
document.body.removeChild(editor);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns true for any descendant of the Monaco editor, not just the edit-context node', () => {
|
||||
// Pins the deliberate whole-container semantics: focus anywhere inside
|
||||
// .monaco-editor counts, so narrowing the guard to a specific inner class
|
||||
// would regress.
|
||||
const editor = document.createElement('div');
|
||||
editor.className = 'monaco-editor';
|
||||
const viewLine = document.createElement('div');
|
||||
viewLine.className = 'view-line';
|
||||
editor.appendChild(viewLine);
|
||||
document.body.appendChild(editor);
|
||||
stubActiveElement(viewLine);
|
||||
try {
|
||||
expect(isInputFocused()).toBe(true);
|
||||
} finally {
|
||||
document.body.removeChild(editor);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,11 @@ export function isInputFocused(): boolean {
|
||||
const el = document.activeElement as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable) return true;
|
||||
// Monaco's EditContext surface (Chromium) is a plain focusable <div>, not a textarea or
|
||||
// contentEditable element, so the checks above miss it. Treat any focus inside the editor
|
||||
// container as input focus, matching the textarea fallback other browsers already get.
|
||||
return !!el.closest('.monaco-editor');
|
||||
}
|
||||
|
||||
export function isPaletteOpen(): boolean {
|
||||
|
||||
Reference in New Issue
Block a user