Web Inspector: Table selection should be handled by a SelectionController
authormattbaker@apple.com <mattbaker@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Tue, 27 Nov 2018 19:41:17 +0000 (19:41 +0000)
committermattbaker@apple.com <mattbaker@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Tue, 27 Nov 2018 19:41:17 +0000 (19:41 +0000)
https://bugs.webkit.org/show_bug.cgi?id=191977
<rdar://problem/46253093>

Reviewed by Devin Rousso.

Add a SelectionController class, which manages an IndexSet of selected
items, and provides operations for adding and removing items from the
selection. Complex behaviors such as shift-clicking to select a range of
items, and updating the selection using the keyboard, are forwarded to
the controller using special-purpose methods that accept DOM Event objects.

* UserInterface/Base/Utilities.js:

* UserInterface/Controllers/SelectionController.js: Added.
(WI.SelectionController):
(WI.SelectionController.prototype.get delegate):
(WI.SelectionController.prototype.get lastSelectedItem):
(WI.SelectionController.prototype.get selectedItems):
(WI.SelectionController.prototype.get allowsMultipleSelection):
(WI.SelectionController.prototype.set allowsMultipleSelection):
(WI.SelectionController.prototype.get numberOfItems):
(WI.SelectionController.prototype.hasSelectedItem):
(WI.SelectionController.prototype.selectItem):
(WI.SelectionController.prototype.deselectItem):
(WI.SelectionController.prototype.selectAll):
(WI.SelectionController.prototype.deselectAll):
(WI.SelectionController.prototype.removeSelectedItems):
(WI.SelectionController.prototype.reset):
(WI.SelectionController.prototype.didRemoveItem):
(WI.SelectionController.prototype.handleKeyDown):
(WI.SelectionController.prototype.handleItemMouseDown.normalizeRange):
(WI.SelectionController.prototype.handleItemMouseDown):
(WI.SelectionController.prototype._deselectAllAndSelect):
(WI.SelectionController.prototype._selectItemsFromArrowKey):
(WI.SelectionController.prototype._nextSelectableIndex):
(WI.SelectionController.prototype._previousSelectableIndex):
(WI.SelectionController.prototype._updateSelectedItems):

* UserInterface/Main.html:
* UserInterface/Test.html:

* UserInterface/Views/Table.js:
(WI.Table):
(WI.Table.prototype.get selectedRow):
(WI.Table.prototype.get selectedRows):
(WI.Table.prototype.get allowsMultipleSelection):
(WI.Table.prototype.set allowsMultipleSelection):
(WI.Table.prototype.isRowSelected):
(WI.Table.prototype.reloadData):
(WI.Table.prototype.selectRow):
(WI.Table.prototype.deselectRow):
(WI.Table.prototype.selectAll):
(WI.Table.prototype.deselectAll):
(WI.Table.prototype.removeRow):
(WI.Table.prototype.removeSelectedRows):
(WI.Table.prototype.selectionControllerSelectionDidChange):
(WI.Table.prototype.selectionControllerNumberOfItems):
(WI.Table.prototype.selectionControllerNextSelectableIndex):
(WI.Table.prototype.selectionControllerPreviousSelectableIndex):
(WI.Table.prototype._handleKeyDown):
(WI.Table.prototype._handleMouseDown):
(WI.Table.prototype._removeRows):
(WI.Table.prototype._toggleSelectedRowStyle):
(WI.Table.prototype._selectRowsFromArrowKey): Deleted.
(WI.Table.prototype._handleMouseDown.normalizeRange): Deleted.
(WI.Table.prototype._deselectAllAndSelect): Deleted.
(WI.Table.prototype._notifySelectionDidChange): Deleted.
(WI.Table.prototype._updateSelectedRows): Deleted.

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@238563 268f45cc-cd09-0410-ab3c-d52691b4dbfc

Source/WebInspectorUI/ChangeLog
Source/WebInspectorUI/UserInterface/Base/Utilities.js
Source/WebInspectorUI/UserInterface/Controllers/SelectionController.js [new file with mode: 0644]
Source/WebInspectorUI/UserInterface/Main.html
Source/WebInspectorUI/UserInterface/Test.html
Source/WebInspectorUI/UserInterface/Views/Table.js

