/* * 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. */ WebInspector.DataGrid = function(columns, editCallback, deleteCallback) { this.element = document.createElement("div"); this.element.className = "data-grid"; this.element.tabIndex = 0; this.element.addEventListener("keydown", this._keyDown.bind(this), false); this._headerTable = document.createElement("table"); this._headerTable.className = "header"; this._headerTableHeaders = {}; this._dataTable = document.createElement("table"); this._dataTable.className = "data"; this._dataTable.addEventListener("mousedown", this._mouseDownInDataTable.bind(this), true); this._dataTable.addEventListener("click", this._clickInDataTable.bind(this), true); this._dataTable.addEventListener("contextmenu", this._contextMenuInDataTable.bind(this), true); // FIXME: Add a createCallback which is different from editCallback and has different // behavior when creating a new node. if (editCallback) { this._dataTable.addEventListener("dblclick", this._ondblclick.bind(this), false); this._editCallback = editCallback; } if (deleteCallback) this._deleteCallback = deleteCallback; this.aligned = {}; this._scrollContainer = document.createElement("div"); this._scrollContainer.className = "data-container"; this._scrollContainer.appendChild(this._dataTable); this.element.appendChild(this._headerTable); this.element.appendChild(this._scrollContainer); var headerRow = document.createElement("tr"); var columnGroup = document.createElement("colgroup"); this._columnCount = 0; for (var columnIdentifier in columns) { var column = columns[columnIdentifier]; if (column.disclosure) this.disclosureColumnIdentifier = columnIdentifier; var col = document.createElement("col"); if (column.width) col.style.width = column.width; column.element = col; columnGroup.appendChild(col); var cell = document.createElement("th"); cell.className = columnIdentifier + "-column"; cell.columnIdentifier = columnIdentifier; this._headerTableHeaders[columnIdentifier] = cell; var div = document.createElement("div"); if (column.titleDOMFragment) div.appendChild(column.titleDOMFragment); else div.textContent = column.title; cell.appendChild(div); if (column.sort) { cell.addStyleClass("sort-" + column.sort); this._sortColumnCell = cell; } if (column.sortable) { cell.addEventListener("click", this._clickInHeaderCell.bind(this), false); cell.addStyleClass("sortable"); } if (column.aligned) this.aligned[columnIdentifier] = column.aligned; headerRow.appendChild(cell); ++this._columnCount; } columnGroup.span = this._columnCount; var cell = document.createElement("th"); cell.className = "corner"; headerRow.appendChild(cell); this._headerTableColumnGroup = columnGroup; this._headerTable.appendChild(this._headerTableColumnGroup); this.headerTableBody.appendChild(headerRow); var fillerRow = document.createElement("tr"); fillerRow.className = "filler"; for (var columnIdentifier in columns) { var column = columns[columnIdentifier]; var cell = document.createElement("td"); cell.className = columnIdentifier + "-column"; fillerRow.appendChild(cell); } this._dataTableColumnGroup = columnGroup.cloneNode(true); this._dataTable.appendChild(this._dataTableColumnGroup); this.dataTableBody.appendChild(fillerRow); this.columns = columns || {}; this._columnsArray = []; for (var columnIdentifier in columns) { columns[columnIdentifier].ordinal = this._columnsArray.length; this._columnsArray.push(columns[columnIdentifier]); } for (var i = 0; i < this._columnsArray.length; ++i) this._columnsArray[i].bodyElement = this._dataTableColumnGroup.children[i]; this.children = []; this.selectedNode = null; this.expandNodesWhenArrowing = false; this.root = true; this.hasChildren = false; this.expanded = true; this.revealed = true; this.selected = false; this.dataGrid = this; this.indentWidth = 15; this.resizers = []; this._columnWidthsInitialized = false; } WebInspector.DataGrid.prototype = { _ondblclick: function(event) { if (this._editing || this._editingNode) return; this._startEditing(event.target); }, _startEditingColumnOfDataGridNode: function(node, column) { this._editing = true; this._editingNode = node; this._editingNode.select(); var element = this._editingNode._element.children[column]; WebInspector.startEditing(element, { context: element.textContent, commitHandler: this._editingCommitted.bind(this), cancelHandler: this._editingCancelled.bind(this) }); window.getSelection().setBaseAndExtent(element, 0, element, 1); }, _startEditing: function(target) { var element = target.enclosingNodeOrSelfWithNodeName("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.isCreationNode) return this._startEditingColumnOfDataGridNode(this._editingNode, 0); this._editing = true; WebInspector.startEditing(element, { context: element.textContent, commitHandler: this._editingCommitted.bind(this), cancelHandler: this._editingCancelled.bind(this) }); window.getSelection().setBaseAndExtent(element, 0, element, 1); }, _editingCommitted: function(element, newText, oldText, context, moveDirection) { // FIXME: We need more column identifiers here throughout this function. // Not needed yet since only editable DataGrid is DOM Storage, which is Key - Value. // FIXME: Better way to do this than regular expressions? var columnIdentifier = parseInt(element.className.match(/\b(\d+)-column\b/)[1]); var textBeforeEditing = this._editingNode.data[columnIdentifier]; var currentEditingNode = this._editingNode; function moveToNextIfNeeded(wasChange) { if (!moveDirection) return; if (moveDirection === "forward") { if (currentEditingNode.isCreationNode && columnIdentifier === 0 && !wasChange) return; if (columnIdentifier === 0) return this._startEditingColumnOfDataGridNode(currentEditingNode, 1); var nextDataGridNode = currentEditingNode.traverseNextNode(true, null, true); if (nextDataGridNode) return this._startEditingColumnOfDataGridNode(nextDataGridNode, 0); if (currentEditingNode.isCreationNode && wasChange) { addCreationNode(false); return this._startEditingColumnOfDataGridNode(this.creationNode, 0); } return; } if (moveDirection === "backward") { if (columnIdentifier === 1) return this._startEditingColumnOfDataGridNode(currentEditingNode, 0); var nextDataGridNode = currentEditingNode.traversePreviousNode(true, null, true); if (nextDataGridNode) return this._startEditingColumnOfDataGridNode(nextDataGridNode, 1); return; } } if (textBeforeEditing == newText) { this._editingCancelled(element); moveToNextIfNeeded.call(this, false); return; } // Update the text in the datagrid that we typed this._editingNode.data[columnIdentifier] = newText; // 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, columnIdentifier, textBeforeEditing, newText); if (this._editingNode.isCreationNode) this.addCreationNode(false); this._editingCancelled(element); moveToNextIfNeeded.call(this, true); }, _editingCancelled: function(element, context) { delete this._editing; this._editingNode = null; }, get sortColumnIdentifier() { if (!this._sortColumnCell) return null; return this._sortColumnCell.columnIdentifier; }, get sortOrder() { if (!this._sortColumnCell || this._sortColumnCell.hasStyleClass("sort-ascending")) return "ascending"; if (this._sortColumnCell.hasStyleClass("sort-descending")) return "descending"; return null; }, get headerTableBody() { if ("_headerTableBody" in this) return this._headerTableBody; this._headerTableBody = this._headerTable.getElementsByTagName("tbody")[0]; if (!this._headerTableBody) { this._headerTableBody = this.element.ownerDocument.createElement("tbody"); this._headerTable.insertBefore(this._headerTableBody, this._headerTable.tFoot); } return this._headerTableBody; }, get dataTableBody() { if ("_dataTableBody" in this) return this._dataTableBody; this._dataTableBody = this._dataTable.getElementsByTagName("tbody")[0]; if (!this._dataTableBody) { this._dataTableBody = this.element.ownerDocument.createElement("tbody"); this._dataTable.insertBefore(this._dataTableBody, this._dataTable.tFoot); } return this._dataTableBody; }, autoSizeColumns: function(minPercent, maxPercent, maxDescentLevel) { if (minPercent) minPercent = Math.min(minPercent, Math.floor(100 / this._columnCount)); var widths = {}; var columns = this.columns; for (var columnIdentifier in columns) widths[columnIdentifier] = (columns[columnIdentifier].title || "").length; var children = maxDescentLevel ? this._enumerateChildren(this, [], maxDescentLevel + 1) : this.children; for (var i = 0; i < children.length; ++i) { var node = children[i]; for (var columnIdentifier in columns) { var text = node.data[columnIdentifier] || ""; if (text.length > widths[columnIdentifier]) widths[columnIdentifier] = text.length; } } var totalColumnWidths = 0; for (var columnIdentifier in columns) totalColumnWidths += widths[columnIdentifier]; var recoupPercent = 0; for (var columnIdentifier in columns) { var width = Math.round(100 * widths[columnIdentifier] / totalColumnWidths); if (minPercent && width < minPercent) { recoupPercent += (minPercent - width); width = minPercent; } else if (maxPercent && width > maxPercent) { recoupPercent -= (width - maxPercent); width = maxPercent; } widths[columnIdentifier] = width; } while (minPercent && recoupPercent > 0) { for (var columnIdentifier in columns) { if (widths[columnIdentifier] > minPercent) { --widths[columnIdentifier]; --recoupPercent; if (!recoupPercent) break; } } } while (maxPercent && recoupPercent < 0) { for (var columnIdentifier in columns) { if (widths[columnIdentifier] < maxPercent) { ++widths[columnIdentifier]; ++recoupPercent; if (!recoupPercent) break; } } } for (var columnIdentifier in columns) columns[columnIdentifier].element.style.width = widths[columnIdentifier] + "%"; this._columnWidthsInitialized = false; this.updateWidths(); }, _enumerateChildren: function(rootNode, result, maxLevel) { if (!rootNode.root) result.push(rootNode); if (!maxLevel) return; for (var i = 0; i < rootNode.children.length; ++i) this._enumerateChildren(rootNode.children[i], result, maxLevel - 1); return result; }, // Updates the widths of the table, including the positions of the column // resizers. // // IMPORTANT: This function MUST be called once after the element of the // DataGrid is attached to its parent element and every subsequent time the // width of the parent element is changed in order to make it possible to // resize the columns. // // If this function is not called after the DataGrid is attached to its // parent element, then the DataGrid's columns will not be resizable. updateWidths: function() { var headerTableColumns = this._headerTableColumnGroup.children; var tableWidth = this._dataTable.offsetWidth; var numColumns = headerTableColumns.length; // 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. for (var i = 0; i < numColumns; i++) { var columnWidth = this.headerTableBody.rows[0].cells[i].offsetWidth; var percentWidth = ((columnWidth / tableWidth) * 100) + "%"; this._headerTableColumnGroup.children[i].style.width = percentWidth; this._dataTableColumnGroup.children[i].style.width = percentWidth; } this._columnWidthsInitialized = true; } this._positionResizers(); this.dispatchEventToListeners("width changed"); }, columnWidthsMap: function() { var result = {}; for (var i = 0; i < this._columnsArray.length; ++i) { var width = this._headerTableColumnGroup.children[i].style.width; result[this._columnsArray[i].columnIdentifier] = parseFloat(width); } return result; }, applyColumnWidthsMap: function(columnWidthsMap) { for (var columnIdentifier in this.columns) { var column = this.columns[columnIdentifier]; var width = (columnWidthsMap[columnIdentifier] || 0) + "%"; this._headerTableColumnGroup.children[column.ordinal].style.width = width; this._dataTableColumnGroup.children[column.ordinal].style.width = width; } // Normalize widths delete this._columnWidthsInitialized; this.updateWidths(); }, isColumnVisible: function(columnIdentifier) { var column = this.columns[columnIdentifier]; var columnElement = column.element; return !columnElement.hidden; }, showColumn: function(columnIdentifier) { var column = this.columns[columnIdentifier]; var columnElement = column.element; if (!columnElement.hidden) return; columnElement.hidden = false; columnElement.removeStyleClass("hidden"); var columnBodyElement = column.bodyElement; columnBodyElement.hidden = false; columnBodyElement.removeStyleClass("hidden"); }, hideColumn: function(columnIdentifier) { var column = this.columns[columnIdentifier]; var columnElement = column.element; if (columnElement.hidden) return; var oldWidth = parseFloat(columnElement.style.width); columnElement.hidden = true; columnElement.addStyleClass("hidden"); columnElement.style.width = 0; var columnBodyElement = column.bodyElement; columnBodyElement.hidden = true; columnBodyElement.addStyleClass("hidden"); columnBodyElement.style.width = 0; this._columnWidthsInitialized = false; }, isScrolledToLastRow: function() { return this._scrollContainer.isScrolledToBottom(); }, scrollToLastRow: function() { this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight - this._scrollContainer.offsetHeight; }, _positionResizers: function() { var headerTableColumns = this._headerTableColumnGroup.children; var numColumns = headerTableColumns.length; var left = 0; var previousResizer = null; // Make n - 1 resizers for n columns. for (var i = 0; i < numColumns - 1; i++) { var resizer = this.resizers[i]; if (!resizer) { // This is the first call to updateWidth, so the resizers need // to be created. resizer = document.createElement("div"); resizer.addStyleClass("data-grid-resizer"); // This resizer is associated with the column to its right. resizer.addEventListener("mousedown", this._startResizerDragging.bind(this), false); this.element.appendChild(resizer); this.resizers[i] = resizer; } // 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 += this.headerTableBody.rows[0].cells[i].offsetWidth; var columnIsVisible = !this._headerTableColumnGroup.children[i].hidden; if (columnIsVisible) { resizer.style.removeProperty("display"); resizer.style.left = left + "px"; resizer.leftNeighboringColumnID = i; if (previousResizer) previousResizer.rightNeighboringColumnID = i; previousResizer = resizer; } else { resizer.style.setProperty("display", "none"); resizer.leftNeighboringColumnID = 0; resizer.rightNeighboringColumnID = 0; } } if (previousResizer) previousResizer.rightNeighboringColumnID = numColumns - 1; }, addCreationNode: function(hasChildren) { if (this.creationNode) this.creationNode.makeNormal(); var emptyData = {}; for (var column in this.columns) emptyData[column] = ''; this.creationNode = new WebInspector.CreationDataGridNode(emptyData, hasChildren); this.appendChild(this.creationNode); }, appendChild: function(child) { this.insertChild(child, this.children.length); }, insertChild: function(child, index) { if (!child) throw("insertChild: Node can't be undefined or null."); if (child.parent === this) throw("insertChild: Node is already a child of this node."); if (child.parent) child.parent.removeChild(child); this.children.splice(index, 0, child); this.hasChildren = true; child.parent = this; child.dataGrid = this.dataGrid; child._recalculateSiblings(index); delete child._depth; delete child._revealed; delete child._attached; child._shouldRefreshChildren = true; var current = child.children[0]; while (current) { current.dataGrid = this.dataGrid; delete current._depth; delete current._revealed; delete current._attached; current._shouldRefreshChildren = true; current = current.traverseNextNode(false, child, true); } if (this.expanded) child._attach(); }, removeChild: function(child) { if (!child) throw("removeChild: Node can't be undefined or null."); if (child.parent !== this) throw("removeChild: Node is not a child of this node."); child.deselect(); child._detach(); this.children.remove(child, true); if (child.previousSibling) child.previousSibling.nextSibling = child.nextSibling; if (child.nextSibling) child.nextSibling.previousSibling = child.previousSibling; child.dataGrid = null; child.parent = null; child.nextSibling = null; child.previousSibling = null; if (this.children.length <= 0) this.hasChildren = false; }, removeChildren: function() { for (var i = 0; i < this.children.length; ++i) { var child = this.children[i]; child.deselect(); child._detach(); child.dataGrid = null; child.parent = null; child.nextSibling = null; child.previousSibling = null; } this.children = []; this.hasChildren = false; }, removeChildrenRecursive: function() { var childrenToRemove = this.children; var child = this.children[0]; while (child) { if (child.children.length) childrenToRemove = childrenToRemove.concat(child.children); child = child.traverseNextNode(false, this, true); } for (var i = 0; i < childrenToRemove.length; ++i) { var child = childrenToRemove[i]; child.deselect(); child._detach(); child.children = []; child.dataGrid = null; child.parent = null; child.nextSibling = null; child.previousSibling = null; } this.children = []; }, sortNodes: function(comparator, reverseMode) { function comparatorWrapper(a, b) { if (a._dataGridNode._data.summaryRow) return 1; if (b._dataGridNode._data.summaryRow) return -1; var aDataGirdNode = a._dataGridNode; var bDataGirdNode = b._dataGridNode; return reverseMode ? comparator(bDataGirdNode, aDataGirdNode) : comparator(aDataGirdNode, bDataGirdNode); } var tbody = this.dataTableBody; var tbodyParent = tbody.parentElement; tbodyParent.removeChild(tbody); var childNodes = tbody.childNodes; var fillerRow = childNodes[childNodes.length - 1]; var sortedRows = Array.prototype.slice.call(childNodes, 0, childNodes.length - 1); sortedRows.sort(comparatorWrapper); var sortedRowsLength = sortedRows.length; tbody.removeChildren(); var previousSiblingNode = null; for (var i = 0; i < sortedRowsLength; ++i) { var row = sortedRows[i]; var node = row._dataGridNode; node.previousSibling = previousSiblingNode; if (previousSiblingNode) previousSiblingNode.nextSibling = node; tbody.appendChild(row); previousSiblingNode = node; } if (previousSiblingNode) previousSiblingNode.nextSibling = null; tbody.appendChild(fillerRow); tbodyParent.appendChild(tbody); }, _keyDown: function(event) { if (!this.selectedNode || event.shiftKey || event.metaKey || event.ctrlKey || this._editing) return; var handled = false; var nextSelectedNode; if (event.keyIdentifier === "Up" && !event.altKey) { nextSelectedNode = this.selectedNode.traversePreviousNode(true); while (nextSelectedNode && !nextSelectedNode.selectable) nextSelectedNode = nextSelectedNode.traversePreviousNode(!this.expandTreeNodesWhenArrowing); handled = nextSelectedNode ? true : false; } else if (event.keyIdentifier === "Down" && !event.altKey) { nextSelectedNode = this.selectedNode.traverseNextNode(true); while (nextSelectedNode && !nextSelectedNode.selectable) nextSelectedNode = nextSelectedNode.traverseNextNode(!this.expandTreeNodesWhenArrowing); handled = nextSelectedNode ? true : false; } else if (event.keyIdentifier === "Left") { if (this.selectedNode.expanded) { if (event.altKey) this.selectedNode.collapseRecursively(); else this.selectedNode.collapse(); handled = true; } else if (this.selectedNode.parent && !this.selectedNode.parent.root) { handled = true; if (this.selectedNode.parent.selectable) { nextSelectedNode = this.selectedNode.parent; handled = nextSelectedNode ? true : false; } else if (this.selectedNode.parent) this.selectedNode.parent.collapse(); } } else if (event.keyIdentifier === "Right") { if (!this.selectedNode.revealed) { this.selectedNode.reveal(); handled = true; } else if (this.selectedNode.hasChildren) { handled = true; if (this.selectedNode.expanded) { nextSelectedNode = this.selectedNode.children[0]; handled = nextSelectedNode ? true : false; } else { if (event.altKey) this.selectedNode.expandRecursively(); else this.selectedNode.expand(); } } } else if (event.keyCode === 8 || event.keyCode === 46) { if (this._deleteCallback) { handled = true; this._deleteCallback(this.selectedNode); } } else if (isEnterKey(event)) { if (this._editCallback) { handled = true; // The first child of the selected element is the