| /* |
| * 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, false); |
| 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"; |