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