diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/client/shared/widgets/TableWidget.js | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/shared/widgets/TableWidget.js')
-rw-r--r-- | devtools/client/shared/widgets/TableWidget.js | 2031 |
1 files changed, 2031 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/TableWidget.js b/devtools/client/shared/widgets/TableWidget.js new file mode 100644 index 0000000000..d37559b587 --- /dev/null +++ b/devtools/client/shared/widgets/TableWidget.js @@ -0,0 +1,2031 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +loader.lazyRequireGetter( + this, + ["clearNamedTimeout", "setNamedTimeout"], + "resource://devtools/client/shared/widgets/view-helpers.js", + true +); +loader.lazyRequireGetter( + this, + "naturalSortCaseInsensitive", + "resource://devtools/shared/natural-sort.js", + true +); +loader.lazyGetter(this, "standardSessionString", () => { + const l10n = new Localization(["devtools/client/storage.ftl"], true); + return l10n.formatValueSync("storage-expires-session"); +}); + +const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const AFTER_SCROLL_DELAY = 100; + +// Different types of events emitted by the Various components of the +// TableWidget. +const EVENTS = { + CELL_EDIT: "cell-edit", + COLUMN_SORTED: "column-sorted", + COLUMN_TOGGLED: "column-toggled", + FIELDS_EDITABLE: "fields-editable", + HEADER_CONTEXT_MENU: "header-context-menu", + ROW_EDIT: "row-edit", + ROW_CONTEXT_MENU: "row-context-menu", + ROW_REMOVED: "row-removed", + ROW_SELECTED: "row-selected", + ROW_UPDATED: "row-updated", + TABLE_CLEARED: "table-cleared", + TABLE_FILTERED: "table-filtered", + SCROLL_END: "scroll-end", +}; +Object.defineProperty(this, "EVENTS", { + value: EVENTS, + enumerable: true, + writable: false, +}); + +/** + * A table widget with various features like resizble/toggleable columns, + * sorting, keyboard navigation etc. + * + * @param {Node} node + * The container element for the table widget. + * @param {object} options + * - initialColumns: map of key vs display name for initial columns of + * the table. See @setupColumns for more info. + * - uniqueId: the column which will be the unique identifier of each + * entry in the table. Default: name. + * - wrapTextInElements: Don't ever use 'value' attribute on labels. + * Default: false. + * - emptyText: Localization ID for the text to display when there are + * no entries in the table to display. + * - highlightUpdated: true to highlight the changed/added row. + * - removableColumns: Whether columns are removeable. If set to false, + * the context menu in the headers will not appear. + * - firstColumn: key of the first column that should appear. + * - cellContextMenuId: ID of a <menupopup> element to be set as a + * context menu of every cell. + */ +function TableWidget(node, options = {}) { + EventEmitter.decorate(this); + + this.document = node.ownerDocument; + this.window = this.document.defaultView; + this._parent = node; + + const { + initialColumns, + emptyText, + uniqueId, + highlightUpdated, + removableColumns, + firstColumn, + wrapTextInElements, + cellContextMenuId, + l10n, + } = options; + this.emptyText = emptyText || ""; + this.uniqueId = uniqueId || "name"; + this.wrapTextInElements = wrapTextInElements || false; + this.firstColumn = firstColumn || ""; + this.highlightUpdated = highlightUpdated || false; + this.removableColumns = removableColumns !== false; + this.cellContextMenuId = cellContextMenuId; + this.l10n = l10n; + + this.tbody = this.document.createXULElement("hbox"); + this.tbody.className = "table-widget-body theme-body"; + this.tbody.setAttribute("flex", "1"); + this.tbody.setAttribute("tabindex", "0"); + this._parent.appendChild(this.tbody); + this.afterScroll = this.afterScroll.bind(this); + this.tbody.addEventListener("scroll", this.onScroll.bind(this)); + + // Prepare placeholder + this.placeholder = this.document.createElement("div"); + this.placeholder.className = "plain table-widget-empty-text"; + this._parent.appendChild(this.placeholder); + this.setPlaceholder(this.emptyText); + + this.items = new Map(); + this.columns = new Map(); + + // Setup the column headers context menu to allow users to hide columns at + // will. + if (this.removableColumns) { + this.onPopupCommand = this.onPopupCommand.bind(this); + this.setupHeadersContextMenu(); + } + + if (initialColumns) { + this.setColumns(initialColumns, uniqueId); + } + + this.bindSelectedRow = id => { + this.selectedRow = id; + }; + this.on(EVENTS.ROW_SELECTED, this.bindSelectedRow); + + this.onChange = this.onChange.bind(this); + this.onEditorDestroyed = this.onEditorDestroyed.bind(this); + this.onEditorTab = this.onEditorTab.bind(this); + this.onKeydown = this.onKeydown.bind(this); + this.onMousedown = this.onMousedown.bind(this); + this.onRowRemoved = this.onRowRemoved.bind(this); + + this.document.addEventListener("keydown", this.onKeydown); + this.document.addEventListener("mousedown", this.onMousedown); +} + +TableWidget.prototype = { + items: null, + editBookmark: null, + scrollIntoViewOnUpdate: null, + + /** + * Return true if the table body has a scrollbar. + */ + get hasScrollbar() { + return this.tbody.scrollHeight > this.tbody.clientHeight; + }, + + /** + * Getter for the headers context menu popup id. + */ + get headersContextMenu() { + if (this.menupopup) { + return this.menupopup.id; + } + return null; + }, + + /** + * Select the row corresponding to the json object `id` + */ + set selectedRow(id) { + for (const column of this.columns.values()) { + if (id || id === "") { + column.selectRow(id[this.uniqueId] || id); + } else { + column.selectedRow = null; + column.selectRow(null); + } + } + }, + + /** + * Is a row currently selected? + * + * @return {Boolean} + * true or false. + */ + get hasSelectedRow() { + return ( + this.columns.get(this.uniqueId) && + this.columns.get(this.uniqueId).selectedRow + ); + }, + + /** + * Returns the json object corresponding to the selected row. + */ + get selectedRow() { + return this.items.get(this.columns.get(this.uniqueId).selectedRow); + }, + + /** + * Selects the row at index `index`. + */ + set selectedIndex(index) { + for (const column of this.columns.values()) { + column.selectRowAt(index); + } + }, + + /** + * Returns the index of the selected row. + */ + get selectedIndex() { + return this.columns.get(this.uniqueId).selectedIndex; + }, + + /** + * Returns the index of the selected row disregarding hidden rows. + */ + get visibleSelectedIndex() { + const column = this.firstVisibleColumn; + const cells = column.visibleCellNodes; + + for (let i = 0; i < cells.length; i++) { + if (cells[i].classList.contains("theme-selected")) { + return i; + } + } + + return -1; + }, + + /** + * Returns the first visible column. + */ + get firstVisibleColumn() { + for (const column of this.columns.values()) { + if (column._private) { + continue; + } + + if (column.column.clientHeight > 0) { + return column; + } + } + + return null; + }, + + /** + * returns all editable columns. + */ + get editableColumns() { + const filter = columns => { + columns = [...columns].filter(col => { + if (col.clientWidth === 0) { + return false; + } + + const cell = col.querySelector(".table-widget-cell"); + + for (const selector of this._editableFieldsEngine.selectors) { + if (cell.matches(selector)) { + return true; + } + } + + return false; + }); + + return columns; + }; + + const columns = this._parent.querySelectorAll(".table-widget-column"); + return filter(columns); + }, + + /** + * Emit all cell edit events. + */ + onChange(data) { + const changedField = data.change.field; + const colName = changedField.parentNode.id; + const column = this.columns.get(colName); + const uniqueId = column.table.uniqueId; + const itemIndex = column.cellNodes.indexOf(changedField); + const items = {}; + + for (const [name, col] of this.columns) { + items[name] = col.cellNodes[itemIndex].value; + } + + const change = { + host: this.host, + key: uniqueId, + field: colName, + oldValue: data.change.oldValue, + newValue: data.change.newValue, + items, + }; + + // A rows position in the table can change as the result of an edit. In + // order to ensure that the correct row is highlighted after an edit we + // save the uniqueId in editBookmark. + this.editBookmark = + colName === uniqueId ? change.newValue : items[uniqueId]; + this.emit(EVENTS.CELL_EDIT, change); + }, + + onEditorDestroyed() { + this._editableFieldsEngine = null; + }, + + /** + * Called by the inplace editor when Tab / Shift-Tab is pressed in edit-mode. + * Because tables are live any row, column, cell or table can be added, + * deleted or moved by deleting and adding e.g. a row again. + * + * This presents various challenges when navigating via the keyboard so please + * keep this in mind whenever editing this method. + * + * @param {Event} event + * Keydown event + */ + onEditorTab(event) { + const textbox = event.target; + const editor = this._editableFieldsEngine; + + if (textbox.id !== editor.INPUT_ID) { + return; + } + + const column = textbox.parentNode; + + // Changing any value can change the position of the row depending on which + // column it is currently sorted on. In addition to this, the table cell may + // have been edited and had to be recreated when the user has pressed tab or + // shift+tab. Both of these situations require us to recover our target, + // select the appropriate row and move the textbox on to the next cell. + if (editor.changePending) { + // We need to apply a change, which can mean that the position of cells + // within the table can change. Because of this we need to wait for + // EVENTS.ROW_EDIT and then move the textbox. + this.once(EVENTS.ROW_EDIT, uniqueId => { + let columnObj; + const cols = this.editableColumns; + let rowIndex = this.visibleSelectedIndex; + const colIndex = cols.indexOf(column); + let newIndex; + + // If the row has been deleted we should bail out. + if (!uniqueId) { + return; + } + + // Find the column we need to move to. + if (event.shiftKey) { + // Navigate backwards on shift tab. + if (colIndex === 0) { + if (rowIndex === 0) { + return; + } + newIndex = cols.length - 1; + } else { + newIndex = colIndex - 1; + } + } else if (colIndex === cols.length - 1) { + const id = cols[0].id; + columnObj = this.columns.get(id); + const maxRowIndex = columnObj.visibleCellNodes.length - 1; + if (rowIndex === maxRowIndex) { + return; + } + newIndex = 0; + } else { + newIndex = colIndex + 1; + } + + const newcol = cols[newIndex]; + columnObj = this.columns.get(newcol.id); + + // Select the correct row even if it has moved due to sorting. + const dataId = editor.currentTarget.getAttribute("data-id"); + if (this.items.get(dataId)) { + this.emit(EVENTS.ROW_SELECTED, dataId); + } else { + this.emit(EVENTS.ROW_SELECTED, uniqueId); + } + + // EVENTS.ROW_SELECTED may have changed the selected row so let's save + // the result in rowIndex. + rowIndex = this.visibleSelectedIndex; + + // Edit the appropriate cell. + const cells = columnObj.visibleCellNodes; + const cell = cells[rowIndex]; + editor.edit(cell); + + // Remove flash-out class... it won't have been auto-removed because the + // cell was hidden for editing. + cell.classList.remove("flash-out"); + }); + } + + // Begin cell edit. We always do this so that we can begin editing even in + // the case that the previous edit will cause the row to move. + const cell = this.getEditedCellOnTab(event, column); + editor.edit(cell); + + // Prevent default input tabbing behaviour + event.preventDefault(); + }, + + /** + * Get the cell that will be edited next on tab / shift tab and highlight the + * appropriate row. Edits etc. are not taken into account. + * + * This is used to tab from one field to another without editing and makes the + * editor much more responsive. + * + * @param {Event} event + * Keydown event + */ + getEditedCellOnTab(event, column) { + let cell = null; + const cols = this.editableColumns; + const rowIndex = this.visibleSelectedIndex; + const colIndex = cols.indexOf(column); + const maxCol = cols.length - 1; + const maxRow = this.columns.get(column.id).visibleCellNodes.length - 1; + + if (event.shiftKey) { + // Navigate backwards on shift tab. + if (colIndex === 0) { + if (rowIndex === 0) { + this._editableFieldsEngine.completeEdit(); + return null; + } + + column = cols[cols.length - 1]; + const cells = this.columns.get(column.id).visibleCellNodes; + cell = cells[rowIndex - 1]; + + const rowId = cell.getAttribute("data-id"); + this.emit(EVENTS.ROW_SELECTED, rowId); + } else { + column = cols[colIndex - 1]; + const cells = this.columns.get(column.id).visibleCellNodes; + cell = cells[rowIndex]; + } + } else if (colIndex === maxCol) { + // If in the rightmost column on the last row stop editing. + if (rowIndex === maxRow) { + this._editableFieldsEngine.completeEdit(); + return null; + } + + // If in the rightmost column of a row then move to the first column of + // the next row. + column = cols[0]; + const cells = this.columns.get(column.id).visibleCellNodes; + cell = cells[rowIndex + 1]; + + const rowId = cell.getAttribute("data-id"); + this.emit(EVENTS.ROW_SELECTED, rowId); + } else { + // Navigate forwards on tab. + column = cols[colIndex + 1]; + const cells = this.columns.get(column.id).visibleCellNodes; + cell = cells[rowIndex]; + } + + return cell; + }, + + /** + * Reset the editable fields engine if the currently edited row is removed. + * + * @param {String} event + * The event name "event-removed." + * @param {Object} row + * The values from the removed row. + */ + onRowRemoved(row) { + if (!this._editableFieldsEngine || !this._editableFieldsEngine.isEditing) { + return; + } + + const removedKey = row[this.uniqueId]; + const column = this.columns.get(this.uniqueId); + + if (removedKey in column.items) { + return; + } + + // The target is lost so we need to hide the remove the textbox from the DOM + // and reset the target nodes. + this.onEditorTargetLost(); + }, + + /** + * Cancel an edit because the edit target has been lost. + */ + onEditorTargetLost() { + const editor = this._editableFieldsEngine; + + if (!editor || !editor.isEditing) { + return; + } + + editor.cancelEdit(); + }, + + /** + * Keydown event handler for the table. Used for keyboard navigation amongst + * rows. + */ + onKeydown(event) { + // If we are in edit mode bail out. + if (this._editableFieldsEngine && this._editableFieldsEngine.isEditing) { + return; + } + + // We need to get the first *visible* selected cell. Some columns are hidden + // e.g. because they contain a unique compound key for cookies that is never + // displayed in the UI. To do this we get all selected cells and filter out + // any that are hidden. + const selectedCells = [ + ...this.tbody.querySelectorAll(".theme-selected"), + ].filter(cell => cell.clientWidth > 0); + // Select the first visible selected cell. + const selectedCell = selectedCells[0]; + if (!selectedCell) { + return; + } + + let colName; + let column; + let visibleCells; + let index; + let cell; + + switch (event.keyCode) { + case KeyCodes.DOM_VK_UP: + event.preventDefault(); + + colName = selectedCell.parentNode.id; + column = this.columns.get(colName); + visibleCells = column.visibleCellNodes; + index = visibleCells.indexOf(selectedCell); + + if (index > 0) { + index--; + } else { + index = visibleCells.length - 1; + } + + cell = visibleCells[index]; + + this.emit(EVENTS.ROW_SELECTED, cell.getAttribute("data-id")); + break; + case KeyCodes.DOM_VK_DOWN: + event.preventDefault(); + + colName = selectedCell.parentNode.id; + column = this.columns.get(colName); + visibleCells = column.visibleCellNodes; + index = visibleCells.indexOf(selectedCell); + + if (index === visibleCells.length - 1) { + index = 0; + } else { + index++; + } + + cell = visibleCells[index]; + + this.emit(EVENTS.ROW_SELECTED, cell.getAttribute("data-id")); + break; + } + }, + + /** + * Close any editors if the area "outside the table" is clicked. In reality, + * the table covers the whole area but there are labels filling the top few + * rows. This method clears any inline editors if an area outside a textbox or + * label is clicked. + */ + onMousedown({ target }) { + const localName = target.localName; + + if (localName === "input" || !this._editableFieldsEngine) { + return; + } + + // Force any editor fields to hide due to XUL focus quirks. + this._editableFieldsEngine.blur(); + }, + + /** + * Make table fields editable. + * + * @param {String|Array} editableColumns + * An array or comma separated list of editable column names. + */ + makeFieldsEditable(editableColumns) { + const selectors = []; + + if (typeof editableColumns === "string") { + editableColumns = [editableColumns]; + } + + for (const id of editableColumns) { + selectors.push("#" + id + " .table-widget-cell"); + } + + for (const [name, column] of this.columns) { + if (!editableColumns.includes(name)) { + column.column.setAttribute("readonly", ""); + } + } + + if (this._editableFieldsEngine) { + this._editableFieldsEngine.selectors = selectors; + this._editableFieldsEngine.items = this.items; + } else { + this._editableFieldsEngine = new EditableFieldsEngine({ + root: this.tbody, + onTab: this.onEditorTab, + onTriggerEvent: "dblclick", + selectors, + items: this.items, + }); + + this._editableFieldsEngine.on("change", this.onChange); + this._editableFieldsEngine.on("destroyed", this.onEditorDestroyed); + + this.on(EVENTS.ROW_REMOVED, this.onRowRemoved); + this.on(EVENTS.TABLE_CLEARED, this._editableFieldsEngine.cancelEdit); + + this.emit(EVENTS.FIELDS_EDITABLE, this._editableFieldsEngine); + } + }, + + destroy() { + this.off(EVENTS.ROW_SELECTED, this.bindSelectedRow); + this.off(EVENTS.ROW_REMOVED, this.onRowRemoved); + + this.document.removeEventListener("keydown", this.onKeydown); + this.document.removeEventListener("mousedown", this.onMousedown); + + if (this._editableFieldsEngine) { + this.off(EVENTS.TABLE_CLEARED, this._editableFieldsEngine.cancelEdit); + this._editableFieldsEngine.off("change", this.onChange); + this._editableFieldsEngine.off("destroyed", this.onEditorDestroyed); + this._editableFieldsEngine.destroy(); + this._editableFieldsEngine = null; + } + + if (this.menupopup) { + this.menupopup.removeEventListener("command", this.onPopupCommand); + this.menupopup.remove(); + } + }, + + /** + * Sets the localization ID of the description to be shown when the table is empty. + * + * @param {String} l10nID + * The ID of the localization string. + * @param {String} learnMoreURL + * A URL referring to a website with further information related to + * the data shown in the table widget. + */ + setPlaceholder(l10nID, learnMoreURL) { + if (learnMoreURL) { + let placeholderLink = this.placeholder.firstElementChild; + if (!placeholderLink) { + placeholderLink = this.document.createElement("a"); + placeholderLink.setAttribute("target", "_blank"); + placeholderLink.setAttribute("data-l10n-name", "learn-more-link"); + this.placeholder.appendChild(placeholderLink); + } + placeholderLink.setAttribute("href", learnMoreURL); + } else { + // Remove link element if no learn more URL is given + this.placeholder.firstElementChild?.remove(); + } + + this.l10n.setAttributes(this.placeholder, l10nID); + }, + + /** + * Prepares the context menu for the headers of the table columns. This + * context menu allows users to toggle various columns, only with an exception + * of the unique columns and when only two columns are visible in the table. + */ + setupHeadersContextMenu() { + let popupset = this.document.getElementsByTagName("popupset")[0]; + if (!popupset) { + popupset = this.document.createXULElement("popupset"); + this.document.documentElement.appendChild(popupset); + } + + this.menupopup = this.document.createXULElement("menupopup"); + this.menupopup.id = "table-widget-column-select"; + this.menupopup.addEventListener("command", this.onPopupCommand); + popupset.appendChild(this.menupopup); + this.populateMenuPopup(); + }, + + /** + * Populates the header context menu with the names of the columns along with + * displaying which columns are hidden or visible. + * + * @param {Array} privateColumns=[] + * An array of column names that should never appear in the table. This + * allows us to e.g. have an invisible compound primary key for a + * table's rows. + */ + populateMenuPopup(privateColumns = []) { + if (!this.menupopup) { + return; + } + + while (this.menupopup.firstChild) { + this.menupopup.firstChild.remove(); + } + + for (const column of this.columns.values()) { + if (privateColumns.includes(column.id)) { + continue; + } + + const menuitem = this.document.createXULElement("menuitem"); + menuitem.setAttribute("label", column.header.getAttribute("value")); + menuitem.setAttribute("data-id", column.id); + menuitem.setAttribute("type", "checkbox"); + menuitem.setAttribute("checked", !column.hidden); + if (column.id == this.uniqueId) { + menuitem.setAttribute("disabled", "true"); + } + this.menupopup.appendChild(menuitem); + } + const checked = this.menupopup.querySelectorAll("menuitem[checked]"); + if (checked.length == 2) { + checked[checked.length - 1].setAttribute("disabled", "true"); + } + }, + + /** + * Event handler for the `command` event on the column headers context menu + */ + onPopupCommand(event) { + const item = event.originalTarget; + let checked = !!item.getAttribute("checked"); + const id = item.getAttribute("data-id"); + this.emit(EVENTS.HEADER_CONTEXT_MENU, id, checked); + checked = this.menupopup.querySelectorAll("menuitem[checked]"); + const disabled = this.menupopup.querySelectorAll("menuitem[disabled]"); + if (checked.length == 2) { + checked[checked.length - 1].setAttribute("disabled", "true"); + } else if (disabled.length > 1) { + disabled[disabled.length - 1].removeAttribute("disabled"); + } + }, + + /** + * Creates the columns in the table. Without calling this method, data cannot + * be inserted into the table unless `initialColumns` was supplied. + * + * @param {Object} columns + * A key value pair representing the columns of the table. Where the + * key represents the id of the column and the value is the displayed + * label in the header of the column. + * @param {String} sortOn + * The id of the column on which the table will be initially sorted on. + * @param {Array} hiddenColumns + * Ids of all the columns that are hidden by default. + * @param {Array} privateColumns=[] + * An array of column names that should never appear in the table. This + * allows us to e.g. have an invisible compound primary key for a + * table's rows. + */ + setColumns( + columns, + sortOn = this.sortedOn, + hiddenColumns = [], + privateColumns = [] + ) { + for (const column of this.columns.values()) { + column.destroy(); + } + + this.columns.clear(); + + if (!(sortOn in columns)) { + sortOn = null; + } + + if (!(this.firstColumn in columns)) { + this.firstColumn = null; + } + + if (this.firstColumn) { + this.columns.set( + this.firstColumn, + new Column(this, this.firstColumn, columns[this.firstColumn]) + ); + } + + for (const id in columns) { + if (!sortOn) { + sortOn = id; + } + + if (this.firstColumn && id == this.firstColumn) { + continue; + } + + this.columns.set(id, new Column(this, id, columns[id])); + if (hiddenColumns.includes(id) || privateColumns.includes(id)) { + // Hide the column. + this.columns.get(id).toggleColumn(); + + if (privateColumns.includes(id)) { + this.columns.get(id).private = true; + } + } + } + this.sortedOn = sortOn; + this.sortBy(this.sortedOn); + this.populateMenuPopup(privateColumns); + }, + + /** + * Returns true if the passed string or the row json object corresponds to the + * selected item in the table. + */ + isSelected(item) { + if (typeof item == "object") { + item = item[this.uniqueId]; + } + + return this.selectedRow && item == this.selectedRow[this.uniqueId]; + }, + + /** + * Selects the row corresponding to the `id` json. + */ + selectRow(id) { + this.selectedRow = id; + }, + + /** + * Selects the next row. Cycles over to the first row if last row is selected + */ + selectNextRow() { + for (const column of this.columns.values()) { + column.selectNextRow(); + } + }, + + /** + * Selects the previous row. Cycles over to the last row if first row is + * selected. + */ + selectPreviousRow() { + for (const column of this.columns.values()) { + column.selectPreviousRow(); + } + }, + + /** + * Clears any selected row. + */ + clearSelection() { + this.selectedIndex = -1; + }, + + /** + * Adds a row into the table. + * + * @param {object} item + * The object from which the key-value pairs will be taken and added + * into the row. This object can have any arbitarary key value pairs, + * but only those will be used whose keys match to the ids of the + * columns. + * @param {boolean} suppressFlash + * true to not flash the row while inserting the row. + */ + push(item, suppressFlash) { + if (!this.sortedOn || !this.columns) { + console.error("Can't insert item without defining columns first"); + return; + } + + if (this.items.has(item[this.uniqueId])) { + this.update(item); + return; + } + + if (this.editBookmark && !this.items.has(this.editBookmark)) { + // Key has been updated... update bookmark. + this.editBookmark = item[this.uniqueId]; + } + + const index = this.columns.get(this.sortedOn).push(item); + for (const [key, column] of this.columns) { + if (key != this.sortedOn) { + column.insertAt(item, index); + } + column.updateZebra(); + } + this.items.set(item[this.uniqueId], item); + this.tbody.removeAttribute("empty"); + + if (!suppressFlash) { + this.emit(EVENTS.ROW_UPDATED, item[this.uniqueId]); + } + + this.emit(EVENTS.ROW_EDIT, item[this.uniqueId]); + }, + + /** + * Removes the row associated with the `item` object. + */ + remove(item) { + if (typeof item != "object") { + item = this.items.get(item); + } + if (!item) { + return; + } + const removed = this.items.delete(item[this.uniqueId]); + + if (!removed) { + return; + } + for (const column of this.columns.values()) { + column.remove(item); + column.updateZebra(); + } + if (this.items.size === 0) { + this.selectedRow = null; + this.tbody.setAttribute("empty", "empty"); + } + + this.emit(EVENTS.ROW_REMOVED, item); + }, + + /** + * Updates the items in the row corresponding to the `item` object previously + * used to insert the row using `push` method. The linking is done via the + * `uniqueId` key's value. + */ + update(item) { + const oldItem = this.items.get(item[this.uniqueId]); + if (!oldItem) { + return; + } + this.items.set(item[this.uniqueId], item); + + let changed = false; + for (const column of this.columns.values()) { + if (item[column.id] != oldItem[column.id]) { + column.update(item); + changed = true; + } + } + if (changed) { + this.emit(EVENTS.ROW_UPDATED, item[this.uniqueId]); + this.emit(EVENTS.ROW_EDIT, item[this.uniqueId]); + } + }, + + /** + * Removes all of the rows from the table. + */ + clear() { + this.items.clear(); + for (const column of this.columns.values()) { + column.clear(); + } + this.tbody.setAttribute("empty", "empty"); + this.setPlaceholder(this.emptyText); + + this.selectedRow = null; + + this.emit(EVENTS.TABLE_CLEARED, this); + }, + + /** + * Sorts the table by a given column. + * + * @param {string} column + * The id of the column on which the table should be sorted. + */ + sortBy(column) { + this.emit(EVENTS.COLUMN_SORTED, column); + this.sortedOn = column; + + if (!this.items.size) { + return; + } + + // First sort the column to "sort by" explicitly. + const sortedItems = this.columns.get(column).sort([...this.items.values()]); + + // Then, sort all the other columns (id !== column) only based on the + // sortedItems provided by the first sort. + // Each column keeps track of the fact that it is the "sort by" column or + // not, so this will not shuffle the items and will just make sure each + // column displays the correct value. + for (const [id, col] of this.columns) { + if (id !== column) { + col.sort(sortedItems); + } + } + }, + + /** + * Filters the table based on a specific value + * + * @param {String} value: The filter value + * @param {Array} ignoreProps: Props to ignore while filtering + */ + filterItems(value, ignoreProps = []) { + if (this.filteredValue == value) { + return; + } + if (this._editableFieldsEngine) { + this._editableFieldsEngine.completeEdit(); + } + + this.filteredValue = value; + if (!value) { + this.emit(EVENTS.TABLE_FILTERED, []); + return; + } + // Shouldn't be case-sensitive + value = value.toLowerCase(); + + const itemsToHide = [...this.items.keys()]; + // Loop through all items and hide unmatched items + for (const [id, val] of this.items) { + for (const prop in val) { + const column = this.columns.get(prop); + if (ignoreProps.includes(prop) || column.hidden) { + continue; + } + + const propValue = val[prop].toString().toLowerCase(); + if (propValue.includes(value)) { + itemsToHide.splice(itemsToHide.indexOf(id), 1); + break; + } + } + } + this.emit(EVENTS.TABLE_FILTERED, itemsToHide); + }, + + /** + * Calls the afterScroll function when the user has stopped scrolling + */ + onScroll() { + clearNamedTimeout("table-scroll"); + setNamedTimeout("table-scroll", AFTER_SCROLL_DELAY, this.afterScroll); + }, + + /** + * Emits the "scroll-end" event when the whole table is scrolled + */ + afterScroll() { + const maxScrollTop = this.tbody.scrollHeight - this.tbody.clientHeight; + // Emit scroll-end event when 9/10 of the table is scrolled + if (this.tbody.scrollTop >= 0.9 * maxScrollTop) { + this.emit("scroll-end"); + } + }, +}; + +TableWidget.EVENTS = EVENTS; + +module.exports.TableWidget = TableWidget; + +/** + * A single column object in the table. + * + * @param {TableWidget} table + * The table object to which the column belongs. + * @param {string} id + * Id of the column. + * @param {String} header + * The displayed string on the column's header. + */ +function Column(table, id, header) { + // By default cells are visible in the UI. + this._private = false; + + this.tbody = table.tbody; + this.document = table.document; + this.window = table.window; + this.id = id; + this.uniqueId = table.uniqueId; + this.wrapTextInElements = table.wrapTextInElements; + this.table = table; + this.cells = []; + this.items = {}; + + this.highlightUpdated = table.highlightUpdated; + + this.column = this.document.createElementNS(HTML_NS, "div"); + this.column.id = id; + this.column.className = "table-widget-column"; + this.tbody.appendChild(this.column); + + this.splitter = this.document.createXULElement("splitter"); + this.splitter.className = "devtools-side-splitter"; + this.tbody.appendChild(this.splitter); + + this.header = this.document.createXULElement("label"); + this.header.className = "devtools-toolbar table-widget-column-header"; + this.header.setAttribute("value", header); + this.column.appendChild(this.header); + if (table.headersContextMenu) { + this.header.setAttribute("context", table.headersContextMenu); + } + this.toggleColumn = this.toggleColumn.bind(this); + this.table.on(EVENTS.HEADER_CONTEXT_MENU, this.toggleColumn); + + this.onColumnSorted = this.onColumnSorted.bind(this); + this.table.on(EVENTS.COLUMN_SORTED, this.onColumnSorted); + + this.onRowUpdated = this.onRowUpdated.bind(this); + this.table.on(EVENTS.ROW_UPDATED, this.onRowUpdated); + + this.onTableFiltered = this.onTableFiltered.bind(this); + this.table.on(EVENTS.TABLE_FILTERED, this.onTableFiltered); + + this.onClick = this.onClick.bind(this); + this.onMousedown = this.onMousedown.bind(this); + this.column.addEventListener("click", this.onClick); + this.column.addEventListener("mousedown", this.onMousedown); +} + +Column.prototype = { + // items is a cell-id to cell-index map. It is basically a reverse map of the + // this.cells object and is used to quickly reverse lookup a cell by its id + // instead of looping through the cells array. This reverse map is not kept + // upto date in sync with the cells array as updating it is in itself a loop + // through all the cells of the columns. Thus update it on demand when it goes + // out of sync with this.cells. + items: null, + + // _itemsDirty is a flag which becomes true when this.items goes out of sync + // with this.cells + _itemsDirty: null, + + selectedRow: null, + + cells: null, + + /** + * Gets whether the table is sorted on this column or not. + * 0 - not sorted. + * 1 - ascending order + * 2 - descending order + */ + get sorted() { + return this._sortState || 0; + }, + + /** + * Returns a boolean indicating whether the column is hidden. + */ + get hidden() { + return this.column.hidden; + }, + + /** + * Get the private state of the column (visibility in the UI). + */ + get private() { + return this._private; + }, + + /** + * Set the private state of the column (visibility in the UI). + * + * @param {Boolean} state + * Private (true or false) + */ + set private(state) { + this._private = state; + }, + + /** + * Sets the sorted value + */ + set sorted(value) { + if (!value) { + this.header.removeAttribute("sorted"); + } else { + this.header.setAttribute( + "sorted", + value == 1 ? "ascending" : "descending" + ); + } + this._sortState = value; + }, + + /** + * Gets the selected row in the column. + */ + get selectedIndex() { + if (!this.selectedRow) { + return -1; + } + return this.items[this.selectedRow]; + }, + + get cellNodes() { + return [...this.column.querySelectorAll(".table-widget-cell")]; + }, + + get visibleCellNodes() { + const editor = this.table._editableFieldsEngine; + const nodes = this.cellNodes.filter(node => { + // If the cell is currently being edited we should class it as visible. + if (editor && editor.currentTarget === node) { + return true; + } + return node.clientWidth !== 0; + }); + + return nodes; + }, + + /** + * Called when the column is sorted by. + * + * @param {string} column + * The id of the column being sorted by. + */ + onColumnSorted(column) { + if (column != this.id) { + this.sorted = 0; + return; + } else if (this.sorted == 0 || this.sorted == 2) { + this.sorted = 1; + } else { + this.sorted = 2; + } + this.updateZebra(); + }, + + onTableFiltered(itemsToHide) { + this._updateItems(); + if (!this.cells) { + return; + } + for (const cell of this.cells) { + cell.hidden = false; + } + for (const id of itemsToHide) { + this.cells[this.items[id]].hidden = true; + } + this.updateZebra(); + }, + + /** + * Called when a row is updated e.g. a cell is changed. This means that + * for a new row this method will be called once for each column. If a single + * cell is changed this method will be called just once. + * + * @param {string} event + * The event name of the event. i.e. EVENTS.ROW_UPDATED + * @param {string} id + * The unique id of the object associated with the row. + */ + onRowUpdated(id) { + this._updateItems(); + + if (this.highlightUpdated && this.items[id] != null) { + if (this.table.scrollIntoViewOnUpdate) { + const cell = this.cells[this.items[id]]; + + // When a new row is created this method is called once for each column + // as each cell is updated. We can only scroll to cells if they are + // visible. We check for visibility and once we find the first visible + // cell in a row we scroll it into view and reset the + // scrollIntoViewOnUpdate flag. + if (cell.label.clientHeight > 0) { + cell.scrollIntoView(); + + this.table.scrollIntoViewOnUpdate = null; + } + } + + if (this.table.editBookmark) { + // A rows position in the table can change as the result of an edit. In + // order to ensure that the correct row is highlighted after an edit we + // save the uniqueId in editBookmark. Here we send the signal that the + // row has been edited and that the row needs to be selected again. + this.table.emit(EVENTS.ROW_SELECTED, this.table.editBookmark); + this.table.editBookmark = null; + } + + this.cells[this.items[id]].flash(); + } + + this.updateZebra(); + }, + + destroy() { + this.table.off(EVENTS.COLUMN_SORTED, this.onColumnSorted); + this.table.off(EVENTS.HEADER_CONTEXT_MENU, this.toggleColumn); + this.table.off(EVENTS.ROW_UPDATED, this.onRowUpdated); + this.table.off(EVENTS.TABLE_FILTERED, this.onTableFiltered); + + this.column.removeEventListener("click", this.onClick); + this.column.removeEventListener("mousedown", this.onMousedown); + + this.splitter.remove(); + this.column.remove(); + this.cells = null; + this.items = null; + this.selectedRow = null; + }, + + /** + * Selects the row at the `index` index + */ + selectRowAt(index) { + if (this.selectedRow != null) { + this.cells[this.items[this.selectedRow]].classList.remove( + "theme-selected" + ); + } + + const cell = this.cells[index]; + if (cell) { + cell.classList.add("theme-selected"); + this.selectedRow = cell.id; + } else { + this.selectedRow = null; + } + }, + + /** + * Selects the row with the object having the `uniqueId` value as `id` + */ + selectRow(id) { + this._updateItems(); + this.selectRowAt(this.items[id]); + }, + + /** + * Selects the next row. Cycles to first if last row is selected. + */ + selectNextRow() { + this._updateItems(); + let index = this.items[this.selectedRow] + 1; + if (index == this.cells.length) { + index = 0; + } + this.selectRowAt(index); + }, + + /** + * Selects the previous row. Cycles to last if first row is selected. + */ + selectPreviousRow() { + this._updateItems(); + let index = this.items[this.selectedRow] - 1; + if (index == -1) { + index = this.cells.length - 1; + } + this.selectRowAt(index); + }, + + /** + * Pushes the `item` object into the column. If this column is sorted on, + * then inserts the object at the right position based on the column's id + * key's value. + * + * @returns {number} + * The index of the currently pushed item. + */ + push(item) { + const value = item[this.id]; + + if (this.sorted) { + let index; + if (this.sorted == 1) { + index = this.cells.findIndex(element => { + return ( + naturalSortCaseInsensitive( + value, + element.value, + standardSessionString + ) === -1 + ); + }); + } else { + index = this.cells.findIndex(element => { + return ( + naturalSortCaseInsensitive( + value, + element.value, + standardSessionString + ) === 1 + ); + }); + } + index = index >= 0 ? index : this.cells.length; + if (index < this.cells.length) { + this._itemsDirty = true; + } + this.items[item[this.uniqueId]] = index; + this.cells.splice(index, 0, new Cell(this, item, this.cells[index])); + return index; + } + + this.items[item[this.uniqueId]] = this.cells.length; + return this.cells.push(new Cell(this, item)) - 1; + }, + + /** + * Inserts the `item` object at the given `index` index in the table. + */ + insertAt(item, index) { + if (index < this.cells.length) { + this._itemsDirty = true; + } + this.items[item[this.uniqueId]] = index; + this.cells.splice(index, 0, new Cell(this, item, this.cells[index])); + this.updateZebra(); + }, + + /** + * Event handler for the command event coming from the header context menu. + * Toggles the column if it was requested by the user. + * When called explicitly without parameters, it toggles the corresponding + * column. + * + * @param {string} event + * The name of the event. i.e. EVENTS.HEADER_CONTEXT_MENU + * @param {string} id + * Id of the column to be toggled + * @param {string} checked + * true if the column is visible + */ + toggleColumn(id, checked) { + if (!arguments.length) { + // Act like a toggling method when called with no params + id = this.id; + checked = this.column.hidden; + } + if (id != this.id) { + return; + } + if (checked) { + this.column.hidden = false; + this.tbody.insertBefore(this.splitter, this.column.nextSibling); + } else { + this.column.hidden = true; + this.splitter.remove(); + } + }, + + /** + * Removes the corresponding item from the column and hide the last visible + * splitter with CSS, so we do not add splitter elements for hidden columns. + */ + remove(item) { + this._updateItems(); + const index = this.items[item[this.uniqueId]]; + if (index == null) { + return; + } + + if (index < this.cells.length) { + this._itemsDirty = true; + } + this.cells[index].destroy(); + this.cells.splice(index, 1); + delete this.items[item[this.uniqueId]]; + }, + + /** + * Updates the corresponding item from the column. + */ + update(item) { + this._updateItems(); + + const index = this.items[item[this.uniqueId]]; + if (index == null) { + return; + } + + this.cells[index].value = item[this.id]; + }, + + /** + * Updates the `this.items` cell-id vs cell-index map to be in sync with + * `this.cells`. + */ + _updateItems() { + if (!this._itemsDirty) { + return; + } + for (let i = 0; i < this.cells.length; i++) { + this.items[this.cells[i].id] = i; + } + this._itemsDirty = false; + }, + + /** + * Clears the current column + */ + clear() { + this.cells = []; + this.items = {}; + this._itemsDirty = false; + while (this.header.nextSibling) { + this.header.nextSibling.remove(); + } + }, + + /** + * Sorts the given items and returns the sorted list if the table was sorted + * by this column. + */ + sort(items) { + // Only sort the array if we are sorting based on this column + if (this.sorted == 1) { + items.sort((a, b) => { + const val1 = Node.isInstance(a[this.id]) + ? a[this.id].textContent + : a[this.id]; + const val2 = Node.isInstance(b[this.id]) + ? b[this.id].textContent + : b[this.id]; + return naturalSortCaseInsensitive(val1, val2, standardSessionString); + }); + } else if (this.sorted > 1) { + items.sort((a, b) => { + const val1 = Node.isInstance(a[this.id]) + ? a[this.id].textContent + : a[this.id]; + const val2 = Node.isInstance(b[this.id]) + ? b[this.id].textContent + : b[this.id]; + return naturalSortCaseInsensitive(val2, val1, standardSessionString); + }); + } + + if (this.selectedRow) { + this.cells[this.items[this.selectedRow]].classList.remove( + "theme-selected" + ); + } + this.items = {}; + // Otherwise, just use the sorted array passed to update the cells value. + for (const [i, item] of items.entries()) { + // See Bug 1706679 (Intermittent) + // Sometimes we would reach the situation in which we were trying to sort + // and item that was no longer available in the TableWidget. + // We should find exactly what is triggering it. + if (!this.cells[i]) { + continue; + } + this.items[item[this.uniqueId]] = i; + this.cells[i].value = item[this.id]; + this.cells[i].id = item[this.uniqueId]; + } + if (this.selectedRow) { + this.cells[this.items[this.selectedRow]].classList.add("theme-selected"); + } + this._itemsDirty = false; + this.updateZebra(); + return items; + }, + + updateZebra() { + this._updateItems(); + let i = 0; + for (const cell of this.cells) { + if (!cell.hidden) { + i++; + } + + const even = !(i % 2); + cell.classList.toggle("even", even); + } + }, + + /** + * Click event handler for the column. Used to detect click on header for + * for sorting. + */ + onClick(event) { + const target = event.originalTarget; + + if (target.nodeType !== target.ELEMENT_NODE || target == this.column) { + return; + } + + if (event.button == 0 && target == this.header) { + this.table.sortBy(this.id); + } + }, + + /** + * Mousedown event handler for the column. Used to select rows. + */ + onMousedown(event) { + const target = event.originalTarget; + + if ( + target.nodeType !== target.ELEMENT_NODE || + target == this.column || + target == this.header + ) { + return; + } + if (event.button == 0) { + const closest = target.closest("[data-id]"); + if (!closest) { + return; + } + + const dataid = closest.getAttribute("data-id"); + this.table.emit(EVENTS.ROW_SELECTED, dataid); + } + }, +}; + +/** + * A single cell in a column + * + * @param {Column} column + * The column object to which the cell belongs. + * @param {object} item + * The object representing the row. It contains a key value pair + * representing the column id and its associated value. The value + * can be a DOMNode that is appended or a string value. + * @param {Cell} nextCell + * The cell object which is next to this cell. null if this cell is last + * cell of the column + */ +function Cell(column, item, nextCell) { + const document = column.document; + + this.wrapTextInElements = column.wrapTextInElements; + this.label = document.createXULElement("label"); + this.label.setAttribute("crop", "end"); + this.label.className = "plain table-widget-cell"; + + if (nextCell) { + column.column.insertBefore(this.label, nextCell.label); + } else { + column.column.appendChild(this.label); + } + + if (column.table.cellContextMenuId) { + this.label.setAttribute("context", column.table.cellContextMenuId); + this.label.addEventListener("contextmenu", event => { + // Make the ID of the clicked cell available as a property on the table. + // It's then available for the popupshowing or command handler. + column.table.contextMenuRowId = this.id; + }); + } + + this.value = item[column.id]; + this.id = item[column.uniqueId]; +} + +Cell.prototype = { + set id(value) { + this._id = value; + this.label.setAttribute("data-id", value); + }, + + get id() { + return this._id; + }, + + get hidden() { + return this.label.hidden; + }, + + set hidden(value) { + this.label.hidden = value; + }, + + set value(value) { + this._value = value; + if (value == null) { + this.label.setAttribute("value", ""); + return; + } + + if (this.wrapTextInElements && !Node.isInstance(value)) { + const span = this.label.ownerDocument.createElementNS(HTML_NS, "span"); + span.textContent = value; + value = span; + } + + if (Node.isInstance(value)) { + this.label.removeAttribute("value"); + + while (this.label.firstChild) { + this.label.firstChild.remove(); + } + + this.label.appendChild(value); + } else { + this.label.setAttribute("value", value + ""); + } + }, + + get value() { + return this._value; + }, + + get classList() { + return this.label.classList; + }, + + /** + * Flashes the cell for a brief time. This when done for with cells in all + * columns, makes it look like the row is being highlighted/flashed. + */ + flash() { + if (!this.label.parentNode) { + return; + } + this.label.classList.remove("flash-out"); + // Cause a reflow so that the animation retriggers on adding back the class + let a = this.label.parentNode.offsetWidth; // eslint-disable-line + const onAnimEnd = () => { + this.label.classList.remove("flash-out"); + this.label.removeEventListener("animationend", onAnimEnd); + }; + this.label.addEventListener("animationend", onAnimEnd); + this.label.classList.add("flash-out"); + }, + + focus() { + this.label.focus(); + }, + + scrollIntoView() { + this.label.scrollIntoView(false); + }, + + destroy() { + this.label.remove(); + this.label = null; + }, +}; + +/** + * Simple widget to make nodes matching a CSS selector editable. + * + * @param {Object} options + * An object with the following format: + * { + * // The node that will act as a container for the editor e.g. a + * // div or table. + * root: someNode, + * + * // The onTab event to be handled by the caller. + * onTab: function(event) { ... } + * + * // Optional event used to trigger the editor. By default this is + * // dblclick. + * onTriggerEvent: "dblclick", + * + * // Array or comma separated string of CSS Selectors matching + * // elements that are to be made editable. + * selectors: [ + * "#name .table-widget-cell", + * "#value .table-widget-cell" + * ] + * } + */ +function EditableFieldsEngine(options) { + EventEmitter.decorate(this); + + if (!Array.isArray(options.selectors)) { + options.selectors = [options.selectors]; + } + + this.root = options.root; + this.selectors = options.selectors; + this.onTab = options.onTab; + this.onTriggerEvent = options.onTriggerEvent || "dblclick"; + this.items = options.items; + + this.edit = this.edit.bind(this); + this.cancelEdit = this.cancelEdit.bind(this); + this.destroy = this.destroy.bind(this); + + this.onTrigger = this.onTrigger.bind(this); + this.root.addEventListener(this.onTriggerEvent, this.onTrigger); +} + +EditableFieldsEngine.prototype = { + INPUT_ID: "inlineEditor", + + get changePending() { + return this.isEditing && this.textbox.value !== this.currentValue; + }, + + get isEditing() { + return this.root && !this.textbox.hidden; + }, + + get textbox() { + if (!this._textbox) { + const doc = this.root.ownerDocument; + this._textbox = doc.createElementNS(HTML_NS, "input"); + this._textbox.id = this.INPUT_ID; + + this.onKeydown = this.onKeydown.bind(this); + this._textbox.addEventListener("keydown", this.onKeydown); + + this.completeEdit = this.completeEdit.bind(this); + doc.addEventListener("blur", this.completeEdit); + } + + return this._textbox; + }, + + /** + * Called when a trigger event is detected (default is dblclick). + * + * @param {EventTarget} target + * Calling event's target. + */ + onTrigger({ target }) { + this.edit(target); + }, + + /** + * Handle keydowns when in edit mode: + * - <escape> revert the value and close the textbox. + * - <return> apply the value and close the textbox. + * - <tab> Handled by the consumer's `onTab` callback. + * - <shift><tab> Handled by the consumer's `onTab` callback. + * + * @param {Event} event + * The calling event. + */ + onKeydown(event) { + if (!this.textbox) { + return; + } + + switch (event.keyCode) { + case KeyCodes.DOM_VK_ESCAPE: + this.cancelEdit(); + event.preventDefault(); + break; + case KeyCodes.DOM_VK_RETURN: + this.completeEdit(); + break; + case KeyCodes.DOM_VK_TAB: + if (this.onTab) { + this.onTab(event); + } + break; + } + }, + + /** + * Overlay the target node with an edit field. + * + * @param {Node} target + * Dom node to be edited. + */ + edit(target) { + if (!target) { + return; + } + + // Some item names and values are not parsable by the client or server so should not be + // editable. + const name = target.getAttribute("data-id"); + const item = this.items.get(name); + if ("isValueEditable" in item && !item.isValueEditable) { + return; + } + + target.scrollIntoView(false); + target.focus(); + + if (!target.matches(this.selectors.join(","))) { + return; + } + + // If we are actively editing something complete the edit first. + if (this.isEditing) { + this.completeEdit(); + } + + this.copyStyles(target, this.textbox); + + target.parentNode.insertBefore(this.textbox, target); + this.currentTarget = target; + this.textbox.value = this.currentValue = target.value; + target.hidden = true; + this.textbox.hidden = false; + + this.textbox.focus(); + this.textbox.select(); + }, + + completeEdit() { + if (!this.isEditing) { + return; + } + + const oldValue = this.currentValue; + const newValue = this.textbox.value; + const changed = oldValue !== newValue; + + this.textbox.hidden = true; + + if (!this.currentTarget) { + return; + } + + this.currentTarget.hidden = false; + if (changed) { + this.currentTarget.value = newValue; + + const data = { + change: { + field: this.currentTarget, + oldValue, + newValue, + }, + }; + + this.emit("change", data); + } + }, + + /** + * Cancel an edit. + */ + cancelEdit() { + if (!this.isEditing) { + return; + } + if (this.currentTarget) { + this.currentTarget.hidden = false; + } + + this.textbox.hidden = true; + }, + + /** + * Stop edit mode and apply changes. + */ + blur() { + if (this.isEditing) { + this.completeEdit(); + } + }, + + /** + * Copies various styles from one node to another. + * + * @param {Node} source + * The node to copy styles from. + * @param {Node} destination [description] + * The node to copy styles to. + */ + copyStyles(source, destination) { + const style = source.ownerDocument.defaultView.getComputedStyle(source); + const props = [ + "borderTopWidth", + "borderRightWidth", + "borderBottomWidth", + "borderLeftWidth", + "fontFamily", + "fontSize", + "fontWeight", + "height", + "marginTop", + "marginRight", + "marginBottom", + "marginLeft", + "marginInlineStart", + "marginInlineEnd", + ]; + + for (const prop of props) { + destination.style[prop] = style[prop]; + } + + // We need to set the label width to 100% to work around a XUL flex bug. + destination.style.width = "100%"; + }, + + /** + * Destroys all editors in the current document. + */ + destroy() { + if (this.textbox) { + this.textbox.removeEventListener("keydown", this.onKeydown); + this.textbox.remove(); + } + + if (this.root) { + this.root.removeEventListener(this.onTriggerEvent, this.onTrigger); + this.root.ownerDocument.removeEventListener("blur", this.completeEdit); + } + + this._textbox = this.root = this.selectors = this.onTab = null; + this.currentTarget = this.currentValue = null; + + this.emit("destroyed"); + }, +}; |