fix(editor): 修复 CodeEditor setValue 时滚动位置与折叠等视图状态丢失

使用 saveViewState/restoreViewState 替代 getPosition/setPosition,并放到
nextTick 中执行,避免被 setAutoHeight 的 setScrollTop(0) 覆盖,导致光标
位置变化时编辑器滚动跳回顶部。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
roymondchen 2026-05-18 12:09:45 +08:00
parent 873a51fc87
commit f1aedc4ce7
2 changed files with 105 additions and 14 deletions

View File

@ -214,24 +214,32 @@ const setEditorValue = (v: string | any, m: string | any) => {
if (props.type === 'diff') { if (props.type === 'diff') {
const originalModel = monaco.editor.createModel(values.value, 'text/javascript'); const originalModel = monaco.editor.createModel(values.value, 'text/javascript');
const modifiedModel = monaco.editor.createModel(toString(m, props.language), 'text/javascript'); const modifiedModel = monaco.editor.createModel(toString(m, props.language), 'text/javascript');
const position = vsDiffEditor?.getPosition(); //
const viewState = vsDiffEditor?.saveViewState();
const result = vsDiffEditor?.setModel({ const result = vsDiffEditor?.setModel({
original: originalModel, original: originalModel,
modified: modifiedModel, modified: modifiedModel,
}); });
if (position) { // setAutoHeight nextTick scrollTop 0 nextTick
vsDiffEditor?.setPosition(position); // Vue nextTick FIFO
if (viewState) {
nextTick(() => {
vsDiffEditor?.restoreViewState(viewState);
vsDiffEditor?.focus(); vsDiffEditor?.focus();
});
} }
return result; return result;
} }
// //
const position = vsEditor?.getPosition(); const viewState = vsEditor?.saveViewState();
const result = vsEditor?.setValue(values.value); const result = vsEditor?.setValue(values.value);
// // setAutoHeight nextTick scrollTop 0 nextTick
if (position) { // Vue nextTick FIFO
vsEditor?.setPosition(position); if (viewState) {
nextTick(() => {
vsEditor?.restoreViewState(viewState);
vsEditor?.focus(); vsEditor?.focus();
});
} }
return result; return result;
}; };

View File

