mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-01 08:01:26 +08:00
🐛 fix(query-editor): 兜底 SQL 编辑器中文输入首次不上屏
- 补充 Monaco IME 组合输入提交兜底 - 统一拦截候选键避免快捷键链路抢占 - 增加 QueryEditor 中文输入回归测试 Fixes #578
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;' })} />);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
Reference in New Issue
Block a user