index d2e0006..b012682 100644 (file)
@@ -1,3 +1,75 @@
+2018-11-27  Matt Baker  <mattbaker@apple.com>
+
+        Web Inspector: Table selection should be handled by a SelectionController
+        https://bugs.webkit.org/show_bug.cgi?id=191977
+        <rdar://problem/46253093>
+
+        Reviewed by Devin Rousso.
+
+        Add a SelectionController class, which manages an IndexSet of selected
+        items, and provides operations for adding and removing items from the
+        selection. Complex behaviors such as shift-clicking to select a range of
+        items, and updating the selection using the keyboard, are forwarded to
+        the controller using special-purpose methods that accept DOM Event objects.
+
+        * UserInterface/Base/Utilities.js:
+
+        * UserInterface/Controllers/SelectionController.js: Added.
+        (WI.SelectionController):
+        (WI.SelectionController.prototype.get delegate):
+        (WI.SelectionController.prototype.get lastSelectedItem):
+        (WI.SelectionController.prototype.get selectedItems):
+        (WI.SelectionController.prototype.get allowsMultipleSelection):
+        (WI.SelectionController.prototype.set allowsMultipleSelection):
+        (WI.SelectionController.prototype.get numberOfItems):
+        (WI.SelectionController.prototype.hasSelectedItem):
+        (WI.SelectionController.prototype.selectItem):
+        (WI.SelectionController.prototype.deselectItem):
+        (WI.SelectionController.prototype.selectAll):
+        (WI.SelectionController.prototype.deselectAll):
+        (WI.SelectionController.prototype.removeSelectedItems):
+        (WI.SelectionController.prototype.reset):
+        (WI.SelectionController.prototype.didRemoveItem):
+        (WI.SelectionController.prototype.handleKeyDown):
+        (WI.SelectionController.prototype.handleItemMouseDown.normalizeRange):
+        (WI.SelectionController.prototype.handleItemMouseDown):
+        (WI.SelectionController.prototype._deselectAllAndSelect):
+        (WI.SelectionController.prototype._selectItemsFromArrowKey):
+        (WI.SelectionController.prototype._nextSelectableIndex):
+        (WI.SelectionController.prototype._previousSelectableIndex):
+        (WI.SelectionController.prototype._updateSelectedItems):
+
+        * UserInterface/Main.html:
+        * UserInterface/Test.html:
+
+        * UserInterface/Views/Table.js:
+        (WI.Table):
+        (WI.Table.prototype.get selectedRow):
+        (WI.Table.prototype.get selectedRows):
+        (WI.Table.prototype.get allowsMultipleSelection):
+        (WI.Table.prototype.set allowsMultipleSelection):
+        (WI.Table.prototype.isRowSelected):
+        (WI.Table.prototype.reloadData):
+        (WI.Table.prototype.selectRow):
+        (WI.Table.prototype.deselectRow):
+        (WI.Table.prototype.selectAll):
+        (WI.Table.prototype.deselectAll):
+        (WI.Table.prototype.removeRow):
+        (WI.Table.prototype.removeSelectedRows):
+        (WI.Table.prototype.selectionControllerSelectionDidChange):
+        (WI.Table.prototype.selectionControllerNumberOfItems):
+        (WI.Table.prototype.selectionControllerNextSelectableIndex):
+        (WI.Table.prototype.selectionControllerPreviousSelectableIndex):
+        (WI.Table.prototype._handleKeyDown):
+        (WI.Table.prototype._handleMouseDown):
+        (WI.Table.prototype._removeRows):
+        (WI.Table.prototype._toggleSelectedRowStyle):
+        (WI.Table.prototype._selectRowsFromArrowKey): Deleted.
+        (WI.Table.prototype._handleMouseDown.normalizeRange): Deleted.
+        (WI.Table.prototype._deselectAllAndSelect): Deleted.
+        (WI.Table.prototype._notifySelectionDidChange): Deleted.
+        (WI.Table.prototype._updateSelectedRows): Deleted.
+
 2018-11-26  Devin Rousso  <drousso@apple.com>
 
         Web Inspector: "No Filter Results" in navigation sidebar should have a button to clear filters
index c222a05..94f5a6a 100644 (file)
@@ -430,6 +430,14 @@ Object.defineProperty(KeyboardEvent.prototype, "commandOrControlKey",
     }
 });
 
