🐛 fix(query-editor): 兜底 SQL 编辑器中文输入首次不上屏

- 补充 Monaco IME 组合输入提交兜底
- 统一拦截候选键避免快捷键链路抢占
- 增加 QueryEditor 中文输入回归测试

Fixes #578
This commit is contained in:
Syngnat
2026-06-21 11:25:40 +08:00
parent 7f2445a6f5
commit 5f56859898
6 changed files with 634 additions and 14 deletions

View File

@@ -89,6 +89,7 @@ import {
getShortcutDisplay,
getShortcutDisplayLabel,
getShortcutPlatform,
installGlobalImeCompositionTracking,
isEditableElement,
isShortcutMatch,
normalizeShortcutCombo,
@@ -1378,6 +1379,9 @@ function App() {
import.meta.env.DEV,
import.meta.env.VITE_GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS,
);
useEffect(() => {
return installGlobalImeCompositionTracking(window, document);
}, []);
const {
aboutDisplayVersion,
aboutInfo,

View File

@@ -8,6 +8,7 @@ import { setCurrentLanguage } from '../i18n';
import { I18nProvider } from '../i18n/provider';
import type { SavedQuery, TabData } from '../types';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
import { setGlobalImeCompositionActive } from '../utils/shortcuts';
import { clearQueryTabDraft, clearSQLFileTabDraft, getQueryTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
import QueryEditor, {
collectQueryEditorObjectDecorationCandidates,
@@ -689,6 +690,7 @@ describe('QueryEditor external SQL save', () => {
clearQueryTabDraft('tab-2');
clearSQLFileTabDraft('tab-1');
clearSQLFileTabDraft('tab-2');
setGlobalImeCompositionActive(false);
});
afterEach(() => {
@@ -3357,6 +3359,97 @@ describe('QueryEditor external SQL save', () => {
expect(lastDecorationCall?.[1]?.[0]?.options?.inlineClassName).toBe('gonavi-query-editor-link-hint');
});
it('ignores IME candidate keydown events when syncing modifier hover state', async () => {
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
windowListeners[type] ||= [];
windowListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
editorState.value = 'select 1';
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value })} />);
});
editorState.editor.updateOptions.mockClear();
editorState.editor.deltaDecorations.mockClear();
await act(async () => {
const imeEvent = {
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
key: 'Process',
keyCode: 229,
which: 229,
isComposing: true,
nativeEvent: {
isComposing: true,
keyCode: 229,
which: 229,
},
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
target: null,
};
windowListeners.keydown?.forEach((listener) => listener(imeEvent));
});
expect(editorState.editor.updateOptions).not.toHaveBeenCalledWith({ mouseStyle: 'text' });
expect(editorState.editor.deltaDecorations).not.toHaveBeenCalled();
});
it('ignores candidate number keys while a composition session is active', async () => {
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
windowListeners[type] ||= [];
windowListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
editorState.value = 'select 1';
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value })} />);
});
setGlobalImeCompositionActive(true);
editorState.editor.updateOptions.mockClear();
editorState.editor.deltaDecorations.mockClear();
await act(async () => {
const candidateSelectEvent = {
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
key: '1',
keyCode: 49,
which: 49,
isComposing: false,
nativeEvent: {
isComposing: false,
},
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
target: null,
};
windowListeners.keydown?.forEach((listener) => listener(candidateSelectEvent));
});
expect(editorState.editor.updateOptions).not.toHaveBeenCalled();
expect(editorState.editor.deltaDecorations).not.toHaveBeenCalled();
});
it('keeps query editor hyperlink decorations blue with a solid underline', () => {
const css = readFileSync(new URL('../App.css', import.meta.url), 'utf8');
@@ -3839,6 +3932,183 @@ describe('QueryEditor external SQL save', () => {
expect(editorState.editor.getModel().getValue).not.toHaveBeenCalled();
});
it('ignores focused local tab query echoes so IME candidate commits are not overwritten', async () => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: '' })} />);
});
editorState.value = '';
editorState.hasTextFocus = true;
editorState.editor.setValue.mockClear();
await act(async () => {
editorState.latestOnChange?.('我');
});
editorState.editor.getValue.mockImplementationOnce(() => '');
await act(async () => {
renderer.update(<QueryEditor tab={createTab({ query: '我' })} />);
});
expect(getQueryTabDraft('tab-1')).toBe('我');
expect(editorState.editor.setValue).not.toHaveBeenCalled();
});
it('still applies true external tab query changes while the editor is focused', async () => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: '' })} />);
});
editorState.value = '';
editorState.hasTextFocus = true;
editorState.editor.setValue.mockClear();
await act(async () => {
editorState.latestOnChange?.('我');
});
editorState.editor.getValue.mockImplementationOnce(() => '');
await act(async () => {
renderer.update(<QueryEditor tab={createTab({ query: 'SELECT 2;' })} />);
});
expect(editorState.editor.setValue).toHaveBeenCalledWith('SELECT 2;');
});
it('recovers committed IME text when Monaco composition end leaves the model unchanged', async () => {
vi.useFakeTimers();
const domListeners: Record<string, ((event?: any) => void)[]> = {};
editorState.domNode.addEventListener.mockImplementation((type: string, listener: (event?: any) => void) => {
domListeners[type] ||= [];
domListeners[type].push(listener);
});
editorState.editor.getValue.mockReset();
editorState.editor.getValue.mockImplementation(() => editorState.value);
try {
await act(async () => {
create(<QueryEditor tab={createTab({ query: "select '';" })} />);
});
editorState.position = { lineNumber: 1, column: 9 };
editorState.selection = null;
editorState.editor.executeEdits.mockClear();
await act(async () => {
domListeners.compositionstart?.forEach((listener) => listener({ data: '' }));
domListeners.compositionend?.forEach((listener) => listener({ data: '我' }));
});
await act(async () => {
vi.runOnlyPendingTimers();
});
expect(editorState.editor.executeEdits).toHaveBeenCalledWith(
'gonavi-ime-composition-fallback',
[{
range: expect.objectContaining({
startLineNumber: 1,
startColumn: 9,
endLineNumber: 1,
endColumn: 9,
}),
text: '我',
forceMoveMarkers: true,
}],
);
expect(editorState.value).toBe("select '我';");
expect(getQueryTabDraft('tab-1')).toBe("select '我';");
} finally {
vi.useRealTimers();
}
});
it('does not duplicate IME text when Monaco already applied the composition commit', async () => {
vi.useFakeTimers();
const domListeners: Record<string, ((event?: any) => void)[]> = {};
editorState.domNode.addEventListener.mockImplementation((type: string, listener: (event?: any) => void) => {
domListeners[type] ||= [];
domListeners[type].push(listener);
});
editorState.editor.getValue.mockReset();
editorState.editor.getValue.mockImplementation(() => editorState.value);
try {
await act(async () => {
create(<QueryEditor tab={createTab({ query: "select '';" })} />);
});
editorState.position = { lineNumber: 1, column: 9 };
editorState.selection = null;
editorState.editor.executeEdits.mockClear();
await act(async () => {
domListeners.compositionstart?.forEach((listener) => listener({ data: '' }));
editorState.value = "select '我';";
domListeners.compositionend?.forEach((listener) => listener({ data: '我' }));
});
await act(async () => {
vi.runOnlyPendingTimers();
});
expect(editorState.editor.executeEdits).not.toHaveBeenCalledWith(
'gonavi-ime-composition-fallback',
expect.anything(),
);
expect(editorState.value).toBe("select '我';");
} finally {
vi.useRealTimers();
}
});
it('uses beforeinput data as the IME fallback text when composition end data is empty', async () => {
vi.useFakeTimers();
const domListeners: Record<string, ((event?: any) => void)[]> = {};
editorState.domNode.addEventListener.mockImplementation((type: string, listener: (event?: any) => void) => {
domListeners[type] ||= [];
domListeners[type].push(listener);
});
editorState.editor.getValue.mockReset();
editorState.editor.getValue.mockImplementation(() => editorState.value);
try {
await act(async () => {
create(<QueryEditor tab={createTab({ query: "select '';" })} />);
});
editorState.position = { lineNumber: 1, column: 9 };
editorState.selection = null;
editorState.editor.executeEdits.mockClear();
await act(async () => {
domListeners.compositionstart?.forEach((listener) => listener({ data: '' }));
domListeners.beforeinput?.forEach((listener) => listener({
data: '我',
inputType: 'insertCompositionText',
isComposing: true,
}));
domListeners.compositionend?.forEach((listener) => listener({ data: '' }));
});
await act(async () => {
vi.runOnlyPendingTimers();
});
expect(editorState.editor.executeEdits).toHaveBeenCalledWith(
'gonavi-ime-composition-fallback',
[expect.objectContaining({ text: '我' })],
);
expect(editorState.value).toBe("select '我';");
} finally {
vi.useRealTimers();
}
});
it('keeps short regular query typing on the Monaco fast path without rerender side effects', async () => {
await act(async () => {
create(<QueryEditor tab={createTab({ query: 'select 1;' })} />);

View File

@@ -10,7 +10,7 @@ import { DBQuery, DBQueryWithCancel, DBQueryMulti, DBQueryMultiTransactional, DB
import { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities, shouldShowOceanBaseRowNumberColumn } from '../utils/dataSourceCapabilities';
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb";
import { getShortcutDisplayLabel, getShortcutPlatform, getShortcutPrimaryModifierDisplayLabel, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding, resolveShortcutBinding } from "../utils/shortcuts";
import { getShortcutDisplayLabel, getShortcutPlatform, getShortcutPrimaryModifierDisplayLabel, isEditableElement, isImeComposingKeyEvent, isShortcutMatch, comboToMonacoKeyBinding, resolveShortcutBinding } from "../utils/shortcuts";
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { isPostgresSchemaDialect } from '../utils/connectionDriverType';
@@ -246,6 +246,15 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const aiContextMenuActionDisposablesRef = useRef<any[]>([]);
const toggleQueryResultsPanelActionRef = useRef<any>(null);
const lastExternalQueryRef = useRef<string>(getTabQueryValue(tab));
const lastLocalQueryRef = useRef<string>(query);
const imeCompositionFallbackRef = useRef<{
editor: any;
valueBefore: string;
selectionBefore: any;
positionBefore: { lineNumber: number; column: number } | null;
committedText: string;
} | null>(null);
const imeCompositionFallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastEditorCursorPositionRef = useRef<any>(null);
const lastHoverTargetPositionRef = useRef<{ lineNumber: number; column: number } | null>(null);
const lastExecutedEditorQueryRef = useRef<string>('');
@@ -441,6 +450,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const syncQueryDraft = useCallback((nextQuery: string) => {
const next = String(nextQuery ?? '');
lastLocalQueryRef.current = next;
if (isExternalSQLFileTab) {
setSQLFileTabDraft(tab.id, next);
return;
@@ -881,6 +891,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return;
}
lastExternalQueryRef.current = incoming;
const editorHasFocus = editorRef.current?.hasTextFocus?.() === true;
if (editorHasFocus && incoming === lastLocalQueryRef.current) {
setQuery(incoming);
return;
}
syncQueryToEditor(incoming);
}, [tab.id, tab.query]);
@@ -1347,16 +1362,23 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const syncModifierState = (keyboardEvent?: KeyboardEvent | MouseEvent | null) => {
const wasPressed = ctrlMetaPressedRef.current;
ctrlMetaPressedRef.current = !!(keyboardEvent?.ctrlKey || keyboardEvent?.metaKey);
if (!ctrlMetaPressedRef.current) {
const isKeyboardLikeEvent = keyboardEvent
&& typeof keyboardEvent === 'object'
&& ('key' in keyboardEvent || 'code' in keyboardEvent || 'repeat' in keyboardEvent);
if (isKeyboardLikeEvent && isImeComposingKeyEvent(keyboardEvent as KeyboardEvent)) {
return;
}
const nextPressed = !!(keyboardEvent?.ctrlKey || keyboardEvent?.metaKey);
ctrlMetaPressedRef.current = nextPressed;
if (!nextPressed && !wasPressed) {
return;
}
if (!nextPressed) {
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
editor.updateOptions?.({ mouseStyle: 'text' });
setQueryEditorMouseCursor(editor, '');
return;
}
const isKeyboardLikeEvent = keyboardEvent
&& typeof keyboardEvent === 'object'
&& ('key' in keyboardEvent || 'code' in keyboardEvent || 'repeat' in keyboardEvent);
if (!wasPressed || isKeyboardLikeEvent) {
applyNavigationHoverStateAtPosition(lastHoverTargetPositionRef.current);
}
@@ -1368,6 +1390,103 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
setQueryEditorMouseCursor(editor, '');
};
const editorDomNode = editor.getDomNode?.();
const clearImeCompositionFallbackTimer = () => {
if (imeCompositionFallbackTimerRef.current !== null) {
clearTimeout(imeCompositionFallbackTimerRef.current);
imeCompositionFallbackTimerRef.current = null;
}
};
const getEditorText = () => String(
editor.getValue?.()
?? editor.getModel?.()?.getValue?.()
?? '',
);
const buildImeFallbackRange = (snapshot: NonNullable<typeof imeCompositionFallbackRef.current>) => {
const selection = snapshot.selectionBefore;
const startFromSelection = typeof selection?.getStartPosition === 'function'
? normalizeEditorPosition(selection.getStartPosition())
: null;
const endFromSelection = typeof selection?.getEndPosition === 'function'
? normalizeEditorPosition(selection.getEndPosition())
: null;
const startPosition = startFromSelection || normalizeEditorPosition({
lineNumber: selection?.startLineNumber ?? selection?.selectionStartLineNumber,
column: selection?.startColumn ?? selection?.selectionStartColumn,
}) || snapshot.positionBefore || lastEditorCursorPositionRef.current || { lineNumber: 1, column: 1 };
const endPosition = endFromSelection || normalizeEditorPosition({
lineNumber: selection?.endLineNumber ?? selection?.positionLineNumber,
column: selection?.endColumn ?? selection?.positionColumn,
}) || startPosition;
return new monaco.Range(
startPosition.lineNumber,
startPosition.column,
endPosition.lineNumber,
endPosition.column,
);
};
const handleImeCompositionStart = () => {
clearImeCompositionFallbackTimer();
imeCompositionFallbackRef.current = {
editor,
valueBefore: getEditorText(),
selectionBefore: editor.getSelection?.() || null,
positionBefore: normalizeEditorPosition(editor.getPosition?.()) || lastEditorCursorPositionRef.current || null,
committedText: '',
};
};
const handleImeBeforeInput = (rawEvent: Event) => {
const snapshot = imeCompositionFallbackRef.current;
if (!snapshot || snapshot.editor !== editor) {
return;
}
const inputEvent = rawEvent as InputEvent;
const nextText = String(inputEvent.data ?? '');
if (nextText && (inputEvent.isComposing || String(inputEvent.inputType || '').includes('Composition'))) {
snapshot.committedText = nextText;
}
};
const handleImeCompositionEnd = (rawEvent: Event) => {
const snapshot = imeCompositionFallbackRef.current;
imeCompositionFallbackRef.current = null;
const committedText = String((rawEvent as CompositionEvent).data ?? '') || snapshot?.committedText || '';
if (!committedText || !snapshot || snapshot.editor !== editor) {
return;
}
const fallbackRange = buildImeFallbackRange(snapshot);
clearImeCompositionFallbackTimer();
imeCompositionFallbackTimerRef.current = setTimeout(() => {
imeCompositionFallbackTimerRef.current = null;
if (editorRef.current !== editor) {
return;
}
const currentValue = getEditorText();
if (currentValue !== snapshot.valueBefore) {
syncQueryDraft(currentValue);
return;
}
editor.executeEdits?.('gonavi-ime-composition-fallback', [{
range: fallbackRange,
text: committedText,
forceMoveMarkers: true,
}]);
const nextValue = getEditorText();
syncQueryDraft(nextValue);
const model = editor.getModel?.();
const startOffset = Number(model?.getOffsetAt?.({
lineNumber: fallbackRange.startLineNumber,
column: fallbackRange.startColumn,
}));
const nextPosition = Number.isFinite(startOffset)
? normalizeEditorPosition(model?.getPositionAt?.(startOffset + committedText.length))
: null;
if (nextPosition) {
editor.setPosition?.(nextPosition);
}
}, 0);
};
const handleEditorDragOver = (rawEvent: Event) => {
const event = rawEvent as DragEvent;
if (!hasSidebarSqlEditorDragPayload(event.dataTransfer)) return;
@@ -1432,6 +1551,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
window.addEventListener('keydown', syncModifierState);
window.addEventListener('keyup', syncModifierState);
window.addEventListener('blur', handleWindowBlur);
editorDomNode?.addEventListener('beforeinput', handleImeBeforeInput, true);
editorDomNode?.addEventListener('compositionstart', handleImeCompositionStart, true);
editorDomNode?.addEventListener('compositionend', handleImeCompositionEnd, true);
editorDomNode?.addEventListener('dragover', handleEditorDragOver, true);
editorDomNode?.addEventListener('drop', handleEditorDrop, true);
@@ -1618,6 +1740,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
window.removeEventListener('keydown', syncModifierState);
window.removeEventListener('keyup', syncModifierState);
window.removeEventListener('blur', handleWindowBlur);
clearImeCompositionFallbackTimer();
editorDomNode?.removeEventListener('beforeinput', handleImeBeforeInput, true);
editorDomNode?.removeEventListener('compositionstart', handleImeCompositionStart, true);
editorDomNode?.removeEventListener('compositionend', handleImeCompositionEnd, true);
editorDomNode?.removeEventListener('dragover', handleEditorDragOver, true);
editorDomNode?.removeEventListener('drop', handleEditorDrop, true);
});

View File

@@ -1,4 +1,4 @@
import { DEFAULT_SHORTCUT_OPTIONS, getShortcutDisplayLabel, isShortcutMatch, type ShortcutPlatform, type ShortcutPlatformBinding } from './shortcuts';
import { DEFAULT_SHORTCUT_OPTIONS, getShortcutDisplayLabel, isImeComposingKeyEvent, isShortcutMatch, type ShortcutPlatform, type ShortcutPlatformBinding } from './shortcuts';
export interface AIChatSendShortcutKeyEventLike {
key?: string;
@@ -36,12 +36,7 @@ export const shouldSendAIChatOnKeyDown = (
if (!binding?.enabled) {
return false;
}
// Some IMEs report Enter during an active candidate/composition as keyCode 229.
const isImeCandidateEvent = event.keyCode === 229
|| event.which === 229
|| event.nativeEvent?.keyCode === 229
|| event.nativeEvent?.which === 229;
if (event.shiftKey || event.isComposing || event.nativeEvent?.isComposing || isImeCandidateEvent) {
if (event.shiftKey || isImeComposingKeyEvent(event as KeyboardEvent)) {
return false;
}
return isShortcutMatch(event as KeyboardEvent, binding.combo);

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
setCurrentLanguage,
@@ -11,11 +11,17 @@ import {
normalizeShortcutCombo,
RESERVED_SHORTCUTS,
comboToMonacoKeyBinding,
eventToShortcut,
getPrimaryShortcutDisplayLabel,
getShortcutDisplayLabel,
getShortcutPrimaryModifierDisplayLabel,
installGlobalImeCompositionTracking,
isGlobalImeCompositionActive,
isImeComposingKeyEvent,
isShortcutMatch,
resolveShortcutBinding,
resolveShortcutDisplay,
setGlobalImeCompositionActive,
sanitizeShortcutOptions,
SHORTCUT_ACTION_META,
} from './shortcuts';
@@ -23,6 +29,7 @@ import type { ConflictInfo } from './shortcuts';
beforeEach(() => {
setCurrentLanguage('zh-CN');
setGlobalImeCompositionActive(false);
});
// ─── findReservedConflict ────────────────────────────────────────────
@@ -164,6 +171,125 @@ describe('RESERVED_SHORTCUTS', () => {
});
});
describe('IME shortcut guards', () => {
it('tracks composition state through global listeners', () => {
const windowListeners = new Map<string, EventListener[]>();
const documentListeners = new Map<string, EventListener[]>();
const target = {
addEventListener: vi.fn((type: string, listener: EventListener) => {
windowListeners.set(type, [...(windowListeners.get(type) || []), listener]);
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
windowListeners.set(type, (windowListeners.get(type) || []).filter(item => item !== listener));
}),
};
const documentTarget = {
visibilityState: 'visible' as DocumentVisibilityState,
addEventListener: vi.fn((type: string, listener: EventListener) => {
documentListeners.set(type, [...(documentListeners.get(type) || []), listener]);
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
documentListeners.set(type, (documentListeners.get(type) || []).filter(item => item !== listener));
}),
};
const dispose = installGlobalImeCompositionTracking(
target as unknown as Window,
documentTarget as unknown as Document,
);
windowListeners.get('compositionstart')?.forEach(listener => listener(new Event('compositionstart')));
expect(isGlobalImeCompositionActive()).toBe(true);
windowListeners.get('compositionend')?.forEach(listener => listener(new Event('compositionend')));
expect(isGlobalImeCompositionActive()).toBe(false);
windowListeners.get('compositionstart')?.forEach(listener => listener(new Event('compositionstart')));
windowListeners.get('blur')?.forEach(listener => listener(new Event('blur')));
expect(isGlobalImeCompositionActive()).toBe(false);
windowListeners.get('compositionstart')?.forEach(listener => listener(new Event('compositionstart')));
documentTarget.visibilityState = 'hidden';
documentListeners.get('visibilitychange')?.forEach(listener => listener(new Event('visibilitychange')));
expect(isGlobalImeCompositionActive()).toBe(false);
dispose();
expect(target.removeEventListener).toHaveBeenCalledWith('compositionstart', expect.any(Function), true);
expect(documentTarget.removeEventListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function), true);
});
it('treats composing key events as non-shortcuts', () => {
const event = {
key: 'Process',
keyCode: 229,
which: 229,
isComposing: true,
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
nativeEvent: {
isComposing: true,
keyCode: 229,
which: 229,
},
} as unknown as KeyboardEvent;
expect(isImeComposingKeyEvent(event)).toBe(true);
expect(eventToShortcut(event)).toBe('');
expect(isShortcutMatch(event, 'Ctrl+Enter')).toBe(false);
});
it('treats number keys as non-shortcuts while a composition session is active', () => {
setGlobalImeCompositionActive(true);
const event = {
key: '1',
keyCode: 49,
which: 49,
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
isComposing: false,
nativeEvent: {
isComposing: false,
},
} as unknown as KeyboardEvent;
expect(isImeComposingKeyEvent(event)).toBe(true);
expect(eventToShortcut(event)).toBe('');
expect(isShortcutMatch(event, '1')).toBe(false);
});
it('treats Monaco visible IME textarea events as composing even without native flags', () => {
const target = {
className: 'inputarea monaco-mouse-cursor-text ime-input',
classList: {
contains: (name: string) => name === 'ime-input',
},
closest: vi.fn(),
} as unknown as EventTarget;
const event = {
key: '1',
keyCode: 49,
which: 49,
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
isComposing: false,
nativeEvent: {
isComposing: false,
},
target,
} as unknown as KeyboardEvent;
expect(isImeComposingKeyEvent(event)).toBe(true);
expect(eventToShortcut(event)).toBe('');
expect(isShortcutMatch(event, '1')).toBe(false);
});
});
// ─── shortcut defaults ───────────────────────────────────────────────
describe('shortcut defaults', () => {

View File

@@ -379,7 +379,106 @@ const normalizeKeyboardKey = (key: string): string => {
return token.length > 1 ? token[0].toUpperCase() + token.slice(1) : token;
};
let globalImeCompositionActive = false;
export const setGlobalImeCompositionActive = (active: boolean): void => {
globalImeCompositionActive = active === true;
};
export const isGlobalImeCompositionActive = (): boolean => globalImeCompositionActive;
type ImeCompositionEventTarget = Pick<Window, 'addEventListener' | 'removeEventListener'>;
type ImeCompositionDocumentTarget = Pick<Document, 'addEventListener' | 'removeEventListener'> & {
visibilityState?: DocumentVisibilityState;
};
export const installGlobalImeCompositionTracking = (
eventTarget: ImeCompositionEventTarget = window,
documentTarget: ImeCompositionDocumentTarget | null = document,
): (() => void) => {
const handleCompositionStart = () => setGlobalImeCompositionActive(true);
const handleCompositionEnd = () => setGlobalImeCompositionActive(false);
const handleBlur = () => setGlobalImeCompositionActive(false);
const handleVisibilityChange = () => {
if (!documentTarget || documentTarget.visibilityState === 'hidden') {
setGlobalImeCompositionActive(false);
}
};
eventTarget.addEventListener('compositionstart', handleCompositionStart, true);
eventTarget.addEventListener('compositionend', handleCompositionEnd, true);
eventTarget.addEventListener('blur', handleBlur, true);
documentTarget?.addEventListener('visibilitychange', handleVisibilityChange, true);
return () => {
eventTarget.removeEventListener('compositionstart', handleCompositionStart, true);
eventTarget.removeEventListener('compositionend', handleCompositionEnd, true);
eventTarget.removeEventListener('blur', handleBlur, true);
documentTarget?.removeEventListener('visibilitychange', handleVisibilityChange, true);
setGlobalImeCompositionActive(false);
};
};
const isMonacoImeInputTarget = (target: EventTarget | null | undefined): boolean => {
if (!target || typeof target !== 'object') {
return false;
}
const element = target as Element & {
className?: unknown;
classList?: { contains?: (name: string) => boolean };
closest?: (selector: string) => Element | null;
};
if (typeof element.classList?.contains === 'function' && element.classList.contains('ime-input')) {
return true;
}
if (typeof element.className === 'string' && /\bime-input\b/.test(element.className)) {
return true;
}
if (typeof element.closest === 'function') {
return Boolean(element.closest('.monaco-editor .inputarea.ime-input, .monaco-editor textarea.ime-input, .ime-input'));
}
return false;
};
export const isImeComposingKeyEvent = (
event: (KeyboardEvent | ReactKeyboardEvent | null | undefined) & {
nativeEvent?: {
isComposing?: boolean;
keyCode?: number;
which?: number;
};
keyCode?: number;
which?: number;
isComposing?: boolean;
key?: string;
target?: EventTarget | null;
},
): boolean => {
if (!event) {
return false;
}
const nativeEvent = event.nativeEvent;
const key = String(event.key || '').trim();
const keyCode = Number(event.keyCode ?? nativeEvent?.keyCode ?? 0);
const which = Number(event.which ?? nativeEvent?.which ?? 0);
return Boolean(
globalImeCompositionActive
|| event.isComposing
|| nativeEvent?.isComposing
|| isMonacoImeInputTarget(event.target)
|| key === 'Process'
|| keyCode === 229
|| which === 229,
);
};
export const eventToShortcut = (event: KeyboardEvent | ReactKeyboardEvent): string => {
if (isImeComposingKeyEvent(event)) {
return '';
}
const key = normalizeKeyboardKey(event.key);
if (!key || MODIFIER_SET.has(key as typeof MODIFIER_ORDER[number])) {
return '';