diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 1e567508..b0517bbf 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -316,6 +316,20 @@ GridView.gridCommands = { this._shiftSelect(-1, this.cellSelector.col.end, selector.ROW, this.viewSection.viewFields().peekLength - 1); }, + ctrlShiftDown: function () { + this._shiftSelectUntilContent(selector.COL, 1, this.cellSelector.row.end, this.getLastDataRowIndex()); + }, + ctrlShiftUp: function () { + this._shiftSelectUntilContent(selector.COL, -1, this.cellSelector.row.end, this.getLastDataRowIndex()); + }, + ctrlShiftRight: function () { + this._shiftSelectUntilContent(selector.ROW, 1, this.cellSelector.col.end, + this.viewSection.viewFields().peekLength - 1); + }, + ctrlShiftLeft: function () { + this._shiftSelectUntilContent(selector.ROW, -1, this.cellSelector.col.end, + this.viewSection.viewFields().peekLength - 1); + }, fillSelectionDown: function() { this.fillSelectionDown(); }, selectAll: function() { this.selectAll(); }, @@ -403,6 +417,120 @@ GridView.prototype._shiftSelect = function(step, selectObs, exemptType, maxVal) selectObs(newVal); }; +GridView.prototype._shiftSelectUntilContent = function(type, direction, selectObs, maxVal) { + const selection = { + colStart: this.cellSelector.col.start(), + colEnd: this.cellSelector.col.end(), + rowStart: this.cellSelector.row.start(), + rowEnd: this.cellSelector.row.end(), + }; + + const steps = this._stepsToContent(type, direction, selection, maxVal); + if (steps > 0) { this._shiftSelect(direction * steps, selectObs, type, maxVal); } +} + +GridView.prototype._stepsToContent = function (type, direction, selection, maxVal) { + const {colEnd: colEnd, rowEnd: rowEnd} = selection; + let selectionData; + + const cursorCol = this.cursor.fieldIndex(); + const cursorRow = this.cursor.rowIndex(); + + if (type === selector.ROW && direction > 0) { + if (colEnd + 1 > maxVal) { return 0; } + + selectionData = this._selectionData({colStart: colEnd, colEnd: maxVal, rowStart: cursorRow, rowEnd: cursorRow}); + } else if (type === selector.ROW && direction < 0) { + if (colEnd - 1 < 0) { return 0; } + + selectionData = this._selectionData({colStart: 0, colEnd, rowStart: cursorRow, rowEnd: cursorRow}); + } else if (type === selector.COL && direction > 0) { + if (rowEnd + 1 > maxVal) { return 0; } + + selectionData = this._selectionData({colStart: cursorCol, colEnd: cursorCol, rowStart: rowEnd, rowEnd: maxVal}); + } else if (type === selector.COL && direction < 0) { + if (rowEnd - 1 > maxVal) { return 0; } + + selectionData = this._selectionData({colStart: cursorCol, colEnd: cursorCol, rowStart: 0, rowEnd}); + } + + const {fields, rowIndices} = selectionData; + if (type === selector.ROW && direction < 0) { + // When moving selection left, we step through fields in reverse order. + fields.reverse(); + } + if (type === selector.COL && direction < 0) { + // When moving selection up, we step through rows in reverse order. + rowIndices.reverse(); + } + + const colValuesByIndex = {}; + for (const field of fields) { + const displayColId = field.displayColModel.peek().colId.peek(); + colValuesByIndex[field._index()] = this.tableModel.tableData.getColValues(displayColId); + } + + let steps = 0; + + if (type === selector.ROW) { + const rowIndex = rowIndices[0]; + const isLastColEmpty = this._isCellValueEmpty(colValuesByIndex[colEnd][rowIndex]); + const isNextColEmpty = this._isCellValueEmpty(colValuesByIndex[colEnd + direction][rowIndex]); + const shouldStopOnEmptyValue = !isLastColEmpty && !isNextColEmpty; + for (let i = 1; i < fields.length; i++) { + const hasEmptyValues = this._isCellValueEmpty(colValuesByIndex[fields[i]._index()][rowIndex]); + if (hasEmptyValues && shouldStopOnEmptyValue) { + return steps; + } else if (!hasEmptyValues && !shouldStopOnEmptyValue) { + return steps + 1; + } + + steps += 1; + } + } else { + const colValues = colValuesByIndex[fields[0]._index()]; + const isLastRowEmpty = this._isCellValueEmpty(colValues[rowIndices[0]]); + const isNextRowEmpty = this._isCellValueEmpty(colValues[rowIndices[1]]); + const shouldStopOnEmptyValue = !isLastRowEmpty && !isNextRowEmpty; + for (let i = 1; i < rowIndices.length; i++) { + const hasEmptyValues = this._isCellValueEmpty(colValues[rowIndices[i]]); + if (hasEmptyValues && shouldStopOnEmptyValue) { + return steps; + } else if (!hasEmptyValues && !shouldStopOnEmptyValue) { + return steps + 1; + } + + steps += 1; + } + } + + return steps; +} + +GridView.prototype._selectionData = function({colStart, colEnd, rowStart, rowEnd}) { + const fields = []; + for (let i = colStart; i <= colEnd; i++) { + const field = this.viewSection.viewFields().at(i); + if (!field) { continue; } + + fields.push(field); + } + + const rowIndices = []; + for (let i = rowStart; i <= rowEnd; i++) { + const rowId = this.viewData.getRowId(i); + if (!rowId) { continue; } + + rowIndices.push(this.tableModel.tableData.getRowIdIndex(rowId)); + } + + return {fields, rowIndices}; +} + +GridView.prototype._isCellValueEmpty = function(value) { + return value === null || value === undefined || value === '' || value === 'false'; +} + /** * Pastes the provided data at the current cursor. * @@ -532,15 +660,15 @@ GridView.prototype.fillSelectionDown = function() { /** - * Returns a GridSelection of the selected rows and cols + * Returns a CopySelection of the selected rows and cols * @returns {Object} CopySelection */ GridView.prototype.getSelection = function() { - var rowIds = [], fields = [], rowStyle = {}, colStyle = {}; - var colStart = this.cellSelector.colLower(); - var colEnd = this.cellSelector.colUpper(); - var rowStart = this.cellSelector.rowLower(); - var rowEnd = this.cellSelector.rowUpper(); + let rowIds = [], fields = [], rowStyle = {}, colStyle = {}; + let colStart = this.cellSelector.colLower(); + let colEnd = this.cellSelector.colUpper(); + let rowStart = this.cellSelector.rowLower(); + let rowEnd = this.cellSelector.rowUpper(); // If there is no selection, just copy/paste the cursor cell if (this.cellSelector.isCurrentSelectType(selector.NONE)) { diff --git a/app/client/components/commandList.ts b/app/client/components/commandList.ts index 65f03399..54a972df 100644 --- a/app/client/components/commandList.ts +++ b/app/client/components/commandList.ts @@ -50,6 +50,10 @@ export type CommandName = | 'shiftUp' | 'shiftRight' | 'shiftLeft' + | 'ctrlShiftDown' + | 'ctrlShiftUp' + | 'ctrlShiftRight' + | 'ctrlShiftLeft' | 'selectAll' | 'copyLink' | 'editField' @@ -373,6 +377,22 @@ export const groups: CommendGroupDef[] = [{ name: 'shiftLeft', keys: ['Shift+Left'], desc: 'Adds the element to the left of the cursor to the selected range' + }, { + name: 'ctrlShiftDown', + keys: ['Mod+Shift+Down'], + desc: 'Adds all elements below the cursor to the selected range' + }, { + name: 'ctrlShiftUp', + keys: ['Mod+Shift+Up'], + desc: 'Adds all elements above the cursor to the selected range' + }, { + name: 'ctrlShiftRight', + keys: ['Mod+Shift+Right'], + desc: 'Adds all elements to the right of the cursor to the selected range' + }, { + name: 'ctrlShiftLeft', + keys: ['Mod+Shift+Left'], + desc: 'Adds all elements to the left of the cursor to the selected range' }, { name: 'selectAll', keys: ['Mod+A'], diff --git a/test/fixtures/docs/ShiftSelection.grist b/test/fixtures/docs/ShiftSelection.grist new file mode 100644 index 00000000..083021d4 Binary files /dev/null and b/test/fixtures/docs/ShiftSelection.grist differ diff --git a/test/nbrowser/ShiftSelection.ts b/test/nbrowser/ShiftSelection.ts new file mode 100644 index 00000000..48576418 --- /dev/null +++ b/test/nbrowser/ShiftSelection.ts @@ -0,0 +1,213 @@ +import {assert, driver, Key, WebElement} from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import {setupTestSuite} from 'test/nbrowser/testUtils'; + +interface CellSelection { + /** 0-based column index. */ + colStart: number; + + /** 0-based column index. */ + colEnd: number; + + /** 0-based row index. */ + rowStart: number; + + /** 0-based row index. */ + rowEnd: number; +} + +interface SelectionRange { + /** 0-based index. */ + start: number; + + /** 0-based index. */ + end: number; +} + +describe('ShiftSelection', function () { + this.timeout(20000); + const cleanup = setupTestSuite(); + gu.bigScreen(); + + before(async function() { + const session = await gu.session().personalSite.login(); + await session.tempDoc(cleanup, 'ShiftSelection.grist'); + }); + + async function getSelectionRange(parent: WebElement, selector: string): Promise { + let start, end; + + let selectionActive = false; + const elements = await parent.findAll(selector); + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + const isSelected = await element.matches('.selected'); + + if (isSelected && !selectionActive) { + start = i; + selectionActive = true; + continue; + } + + if (!isSelected && selectionActive) { + end = i - 1; + break; + } + } + + if (start === undefined) { + return undefined; + } + + if (end === undefined) { + end = elements.length - 1; + } + + return { + start: start, + end: end, + }; + } + + async function getSelectedCells(): Promise { + const activeSection = await driver.find('.active_section'); + + const colSelection = await getSelectionRange(activeSection, '.column_names .column_name'); + if (!colSelection) { + return undefined; + } + + const rowSelection = await getSelectionRange(activeSection, '.gridview_row .gridview_data_row_num'); + if (!rowSelection) { + // Edge case if only a cell in the "new" row is selected + // Not relevant for our tests + return undefined; + } + + return { + colStart: colSelection.start, + colEnd: colSelection.end, + rowStart: rowSelection.start, + rowEnd: rowSelection.end, + }; + } + + async function assertCellSelection(expected: CellSelection|undefined) { + const currentSelection = await getSelectedCells(); + assert.deepEqual(currentSelection, expected); + } + + + it('Shift+Up extends the selection up', async function () { + await gu.getCell(1, 2).click(); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.UP)); + await assertCellSelection({colStart: 1, colEnd: 1, rowStart: 0, rowEnd: 1}); + }); + + it('Shift+Down extends the selection down', async function () { + await gu.getCell(1, 2).click(); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.DOWN)); + await assertCellSelection({colStart: 1, colEnd: 1, rowStart: 1, rowEnd: 2}); + }); + + it('Shift+Left extends the selection left', async function () { + await gu.getCell(1, 2).click(); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT)); + await assertCellSelection({colStart: 0, colEnd: 1, rowStart: 1, rowEnd: 1}); + }); + + it('Shift+Right extends the selection right', async function () { + await gu.getCell(1, 2).click(); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT)); + await assertCellSelection({colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 1}); + }); + + it('Shift+Right + Shift+Left leads to the initial selection', async function () { + await gu.getCell(1, 2).click(); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT)); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT)); + await assertCellSelection({colStart: 1, colEnd: 1, rowStart: 1, rowEnd: 1}); + }); + + it('Shift+Up + Shift+Down leads to the initial selection', async function () { + await gu.getCell(1, 2).click(); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.UP)); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.DOWN)); + await assertCellSelection({colStart: 1, colEnd: 1, rowStart: 1, rowEnd: 1}); + }); + + it('Ctrl+Shift+Up extends the selection blockwise up', async function () { + await gu.getCell(5, 7).click(); + + const ctrlKey = await gu.modKey(); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP)); + await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 6}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP)); + await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 2, rowEnd: 6}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP)); + await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 0, rowEnd: 6}); + }); + + it('Ctrl+Shift+Down extends the selection blockwise down', async function () { + await gu.getCell(5, 5).click(); + + const ctrlKey = await gu.modKey(); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.DOWN)); + await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 6}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.DOWN)); + await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 8}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.DOWN)); + await assertCellSelection({colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 10}); + }); + + it('Ctrl+Shift+Left extends the selection blockwise left', async function () { + await gu.getCell(6, 5).click(); + + const ctrlKey = await gu.modKey(); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.LEFT)); + await assertCellSelection({colStart: 4, colEnd: 6, rowStart: 4, rowEnd: 4}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.LEFT)); + await assertCellSelection({colStart: 2, colEnd: 6, rowStart: 4, rowEnd: 4}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.LEFT)); + await assertCellSelection({colStart: 0, colEnd: 6, rowStart: 4, rowEnd: 4}); + }); + + it('Ctrl+Shift+Right extends the selection blockwise right', async function () { + await gu.getCell(4, 5).click(); + + const ctrlKey = await gu.modKey(); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.RIGHT)); + await assertCellSelection({colStart: 4, colEnd: 6, rowStart: 4, rowEnd: 4}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.RIGHT)); + await assertCellSelection({colStart: 4, colEnd: 8, rowStart: 4, rowEnd: 4}); + + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.RIGHT)); + await assertCellSelection({colStart: 4, colEnd: 10, rowStart: 4, rowEnd: 4}); + }); + + it('Ctrl+Shift+* extends the selection until all the next cells are empty', async function () { + await gu.getCell(3, 7).click(); + + const ctrlKey = await gu.modKey(); + + await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT)); + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP)); + await assertCellSelection({colStart: 3, colEnd: 4, rowStart: 2, rowEnd: 6}); + + await gu.getCell(4, 7).click(); + await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT)); + await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP)); + await assertCellSelection({colStart: 3, colEnd: 4, rowStart: 4, rowEnd: 6}); + }); + });