+Object.defineProperty(MouseEvent.prototype, "commandOrControlKey",
+{
+    get()
+    {
+        return WI.Platform.name === "mac" ? this.metaKey : this.ctrlKey;
+    }
+});
+
 Object.defineProperty(Array, "isTypedArray",
 {
     value(array)
diff --git a/Source/WebInspectorUI/UserInterface/Controllers/SelectionController.js b/Source/WebInspectorUI/UserInterface/Controllers/SelectionController.js
new file mode 100644 (file)
index 0000000..ab2417b
--- /dev/null
@@ -0,0 +1,375 @@
+/*
+ * Copyright (C) 2018 Apple Inc. All Rights Reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ *    notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+ * THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+WI.SelectionController = class SelectionController extends WI.Object
+{
+    constructor(delegate)
+    {
+        super();
+
+        console.assert(delegate);
+        this._delegate = delegate;
+
+        this._allowsMultipleSelection = false;
+        this._lastSelectedIndex = NaN;
+        this._shiftAnchorIndex = NaN;
+        this._selectedIndexes = new WI.IndexSet;
+
+        console.assert(this._delegate.selectionControllerNumberOfItems, "SelectionController delegate must implement selectionControllerNumberOfItems.");
+        console.assert(this._delegate.selectionControllerNextSelectableIndex, "SelectionController delegate must implement selectionControllerNextSelectableIndex.");
+        console.assert(this._delegate.selectionControllerPreviousSelectableIndex, "SelectionController delegate must implement selectionControllerPreviousSelectableIndex.");
+    }
+
+    // Public
+
+    get delegate() { return this._delegate; }
+    get lastSelectedItem() { return this._lastSelectedIndex; }
+    get selectedItems() { return this._selectedIndexes; }
+
+    get allowsMultipleSelection()
+    {
+        return this._allowsMultipleSelection;
+    }
+
+    set allowsMultipleSelection(flag)
+    {
+        if (this._allowsMultipleSelection === flag)
+            return;
+
+        this._allowsMultipleSelection = flag;
+        if (this._allowsMultipleSelection)
+            return;
+
+        if (this._selectedIndexes.size > 1) {
+            console.assert(this._lastSelectedIndex >= 0);
+            this._updateSelectedItems(new WI.IndexSet([this._lastSelectedIndex]));
+        }
+    }
+
+    get numberOfItems()
+    {
+        return this._delegate.selectionControllerNumberOfItems(this);
+    }
+
+    hasSelectedItem(index)
+    {
+        return this._selectedIndexes.has(index);
+    }
+
+    selectItem(index, extendSelection = false)
+    {
+        console.assert(!extendSelection || this._allowsMultipleSelection, "Cannot extend selection with multiple selection disabled.");
+        console.assert(index >= 0 && index < this.numberOfItems);
+
+        if (this.hasSelectedItem(index)) {
+            if (!extendSelection)
+                this._deselectAllAndSelect(index);
+            return;
+        }
+
+        let newSelectedItems = extendSelection ? this._selectedIndexes.copy() : new WI.IndexSet;
+        newSelectedItems.add(index);
+
+        this._shiftAnchorIndex = NaN;
+        this._lastSelectedIndex = index;
+
+        this._updateSelectedItems(newSelectedItems);
+    }
+
+    deselectItem(index)
+    {
+        console.assert(index >= 0 && index < this.numberOfItems);
+
+        if (!this.hasSelectedItem(index))
+            return;
+
+        let newSelectedItems = this._selectedIndexes.copy();
+        newSelectedItems.delete(index);
+
+        if (this._shiftAnchorIndex === index)
+            this._shiftAnchorIndex = NaN;
+
+        if (this._lastSelectedIndex === index) {
+            this._lastSelectedIndex = NaN;
+            if (newSelectedItems.size) {
+                // Find selected item closest to deselected item.
+                let preceding = newSelectedItems.indexLessThan(index);
+                let following = newSelectedItems.indexGreaterThan(index);
+
+                if (isNaN(preceding))
+                    this._lastSelectedIndex = following;
+                else if (isNaN(following))
+                    this._lastSelectedIndex = preceding;
+                else {
+                    if ((following - index) < (index - preceding))
+                        this._lastSelectedIndex = following;
+                    else
+                        this._lastSelectedIndex = preceding;
+                }
+            }
+        }
+
+        this._updateSelectedItems(newSelectedItems);
+    }
+
+    selectAll()
+    {
+        if (!this.numberOfItems || !this._allowsMultipleSelection)
+            return;
+
+        if (this._selectedIndexes.size === this.numberOfItems)
+            return;
+
+        let newSelectedItems = new WI.IndexSet;
+        newSelectedItems.addRange(0, this.numberOfItems);
+
+        this._lastSelectedIndex = newSelectedItems.lastIndex;
+        if (isNaN(this._shiftAnchorIndex))
+            this._shiftAnchorIndex = this._lastSelectedIndex;
+
+        this._updateSelectedItems(newSelectedItems);
+    }
+
+    deselectAll()
+    {
+        const index = NaN;
+        this._deselectAllAndSelect(index);
+    }
+
+    removeSelectedItems()
+    {
+        let numberOfSelectedItems = this._selectedIndexes.size;
+        if (!numberOfSelectedItems)
+            return;
+
+        // Try selecting the item following the selection.
+        let lastSelectedIndex = this._selectedIndexes.lastIndex;
+        let indexToSelect = lastSelectedIndex + 1;
+        if (indexToSelect === this.numberOfItems) {
+            // If no item exists after the last item in the selection, try selecting
+            // a deselected item (hole) within the selection.
+            let firstSelectedIndex = this._selectedIndexes.firstIndex;
+            if (lastSelectedIndex - firstSelectedIndex > numberOfSelectedItems) {
+                indexToSelect = this._selectedIndexes.firstIndex + 1;
+                while (this._selectedIndexes.has(indexToSelect))
+                    indexToSelect++;
+            } else {
+                // If the selection contains no holes, try selecting the item
+                // preceding the selection.
+                indexToSelect = firstSelectedIndex > 0 ? firstSelectedIndex - 1 : NaN;
+            }
+        }
+
+        this._deselectAllAndSelect(indexToSelect);
+    }
+
+    reset()
+    {
+        this._shiftAnchorIndex = NaN;
+        this._lastSelectedIndex = NaN;
+        this._selectedIndexes.clear();
+    }
+
+    didRemoveItem(index)
+    {
+        if (this.hasSelectedItem(index))
+            this.deselectItem(index);
+
+        while (index = this._selectedIndexes.indexGreaterThan(index)) {
+            this._selectedIndexes.delete(index);
+            this._selectedIndexes.add(index - 1);
+        }
+    }
+
+    handleKeyDown(event)
+    {
+        if (!this.numberOfItems)
+            return false;
+
+        if (event.key === "a" && event.commandOrControlKey()) {
+            this.selectAll();
+            return true;
+        }
+
+        if (event.metaKey || event.ctrlKey)
+            return false;
+
+        if (event.keyIdentifier === "Up" || event.keyIdentifier === "Down") {
+            this._selectItemsFromArrowKey(event.keyIdentifier === "Up", event.shiftKey);
+
+            event.preventDefault();
+            event.stopPropagation();
+            return true;
+        }
+
+        return false;
+    }
+
+    handleItemMouseDown(index, event)
+    {
+        if (event.button !== 0 || event.ctrlKey)
+            return;
+
+        // Command (macOS) or Control (Windows) key takes precedence over shift
+        // whether or not multiple selection is enabled, so handle it first.
+        if (event.commandOrControlKey) {
+            if (this.hasSelectedItem(index))
+                this.deselectItem(index);
+            else
+                this.selectItem(index, this._allowsMultipleSelection);
+            return;
+        }
+
+        let shiftExtendSelection = this._allowsMultipleSelection && event.shiftKey;
+        if (!shiftExtendSelection) {
+            this.selectItem(index);
+            return;
+        }
+
+        let newSelectedItems = this._selectedIndexes.copy();
+
+        // Shift-clicking when nothing is selected should cause the first item
+        // through the clicked item to be selected.
+        if (!newSelectedItems.size) {
+            this._shiftAnchorIndex = 0;
+            this._lastSelectedIndex = index;
+            newSelectedItems.addRange(0, index + 1);
+            this._updateSelectedItems(newSelectedItems);
+            return;
+        }
+
+        if (isNaN(this._shiftAnchorIndex))
+            this._shiftAnchorIndex = this._lastSelectedIndex;
+
+        // Shift-clicking will add to or delete from the current selection, or
+        // pivot the selection around the anchor (a delete followed by an add).
+        // We could check for all three cases, and add or delete only those items
+        // that are necessary, but it is simpler to throw out the previous shift-
+        // selected range and add the new range between the anchor and clicked item.
+
+        function normalizeRange(startIndex, endIndex) {
+            return startIndex > endIndex ? [endIndex, startIndex] : [startIndex, endIndex];
+        }
+
+        if (this._shiftAnchorIndex !== this._lastSelectedIndex) {
+            let [startIndex, endIndex] = normalizeRange(this._shiftAnchorIndex, this._lastSelectedIndex);
+            newSelectedItems.deleteRange(startIndex, endIndex - startIndex + 1);
+        }
+
+        let [startIndex, endIndex] = normalizeRange(this._shiftAnchorIndex, index);
+        newSelectedItems.addRange(startIndex, endIndex - startIndex + 1);
+
+        this._lastSelectedIndex = index;
+
+        this._updateSelectedItems(newSelectedItems);
+    }
+
+    // Private
+
+    _deselectAllAndSelect(index)
+    {
+        if (!this._selectedIndexes.size)
+            return;
+
+        if (this._selectedIndexes.size === 1 && this._selectedIndexes.firstIndex === index)
+            return;
+
+        this._shiftAnchorIndex = NaN;
+        this._lastSelectedIndex = index;
+
+        let newSelectedItems = new WI.IndexSet;
+        if (!isNaN(index))
+            newSelectedItems.add(index);
+
+        this._updateSelectedItems(newSelectedItems);
+    }
+
+    _selectItemsFromArrowKey(goingUp, shiftKey)
+    {
+        if (!this._selectedIndexes.size) {
+            let index = goingUp ? this.numberOfItems - 1 : 0;
+            this.selectItem(index);
+            return;
+        }
+
+        let index = goingUp ? this._previousSelectableIndex(this._lastSelectedIndex) : this._nextSelectableIndex(this._lastSelectedIndex);
+        if (isNaN(index))
+            return;
+
+        let extendSelection = shiftKey && this._allowsMultipleSelection;
+        if (!extendSelection || !this.hasSelectedItem(index)) {
+            this.selectItem(index, extendSelection);
+            return;
+        }
+
+        // Since the item in the direction of movement is selected, we are either
+        // extending the selection into the item, or deselecting. Determine which
+        // by checking whether the item opposite the anchor item is selected.
+        let priorIndex = goingUp ? this._nextSelectableIndex(this._lastSelectedIndex) : this._previousSelectableIndex(this._lastSelectedIndex);
+        if (!this.hasSelectedItem(priorIndex)) {
+            this.deselectItem(this._lastSelectedIndex);
+            return;
+        }
+
+        // The selection is being extended into the item; make it the new
+        // anchor item then continue searching in the direction of movement
+        // for an unselected item to select.
+        while (!isNaN(index)) {
+            if (!this.hasSelectedItem(index)) {
+                this.selectItem(index, extendSelection);
+                break;
+            }
+
+            this._lastSelectedIndex = index;
+            index = goingUp ? this._previousSelectableIndex(index) : this._nextSelectableIndex(index);
+        }
+    }
+
+    _nextSelectableIndex(index)
+    {
+        return this._delegate.selectionControllerNextSelectableIndex(this, index);
+    }
+
+    _previousSelectableIndex(index)
+    {
+        return this._delegate.selectionControllerPreviousSelectableIndex(this, index);
+    }
+
+    _updateSelectedItems(indexes)
+    {
+        if (this._selectedIndexes.equals(indexes))
+            return;
+
+        let oldSelectedIndexes = this._selectedIndexes.copy();
+        this._selectedIndexes = indexes;
+
+        if (!this._delegate.selectionControllerSelectionDidChange)
+            return;
+
+        let deselectedItems = oldSelectedIndexes.difference(indexes);
+        let selectedItems = indexes.difference(oldSelectedIndexes);
+        this._delegate.selectionControllerSelectionDidChange(this, deselectedItems, selectedItems);
+    }
+};
index 78fc4ea..5d01e39 100644 (file)
 
     <script src="Views/View.js"></script>
 
+    <script src="Controllers/SelectionController.js"></script>
+
     <script src="Views/ConsoleCommandView.js"></script>
     <script src="Views/ConsoleMessageView.js"></script>
     <script src="Views/ContentBrowser.js"></script>
index 9dd40b9..ef8326a 100644 (file)
 
     <script src="Controllers/Formatter.js"></script>
     <script src="Controllers/ResourceQueryController.js"></script>
+    <script src="Controllers/SelectionController.js"></script>
     <script src="Workers/Formatter/FormatterContentBuilder.js"></script>
     <script src="Views/CodeMirrorAdditions.js"></script>
     <script src="Views/CodeMirrorFormatters.js"></script>
index ec4f883..a9700f1 100644 (file)
@@ -87,10 +87,7 @@ WI.Table = class Table extends WI.View
         this._columnWidths = null; // Calculated in _resizeColumnsAndFiller.
         this._fillerHeight = 0; // Calculated in _resizeColumnsAndFiller.
 
-        this._shiftAnchorIndex = NaN;
-        this._selectedRowIndex = NaN;
-        this._allowsMultipleSelection = false;
-        this._selectedRows = new WI.IndexSet;
+        this._selectionController = new WI.SelectionController(this);
 
         this._resizers = [];
         this._currentResizer = null;
@@ -125,11 +122,15 @@ WI.Table = class Table extends WI.View
     get dataSource() { return this._dataSource; }
     get delegate() { return this._delegate; }
     get rowHeight() { return this._rowHeight; }
-    get selectedRow() { return this._selectedRowIndex; }
+
+    get selectedRow()
+    {
+        return this._selectionController.lastSelectedItem;
+    }
 
     get selectedRows()
     {
-        return Array.from(this._selectedRows);
+        return Array.from(this._selectionController.selectedItems);
     }
 
     get scrollContainer() { return this._scrollContainerElement; }
@@ -220,37 +221,24 @@ WI.Table = class Table extends WI.View
 
     get allowsMultipleSelection()
     {
-        return this._allowsMultipleSelection;
+        return this._selectionController.allowsMultipleSelection;
     }
 
     set allowsMultipleSelection(flag)
     {
-        if (this._allowsMultipleSelection === flag)
-            return;
-
-        this._allowsMultipleSelection = flag;
-        if (this._allowsMultipleSelection)
-            return;
-
-        if (this._selectedRows.size > 1) {
-            console.assert(this._selectedRowIndex >= 0);
-            this._selectedRows = new WI.IndexSet([this._selectedRowIndex]);
-            this._notifySelectionDidChange();
-        }
+        this._selectionController.allowsMultipleSelection = flag;
     }
 
     isRowSelected(rowIndex)
     {
-        return this._selectedRows.has(rowIndex);
+        return this._selectionController.hasSelectedItem(rowIndex);
     }
 
     reloadData()
     {
         this._cachedRows.clear();
 
-        this._shiftAnchorIndex = NaN;
-        this._selectedRowIndex = NaN;
-        this._selectedRows.clear();
+        this._selectionController.reset();
 
         this._cachedNumberOfRows = NaN;
         this._previousRevealedRowCount = NaN;
@@ -320,87 +308,22 @@ WI.Table = class Table extends WI.View
 
     selectRow(rowIndex, extendSelection = false)
     {
-        console.assert(!extendSelection || this._allowsMultipleSelection, "Cannot extend selection with multiple selection disabled.");
-        console.assert(rowIndex >= 0 && rowIndex < this.numberOfRows);
-
-        if (this.isRowSelected(rowIndex)) {
-            if (!extendSelection)
-                this._deselectAllAndSelect(rowIndex);
-            return;
-        }
-
-        if (!extendSelection && this._selectedRows.size) {
-            this._suppressNextSelectionDidChange = true;
-            this.deselectAll();
-        }
-
-        this._shiftAnchorIndex = NaN;
-        this._selectedRowIndex = rowIndex;
-        this._selectedRows.add(rowIndex);
-
-        this._toggleSelectedRowStyle([this._selectedRowIndex], true);
-
-        this._notifySelectionDidChange();
+        this._selectionController.selectItem(rowIndex, extendSelection);
     }
 
     deselectRow(rowIndex)
     {
-        console.assert(rowIndex >= 0 && rowIndex < this.numberOfRows);
-
-        if (!this.isRowSelected(rowIndex))
-            return;
-
-        this._toggleSelectedRowStyle([rowIndex], false);
-
-        this._selectedRows.delete(rowIndex);
-
-        if (this._shiftAnchorIndex === rowIndex)
-            this._shiftAnchorIndex = NaN;
-
-        if (this._selectedRowIndex === rowIndex) {
-            this._selectedRowIndex = NaN;
-            if (this._selectedRows.size) {
-                // Find selected row closest to deselected row.
-                let preceding = this._selectedRows.indexLessThan(rowIndex);
-                let following = this._selectedRows.indexGreaterThan(rowIndex);
-
-                if (isNaN(preceding))
-                    this._selectedRowIndex = following;
-                else if (isNaN(following))
-                    this._selectedRowIndex = preceding;
-                else {
-                    if ((following - rowIndex) < (rowIndex - preceding))
-                        this._selectedRowIndex = following;
-                    else
-                        this._selectedRowIndex = preceding;
-                }
-            }
-        }
-
-        this._notifySelectionDidChange();
+        this._selectionController.deselectItem(rowIndex);
     }
 
     selectAll()
     {
-        if (!this.numberOfRows || !this._allowsMultipleSelection)
-            return;
-
-        if (this._selectedRows.size === this.numberOfRows)
-            return;
-
-        this._selectedRows.addRange(0, this.numberOfRows);
-        this._selectedRowIndex = this._selectedRows.size - 1;
-
-        for (let row of this._cachedRows.values())
-            row.classList.add("selected");
-
-        this._notifySelectionDidChange();
+        this._selectionController.selectAll();
     }
 
     deselectAll()
     {
-        const rowIndex = NaN;
-        this._deselectAllAndSelect(rowIndex);
+        this._selectionController.deselectAll();
     }
 
     removeRow(rowIndex)
@@ -411,37 +334,19 @@ WI.Table = class Table extends WI.View
             this.deselectRow(rowIndex);
 
         this._removeRows(new WI.IndexSet([rowIndex]));
+        this._selectionController.didRemoveItem(rowIndex);
     }
 
     removeSelectedRows()
     {
-        let numberOfSelectedRows = this._selectedRows.size;
-        if (!numberOfSelectedRows)
-            return;
-
-        // Try selecting the row following the selection.
-        let lastSelectedRow = this._selectedRows.lastIndex;
-        let rowToSelect = lastSelectedRow + 1;
-        if (rowToSelect === this.numberOfRows) {
-            // If no row exists after the last selected row, try selecting a
-            // deselected row (hole) within the selection.
-            let firstSelectedRow = this._selectedRows.firstIndex;
-            if (lastSelectedRow - firstSelectedRow > numberOfSelectedRows) {
-                rowToSelect = this._selectedRows.firstIndex + 1;
-                while (this._selectedRows.has(rowToSelect))
-                    rowToSelect++;
-            } else {
-                // If the selection contains no holes, try selecting the row
-                // preceding the selection.
-                rowToSelect = firstSelectedRow > 0 ? firstSelectedRow - 1 : NaN;
-            }
-        }
-
         // Change the selection before removing rows. This matches the behavior
         // of macOS Finder (in list and column modes) when removing selected items.
-        let oldSelectedRows = this._selectedRows.copy();
-        this._deselectAllAndSelect(rowToSelect);
-        this._removeRows(oldSelectedRows);
+        let oldSelectedItems = this._selectionController.selectedItems.copy();
+
+        this._selectionController.removeSelectedItems();
+
+        if (!oldSelectedItems.equals(this._selectionController.selectedItems))
+            this._removeRows(oldSelectedItems);
     }
 
     revealRow(rowIndex)
@@ -667,6 +572,44 @@ WI.Table = class Table extends WI.View
         this._cachedHeight = NaN;
     }
 
+    // SelectionController delegate
+
+    selectionControllerSelectionDidChange(controller, deselectedItems, selectedItems)
+    {
+        if (deselectedItems.size)
+            this._toggleSelectedRowStyle(deselectedItems, false);
+        if (selectedItems.size)
+            this._toggleSelectedRowStyle(selectedItems, true);
+
+        if (selectedItems.size === 1) {
+            let rowIndex = selectedItems.firstIndex;
+            if (!this._isRowVisible(rowIndex))
+                this.revealRow(rowIndex);
+        }
+
+        if (this._delegate.tableSelectionDidChange)
+            this._delegate.tableSelectionDidChange(this);
+    }
+
+    selectionControllerNumberOfItems(controller)
+    {
+        return this.numberOfRows;
+    }
+
+    selectionControllerNextSelectableIndex(controller, index)
+    {
+        if (index >= this.numberOfRows - 1)
+            return NaN;
+        return index + 1;
+    }
+
+    selectionControllerPreviousSelectableIndex(controller, index)
+    {
+        if (index <= 0)
+            return NaN;
+        return index - 1;
+    }
+
     // Resizer delegate
 
     resizerDragStarted(resizer)
@@ -1305,74 +1248,11 @@ WI.Table = class Table extends WI.View
 
     _handleKeyDown(event)
     {
-        if (!this.numberOfRows)
-            return;
-
-        if (event.key === "a" && event.commandOrControlKey) {
-            this.selectAll();
-            return;
-        }
-
-        if (event.metaKey || event.ctrlKey)
-            return;
-
-        if (event.keyIdentifier === "Up" || event.keyIdentifier === "Down") {
-            this._selectRowsFromArrowKey(event.keyIdentifier === "Up", event.shiftKey);
-
-            this.revealRow(this._selectedRowIndex);
-
-            event.preventDefault();
-            event.stopPropagation();
-        }
-    }
-
-    _selectRowsFromArrowKey(goingUp, shiftKey)
-    {
-        if (!this._selectedRows.size) {
-            let rowIndex = goingUp ? this.numberOfRows - 1 : 0;
-            this.selectRow(rowIndex);
-            return;
-        }
-
-        let rowIncrement = goingUp ? -1 : 1;
-        let rowIndex = this._selectedRowIndex + rowIncrement;
-        if (rowIndex < 0 || rowIndex >= this.numberOfRows)
-            return;
-
-        let extendSelection = shiftKey && this._allowsMultipleSelection;
-
-        if (!extendSelection || !this.isRowSelected(rowIndex)) {
-            this.selectRow(rowIndex, extendSelection);
-            return;
-        }
-
-        // Since the row in the direction of movement is selected, we are either
-        // extending the selection into the row, or deselecting. Determine which
-        // by checking whether the row opposite the anchor row is selected.
-        let priorRowIndex = this._selectedRowIndex - rowIncrement;
-        if (!this.isRowSelected(priorRowIndex)) {
-            this.deselectRow(this._selectedRowIndex);
-            return;
-        }
-
-        // The selection is being extended into the row; make it the new
-        // anchor row then continue searching in the direction of movement
-        // for an unselected row to select.
-        for (; rowIndex >= 0 && rowIndex < this.numberOfRows; rowIndex += rowIncrement) {
-            if (!this.isRowSelected(rowIndex)) {
-                this.selectRow(rowIndex, extendSelection);
-                break;
-            }
-
-            this._selectedRowIndex = rowIndex;
-        }
+        this._selectionController.handleKeyDown(event);
     }
 
     _handleMouseDown(event)
     {
-        if (event.button !== 0 || event.ctrlKey)
-            return;
-
         let cell = event.target.enclosingNodeOrSelfWithClass("cell");
         if (!cell)
             return;
@@ -1382,69 +1262,17 @@ WI.Table = class Table extends WI.View
             return;
 
         let rowIndex = row.__index;
-        let isRowSelected = this.isRowSelected(rowIndex);
 
         // Before checking if multiple selection is allowed, check if clicking the
         // row would cause it to be selected, and whether it is allowed by the delegate.
-        if (!isRowSelected && this._delegate.tableShouldSelectRow) {
+        if (!this.isRowSelected(rowIndex) && this._delegate.tableShouldSelectRow) {
             let columnIndex = Array.from(row.children).indexOf(cell);
             let column = this._visibleColumns[columnIndex];
             if (!this._delegate.tableShouldSelectRow(this, cell, column, rowIndex))
                 return;
         }
 
-        // Command (meta) key takes precedence over shift whether or not multiple
-        // selection is enabled, so handle it first.
-        if (event.metaKey) {
-            if (isRowSelected)
-                this.deselectRow(rowIndex);
-            else
-                this.selectRow(rowIndex, this._allowsMultipleSelection);
-            return;
-        }
-
-        let shiftExtendSelection = this._allowsMultipleSelection && event.shiftKey;
-        if (!shiftExtendSelection) {
-            this.selectRow(rowIndex);
-            return;
-        }
-
-        let newSelectedRows = this._selectedRows.copy();
-
-        // Shift-clicking when nothing is selected should cause the first row
-        // through the clicked row to be selected.
-        if (!newSelectedRows.size) {
-            this._shiftAnchorIndex = 0;
-            this._selectedRowIndex = rowIndex;
-            newSelectedRows.addRange(0, rowIndex + 1);
-            this._updateSelectedRows(newSelectedRows);
-            return;
-        }
-
-        if (isNaN(this._shiftAnchorIndex))
-            this._shiftAnchorIndex = this._selectedRowIndex;
-
-        // Shift-clicking will add to or delete from the current selection, or
-        // pivot the selection around the anchor (a delete followed by an add).
-        // We could check for all three cases, and add or delete only those rows
-        // that are necessary, but it is simpler to throw out the previous shift-
-        // selected range and add the new range between the anchor and clicked row.
-
-        function normalizeRange(startIndex, endIndex) {
-            return startIndex > endIndex ? [endIndex, startIndex] : [startIndex, endIndex];
-        }
-
-        if (this._shiftAnchorIndex !== this._selectedRowIndex) {
-            let [startIndex, endIndex] = normalizeRange(this._shiftAnchorIndex, this._selectedRowIndex);
-            newSelectedRows.deleteRange(startIndex, endIndex - startIndex + 1);
-        }
-
-        let [startIndex, endIndex] = normalizeRange(this._shiftAnchorIndex, rowIndex);
-        newSelectedRows.addRange(startIndex, endIndex - startIndex + 1);
-
-        this._selectedRowIndex = rowIndex;
-
-        this._updateSelectedRows(newSelectedRows);
+        this._selectionController.handleItemMouseDown(rowIndex, event);
     }
 
     _handleContextMenu(event)
@@ -1523,54 +1351,19 @@ WI.Table = class Table extends WI.View
         }
     }
 
-    _deselectAllAndSelect(rowIndex)
-    {
-        if (!this._selectedRows.size)
-            return;
-
-        if (this._selectedRows.size === 1 && this._selectedRows.firstIndex === rowIndex)
-            return;
-
-        this._toggleSelectedRowStyle(this._selectedRows, false);
-
-        this._shiftAnchorIndex = NaN;
-        this._selectedRowIndex = rowIndex;
-        this._selectedRows.clear();
-
-        if (!isNaN(rowIndex)) {
-            this._selectedRows.add(rowIndex);
-            this._toggleSelectedRowStyle(this._selectedRows, true);
-        }
-
-        this._notifySelectionDidChange();
-    }
-
     _removeRows(rowIndexes)
     {
         let removed = 0;
 
         let adjustRowAtIndex = (index) => {
-            let newIndex = index - removed;
             let row = this._cachedRows.get(index);
             if (row) {
-                this._cachedRows.delete(row.__index);
-                row.__index = newIndex;
-                this._cachedRows.set(newIndex, row);
-            }
-
-            if (this.isRowSelected(index)) {
-                this._selectedRows.delete(index);
-                this._selectedRows.add(newIndex);
-                if (this._selectedRowIndex === index)
-                    this._selectedRowIndex = newIndex;
+                this._cachedRows.delete(index);
+                row.__index -= removed;
+                this._cachedRows.set(row.__index, row);
             }
         };
 
-        if (rowIndexes.has(this._shiftAnchorIndex))
-            this._shiftAnchorIndex = NaN;
-        if (rowIndexes.has(this._selectedRowIndex))
-            this._selectedRowIndex = NaN;
-
         for (let index = rowIndexes.firstIndex; index <= rowIndexes.lastIndex; ++index) {
             if (rowIndexes.has(index)) {
                 let row = this._cachedRows.get(index);
@@ -1601,17 +1394,6 @@ WI.Table = class Table extends WI.View
         }
     }
 
-    _notifySelectionDidChange()
-    {
-        if (this._suppressNextSelectionDidChange) {
-            this._suppressNextSelectionDidChange = false;
-            return;
-        }
-
-        if (this._delegate.tableSelectionDidChange)
-            this._delegate.tableSelectionDidChange(this);
-    }
-
     _toggleSelectedRowStyle(rowIndexes, flag)
     {
         for (let index of rowIndexes) {
@@ -1620,24 +1402,6 @@ WI.Table = class Table extends WI.View
                 row.classList.toggle("selected", flag);
         }
     }
-
-    _updateSelectedRows(rowIndexes)
-    {
-        if (this._selectedRows.equals(rowIndexes))
-            return;
-
-        let deselectedRows = this._selectedRows.difference(rowIndexes);
-        if (deselectedRows.size)
-            this._toggleSelectedRowStyle(deselectedRows, false);
-
-        let selectedRows = rowIndexes.difference(this._selectedRows);
-        if (selectedRows.size)
-            this._toggleSelectedRowStyle(selectedRows, true);
-
-        this._selectedRows = rowIndexes;
-
-        this._notifySelectionDidChange();
-    }
 };
 
 WI.Table.SortOrder = {