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 1ae8a8e47bf..fdbbc3aebd9 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -30,6 +30,7 @@ const Up = 'ArrowUp'; const Down = 'ArrowDown'; const Left = 'ArrowLeft'; const Right = 'ArrowRight'; +const Tab = 'Tab'; class SelectionPlugin implements PluginWithState { private editor: IEditor | null = null; @@ -132,8 +133,7 @@ class SelectionPlugin implements PluginWithState { return; } else if (selection?.type == 'image' && selection.image !== rawEvent.target) { - this.selectBeforeImage(editor, selection.image); - + this.selectBeforeOrAfterElement(editor, selection.image); return; } @@ -265,16 +265,16 @@ class SelectionPlugin implements PluginWithState { case 'image': if (!isModifierKey(rawEvent) && !rawEvent.shiftKey && selection.image.parentNode) { if (key === 'Escape') { - this.selectBeforeImage(editor, selection.image); + this.selectBeforeOrAfterElement(editor, selection.image); rawEvent.stopPropagation(); } else if (key !== 'Delete' && key !== 'Backspace') { - this.selectBeforeImage(editor, selection.image); + this.selectBeforeOrAfterElement(editor, selection.image); } } break; case 'range': - if (key == Up || key == Down || key == Left || key == Right) { + if (key == Up || key == Down || key == Left || key == Right || key == Tab) { const start = selection.range.startContainer; this.state.tableSelection = this.parseTableSelection( start, @@ -282,8 +282,10 @@ class SelectionPlugin implements PluginWithState { editor.getDOMHelper() ); + const rangeKey = key == Tab ? this.getTabKey(rawEvent) : key; + if (this.state.tableSelection) { - win?.requestAnimationFrame(() => this.handleSelectionInTable(key)); + win?.requestAnimationFrame(() => this.handleSelectionInTable(rangeKey)); } } break; @@ -316,7 +318,13 @@ class SelectionPlugin implements PluginWithState { } } - private handleSelectionInTable(key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight') { + private getTabKey(rawEvent: KeyboardEvent) { + return rawEvent.shiftKey ? 'TabLeft' : 'TabRight'; + } + + private handleSelectionInTable( + key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'TabLeft' | 'TabRight' + ) { if (!this.editor || !this.state.tableSelection) { return; } @@ -340,8 +348,8 @@ class SelectionPlugin implements PluginWithState { let lastCo = findCoordinate(tableSel?.parsedTable, end, domHelper); const { parsedTable, firstCo: oldCo, table } = this.state.tableSelection; - if (lastCo && tableSel.table == table && lastCo.col != oldCo.col) { - if (key == Up || key == Down) { + if (lastCo && tableSel.table == table) { + if (lastCo.col != oldCo.col && (key == Up || key == Down)) { const change = key == Up ? -1 : 1; const originalTd = findTableCellElement(parsedTable, oldCo)?.cell; let td: HTMLTableCellElement | null = null; @@ -359,24 +367,42 @@ class SelectionPlugin implements PluginWithState { } if (collapsed && td) { - const { node, offset } = normalizePos( + this.setRangeSelectionInTable( td, - key == Up ? td.childNodes.length : 0 - ); - const range = this.editor.getDocument().createRange(); - - range.setStart(node, offset); - range.collapse(true /*toStart*/); - - this.setDOMSelection( - { - type: 'range', - range, - isReverted: false, - }, - null /*tableSelection*/ + key == Up ? td.childNodes.length : 0, + this.editor ); } + } else if (key == 'TabLeft' || key == 'TabRight') { + const reverse = key == 'TabLeft'; + for ( + let step = reverse ? -1 : 1, + row = lastCo.row ?? 0, + col = (lastCo.col ?? 0) + step; + ; + col += step + ) { + if (col < 0 || col >= parsedTable[row].length) { + row += step; + if (row < 0) { + this.selectBeforeOrAfterElement(this.editor, tableSel.table); + break; + } else if (row >= parsedTable.length) { + this.selectBeforeOrAfterElement( + this.editor, + tableSel.table, + true /*after*/ + ); + break; + } + col = reverse ? parsedTable[row].length - 1 : 0; + } + const cell = parsedTable[row][col]; + if (typeof cell != 'string') { + this.setRangeSelectionInTable(cell, 0, this.editor); + break; + } + } } else { this.state.tableSelection = null; } @@ -389,6 +415,24 @@ class SelectionPlugin implements PluginWithState { } } + private setRangeSelectionInTable(cell: Node, nodeOffset: number, editor: IEditor) { + // Get deepest editable position in the cell + const { node, offset } = normalizePos(cell, nodeOffset); + + const range = editor.getDocument().createRange(); + range.setStart(node, offset); + range.collapse(true /*toStart*/); + + this.setDOMSelection( + { + type: 'range', + range, + isReverted: false, + }, + null /*tableSelection*/ + ); + } + private updateTableSelectionFromKeyboard(rowChange: number, colChange: number) { if (this.state.tableSelection?.lastCo && this.editor) { const { lastCo, parsedTable } = this.state.tableSelection; @@ -411,14 +455,14 @@ class SelectionPlugin implements PluginWithState { ); } - private selectBeforeImage(editor: IEditor, image: HTMLImageElement) { + private selectBeforeOrAfterElement(editor: IEditor, element: HTMLElement, after?: boolean) { const doc = editor.getDocument(); - const parent = image.parentNode; - const index = parent && toArray(parent.childNodes).indexOf(image); + const parent = element.parentNode; + const index = parent && toArray(parent.childNodes).indexOf(element); if (parent && index !== null && index >= 0) { const range = doc.createRange(); - range.setStart(parent, index); + range.setStart(parent, index + (after ? 1 : 0)); range.collapse(); this.setDOMSelection( 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 68e83fe8f95..1fc529ce6fb 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -1104,6 +1104,313 @@ describe('SelectionPlugin handle table selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); }); + it('From Range, Press Tab', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td1, + startOffset: 0, + endContainer: td1, + endOffset: 0, + commonAncestorContainer: tr1, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td1, + startOffset: 0, + endContainer: td1, + endOffset: 0, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + const setStartSpy = jasmine.createSpy('setStart'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + expect(setStartSpy).toHaveBeenCalledWith(td2, 0); + }); + + it('From Range, Press Shift+Tab', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + const setStartSpy = jasmine.createSpy('setStart'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + shiftKey: true, + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + expect(setStartSpy).toHaveBeenCalledWith(td1, 0); + }); + + it('From Range, Press Tab - Next Row', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + const setStartSpy = jasmine.createSpy('setStart'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + expect(setStartSpy).toHaveBeenCalledWith(td3, 0); + }); + + it('From Range, First cell - Press Shift+Tab', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td1, + startOffset: 0, + endContainer: td1, + endOffset: 0, + commonAncestorContainer: tr1, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td1, + startOffset: 0, + endContainer: td1, + endOffset: 0, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + const setStartSpy = jasmine.createSpy('setStart'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + shiftKey: true, + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + expect(setStartSpy).toHaveBeenCalledWith(table.parentNode, 0); + }); + + it('From Range, Last cell - Press Tab', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td4, + startOffset: 0, + endContainer: td4, + endOffset: 0, + commonAncestorContainer: tr2, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td4, + startOffset: 0, + endContainer: td4, + endOffset: 0, + commonAncestorContainer: tr2, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + const setStartSpy = jasmine.createSpy('setStart'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + expect(setStartSpy).toHaveBeenCalledWith(table.parentNode, 1); + }); + it('From Range, Press Down', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', @@ -1437,16 +1744,7 @@ describe('SelectionPlugin handle table selection', () => { expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); expect(plugin.getState()).toEqual({ selection: null, - tableSelection: { - table, - parsedTable: [ - [td1, td2], - [td3, td4], - ], - firstCo: { row: 0, col: 1 }, - lastCo: { row: 1, col: 1 }, - startNode: td2, - }, + tableSelection: null, imageSelectionBorderColor: undefined, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1);