/* * 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); } restyleRow(rowIndex) { if (!this._isRowVisible(rowIndex)) return; let row = this._cachedRows.get(rowIndex); if (!row) return; this._styleRow(row); } 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; this._styleRow(row); if (this._delegate.tableRowHovered) { this._boundHandleRowMouseEnter ??= this._handleRowMouseEnter.bind(this); this._boundHandleRowMouseLeave ??= this._handleRowMouseLeave.bind(this); row.addEventListener("mouseenter", this._boundHandleRowMouseEnter); row.addEventListener("mouseleave", this._boundHandleRowMouseLeave); } this._cachedRows.set(rowIndex, row); return row; } _styleRow(row) { let selected = row.classList.contains("selected"); row.className = ""; if (selected || this.isRowSelected(row.__index)) row.classList.add("selected"); if (this._delegate.tableRowClassNames) row.classList.add(...this._delegate.tableRowClassNames(this, row.__index)); } _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); } } _handleRowMouseEnter(event) { let row = event.target; this.delegate.tableRowHovered(this, row.__index); } _handleRowMouseLeave(event) { this.delegate.tableRowHovered(this, NaN); } _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", };