🐛 fix(monaco-editor): 修复 WebKit 输入法组合输入时异常滚动

- 仅在原生 WebKit 环境稳定 Monaco 输入法组合期间的文本区滚动位置
- 在组合结束、失焦和编辑器销毁时及时停止并清理监听器
- 补充 WebKit、Chromium 隔离及销毁清理回归测试
This commit is contained in:
Syngnat
2026-07-18 14:00:19 +08:00
parent 7017db418b
commit f08bcb5e10
2 changed files with 187 additions and 0 deletions

View 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);
});
});

View File

@@ -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]);