@ -20,8 +20,8 @@ const {
vsEditorInstance: { vsEditorInstance: {
getValue: vi.fn(() => 'editor-value'), getValue: vi.fn(() => 'editor-value'),
setValue: vi.fn(), setValue: vi.fn(),
getPosition: vi.fn(() => ({ lineNumber: 1, column: 1 })), saveViewState: vi.fn(() => null),
setPosition: vi.fn(), restoreViewState: vi.fn(),
focus: vi.fn(), focus: vi.fn(),
layout: vi.fn(), layout: vi.fn(),
setScrollTop: vi.fn(), setScrollTop: vi.fn(),
@ -34,8 +34,8 @@ const {
} as any, } as any,
vsDiffEditorInstance: { vsDiffEditorInstance: {
getModifiedEditor: vi.fn(), getModifiedEditor: vi.fn(),
getPosition: vi.fn(() => null), saveViewState: vi.fn(() => null),
setPosition: vi.fn(), restoreViewState: vi.fn(),
setModel: vi.fn(), setModel: vi.fn(),
focus: vi.fn(), focus: vi.fn(),
layout: vi.fn(), layout: vi.fn(),
@ -115,6 +115,9 @@ beforeEach(() => {
vsEditorInstance.onDidBlurEditorWidget.mockImplementation((cb: any) => { vsEditorInstance.onDidBlurEditorWidget.mockImplementation((cb: any) => {
blurHandlers.push(cb); blurHandlers.push(cb);
}); });
// 默认无视图状态,避免上一个用例 mockReturnValue 渗透
vsEditorInstance.saveViewState.mockReturnValue(null);
vsDiffEditorInstance.saveViewState.mockReturnValue(null);
const modifiedEditor = { const modifiedEditor = {
getValue: vi.fn(() => 'modified-value'), getValue: vi.fn(() => 'modified-value'),
onDidChangeModelContent: vi.fn((cb: any) => diffContentChangeHandlers.push(cb)), onDidChangeModelContent: vi.fn((cb: any) => diffContentChangeHandlers.push(cb)),
@ -231,6 +234,86 @@ describe('CodeEditor', () => {
wrapper.unmount(); wrapper.unmount();
}); });
test('setValue 后通过 saveViewState / restoreViewState 保留光标与滚动状态', async () => {
const fakeViewState = { __fake: true } as any;
vsEditorInstance.saveViewState.mockReturnValue(fakeViewState);
const wrapper = mount(CodeEditor, { props: { initValues: 'abc' } as any, attachTo: document.body });
await flush();
vsEditorInstance.restoreViewState.mockClear();
vsEditorInstance.focus.mockClear();
await wrapper.setProps({ initValues: 'xyz' } as any);
await flush();
expect(vsEditorInstance.saveViewState).toHaveBeenCalled();
expect(vsEditorInstance.restoreViewState).toHaveBeenCalledWith(fakeViewState);
expect(vsEditorInstance.focus).toHaveBeenCalled();
wrapper.unmount();
});
test('saveViewState 返回 null 时不调用 restoreViewState / focus', async () => {
vsEditorInstance.saveViewState.mockReturnValue(null);
const wrapper = mount(CodeEditor, { props: { initValues: 'abc' } as any, attachTo: document.body });
await flush();
vsEditorInstance.restoreViewState.mockClear();
vsEditorInstance.focus.mockClear();
await wrapper.setProps({ initValues: 'xyz' } as any);
await flush();
expect(vsEditorInstance.restoreViewState).not.toHaveBeenCalled();
expect(vsEditorInstance.focus).not.toHaveBeenCalled();
wrapper.unmount();
});
test('restoreViewState 在 setAutoHeight 的 setScrollTop 之后执行', async () => {
const fakeViewState = { __fake: true } as any;
vsEditorInstance.saveViewState.mockReturnValue(fakeViewState);
const wrapper = mount(CodeEditor, {
props: { initValues: 'a', autosize: { minRows: 1, maxRows: 10 } } as any,
attachTo: document.body,
});
await flush();
vsEditorInstance.setScrollTop.mockClear();
vsEditorInstance.restoreViewState.mockClear();
// 行数变化触发 setAutoHeight 的 nextTick其中会调用 setScrollTop(0)
await wrapper.setProps({ initValues: 'a\nb\nc\nd\ne' } as any);
await flush();
expect(vsEditorInstance.setScrollTop).toHaveBeenCalled();
expect(vsEditorInstance.restoreViewState).toHaveBeenCalledWith(fakeViewState);
const setScrollTopOrder = vsEditorInstance.setScrollTop.mock.invocationCallOrder[0];
const restoreOrder = vsEditorInstance.restoreViewState.mock.invocationCallOrder[0];
expect(setScrollTopOrder).toBeLessThan(restoreOrder);
wrapper.unmount();
});
test('diff 模式下保留视图状态', async () => {
const fakeViewState = { __fake_diff: true } as any;
vsDiffEditorInstance.saveViewState.mockReturnValue(fakeViewState);
const wrapper = mount(CodeEditor, {
props: { type: 'diff', initValues: 'a', modifiedValues: 'b' } as any,
attachTo: document.body,
});
await flush();
vsDiffEditorInstance.saveViewState.mockClear();
vsDiffEditorInstance.restoreViewState.mockClear();
vsDiffEditorInstance.focus.mockClear();
await wrapper.setProps({ initValues: 'x' } as any);
await flush();
expect(vsDiffEditorInstance.saveViewState).toHaveBeenCalled();
expect(vsDiffEditorInstance.restoreViewState).toHaveBeenCalledWith(fakeViewState);
expect(vsDiffEditorInstance.focus).toHaveBeenCalled();
wrapper.unmount();
});
test('expose getEditor / focus / setEditorValue', async () => { test('expose getEditor / focus / setEditorValue', async () => {
vsEditorInstance.getValue.mockReturnValue('editor-value'); vsEditorInstance.getValue.mockReturnValue('editor-value');
const wrapper = mount(CodeEditor, { props: { initValues: 'abc' } as any, attachTo: document.body }); const wrapper = mount(CodeEditor, { props: { initValues: 'abc' } as any, attachTo: document.body });