mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-11 09:13:36 +08:00
🐛 fix(monaco-editor): 修复 WebKit 输入法组合输入时异常滚动
- 仅在原生 WebKit 环境稳定 Monaco 输入法组合期间的文本区滚动位置 - 在组合结束、失焦和编辑器销毁时及时停止并清理监听器 - 补充 WebKit、Chromium 隔离及销毁清理回归测试
This commit is contained in:
118
frontend/src/components/MonacoEditor.ime-scroll.test.ts
Normal file
118
frontend/src/components/MonacoEditor.ime-scroll.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { installWebKitImeScrollStabilizer } from './MonacoEditor';
|
||||
|
||||
class FakeTextAreaElement {
|
||||
scrollLeft = 0;
|
||||
scrollTop = 0;
|
||||
|
||||
private listeners = new Map<string, Set<() => void>>();
|
||||
|
||||
addEventListener(type: string, listener: () => void): void {
|
||||
const listeners = this.listeners.get(type) || new Set();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: () => void): void {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
}
|
||||
|
||||
dispatch(type: string): void {
|
||||
this.listeners.get(type)?.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
|
||||
describe('MonacoEditor WebKit IME scroll stabilizer', () => {
|
||||
let input: FakeTextAreaElement;
|
||||
let disposeListener: (() => void) | null;
|
||||
let editor: any;
|
||||
|
||||
beforeEach(() => {
|
||||
input = new FakeTextAreaElement();
|
||||
disposeListener = null;
|
||||
vi.stubGlobal('HTMLTextAreaElement', FakeTextAreaElement);
|
||||
editor = {
|
||||
getDomNode: () => ({
|
||||
querySelector: () => input,
|
||||
}),
|
||||
onDidDispose: vi.fn((listener: () => void) => {
|
||||
disposeListener = listener;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('keeps Monaco textarea offsets stable throughout WebKit composition', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/620.1.2 (KHTML, like Gecko)',
|
||||
});
|
||||
input.scrollTop = 26;
|
||||
input.scrollLeft = 118;
|
||||
|
||||
installWebKitImeScrollStabilizer(editor);
|
||||
input.dispatch('compositionstart');
|
||||
|
||||
input.scrollTop = 52;
|
||||
input.scrollLeft = 141;
|
||||
input.dispatch('compositionupdate');
|
||||
|
||||
expect(input.scrollTop).toBe(26);
|
||||
expect(input.scrollLeft).toBe(118);
|
||||
|
||||
input.scrollTop = 73;
|
||||
input.scrollLeft = 166;
|
||||
input.dispatch('scroll');
|
||||
|
||||
expect(input.scrollTop).toBe(26);
|
||||
expect(input.scrollLeft).toBe(118);
|
||||
|
||||
input.dispatch('compositionend');
|
||||
input.scrollTop = 91;
|
||||
input.scrollLeft = 204;
|
||||
input.dispatch('scroll');
|
||||
|
||||
expect(input.scrollTop).toBe(91);
|
||||
expect(input.scrollLeft).toBe(204);
|
||||
});
|
||||
|
||||
it('does not interfere with Chromium textarea scrolling', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent: 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
|
||||
});
|
||||
|
||||
installWebKitImeScrollStabilizer(editor);
|
||||
input.scrollTop = 12;
|
||||
input.scrollLeft = 34;
|
||||
input.dispatch('compositionstart');
|
||||
input.scrollTop = 56;
|
||||
input.scrollLeft = 78;
|
||||
input.dispatch('compositionupdate');
|
||||
|
||||
expect(input.scrollTop).toBe(56);
|
||||
expect(input.scrollLeft).toBe(78);
|
||||
expect(editor.onDidDispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the WebKit workaround when Monaco is disposed', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/620.1.2 (KHTML, like Gecko)',
|
||||
});
|
||||
|
||||
installWebKitImeScrollStabilizer(editor);
|
||||
input.scrollTop = 10;
|
||||
input.scrollLeft = 20;
|
||||
input.dispatch('compositionstart');
|
||||
disposeListener?.();
|
||||
|
||||
input.scrollTop = 30;
|
||||
input.scrollLeft = 40;
|
||||
input.dispatch('compositionupdate');
|
||||
|
||||
expect(input.scrollTop).toBe(30);
|
||||
expect(input.scrollLeft).toBe(40);
|
||||
});
|
||||
});
|
||||
@@ -263,6 +263,74 @@ const patchQueryEditorAiInlineRightArrowFallback = (editor: any, monaco: any) =>
|
||||
editor.addCommand = patchedAddCommand;
|
||||
};
|
||||
|
||||
const isWebKitImeScrollRuntime = (): boolean => {
|
||||
const userAgent = typeof navigator === 'undefined' ? '' : String(navigator.userAgent || '');
|
||||
return /AppleWebKit\//i.test(userAgent)
|
||||
&& !/(?:Chrome|Chromium|CriOS|Edg|EdgiOS|OPR|OPiOS|FxiOS)\//i.test(userAgent);
|
||||
};
|
||||
|
||||
export const installWebKitImeScrollStabilizer = (editor: any) => {
|
||||
if (!isWebKitImeScrollRuntime() || editor?.__gonaviWebKitImeScrollStabilizerInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editorDomNode = editor?.getDomNode?.();
|
||||
const TextAreaElement = typeof HTMLTextAreaElement === 'undefined' ? null : HTMLTextAreaElement;
|
||||
const input = editorDomNode?.querySelector?.('textarea.inputarea, .inputarea textarea, textarea') as HTMLTextAreaElement | null;
|
||||
if (!TextAreaElement || !(input instanceof TextAreaElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(editor, '__gonaviWebKitImeScrollStabilizerInstalled', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
let composing = false;
|
||||
let compositionScrollLeft = 0;
|
||||
let compositionScrollTop = 0;
|
||||
|
||||
const restoreCompositionScroll = () => {
|
||||
if (!composing) {
|
||||
return;
|
||||
}
|
||||
if (input.scrollLeft !== compositionScrollLeft) {
|
||||
input.scrollLeft = compositionScrollLeft;
|
||||
}
|
||||
if (input.scrollTop !== compositionScrollTop) {
|
||||
input.scrollTop = compositionScrollTop;
|
||||
}
|
||||
};
|
||||
const handleCompositionStart = () => {
|
||||
// Monaco has already positioned its visible IME textarea when this listener runs.
|
||||
// Keep that baseline stable while WebKit and Monaco both try to scroll the textarea.
|
||||
compositionScrollLeft = input.scrollLeft;
|
||||
compositionScrollTop = input.scrollTop;
|
||||
composing = true;
|
||||
};
|
||||
const handleCompositionEnd = () => {
|
||||
composing = false;
|
||||
};
|
||||
const handleBlur = () => {
|
||||
composing = false;
|
||||
};
|
||||
|
||||
input.addEventListener('compositionstart', handleCompositionStart);
|
||||
input.addEventListener('compositionupdate', restoreCompositionScroll);
|
||||
input.addEventListener('compositionend', handleCompositionEnd);
|
||||
input.addEventListener('scroll', restoreCompositionScroll);
|
||||
input.addEventListener('blur', handleBlur);
|
||||
|
||||
editor.onDidDispose?.(() => {
|
||||
composing = false;
|
||||
input.removeEventListener('compositionstart', handleCompositionStart);
|
||||
input.removeEventListener('compositionupdate', restoreCompositionScroll);
|
||||
input.removeEventListener('compositionend', handleCompositionEnd);
|
||||
input.removeEventListener('scroll', restoreCompositionScroll);
|
||||
input.removeEventListener('blur', handleBlur);
|
||||
});
|
||||
};
|
||||
|
||||
export const installPrintableInputFallback = (editor: any, monaco: any) => {
|
||||
const editorDomNode = editor?.getDomNode?.();
|
||||
if (!editorDomNode || editor.__gonaviPrintableInputFallbackInstalled) {
|
||||
@@ -820,6 +888,7 @@ const MonacoEditor: React.FC<MonacoEditorProps> = ({
|
||||
installOceanBaseOracleNavigationFallback(editor);
|
||||
patchQueryEditorAiInlineRightArrowFallback(editor, monaco);
|
||||
installPrintableInputFallback(editor, monaco);
|
||||
installWebKitImeScrollStabilizer(editor);
|
||||
onMount?.(editor, monaco);
|
||||
}, [onMount]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user