HTML Tree: Multi-selection fixes

- Clarification between focused row and pivot:
  - Pivot is only the row from which shift-selection pivots
  - Focused row is the one with the border around it
- Fixed an issue where clicking the focused row didn't select it.
  Closes #2402
- Allows to create a non-contiguous range-selection with ctrl/cmd+shift.
  Closes #2403
This commit is contained in:
Adomas Venčkauskas 2022-03-14 12:58:49 +02:00
parent 3ec883a7f6
commit 12cd201b48
3 changed files with 55 additions and 71 deletions

View file

@ -59,7 +59,7 @@ class TreeSelection {
this._tree = tree;
Object.assign(this, {
pivot: 0,
_focused: 0,
focused: 0,
selected: new Set([]),
_selectEventsSuppressed: false
});
@ -92,12 +92,12 @@ class TreeSelection {
if (this.selectEventsSuppressed) return;
let previousPivot = this.pivot;
let previousFocused = this.focused;
this.pivot = index;
this._focused = index;
this.focused = index;
if (this._tree.invalidate) {
this._tree.invalidateRow(index);
this._tree.invalidateRow(previousPivot);
this._tree.invalidateRow(previousFocused);
}
this._updateTree(shouldDebounce);
}
@ -121,7 +121,7 @@ class TreeSelection {
select(index, shouldDebounce) {
if (!this._tree.props.isSelectable(index)) return;
index = Math.max(0, index);
if (this.selected.size == 1 && this._focused == index && this.pivot == index) {
if (this.selected.size == 1 && this.isSelected(index)) {
return false;
}
@ -129,7 +129,7 @@ class TreeSelection {
toInvalidate.add(index);
toInvalidate.add(this.pivot);
this.selected = new Set([index]);
this._focused = index;
this.focused = index;
this.pivot = index;
if (this.selectEventsSuppressed) return true;
@ -174,17 +174,22 @@ class TreeSelection {
/**
* Performs a shift-select from current pivot to provided index. Updates focused item to index.
* @param index {Number} The index is 0-clamped.
* @param augment {Boolean} Adds to existing selection if true
* @param shouldDebounce {Boolean} Whether the update to the tree should be debounced
*/
shiftSelect(index, shouldDebounce) {
shiftSelect(index, augment, shouldDebounce) {
if (!this._tree.props.isSelectable(index)) return;
index = Math.max(0, index);
let from = Math.min(index, this.pivot);
let to = Math.max(index, this.pivot);
this._focused = index;
let oldFocused = this.focused;
this.focused = index;
let oldSelected = this.selected;
this._rangedSelect(from, to);
if (augment) {
oldSelected = new Set(oldSelected);
}
this._rangedSelect(from, to, augment);
if (this.selectEventsSuppressed) return;
@ -199,6 +204,7 @@ class TreeSelection {
for (let index of oldSelected) {
this._tree.invalidateRow(index);
}
this._tree.invalidateRow(oldFocused);
}
this._updateTree(shouldDebounce);
}
@ -218,29 +224,6 @@ class TreeSelection {
return this.selected.size;
}
get focused() {
return this._focused;
}
set focused(index) {
index = Math.max(0, index);
let previousFocused = this._focused;
let previousPivot = this.pivot;
this.pivot = index;
this._focused = index;
if (this.selectEventsSuppressed) return;
this._updateTree();
if (this._tree.invalidate) {
this._tree.invalidateRow(previousFocused);
if (previousPivot != previousFocused) {
this._tree.invalidateRow(previousPivot);
}
this._tree.invalidateRow(index);
}
}
get selectEventsSuppressed() {
return this._selectEventsSuppressed;
}
@ -485,17 +468,17 @@ class VirtualizedTable extends React.Component {
* @param {Integer} direction - -1 for up, 1 for down
* @param {Boolean} selectTo
*/
_onJumpSelect(direction, selectTo) {
_onJumpSelect(direction, selectTo, toggleSelection) {
if (direction == 1) {
const lastVisible = this._jsWindow.getLastVisibleRow();
if (this.selection.focused != lastVisible) {
return this.onSelection(lastVisible, selectTo);
return this.onSelection(lastVisible, selectTo, toggleSelection);
}
}
else {
const firstVisible = this._jsWindow.getFirstVisibleRow();
if (this.selection.focused != firstVisible) {
return this.onSelection(firstVisible, selectTo);
return this.onSelection(firstVisible, selectTo, toggleSelection);
}
}
const height = document.getElementById(this._jsWindowID).clientHeight;
@ -504,7 +487,7 @@ class VirtualizedTable extends React.Component {
const rowCount = this.props.getRowCount();
destination = Math.min(destination, rowCount - 1);
destination = Math.max(0, destination);
return this.onSelection(destination, selectTo);
return this.onSelection(destination, selectTo, toggleSelection);
}
/**
@ -520,7 +503,8 @@ class VirtualizedTable extends React.Component {
if (e.altKey) return;
const shiftSelect = e.shiftKey;
const movePivot = Zotero.isMac ? e.metaKey : e.ctrlKey;
const moveFocused = Zotero.isMac ? e.metaKey : e.ctrlKey;
const toggleSelection = shiftSelect && moveFocused;
const rowCount = this.props.getRowCount();
switch (e.key) {
@ -530,7 +514,7 @@ class VirtualizedTable extends React.Component {
prevSelect--;
}
prevSelect = Math.max(0, prevSelect);
this.onSelection(prevSelect, shiftSelect, false, movePivot, e.repeat);
this.onSelection(prevSelect, shiftSelect, toggleSelection, moveFocused, e.repeat);
break;
case "ArrowDown":
@ -539,20 +523,20 @@ class VirtualizedTable extends React.Component {
nextSelect++;
}
nextSelect = Math.min(nextSelect, rowCount - 1);
this.onSelection(nextSelect, shiftSelect, false, movePivot, e.repeat);
this.onSelection(nextSelect, shiftSelect, toggleSelection, moveFocused, e.repeat);
break;
case "Home":
this.onSelection(0, shiftSelect, false, movePivot);
this.onSelection(0, shiftSelect, toggleSelection, moveFocused);
break;
case "End":
this.onSelection(rowCount - 1, shiftSelect, false, movePivot);
this.onSelection(rowCount - 1, shiftSelect, toggleSelection, moveFocused);
break;
case "PageUp":
if (!Zotero.isMac) {
this._onJumpSelect(-1, shiftSelect, e.repeat);
this._onJumpSelect(-1, shiftSelect, toggleSelection, e.repeat);
}
else {
this._jsWindow.scrollTo(this._jsWindow.scrollOffset - this._jsWindow.getWindowHeight() + this._rowHeight);
@ -561,7 +545,7 @@ class VirtualizedTable extends React.Component {
case "PageDown":
if (!Zotero.isMac) {
this._onJumpSelect(1, shiftSelect, e.repeat);
this._onJumpSelect(1, shiftSelect, toggleSelection, e.repeat);
}
else {
this._jsWindow.scrollTo(this._jsWindow.scrollOffset + this._jsWindow.getWindowHeight() - this._rowHeight);
@ -600,7 +584,7 @@ class VirtualizedTable extends React.Component {
this._handleTyping(e.key);
}
if (shiftSelect || movePivot) return;
if (shiftSelect || moveFocused) return;
switch (e.key) {
case "ArrowLeft":
@ -641,11 +625,11 @@ class VirtualizedTable extends React.Component {
}
const rowCount = this.props.getRowCount();
if (allSameChar) {
for (let i = this.selection.pivot + 1, checked = 0; checked < rowCount; i++, checked++) {
for (let i = this.selection.focused + 1, checked = 0; checked < rowCount; i++, checked++) {
i %= rowCount;
let rowString = this.props.getRowString(i);
if (rowString.toLowerCase().indexOf(char) == 0) {
if (i != this.selection.pivot) {
if (i != this.selection.focused) {
this.scrollToRow(i);
this.onSelection(i);
}
@ -657,7 +641,7 @@ class VirtualizedTable extends React.Component {
for (let i = 0; i < rowCount; i++) {
let rowString = this.props.getRowString(i);
if (rowString.toLowerCase().indexOf(this._typingString) == 0) {
if (i != this.selection.pivot) {
if (i != this.selection.focused) {
this.scrollToRow(i);
this.onSelection(i);
}
@ -696,13 +680,13 @@ class VirtualizedTable extends React.Component {
_handleMouseUp = async (e, index) => {
const shiftSelect = e.shiftKey;
const toggleSelection = e.ctrlKey || e.metaKey;
const augment = e.ctrlKey || e.metaKey;
if (this._isMouseDrag || e.button != 0) {
// other mouse buttons are ignored
this._isMouseDrag = false;
return;
}
this._onSelection(index, shiftSelect, toggleSelection);
this._onSelection(index, shiftSelect, augment);
this.focus();
}
@ -734,39 +718,39 @@ class VirtualizedTable extends React.Component {
* If true will select from focused up to index (does not update pivot)
* @param {Boolean} toggleSelection
* If true will add to selection
* @param {Boolean} movePivot
* Will move pivot without adding anything to the selection
* @param {Boolean} moveFocused
* Will move focus without adding anything to the selection
*/
_onSelection = (index, shiftSelect, toggleSelection, movePivot, shouldDebounce) => {
_onSelection = (index, shiftSelect, toggleSelection, moveFocused, shouldDebounce) => {
if (this.selection.selectEventsSuppressed) return;
if (movePivot) {
if (!this.props.multiSelect) return;
let previousPivot = this.selection.pivot;
this.selection._focused = index;
if (!this.props.multiSelect && (shiftSelect || toggleSelection || moveFocused)) {
return;
}
else if (shiftSelect) {
this.selection.shiftSelect(index, toggleSelection, shouldDebounce);
}
else if (toggleSelection) {
this.selection.toggleSelect(index, shouldDebounce);
}
else if (moveFocused) {
let previousFocused = this.selection.focused;
this.selection.focused = index;
this.selection.pivot = index;
this.invalidateRow(previousPivot);
this.invalidateRow(previousFocused);
this.invalidateRow(index);
}
// Normal selection
else if (!shiftSelect && !toggleSelection) {
else if (!toggleSelection) {
if (index > 0 && !this.props.isSelectable(index)) {
return;
}
this.selection.select(index, shouldDebounce);
}
// Range selection
else if (shiftSelect && this.props.multiSelect) {
this.selection.shiftSelect(index, shouldDebounce);
}
// If index is not selectable and this is not normal selection we return
else if (!this.props.isSelectable(index)) {
return;
}
// Additive selection
else if (this.props.multiSelect) {
this.selection.toggleSelect(index, shouldDebounce);
}
// None of the previous conditions were satisfied, so nothing changes
else {
return;
@ -1567,7 +1551,7 @@ function makeRowRenderer(getRowData) {
}
div.classList.toggle('selected', selection.isSelected(index));
div.classList.toggle('pivot', selection.pivot == index);
div.classList.toggle('focused', selection.focused == index);
const rowData = getRowData(index);
for (let column of columns) {

View file

@ -902,7 +902,7 @@ var ItemTree = class ItemTree extends LibraryTree {
*/
handleKeyUp = (event) => {
if (!Zotero.locked && event.code === 'Tab' && this.selection.count == 0) {
this.selection.select(this.selection.pivot);
this.selection.select(this.selection.focused);
}
};
@ -1011,7 +1011,7 @@ var ItemTree = class ItemTree extends LibraryTree {
this._getColumns();
this.selection.clearSelection();
this.selection.pivot = 0;
this.selection.focused = 0;
await this.refresh();
if (Zotero.CollectionTreeCache.error) {
return this.setItemsPaneMessage(Zotero.getString('pane.items.loadError'));
@ -2867,7 +2867,7 @@ var ItemTree = class ItemTree extends LibraryTree {
}
div.classList.toggle('selected', selection.isSelected(index));
div.classList.toggle('pivot', selection.pivot == index);
div.classList.toggle('focused', selection.focused == index);
div.classList.remove('drop', 'drop-before', 'drop-after');
const rowData = this._getRowData(index);
div.classList.toggle('context-row', !!rowData.contextRow);

View file

@ -138,7 +138,7 @@
.virtualized-table.multi-select:focus {
.row.pivot {
.row.focused {
border: 1px dotted highlight;
box-sizing: initial;
margin: -1px 0;