1
0
mirror of synced 2026-05-20 17:28:42 +08:00

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') {
const originalModel = monaco.editor.createModel(values.value, '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({
original: originalModel,
modified: modifiedModel,
});
if (position) {
vsDiffEditor?.setPosition(position);
vsDiffEditor?.focus();
// setAutoHeight 内部会在 nextTick 中将 scrollTop 重置为 0这里也放到 nextTick 中
// 利用 Vue nextTick 队列的 FIFO 特性,保证恢复在重置之后执行
if (viewState) {
nextTick(() => {
vsDiffEditor?.restoreViewState(viewState);
vsDiffEditor?.focus();
});
}
return result;
}
// 保存当前光标位置
const position = vsEditor?.getPosition();
// 保存视图状态(光标、选区、滚动、折叠等)
const viewState = vsEditor?.saveViewState();
const result = vsEditor?.setValue(values.value);
// 恢复光标位置
if (position) {
vsEditor?.setPosition(position);
vsEditor?.focus();
// setAutoHeight 内部会在 nextTick 中将 scrollTop 重置为 0这里也放到 nextTick 中
// 利用 Vue nextTick 队列的 FIFO 特性,保证恢复在重置之后执行
if (viewState) {
nextTick(() => {
vsEditor?.restoreViewState(viewState);
vsEditor?.focus();
});
}
return result;
};

View File

@@ -20,8 +20,8 @@ const {
vsEditorInstance: {
getValue: vi.fn(() => 'editor-value'),
setValue: vi.fn(),
getPosition: vi.fn(() => ({ lineNumber: 1, column: 1 })),
setPosition: vi.fn(),
saveViewState: vi.fn(() => null),
restoreViewState: vi.fn(),
focus: vi.fn(),
layout: vi.fn(),
setScrollTop: vi.fn(),
@@ -34,8 +34,8 @@ const {
} as any,
vsDiffEditorInstance: {
getModifiedEditor: vi.fn(),
getPosition: vi.fn(() => null),
setPosition: vi.fn(),
saveViewState: vi.fn(() => null),
restoreViewState: vi.fn(),
setModel: vi.fn(),
focus: vi.fn(),
layout: vi.fn(),
@@ -115,6 +115,9 @@ beforeEach(() => {
vsEditorInstance.onDidBlurEditorWidget.mockImplementation((cb: any) => {
blurHandlers.push(cb);
});
// 默认无视图状态,避免上一个用例 mockReturnValue 渗透
vsEditorInstance.saveViewState.mockReturnValue(null);
vsDiffEditorInstance.saveViewState.mockReturnValue(null);
const modifiedEditor = {
getValue: vi.fn(() => 'modified-value'),
onDidChangeModelContent: vi.fn((cb: any) => diffContentChangeHandlers.push(cb)),
@@ -231,6 +234,86 @@ describe('CodeEditor', () => {
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 () => {
vsEditorInstance.getValue.mockReturnValue('editor-value');
const wrapper = mount(CodeEditor, { props: { initValues: 'abc' } as any, attachTo: document.body });