/*
 * Copyright (C) 2008, 2013-2016 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.DataGrid = class DataGrid extends WI.View
{
    constructor(columnsData, {editCallback, copyCallback, deleteCallback, preferredColumnOrder} = {})
    {
        super();

        this.columns = new Map;
        this.orderedColumns = [];

        this._settingsIdentifier = null;
        this._sortColumnIdentifier = null;
        this._sortColumnIdentifierSetting = null;
        this._sortOrder = WI.DataGrid.SortOrder.Indeterminate;
        this._sortOrderSetting = null;
        this._columnVisibilitySetting = null;
        this._columnChooserEnabled = false;
        this._headerVisible = true;

        this._rows = [];

        this.children = [];
        this.selectedNode = null;
        this.expandNodesWhenArrowing = false;
        this.root = true;
        this.hasChildren = false;
        this.expanded = true;
        this.revealed = true;
        this.selected = false;
        this.dataGrid = this;
        this.indentWidth = 15;
        this.rowHeight = 20;
        this.resizers = [];
        this._columnWidthsInitialized = false;
        this._scrollbarWidth = 0;

        this._cachedScrollTop = NaN;
        this._cachedScrollableOffsetHeight = NaN;
        this._previousRevealedRowCount = NaN;
        this._topDataTableMarginHeight = NaN;
        this._bottomDataTableMarginHeight = NaN;

        this._filterText = "";
        this._filterDelegate = null;
        this._filterDidModifyNodeWhileProcessingItems = false;

        this.element.className = "data-grid";
        this.element.tabIndex = 0;
        this.element.addEventListener("keydown", this._keyDown.bind(this), false);
        this.element.copyHandler = this;

        this._headerWrapperElement = document.createElement("div");
        this._headerWrapperElement.classList.add("header-wrapper");

        this._headerTableElement = document.createElement("table");
        this._headerTableElement.className = "header";
        this._headerWrapperElement.appendChild(this._headerTableElement);

        this._headerTableColumnGroupElement = this._headerTableElement.createChild("colgroup");
        this._headerTableBodyElement = this._headerTableElement.createChild("tbody");
        this._headerTableRowElement = this._headerTableBodyElement.createChild("tr");
        this._headerTableRowElement.addEventListener("contextmenu", this._contextMenuInHeader.bind(this), true);
        this._headerTableCellElements = new Map;

        this._scrollContainerElement = document.createElement("div");
        this._scrollContainerElement.className = "data-container";

        this._scrollListener = () => this._noteScrollPositionChanged();
        this._updateScrollListeners();

        this._topDataTableMarginElement = this._scrollContainerElement.createChild("div");

        this._dataTableElement = this._scrollContainerElement.createChild("table", "data");

        this._bottomDataTableMarginElement = this._scrollContainerElement.createChild("div");

        this._dataTableElement.addEventListener("mousedown", this._mouseDownInDataTable.bind(this));
        this._dataTableElement.addEventListener("click", this._clickInDataTable.bind(this));
        this._dataTableElement.addEventListener("contextmenu", this._contextMenuInDataTable.bind(this), true);

        // FIXME: Add a createCallback which is different from editCallback and has different
        // behavior when creating a new node.
        if (editCallback) {
            this._dataTableElement.addEventListener("dblclick", this._ondblclick.bind(this), false);
            this._editCallback = editCallback;
        }

        if (copyCallback)
            this._copyCallback = copyCallback;

        if (deleteCallback)
            this._deleteCallback = deleteCallback;

        this._dataTableColumnGroupElement = this._headerTableColumnGroupElement.cloneNode(true);
        this._dataTableElement.appendChild(this._dataTableColumnGroupElement);

        // This element is used by DataGridNodes to manipulate table rows and cells.
        this.dataTableBodyElement = this._dataTableElement.createChild("tbody");

        this._fillerRowElement = this.dataTableBodyElement.createChild("tr", "filler");

        this.element.appendChild(this._headerWrapperElement);
        this.element.appendChild(this._scrollContainerElement);

        if (preferredColumnOrder) {
            for (var columnIdentifier of preferredColumnOrder)
                this.insertColumn(columnIdentifier, columnsData[columnIdentifier]);
        } else {
            for (var columnIdentifier in columnsData)
                this.insertColumn(columnIdentifier, columnsData[columnIdentifier]);
        }

        this._updateScrollbarPadding();

        this._copyTextDelimiter = "\t";
    }

    _updateScrollbarPadding()
    {
        if (this._inline)
            return;

        let scrollbarWidth = this._scrollContainerElement.offsetWidth - this._scrollContainerElement.scrollWidth;
        if (this._scrollbarWidth === scrollbarWidth)
            return;

        if (WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL)
            this._headerWrapperElement.style.setProperty("padding-left", `${scrollbarWidth}px`);
        else
            this._headerWrapperElement.style.setProperty("padding-right", `${scrollbarWidth}px`);

        this._scrollbarWidth = scrollbarWidth;
    }

    static createSortableDataGrid(columnNames, values)
    {
        var numColumns = columnNames.length;
        if (!numColumns)
            return null;

        var columnsData = {};
        for (var columnName of columnNames) {
            columnsData[columnName] = {
                width: columnName.length,
                title: columnName,
                sortable: true,
            };
        }

        let dataGrid = new WI.DataGrid(columnsData, {preferredColumnOrder: columnNames});
        for (var i = 0; i < values.length / numColumns; ++i) {
            var data = {};
            for (var j = 0; j < columnNames.length; ++j)
                data[columnNames[j]] = values[numColumns * i + j];

            var node = new WI.DataGridNode(data);
            dataGrid.appendChild(node);
        }

        function sortDataGrid()
        {
            var sortColumnIdentifier = dataGrid.sortColumnIdentifier;

            var columnIsNumeric = true;
            for (var node of dataGrid.children) {
                var value = dataGrid.textForDataGridNodeColumn(node, sortColumnIdentifier);
                if (isNaN(Number(value)))
                    columnIsNumeric = false;
            }

            function comparator(dataGridNode1, dataGridNode2)
            {
                var item1 = dataGrid.textForDataGridNodeColumn(dataGridNode1, sortColumnIdentifier);
                var item2 = dataGrid.textForDataGridNodeColumn(dataGridNode2, sortColumnIdentifier);

                var comparison;
                if (columnIsNumeric) {
                    var number1 = parseFloat(item1);
                    var number2 = parseFloat(item2);
                    comparison = number1 < number2 ? -1 : (number1 > number2 ? 1 : 0);
                } else
                    comparison = item1 < item2 ? -1 : (item1 > item2 ? 1 : 0);

                return comparison;
            }

            dataGrid.sortNodes(comparator);
        }

        dataGrid.addEventListener(WI.DataGrid.Event.SortChanged, sortDataGrid, this);

        dataGrid.sortOrder = WI.DataGrid.SortOrder.Ascending;
        dataGrid.sortColumnIdentifier = columnNames[0];

        return dataGrid;
    }

    get headerVisible() { return this._headerVisible; }

    set headerVisible(x)
    {
        if (x === this._headerVisible)
            return;

        this._headerVisible = x;
        this.element.classList.toggle("no-header", !this._headerVisible);
    }

    get columnChooserEnabled() { return this._columnChooserEnabled; }
    set columnChooserEnabled(x) { this._columnChooserEnabled = x; }

    get copyTextDelimiter() { return this._copyTextDelimiter; }
    set copyTextDelimiter(x) { this._copyTextDelimiter = x; }

    get refreshCallback()
    {
        return this._refreshCallback;
    }

    set refreshCallback(refreshCallback)
    {
        this._refreshCallback = refreshCallback;
    }

    get sortOrder()
    {
        return this._sortOrder;
    }

    set sortOrder(order)
    {
        if (!order || order === this._sortOrder)
            return;

        this._sortOrder = order;

        if (this._sortOrderSetting)
            this._sortOrderSetting.value = this._sortOrder;

        if (!this._sortColumnIdentifier)
            return;

        var sortHeaderCellElement = this._headerTableCellElements.get(this._sortColumnIdentifier);

        sortHeaderCellElement.classList.toggle(WI.DataGrid.SortColumnAscendingStyleClassName, this._sortOrder === WI.DataGrid.SortOrder.Ascending);
        sortHeaderCellElement.classList.toggle(WI.DataGrid.SortColumnDescendingStyleClassName, this._sortOrder === WI.DataGrid.SortOrder.Descending);

        this.dispatchEventToListeners(WI.DataGrid.Event.SortChanged);
    }

    get sortColumnIdentifier()
    {
        return this._sortColumnIdentifier;
    }

    set sortColumnIdentifier(columnIdentifier)
    {
        console.assert(columnIdentifier && this.columns.has(columnIdentifier));
        console.assert("sortable" in this.columns.get(columnIdentifier));

        if (this._sortColumnIdentifier === columnIdentifier)
            return;

        let oldSortColumnIdentifier = this._sortColumnIdentifier;
        this._sortColumnIdentifier = columnIdentifier;
        this._updateSortedColumn(oldSortColumnIdentifier);
    }

    get inline() { return this._inline; }

    set inline(x)
    {
        if (this._inline === x)
            return;

        this._inline = x || false;

        this._element.classList.toggle("inline", this._inline);

        this._updateScrollListeners();
    }

    get variableHeightRows() { return this._variableHeightRows; }

    set variableHeightRows(x)
    {
        if (this._variableHeightRows === x)
            return;

        this._variableHeightRows = x || false;

        this._element.classList.toggle("variable-height-rows", this._variableHeightRows);

        this._updateScrollListeners();
    }

    get filterText() { return this._filterText; }

    set filterText(x)
    {
        if (this._filterText === x)
            return;

        this._filterText = x;
        this.filterDidChange();
    }

    get filterDelegate() { return this._filterDelegate; }

    set filterDelegate(delegate)
    {
        this._filterDelegate = delegate;
        this.filterDidChange();
    }

    filterDidChange()
    {
        if (this._scheduledFilterUpdateIdentifier)
            return;

        if (this._applyFilterToNodesTask) {
            this._applyFilterToNodesTask.cancel();
            this._applyFilterToNodesTask = null;
        }

        this._scheduledFilterUpdateIdentifier = requestAnimationFrame(this._updateFilter.bind(this));
    }

    hasFilters()
    {
        return this._textFilterRegex || this._hasFilterDelegate();
    }

    matchNodeAgainstCustomFilters(node)
    {
        if (!this._hasFilterDelegate())
            return true;
        return this._filterDelegate.dataGridMatchNodeAgainstCustomFilters(node);
    }

    createSettings(identifier)
    {
        console.assert(identifier && typeof identifier === "string");
        if (this._settingsIdentifier === identifier)
            return;

        this._settingsIdentifier = identifier;

        this._sortColumnIdentifierSetting = new WI.Setting(this._settingsIdentifier + "-sort", this._sortColumnIdentifier);
        this._sortOrderSetting = new WI.Setting(this._settingsIdentifier + "-sort-order", this._sortOrder);
        this._columnVisibilitySetting = new WI.Setting(this._settingsIdentifier + "-column-visibility", {});

        if (!this.columns)
            return;

        if (this._sortColumnIdentifierSetting.value) {
            this.sortColumnIdentifier = this._sortColumnIdentifierSetting.value;
            this.sortOrder = this._sortOrderSetting.value;
        }

        let visibilitySettings = this._columnVisibilitySetting.value;
        for (let columnIdentifier in visibilitySettings) {
            let visible = visibilitySettings[columnIdentifier];
            this.setColumnVisible(columnIdentifier, visible);
        }
    }

    startEditingNode(node)
    {
        console.assert(this._editCallback);
        if (this._editing || this._editingNode)
            return;

        this._startEditingNodeAtColumnIndex(node, 0);
    }

    _updateScrollListeners()
    {
        if (this._inline || this._variableHeightRows) {
            this._scrollContainerElement.removeEventListener("scroll", this._scrollListener);
            this._scrollContainerElement.removeEventListener("mousewheel", this._scrollListener);
        } else {
            this._scrollContainerElement.addEventListener("scroll", this._scrollListener);
            this._scrollContainerElement.addEventListener("mousewheel", this._scrollListener);
        }
    }

    _applyFiltersToNodeAndDispatchEvent(node)
    {
        const nodeWasHidden = node.hidden;
        this._applyFiltersToNode(node);
        if (nodeWasHidden !== node.hidden)
            this.dispatchEventToListeners(WI.DataGrid.Event.NodeWasFiltered, {node});

        return nodeWasHidden !== node.hidden;
    }

    _applyFiltersToNode(node)
    {
        if (!this.hasFilters()) {
            // No filters, so make everything visible.
            node.hidden = false;

            // If the node was expanded during filtering, collapse it again.
            if (node.expanded && node[WI.DataGrid.WasExpandedDuringFilteringSymbol]) {
                node[WI.DataGrid.WasExpandedDuringFilteringSymbol] = false;
                node.collapse();
            }

            return;
        }

        let filterableData = node.filterableData || [];
        let flags = {expandNode: false};
        let filterRegex = this._textFilterRegex;

        function matchTextFilter()
        {
            if (!filterableData.length || !filterRegex)
                return true;

            if (filterableData.some((value) => filterRegex.test(value))) {
                flags.expandNode = true;
                return true;
            }

            return false;
        }

        function makeVisible()
        {
            // Make this element visible.
            node.hidden = false;

            // Make the ancestors visible and expand them.
            let currentAncestor = node.parent;
            while (currentAncestor && !currentAncestor.root) {
                currentAncestor.hidden = false;

                // Only expand if the built-in filters matched, not custom filters.
                if (flags.expandNode && !currentAncestor.expanded) {
                    currentAncestor[WI.DataGrid.WasExpandedDuringFilteringSymbol] = true;
                    currentAncestor.expand();
                }

                currentAncestor = currentAncestor.parent;
            }
        }

        if (matchTextFilter() && this.matchNodeAgainstCustomFilters(node)) {
            // Make the node visible since it matches.
            makeVisible();

            // If the node didn't match a built-in filter and was expanded earlier during filtering, collapse it again.
            if (!flags.expandNode && node.expanded && node[WI.DataGrid.WasExpandedDuringFilteringSymbol]) {
                node[WI.DataGrid.WasExpandedDuringFilteringSymbol] = false;
                node.collapse();
            }

            return;
        }

        // Make the node invisible since it does not match.
        node.hidden = true;
    }

    _updateSortedColumn(oldSortColumnIdentifier)
    {
        if (this._sortColumnIdentifierSetting)
            this._sortColumnIdentifierSetting.value = this._sortColumnIdentifier;

        if (oldSortColumnIdentifier) {
            let oldSortHeaderCellElement = this._headerTableCellElements.get(oldSortColumnIdentifier);
            oldSortHeaderCellElement.classList.remove(WI.DataGrid.SortColumnAscendingStyleClassName);
            oldSortHeaderCellElement.classList.remove(WI.DataGrid.SortColumnDescendingStyleClassName);
        }

        if (this._sortColumnIdentifier) {
            let newSortHeaderCellElement = this._headerTableCellElements.get(this._sortColumnIdentifier);
            newSortHeaderCellElement.classList.toggle(WI.DataGrid.SortColumnAscendingStyleClassName, this._sortOrder === WI.DataGrid.SortOrder.Ascending);
            newSortHeaderCellElement.classList.toggle(WI.DataGrid.SortColumnDescendingStyleClassName, this._sortOrder === WI.DataGrid.SortOrder.Descending);
        }

        this.dispatchEventToListeners(WI.DataGrid.Event.SortChanged);
    }

    _hasFilterDelegate()
    {
        return this._filterDelegate && typeof this._filterDelegate.dataGridMatchNodeAgainstCustomFilters === "function";
    }

    _ondblclick(event)
    {
        if (this._editing || this._editingNode)
            return;

        this._startEditing(event.target);
    }

    _startEditingNodeAtColumnIndex(node, columnIndex)
    {
        console.assert(node, "Invalid argument: must provide DataGridNode to edit.");

        this.updateLayoutIfNeeded();

        this._editing = true;
        this._editingNode = node;
        this._editingNode.select();

        var element = this._editingNode.element.children[columnIndex];
        WI.startEditing(element, this._startEditingConfig(element));

        window.getSelection().setBaseAndExtent(element, 0, element, 1);
    }

    _startEditing(target)
    {
        let element = target.closest("td");
        if (!element)
            return;

        let node = this.dataGridNodeFromNode(target);
        if (!node.editable)
            return;

        this._editingNode = node;
        if (!this._editingNode) {
            if (!this.placeholderNode)
                return;
            this._editingNode = this.placeholderNode;
        }

        // Force editing the 1st column when editing the placeholder node
        if (this._editingNode.isPlaceholderNode)
            return this._startEditingNodeAtColumnIndex(this._editingNode, 0);

        this._editing = true;
        WI.startEditing(element, this._startEditingConfig(element));

        window.getSelection().setBaseAndExtent(element, 0, element, 1);
    }

    _startEditingConfig(element)
    {
        return new WI.EditingConfig(this._editingCommitted.bind(this), this._editingCancelled.bind(this), element.textContent);
    }

    _editingCommitted(element, newText, oldText, context, moveDirection)
    {
        var columnIdentifier = element.__columnIdentifier;
        var columnIndex = this.orderedColumns.indexOf(columnIdentifier);

        var textBeforeEditing = this._editingNode.data[columnIdentifier] || "";

        var currentEditingNode = this._editingNode;
        currentEditingNode.data[columnIdentifier] = newText.trim();

        // Returns an object with the next node and column index to edit, and whether it
        // is an appropriate time to re-sort the table rows. When editing, we want to
        // postpone sorting until we switch rows or wrap around a row.
        function determineNextCell(valueDidChange) {
            if (moveDirection === "forward") {
                if (columnIndex < this.orderedColumns.length - 1)
                    return {shouldSort: false, editingNode: currentEditingNode, columnIndex: columnIndex + 1};

                // Continue by editing the first column of the next row if it exists.
                var nextDataGridNode = currentEditingNode.traverseNextNode(true, null, true);
                return {shouldSort: true, editingNode: nextDataGridNode || currentEditingNode, columnIndex: 0};
            }

            if (moveDirection === "backward") {
                if (columnIndex > 0)
                    return {shouldSort: false, editingNode: currentEditingNode, columnIndex: columnIndex - 1};

                var previousDataGridNode = currentEditingNode.traversePreviousNode(true, null, true);
                return {shouldSort: true, editingNode: previousDataGridNode || currentEditingNode, columnIndex: this.orderedColumns.length - 1};
            }

            // If we are not moving in any direction, then sort and stop.
            return {shouldSort: true};
        }

        function moveToNextCell(valueDidChange) {
            var moveCommand = determineNextCell.call(this, valueDidChange);
            if (moveCommand.shouldSort && this._sortAfterEditingCallback) {
                this._sortAfterEditingCallback();
                this._sortAfterEditingCallback = null;
            }
            if (moveCommand.editingNode)
                this._startEditingNodeAtColumnIndex(moveCommand.editingNode, moveCommand.columnIndex);
        }

        this._editingCancelled(element);

        this._editCallback(currentEditingNode, columnIdentifier, textBeforeEditing, newText, moveDirection);

        var textDidChange = textBeforeEditing.trim() !== newText.trim();
        moveToNextCell.call(this, textDidChange);
    }

    _editingCancelled(element)
    {
        console.assert(this._editingNode.element === element.closest("tr"));

        this._editingNode.refresh();

        this._editing = false;
        this._editingNode = null;
    }

    autoSizeColumns(minPercent, maxPercent, maxDescentLevel)
    {
        if (minPercent)
            minPercent = Math.min(minPercent, Math.floor(100 / this.orderedColumns.length));
        var widths = {};
        // For the first width approximation, use the character length of column titles.
        for (var [identifier, column] of this.columns)
            widths[identifier] = (column["title"] || "").length;

        // Now approximate the width of each column as max(title, cells).
        var children = maxDescentLevel ? this._enumerateChildren(this, [], maxDescentLevel + 1) : this.children;
        for (var node of children) {
            for (var identifier of this.columns.keys()) {
                var text = this.textForDataGridNodeColumn(node, identifier);
                if (text.length > widths[identifier])
                    widths[identifier] = text.length;
            }
        }

        var totalColumnWidths = 0;
        for (var identifier of this.columns.keys())
            totalColumnWidths += widths[identifier];

        // Compute percentages and clamp desired widths to min and max widths.
        var recoupPercent = 0;
        for (var identifier of this.columns.keys()) {
            var width = Math.round(100 * widths[identifier] / totalColumnWidths);
            if (minPercent && width < minPercent) {
                recoupPercent += minPercent - width;
                width = minPercent;
            } else if (maxPercent && width > maxPercent) {
                recoupPercent -= width - maxPercent;
                width = maxPercent;
            }
            widths[identifier] = width;
        }

        // If we assigned too much width due to the above, reduce column widths.
        while (minPercent && recoupPercent > 0) {
            for (var identifier of this.columns.keys()) {
                if (widths[identifier] > minPercent) {
                    --widths[identifier];
                    --recoupPercent;
                    if (!recoupPercent)
                        break;
                }
            }
        }

        // If extra width remains after clamping widths, expand column widths.
        while (maxPercent && recoupPercent < 0) {
            for (var identifier of this.columns.keys()) {
                if (widths[identifier] < maxPercent) {
                    ++widths[identifier];
                    ++recoupPercent;
                    if (!recoupPercent)
                        break;
                }
            }
        }

        for (var [identifier, column] of this.columns) {
            column["element"].style.width = widths[identifier] + "%";
            column["bodyElement"].style.width = widths[identifier] + "%";
        }

        this._columnWidthsInitialized = false;
        this.needsLayout();
    }

    insertColumn(columnIdentifier, columnData, insertionIndex)
    {
        if (insertionIndex === undefined)
            insertionIndex = this.orderedColumns.length;
        insertionIndex = Number.constrain(insertionIndex, 0, this.orderedColumns.length);

        var listeners = new WI.EventListenerSet(this, "DataGrid column DOM listeners");

        // Copy configuration properties instead of keeping a reference to the passed-in object.
        var column = Object.shallowCopy(columnData);
        column["listeners"] = listeners;
        column["ordinal"] = insertionIndex;
        column["columnIdentifier"] = columnIdentifier;

        this.orderedColumns.splice(insertionIndex, 0, columnIdentifier);

        for (var [identifier, existingColumn] of this.columns) {
            var ordinal = existingColumn["ordinal"];
            if (ordinal >= insertionIndex) // Also adjust the "old" column at insertion index.
                existingColumn["ordinal"] = ordinal + 1;
        }
        this.columns.set(columnIdentifier, column);

        if (column["disclosure"])
            this.disclosureColumnIdentifier = columnIdentifier;

        var headerColumnElement = document.createElement("col");
        if (column["width"])
            headerColumnElement.style.width = column["width"];
        column["element"] = headerColumnElement;
        var referenceElement = this._headerTableColumnGroupElement.children[insertionIndex];
        this._headerTableColumnGroupElement.insertBefore(headerColumnElement, referenceElement);

        var headerCellElement = document.createElement("th");
        headerCellElement.className = columnIdentifier + "-column";
        headerCellElement.columnIdentifier = columnIdentifier;
        if (column["aligned"])
            headerCellElement.classList.add(column["aligned"]);
        this._headerTableCellElements.set(columnIdentifier, headerCellElement);
        var referenceElement = this._headerTableRowElement.children[insertionIndex];
        this._headerTableRowElement.insertBefore(headerCellElement, referenceElement);

        if (column["headerView"]) {
            let headerView = column["headerView"];
            console.assert(headerView instanceof WI.View);

            headerCellElement.appendChild(headerView.element);
            this.addSubview(headerView);
        } else {
            let titleElement = headerCellElement.createChild("div");
            if (column["titleDOMFragment"])
                titleElement.appendChild(column["titleDOMFragment"]);
            else
                titleElement.textContent = column["title"] || "";
        }

        if (column["sortable"]) {
            listeners.register(headerCellElement, "click", this._headerCellClicked);
            headerCellElement.classList.add(WI.DataGrid.SortableColumnStyleClassName);
        }

        if (column["group"])
            headerCellElement.classList.add("column-group-" + column["group"]);

        if (column["tooltip"])
            headerCellElement.title = column["tooltip"];

        if (column["collapsesGroup"]) {
            console.assert(column["group"] !== column["collapsesGroup"]);

            headerCellElement.createChild("div", "divider");

            var collapseDiv = headerCellElement.createChild("div", "collapser-button");
            collapseDiv.title = this._collapserButtonCollapseColumnsToolTip();
            listeners.register(collapseDiv, "mouseover", this._mouseoverColumnCollapser);
            listeners.register(collapseDiv, "mouseout", this._mouseoutColumnCollapser);
            listeners.register(collapseDiv, "click", this._clickInColumnCollapser);

            headerCellElement.collapsesGroup = column["collapsesGroup"];
            headerCellElement.classList.add("collapser");
        }

        this._headerTableColumnGroupElement.span = this.orderedColumns.length;

        var dataColumnElement = headerColumnElement.cloneNode();
        var referenceElement = this._dataTableColumnGroupElement.children[insertionIndex];
        this._dataTableColumnGroupElement.insertBefore(dataColumnElement, referenceElement);
        column["bodyElement"] = dataColumnElement;

        var fillerCellElement = document.createElement("td");
        fillerCellElement.className = columnIdentifier + "-column";
        fillerCellElement.__columnIdentifier = columnIdentifier;
        if (column["group"])
            fillerCellElement.classList.add("column-group-" + column["group"]);
        var referenceElement = this._fillerRowElement.children[insertionIndex];
        this._fillerRowElement.insertBefore(fillerCellElement, referenceElement);

        listeners.install();

        this.setColumnVisible(columnIdentifier, !column.hidden);
    }

    removeColumn(columnIdentifier)
    {
        console.assert(this.columns.has(columnIdentifier));
        var removedColumn = this.columns.get(columnIdentifier);
        this.columns.delete(columnIdentifier);
        this.orderedColumns.splice(this.orderedColumns.indexOf(columnIdentifier), 1);

        var removedOrdinal = removedColumn["ordinal"];
        for (var [identifier, column] of this.columns) {
            var ordinal = column["ordinal"];
            if (ordinal > removedOrdinal)
                column["ordinal"] = ordinal - 1;
        }

        removedColumn["listeners"].uninstall(true);

        if (removedColumn["disclosure"])
            this.disclosureColumnIdentifier = undefined;

        if (this.sortColumnIdentifier === columnIdentifier)
            this.sortColumnIdentifier = null;

        this._headerTableCellElements.delete(columnIdentifier);
        this._headerTableRowElement.children[removedOrdinal].remove();
        this._headerTableColumnGroupElement.children[removedOrdinal].remove();
        this._dataTableColumnGroupElement.children[removedOrdinal].remove();
        this._fillerRowElement.children[removedOrdinal].remove();

        this._headerTableColumnGroupElement.span = this.orderedColumns.length;

        for (var child of this.children)
            child.refresh();
    }

    _enumerateChildren(rootNode, result, maxLevel)
    {
        if (!rootNode.root)
            result.push(rootNode);
        if (!maxLevel)
            return;
        for (var i = 0; i < rootNode.children.length; ++i)
            this._enumerateChildren(rootNode.children[i], result, maxLevel - 1);
        return result;
    }

    // Updates the widths of the table, including the positions of the column
    // resizers.
    //
    // IMPORTANT: This function MUST be called once after the element of the
    // DataGrid is attached to its parent element and every subsequent time the
    // width of the parent element is changed in order to make it possible to
    // resize the columns.
    //
    // If this function is not called after the DataGrid is attached to its
    // parent element, then the DataGrid's columns will not be resizable.
    layout()
    {
        // Do not attempt to use offsets if we're not attached to the document tree yet.
        if (!this._columnWidthsInitialized && this.element.offsetWidth) {
            // Give all the columns initial widths now so that during a resize,
            // when the two columns that get resized get a percent value for
            // their widths, all the other columns already have percent values
            // for their widths.
            let headerTableColumnElements = this._headerTableColumnGroupElement.children;
            let tableWidth = this._dataTableElement.offsetWidth;
            let numColumns = headerTableColumnElements.length;
            let cells = this._headerTableBodyElement.rows[0].cells;

            // Calculate widths.
            let columnWidths = [];
            for (let i = 0; i < numColumns; ++i) {
                let headerCellElement = cells[i];
                if (this.isColumnVisible(headerCellElement.columnIdentifier)) {
                    let columnWidth = headerCellElement.offsetWidth;
                    let percentWidth = ((columnWidth / tableWidth) * 100) + "%";
                    columnWidths.push(percentWidth);
                } else
                    columnWidths.push(0);
            }

            // Apply widths.
            for (let i = 0; i < numColumns; i++) {
                let percentWidth = columnWidths[i];
                this._headerTableColumnGroupElement.children[i].style.width = percentWidth;
                this._dataTableColumnGroupElement.children[i].style.width = percentWidth;
            }

            this._columnWidthsInitialized = true;
            this._updateHeaderAndScrollbar();
        }

        this.updateVisibleRows();
    }

    sizeDidChange()
    {
        this._updateHeaderAndScrollbar();
    }

    _updateHeaderAndScrollbar()
    {
        this._positionResizerElements();
        this._positionHeaderViews();
        this._updateScrollbarPadding();

        this._cachedScrollTop = NaN;
        this._cachedScrollableOffsetHeight = NaN;
    }

    isColumnVisible(columnIdentifier)
    {
        return !this.columns.get(columnIdentifier)["hidden"];
    }

    setColumnVisible(columnIdentifier, visible)
    {
        let column = this.columns.get(columnIdentifier);
        console.assert(column, "Missing column info for identifier: " + columnIdentifier);
        console.assert(typeof visible === "boolean", "New visible state should be explicit boolean", typeof visible);

        if (!column || visible === !column.hidden)
            return;

        column.element.style.width = visible ? column.width : 0;
        column.hidden = !visible;

        if (this._columnVisibilitySetting) {
            if (this._columnVisibilitySetting.value[columnIdentifier] !== visible) {
                let copy = Object.shallowCopy(this._columnVisibilitySetting.value);
                copy[columnIdentifier] = visible;
                this._columnVisibilitySetting.value = copy;
            }
        }

        this._columnWidthsInitialized = false;
        this.updateLayout();
    }

    get scrollContainer()
    {
        return this._scrollContainerElement;
    }

    isScrolledToLastRow()
    {
        return this._scrollContainerElement.isScrolledToBottom();
    }

    scrollToLastRow()
    {
        this._scrollContainerElement.scrollTop = this._scrollContainerElement.scrollHeight - this._scrollContainerElement.offsetHeight;
    }

    _positionResizerElements()
    {
        let leadingOffset = 0;
        var previousResizer = null;

        // Make n - 1 resizers for n columns.
        var numResizers = this.orderedColumns.length - 1;

        // Calculate leading offsets.
        // Get the width of the cell in the first (and only) row of the
        // header table in order to determine the width of the column, since
        // it is not possible to query a column for its width.
        var cells = this._headerTableBodyElement.rows[0].cells;
        var columnWidths = [];
        for (var i = 0; i < numResizers; ++i) {
            leadingOffset += cells[i].getBoundingClientRect().width;
            columnWidths.push(leadingOffset);
        }

        // Apply leading offsets.
        for (var i = 0; i < numResizers; ++i) {
            // Create a new resizer if one does not exist for this column.
            // This resizer is associated with the column to its right.
            var resizer = this.resizers[i];
            if (!resizer) {
                resizer = this.resizers[i] = new WI.Resizer(WI.Resizer.RuleOrientation.Vertical, this);
                this.element.appendChild(resizer.element);
            }

            leadingOffset = columnWidths[i];

            if (this.isColumnVisible(this.orderedColumns[i])) {
                resizer.element.style.removeProperty("display");
                resizer.element.style.setProperty(WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL ? "right" : "left", `${leadingOffset}px`);
                resizer[WI.DataGrid.PreviousColumnOrdinalSymbol] = i;
                if (previousResizer)
                    previousResizer[WI.DataGrid.NextColumnOrdinalSymbol] = i;
                previousResizer = resizer;
            } else {
                resizer.element.style.setProperty("display", "none");
                resizer[WI.DataGrid.PreviousColumnOrdinalSymbol] = 0;
                resizer[WI.DataGrid.NextColumnOrdinalSymbol] = 0;
            }
        }
        if (previousResizer)
            previousResizer[WI.DataGrid.NextColumnOrdinalSymbol] = this.orderedColumns.length - 1;
    }

    _positionHeaderViews()
    {
        let leadingOffset = 0;
        let headerViews = [];
        let offsets = [];
        let columnWidths = [];

        // Calculate leading offsets and widths.
        for (let columnIdentifier of this.orderedColumns) {
            let column = this.columns.get(columnIdentifier);
            console.assert(column, "Missing column data for header cell with columnIdentifier " + columnIdentifier);
            if (!column)
                continue;

            let columnWidth = this._headerTableCellElements.get(columnIdentifier).offsetWidth;
            let headerView = column["headerView"];
            if (headerView) {
                headerViews.push(headerView);
                offsets.push(leadingOffset);
                columnWidths.push(columnWidth);
            }

            leadingOffset += columnWidth;
        }

        // Apply leading offsets and widths.
        for (let i = 0; i < headerViews.length; ++i) {
            let headerView = headerViews[i];
            headerView.element.style.setProperty(WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL ? "right" : "left", `${offsets[i]}px`);
            headerView.element.style.width = columnWidths[i] + "px";
            headerView.updateLayout(WI.View.LayoutReason.Resize);
        }
    }

    _noteRowsChanged()
    {
        this._previousRevealedRowCount = NaN;

        this.needsLayout();
    }

    _noteRowRemoved(dataGridNode)
    {
        if (this._inline || this._variableHeightRows) {
            // Inline DataGrids rows are not updated in layout, so
            // we need to remove rows immediately.
            if (dataGridNode.element && dataGridNode.element.parentNode)
                dataGridNode.element.parentNode.removeChild(dataGridNode.element);
            return;
        }

        this._noteRowsChanged();
    }

    _noteScrollPositionChanged()
    {
        this._cachedScrollTop = NaN;

        this.needsLayout();
    }

    updateVisibleRows(focusedDataGridNode)
    {
        if (this._inline || this._variableHeightRows) {
            // Inline DataGrids always show all their rows, so we can't virtualize them.
            // In general, inline DataGrids usually have a small number of rows.

            // FIXME: This is a slow path for variable height rows that is similar to the old
            // non-virtualized DataGrid. Ideally we would track row height per-DataGridNode
            // and then we could virtualize even those cases. Currently variable height row
            // DataGrids don't usually have many rows, other than IndexedDB.

            let nextElement = this.dataTableBodyElement.lastChild;
            for (let i = this._rows.length - 1; i >= 0; --i) {
                let rowElement = this._rows[i].element;
                if (rowElement.nextSibling !== nextElement)
                    this.dataTableBodyElement.insertBefore(rowElement, nextElement);
                nextElement = rowElement;
            }

            if (focusedDataGridNode)
                focusedDataGridNode.element.scrollIntoViewIfNeeded(false);

            return;
        }

        let rowHeight = this.rowHeight;
        let updateOffsetThreshold = rowHeight * 5;
        let overflowPadding = updateOffsetThreshold * 3;

        if (isNaN(this._cachedScrollTop))
            this._cachedScrollTop = this._scrollContainerElement.scrollTop;

        if (isNaN(this._cachedScrollableOffsetHeight))
            this._cachedScrollableOffsetHeight = this._scrollContainerElement.offsetHeight;

        let visibleRowCount = Math.ceil((this._cachedScrollableOffsetHeight + (overflowPadding * 2)) / rowHeight);

        if (!focusedDataGridNode) {
            let currentTopMargin = this._topDataTableMarginHeight;
            let currentBottomMargin = this._bottomDataTableMarginHeight;
            let currentTableBottom = currentTopMargin + (visibleRowCount * rowHeight);

            let belowTopThreshold = !currentTopMargin || this._cachedScrollTop > currentTopMargin + updateOffsetThreshold;
            let aboveBottomThreshold = !currentBottomMargin || this._cachedScrollTop + this._cachedScrollableOffsetHeight < currentTableBottom - updateOffsetThreshold;

            if (belowTopThreshold && aboveBottomThreshold && !isNaN(this._previousRevealedRowCount))
                return;
        }

        let revealedRows = this._rows.filter((row) => row.revealed && !row.hidden);

        this._previousRevealedRowCount = revealedRows.length;

        if (focusedDataGridNode) {
            let focusedIndex = revealedRows.indexOf(focusedDataGridNode);
            let firstVisibleRowIndex = this._cachedScrollTop / rowHeight;
            if (focusedIndex < firstVisibleRowIndex || focusedIndex > firstVisibleRowIndex + visibleRowCount)
                this._scrollContainerElement.scrollTop = this._cachedScrollTop = (focusedIndex * rowHeight) - (this._cachedScrollableOffsetHeight / 2) + (rowHeight / 2);
        }

        let topHiddenRowCount = Math.max(0, Math.floor((this._cachedScrollTop - overflowPadding) / rowHeight));
        let bottomHiddenRowCount = Math.max(0, this._previousRevealedRowCount - topHiddenRowCount - visibleRowCount);

        let marginTop = topHiddenRowCount * rowHeight;
        let marginBottom = bottomHiddenRowCount * rowHeight;

        if (this._topDataTableMarginHeight !== marginTop) {
            this._topDataTableMarginHeight = marginTop;
            this._topDataTableMarginElement.style.height = marginTop + "px";
        }

        if (this._bottomDataTableMarginElement !== marginBottom) {
            this._bottomDataTableMarginHeight = marginBottom;
            this._bottomDataTableMarginElement.style.height = marginBottom + "px";
        }

        // If there are an odd number of rows hidden, the first visible row must be an even row.
        this._dataTableElement.classList.toggle("even-first-zebra-stripe", !!(topHiddenRowCount % 2));

        this.dataTableBodyElement.removeChildren();

        for (let i = topHiddenRowCount; i < topHiddenRowCount + visibleRowCount; ++i) {
            let rowDataGridNode = revealedRows[i];
            if (!rowDataGridNode)
                continue;
            this.dataTableBodyElement.appendChild(rowDataGridNode.element);
        }

        this.dataTableBodyElement.appendChild(this._fillerRowElement);
    }

    addPlaceholderNode()
    {
        if (this.placeholderNode)
            this.placeholderNode.makeNormal();

        var emptyData = {};
        for (var identifier of this.columns.keys())
            emptyData[identifier] = "";
        this.placeholderNode = new WI.PlaceholderDataGridNode(emptyData);
        this.appendChild(this.placeholderNode);
    }

    appendChild(child)
    {
        this.insertChild(child, this.children.length);
    }

    insertChild(child, index)
    {
        console.assert(child);
        if (!child)
            return;

        console.assert(child.parent !== this);
        if (child.parent === this)
            return;

        if (child.parent)
            child.parent.removeChild(child);

        this.children.splice(index, 0, child);
        this.hasChildren = true;

        child.parent = this;
        child.dataGrid = this.dataGrid;
        child._recalculateSiblings(index);

        delete child._depth;
        delete child._revealed;
        delete child._attached;
        delete child._leftPadding;
        child._shouldRefreshChildren = true;

        var current = child.children[0];
        while (current) {
            current.dataGrid = this.dataGrid;
            delete current._depth;
            delete current._revealed;
            delete current._attached;
            delete current._leftPadding;
            current._shouldRefreshChildren = true;
            current = current.traverseNextNode(false, child, true);
        }

        if (this.expanded)
            child._attach();

        if (!this.dataGrid.hasFilters())
            return;

        this.dataGrid._applyFiltersToNodeAndDispatchEvent(child);
    }

    removeChild(child)
    {
        console.assert(child);
        if (!child)
            return;

        console.assert(child.parent === this);
        if (child.parent !== this)
            return;

        child.deselect();
        child._detach();

        this.children.remove(child, true);

        if (child.previousSibling)
            child.previousSibling.nextSibling = child.nextSibling;
        if (child.nextSibling)
            child.nextSibling.previousSibling = child.previousSibling;

        child.dataGrid = null;
        child.parent = null;
        child.nextSibling = null;
        child.previousSibling = null;

        if (this.children.length <= 0)
            this.hasChildren = false;

        console.assert(!child.isPlaceholderNode, "Shouldn't delete the placeholder node.");
    }

    removeChildren()
    {
        for (var i = 0; i < this.children.length; ++i) {
            var child = this.children[i];
            child.deselect();
            child._detach();

            child.dataGrid = null;
            child.parent = null;
            child.nextSibling = null;
            child.previousSibling = null;
        }

        this.children = [];
        this.hasChildren = false;
    }

    findNode(comparator, skipHidden, stayWithin, dontPopulate)
    {
        console.assert(typeof comparator === "function");

        let currentNode = this._rows[0];
        while (currentNode && !currentNode.root) {
            if (!currentNode.isPlaceholderNode && !(skipHidden && currentNode.hidden)) {
                if (comparator(currentNode))
                    return currentNode;
            }

            currentNode = currentNode.traverseNextNode(skipHidden, stayWithin, dontPopulate);
        }

        return null;
    }

    sortNodes(comparator)
    {
        // FIXME: This should use the layout loop and not its own requestAnimationFrame.
        this._sortNodesComparator = comparator;

        if (this._sortNodesRequestId)
            return;

        this._sortNodesRequestId = window.requestAnimationFrame(() => {
            if (this._sortNodesComparator)
                this._sortNodesCallback(this._sortNodesComparator);
        });
    }

    sortNodesImmediately(comparator)
    {
        this._sortNodesCallback(comparator);
    }

    _sortNodesCallback(comparator)
    {
        function comparatorWrapper(aNode, bNode)
        {
            console.assert(!aNode.hasChildren, "This sort method can't be used with parent nodes, children will be displayed out of order.");
            console.assert(!bNode.hasChildren, "This sort method can't be used with parent nodes, children will be displayed out of order.");

            if (aNode.isPlaceholderNode)
                return 1;
            if (bNode.isPlaceholderNode)
                return -1;

            var reverseFactor = this.sortOrder !== WI.DataGrid.SortOrder.Ascending ? -1 : 1;
            return reverseFactor * comparator(aNode, bNode);
        }

        this._sortNodesRequestId = undefined;
        this._sortNodesComparator = null;

        if (this._editing) {
            this._sortAfterEditingCallback = this.sortNodes.bind(this, comparator);
            return;
        }

        this._rows.sort(comparatorWrapper.bind(this));
        this._noteRowsChanged();

        let previousSiblingNode = null;
        for (let node of this._rows) {
            node.previousSibling = previousSiblingNode;
            if (previousSiblingNode)
                previousSiblingNode.nextSibling = node;
            previousSiblingNode = node;
        }

        if (previousSiblingNode)
            previousSiblingNode.nextSibling = null;

        // A sortable data grid might not be added to a view, so it needs its layout updated here.
        if (!this.parentView)
            this.updateLayoutIfNeeded();
    }

    _toggledSortOrder()
    {
        return this._sortOrder !== WI.DataGrid.SortOrder.Descending ? WI.DataGrid.SortOrder.Descending : WI.DataGrid.SortOrder.Ascending;
    }

    _selectSortColumnAndSetOrder(columnIdentifier, sortOrder)
    {
        this.sortColumnIdentifier = columnIdentifier;
        this.sortOrder = sortOrder;
    }

    _keyDown(event)
    {
        if (!this.selectedNode || event.shiftKey || event.metaKey || event.ctrlKey || this._editing)
            return;

        let isRTL = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL;

        var handled = false;
        var nextSelectedNode;
        if (event.keyIdentifier === "Up" && !event.altKey) {
            nextSelectedNode = this.selectedNode.traversePreviousNode(true);
            while (nextSelectedNode && !nextSelectedNode.selectable)
                nextSelectedNode = nextSelectedNode.traversePreviousNode(true);
            handled = nextSelectedNode ? true : false;
        } else if (event.keyIdentifier === "Down" && !event.altKey) {
            nextSelectedNode = this.selectedNode.traverseNextNode(true);
            while (nextSelectedNode && !nextSelectedNode.selectable)
                nextSelectedNode = nextSelectedNode.traverseNextNode(true);
            handled = nextSelectedNode ? true : false;
        } else if ((!isRTL && event.keyIdentifier === "Left") || (isRTL && event.keyIdentifier === "Right")) {
            if (this.selectedNode.expanded) {
                if (event.altKey)
                    this.selectedNode.collapseRecursively();
                else
                    this.selectedNode.collapse();
                handled = true;
            } else if (this.selectedNode.parent && !this.selectedNode.parent.root) {
                handled = true;
                if (this.selectedNode.parent.selectable) {
                    nextSelectedNode = this.selectedNode.parent;
                    handled = nextSelectedNode ? true : false;
                } else if (this.selectedNode.parent)
                    this.selectedNode.parent.collapse();
            }
        } else if ((!isRTL && event.keyIdentifier === "Right") || (isRTL && event.keyIdentifier === "Left")) {
            if (!this.selectedNode.revealed) {
                this.selectedNode.reveal();
                handled = true;
            } else if (this.selectedNode.hasChildren) {
                handled = true;
                if (this.selectedNode.expanded) {
                    nextSelectedNode = this.selectedNode.children[0];
                    handled = nextSelectedNode ? true : false;
                } else {
                    if (event.altKey)
                        this.selectedNode.expandRecursively();
                    else
                        this.selectedNode.expand();
                }
            }
        } else if (event.keyCode === 8 || event.keyCode === 46) {
            if (this._deleteCallback) {
                handled = true;
                this._deleteCallback(this.selectedNode);
            }
        } else if (isEnterKey(event)) {
            if (this._editCallback) {
                handled = true;
                this._startEditing(this.selectedNode.element.children[0]);
            }
        }

        if (nextSelectedNode) {
            nextSelectedNode.reveal();
            nextSelectedNode.select();
        }

        if (handled) {
            event.preventDefault();
            event.stopPropagation();
        }
    }

    closed()
    {
        // Implemented by subclasses.
    }

    expand()
    {
        // This is the root, do nothing.
    }

    collapse()
    {
        // This is the root, do nothing.
    }

    reveal()
    {
        // This is the root, do nothing.
    }

    revealAndSelect()
    {
        // This is the root, do nothing.
    }

    dataGridNodeFromNode(target)
    {
        var rowElement = target.closest("tr");
        return rowElement && rowElement._dataGridNode;
    }

    dataGridNodeFromPoint(x, y)
    {
        var node = this._dataTableElement.ownerDocument.elementFromPoint(x, y);
        var rowElement = node.closest("tr");
        return rowElement && rowElement._dataGridNode;
    }

    _headerCellClicked(event)
    {
        let cell = event.target.closest("th");
        if (!cell || !cell.columnIdentifier || !cell.classList.contains(WI.DataGrid.SortableColumnStyleClassName))
            return;

        let sortOrder = this._sortColumnIdentifier === cell.columnIdentifier ? this._toggledSortOrder() : this.sortOrder;
        this._selectSortColumnAndSetOrder(cell.columnIdentifier, sortOrder);
    }

    _mouseoverColumnCollapser(event)
    {
        var cell = event.target.closest("th");
        if (!cell || !cell.collapsesGroup)
            return;

        cell.classList.add("mouse-over-collapser");
    }

    _mouseoutColumnCollapser(event)
    {
        var cell = event.target.closest("th");
        if (!cell || !cell.collapsesGroup)
            return;

        cell.classList.remove("mouse-over-collapser");
    }

    _clickInColumnCollapser(event)
    {
        var cell = event.target.closest("th");
        if (!cell || !cell.collapsesGroup)
            return;

        this._collapseColumnGroupWithCell(cell);

        event.stopPropagation();
        event.preventDefault();
    }

    collapseColumnGroup(columnGroup)
    {
        var collapserColumnIdentifier = null;
        for (var [identifier, column] of this.columns) {
            if (column["collapsesGroup"] === columnGroup) {
                collapserColumnIdentifier = identifier;
                break;
            }
        }

        console.assert(collapserColumnIdentifier);
        if (!collapserColumnIdentifier)
            return;

        var cell = this._headerTableCellElements.get(collapserColumnIdentifier);
        this._collapseColumnGroupWithCell(cell);
    }

    _collapseColumnGroupWithCell(cell)
    {
        var columnsWillCollapse = cell.classList.toggle("collapsed");

        this.willToggleColumnGroup(cell.collapsesGroup, columnsWillCollapse);

        for (var [identifier, column] of this.columns) {
            if (column["group"] === cell.collapsesGroup)
                this.setColumnVisible(identifier, !columnsWillCollapse);
        }

        var collapserButton = cell.querySelector(".collapser-button");
        if (collapserButton)
            collapserButton.title = columnsWillCollapse ? this._collapserButtonExpandColumnsToolTip() : this._collapserButtonCollapseColumnsToolTip();

        this.didToggleColumnGroup(cell.collapsesGroup, columnsWillCollapse);
    }

    _collapserButtonCollapseColumnsToolTip()
    {
        return WI.UIString("Collapse columns");
    }

    _collapserButtonExpandColumnsToolTip()
    {
        return WI.UIString("Expand columns");
    }

    willToggleColumnGroup(columnGroup, willCollapse)
    {
        // Implemented by subclasses if needed.
    }

    didToggleColumnGroup(columnGroup, didCollapse)
    {
        // Implemented by subclasses if needed.
    }

    headerTableHeader(columnIdentifier)
    {
        return this._headerTableCellElements.get(columnIdentifier);
    }

    _mouseDownInDataTable(event)
    {
        var gridNode = this.dataGridNodeFromNode(event.target);
        if (!gridNode) {
            if (this.selectedNode)
                this.selectedNode.deselect();
            return;
        }

        if (!gridNode.selectable || gridNode.isEventWithinDisclosureTriangle(event))
            return;

        if (event.metaKey) {
            if (gridNode.selected)
                gridNode.deselect();
            else
                gridNode.select();
        } else
            gridNode.select();
    }

    _contextMenuInHeader(event)
    {
        let contextMenu = WI.ContextMenu.createFromEvent(event);

        if (this._hasCopyableData())
            contextMenu.appendItem(WI.UIString("Copy Table"), this._copyTable.bind(this));

        let headerCellElement = event.target.closest("th");
        if (!headerCellElement)
            return;

        let columnIdentifier = headerCellElement.columnIdentifier;
        let column = this.columns.get(columnIdentifier);
        console.assert(column, "Missing column info for identifier: " + columnIdentifier);
        if (!column)
            return;

        if (column.sortable) {
            contextMenu.appendSeparator();

            if (this.sortColumnIdentifier !== columnIdentifier || this.sortOrder !== WI.DataGrid.SortOrder.Ascending) {
                contextMenu.appendItem(WI.UIString("Sort Ascending"), () => {
                    this._selectSortColumnAndSetOrder(columnIdentifier, WI.DataGrid.SortOrder.Ascending);
                });
            }

            if (this.sortColumnIdentifier !== columnIdentifier || this.sortOrder !== WI.DataGrid.SortOrder.Descending) {
                contextMenu.appendItem(WI.UIString("Sort Descending"), () => {
                    this._selectSortColumnAndSetOrder(columnIdentifier, WI.DataGrid.SortOrder.Descending);
                });
            }
        }

        if (this._columnChooserEnabled) {
            let didAddSeparator = false;

            for (let [identifier, columnInfo] of this.columns) {
                if (columnInfo.locked)
                    continue;

                if (!didAddSeparator) {
                    contextMenu.appendSeparator();
                    didAddSeparator = true;

                    const disabled = true;
                    contextMenu.appendItem(WI.UIString("Displayed Columns"), () => {}, disabled);
                }

                contextMenu.appendCheckboxItem(columnInfo.title, () => {
                    this.setColumnVisible(identifier, !!columnInfo.hidden);
                }, !columnInfo.hidden);
            }
        }
    }

    _contextMenuInDataTable(event)
    {
        let contextMenu = WI.ContextMenu.createFromEvent(event);

        let gridNode = this.dataGridNodeFromNode(event.target);

        if (gridNode)
            gridNode.appendContextMenuItems(contextMenu);

        if (this.dataGrid._refreshCallback && (!gridNode || gridNode !== this.placeholderNode))
            contextMenu.appendItem(WI.UIString("Refresh"), this._refreshCallback.bind(this));

        if (gridNode) {
            if (gridNode.selectable && gridNode.copyable && !gridNode.isEventWithinDisclosureTriangle(event)) {
                contextMenu.appendItem(WI.UIString("Copy Row"), this._copyRow.bind(this, event.target));
                contextMenu.appendItem(WI.UIString("Copy Table"), this._copyTable.bind(this));

                if (this.dataGrid._editCallback) {
                    if (gridNode === this.placeholderNode)
                        contextMenu.appendItem(WI.UIString("Add New"), this._startEditing.bind(this, event.target));
                    else if (gridNode.editable) {
                        let element = event.target.closest("td");
                        let columnIdentifier = element.__columnIdentifier;
                        let columnTitle = this.dataGrid.columns.get(columnIdentifier)["title"];
                        contextMenu.appendItem(WI.UIString("Edit \u201C%s\u201D").format(columnTitle), this._startEditing.bind(this, event.target));
                    }
                }

                if (this.dataGrid._deleteCallback && gridNode !== this.placeholderNode && gridNode.editable)
                    contextMenu.appendItem(WI.UIString("Delete"), this._deleteCallback.bind(this, gridNode));
            }

            if (gridNode.children.some((child) => child.hasChildren) || (gridNode.hasChildren && !gridNode.children.length)) {
                contextMenu.appendSeparator();

                contextMenu.appendItem(WI.UIString("Expand All"), gridNode.expandRecursively.bind(gridNode));
                contextMenu.appendItem(WI.UIString("Collapse All"), gridNode.collapseRecursively.bind(gridNode));
            }
        }
    }

    _clickInDataTable(event)
    {
        var gridNode = this.dataGridNodeFromNode(event.target);
        if (!gridNode || !gridNode.hasChildren)
            return;

        if (!gridNode.isEventWithinDisclosureTriangle(event))
            return;

        if (gridNode.expanded) {
            if (event.altKey)
                gridNode.collapseRecursively();
            else
                gridNode.collapse();
        } else {
            if (event.altKey)
                gridNode.expandRecursively();
            else
                gridNode.expand();
        }
    }

    textForDataGridNodeColumn(node, columnIdentifier)
    {
        var data = node.data[columnIdentifier];
        return (data instanceof Node ? data.textContent : data) || "";
    }

    _copyTextForDataGridNode(node)
    {
        let fields = node.dataGrid.orderedColumns.map((identifier) => {
            let text = this.textForDataGridNodeColumn(node, identifier);
            if (this._copyCallback)
                text = this._copyCallback(node, identifier, text);
            return text;
        });
        return fields.join(this._copyTextDelimiter);
    }

    _copyTextForDataGridHeaders()
    {
        let fields = this.orderedColumns.map((identifier) => this.headerTableHeader(identifier).textContent);
        return fields.join(this._copyTextDelimiter);
    }

    handleBeforeCopyEvent(event)
    {
        if (this.selectedNode && window.getSelection().isCollapsed)
            event.preventDefault();
    }

    handleCopyEvent(event)
    {
        if (!this.selectedNode || !window.getSelection().isCollapsed)
            return;

        var copyText = this._copyTextForDataGridNode(this.selectedNode);
        event.clipboardData.setData("text/plain", copyText);
        event.stopPropagation();
        event.preventDefault();
    }

    _copyRow(target)
    {
        var gridNode = this.dataGridNodeFromNode(target);
        if (!gridNode)
            return;

        var copyText = this._copyTextForDataGridNode(gridNode);
        InspectorFrontendHost.copyText(copyText);
    }

    _copyTable()
    {
        let copyData = [];
        copyData.push(this._copyTextForDataGridHeaders());
        for (let gridNode of this.children) {
            if (!gridNode.copyable)
                continue;
            copyData.push(this._copyTextForDataGridNode(gridNode));
        }

        InspectorFrontendHost.copyText(copyData.join("\n"));
    }

    _hasCopyableData()
    {
        let gridNode = this.children[0];
        return gridNode && gridNode.selectable && gridNode.copyable;
    }

    resizerDragStarted(resizer)
    {
        if (!resizer[WI.DataGrid.NextColumnOrdinalSymbol])
            return true; // Abort the drag;

        this._currentResizer = resizer;
    }

    resizerDragging(resizer, positionDelta)
    {
        console.assert(resizer === this._currentResizer, resizer, this._currentResizer);
        if (resizer !== this._currentResizer)
            return;

        let isRTL = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL;
        if (isRTL)
            positionDelta *= -1;

        // Constrain the dragpoint to be within the containing div of the datagrid.
        let dragPoint = 0;
        if (isRTL)
            dragPoint += this.element.totalOffsetRight - resizer.initialPosition - positionDelta;
        else
            dragPoint += resizer.initialPosition - this.element.totalOffsetLeft - positionDelta;

        // Constrain the dragpoint to be within the space made up by the
        // column directly to the left and the column directly to the right.
        var leftColumnIndex = resizer[WI.DataGrid.PreviousColumnOrdinalSymbol];
        var rightColumnIndex = resizer[WI.DataGrid.NextColumnOrdinalSymbol];
        var firstRowCells = this._headerTableBodyElement.rows[0].cells;
        let leadingEdgeOfPreviousColumn = 0;
        for (let i = 0; i < leftColumnIndex; ++i)
            leadingEdgeOfPreviousColumn += firstRowCells[i].offsetWidth;

        let trailingEdgeOfNextColumn = leadingEdgeOfPreviousColumn + firstRowCells[leftColumnIndex].offsetWidth + firstRowCells[rightColumnIndex].offsetWidth;

        // Give each column some padding so that they don't disappear.
        let leftMinimum = leadingEdgeOfPreviousColumn + WI.DataGrid.ColumnResizePadding;
        let rightMaximum = trailingEdgeOfNextColumn - WI.DataGrid.ColumnResizePadding;

        dragPoint = Number.constrain(dragPoint, leftMinimum, rightMaximum);

        resizer.element.style.setProperty(isRTL ? "right" : "left", `${dragPoint - this.CenterResizerOverBorderAdjustment}px`);

        let percentLeftColumn = (((dragPoint - leadingEdgeOfPreviousColumn) / this._dataTableElement.offsetWidth) * 100) + "%";
        this._headerTableColumnGroupElement.children[leftColumnIndex].style.width = percentLeftColumn;
        this._dataTableColumnGroupElement.children[leftColumnIndex].style.width = percentLeftColumn;

        let percentRightColumn = (((trailingEdgeOfNextColumn - dragPoint) / this._dataTableElement.offsetWidth) * 100) + "%";
        this._headerTableColumnGroupElement.children[rightColumnIndex].style.width = percentRightColumn;
        this._dataTableColumnGroupElement.children[rightColumnIndex].style.width = percentRightColumn;

        this._positionResizerElements();
        this._positionHeaderViews();

        const skipHidden = true;
        const dontPopulate = true;

        let leftColumnIdentifier = this.orderedColumns[leftColumnIndex];
        let rightColumnIdentifier = this.orderedColumns[rightColumnIndex];
        let child = this.children[0];

        while (child) {
            child.didResizeColumn(leftColumnIdentifier);
            child.didResizeColumn(rightColumnIdentifier);
            child = child.traverseNextNode(skipHidden, this, dontPopulate);
        }

        event.preventDefault();
    }

    resizerDragEnded(resizer)
    {
        console.assert(resizer === this._currentResizer, resizer, this._currentResizer);
        if (resizer !== this._currentResizer)
            return;

        this._currentResizer = null;
    }

    _updateFilter()
    {
        if (this._scheduledFilterUpdateIdentifier) {
            cancelAnimationFrame(this._scheduledFilterUpdateIdentifier);
            this._scheduledFilterUpdateIdentifier = undefined;
        }

        if (!this._rows.length)
            return;

        this._textFilterRegex = this._filterText ? WI.SearchUtilities.regExpForString(this._filterText, WI.SearchUtilities.defaultSettings) : null;

        if (this._applyFilterToNodesTask && this._applyFilterToNodesTask.processing)
            this._applyFilterToNodesTask.cancel();

        function *createIteratorForNodesToBeFiltered()
        {
            let hasFilters = this.hasFilters();

            let currentNode = this._rows[0];
            while (currentNode && !currentNode.root) {
                yield currentNode;

                // Don't populate if we don't have any active filters.
                // We only need to populate when a filter needs to reveal.
                let dontPopulate = !hasFilters;
                if (hasFilters && this._filterDelegate && this._filterDelegate.dataGridMatchShouldPopulateWhenFilteringNode)
                    dontPopulate = this._filterDelegate.dataGridMatchShouldPopulateWhenFilteringNode(currentNode);
                currentNode = currentNode.traverseNextNode(false, null, dontPopulate);
            }
        }

        let items = createIteratorForNodesToBeFiltered.call(this);
        this._applyFilterToNodesTask = new WI.YieldableTask(this, items, {workInterval: 100});

        this._filterDidModifyNodeWhileProcessingItems = false;

        this._applyFilterToNodesTask.start();
    }

    // YieldableTask delegate

    yieldableTaskWillProcessItem(task, node)
    {
        let nodeWasModified = this._applyFiltersToNodeAndDispatchEvent(node);
        if (nodeWasModified)
            this._filterDidModifyNodeWhileProcessingItems = true;
    }

    yieldableTaskDidYield(task, processedItems, elapsedTime)
    {
        if (!this._filterDidModifyNodeWhileProcessingItems)
            return;

        this._filterDidModifyNodeWhileProcessingItems = false;

        this.dispatchEventToListeners(WI.DataGrid.Event.FilterDidChange);
    }

    yieldableTaskDidFinish(task)
    {
        this._applyFilterToNodesTask = null;
    }
};

WI.DataGrid.Event = {
    SortChanged: "datagrid-sort-changed",
    SelectedNodeChanged: "datagrid-selected-node-changed",
    ExpandedNode: "datagrid-expanded-node",
    CollapsedNode: "datagrid-collapsed-node",
    FilterDidChange: "datagrid-filter-did-change",
    NodeWasFiltered: "datagrid-node-was-filtered"
};

WI.DataGrid.SortOrder = {
    Indeterminate: "data-grid-sort-order-indeterminate",
    Ascending: "data-grid-sort-order-ascending",
    Descending: "data-grid-sort-order-descending"
};

WI.DataGrid.PreviousColumnOrdinalSymbol = Symbol("previous-column-ordinal");
WI.DataGrid.NextColumnOrdinalSymbol = Symbol("next-column-ordinal");
WI.DataGrid.WasExpandedDuringFilteringSymbol = Symbol("was-expanded-during-filtering");

WI.DataGrid.ColumnResizePadding = 10;
WI.DataGrid.CenterResizerOverBorderAdjustment = 3;

WI.DataGrid.SortColumnAscendingStyleClassName = "sort-ascending";
WI.DataGrid.SortColumnDescendingStyleClassName = "sort-descending";
WI.DataGrid.SortableColumnStyleClassName = "sortable";
