(core) Scroll shift selection into view

Summary: Using the selection shortcuts will now scroll the selection into view.

Test Plan: Manual.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4055
This commit is contained in:
George Gevoian 2023-10-09 21:03:21 -04:00
parent f0ef93e9de
commit 988ab47376

View File

@ -134,23 +134,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
}));
this.autoDispose(this.cursor.fieldIndex.subscribe(idx => {
// If there are some frozen columns.
if (this.numFrozen.peek() && idx < this.numFrozen.peek()) { return; }
const offset = this.colRightOffsets.peek().getSumTo(idx);
const rowNumsWidth = this._cornerDom.clientWidth;
const viewWidth = this.scrollPane.clientWidth - rowNumsWidth;
const fieldWidth = this.colRightOffsets.peek().getValue(idx) + 1; // +1px border
// Left and right pixel edge of 'viewport', starting from edge of row nums.
const frozenWidth = this.frozenWidth.peek();
const leftEdge = this.scrollPane.scrollLeft + frozenWidth;
const rightEdge = leftEdge + (viewWidth - frozenWidth);
//If cell doesn't fit onscreen, scroll to fit
const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth);
this.scrollPane.scrollLeft = this.scrollPane.scrollLeft + scrollShift;
this._scrollColumnIntoView(idx);
}));
this.isPreview = isPreview;
@ -302,34 +286,14 @@ GridView.gridCommands = {
}
this.cursor.rowIndex(this.cursor.rowIndex() - 1);
},
shiftDown: function() {
this._shiftSelect(1, this.cellSelector.row.end, selector.COL, this.getLastDataRowIndex());
},
shiftUp: function() {
this._shiftSelect(-1, this.cellSelector.row.end, selector.COL, this.getLastDataRowIndex());
},
shiftRight: function() {
this._shiftSelect(1, this.cellSelector.col.end, selector.ROW,
this.viewSection.viewFields().peekLength - 1);
},
shiftLeft: function() {
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);
},
shiftDown: function() { this._shiftSelect({step: 1, direction: 'down'}); },
shiftUp: function() { this._shiftSelect({step: 1, direction: 'up'}); },
shiftRight: function() { this._shiftSelect({step: 1, direction: 'right'}); },
shiftLeft: function() { this._shiftSelect({step: 1, direction: 'left'}); },
ctrlShiftDown: function () { this._shiftSelectUntilFirstOrLastNonEmptyCell({direction: 'down'}); },
ctrlShiftUp: function () { this._shiftSelectUntilFirstOrLastNonEmptyCell({direction: 'up'}); },
ctrlShiftRight: function () { this._shiftSelectUntilFirstOrLastNonEmptyCell({direction: 'right'}); },
ctrlShiftLeft: function () { this._shiftSelectUntilFirstOrLastNonEmptyCell({direction: 'left'}); },
fillSelectionDown: function() { this.fillSelectionDown(); },
selectAll: function() { this.selectAll(); },
@ -401,81 +365,117 @@ GridView.prototype.onTableLoaded = function() {
/**
* Update the bounds of the cell selector's selected range for Shift+Direction keyboard shortcuts.
* @param {integer} step - amount to increase/decrease the select bound
* @param {Observable} selectObs - observable to change
* @exemptType {Selector type string} - selector type to noop on
IE: Shift + Up/Down should noop if columns are selected. And vice versa for rows.
* @param {integer} maxVal - maximum value allowed for the selectObs
**/
GridView.prototype._shiftSelect = function(step, selectObs, exemptType, maxVal) {
console.assert(exemptType === selector.ROW || exemptType === selector.COL);
if (this.cellSelector.isCurrentSelectType(exemptType)) return;
*/
GridView.prototype._shiftSelect = function({step, direction}) {
const type = ['up', 'down'].includes(direction) ? selector.ROW : selector.COL;
const exemptType = type === selector.ROW ? selector.COL : selector.ROW;
if (this.cellSelector.isCurrentSelectType(exemptType)) { return; }
if (this.cellSelector.isCurrentSelectType(selector.NONE)) {
this.cellSelector.currentSelectType(selector.CELL);
}
var newVal = gutil.clamp(selectObs() + step, 0, maxVal);
let selectObs;
let maxVal;
if (type === 'row') {
selectObs = this.cellSelector.row.end;
maxVal = this.getLastDataRowIndex();
} else {
selectObs = this.cellSelector.col.end;
maxVal = this.viewSection.viewFields().peekLength - 1;
}
step = ['up', 'left'].includes(direction) ? -step : step;
const newVal = gutil.clamp(selectObs() + step, 0, maxVal);
selectObs(newVal);
if (type === 'row') {
this.scrolly.scrollRowIntoView(newVal);
} else {
this._scrollColumnIntoView(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); }
/**
* Shifts the current selection in the specified `direction` until the first or last
* non-empty cell.
*
* If the current selection ends on an empty cell, the selection will be shifted to
* the first non-empty cell in the specified direction. Otherwise, the selection
* will be shifted to the last non-empty cell.
*/
GridView.prototype._shiftSelectUntilFirstOrLastNonEmptyCell = function({direction}) {
const steps = this._stepsToContent({direction});
if (steps > 0) { this._shiftSelect({step: steps, direction}); }
}
GridView.prototype._stepsToContent = function (type, direction, selection, maxVal) {
const {colEnd: colEnd, rowEnd: rowEnd} = selection;
let selectionData;
/**
* Gets the number of rows/columns until the first or last non-empty cell in the specified
* `direction`.
*/
GridView.prototype._stepsToContent = function ({direction}) {
const colEnd = this.cellSelector.col.end();
const rowEnd = this.cellSelector.row.end();
const cursorCol = this.cursor.fieldIndex();
const cursorRow = this.cursor.rowIndex();
const type = ['up', 'down'].includes(direction) ? selector.ROW : selector.COL;
const maxVal = type === selector.ROW
? this.getLastDataRowIndex()
: this.viewSection.viewFields().peekLength - 1;
if (type === selector.ROW && direction > 0) {
// Get table data for the current selection plus additional data in the specified `direction`.
let selectionData;
switch (direction) {
case 'right': {
if (colEnd + 1 > maxVal) { return 0; }
selectionData = this._selectionData({colStart: colEnd, colEnd: maxVal, rowStart: cursorRow, rowEnd: cursorRow});
} else if (type === selector.ROW && direction < 0) {
break;
}
case 'left': {
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) {
break;
}
case 'up': {
if (rowEnd - 1 > maxVal) { return 0; }
selectionData = this._selectionData({colStart: cursorCol, colEnd: cursorCol, rowStart: 0, rowEnd});
break;
}
case 'down': {
if (rowEnd + 1 > maxVal) { return 0; }
selectionData = this._selectionData({colStart: cursorCol, colEnd: cursorCol, rowStart: rowEnd, rowEnd: maxVal});
break;
}
}
const {fields, rowIndices} = selectionData;
if (type === selector.ROW && direction < 0) {
if (direction === 'left') {
// When moving selection left, we step through fields in reverse order.
fields.reverse();
}
if (type === selector.COL && direction < 0) {
if (direction === 'up') {
// When moving selection up, we step through rows in reverse order.
rowIndices.reverse();
}
// Prepare a map of field indexes to their respective column values. We'll consult these
// values below when looking for the first (or last) non-empty cell value in the direction
// of the new selection.
const colValuesByIndex = {};
for (const field of fields) {
const displayColId = field.displayColModel.peek().colId.peek();
colValuesByIndex[field._index()] = this.tableModel.tableData.getColValues(displayColId);
}
// Count the number of steps until the first or last non-empty cell.
let steps = 0;
if (type === selector.ROW) {
if (type === selector.COL) {
// The selection is changing on the x-axis (i.e. the selected columns changed).
const rowIndex = rowIndices[0];
const isLastColEmpty = this._isCellValueEmpty(colValuesByIndex[colEnd][rowIndex]);
const isNextColEmpty = this._isCellValueEmpty(colValuesByIndex[colEnd + direction][rowIndex]);
const isNextColEmpty = this._isCellValueEmpty(
colValuesByIndex[colEnd + (direction === 'right' ? 1 : -1)][rowIndex]);
const shouldStopOnEmptyValue = !isLastColEmpty && !isNextColEmpty;
for (let i = 1; i < fields.length; i++) {
const hasEmptyValues = this._isCellValueEmpty(colValuesByIndex[fields[i]._index()][rowIndex]);
@ -488,6 +488,7 @@ GridView.prototype._stepsToContent = function (type, direction, selection, maxVa
steps += 1;
}
} else {
// The selection is changing on the y-axis (i.e. the selected rows changed).
const colValues = colValuesByIndex[fields[0]._index()];
const isLastRowEmpty = this._isCellValueEmpty(colValues[rowIndices[0]]);
const isNextRowEmpty = this._isCellValueEmpty(colValues[rowIndices[1]]);
@ -1952,6 +1953,26 @@ GridView.prototype._showTooltipOnHover = function(field, isShowingTooltip) {
];
};
GridView.prototype._scrollColumnIntoView = function(colIndex) {
// If there are some frozen columns.
if (this.numFrozen.peek() && colIndex < this.numFrozen.peek()) { return; }
const offset = this.colRightOffsets.peek().getSumTo(colIndex);
const rowNumsWidth = this._cornerDom.clientWidth;
const viewWidth = this.scrollPane.clientWidth - rowNumsWidth;
const fieldWidth = this.colRightOffsets.peek().getValue(colIndex) + 1; // +1px border
// Left and right pixel edge of 'viewport', starting from edge of row nums.
const frozenWidth = this.frozenWidth.peek();
const leftEdge = this.scrollPane.scrollLeft + frozenWidth;
const rightEdge = leftEdge + (viewWidth - frozenWidth);
// If cell doesn't fit onscreen, scroll to fit.
const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth);
this.scrollPane.scrollLeft = this.scrollPane.scrollLeft + scrollShift;
}
function buildStyleOption(owner, computedRule, optionName) {
return ko.computed(() => {
if (owner.isDisposed()) { return null; }