/* 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/. */ const { AppConstants } = ChromeUtils.importESModule( "resource://gre/modules/AppConstants.sys.mjs" ); const { UIDensity } = ChromeUtils.importESModule( "resource:///modules/UIDensity.sys.mjs" ); import { TreeView, TreeViewTableRow, } from "chrome://messenger/content/tree-view.mjs"; const xulStoreURL = location.href.replace(/\?.*/, ""); /** * Create a shallow clone of an array of column definitions. Use this to avoid * the same objects being used in multiple places, so that properties are not * accidentally overwritten. * * @param {ColumnDef[]} columns * @returns {ColumnDef[]} */ function cloneColumns(columns) { return columns.map(column => ({ ...column })); } /** * Subclass of TreeView that handles the column arrangement and sorting events * automatically. Remembers the columns and sort order between sessions. */ class AutoTreeView extends TreeView { #defaultColumns; #rowFragment; connectedCallback() { super.connectedCallback(); this.table.editable = true; this.table.addEventListener("column-resized", this); this.table.addEventListener("columns-changed", this); this.table.addEventListener("reorder-columns", this); this.table.addEventListener("restore-columns", this); this.table.addEventListener("sort-changed", this); window.addEventListener("uidensitychange", this); } disconnectedCallback() { super.disconnectedCallback(); window.removeEventListener("uidensitychange", this); } handleEvent(event) { switch (event.type) { case "column-resized": this.resizeColumn(event.detail.column, event.detail.splitter.width); break; case "columns-changed": this.changeColumns( event.detail.value, !event.detail.target.hasAttribute("checked") ); break; case "reorder-columns": // TODO: Fix this event so it provides column IDs, not column defs. this.reorderColumns(event.detail.columns.map(column => column.id)); break; case "restore-columns": this.restoreDefaultColumns(); break; case "sort-changed": this.sortBy(event.detail.column); break; case "uidensitychange": if (this._rowElementClass) { this._rowElementClass.ROW_HEIGHT = this._rowElementClass.ROW_HEIGHTS[UIDensity.prefValue]; this.reset(); } break; case "keydown": { let modifier = event.ctrlKey; let antiModifier = event.metaKey; if (AppConstants.platform == "macosx") { [modifier, antiModifier] = [antiModifier, modifier]; } if (event.key.toLowerCase() == "a" && modifier && !antiModifier) { this.selectAll(); this.dispatchEvent(new CustomEvent("select")); event.preventDefault(); break; } // Falls through. } default: super.handleEvent(event); break; } } /** * The default columns for this TreeView. These will be used if there's no * information in the xulStore about columns, or if the user clicks the * "Restore Columns" item from the column picker. * * @type {ColumnDef[]} */ get defaultColumns() { return this.#defaultColumns; } set defaultColumns(columns) { if (this.#defaultColumns) { throw new Error( "Default columns on a tree view should be set only once." ); } for (const column of columns) { if (!column.id) { throw new Error("Tree view columns must have IDs."); } if (document.getElementById(column.id)) { throw new Error( "Tree view column IDs must be unique within the document." ); } if (/[^\w-]/.test(column.id)) { throw new Error("Tree view column IDs must use only safe characters."); } } this.#defaultColumns = cloneColumns(columns); this.table.setColumns(this.#restoreColumns(cloneColumns(columns))); } /** * The current view for this list. Setting a view causes it to be sorted, * if there is information in the xulStore about sorting. * * @type {TreeDataAdapter} */ get view() { return super.view; } set view(view) { if (!view) { super.view = view; return; } const sortColumn = Services.xulStore.getValue( xulStoreURL, this.id, "sortColumn" ); const sortDirection = Services.xulStore.getValue( xulStoreURL, this.id, "sortDirection" ); if (sortColumn && sortDirection) { // Pre-sort the view to avoid displaying it unsorted, then sorting it. view.sortBy(sortColumn, sortDirection); } super.view = view; // Now update the headers. this.sortBy(view.sortColumn, view.sortDirection); } /** * Forget the cached row fragment, then clear all rows from the list and * create them again. */ reset() { this.#rowFragment = null; super.reset(); } /** * Resize the given column to the given width, and remember the width. * * @param {string} columnId * @param {integer} width */ resizeColumn(columnId, width) { const column = this.table.columns.find(c => c.id == columnId); if (column) { column.width = width; } this.#persistColumns(); } /** * Show or hide the given column, and remember the state. * * @param {string} columnId * @param {boolean} hidden */ changeColumns(columnId, hidden) { const column = this.table.columns.find(c => c.id == columnId); if (column.hidden == hidden) { return; } column.hidden = hidden; this.table.updateColumns(this.table.columns); this.reset(); this.#persistColumns(); } /** * Rearrange the columns into the given order, and remember the order. * * @param {string[]} columnIds - The IDs of the columns, in the order wanted. */ reorderColumns(columnIds) { const columns = cloneColumns(this.table.columns); columns.sort((a, b) => columnIds.indexOf(a.id) - columnIds.indexOf(b.id)); this.table.updateColumns(columns); this.reset(); this.#persistColumns(); } /** * Revert the columns to the default settings, and clear the column * information from the xulStore. */ restoreDefaultColumns() { this.table.setColumns(cloneColumns(this.#defaultColumns)); this.reset(); this.#forgetColumns(); } /** * Save the column order, visibility states, and widths in the xulStore. */ #persistColumns() { const columns = []; let save = false; for (let i = 0; i < this.table.columns.length; i++) { const column = this.table.columns[i]; const j = this.#defaultColumns.findIndex(c => c.id == column.id); const defaultColumn = this.#defaultColumns[j]; let columnDef = column.id; if (j != i) { save = true; } if (column.width && column.width != defaultColumn.width) { columnDef += `:${parseInt(column.width, 10)}`; save = true; } if (column.hidden) { columnDef += ":hidden"; } if (column.hidden != defaultColumn.hidden) { save = true; } columns.push(columnDef); } if (save) { Services.xulStore.setValue( xulStoreURL, this.id, "columns", columns.join(",") ); return; } Services.xulStore.removeValue(xulStoreURL, this.id, "columns"); } /** * Retrieve the column information from the xulStore and apply it to `columns`. * * @param {ColumnDef} columns */ #restoreColumns(columns) { if ( !this.id || !Services.xulStore.hasValue(xulStoreURL, this.id, "columns") ) { return columns; } try { const value = Services.xulStore.getValue(xulStoreURL, this.id, "columns"); const order = []; const hidden = new Map(); const widths = new Map(); for (const columnDef of value.split(",")) { const [id, ...state] = columnDef.split(":"); order.push(id); const width = parseInt(state.at(0), 10); if (!isNaN(width)) { widths.set(id, width); } hidden.set(id, state.at(-1) == "hidden"); } columns.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)); for (const column of columns) { if (hidden.has(column.id)) { column.hidden = hidden.get(column.id); } if (widths.has(column.id)) { column.width = widths.get(column.id); } } } catch (ex) { console.error(ex); } return columns; } /** * Remove column information from the xulStore. */ #forgetColumns() { if (!this.id) { return; } Services.xulStore.removeValue(xulStoreURL, this.id, "columns"); } /** * Sort the view by the given column and direction, and remember the sort. * * @param {string} [newColumn] - If not given, the currently sorted column * will be used. * @param {"ascending"|"descending"} [newDirection] - If not given, and * `newColumn` is the currently sorted column, the sort direction flips. * Otherwise, ascending direction is used. */ sortBy(newColumn, newDirection) { const { sortColumn, sortDirection } = this.view; if (!newColumn) { if (!sortColumn) { return; } newColumn = sortColumn; } if (!newDirection) { newDirection = sortColumn == newColumn && sortDirection == "ascending" ? "descending" : "ascending"; } if (newColumn != sortColumn || newDirection != sortDirection) { this.view.sortBy(newColumn, newDirection); this.#persistSort(); } this.table.header .querySelector("[aria-sort]") ?.removeAttribute("aria-sort"); // Use the values from the view here, in case it rejects `newColumn` or // `newDirection`. const header = this.table.header.querySelector(`#${this.view.sortColumn}`); if (header) { header.ariaSort = this.view.sortDirection; } } /** * Save the sort column and direction in the xulStore. */ #persistSort() { if (!this.id) { return; } const { sortColumn, sortDirection } = this.view; if (sortColumn && sortDirection) { Services.xulStore.setValue( xulStoreURL, this.id, "sortColumn", sortColumn ); Services.xulStore.setValue( xulStoreURL, this.id, "sortDirection", sortDirection ); return; } Services.xulStore.removeValue(xulStoreURL, this.id, "sortColumn"); Services.xulStore.removeValue(xulStoreURL, this.id, "sortDirection"); } /** * The template for creating rows. This is thrown away (by `reset`) and * recreated when necessary. * * @type {DocumentFragment} */ get rowFragment() { if (this.#rowFragment) { return this.#rowFragment; } this.#rowFragment = document.createDocumentFragment(); for (const column of this.table.columns) { const cell = this.#rowFragment.appendChild(document.createElement("td")); cell.classList.add(`${column.id.toLowerCase()}-column`); if (column.hidden) { cell.hidden = true; continue; } // Set role as gridcell for keyboard navigation if (this.table.body.role === "treegrid") { cell.role = "gridcell"; } if (column.checkbox) { const checkbox = cell.appendChild(document.createElement("input")); checkbox.type = "checkbox"; checkbox.tabIndex = -1; continue; } let container = cell; if (column.twisty || column.cellIcon) { container = cell.appendChild(document.createElement("div")); container.classList.add("container"); } if (column.twisty) { const twistyButton = container.appendChild( document.createElement("button") ); twistyButton.type = "button"; twistyButton.classList.add("button", "button-flat", "twisty"); twistyButton.ariaHidden = "hidden"; twistyButton.tabIndex = -1; } if (column.cellIcon) { container .appendChild(document.createElement("img")) .classList.add("icon"); } if (container.childElementCount) { container .appendChild(document.createElement("div")) .classList.add("container-inner"); } } return this.#rowFragment; } } customElements.define("auto-tree-view", AutoTreeView); /** * Rows in a AutoTreeView table. Handles putting the text into the table cells * and hiding the appropriate columns. */ class AutoTreeViewTableRow extends TreeViewTableRow { static ROW_HEIGHTS = { [UIDensity.MODE_COMPACT]: 18, [UIDensity.MODE_NORMAL]: 22, [UIDensity.MODE_TOUCH]: 32, }; static ROW_HEIGHT = AutoTreeViewTableRow.ROW_HEIGHTS[UIDensity.prefValue]; connectedCallback() { if (this.hasConnected) { return; } super.connectedCallback(); this.setAttribute("draggable", "true"); this.classList.add("table-layout"); this.role = this.list.table.body.role === "treegrid" ? "row" : "option"; this.append(this.list.rowFragment.cloneNode(true)); } /** * Fill out the row with content based on the current value of `this._index`. * This overrides the TreeViewTableRow function, but does not call it, to * avoid making multiple expensive calls to the view. Instead one call is * made to `view.rowAt` to collect the view row, and that provides all * needed data. (All `AutoTreeView` views are `TreeDataAdapter` rather than * `nsITreeView`, so this is possible.) */ fillRow() { if (this._animationFrame) { cancelAnimationFrame(this._animationFrame); this._animationFrame = null; } const viewRow = this.view.rowAt(this._index); if (!viewRow) { // Weird, but it could happen. Don't waste time. return; } if (Services.appinfo.accessibilityEnabled || Cu.isInAutomation) { this.ariaRowIndex = this._index + 1; this.ariaLevel = viewRow.level + 1; this.ariaSetSize = viewRow.setSize; this.ariaPosInSet = viewRow.posInSet + 1; } this.id = `${this.list.id}-row${this._index}`; const isGroup = viewRow.children.length > 0; this.classList.toggle("children", isGroup); const isGroupOpen = viewRow.open; this.ariaExpanded = isGroup ? isGroupOpen : null; this.classList.toggle("collapsed", !isGroupOpen); this.dataset.properties = [...viewRow.properties].join(" "); for (const column of this.list.table.columns) { const cell = this.querySelector(`.${column.id.toLowerCase()}-column`); if (column.hidden) { cell.hidden = true; continue; } cell.ariaLabel = null; cell.title = ""; if (column.twisty) { // Add the twisty icon here (instead of once in `connectedCallback`) // every time so that the animation doesn't happen when the row gets // recycled. But not if we actually want it to animate. if (!this._twistyAnimating) { const twistyButton = cell.querySelector("button.twisty"); const twistyIcon = document.createElement("img"); twistyIcon.classList.add("twisty-icon"); twistyButton.replaceChildren(twistyIcon); } delete this._twistyAnimating; } if (column.checkbox) { const checkbox = cell.querySelector(`input[type="checkbox"]`); checkbox.checked = viewRow.hasProperty(column.checkbox); checkbox.onchange = () => { viewRow.toggleProperty(column.checkbox, checkbox.checked); this.dataset.properties = [...viewRow.properties].join(" "); }; continue; } const text = viewRow.getText(column.id); let container = cell.querySelector("div.container") || cell; if (column.twisty) { container.style.paddingInlineStart = viewRow.level * 16 + "px"; } container = container.querySelector("div.container-inner") ?? container; container.textContent = text; if (column.l10n?.cell) { document.l10n.setAttributes(cell, column.l10n.cell, { title: text }); continue; } cell.title = text; } this.setAttribute("aria-label", this.firstElementChild.textContent); } } customElements.define("auto-tree-view-table-row", AutoTreeViewTableRow, { extends: "tr", });