chrome-devtools-frontend
Version:
Chrome DevTools UI
1,530 lines (1,382 loc) • 78.5 kB
JavaScript
/*
* Copyright (C) 2008 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. ``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
* 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.
*/
import * as Common from '../common/common.js';
import * as i18n from '../i18n/i18n.js';
import * as Platform from '../platform/platform.js';
import * as UI from '../ui/ui.js';
export const UIStrings = {
/**
*@description Accessible text label for expandible nodes in datagrids
*/
expanded: 'expanded',
/**
*@description accessible name for expandible nodes in datagrids
*/
collapsed: 'collapsed',
/**
*@description Accessible text for datagrid
*@example {Coverage grid} PH1
*@example {expanded} PH2
*/
sRowS: '{PH1} Row {PH2}',
/**
*@description Number of rows in a grid
*@example {1} PH1
*/
rowsS: 'Rows: {PH1}',
/**
* @description Default Accessible Text for a Datagrid. This text is read to the user by a
* screenreader when they navigate to a table structure. The placeholders tell the user something
* brief about the table contents i.e. the topic and how much data is in it.
* @example {Network} PH1
* @example {Rows: 27} PH2
*/
sSUseTheUpAndDownArrowKeysTo:
'{PH1} {PH2}, use the up and down arrow keys to navigate and interact with the rows of the table; Use browse mode to read cell by cell.',
/**
*@description A context menu item in the Data Grid of a data grid
*/
sortByString: 'Sort By',
/**
*@description A context menu item in data grids to reset the columns to their default weight
*/
resetColumns: 'Reset Columns',
/**
*@description A context menu item in data grids to list header options.
*/
headerOptions: 'Header Options',
/**
*@description Text to refresh the page
*/
refresh: 'Refresh',
/**
*@description A context menu item in the Data Grid of a data grid
*/
addNew: 'Add new',
/**
*@description A context menu item in the Data Grid of a data grid
*@example {pattern} PH1
*/
editS: 'Edit "{PH1}"',
/**
*@description Text to delete something
*/
delete: 'Delete',
/**
*@description Depth of a node in the datagrid
*@example {1} PH1
*/
levelS: 'level {PH1}',
/**
*@description Text exposed to screen readers on checked items.
*/
checked: 'checked',
};
const str_ = i18n.i18n.registerUIStrings('data_grid/DataGrid.js', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
/** @type {!WeakMap<!Element, string>} */
const elementToLongTextMap = new WeakMap();
/** @type {!WeakMap<!Node, string>} */
const nodeToColumnIdMap = new WeakMap();
/** @type {!WeakMap<!Element, !UI.Icon.Icon>} */
const elementToSortIconMap = new WeakMap();
/** @type {!WeakMap<!Element, number>} */
const elementToPreferedWidthMap = new WeakMap();
/** @type {!WeakMap<!Element, number>} */
const elementToPositionMap = new WeakMap();
/** @type {!WeakMap<!Element, number>} */
const elementToIndexMap = new WeakMap();
/**
* @template NODE_TYPE
*/
export class DataGridImpl extends Common.ObjectWrapper.ObjectWrapper {
/**
* @param {!Parameters} dataGridParameters
*/
constructor(dataGridParameters) {
super();
const {displayName, columns: columnsArray, editCallback, deleteCallback, refreshCallback} = dataGridParameters;
this.element = document.createElement('div');
this.element.classList.add('data-grid');
UI.Utils.appendStyle(this.element, 'data_grid/dataGrid.css', {enableLegacyPatching: true});
this.element.tabIndex = 0;
this.element.addEventListener('keydown', this._keyDown.bind(this), false);
this.element.addEventListener('contextmenu', this._contextMenu.bind(this), true);
this.element.addEventListener('focusin', event => {
this.updateGridAccessibleNameOnFocus();
event.consume(true);
});
this.element.addEventListener('focusout', event => {
this.updateGridAccessibleName(/* text */ '');
event.consume(true);
});
UI.ARIAUtils.markAsApplication(this.element);
this._displayName = displayName;
this._editCallback = editCallback;
this._deleteCallback = deleteCallback;
this._refreshCallback = refreshCallback;
const headerContainer = this.element.createChild('div', 'header-container');
/** @type {!Element} */
this._headerTable = headerContainer.createChild('table', 'header');
// Hide the header table from screen readers since titles are also added to data table.
UI.ARIAUtils.markAsHidden(this._headerTable);
/** @type {!Object.<string, !Element>} */
this._headerTableHeaders = {};
/** @type {!Element} */
this._scrollContainer = this.element.createChild('div', 'data-container');
/** @type {!Element} */
this._dataTable = this._scrollContainer.createChild('table', 'data');
// FIXME: Add a createCallback which is different from editCallback and has different
// behavior when creating a new node.
if (editCallback) {
this._dataTable.addEventListener('dblclick', this._ondblclick.bind(this), false);
}
this._dataTable.addEventListener('mousedown', this._mouseDownInDataTable.bind(this));
this._dataTable.addEventListener('click', this._clickInDataTable.bind(this), true);
/** @type {boolean} */
this._inline = false;
/** @type {!Array.<!ColumnDescriptor>} */
this._columnsArray = [];
/** @type {!Object.<string, !ColumnDescriptor>} */
this._columns = {};
/** @type {!Array.<!ColumnDescriptor>} */
this.visibleColumnsArray = columnsArray;
columnsArray.forEach(column => this._innerAddColumn(column));
/** @type {?string} */
this._cellClass = null;
/** @type {!Element} */
this._headerTableColumnGroup = this._headerTable.createChild('colgroup');
/** @type {!HTMLTableSectionElement} */
this._headerTableBody = /** @type {!HTMLTableSectionElement} */ (this._headerTable.createChild('tbody'));
/** @type {!Element} */
this._headerRow = this._headerTableBody.createChild('tr');
/** @type {!Element} */
this._dataTableColumnGroup = this._dataTable.createChild('colgroup');
/**
* @type {!Element}
*/
this.dataTableBody = this._dataTable.createChild('tbody');
/** @type {!HTMLElement} */
this._topFillerRow =
/** @type {!HTMLElement} */ (this.dataTableBody.createChild('tr', 'data-grid-filler-row revealed'));
/** @type {!HTMLElement} */
this._bottomFillerRow =
/** @type {!HTMLElement} */ (this.dataTableBody.createChild('tr', 'data-grid-filler-row revealed'));
this.setVerticalPadding(0, 0);
this._refreshHeader();
/** @type {boolean} */
this._editing = false;
/** @type {?DataGridNode<!NODE_TYPE>} */
this.selectedNode = null;
/** @type {boolean} */
this.expandNodesWhenArrowing = false;
this.setRootNode(/** @type {!DataGridNode<!NODE_TYPE>} */ (new DataGridNode()));
this.setHasSelection(false);
/** @type {number} */
this.indentWidth = 15;
/** @type {!Array.<!HTMLElement>} */
this._resizers = [];
/** @type {boolean} */
this._columnWidthsInitialized = false;
/** @type {number} */
this._cornerWidth = CornerWidth;
/** @type {!ResizeMethod} */
this._resizeMethod = ResizeMethod.Nearest;
/** @type {?function(!UI.ContextMenu.SubMenu):void} */
this._headerContextMenuCallback = null;
/** @type {?function(!UI.ContextMenu.ContextMenu, !DataGridNode<!NODE_TYPE>):void} */
this._rowContextMenuCallback = null;
/**
* @type {!WeakMap<!Node, !DataGridNode<!NODE_TYPE>>}
*/
this.elementToDataGridNode = new WeakMap();
}
/**
* @return {?DataGridNode<!NODE_TYPE>|undefined}
*/
_firstSelectableNode() {
let firstSelectableNode = this._rootNode;
while (firstSelectableNode && !firstSelectableNode.selectable) {
firstSelectableNode = firstSelectableNode.traverseNextNode(true) || undefined;
}
return firstSelectableNode;
}
/**
* @return {!DataGridNode<!NODE_TYPE>|undefined}
*/
_lastSelectableNode() {
let lastSelectableNode = this._rootNode;
let iterator = this._rootNode;
while (iterator) {
if (iterator.selectable) {
lastSelectableNode = iterator;
}
iterator = iterator.traverseNextNode(true) || undefined;
}
return lastSelectableNode;
}
/**
* @param {!Element} element
* @param {*} value
*/
setElementContent(element, value) {
const columnId = this.columnIdFromNode(element);
if (!columnId) {
return;
}
const column = this._columns[columnId];
if (column.dataType === DataType.Boolean) {
DataGridImpl.setElementBoolean(element, /** @type {boolean} */ (Boolean(value)));
} else if (value !== null) {
DataGridImpl.setElementText(element, /** @type {string} */ (value), Boolean(column.longText));
}
}
/**
* @param {!Element} element
* @param {string} newText
* @param {boolean} longText
*/
static setElementText(element, newText, longText) {
if (longText && newText.length > 1000) {
element.textContent = Platform.StringUtilities.trimEndWithMaxLength(newText, 1000);
UI.Tooltip.Tooltip.install(element, newText);
elementToLongTextMap.set(element, newText);
} else {
element.textContent = newText;
UI.Tooltip.Tooltip.install(element, '');
elementToLongTextMap.delete(element);
}
}
/**
* @param {!Element} element
* @param {boolean} value
*/
static setElementBoolean(element, value) {
element.textContent = value ? '\u2713' : '';
UI.Tooltip.Tooltip.install(element, '');
}
/**
* @param {boolean} isStriped
*/
setStriped(isStriped) {
this.element.classList.toggle('striped-data-grid', isStriped);
}
/**
* @param {boolean} focusable
*/
setFocusable(focusable) {
this.element.tabIndex = focusable ? 0 : -1;
if (focusable === false) {
UI.ARIAUtils.removeRole(this.element);
}
}
/**
* @param {boolean} hasSelected
*/
setHasSelection(hasSelected) {
// 'no-selection' class causes datagrid to have a focus-indicator border
this.element.classList.toggle('no-selection', !hasSelected);
}
/**
* @param {string=} text
*/
updateGridAccessibleName(text) {
// Update the label with the provided text or the current selected node
const accessibleText = (this.selectedNode && this.selectedNode.existingElement()) ? this.selectedNode.nodeAccessibleText : '';
if (this.element === this.element.ownerDocument.deepActiveElement()) {
// Only alert if the datagrid has focus
UI.ARIAUtils.alert(text ? text : accessibleText, this.element);
}
}
updateGridAccessibleNameOnFocus() {
// When a grid gets focus
// 1) If an item is selected - Read the content of the row
let accessibleText;
if (this.selectedNode && this.selectedNode.existingElement()) {
let expandText = '';
if (this.selectedNode.hasChildren()) {
expandText = this.selectedNode.expanded ? i18nString(UIStrings.expanded) : i18nString(UIStrings.collapsed);
}
const rowHeader = i18nString(UIStrings.sRowS, {PH1: this._displayName, PH2: expandText});
accessibleText = `${rowHeader} ${this.selectedNode.nodeAccessibleText}`;
} else {
// 2) If there is no selected item - Read the name of the grid and give instructions
if (!this._rootNode) {
return;
}
const children = this._enumerateChildren(this._rootNode, [], 1);
const items = i18nString(UIStrings.rowsS, {PH1: children.length});
accessibleText = i18nString(UIStrings.sSUseTheUpAndDownArrowKeysTo, {PH1: this._displayName, PH2: items});
}
UI.ARIAUtils.alert(accessibleText, this.element);
}
/**
* @return {!Element}
*/
headerTableBody() {
return this._headerTableBody;
}
/**
* @param {!ColumnDescriptor} column
* @param {number=} position
*/
_innerAddColumn(column, position) {
column.defaultWeight = column.weight;
const columnId = column.id;
if (columnId in this._columns) {
this._innerRemoveColumn(columnId);
}
if (position === undefined) {
position = this._columnsArray.length;
}
this._columnsArray.splice(position, 0, column);
this._columns[columnId] = column;
if (column.disclosure) {
this.disclosureColumnId = columnId;
}
const cell = document.createElement('th');
cell.className = columnId + '-column';
nodeToColumnIdMap.set(cell, columnId);
this._headerTableHeaders[columnId] = cell;
const div = document.createElement('div');
if (column.titleDOMFragment) {
div.appendChild(column.titleDOMFragment);
} else {
div.textContent = column.title || null;
}
cell.appendChild(div);
if (column.sort) {
cell.classList.add(column.sort);
/** @type {!Element} */
this._sortColumnCell = cell;
}
if (column.sortable) {
cell.addEventListener('click', this._clickInHeaderCell.bind(this), false);
cell.classList.add('sortable');
const icon = UI.Icon.Icon.create('', 'sort-order-icon');
cell.createChild('div', 'sort-order-icon-container').appendChild(icon);
elementToSortIconMap.set(cell, icon);
}
}
/**
* @param {!ColumnDescriptor} column
* @param {number=} position
*/
addColumn(column, position) {
this._innerAddColumn(column, position);
}
/**
* @param {string} columnId
*/
_innerRemoveColumn(columnId) {
const column = this._columns[columnId];
if (!column) {
return;
}
delete this._columns[columnId];
const index = this._columnsArray.findIndex(columnConfig => columnConfig.id === columnId);
this._columnsArray.splice(index, 1);
const cell = this._headerTableHeaders[columnId];
if (cell.parentElement) {
cell.parentElement.removeChild(cell);
}
delete this._headerTableHeaders[columnId];
}
/**
* @param {string} columnId
*/
removeColumn(columnId) {
this._innerRemoveColumn(columnId);
}
/**
* @param {string} cellClass
*/
setCellClass(cellClass) {
this._cellClass = cellClass;
}
_refreshHeader() {
this._headerTableColumnGroup.removeChildren();
this._dataTableColumnGroup.removeChildren();
this._headerRow.removeChildren();
this._topFillerRow.removeChildren();
this._bottomFillerRow.removeChildren();
for (let i = 0; i < this.visibleColumnsArray.length; ++i) {
const column = this.visibleColumnsArray[i];
const columnId = column.id;
const headerColumn = /** @type {!HTMLElement} */ (this._headerTableColumnGroup.createChild('col'));
const dataColumn = /** @type {!HTMLElement} */ (this._dataTableColumnGroup.createChild('col'));
if (column.width) {
headerColumn.style.width = column.width;
dataColumn.style.width = column.width;
}
this._headerRow.appendChild(this._headerTableHeaders[columnId]);
const topFillerRowCell =
/** @type {!HTMLTableCellElement} */ (this._topFillerRow.createChild('th', 'top-filler-td'));
topFillerRowCell.textContent = column.title || null;
topFillerRowCell.scope = 'col';
const bottomFillerRowChild = this._bottomFillerRow.createChild('td', 'bottom-filler-td');
nodeToColumnIdMap.set(bottomFillerRowChild, columnId);
}
this._headerRow.createChild('th', 'corner');
const topFillerRowCornerCell =
/** @type {!HTMLTableCellElement} */ (this._topFillerRow.createChild('th', 'corner'));
topFillerRowCornerCell.classList.add('top-filler-td');
topFillerRowCornerCell.scope = 'col';
this._bottomFillerRow.createChild('td', 'corner').classList.add('bottom-filler-td');
this._headerTableColumnGroup.createChild('col', 'corner');
this._dataTableColumnGroup.createChild('col', 'corner');
}
/**
* @param {number} top
* @param {number} bottom
* @protected
*/
setVerticalPadding(top, bottom) {
const topPx = top + 'px';
const bottomPx = (top || bottom) ? bottom + 'px' : 'auto';
if (this._topFillerRow.style.height === topPx && this._bottomFillerRow.style.height === bottomPx) {
return;
}
this._topFillerRow.style.height = topPx;
this._bottomFillerRow.style.height = bottomPx;
this.dispatchEventToListeners(Events.PaddingChanged);
}
/**
* @param {!DataGridNode<!NODE_TYPE>} rootNode
* @protected
*/
setRootNode(rootNode) {
if (this._rootNode) {
this._rootNode.removeChildren();
this._rootNode.dataGrid = null;
this._rootNode._isRoot = false;
}
/** @type {!DataGridNode<!NODE_TYPE>} */
this._rootNode = rootNode;
rootNode._isRoot = true;
rootNode.setHasChildren(false);
rootNode._expanded = true;
rootNode._revealed = true;
rootNode.selectable = false;
rootNode.dataGrid = this;
}
/**
* @return {!DataGridNode<!NODE_TYPE>}
*/
rootNode() {
let rootNode = this._rootNode;
if (!rootNode) {
rootNode = new DataGridNode();
this.setRootNode(rootNode);
}
return rootNode;
}
/**
* @param {!Event} event
*/
_ondblclick(event) {
if (this._editing || this._editingNode) {
return;
}
const columnId = this.columnIdFromNode(/** @type {!Node} */ (event.target));
if (!columnId || !this._columns[columnId].editable) {
return;
}
this._startEditing(/** @type {!Node} */ (event.target));
}
/**
* @param {!DataGridNode<!NODE_TYPE>} node
* @param {number} cellIndex
*/
_startEditingColumnOfDataGridNode(node, cellIndex) {
this._editing = true;
/** @type {?DataGridNode<!NODE_TYPE>} */
this._editingNode = node;
this._editingNode.select();
const editingNodeElement = this._editingNode._element;
if (!editingNodeElement) {
return;
}
const element = editingNodeElement.children[cellIndex];
const elementLongText = elementToLongTextMap.get(element);
if (elementLongText) {
element.textContent = elementLongText;
}
const column = this.visibleColumnsArray[cellIndex];
if (column.dataType === DataType.Boolean) {
const checkboxLabel = UI.UIUtils.CheckboxLabel.create(undefined, /** @type {boolean} */ (node.data[column.id]));
UI.ARIAUtils.setAccessibleName(checkboxLabel, column.title || '');
let hasChanged = false;
checkboxLabel.style.height = '100%';
const checkboxElement = checkboxLabel.checkboxElement;
checkboxElement.classList.add('inside-datagrid');
const initialValue = checkboxElement.checked;
checkboxElement.addEventListener('change', () => {
hasChanged = true;
this._editingCommitted(element, checkboxElement.checked, initialValue, undefined, 'forward');
}, false);
checkboxElement.addEventListener('keydown', event => {
if (event.key === 'Tab') {
event.consume(true);
hasChanged = true;
return this._editingCommitted(
element, checkboxElement.checked, initialValue, undefined, event.shiftKey ? 'backward' : 'forward');
}
if (event.key === ' ') {
event.consume(true);
checkboxElement.checked = !checkboxElement.checked;
} else if (event.key === 'Enter') {
event.consume(true);
hasChanged = true;
this._editingCommitted(element, checkboxElement.checked, initialValue, undefined, 'forward');
}
}, false);
checkboxElement.addEventListener('blur', () => {
if (hasChanged) {
return;
}
this._editingCommitted(element, checkboxElement.checked, checkboxElement.checked, undefined, 'next');
}, false);
element.innerHTML = '';
element.appendChild(checkboxLabel);
checkboxElement.focus();
} else {
UI.InplaceEditor.InplaceEditor.startEditing(element, this._startEditingConfig(element));
const componentSelection = element.getComponentSelection();
if (componentSelection) {
componentSelection.selectAllChildren(element);
}
}
}
/**
* @param {!DataGridNode<!NODE_TYPE>} node
* @param {string} columnIdentifier
*/
startEditingNextEditableColumnOfDataGridNode(node, columnIdentifier) {
const column = this._columns[columnIdentifier];
const cellIndex = this.visibleColumnsArray.indexOf(column);
const nextEditableColumn = this._nextEditableColumn(cellIndex);
if (nextEditableColumn !== -1) {
this._startEditingColumnOfDataGridNode(node, nextEditableColumn);
}
}
/**
* @param {!Node} target
*/
_startEditing(target) {
const element = /** @type {?Element} */ (UI.UIUtils.enclosingNodeOrSelfWithNodeName(target, 'td'));
if (!element) {
return;
}
this._editingNode = this.dataGridNodeFromNode(target);
if (!this._editingNode) {
if (!this.creationNode) {
return;
}
this._editingNode = this.creationNode;
}
// Force editing the 1st column when editing the creation node
if (this._editingNode instanceof CreationDataGridNode && this._editingNode.isCreationNode) {
this._startEditingColumnOfDataGridNode(this._editingNode, this._nextEditableColumn(-1));
return;
}
const columnId = this.columnIdFromNode(target);
if (!columnId) {
return;
}
const column = this._columns[columnId];
const cellIndex = this.visibleColumnsArray.indexOf(column);
if (this._editingNode) {
this._startEditingColumnOfDataGridNode(this._editingNode, cellIndex);
}
}
renderInline() {
this.element.classList.add('inline');
this._cornerWidth = 0;
this._inline = true;
this.updateWidths();
}
/**
* @param {!Element} element
* @return {!UI.InplaceEditor.Config<?>}
*/
_startEditingConfig(element) {
return new UI.InplaceEditor.Config(this._editingCommitted.bind(this), this._editingCancelled.bind(this));
}
/**
* @param {!Element} element
* @param {*} newText
* @param {*} oldText
* @param {string|undefined} context
* @param {string} moveDirection
*/
_editingCommitted(element, newText, oldText, context, moveDirection) {
const columnId = this.columnIdFromNode(element);
if (!columnId) {
this._editingCancelled(element);
return;
}
const column = this._columns[columnId];
const cellIndex = this.visibleColumnsArray.indexOf(column);
if (!this._editingNode) {
return;
}
const valueBeforeEditing = /** @type {string|boolean} */ (
this._editingNode.data[columnId] === null ? '' : this._editingNode.data[columnId]);
const currentEditingNode = this._editingNode;
/**
* @param {boolean} wasChange
* @this {DataGridImpl<!NODE_TYPE>}
*/
function moveToNextIfNeeded(wasChange) {
if (!moveDirection) {
return;
}
if (moveDirection === 'forward') {
const firstEditableColumn = this._nextEditableColumn(-1);
const isCreationNode = currentEditingNode instanceof CreationDataGridNode && currentEditingNode.isCreationNode;
if (isCreationNode && cellIndex === firstEditableColumn && !wasChange) {
return;
}
const nextEditableColumn = this._nextEditableColumn(cellIndex);
if (nextEditableColumn !== -1) {
this._startEditingColumnOfDataGridNode(currentEditingNode, nextEditableColumn);
return;
}
const nextDataGridNode = currentEditingNode.traverseNextNode(true, null, true);
if (nextDataGridNode) {
this._startEditingColumnOfDataGridNode(nextDataGridNode, firstEditableColumn);
return;
}
if (isCreationNode && wasChange && this.creationNode) {
this.addCreationNode(false);
this._startEditingColumnOfDataGridNode(this.creationNode, firstEditableColumn);
return;
}
return;
}
if (moveDirection === 'backward') {
const prevEditableColumn = this._nextEditableColumn(cellIndex, true);
if (prevEditableColumn !== -1) {
this._startEditingColumnOfDataGridNode(currentEditingNode, prevEditableColumn);
return;
}
const lastEditableColumn = this._nextEditableColumn(this.visibleColumnsArray.length, true);
const nextDataGridNode = currentEditingNode.traversePreviousNode(true, true);
if (nextDataGridNode) {
this._startEditingColumnOfDataGridNode(nextDataGridNode, lastEditableColumn);
}
return;
}
}
// Show trimmed text after editing.
this.setElementContent(element, newText);
if (valueBeforeEditing === newText) {
this._editingCancelled(element);
moveToNextIfNeeded.call(this, false);
return;
}
// Update the text in the datagrid that we typed
this._editingNode.data[columnId] = newText;
if (!this._editCallback) {
return;
}
// Make the callback - expects an editing node (table row), the column number that is being edited,
// the text that used to be there, and the new text.
this._editCallback(this._editingNode, columnId, valueBeforeEditing, newText);
if (this._editingNode instanceof CreationDataGridNode && this._editingNode.isCreationNode) {
this.addCreationNode(false);
}
this._editingCancelled(element);
moveToNextIfNeeded.call(this, true);
}
/**
* @param {!Element} element
*/
_editingCancelled(element) {
this._editing = false;
this._editingNode = null;
}
/**
* @param {number} cellIndex
* @param {boolean=} moveBackward
* @return {number}
*/
_nextEditableColumn(cellIndex, moveBackward) {
const increment = moveBackward ? -1 : 1;
const columns = this.visibleColumnsArray;
for (let i = cellIndex + increment; (i >= 0) && (i < columns.length); i += increment) {
if (columns[i].editable) {
return i;
}
}
return -1;
}
/**
* @return {?string}
*/
sortColumnId() {
if (!this._sortColumnCell) {
return null;
}
return nodeToColumnIdMap.get(this._sortColumnCell) || null;
}
/**
* @return {?string}
*/
sortOrder() {
if (!this._sortColumnCell || this._sortColumnCell.classList.contains(Order.Ascending)) {
return Order.Ascending;
}
if (this._sortColumnCell.classList.contains(Order.Descending)) {
return Order.Descending;
}
return null;
}
/**
* @return {boolean}
*/
isSortOrderAscending() {
return !this._sortColumnCell || this._sortColumnCell.classList.contains(Order.Ascending);
}
/**
* @param {!Array.<number>} widths
* @param {number} minPercent
* @param {number=} maxPercent
* @return {!Array.<number>}
*/
_autoSizeWidths(widths, minPercent, maxPercent) {
if (minPercent) {
minPercent = Math.min(minPercent, Math.floor(100 / widths.length));
}
let totalWidth = 0;
for (let i = 0; i < widths.length; ++i) {
totalWidth += widths[i];
}
let totalPercentWidth = 0;
for (let i = 0; i < widths.length; ++i) {
let width = Math.round(100 * widths[i] / totalWidth);
if (minPercent && width < minPercent) {
width = minPercent;
} else if (maxPercent && width > maxPercent) {
width = maxPercent;
}
totalPercentWidth += width;
widths[i] = width;
}
let recoupPercent = totalPercentWidth - 100;
while (minPercent && recoupPercent > 0) {
for (let i = 0; i < widths.length; ++i) {
if (widths[i] > minPercent) {
--widths[i];
--recoupPercent;
if (!recoupPercent) {
break;
}
}
}
}
while (maxPercent && recoupPercent < 0) {
for (let i = 0; i < widths.length; ++i) {
if (widths[i] < maxPercent) {
++widths[i];
++recoupPercent;
if (!recoupPercent) {
break;
}
}
}
}
return widths;
}
/**
* The range of |minPercent| and |maxPercent| is [0, 100].
* @param {number} minPercent
* @param {number=} maxPercent
* @param {number=} maxDescentLevel
*/
autoSizeColumns(minPercent, maxPercent, maxDescentLevel) {
let widths = [];
for (let i = 0; i < this._columnsArray.length; ++i) {
widths.push((this._columnsArray[i].title || '').length);
}
maxDescentLevel = maxDescentLevel || 0;
if (!this._rootNode) {
return;
}
const children = this._enumerateChildren(this._rootNode, [], maxDescentLevel + 1);
for (let i = 0; i < children.length; ++i) {
const node = children[i];
for (let j = 0; j < this._columnsArray.length; ++j) {
const text = String(node.data[this._columnsArray[j].id]);
if (text.length > widths[j]) {
widths[j] = text.length;
}
}
}
widths = this._autoSizeWidths(widths, minPercent, maxPercent);
for (let i = 0; i < this._columnsArray.length; ++i) {
this._columnsArray[i].weight = widths[i];
}
this._columnWidthsInitialized = false;
this.updateWidths();
}
/**
* @param {!DataGridNode<!NODE_TYPE>} rootNode
* @param {!Array<!DataGridNode<!NODE_TYPE>>} result
* @param {number} maxLevel
* @return {!Array<!DataGridNode<!NODE_TYPE>>}
*/
_enumerateChildren(rootNode, result, maxLevel) {
if (!rootNode._isRoot) {
result.push(rootNode);
}
if (!maxLevel) {
return [];
}
for (let i = 0; i < rootNode.children.length; ++i) {
this._enumerateChildren(rootNode.children[i], result, maxLevel - 1);
}
return result;
}
onResize() {
this.updateWidths();
}
// 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.
updateWidths() {
// Do not attempt to use offsetes 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.
// Use container size to avoid changes of table width caused by change of column widths.
const tableWidth = this.element.offsetWidth - this._cornerWidth;
const cells = this._headerTableBody.rows[0].cells;
const numColumns = cells.length - 1; // Do not process corner column.
for (let i = 0; i < numColumns; i++) {
const column = this.visibleColumnsArray[i];
if (!column.weight) {
column.weight = 100 * cells[i].offsetWidth / tableWidth || 10;
}
}
this._columnWidthsInitialized = true;
}
this._applyColumnWeights();
}
/**
* @param {string} columnId
* @returns {number}
*/
indexOfVisibleColumn(columnId) {
return this.visibleColumnsArray.findIndex(column => column.id === columnId);
}
/**
* @param {string} name
*/
setName(name) {
this._columnWeightsSetting =
Common.Settings.Settings.instance().createSetting('dataGrid-' + name + '-columnWeights', {});
this._loadColumnWeights();
}
_resetColumnWeights() {
for (const column of this._columnsArray) {
if (!column.defaultWeight) {
continue;
}
column.weight = column.defaultWeight;
}
this._applyColumnWeights();
this._saveColumnWeights();
}
_loadColumnWeights() {
if (!this._columnWeightsSetting) {
return;
}
const weights = this._columnWeightsSetting.get();
for (let i = 0; i < this._columnsArray.length; ++i) {
const column = this._columnsArray[i];
const weight = weights[column.id];
if (weight) {
column.weight = weight;
}
}
this._applyColumnWeights();
}
_saveColumnWeights() {
if (!this._columnWeightsSetting) {
return;
}
/** @type {!Object<string,*>} */
const weights = {};
for (let i = 0; i < this._columnsArray.length; ++i) {
const column = this._columnsArray[i];
weights[column.id] = column.weight;
}
this._columnWeightsSetting.set(weights);
}
wasShown() {
this._loadColumnWeights();
}
willHide() {
}
_applyColumnWeights() {
let tableWidth = this.element.offsetWidth - this._cornerWidth;
if (tableWidth <= 0) {
return;
}
let sumOfWeights = 0.0;
const fixedColumnWidths = [];
for (let i = 0; i < this.visibleColumnsArray.length; ++i) {
const column = this.visibleColumnsArray[i];
if (column.fixedWidth) {
const currentChild = this._headerTableColumnGroup.children[i];
const width = elementToPreferedWidthMap.get(currentChild) || this._headerTableBody.rows[0].cells[i].offsetWidth;
fixedColumnWidths[i] = width;
tableWidth -= width;
} else {
sumOfWeights += (this.visibleColumnsArray[i].weight || 0);
}
}
let sum = 0;
let lastOffset = 0;
const minColumnWidth = 14; // px
for (let i = 0; i < this.visibleColumnsArray.length; ++i) {
const column = this.visibleColumnsArray[i];
let width;
if (column.fixedWidth) {
width = fixedColumnWidths[i];
} else {
sum += (column.weight || 0);
const offset = (sum * tableWidth / sumOfWeights) | 0;
width = Math.max(offset - lastOffset, minColumnWidth);
lastOffset = offset;
}
this._setPreferredWidth(i, width);
}
this._positionResizers();
}
/**
* @param {!Set<string>} columnsVisibility
*/
setColumnsVisiblity(columnsVisibility) {
this.visibleColumnsArray = [];
for (const column of this._columnsArray) {
if (columnsVisibility.has(column.id)) {
this.visibleColumnsArray.push(column);
}
}
this._refreshHeader();
this._applyColumnWeights();
const nodes = this._enumerateChildren(this.rootNode(), [], -1);
for (const node of nodes) {
node.refresh();
}
}
get scrollContainer() {
return /** @type {!HTMLElement} */ (this._scrollContainer);
}
_positionResizers() {
const headerTableColumns = this._headerTableColumnGroup.children;
const numColumns = headerTableColumns.length - 1; // Do not process corner column.
/** @type {!Array<number>} */
const left = [];
const resizers = this._resizers;
while (resizers.length > numColumns - 1) {
const resizer = resizers.pop();
if (resizer) {
resizer.remove();
}
}
for (let i = 0; i < numColumns - 1; i++) {
// 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.
left[i] = (left[i - 1] || 0) + this._headerTableBody.rows[0].cells[i].offsetWidth;
}
// Make n - 1 resizers for n columns.
for (let i = 0; i < numColumns - 1; i++) {
let resizer = resizers[i];
if (!resizer) {
// This is the first call to updateWidth, so the resizers need
// to be created.
resizer = document.createElement('div');
elementToIndexMap.set(resizer, i);
resizer.classList.add('data-grid-resizer');
// This resizer is associated with the column to its right.
UI.UIUtils.installDragHandle(
resizer, this._startResizerDragging.bind(this), this._resizerDragging.bind(this),
this._endResizerDragging.bind(this), 'col-resize');
this.element.appendChild(resizer);
resizers.push(/** @type {!HTMLElement} */ (resizer));
}
if (elementToPositionMap.get(resizer) !== left[i]) {
elementToPositionMap.set(resizer, left[i]);
resizer.style.left = left[i] + 'px';
}
}
}
/**
* @param {boolean=} hasChildren
*/
addCreationNode(hasChildren) {
if (this.creationNode) {
this.creationNode.makeNormal();
}
/** @type {!Object<string,*>} */
const emptyData = {};
for (const column in this._columns) {
emptyData[column] = null;
}
this.creationNode = new CreationDataGridNode(emptyData, hasChildren);
this.rootNode().appendChild(this.creationNode);
}
/**
* @param {!Event} event
*/
_keyDown(event) {
if (!(event instanceof KeyboardEvent)) {
return;
}
if (event.shiftKey || event.metaKey || event.ctrlKey || this._editing || UI.UIUtils.isEditing()) {
return;
}
let handled = false;
let nextSelectedNode;
if (!this.selectedNode) {
// Select the first or last node based on the arrow key direction
if (event.key === 'ArrowUp' && !event.altKey) {
nextSelectedNode = this._lastSelectableNode();
} else if (event.key === 'ArrowDown' && !event.altKey) {
nextSelectedNode = this._firstSelectableNode();
}
handled = nextSelectedNode ? true : false;
} else if (event.key === 'ArrowUp' && !event.altKey) {
nextSelectedNode = this.selectedNode.traversePreviousNode(true);
while (nextSelectedNode && !nextSelectedNode.selectable) {
nextSelectedNode = nextSelectedNode.traversePreviousNode(true);
}
handled = nextSelectedNode ? true : false;
} else if (event.key === 'ArrowDown' && !event.altKey) {
nextSelectedNode = this.selectedNode.traverseNextNode(true);
while (nextSelectedNode && !nextSelectedNode.selectable) {
nextSelectedNode = nextSelectedNode.traverseNextNode(true);
}
handled = nextSelectedNode ? true : false;
} else if (event.key === 'ArrowLeft') {
if (this.selectedNode.expanded) {
if (event.altKey) {
this.selectedNode.collapseRecursively();
} else {
this.selectedNode.collapse();
}
handled = true;
} else if (this.selectedNode.parent && !this.selectedNode.parent._isRoot) {
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 (event.key === 'ArrowRight') {
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 (event.key === 'Enter') {
if (this._editCallback) {
handled = true;
const selectedNodeElement = this.selectedNode._element;
if (!selectedNodeElement) {
return;
}
this._startEditing(selectedNodeElement.children[this._nextEditableColumn(-1)]);
} else {
this.dispatchEventToListeners(Events.OpenedNode, this.selectedNode);
}
}
if (nextSelectedNode) {
nextSelectedNode.reveal();
nextSelectedNode.select();
}
if ((event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' ||
event.key === 'ArrowRight') &&
document.activeElement !== this.element) {
// crbug.com/1005449
// navigational keys pressed but current DataGrid panel has lost focus;
// re-focus to ensure subsequent keydowns can be registered within this DataGrid
this.element.focus();
}
if (handled) {
event.consume(true);
}
}
/**
* @param {?DataGridNode<!NODE_TYPE>} root
* @param {boolean} onlyAffectsSubtree
*/
updateSelectionBeforeRemoval(root, onlyAffectsSubtree) {
let ancestor = this.selectedNode;
while (ancestor && ancestor !== root) {
ancestor = ancestor.parent;
}
// Selection is not in the subtree being deleted.
if (!ancestor) {
return;
}
let nextSelectedNode;
// Skip subtree being deleted when looking for the next selectable node.
for (ancestor = root; ancestor && !ancestor.nextSibling; ancestor = ancestor.parent) {
}
if (ancestor) {
nextSelectedNode = ancestor.nextSibling;
}
while (nextSelectedNode && !nextSelectedNode.selectable) {
nextSelectedNode = nextSelectedNode.traverseNextNode(true);
}
const isCreationNode = nextSelectedNode instanceof CreationDataGridNode && nextSelectedNode.isCreationNode;
if (!nextSelectedNode || isCreationNode) {
if (!root) {
return;
}
nextSelectedNode = root.traversePreviousNode(true);
while (nextSelectedNode && !nextSelectedNode.selectable) {
nextSelectedNode = nextSelectedNode.traversePreviousNode(true);
}
}
if (nextSelectedNode) {
nextSelectedNode.reveal();
nextSelectedNode.select();
} else if (this.selectedNode) {
this.selectedNode.deselect();
}
}
/**
* @param {!Node} target
* @return {?DataGridNode<!NODE_TYPE>}
*/
dataGridNodeFromNode(target) {
const rowElement = UI.UIUtils.enclosingNodeOrSelfWithNodeName(target, 'tr');
return (rowElement && this.elementToDataGridNode.get(rowElement)) || null;
}
/**
* @param {!Node} target
* @return {?string}
*/
columnIdFromNode(target) {
const cellElement = UI.UIUtils.enclosingNodeOrSelfWithNodeName(target, 'td');
return (cellElement && nodeToColumnIdMap.get(cellElement)) || null;
}
/**
* @param {!Event} event
*/
_clickInHeaderCell(event) {
const cell = UI.UIUtils.enclosingNodeOrSelfWithNodeName(/** @type {!Node} */ (event.target), 'th');
if (!cell) {
return;
}
this._sortByColumnHeaderCell(/** @type {!HTMLElement} */ (cell));
}
/**
* @param {!Element} cell
*/
_sortByColumnHeaderCell(cell) {
if (!nodeToColumnIdMap.has(cell) || !cell.classList.contains('sortable')) {
return;
}
let sortOrder = Order.Ascending;
if ((cell === this._sortColumnCell) && this.isSortOrderAscending()) {
sortOrder = Order.Descending;
}
if (this._sortColumnCell) {
this._sortColumnCell.classList.remove(Order.Ascending, Order.Descending);
}
this._sortColumnCell = cell;
cell.classList.add(sortOrder);
const icon = elementToSortIconMap.get(cell);
if (!icon) {
return;
}
icon.setIconType(sortOrder === Order.Ascending ? 'smallicon-triangle-up' : 'smallicon-triangle-down');
this.dispatchEventToListeners(Events.SortingChanged);
}
/**
* @param {string} columnId
* @param {!Order} sortOrder
*/
markColumnAsSortedBy(columnId, sortOrder) {
if (this._sortColumnCell) {
this._sortColumnCell.classList.remove(Order.Ascending, Order.Descending);
}
this._sortColumnCell = this._headerTableHeaders[columnId];
this._sortColumnCell.classList.add(sortOrder);
}
/**
* @param {string} columnId
* @return {!Element}
*/
headerTableHeader(columnId) {
return this._headerTableHeaders[columnId];
}
/**
* @param {!Event} event
*/
_mouseDownInDataTable(event) {
const target = /** @type {!Node} */ (event.target);
const gridNode = this.dataGridNodeFromNode(target);
if (!gridNode || !gridNode.selectable ||
gridNode.isEventWithinDisclosureTriangle(/** @type {!MouseEvent} */ (event))) {
return;
}
const columnId = this.columnIdFromNode(target);
if (columnId && this._columns[columnId].nonSelectable) {
return;
}
if (/** @type {!MouseEvent} */ (event).metaKey) {
if (gridNode.selected) {
gridNode.deselect();
} else {
gridNode.select();
}
} else {
gridNode.select();
this.dispatchEventToListeners(Events.OpenedNode, gridNode);
}
}
/**
* @param {?function(!UI.ContextMenu.SubMenu):void} callback
*/
setHeaderContextMenuCallback(callback) {
this._headerContextMenuCallback = callback;
}
/**
* @param {?function(!UI.ContextMenu.ContextMenu, !DataGridNode<!NODE_TYPE>):void} callback
*/
setRowContextMenuCallback(callback) {
this._rowContextMenuCallback = callback;
}
/**
* @param {!Event} event
*/
_contextMenu(event) {
if (!(event instanceof MouseEvent)) {
return;
}
const contextMenu = new UI.ContextMenu.ContextMenu(event);
const target = /** @type {!Node} */ (event.target);
const sortableVisibleColumns = this.visibleColumnsArray.filter(column => {
return (column.sortable && column.title);
});
const sortableHiddenColumns = this._columnsArray.filter(
column => sortableVisibleColumns.indexOf(column) === -1 && column.allowInSortByEvenWhenHidden);
const sortableColumns = [...sortableVisibleColumns, ...sortableHiddenColumns];
if (sortableColumns.length > 0) {
const sortMenu = contextMenu.defaultSection().appendSubMenuItem(i18nString(UIStrings.sortByString));
for (const column of sortableColumns) {
const headerCell = this._headerTableHeaders[column.id];
sortMenu.defaultSection().appendItem(
/** @type {string} */ (column.title), this._sortByColumnHeaderCell.bind(this, headerCell));
}
}
if (target.isSelfOrDescendant(this._headerTableBody)) {
if (this._headerContextMenuCallback) {
this._headerContextMenuCallback(contextMenu);
}
contextMenu.defaultSection().appendItem(i18nString(UIStrings.resetColumns), this._resetColumnWeights.bind(this));
contextMenu.show();
return;
}
// Add header context menu to a subsection available from the body
const headerSubMenu = contextMenu.defaultSection().appendSubMenuItem(i18nString(UIStrings.headerOptions));
if (this._headerContextMenuCallback) {
this._headerContextMenuCallback(headerSubMenu);
}
headerSubMenu.defaultSection().appendItem(i18nString(UIStrings.resetColumns), this._resetColumnWeights.bind(this));
const isContextMenuKey = (event.button === 0);
const gridNode = isContextMenuKey ? this.selectedNode : this.dataGridNodeFromNode(target);
const selectedNodeElement = this.selectedNode && this.selectedNode.existingElement();
if (isContextMenuKey && selectedNodeElement) {
const boundingRowRect = selectedNodeElement.getBoundingClientRect();
if (boundingRowRect) {
const x = (boundingRowRect.right + boundingRowRect.left) / 2;
const y = (boundingRowRect.bottom + boundingRowRect.top) / 2;
contextMenu.setX(x);
contextMenu.setY(y);
}
}
if (this._refreshCallback && (!gridNode || gridNode !== this.creationNode)) {
contextMenu.defaultSection().appendItem(i18nString(UIStrings.refresh), this._refreshCallback.bind(this));
}
if (gridNode && gridNode.selectable && !gridNode.isEventWithinDisclosureTriangle(event)) {
if (this._editCallback) {
if (gridNode === this.creationNode) {
const firstEditColumnIndex = this._nextEditableColumn(-1);
const tableCellElement = gridNode.element().children[firstEditColumnIndex];
contextMenu.defaultSection().appendItem(
i18nString(UIStrings.addNew), this._startEditing.bind(this, tableCellElement));
} else if (isContextMenuKey) {
const firstEditColumnIndex = this._nextEditableColumn(-1);
if (firstEditColumnIndex > -1) {
const firstColumn = this.visibleColumnsArray[firstEditColumnIndex];
if (firstColumn && firstColumn.editable) {
contextMenu.defaultSection().appendItem(
i18nString(UIStrings.editS, {PH1: firstColumn.title}),
this._startEditingColumnOfDataGridNode.bind(this, gridNode, firstEditColumnIndex));
}
}
} else {
const columnId = this.columnIdFromNode(target);
if (columnId && this._columns[columnId].editable) {
contextMenu.defaultSection().appendItem(
i18nString(UIStrings.editS, {PH1: this._columns[columnId].title}),
this._startEditing.bind(this,