diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d0461d..06dc72a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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, diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 16a2194..3725c0d 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -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 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(); + }); + + 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 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(); + }); + + 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(); + }); + + editorState.value = ''; + editorState.hasTextFocus = true; + editorState.editor.setValue.mockClear(); + + await act(async () => { + editorState.latestOnChange?.('我'); + }); + + editorState.editor.getValue.mockImplementationOnce(() => ''); + await act(async () => { + renderer.update(); + }); + + 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(); + }); + + editorState.value = ''; + editorState.hasTextFocus = true; + editorState.editor.setValue.mockClear(); + + await act(async () => { + editorState.latestOnChange?.('我'); + }); + + editorState.editor.getValue.mockImplementationOnce(() => ''); + await act(async () => { + renderer.update(); + }); + + 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 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(); + }); + + 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 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(); + }); + + 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 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(); + }); + + 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(); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 704978c..ab3a346 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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([]); const toggleQueryResultsPanelActionRef = useRef(null); const lastExternalQueryRef = useRef(getTabQueryValue(tab)); + const lastLocalQueryRef = useRef(query); + const imeCompositionFallbackRef = useRef<{ + editor: any; + valueBefore: string; + selectionBefore: any; + positionBefore: { lineNumber: number; column: number } | null; + committedText: string; + } | null>(null); + const imeCompositionFallbackTimerRef = useRef | null>(null); const lastEditorCursorPositionRef = useRef(null); const lastHoverTargetPositionRef = useRef<{ lineNumber: number; column: number } | null>(null); const lastExecutedEditorQueryRef = useRef(''); @@ -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) => { + 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); }); diff --git a/frontend/src/utils/aiChatSendShortcut.ts b/frontend/src/utils/aiChatSendShortcut.ts index 496d139..894429d 100644 --- a/frontend/src/utils/aiChatSendShortcut.ts +++ b/frontend/src/utils/aiChatSendShortcut.ts @@ -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); diff --git a/frontend/src/utils/shortcuts.test.ts b/frontend/src/utils/shortcuts.test.ts index ce01509..f7ce376 100644 --- a/frontend/src/utils/shortcuts.test.ts +++ b/frontend/src/utils/shortcuts.test.ts @@ -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(); + const documentListeners = new Map(); + 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', () => { diff --git a/frontend/src/utils/shortcuts.ts b/frontend/src/utils/shortcuts.ts index 2dc500d..8260c36 100644 --- a/frontend/src/utils/shortcuts.ts +++ b/frontend/src/utils/shortcuts.ts @@ -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; +type ImeCompositionDocumentTarget = Pick & { + 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 '';