diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts index e7bf054ef73..9c75100e40b 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -40,6 +40,7 @@ class SelectionPlugin implements PluginWithState { private disposer: (() => void) | null = null; private isSafari = false; private isMac = false; + private scrollTopCache: number = 0; constructor(options: EditorOptions) { this.state = { @@ -113,6 +114,12 @@ class SelectionPlugin implements PluginWithState { case 'contentChanged': this.state.tableSelection = null; break; + + case 'scroll': + if (!this.editor.hasFocus()) { + this.scrollTopCache = event.scrollContainer.scrollTop; + } + break; } } @@ -521,11 +528,21 @@ class SelectionPlugin implements PluginWithState { // Editor is focused, now we can get live selection. So no need to keep a selection if the selection type is range. this.state.selection = null; } + + if (this.scrollTopCache && this.editor) { + const sc = this.editor.getScrollContainer(); + sc.scrollTop = this.scrollTopCache; + this.scrollTopCache = 0; + } }; private onBlur = () => { - if (!this.state.selection && this.editor) { - this.state.selection = this.editor.getDOMSelection(); + if (this.editor) { + if (!this.state.selection) { + this.state.selection = this.editor.getDOMSelection(); + } + const sc = this.editor.getScrollContainer(); + this.scrollTopCache = sc.scrollTop; } }; diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts index 65e44345e26..4b3501d1093 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -87,6 +87,7 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { let setDOMSelectionSpy: jasmine.Spy; let removeEventListenerSpy: jasmine.Spy; let addEventListenerSpy: jasmine.Spy; + let getScrollContainerSpy: jasmine.Spy; let editor: IEditor; @@ -100,6 +101,7 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { addEventListener: addEventListenerSpy, }); setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + getScrollContainerSpy = jasmine.createSpy('getScrollContainer'); plugin = createSelectionPlugin({}); @@ -113,6 +115,7 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { }, getElementAtCursor: getElementAtCursorSpy, setDOMSelection: setDOMSelectionSpy, + getScrollContainer: getScrollContainerSpy, }); plugin.initialize(editor); }); @@ -152,6 +155,109 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { tableSelection: null, }); }); + + it('Trigger onFocusEvent, use cached scrollTop', () => { + const scMock: any = {}; + const scrollTop = 5; + getScrollContainerSpy.and.returnValue(scMock); + (plugin as any).scrollTopCache = scrollTop; + + eventMap.focus.beforeDispatch(); + + expect(scMock.scrollTop).toEqual(scrollTop); + expect((plugin as any).scrollTopCache).toEqual(0); + }); + + it('onBlur cache scrollTop', () => { + const scrollTop = 5; + const scMock: any = { scrollTop }; + getScrollContainerSpy.and.returnValue(scMock); + plugin.getState().selection = true; + + eventMap.blur.beforeDispatch(); + + expect((plugin as any).scrollTopCache).toEqual(scrollTop); + }); +}); + +describe('SelectionPlugin scroll event ', () => { + let plugin: PluginWithState; + let triggerEvent: jasmine.Spy; + let getElementAtCursorSpy: jasmine.Spy; + let getDocumentSpy: jasmine.Spy; + let setDOMSelectionSpy: jasmine.Spy; + let removeEventListenerSpy: jasmine.Spy; + let addEventListenerSpy: jasmine.Spy; + let getScrollContainerSpy: jasmine.Spy; + let hasFocusSpy: jasmine.Spy; + + let editor: IEditor; + + beforeEach(() => { + triggerEvent = jasmine.createSpy('triggerEvent'); + getElementAtCursorSpy = jasmine.createSpy('getElementAtCursor'); + removeEventListenerSpy = jasmine.createSpy('removeEventListener'); + addEventListenerSpy = jasmine.createSpy('addEventListener'); + getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ + removeEventListener: removeEventListenerSpy, + addEventListener: addEventListenerSpy, + }); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + getScrollContainerSpy = jasmine.createSpy('getScrollContainer'); + hasFocusSpy = jasmine.createSpy('hasFocus'); + + plugin = createSelectionPlugin({}); + + editor = ({ + getDocument: getDocumentSpy, + triggerEvent, + getEnvironment: () => ({}), + attachDomEvent: () => { + return jasmine.createSpy('disposer'); + }, + getElementAtCursor: getElementAtCursorSpy, + setDOMSelection: setDOMSelectionSpy, + getScrollContainer: getScrollContainerSpy, + hasFocus: hasFocusSpy, + }); + plugin.initialize(editor); + }); + + afterEach(() => { + plugin.dispose(); + }); + + it('Cache scrollTop', () => { + hasFocusSpy.and.returnValue(false); + const scrollTop = 5; + const scMock: any = { scrollTop }; + getScrollContainerSpy.and.returnValue(scMock); + (plugin as any).scrollTopCache = undefined; + + plugin.onPluginEvent?.({ + eventType: 'scroll', + rawEvent: {}, + scrollContainer: scMock, + }); + + expect((plugin as any).scrollTopCache).toEqual(scrollTop); + }); + + it('Do not cache scrollTop', () => { + hasFocusSpy.and.returnValue(true); + const scrollTop = 5; + const scMock: any = { scrollTop }; + getScrollContainerSpy.and.returnValue(scMock); + (plugin as any).scrollTopCache = undefined; + + plugin.onPluginEvent?.({ + eventType: 'scroll', + rawEvent: {}, + scrollContainer: scMock, + }); + + expect((plugin as any).scrollTopCache).toEqual(undefined); + }); }); describe('SelectionPlugin handle image selection', () => {