From f1aedc4ce7f93dd07cb4b7b3c1d39e459b504176 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Mon, 18 May 2026 12:09:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E4=BF=AE=E5=A4=8D=20CodeEditor?= =?UTF-8?q?=20setValue=20=E6=97=B6=E6=BB=9A=E5=8A=A8=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=8E=E6=8A=98=E5=8F=A0=E7=AD=89=E8=A7=86=E5=9B=BE=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用 saveViewState/restoreViewState 替代 getPosition/setPosition,并放到 nextTick 中执行,避免被 setAutoHeight 的 setScrollTop(0) 覆盖,导致光标 位置变化时编辑器滚动跳回顶部。 Co-authored-by: Cursor --- packages/editor/src/layouts/CodeEditor.vue | 28 ++++-- .../tests/unit/layouts/CodeEditor.spec.ts | 91 ++++++++++++++++++- 2 files changed, 105 insertions(+), 14 deletions(-) diff --git a/packages/editor/src/layouts/CodeEditor.vue b/packages/editor/src/layouts/CodeEditor.vue index b0f22207..1592aaa5 100644 --- a/packages/editor/src/layouts/CodeEditor.vue +++ b/packages/editor/src/layouts/CodeEditor.vue @@ -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; }; diff --git a/packages/editor/tests/unit/layouts/CodeEditor.spec.ts b/packages/editor/tests/unit/layouts/CodeEditor.spec.ts index bd66b023..3399c6ee 100644 --- a/packages/editor/tests/unit/layouts/CodeEditor.spec.ts +++ b/packages/editor/tests/unit/layouts/CodeEditor.spec.ts @@ -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 });