| /* |
| * Copyright (C) 2008-2018 Apple Inc. All Rights Reserved. |
| * |
| * Redistribution and use in source and binary forms, with or without |
| * modification, are permitted provided that the following conditions |
| * are met: |
| * 1. Redistributions of source code must retain the above copyright |
| * notice, this list of conditions and the following disclaimer. |
| * 2. Redistributions in binary form must reproduce the above copyright |
| * notice, this list of conditions and the following disclaimer in the |
| * documentation and/or other materials provided with the distribution. |
| * |
| * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' |
| * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, |
| * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
| * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS |
| * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR |
| * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF |
| * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS |
| * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
| * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
| * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF |
| * THE POSSIBILITY OF SUCH DAMAGE. |
| */ |
| |
| WI.Table = class Table extends WI.View |
| { |
| constructor(identifier, dataSource, delegate, rowHeight) |
| { |
| super(); |
| |
| console.assert(typeof identifier === "string"); |
| console.assert(dataSource); |
| console.assert(delegate); |
| console.assert(rowHeight > 0); |
| |
| this._identifier = identifier; |
| this._dataSource = dataSource; |
| this._delegate = delegate; |
| this._rowHeight = rowHeight; |
| |
| // FIXME: Should be able to horizontally scroll non-locked table contents. |
| // To do this smoothly (without tearing) will require synchronous scroll events, or |
| // synchronized scrolling between multiple elements, or making `position: sticky` |
| // respect different vertical / horizontal scroll containers. |
| |
| this.element.classList.add("table", identifier); |
| this.element.tabIndex = 0; |
| this.element.addEventListener("keydown", this._handleKeyDown.bind(this)); |
| |
| this._headerElement = this.element.appendChild(document.createElement("div")); |
| this._headerElement.className = "header"; |
| |
| let scrollHandler = this._handleScroll.bind(this); |
| this._scrollContainerElement = this.element.appendChild(document.createElement("div")); |
| this._scrollContainerElement.className = "data-container"; |
| this._scrollContainerElement.addEventListener("scroll", scrollHandler); |
| this._scrollContainerElement.addEventListener("mousewheel", scrollHandler); |
| this._scrollContainerElement.addEventListener("mousedown", this._handleMouseDown.bind(this)); |
| if (this._delegate.tableCellContextMenuClicked) |
| this._scrollContainerElement.addEventListener("contextmenu", this._handleContextMenu.bind(this)); |
| |
| this._topSpacerElement = this._scrollContainerElement.appendChild(document.createElement("div")); |
| this._topSpacerElement.className = "spacer"; |
| |
| this._listElement = this._scrollContainerElement.appendChild(document.createElement("ul")); |
| this._listElement.className = "data-list"; |
| |
| this._bottomSpacerElement = this._scrollContainerElement.appendChild(document.createElement("div")); |
| this._bottomSpacerElement.className = "spacer"; |
| |
| this._fillerRow = this._listElement.appendChild(document.createElement("li")); |
| this._fillerRow.className = "filler"; |
| |
| this._resizersElement = this._element.appendChild(document.createElement("div")); |
| this._resizersElement.className = "resizers"; |
| |
| this._cachedRows = new Map; |
| |
| this._columnSpecs = new Map; |
| this._columnOrder = []; |
| this._visibleColumns = []; |
| this._hiddenColumns = []; |
| |
| this._widthGeneration = 1; |
| this._columnWidths = null; // Calculated in _resizeColumnsAndFiller. |
| this._fillerHeight = 0; // Calculated in _resizeColumnsAndFiller. |
| |
| let selectionComparator = WI.SelectionController.createListComparator(this._indexForRepresentedObject.bind(this)); |
| this._selectionController = new WI.SelectionController(this, selectionComparator); |
| |
| this._resizers = []; |
| this._currentResizer = null; |
| this._resizeLeftColumns = null; |
| this._resizeRightColumns = null; |
| this._resizeOriginalColumnWidths = null; |
| this._lastColumnIndexToAcceptRemainderPixel = 0; |
| |
| this._sortOrderSetting = new WI.Setting(this._identifier + "-sort-order", WI.Table.SortOrder.Indeterminate); |
| this._sortColumnIdentifierSetting = new WI.Setting(this._identifier + "-sort", null); |
| this._columnVisibilitySetting = new WI.Setting(this._identifier + "-column-visibility", {}); |
| |
| this._sortOrder = this._sortOrderSetting.value; |
| this._sortColumnIdentifier = this._sortColumnIdentifierSetting.value; |
| |
| this._cachedWidth = NaN; |
| this._cachedHeight = NaN; |
| this._cachedScrollTop = NaN; |
| this._previousCachedWidth = NaN; |
| this._previousRevealedRowCount = NaN; |
| this._topSpacerHeight = NaN; |
| this._bottomSpacerHeight = NaN; |
| this._visibleRowIndexStart = NaN; |
| this._visibleRowIndexEnd = NaN; |
| |
| console.assert(this._dataSource.tableNumberOfRows, "Table data source must implement tableNumberOfRows."); |
| console.assert(this._dataSource.tableIndexForRepresentedObject, "Table data source must implement tableIndexForRepresentedObject."); |
| console.assert(this._dataSource.tableRepresentedObjectForIndex, "Table data source must implement tableRepresentedObjectForIndex."); |
| |
| console.assert(this._delegate.tablePopulateCell, "Table delegate must implement tablePopulateCell."); |
| } |
| |
| // Public |
| |
| get identifier() { return this._identifier; } |
| get dataSource() { return this._dataSource; } |
| get delegate() { return this._delegate; } |
| get rowHeight() { return this._rowHeight; } |
| |
| get selectedRow() |
| { |
| let item = this._selectionController.lastSelectedItem; |
| let index = this._indexForRepresentedObject(item); |
| return index >= 0 ? index : NaN; |
| } |
| |
| get selectedRows() |
| { |
| let rowIndexes = []; |
| for (let item of this._selectionController.selectedItems) |
| rowIndexes.push(this._indexForRepresentedObject(item)); |
| return rowIndexes; |
| } |
| |
| get scrollContainer() { return this._scrollContainerElement; } |
| |
| get numberOfRows() |
| { |
| return this._dataSource.tableNumberOfRows(this); |
| } |
| |
| get sortOrder() |
| { |
| return this._sortOrder; |
| } |
| |
| set sortOrder(sortOrder) |
| { |
| if (sortOrder === this._sortOrder && this.didInitialLayout) |
| return; |
| |
| console.assert(sortOrder === WI.Table.SortOrder.Indeterminate || sortOrder === WI.Table.SortOrder.Ascending || sortOrder === WI.Table.SortOrder.Descending); |
| |
| this._sortOrder = sortOrder; |
| this._sortOrderSetting.value = sortOrder; |
| |
| if (this._sortColumnIdentifier) { |
| let column = this._columnSpecs.get(this._sortColumnIdentifier); |
| let columnIndex = this._visibleColumns.indexOf(column); |
| if (columnIndex !== -1) { |
| let headerCell = this._headerElement.children[columnIndex]; |
| headerCell.classList.toggle("sort-ascending", this._sortOrder === WI.Table.SortOrder.Ascending); |
| headerCell.classList.toggle("sort-descending", this._sortOrder === WI.Table.SortOrder.Descending); |
| } |
| |
| if (this._dataSource.tableSortChanged) |
| this._dataSource.tableSortChanged(this); |
| } |
| } |
| |
| get sortColumnIdentifier() |
| { |
| return this._sortColumnIdentifier; |
| } |
| |
| set sortColumnIdentifier(columnIdentifier) |
| { |
| if (columnIdentifier === this._sortColumnIdentifier && this.didInitialLayout) |
| return; |
| |
| let column = this._columnSpecs.get(columnIdentifier); |
| |
| console.assert(column, "Column not found.", columnIdentifier); |
| if (!column) |
| return; |
| |
| console.assert(column.sortable, "Column is not sortable.", columnIdentifier); |
| if (!column.sortable) |
| return; |
| |
| let oldSortColumnIdentifier = this._sortColumnIdentifier; |
| this._sortColumnIdentifier = columnIdentifier; |
| this._sortColumnIdentifierSetting.value = columnIdentifier; |
| |
| if (oldSortColumnIdentifier) { |
| let oldColumn = this._columnSpecs.get(oldSortColumnIdentifier); |
| let oldColumnIndex = this._visibleColumns.indexOf(oldColumn); |
| if (oldColumnIndex !== -1) { |
| let headerCell = this._headerElement.children[oldColumnIndex]; |
| headerCell.classList.remove("sort-ascending", "sort-descending"); |
| } |
| } |
| |
| if (this._sortColumnIdentifier) { |
| let newColumnIndex = this._visibleColumns.indexOf(column); |
| if (newColumnIndex !== -1) { |
| let headerCell = this._headerElement.children[newColumnIndex]; |
| headerCell.classList.toggle("sort-ascending", this._sortOrder === WI.Table.SortOrder.Ascending); |
| headerCell.classList.toggle("sort-descending", this._sortOrder === WI.Table.SortOrder.Descending); |
| } else |
| this._sortColumnIdentifier = null; |
| } |
| |
| if (this._dataSource.tableSortChanged) |
| this._dataSource.tableSortChanged(this); |
| } |
| |
| get allowsMultipleSelection() |
| { |
| return this._selectionController.allowsMultipleSelection; |
| } |
| |
| set allowsMultipleSelection(flag) |
| { |
| this._selectionController.allowsMultipleSelection = flag; |
| } |
| |
| get columns() |
| { |
| return Array.from(this._columnSpecs.values()); |
| } |
| |
| isRowSelected(rowIndex) |
| { |
| return this._selectionController.hasSelectedItem(this._representedObjectForIndex(rowIndex)); |
| } |
| |
| reloadData() |
| { |
| this._cachedRows.clear(); |
| |
| this._selectionController.reset(); |
| |
| this._previousRevealedRowCount = NaN; |
| this.needsLayout(); |
| } |
| |
| reloadDataAddedToEndOnly() |
| { |
| this._previousRevealedRowCount = NaN; |
| this.needsLayout(); |
| } |
| |
| reloadRow(rowIndex) |
| { |
| // Visible row, repopulate the cell. |
| if (this._isRowVisible(rowIndex)) { |
| let row = this._cachedRows.get(rowIndex); |
| if (!row) |
| return; |
| this._populateRow(row); |
| return; |
| } |
| |
| // Non-visible row, will populate when it becomes visible. |
| this._cachedRows.delete(rowIndex); |
| } |
| |
| reloadVisibleColumnCells(column) |
| { |
| let columnIndex = this._visibleColumns.indexOf(column); |
| if (columnIndex === -1) |
| return; |
| |
| let numberOfRows = Math.min(this._visibleRowIndexEnd, this.numberOfRows); |
| for (let rowIndex = this._visibleRowIndexStart; rowIndex < numberOfRows; ++rowIndex) { |
| let row = this._cachedRows.get(rowIndex); |
| if (!row) |
| continue; |
| let cell = row.children[columnIndex]; |
| if (!cell) |
| continue; |
| this._delegate.tablePopulateCell(this, cell, column, rowIndex); |
| } |
| } |
| |
| reloadCell(rowIndex, columnIdentifier) |
| { |
| let column = this._columnSpecs.get(columnIdentifier); |
| let columnIndex = this._visibleColumns.indexOf(column); |
| if (columnIndex === -1) |
| return; |
| |
| // Visible row, repopulate the cell. |
| if (this._isRowVisible(rowIndex)) { |
| let row = this._cachedRows.get(rowIndex); |
| if (!row) |
| return; |
| let cell = row.children[columnIndex]; |
| if (!cell) |
| return; |
| this._delegate.tablePopulateCell(this, cell, column, rowIndex); |
| return; |
| } |
| |
| // Non-visible row, will populate when it becomes visible. |
| this._cachedRows.delete(rowIndex); |
| } |
| |
| selectRow(rowIndex, extendSelection = false) |
| { |
| this._selectionController.selectItem(this._representedObjectForIndex(rowIndex), extendSelection); |
| } |
| |
| deselectRow(rowIndex) |
| { |
| this._selectionController.deselectItem(this._representedObjectForIndex(rowIndex)); |
| } |
| |
| selectAll() |
| { |
| this._selectionController.selectAll(); |
| } |
| |
| deselectAll() |
| { |
| this._selectionController.deselectAll(); |
| } |
| |
| removeRow(rowIndex) |
| { |
| console.assert(rowIndex >= 0 && rowIndex < this.numberOfRows); |
| |
| if (this.isRowSelected(rowIndex)) |
| this.deselectRow(rowIndex); |
| |
| this._removeRows(new Set([this._representedObjectForIndex(rowIndex)])); |
| } |
| |
| removeSelectedRows() |
| { |
| let selectedItems = this._selectionController.selectedItems; |
| if (!selectedItems.size) |
| return; |
| |
| // Change the selection before removing rows. This matches the behavior |
| // of macOS Finder (in list and column modes) when removing selected items. |
| this._selectionController.removeSelectedItems(); |
| |
| this._removeRows(selectedItems); |
| } |
| |
| revealRow(rowIndex) |
| { |
| console.assert(rowIndex >= 0 && rowIndex < this.numberOfRows); |
| if (rowIndex < 0 || rowIndex >= this.numberOfRows) |
| return; |
| |
| if (this._isRowVisible(rowIndex)) { |
| let row = this._cachedRows.get(rowIndex); |
| console.assert(row, "Visible rows should always be in the cache."); |
| if (row) { |
| row.scrollIntoViewIfNeeded(false); |
| this._cachedScrollTop = NaN; |
| this.needsLayout(); |
| } |
| } else { |
| let rowPosition = rowIndex * this._rowHeight; |
| let scrollableOffsetHeight = this._calculateOffsetHeight(); |
| let scrollTop = this._calculateScrollTop(); |
| let newScrollTop = NaN; |
| if (rowPosition + this._rowHeight < scrollTop) |
| newScrollTop = rowPosition; |
| else if (rowPosition > scrollTop + scrollableOffsetHeight) |
| newScrollTop = scrollTop + scrollableOffsetHeight - this._rowHeight; |
| |
| if (!isNaN(newScrollTop)) { |
| this._scrollContainerElement.scrollTop = newScrollTop; |
| this.updateLayout(); |
| } |
| } |
| } |
| |
| columnWithIdentifier(identifier) |
| { |
| return this._columnSpecs.get(identifier); |
| } |
| |
| cellForRowAndColumn(rowIndex, column) |
| { |
| if (!this._isRowVisible(rowIndex)) |
| return null; |
| |
| let row = this._cachedRows.get(rowIndex); |
| if (!row) |
| return null; |
| |
| let columnIndex = this._visibleColumns.indexOf(column); |
| if (columnIndex === -1) |
| return null; |
| |
| return row.children[columnIndex]; |
| } |
| |
| addColumn(column) |
| { |
| this._columnSpecs.set(column.identifier, column); |
| this._columnOrder.push(column.identifier); |
| |
| if (column.hidden) { |
| this._hiddenColumns.push(column); |
| column.width = NaN; |
| } else { |
| this._visibleColumns.push(column); |
| this._headerElement.appendChild(this._createHeaderCell(column)); |
| this._fillerRow.appendChild(this._createFillerCell(column)); |
| if (column.headerView) |
| this.addSubview(column.headerView); |
| } |
| |
| // Restore saved user-specified column visibility. |
| let savedColumnVisibility = this._columnVisibilitySetting.value; |
| if (column.identifier in savedColumnVisibility) { |
| let visible = savedColumnVisibility[column.identifier]; |
| if (visible) |
| this.showColumn(column); |
| else |
| this.hideColumn(column); |
| } |
| |
| this.reloadData(); |
| } |
| |
| showColumn(column) |
| { |
| console.assert(this._columnSpecs.get(column.identifier) === column, "Column not in this table."); |
| console.assert(!column.locked, "Locked columns should always be shown."); |
| if (column.locked) |
| return; |
| |
| if (!column.hidden) |
| return; |
| |
| column.hidden = false; |
| |
| let columnIndex = this._hiddenColumns.indexOf(column); |
| this._hiddenColumns.splice(columnIndex, 1); |
| |
| let newColumnIndex = this._indexToInsertColumn(column); |
| this._visibleColumns.insertAtIndex(column, newColumnIndex); |
| |
| // Save user preference for this column to be visible. |
| let savedColumnVisibility = this._columnVisibilitySetting.value; |
| if (savedColumnVisibility[column.identifier] !== true) { |
| let copy = Object.shallowCopy(savedColumnVisibility); |
| if (column.defaultHidden) |
| copy[column.identifier] = true; |
| else |
| delete copy[column.identifier]; |
| this._columnVisibilitySetting.value = copy; |
| } |
| |
| this._headerElement.insertBefore(this._createHeaderCell(column), this._headerElement.children[newColumnIndex]); |
| this._fillerRow.insertBefore(this._createFillerCell(column), this._fillerRow.children[newColumnIndex]); |
| |
| if (column.headerView) |
| this.addSubview(column.headerView); |
| |
| if (this._sortColumnIdentifier === column.identifier) { |
| let headerCell = this._headerElement.children[newColumnIndex]; |
| headerCell.classList.toggle("sort-ascending", this._sortOrder === WI.Table.SortOrder.Ascending); |
| headerCell.classList.toggle("sort-descending", this._sortOrder === WI.Table.SortOrder.Descending); |
| } |
| |
| // We haven't yet done any layout, nothing to do. |
| if (!this._columnWidths) |
| return; |
| |
| // To avoid recreating all the cells in the row we create empty cells, |
| // size them, and then populate them. We always populate a cell after |
| // it has been sized. |
| let cellsToPopulate = []; |
| for (let row of this._listElement.children) { |
| if (row !== this._fillerRow) { |
| let unpopulatedCell = this._createCell(column, newColumnIndex); |
| cellsToPopulate.push(unpopulatedCell); |
| row.insertBefore(unpopulatedCell, row.children[newColumnIndex]); |
| } |
| } |
| |
| // Re-layout all columns to make space. |
| this._widthGeneration++; |
| this._columnWidths = null; |
| this._resizeColumnsAndFiller(); |
| |
| // Now populate only the new cells for this column. |
| for (let cell of cellsToPopulate) |
| this._delegate.tablePopulateCell(this, cell, column, cell.parentElement.__index); |
| |
| // Now populate columns that may be sensitive to resizes. |
| for (let visibleColumn of this._visibleColumns) { |
| if (visibleColumn !== column) { |
| if (visibleColumn.needsReloadOnResize) |
| this.reloadVisibleColumnCells(visibleColumn); |
| } |
| } |
| } |
| |
| hideColumn(column) |
| { |
| console.assert(this._columnSpecs.get(column.identifier) === column, "Column not in this table."); |
| console.assert(!column.locked, "Locked columns should always be shown."); |
| if (column.locked) |
| return; |
| |
| console.assert(column.hideable, "Column is not hideable so should always be shown."); |
| if (!column.hideable) |
| return; |
| |
| if (column.hidden) |
| return; |
| |
| column.hidden = true; |
| |
| this._hiddenColumns.push(column); |
| |
| let columnIndex = this._visibleColumns.indexOf(column); |
| this._visibleColumns.splice(columnIndex, 1); |
| |
| // Save user preference for this column to be hidden. |
| let savedColumnVisibility = this._columnVisibilitySetting.value; |
| if (savedColumnVisibility[column.identifier] !== false) { |
| let copy = Object.shallowCopy(savedColumnVisibility); |
| if (column.defaultHidden) |
| delete copy[column.identifier]; |
| else |
| copy[column.identifier] = false; |
| this._columnVisibilitySetting.value = copy; |
| } |
| |
| this._headerElement.removeChild(this._headerElement.children[columnIndex]); |
| this._fillerRow.removeChild(this._fillerRow.children[columnIndex]); |
| |
| if (column.headerView) |
| this.removeSubview(column.headerView); |
| |
| // We haven't yet done any layout, nothing to do. |
| if (!this._columnWidths) |
| return; |
| |
| for (let row of this._listElement.children) { |
| if (row !== this._fillerRow) |
| row.removeChild(row.children[columnIndex]); |
| } |
| |
| // Re-layout all columns to make space. |
| this._widthGeneration++; |
| this._columnWidths = null; |
| this._resizeColumnsAndFiller(); |
| |
| // Now populate columns that may be sensitive to resizes. |
| for (let visibleColumn of this._visibleColumns) { |
| if (visibleColumn.needsReloadOnResize) |
| this.reloadVisibleColumnCells(visibleColumn); |
| } |
| } |
| |
| // Protected |
| |
| attached() |
| { |
| super.attached(); |
| |
| if (this._cachedScrollTop && !this._scrollContainerElement.scrollTop) |
| this._scrollContainerElement.scrollTop = this._cachedScrollTop; |
| } |
| |
| initialLayout() |
| { |
| this.sortOrder = this._sortOrderSetting.value; |
| |
| let restoreSortColumnIdentifier = this._sortColumnIdentifierSetting.value; |
| if (!this._columnSpecs.has(restoreSortColumnIdentifier)) |
| this._sortColumnIdentifierSetting.value = null; |
| else |
| this.sortColumnIdentifier = restoreSortColumnIdentifier; |
| } |
| |
| layout() |
| { |
| this._updateVisibleRows(); |
| this._resizeColumnsAndFiller(); |
| } |
| |
| sizeDidChange() |
| { |
| super.sizeDidChange(); |
| |
| this._previousCachedWidth = this._cachedWidth; |
| this._cachedWidth = NaN; |
| this._cachedHeight = NaN; |
| } |
| |
| // SelectionController delegate |
| |
| selectionControllerSelectionDidChange(controller, deselectedItems, selectedItems) |
| { |
| for (let item of deselectedItems) { |
| let rowIndex = this._indexForRepresentedObject(item); |
| let row = this._cachedRows.get(rowIndex); |
| if (row) |
| row.classList.toggle("selected", false); |
| } |
| |
| for (let item of selectedItems) { |
| let rowIndex = this._indexForRepresentedObject(item); |
| let row = this._cachedRows.get(rowIndex); |
| if (row) |
| row.classList.toggle("selected", true); |
| } |
| |
| if (this._selectionController.lastSelectedItem) { |
| let rowIndex = this._indexForRepresentedObject(this._selectionController.lastSelectedItem); |
| this.revealRow(rowIndex); |
| } |
| |
| if (this._delegate.tableSelectionDidChange) |
| this._delegate.tableSelectionDidChange(this); |
| } |
| |
| selectionControllerFirstSelectableItem(controller) |
| { |
| return this._representedObjectForIndex(0); |
| } |
| |
| selectionControllerLastSelectableItem(controller) |
| { |
| return this._representedObjectForIndex(this.numberOfRows - 1); |
| } |
| |
| selectionControllerPreviousSelectableItem(controller, item) |
| { |
| let index = this._indexForRepresentedObject(item); |
| console.assert(index >= 0 && index < this.numberOfRows); |
| |
| return index > 0 ? this._representedObjectForIndex(index - 1) : null; |
| } |
| |
| selectionControllerNextSelectableItem(controller, item) |
| { |
| let index = this._indexForRepresentedObject(item); |
| console.assert(index >= 0 && index < this.numberOfRows); |
| |
| return index < this.numberOfRows - 1 ? this._representedObjectForIndex(index + 1) : null; |
| } |
| |
| // Resizer delegate |
| |
| resizerDragStarted(resizer) |
| { |
| console.assert(!this._currentResizer, resizer, this._currentResizer); |
| |
| let resizerIndex = this._resizers.indexOf(resizer); |
| |
| this._currentResizer = resizer; |
| this._resizeLeftColumns = this._visibleColumns.slice(0, resizerIndex + 1).reverse(); // Reversed to simplify iteration. |
| this._resizeRightColumns = this._visibleColumns.slice(resizerIndex + 1); |
| this._resizeOriginalColumnWidths = [].concat(this._columnWidths); |
| } |
| |
| resizerDragging(resizer, positionDelta) |
| { |
| console.assert(resizer === this._currentResizer, resizer, this._currentResizer); |
| if (resizer !== this._currentResizer) |
| return; |
| |
| if (WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL) |
| positionDelta = -positionDelta; |
| |
| // Completely recalculate columns from the original sizes based on the new mouse position. |
| this._columnWidths = [].concat(this._resizeOriginalColumnWidths); |
| |
| if (!positionDelta) { |
| this._applyColumnWidths(); |
| return; |
| } |
| |
| let delta = Math.abs(positionDelta); |
| let leftDirection = positionDelta > 0; |
| let rightDirection = !leftDirection; |
| |
| let columnWidths = this._columnWidths; |
| let visibleColumns = this._visibleColumns; |
| |
| function growableSize(column) { |
| let width = columnWidths[visibleColumns.indexOf(column)]; |
| if (column.maxWidth) |
| return column.maxWidth - width; |
| return Infinity; |
| } |
| |
| function shrinkableSize(column) { |
| let width = columnWidths[visibleColumns.indexOf(column)]; |
| if (column.minWidth) |
| return width - column.minWidth; |
| return width; |
| } |
| |
| function canGrow(column) { |
| return growableSize(column) > 0; |
| } |
| |
| function canShrink(column) { |
| return shrinkableSize(column) > 0; |
| } |
| |
| function columnToResize(columns, isShrinking) { |
| // First find a flexible column we can resize. |
| for (let column of columns) { |
| if (!column.flexible) |
| continue; |
| if (isShrinking ? canShrink(column) : canGrow(column)) |
| return column; |
| } |
| |
| // Failing that see if we can resize the immediately neighbor. |
| let immediateColumn = columns[0]; |
| if ((isShrinking && canShrink(immediateColumn)) || (!isShrinking && canGrow(immediateColumn))) |
| return immediateColumn; |
| |
| // Bail. There isn't anything obvious in the table that can resize. |
| return null; |
| } |
| |
| while (delta > 0) { |
| let leftColumn = columnToResize(this._resizeLeftColumns, leftDirection); |
| let rightColumn = columnToResize(this._resizeRightColumns, rightDirection); |
| if (!leftColumn || !rightColumn) { |
| // No more left or right column to grow or shrink. |
| break; |
| } |
| |
| let incrementalDelta = Math.min(delta, |
| leftDirection ? shrinkableSize(leftColumn) : shrinkableSize(rightColumn), |
| leftDirection ? growableSize(rightColumn) : growableSize(leftColumn)); |
| |
| let leftIndex = this._visibleColumns.indexOf(leftColumn); |
| let rightIndex = this._visibleColumns.indexOf(rightColumn); |
| |
| if (leftDirection) { |
| this._columnWidths[leftIndex] -= incrementalDelta; |
| this._columnWidths[rightIndex] += incrementalDelta; |
| } else { |
| this._columnWidths[leftIndex] += incrementalDelta; |
| this._columnWidths[rightIndex] -= incrementalDelta; |
| } |
| |
| delta -= incrementalDelta; |
| } |
| |
| // We have new column widths. |
| this._widthGeneration++; |
| |
| this._applyColumnWidths(); |
| this._positionHeaderViews(); |
| } |
| |
| resizerDragEnded(resizer) |
| { |
| console.assert(resizer === this._currentResizer, resizer, this._currentResizer); |
| if (resizer !== this._currentResizer) |
| return; |
| |
| this._currentResizer = null; |
| this._resizeLeftColumns = null; |
| this._resizeRightColumns = null; |
| this._resizeOriginalColumnWidths = null; |
| |
| this._positionResizerElements(); |
| this._positionHeaderViews(); |
| } |
| |
| // Private |
| |
| _createHeaderCell(column) |
| { |
| let cell = document.createElement("span"); |
| cell.classList.add("cell", column.identifier); |
| cell.textContent = column.name; |
| |
| if (column.align) |
| cell.classList.add("align-" + column.align); |
| if (column.sortable) { |
| cell.classList.add("sortable"); |
| cell.addEventListener("click", this._handleHeaderCellClicked.bind(this, column)); |
| } |
| |
| cell.addEventListener("contextmenu", this._handleHeaderContextMenu.bind(this, column)); |
| |
| return cell; |
| } |
| |
| _createFillerCell(column) |
| { |
| let cell = document.createElement("span"); |
| cell.classList.add("cell", column.identifier); |
| return cell; |
| } |
| |
| _createCell(column, columnIndex) |
| { |
| let cell = document.createElement("span"); |
| cell.classList.add("cell", column.identifier); |
| if (column.align) |
| cell.classList.add("align-" + column.align); |
| if (this._columnWidths) |
| cell.style.width = this._columnWidths[columnIndex] + "px"; |
| return cell; |
| } |
| |
| _getOrCreateRow(rowIndex) |
| { |
| let cachedRow = this._cachedRows.get(rowIndex); |
| if (cachedRow) |
| return cachedRow; |
| |
| let row = document.createElement("li"); |
| row.__index = rowIndex; |
| row.__widthGeneration = 0; |
| if (this.isRowSelected(rowIndex)) |
| row.classList.add("selected"); |
| |
| this._cachedRows.set(rowIndex, row); |
| return row; |
| } |
| |
| _populatedCellForColumnAndRow(column, columnIndex, rowIndex) |
| { |
| console.assert(rowIndex !== undefined, "Tried to populate a row that did not know its index. Is this the filler row?"); |
| |
| let cell = this._createCell(column, columnIndex); |
| this._delegate.tablePopulateCell(this, cell, column, rowIndex); |
| return cell; |
| } |
| |
| _populateRow(row) |
| { |
| row.removeChildren(); |
| |
| let rowIndex = row.__index; |
| for (let i = 0; i < this._visibleColumns.length; ++i) { |
| let column = this._visibleColumns[i]; |
| let cell = this._populatedCellForColumnAndRow(column, i, rowIndex); |
| row.appendChild(cell); |
| } |
| } |
| |
| _resizeColumnsAndFiller() |
| { |
| if (isNaN(this._cachedWidth) || !this._cachedWidth) |
| this._cachedWidth = this._scrollContainerElement.realOffsetWidth; |
| |
| // Not visible yet. |
| if (!this._cachedWidth) |
| return; |
| |
| let availableWidth = this._cachedWidth; |
| let availableHeight = this._cachedHeight; |
| |
| let contentHeight = this.numberOfRows * this._rowHeight; |
| this._fillerHeight = Math.max(availableHeight - contentHeight, 0); |
| |
| // No change to layout metrics so no resizing is needed. |
| if (this._columnWidths && this._cachedWidth === this._previousCachedWidth) { |
| this._updateFillerRowWithNewHeight(); |
| this._applyColumnWidthsToColumnsIfNeeded(); |
| return; |
| } |
| |
| this._previousCachedWidth = this._cachedWidth; |
| |
| let lockedWidth = 0; |
| let lockedColumnCount = 0; |
| let totalMinimumWidth = 0; |
| |
| for (let column of this._visibleColumns) { |
| if (column.locked) { |
| lockedWidth += column.width; |
| lockedColumnCount++; |
| totalMinimumWidth += column.width; |
| } else if (column.minWidth) |
| totalMinimumWidth += column.minWidth; |
| } |
| |
| let flexibleWidth = availableWidth - lockedWidth; |
| let flexibleColumnCount = this._visibleColumns.length - lockedColumnCount; |
| |
| // NOTE: We will often distribute pixels evenly across flexible columns in the table. |
| // If `availableWidth < totalMinimumWidth` than the table is too small for the minimum |
| // sizes of all the columns and we will start crunching the table (removing pixels from |
| // all flexible columns). This would be the appropriate time to introduce horizontal |
| // scrolling. For now we just remove pixels evenly. |
| // |
| // When distributing pixels, always start from the last column to accept remainder |
| // pixels so we don't always add from one side / to one column. |
| function distributeRemainingPixels(remainder, shrinking) { |
| // No pixels to distribute. |
| if (!remainder) |
| return; |
| |
| let indexToStartAddingRemainderPixels = (this._lastColumnIndexToAcceptRemainderPixel + 1) % this._visibleColumns.length; |
| |
| // Handle tables that are too small or too large. If the size constraints |
| // cause the columns to be too small or large. A second pass will do the |
| // expanding or crunching ignoring constraints. |
| let ignoreConstraints = false; |
| |
| while (remainder > 0) { |
| let initialRemainder = remainder; |
| |
| for (let i = indexToStartAddingRemainderPixels; i < this._columnWidths.length; ++i) { |
| let column = this._visibleColumns[i]; |
| if (column.locked) |
| continue; |
| |
| if (shrinking) { |
| if (ignoreConstraints || (column.minWidth && this._columnWidths[i] > column.minWidth)) { |
| this._columnWidths[i]--; |
| remainder--; |
| } |
| } else { |
| if (ignoreConstraints || (column.maxWidth && this._columnWidths[i] < column.maxWidth)) { |
| this._columnWidths[i]++; |
| remainder--; |
| } else if (!column.maxWidth) { |
| this._columnWidths[i]++; |
| remainder--; |
| } |
| } |
| |
| if (!remainder) { |
| this._lastColumnIndexToAcceptRemainderPixel = i; |
| break; |
| } |
| } |
| |
| if (remainder === initialRemainder && !indexToStartAddingRemainderPixels) { |
| // We have remaining pixels. Start crunching if we need to. |
| if (ignoreConstraints) |
| break; |
| ignoreConstraints = true; |
| } |
| |
| indexToStartAddingRemainderPixels = 0; |
| } |
| |
| console.assert(!remainder, "Should not have undistributed pixels."); |
| } |
| |
| // Two kinds of layouts. Autosize or Resize. |
| if (!this._columnWidths) { |
| // Autosize: Flex all the flexes evenly and trickle out any remaining pixels. |
| this._columnWidths = []; |
| this._lastColumnIndexToAcceptRemainderPixel = 0; |
| |
| let bestFitWidth = 0; |
| let bestFitColumnCount = 0; |
| |
| function bestFit(callback) { |
| while (true) { |
| let remainingFlexibleColumnCount = flexibleColumnCount - bestFitColumnCount; |
| if (!remainingFlexibleColumnCount) |
| return; |
| |
| // Fair size to give each flexible column. |
| let remainingFlexibleWidth = flexibleWidth - bestFitWidth; |
| let flexWidth = Math.floor(remainingFlexibleWidth / remainingFlexibleColumnCount); |
| |
| let didPerformBestFit = false; |
| for (let i = 0; i < this._visibleColumns.length; ++i) { |
| // Already best fit this column. |
| if (this._columnWidths[i]) |
| continue; |
| |
| let column = this._visibleColumns[i]; |
| console.assert(column.flexible, "Non-flexible columns should have been sized earlier", column); |
| |
| // Attempt best fit. |
| let bestWidth = callback(column, flexWidth); |
| if (bestWidth === -1) |
| continue; |
| |
| this._columnWidths[i] = bestWidth; |
| bestFitWidth += bestWidth; |
| bestFitColumnCount++; |
| didPerformBestFit = true; |
| } |
| if (!didPerformBestFit) |
| return; |
| |
| // Repeat with a new flex size now that we have fewer flexible columns. |
| } |
| } |
| |
| // Fit the locked columns. |
| for (let i = 0; i < this._visibleColumns.length; ++i) { |
| let column = this._visibleColumns[i]; |
| if (column.locked) |
| this._columnWidths[i] = column.width; |
| } |
| |
| // Best fit with the preferred initial width for flexible columns. |
| bestFit.call(this, (column, width) => { |
| if (!column.preferredInitialWidth || width <= column.preferredInitialWidth) |
| return -1; |
| return column.preferredInitialWidth; |
| }); |
| |
| // Best fit max size flexible columns. May make more pixels available for other columns. |
| bestFit.call(this, (column, width) => { |
| if (!column.maxWidth || width <= column.maxWidth) |
| return -1; |
| return column.maxWidth; |
| }); |
| |
| // Best fit min size flexible columns. May make less pixels available for other columns. |
| bestFit.call(this, (column, width) => { |
| if (!column.minWidth || width >= column.minWidth) |
| return -1; |
| return column.minWidth; |
| }); |
| |
| // Best fit the remaining flexible columns with the fair remaining size. |
| bestFit.call(this, (column, width) => width); |
| |
| // Distribute any remaining pixels evenly. |
| let remainder = availableWidth - (lockedWidth + bestFitWidth); |
| let shrinking = remainder < 0; |
| distributeRemainingPixels.call(this, Math.abs(remainder), shrinking); |
| } else { |
| // Resize: Distribute pixels evenly across flex columns. |
| console.assert(this._columnWidths.length === this._visibleColumns.length, "Number of columns should not change in a resize."); |
| |
| let originalTotalColumnWidth = 0; |
| for (let width of this._columnWidths) |
| originalTotalColumnWidth += width; |
| |
| let remainder = Math.abs(availableWidth - originalTotalColumnWidth); |
| let shrinking = availableWidth < originalTotalColumnWidth; |
| distributeRemainingPixels.call(this, remainder, shrinking); |
| } |
| |
| // We have new column widths. |
| this._widthGeneration++; |
| |
| // Apply widths. |
| |
| this._updateFillerRowWithNewHeight(); |
| this._applyColumnWidths(); |
| this._positionResizerElements(); |
| this._positionHeaderViews(); |
| } |
| |
| _updateVisibleRows() |
| { |
| let rowHeight = this._rowHeight; |
| let updateOffsetThreshold = rowHeight * 10; |
| let overflowPadding = updateOffsetThreshold * 3; |
| |
| let scrollTop = this._calculateScrollTop(); |
| let scrollableOffsetHeight = this._calculateOffsetHeight(); |
| |
| let visibleRowCount = Math.ceil((scrollableOffsetHeight + (overflowPadding * 2)) / rowHeight); |
| let currentTopMargin = this._topSpacerHeight; |
| let currentBottomMargin = this._bottomSpacerHeight; |
| let currentTableBottom = currentTopMargin + (visibleRowCount * rowHeight); |
| |
| let belowTopThreshold = !currentTopMargin || scrollTop > currentTopMargin + updateOffsetThreshold; |
| let aboveBottomThreshold = !currentBottomMargin || scrollTop + scrollableOffsetHeight < currentTableBottom - updateOffsetThreshold; |
| |
| if (belowTopThreshold && aboveBottomThreshold && !isNaN(this._previousRevealedRowCount)) |
| return; |
| |
| let numberOfRows = this.numberOfRows; |
| this._previousRevealedRowCount = numberOfRows; |
| |
| // Scroll back up if the number of rows was reduced such that the existing |
| // scroll top value is larger than it could otherwise have been. We only |
| // need to do this adjustment if there are more rows than would fit on screen, |
| // because when the filler row activates it will reset our scroll. |
| if (scrollTop) { |
| let rowsThatCanFitOnScreen = Math.ceil(scrollableOffsetHeight / rowHeight); |
| if (numberOfRows >= rowsThatCanFitOnScreen) { |
| let maximumScrollTop = Math.max(0, (numberOfRows * rowHeight) - scrollableOffsetHeight); |
| if (scrollTop > maximumScrollTop) { |
| this._scrollContainerElement.scrollTop = maximumScrollTop; |
| this._cachedScrollTop = maximumScrollTop; |
| } |
| } |
| } |
| |
| let topHiddenRowCount = Math.max(0, Math.floor((scrollTop - overflowPadding) / rowHeight)); |
| let bottomHiddenRowCount = Math.max(0, this._previousRevealedRowCount - topHiddenRowCount - visibleRowCount); |
| |
| let marginTop = topHiddenRowCount * rowHeight; |
| let marginBottom = bottomHiddenRowCount * rowHeight; |
| |
| if (this._topSpacerHeight !== marginTop) { |
| this._topSpacerHeight = marginTop; |
| this._topSpacerElement.style.height = marginTop + "px"; |
| } |
| |
| if (this._bottomDataTableMarginElement !== marginBottom) { |
| this._bottomSpacerHeight = marginBottom; |
| this._bottomSpacerElement.style.height = marginBottom + "px"; |
| } |
| |
| this._visibleRowIndexStart = topHiddenRowCount; |
| this._visibleRowIndexEnd = this._visibleRowIndexStart + visibleRowCount; |
| |
| // Completely remove all rows and add new ones. |
| this._listElement.removeChildren(); |
| |
| // If there are an odd number of rows hidden, the first visible row must be an even row. |
| this._listElement.classList.toggle("even-first-zebra-stripe", !!(topHiddenRowCount % 2)); |
| |
| for (let i = this._visibleRowIndexStart; i < this._visibleRowIndexEnd && i < numberOfRows; ++i) { |
| let row = this._getOrCreateRow(i); |
| this._listElement.appendChild(row); |
| } |
| |
| this._listElement.appendChild(this._fillerRow); |
| } |
| |
| _updateFillerRowWithNewHeight() |
| { |
| if (!this._fillerHeight) { |
| this._scrollContainerElement.classList.remove("not-scrollable"); |
| this._fillerRow.remove(); |
| return; |
| } |
| |
| this._scrollContainerElement.classList.add("not-scrollable"); |
| |
| // In the event that we just made the table not scrollable then the number |
| // of rows can fit on screen. Reset the scroll top. |
| if (this._cachedScrollTop) { |
| this._scrollContainerElement.scrollTop = 0; |
| this._cachedScrollTop = 0; |
| } |
| |
| // Extend past edge some reasonable amount. At least 200px. |
| const paddingPastTheEdge = 200; |
| this._fillerHeight += paddingPastTheEdge; |
| |
| for (let cell of this._fillerRow.children) |
| cell.style.height = this._fillerHeight + "px"; |
| |
| if (!this._fillerRow.parentElement) |
| this._listElement.appendChild(this._fillerRow); |
| } |
| |
| _applyColumnWidths() |
| { |
| for (let i = 0; i < this._headerElement.children.length; ++i) |
| this._headerElement.children[i].style.width = this._columnWidths[i] + "px"; |
| |
| for (let row of this._listElement.children) { |
| for (let i = 0; i < row.children.length; ++i) |
| row.children[i].style.width = this._columnWidths[i] + "px"; |
| row.__widthGeneration = this._widthGeneration; |
| } |
| |
| // Update Table Columns after cells since events may respond to this. |
| for (let i = 0; i < this._visibleColumns.length; ++i) |
| this._visibleColumns[i].width = this._columnWidths[i]; |
| |
| // Create missing cells after we've sized. |
| for (let row of this._listElement.children) { |
| if (row !== this._fillerRow) { |
| if (row.children.length !== this._visibleColumns.length) |
| this._populateRow(row); |
| } |
| } |
| } |
| |
| _applyColumnWidthsToColumnsIfNeeded() |
| { |
| // Apply and create missing cells only if row needs a width update. |
| for (let row of this._listElement.children) { |
| if (row.__widthGeneration !== this._widthGeneration) { |
| for (let i = 0; i < row.children.length; ++i) |
| row.children[i].style.width = this._columnWidths[i] + "px"; |
| if (row !== this._fillerRow) { |
| if (row.children.length !== this._visibleColumns.length) |
| this._populateRow(row); |
| } |
| row.__widthGeneration = this._widthGeneration; |
| } |
| } |
| } |
| |
| _positionResizerElements() |
| { |
| console.assert(this._visibleColumns.length === this._columnWidths.length); |
| |
| // Create the appropriate number of resizers. |
| let resizersNeededCount = this._visibleColumns.length - 1; |
| if (this._resizers.length !== resizersNeededCount) { |
| if (this._resizers.length < resizersNeededCount) { |
| do { |
| let resizer = new WI.Resizer(WI.Resizer.RuleOrientation.Vertical, this); |
| this._resizers.push(resizer); |
| this._resizersElement.appendChild(resizer.element); |
| } while (this._resizers.length < resizersNeededCount); |
| } else { |
| do { |
| let resizer = this._resizers.pop(); |
| this._resizersElement.removeChild(resizer.element); |
| } while (this._resizers.length > resizersNeededCount); |
| } |
| } |
| |
| // Position them. |
| const columnResizerAdjustment = 3; |
| let positionAttribute = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL ? "right" : "left"; |
| let totalWidth = 0; |
| for (let i = 0; i < resizersNeededCount; ++i) { |
| totalWidth += this._columnWidths[i]; |
| this._resizers[i].element.style[positionAttribute] = (totalWidth - columnResizerAdjustment) + "px"; |
| } |
| } |
| |
| _positionHeaderViews() |
| { |
| if (!this.subviews.length) |
| return; |
| |
| let offset = 0; |
| let updates = []; |
| for (let i = 0; i < this._visibleColumns.length; ++i) { |
| let column = this._visibleColumns[i]; |
| let width = this._columnWidths[i]; |
| if (column.headerView) |
| updates.push({headerView: column.headerView, offset, width}); |
| offset += width; |
| } |
| |
| let styleProperty = WI.resolvedLayoutDirection() === WI.LayoutDirection.RTL ? "right" : "left"; |
| for (let {headerView, offset, width} of updates) { |
| headerView.element.style.setProperty(styleProperty, offset + "px"); |
| headerView.element.style.width = width + "px"; |
| headerView.updateLayout(WI.View.LayoutReason.Resize); |
| } |
| } |
| |
| _isRowVisible(rowIndex) |
| { |
| if (!this._previousRevealedRowCount) |
| return false; |
| |
| return rowIndex >= this._visibleRowIndexStart && rowIndex <= this._visibleRowIndexEnd; |
| } |
| |
| _indexToInsertColumn(column) |
| { |
| let currentVisibleColumnIndex = 0; |
| |
| for (let columnIdentifier of this._columnOrder) { |
| if (columnIdentifier === column.identifier) |
| return currentVisibleColumnIndex; |
| if (columnIdentifier === this._visibleColumns[currentVisibleColumnIndex].identifier) { |
| currentVisibleColumnIndex++; |
| if (currentVisibleColumnIndex >= this._visibleColumns.length) |
| break; |
| } |
| } |
| |
| return currentVisibleColumnIndex; |
| } |
| |
| _handleScroll(event) |
| { |
| if (event.type === "mousewheel" && !event.wheelDeltaY) |
| return; |
| |
| this._cachedScrollTop = NaN; |
| this.needsLayout(); |
| } |
| |
| _handleKeyDown(event) |
| { |
| this._selectionController.handleKeyDown(event); |
| } |
| |
| _handleMouseDown(event) |
| { |
| let cell = event.target.closest(".cell"); |
| if (!cell) |
| return; |
| |
| let row = cell.parentElement; |
| if (row === this._fillerRow) |
| return; |
| |
| let rowIndex = row.__index; |
| |
| // Before checking if multiple selection is allowed, check if clicking the |
| // row would cause it to be selected, and whether it is allowed by the delegate. |
| if (!this.isRowSelected(rowIndex) && this._delegate.tableShouldSelectRow) { |
| let columnIndex = Array.from(row.children).indexOf(cell); |
| let column = this._visibleColumns[columnIndex]; |
| if (!this._delegate.tableShouldSelectRow(this, cell, column, rowIndex)) |
| return; |
| } |
| |
| this._selectionController.handleItemMouseDown(this._representedObjectForIndex(rowIndex), event); |
| } |
| |
| _handleContextMenu(event) |
| { |
| let cell = event.target.closest(".cell"); |
| if (!cell) |
| return; |
| |
| let row = cell.parentElement; |
| if (row === this._fillerRow) |
| return; |
| |
| let columnIndex = Array.from(row.children).indexOf(cell); |
| let column = this._visibleColumns[columnIndex]; |
| let rowIndex = row.__index; |
| |
| this._delegate.tableCellContextMenuClicked(this, cell, column, rowIndex, event); |
| } |
| |
| _handleHeaderCellClicked(column, event) |
| { |
| let sortOrder = this._sortOrder; |
| if (sortOrder === WI.Table.SortOrder.Indeterminate) |
| sortOrder = WI.Table.SortOrder.Descending; |
| else if (this._sortColumnIdentifier === column.identifier) |
| sortOrder = sortOrder === WI.Table.SortOrder.Ascending ? WI.Table.SortOrder.Descending : WI.Table.SortOrder.Ascending; |
| |
| this.sortColumnIdentifier = column.identifier; |
| this.sortOrder = sortOrder; |
| } |
| |
| _handleHeaderContextMenu(column, event) |
| { |
| let contextMenu = WI.ContextMenu.createFromEvent(event); |
| |
| if (column.sortable) { |
| if (this.sortColumnIdentifier !== column.identifier || this.sortOrder !== WI.Table.SortOrder.Ascending) { |
| contextMenu.appendItem(WI.UIString("Sort Ascending"), () => { |
| this.sortColumnIdentifier = column.identifier; |
| this.sortOrder = WI.Table.SortOrder.Ascending; |
| }); |
| } |
| |
| if (this.sortColumnIdentifier !== column.identifier || this.sortOrder !== WI.Table.SortOrder.Descending) { |
| contextMenu.appendItem(WI.UIString("Sort Descending"), () => { |
| this.sortColumnIdentifier = column.identifier; |
| this.sortOrder = WI.Table.SortOrder.Descending; |
| }); |
| } |
| } |
| |
| contextMenu.appendSeparator(); |
| |
| let didAppendHeaderItem = false; |
| |
| for (let [columnIdentifier, column] of this._columnSpecs) { |
| if (column.locked) |
| continue; |
| if (!column.hideable) |
| continue; |
| |
| // Add a header item before the list of toggleable columns. |
| if (!didAppendHeaderItem) { |
| const disabled = true; |
| contextMenu.appendItem(WI.UIString("Displayed Columns"), () => {}, disabled); |
| didAppendHeaderItem = true; |
| } |
| |
| let checked = !column.hidden; |
| contextMenu.appendCheckboxItem(column.name, () => { |
| if (column.hidden) |
| this.showColumn(column); |
| else |
| this.hideColumn(column); |
| }, checked); |
| } |
| } |
| |
| _removeRows(representedObjects) |
| { |
| let removed = 0; |
| |
| let adjustRowAtIndex = (index) => { |
| let row = this._cachedRows.get(index); |
| if (row) { |
| this._cachedRows.delete(index); |
| row.__index -= removed; |
| this._cachedRows.set(row.__index, row); |
| } |
| }; |
| |
| let rowIndexes = []; |
| for (let object of representedObjects) |
| rowIndexes.push(this._indexForRepresentedObject(object)); |
| |
| rowIndexes.sort((a, b) => a - b); |
| |
| let lastIndex = rowIndexes.lastValue; |
| for (let index = rowIndexes[0]; index <= lastIndex; ++index) { |
| if (rowIndexes.binaryIndexOf(index) >= 0) { |
| let row = this._cachedRows.get(index); |
| if (row) { |
| this._cachedRows.delete(index); |
| row.remove(); |
| } |
| removed++; |
| continue; |
| } |
| |
| if (removed) |
| adjustRowAtIndex(index); |
| } |
| |
| if (!removed) |
| return; |
| |
| for (let index = lastIndex + 1; index < this.numberOfRows; ++index) |
| adjustRowAtIndex(index); |
| |
| |
| this._selectionController.didRemoveItems(representedObjects); |
| |
| if (this._delegate.tableDidRemoveRows) |
| this._delegate.tableDidRemoveRows(this, rowIndexes); |
| } |
| |
| _indexForRepresentedObject(object) |
| { |
| return this.dataSource.tableIndexForRepresentedObject(this, object); |
| } |
| |
| _representedObjectForIndex(index) |
| { |
| return this.dataSource.tableRepresentedObjectForIndex(this, index); |
| } |
| |
| _calculateOffsetHeight() |
| { |
| if (isNaN(this._cachedHeight)) |
| this._cachedHeight = this._scrollContainerElement.realOffsetHeight; |
| return this._cachedHeight; |
| } |
| |
| _calculateScrollTop() |
| { |
| if (isNaN(this._cachedScrollTop)) |
| this._cachedScrollTop = this._scrollContainerElement.scrollTop; |
| return this._cachedScrollTop; |
| } |
| }; |
| |
| WI.Table.SortOrder = { |
| Indeterminate: "table-sort-order-indeterminate", |
| Ascending: "table-sort-order-ascending", |
| Descending: "table-sort-order-descending", |
| }; |