From b4a16f64641873bd067ab5f6c6d54c0b4c12fdcf Mon Sep 17 00:00:00 2001 From: tehcneko Date: Sun, 20 Apr 2025 17:18:26 +0800 Subject: [PATCH] WebUI: Optimize table performance with virtual list Adding virtual list support to dynamic tables to improve performance on large lists, I observed a 100x performance improvement on rendering on a torrent table with 5000 torrents. This optimization is disabled by default and can be enabled in options. PR #22502. --- src/webui/www/private/rename_files.html | 12 +- src/webui/www/private/scripts/dynamicTable.js | 632 ++++++++++++------ src/webui/www/private/scripts/prop-files.js | 169 +---- src/webui/www/private/scripts/prop-peers.js | 2 +- .../www/private/scripts/prop-trackers.js | 2 +- .../www/private/scripts/prop-webseeds.js | 2 +- src/webui/www/private/views/log.html | 6 +- src/webui/www/private/views/preferences.html | 6 + src/webui/www/private/views/transferlist.html | 2 +- 9 files changed, 474 insertions(+), 359 deletions(-) diff --git a/src/webui/www/private/rename_files.html b/src/webui/www/private/rename_files.html index f8eefa009..37a15b3a7 100644 --- a/src/webui/www/private/rename_files.html +++ b/src/webui/www/private/rename_files.html @@ -41,7 +41,7 @@ // Setup the dynamic table for bulk renaming const bulkRenameFilesTable = new window.qBittorrent.DynamicTable.BulkRenameTorrentFilesTable(); - bulkRenameFilesTable.setup("bulkRenameFilesTableDiv", "bulkRenameFilesTableFixedHeaderDiv", bulkRenameFilesContextMenu); + bulkRenameFilesTable.setup("bulkRenameFilesTableDiv", "bulkRenameFilesTableFixedHeaderDiv", bulkRenameFilesContextMenu, true); // Inject checkbox into the first column of the table header const tableHeaders = document.querySelectorAll("#bulkRenameFilesTableFixedHeaderDiv .dynamicTableHeader th"); @@ -172,7 +172,10 @@ // Update renamed column for matched rows for (let i = 0; i < matchedRows.length; ++i) { const row = matchedRows[i]; - $(`filesTablefileRenamed${row.rowId}`).textContent = row.renamed; + const element = document.getElementById(`filesTablefileRenamed${row.rowId}`); + if (element === null) + continue; + element.textContent = row.renamed; } }; fileRenamer.onInvalidRegex = (err) => { @@ -287,11 +290,6 @@ event.preventDefault(); window.qBittorrent.Client.closeWindow(windowEl); }); - // synchronize header scrolling to table body - $("bulkRenameFilesTableDiv").onscroll = function() { - const length = $(this).scrollLeft; - $("bulkRenameFilesTableFixedHeaderDiv").scrollLeft = length; - }; const handleTorrentFiles = (files, selectedRows) => { const rows = files.map((file, index) => { diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index bbf5e5479..d05cd8c53 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -71,14 +71,18 @@ window.qBittorrent.DynamicTable ??= (() => { initialize: () => {}, - setup: function(dynamicTableDivId, dynamicTableFixedHeaderDivId, contextMenu) { + setup: function(dynamicTableDivId, dynamicTableFixedHeaderDivId, contextMenu, useVirtualList = false) { this.dynamicTableDivId = dynamicTableDivId; this.dynamicTableFixedHeaderDivId = dynamicTableFixedHeaderDivId; this.dynamicTableDiv = document.getElementById(dynamicTableDivId); + this.useVirtualList = useVirtualList && (LocalPreferences.get("use_virtual_list", "false") === "true"); this.fixedTableHeader = document.querySelector(`#${dynamicTableFixedHeaderDivId} thead tr`); this.hiddenTableHeader = this.dynamicTableDiv.querySelector(`thead tr`); + this.table = this.dynamicTableDiv.querySelector(`table`); this.tableBody = this.dynamicTableDiv.querySelector(`tbody`); + this.rowHeight = 26; this.rows = new Map(); + this.cachedElements = []; this.selectedRows = []; this.columns = []; this.contextMenu = contextMenu; @@ -91,14 +95,34 @@ window.qBittorrent.DynamicTable ??= (() => { this.setupHeaderEvents(); this.setupHeaderMenu(); this.setupAltRow(); + this.setupVirtualList(); + }, + + setupVirtualList: function() { + if (!this.useVirtualList) + return; + this.table.style.position = "relative"; + + this.renderedHeight = this.dynamicTableDiv.offsetHeight; + const resizeCallback = window.qBittorrent.Misc.createDebounceHandler(100, () => { + const height = this.dynamicTableDiv.offsetHeight; + const needRerender = this.renderedHeight < height; + this.renderedHeight = height; + if (needRerender) + this.rerender(); + }); + new ResizeObserver(resizeCallback).observe(this.dynamicTableDiv); }, setupCommonEvents: function() { - const tableFixedHeaderDiv = $(this.dynamicTableFixedHeaderDivId); + const tableFixedHeaderDiv = document.getElementById(this.dynamicTableFixedHeaderDivId); const tableElement = tableFixedHeaderDiv.querySelector("table"); - this.dynamicTableDiv.addEventListener("scroll", function() { - tableElement.style.left = `${-this.scrollLeft}px`; + this.dynamicTableDiv.addEventListener("scroll", (e) => { + tableElement.style.left = `${-this.dynamicTableDiv.scrollLeft}px`; + // rerender on scroll + if (this.useVirtualList) + this.rerender(); }); this.dynamicTableDiv.addEventListener("click", (e) => { @@ -404,6 +428,9 @@ window.qBittorrent.DynamicTable ??= (() => { const style = `width: ${column.width}px; ${column.style}`; this.getRowCells(this.hiddenTableHeader)[pos].style.cssText = style; this.getRowCells(this.fixedTableHeader)[pos].style.cssText = style; + // rerender on column resize + if (this.useVirtualList) + this.rerender(); column.onResize?.(column.name); }, @@ -707,10 +734,9 @@ window.qBittorrent.DynamicTable ??= (() => { selectAll: function() { this.deselectAll(); - for (const tr of this.getTrs()) { - this.selectedRows.push(tr.rowId); - tr.classList.add("selected"); - } + for (const row of this.getFilteredAndSortedRows()) + this.selectedRows.push(row.rowId); + this.setRowClass(); }, deselectAll: function() { @@ -832,64 +858,138 @@ window.qBittorrent.DynamicTable ??= (() => { } } - const trs = [...this.getTrs()]; + if (this.useVirtualList) { + // rerender on table update + this.rerender(rows); + } + else { + const trs = [...this.getTrs()]; - for (let rowPos = 0; rowPos < rows.length; ++rowPos) { - const rowId = rows[rowPos]["rowId"]; - let tr_found = false; - for (let j = rowPos; j < trs.length; ++j) { - if (trs[j]["rowId"] === rowId) { - tr_found = true; - if (rowPos === j) + for (let rowPos = 0; rowPos < rows.length; ++rowPos) { + const rowId = rows[rowPos].rowId; + let tr_found = false; + for (let j = rowPos; j < trs.length; ++j) { + if (trs[j].rowId === rowId) { + tr_found = true; + if (rowPos === j) + break; + trs[j].inject(trs[rowPos], "before"); + const tmpTr = trs[j]; + trs.splice(j, 1); + trs.splice(rowPos, 0, tmpTr); break; - trs[j].inject(trs[rowPos], "before"); - const tmpTr = trs[j]; - trs.splice(j, 1); - trs.splice(rowPos, 0, tmpTr); - break; + } + } + if (tr_found) { // row already exists in the table + this.updateRow(trs[rowPos], fullUpdate); + } + else { // else create a new row in the table + const tr = this.createRowElement(rows[rowPos]); + + // Insert + if (rowPos >= trs.length) { + tr.inject(this.tableBody); + trs.push(tr); + } + else { + tr.inject(trs[rowPos], "before"); + trs.splice(rowPos, 0, tr); + } + + this.updateRow(tr, true); } } - if (tr_found) { // row already exists in the table - this.updateRow(trs[rowPos], fullUpdate); + + const rowPos = rows.length; + + while ((rowPos < trs.length) && (trs.length > 0)) + trs.pop().destroy(); + } + }, + + rerender: function(rows = this.getFilteredAndSortedRows()) { + // set the scrollable height + this.table.style.height = `${rows.length * this.rowHeight}px`; + + // show extra 6 rows at top/bottom to reduce flickering + const extraRowCount = 6; + // how many rows can be shown in the visible area + const visibleRowCount = Math.ceil(this.renderedHeight / this.rowHeight) + (extraRowCount * 2); + // start position of visible rows, offsetted by scrollTop + let startRow = Math.max((Math.trunc(this.dynamicTableDiv.scrollTop / this.rowHeight) - extraRowCount), 0); + // ensure startRow is even + if ((startRow % 2) === 1) + startRow = Math.max(0, startRow - 1); + const endRow = Math.min((startRow + visibleRowCount), rows.length); + + const elements = []; + for (let i = startRow; i < endRow; ++i) { + const row = rows[i]; + if (row === undefined) + continue; + const offset = i * this.rowHeight; + const position = i - startRow; + // reuse existing elements + let element = this.cachedElements[position]; + if (element !== undefined) + this.updateRowElement(element, row.rowId, offset); + else + element = this.cachedElements[position] = this.createRowElement(row, offset); + elements.push(element); + } + this.tableBody.replaceChildren(...elements); + + // update row classes + this.setRowClass(); + + // update visible rows + for (const row of this.tableBody.children) + this.updateRow(row, true); + + // refresh row height based on first row + setTimeout(() => { + if (this.tableBody.firstChild === null) + return; + const tr = this.tableBody.firstChild; + if (this.rowHeight !== tr.offsetHeight) { + this.rowHeight = tr.offsetHeight; + // rerender on row height change + this.rerender(); } - else { // else create a new row in the table - const tr = document.createElement("tr"); - // set tabindex so element receives keydown events - // more info: https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event - tr.tabIndex = -1; - const rowId = rows[rowPos]["rowId"]; - tr.setAttribute("data-row-id", rowId); - tr["rowId"] = rowId; + }); + }, - for (let k = 0; k < this.columns.length; ++k) { - const td = document.createElement("td"); - if ((this.columns[k].visible === "0") || this.columns[k].force_hide) - td.classList.add("invisible"); - tr.append(td); - } + createRowElement: function(row, top = -1) { + const tr = document.createElement("tr"); + // set tabindex so element receives keydown events + // more info: https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event + tr.tabIndex = -1; - // Insert - if (rowPos >= trs.length) { - tr.inject(this.tableBody); - trs.push(tr); - } - else { - tr.inject(trs[rowPos], "before"); - trs.splice(rowPos, 0, tr); - } - - // Update context menu - this.contextMenu?.addTarget(tr); - - this.updateRow(tr, true); - } + for (let k = 0; k < this.columns.length; ++k) { + const td = document.createElement("td"); + if ((this.columns[k].visible === "0") || this.columns[k].force_hide) + td.classList.add("invisible"); + tr.append(td); } - const rowPos = rows.length; + this.updateRowElement(tr, row.rowId, top); - while ((rowPos < trs.length) && (trs.length > 0)) - trs.pop().destroy(); + // update context menu + this.contextMenu?.addTarget(tr); + return tr; + }, + + updateRowElement: function(tr, rowId, top) { + tr.dataset.rowId = rowId; + tr.rowId = rowId; + + tr.className = ""; + + if (this.useVirtualList) { + tr.style.position = "absolute"; + tr.style.top = `${top}px`; + } }, updateRow: function(tr, fullUpdate) { @@ -898,6 +998,11 @@ window.qBittorrent.DynamicTable ??= (() => { const tds = this.getRowCells(tr); for (let i = 0; i < this.columns.length; ++i) { + // required due to position: absolute breaks table layout + if (this.useVirtualList) { + tds[i].style.width = `${this.columns[i].width}px`; + tds[i].style.maxWidth = `${this.columns[i].width}px`; + } if (this.columns[i].dataProperties.some(prop => Object.hasOwn(data, prop))) this.columns[i].updateTd(tds[i], row); } @@ -907,15 +1012,25 @@ window.qBittorrent.DynamicTable ??= (() => { removeRow: function(rowId) { this.selectedRows.erase(rowId); this.rows.delete(rowId); - const tr = this.getTrByRowId(rowId); - tr?.destroy(); + if (this.useVirtualList) { + this.rerender(); + } + else { + const tr = this.getTrByRowId(rowId); + tr?.destroy(); + } }, clear: function() { this.deselectAll(); this.rows.clear(); - for (const tr of this.getTrs()) - tr.destroy(); + if (this.useVirtualList) { + this.rerender(); + } + else { + for (const tr of this.getTrs()) + tr.destroy(); + } }, selectedRowsIds: function() { @@ -986,6 +1101,12 @@ window.qBittorrent.DynamicTable ??= (() => { const TorrentsTable = new Class({ Extends: DynamicTable, + setupVirtualList: function() { + this.parent(); + + this.rowHeight = 22; + }, + initColumns: function() { this.newColumn("priority", "", "#", 30, true); this.newColumn("state_icon", "", "QBT_TR(Status Icon)QBT_TR[CONTEXT=TransferListModel]", 30, false); @@ -2075,6 +2196,12 @@ window.qBittorrent.DynamicTable ??= (() => { prevReverseSort: null, fileTree: new window.qBittorrent.FileTree.FileTree(), + setupVirtualList: function() { + this.parent(); + + this.rowHeight = 29; + }, + populateTable: function(root) { this.fileTree.setRoot(root); root.children.each((node) => { @@ -2145,30 +2272,30 @@ window.qBittorrent.DynamicTable ??= (() => { * Toggles the global checkbox and all checkboxes underneath */ toggleGlobalCheckbox: function() { - const checkbox = $("rootMultiRename_cb"); + const checkbox = document.getElementById("rootMultiRename_cb"); const checkboxes = document.querySelectorAll("input.RenamingCB"); for (let i = 0; i < checkboxes.length; ++i) { - const node = this.getNode(i); - if (checkbox.checked || checkbox.indeterminate) { const cb = checkboxes[i]; cb.checked = true; cb.indeterminate = false; cb.state = "checked"; - node.checked = 0; - node.full_data.checked = node.checked; } else { const cb = checkboxes[i]; cb.checked = false; cb.indeterminate = false; cb.state = "unchecked"; - node.checked = 1; - node.full_data.checked = node.checked; } } + const nodes = this.fileTree.toArray(); + for (const node of nodes) { + node.checked = (checkbox.checked || checkbox.indeterminate) ? 0 : 1; + node.full_data.checked = node.checked; + } + this.updateGlobalCheckbox(); }, @@ -2176,7 +2303,7 @@ window.qBittorrent.DynamicTable ??= (() => { const node = this.getNode(rowId); node.checked = checkState; node.full_data.checked = checkState; - const checkbox = $(`cbRename${rowId}`); + const checkbox = document.getElementById(`cbRename${rowId}`); checkbox.checked = node.checked === 0; checkbox.state = checkbox.checked ? "checked" : "unchecked"; @@ -2184,11 +2311,11 @@ window.qBittorrent.DynamicTable ??= (() => { this.toggleNodeTreeCheckbox(node.children[i].rowId, checkState); }, - updateGlobalCheckbox: () => { - const checkbox = $("rootMultiRename_cb"); - const checkboxes = document.querySelectorAll("input.RenamingCB"); - const isAllChecked = Array.prototype.every.call(checkboxes, (checkbox => checkbox.checked)); - const isAllUnchecked = (() => Array.prototype.every.call(checkboxes, (checkbox => !checkbox.checked))); + updateGlobalCheckbox: function() { + const checkbox = document.getElementById("rootMultiRename_cb"); + const nodes = this.fileTree.toArray(); + const isAllChecked = nodes.every((node) => node.checked === 0); + const isAllUnchecked = (() => nodes.every((node) => node.checked !== 0)); if (isAllChecked) { checkbox.state = "checked"; checkbox.indeterminate = false; @@ -2210,82 +2337,86 @@ window.qBittorrent.DynamicTable ??= (() => { const that = this; // checked - this.columns["checked"].updateTd = function(td, row) { + this.columns["checked"].updateTd = (td, row) => { const id = row.rowId; - const value = this.getRowValue(row); + const node = that.getNode(id); - const treeImg = document.createElement("img"); - treeImg.src = "images/L.gif"; - treeImg.style.marginBottom = "-2px"; + if (td.firstElementChild === null) { + const treeImg = document.createElement("img"); + treeImg.src = "images/L.gif"; + treeImg.style.marginBottom = "-2px"; + td.append(treeImg); + } - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; + let checkbox = td.children[1]; + if (checkbox === undefined) { + checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "RenamingCB"; + checkbox.addEventListener("click", (e) => { + const id = e.target.dataset.id; + const node = that.getNode(id); + node.checked = e.target.checked ? 0 : 1; + node.full_data.checked = node.checked; + that.updateGlobalCheckbox(); + that.onRowSelectionChange(node); + e.stopPropagation(); + }); + checkbox.indeterminate = false; + td.append(checkbox); + } checkbox.id = `cbRename${id}`; - checkbox.setAttribute("data-id", id); - checkbox.className = "RenamingCB"; - checkbox.addEventListener("click", (e) => { - const node = that.getNode(id); - node.checked = e.target.checked ? 0 : 1; - node.full_data.checked = node.checked; - that.updateGlobalCheckbox(); - that.onRowSelectionChange(node); - e.stopPropagation(); - }); - checkbox.checked = (value === 0); + checkbox.dataset.id = id; + checkbox.checked = (node.checked === 0); checkbox.state = checkbox.checked ? "checked" : "unchecked"; - checkbox.indeterminate = false; - td.replaceChildren(treeImg, checkbox); }; this.columns["checked"].staticWidth = 50; // original this.columns["original"].updateTd = function(td, row) { const id = row.rowId; - const fileNameId = `filesTablefileName${id}`; const node = that.getNode(id); + const value = this.getRowValue(row); + let dirImg = td.children[0]; + if (dirImg === undefined) { + dirImg = document.createElement("img"); + dirImg.src = "images/directory.svg"; + dirImg.style.width = "20px"; + dirImg.style.paddingRight = "5px"; + dirImg.style.marginBottom = "-3px"; + td.append(dirImg); + } if (node.isFolder) { - const value = this.getRowValue(row); - const dirImgId = `renameTableDirImg${id}`; - if ($(dirImgId)) { - // just update file name - $(fileNameId).textContent = value; - } - else { - const span = document.createElement("span"); - span.textContent = value; - span.id = fileNameId; + dirImg.style.display = "inline"; + dirImg.style.marginLeft = `${node.depth * 20}px`; + } + else { + dirImg.style.display = "none"; + } - const dirImg = document.createElement("img"); - dirImg.src = "images/directory.svg"; - dirImg.style.width = "20px"; - dirImg.style.paddingRight = "5px"; - dirImg.style.marginBottom = "-3px"; - dirImg.style.marginLeft = `${node.depth * 20}px`; - dirImg.id = dirImgId; - td.replaceChildren(dirImg, span); - } - } - else { // is file - const value = this.getRowValue(row); - const span = document.createElement("span"); - span.textContent = value; - span.id = fileNameId; - span.style.marginLeft = `${(node.depth + 1) * 20}px`; - td.replaceChildren(span); + let span = td.children[1]; + if (span === undefined) { + span = document.createElement("span"); + td.append(span); } + span.textContent = value; + span.style.marginLeft = node.isFolder ? "0" : `${(node.depth + 1) * 20}px`; }; // renamed - this.columns["renamed"].updateTd = function(td, row) { + this.columns["renamed"].updateTd = (td, row) => { const id = row.rowId; const fileNameRenamedId = `filesTablefileRenamed${id}`; - const value = this.getRowValue(row); + const node = that.getNode(id); - const span = document.createElement("span"); - span.textContent = value; + let span = td.firstElementChild; + if (span === null) { + span = document.createElement("span"); + td.append(span); + } span.id = fileNameRenamedId; - td.replaceChildren(span); + span.textContent = node.renamed; }; }, @@ -2431,7 +2562,15 @@ window.qBittorrent.DynamicTable ??= (() => { row.full_data.remaining = (row.full_data.size * (1.0 - (row.full_data.progress / 100))); }, - setupCommonEvents: () => {} + setupCommonEvents: function() { + const headerDiv = document.getElementById("bulkRenameFilesTableFixedHeaderDiv"); + this.dynamicTableDiv.addEventListener("scroll", (e) => { + headerDiv.scrollLeft = this.dynamicTableDiv.scrollLeft; + // rerender on scroll + if (this.useVirtualList) + this.rerender(); + }); + } }); const TorrentFilesTable = new Class({ @@ -2445,6 +2584,108 @@ window.qBittorrent.DynamicTable ??= (() => { prevReverseSort: null, fileTree: new window.qBittorrent.FileTree.FileTree(), + initialize: function() { + this.collapseState = new Map(); + }, + + isCollapsed: function(id) { + return this.collapseState.get(id)?.collapsed ?? false; + }, + + expandNode: function(id) { + const state = this.collapseState.get(id); + if (state !== undefined) + state.collapsed = false; + this._updateNodeState(id, false); + }, + + collapseNode: function(id) { + const state = this.collapseState.get(id); + if (state !== undefined) + state.collapsed = true; + this._updateNodeState(id, true); + }, + + expandAllNodes: function() { + for (const [key, _] of this.collapseState) + this.expandNode(key); + }, + + collapseAllNodes: function() { + for (const [key, state] of this.collapseState) { + // collapse all nodes except root + if (state.depth >= 1) + this.collapseNode(key); + } + }, + + _updateNodeVisibility: (node, shouldHide) => { + const span = document.getElementById(`filesTablefileName${node.rowId}`); + // span won't exist if row has been filtered out + if (span === null) + return; + const tr = span.parentElement.parentElement; + tr.classList.toggle("invisible", shouldHide); + }, + + _updateNodeCollapseIcon: (node, isCollapsed) => { + const span = document.getElementById(`filesTablefileName${node.rowId}`); + // span won't exist if row has been filtered out + if (span === null) + return; + const td = span.parentElement; + + // rotate the collapse icon + const collapseIcon = td.firstElementChild; + collapseIcon.classList.toggle("rotate", isCollapsed); + }, + + _updateNodeState: function(id, shouldCollapse) { + // collapsed rows will be filtered out when using virtual list + if (this.useVirtualList) + return; + const node = this.getNode(id); + if (!node.isFolder) + return; + + this._updateNodeCollapseIcon(node, shouldCollapse); + + for (const child of node.children) + this._updateNodeVisibility(child, shouldCollapse); + }, + + clear: function() { + this.parent(); + + this.collapseState.clear(); + }, + + setupVirtualList: function() { + this.parent(); + + this.rowHeight = 29.5; + }, + + expandFolder: function(id) { + const node = this.getNode(id); + if (node.isFolder) + this.expandNode(node); + }, + + collapseFolder: function(id) { + const node = this.getNode(id); + if (node.isFolder) + this.collapseNode(node); + }, + + isAllCheckboxesChecked: function() { + return this.fileTree.toArray().every((node) => node.checked === 1); + }, + + isAllCheckboxesUnchecked: function() { + return this.fileTree.toArray().every((node) => node.checked !== 1); + }, + populateTable: function(root) { this.fileTree.setRoot(root); root.children.each((node) => { @@ -2456,6 +2697,8 @@ window.qBittorrent.DynamicTable ??= (() => { node.depth = depth; if (node.isFolder) { + if (!this.collapseState.has(node.rowId)) + this.collapseState.set(node.rowId, { depth: depth, collapsed: depth > 0 }); const data = { rowId: node.rowId, size: node.size, @@ -2470,7 +2713,7 @@ window.qBittorrent.DynamicTable ??= (() => { node.data = data; node.full_data = data; - this.updateRowData(data); + this.updateRowData(data, depth); } else { node.data.rowId = node.rowId; @@ -2496,6 +2739,11 @@ window.qBittorrent.DynamicTable ??= (() => { return this.rows.get(rowId); }, + getRowFileId: function(rowId) { + const row = this.rows.get(rowId); + return row?.full_data.fileId; + }, + initColumns: function() { this.newColumn("checked", "", "", 50, true); this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TrackerListWidget]", 300, true); @@ -2526,15 +2774,19 @@ window.qBittorrent.DynamicTable ??= (() => { const id = row.rowId; const value = this.getRowValue(row); - if (window.qBittorrent.PropFiles.isDownloadCheckboxExists(id)) { - window.qBittorrent.PropFiles.updateDownloadCheckbox(id, value); - } - else { + if (td.firstElementChild === null) { const treeImg = document.createElement("img"); treeImg.src = "images/L.gif"; treeImg.style.marginBottom = "-2px"; - td.append(treeImg, window.qBittorrent.PropFiles.createDownloadCheckbox(id, row.full_data.fileId, value)); + td.append(treeImg); } + + const downloadCheckbox = td.children[1]; + if (downloadCheckbox === undefined) + td.append(window.qBittorrent.PropFiles.createDownloadCheckbox(id, row.full_data.fileId, value)); + else + window.qBittorrent.PropFiles.updateDownloadCheckbox(downloadCheckbox, id, row.full_data.fileId, value); + }; this.columns["checked"].staticWidth = 50; @@ -2543,46 +2795,56 @@ window.qBittorrent.DynamicTable ??= (() => { const id = row.rowId; const fileNameId = `filesTablefileName${id}`; const node = that.getNode(id); + const value = this.getRowValue(row); + let collapseIcon = td.firstElementChild; + if (collapseIcon === null) { + collapseIcon = document.createElement("img"); + collapseIcon.src = "images/go-down.svg"; + collapseIcon.className = "filesTableCollapseIcon"; + collapseIcon.addEventListener("click", (e) => { + const id = collapseIcon.dataset.id; + const node = that.getNode(id); + if (node !== null) { + if (that.isCollapsed(node.rowId)) + that.expandNode(node.rowId); + else + that.collapseNode(node.rowId); + if (that.useVirtualList) + that.rerender(); + } + }); + td.append(collapseIcon); + } if (node.isFolder) { - const value = this.getRowValue(row); - const collapseIconId = `filesTableCollapseIcon${id}`; - const dirImgId = `filesTableDirImg${id}`; - if ($(dirImgId)) { - // just update file name - $(fileNameId).textContent = value; - } - else { - const collapseIcon = document.createElement("img"); - collapseIcon.src = "images/go-down.svg"; - collapseIcon.style.marginLeft = `${node.depth * 20}px`; - collapseIcon.className = "filesTableCollapseIcon"; - collapseIcon.id = collapseIconId; - collapseIcon.setAttribute("data-id", id); - collapseIcon.addEventListener("click", function(e) { qBittorrent.PropFiles.collapseIconClicked(this); }); - - const span = document.createElement("span"); - span.textContent = value; - span.id = fileNameId; - - const dirImg = document.createElement("img"); - dirImg.src = "images/directory.svg"; - dirImg.style.width = "20px"; - dirImg.style.paddingRight = "5px"; - dirImg.style.marginBottom = "-3px"; - dirImg.id = dirImgId; - - td.replaceChildren(collapseIcon, dirImg, span); - } + collapseIcon.style.marginLeft = `${node.depth * 20}px`; + collapseIcon.style.display = "inline"; + collapseIcon.dataset.id = id; + collapseIcon.classList.toggle("rotate", that.isCollapsed(node.rowId)); } else { - const value = this.getRowValue(row); - const span = document.createElement("span"); - span.textContent = value; - span.id = fileNameId; - span.style.marginLeft = `${(node.depth + 1) * 20}px`; - td.replaceChildren(span); + collapseIcon.style.display = "none"; } + + let dirImg = td.children[1]; + if (dirImg === undefined) { + dirImg = document.createElement("img"); + dirImg.src = "images/directory.svg"; + dirImg.style.width = "20px"; + dirImg.style.paddingRight = "5px"; + dirImg.style.marginBottom = "-3px"; + td.append(dirImg); + } + dirImg.style.display = node.isFolder ? "inline" : "none"; + + let span = td.children[2]; + if (span === undefined) { + span = document.createElement("span"); + td.append(span); + } + span.id = fileNameId; + span.textContent = value; + span.style.marginLeft = node.isFolder ? "0" : `${(node.depth + 1) * 20}px`; }; this.columns["name"].calculateBuffer = (rowId) => { const node = that.getNode(rowId); @@ -2599,10 +2861,9 @@ window.qBittorrent.DynamicTable ??= (() => { const id = row.rowId; const value = Number(this.getRowValue(row)); - const progressBar = $(`pbf_${id}`); + const progressBar = td.firstElementChild; if (progressBar === null) { td.append(new window.qBittorrent.ProgressBar.ProgressBar(value, { - id: `pbf_${id}`, width: 80 })); } @@ -2617,10 +2878,11 @@ window.qBittorrent.DynamicTable ??= (() => { const id = row.rowId; const value = this.getRowValue(row); - if (window.qBittorrent.PropFiles.isPriorityComboExists(id)) - window.qBittorrent.PropFiles.updatePriorityCombo(id, value); - else + const priorityCombo = td.firstElementChild; + if (priorityCombo === null) td.append(window.qBittorrent.PropFiles.createPriorityCombo(id, row.full_data.fileId, value)); + else + window.qBittorrent.PropFiles.updatePriorityCombo(priorityCombo, id, row.full_data.fileId, value); }; this.columns["priority"].staticWidth = 140; @@ -2652,7 +2914,7 @@ window.qBittorrent.DynamicTable ??= (() => { }, _filterNodes: function(node, filterTerms, filteredRows) { - if (node.isFolder) { + if (node.isFolder && (!this.useVirtualList || !this.isCollapsed(node.rowId))) { const childAdded = node.children.reduce((acc, child) => { // we must execute the function before ORing w/ acc or we'll stop checking child nodes after the first successful match return (this._filterNodes(child, filterTerms, filteredRows) || acc); @@ -2689,19 +2951,11 @@ window.qBittorrent.DynamicTable ??= (() => { const generateRowsSignature = () => { const rowsData = []; for (const { full_data } of this.getRowValues()) - rowsData.push(full_data); + rowsData.push({ ...full_data, collapsed: this.isCollapsed(full_data.rowId) }); return JSON.stringify(rowsData); }; const getFilteredRows = function() { - if (this.filterTerms.length === 0) { - const nodeArray = this.fileTree.toArray(); - const filteredRows = nodeArray.map((node) => { - return this.getRow(node); - }); - return filteredRows; - } - const filteredRows = []; this.getRoot().children.each((child) => { this._filterNodes(child, this.filterTerms, filteredRows); @@ -2758,11 +3012,11 @@ window.qBittorrent.DynamicTable ??= (() => { switch (e.key) { case "ArrowLeft": e.preventDefault(); - window.qBittorrent.PropFiles.collapseFolder(this.getSelectedRowId()); + this.collapseFolder(this.getSelectedRowId()); break; case "ArrowRight": e.preventDefault(); - window.qBittorrent.PropFiles.expandFolder(this.getSelectedRowId()); + this.expandFolder(this.getSelectedRowId()); break; } }); @@ -3247,10 +3501,6 @@ window.qBittorrent.DynamicTable ??= (() => { filterText: "", - filteredLength: function() { - return this.tableBody.rows.length; - }, - initColumns: function() { this.newColumn("rowId", "", "QBT_TR(ID)QBT_TR[CONTEXT=ExecutionLogWidget]", 50, true); this.newColumn("message", "", "QBT_TR(Message)QBT_TR[CONTEXT=ExecutionLogWidget]", 350, true); @@ -3293,7 +3543,7 @@ window.qBittorrent.DynamicTable ??= (() => { } td.textContent = logLevel; td.title = logLevel; - td.closest("tr").className = `logTableRow${addClass}`; + td.closest("tr").classList.add(`logTableRow${addClass}`); }; }, @@ -3323,6 +3573,8 @@ window.qBittorrent.DynamicTable ??= (() => { return (this.reverseSort === "0") ? res : -res; }); + this.filteredLength = filteredRows.length; + return filteredRows; }, }); @@ -3355,7 +3607,7 @@ window.qBittorrent.DynamicTable ??= (() => { } td.textContent = status; td.title = status; - td.closest("tr").className = `logTableRow${addClass}`; + td.closest("tr").classList.add(`logTableRow${addClass}`); }; }, diff --git a/src/webui/www/private/scripts/prop-files.js b/src/webui/www/private/scripts/prop-files.js index e5eaae54a..482781112 100644 --- a/src/webui/www/private/scripts/prop-files.js +++ b/src/webui/www/private/scripts/prop-files.js @@ -33,16 +33,11 @@ window.qBittorrent.PropFiles ??= (() => { const exports = () => { return { normalizePriority: normalizePriority, - isDownloadCheckboxExists: isDownloadCheckboxExists, createDownloadCheckbox: createDownloadCheckbox, updateDownloadCheckbox: updateDownloadCheckbox, - isPriorityComboExists: isPriorityComboExists, createPriorityCombo: createPriorityCombo, updatePriorityCombo: updatePriorityCombo, updateData: updateData, - collapseIconClicked: collapseIconClicked, - expandFolder: expandFolder, - collapseFolder: collapseFolder, clear: clear }; }; @@ -126,25 +121,20 @@ window.qBittorrent.PropFiles ??= (() => { updateGlobalCheckbox(); }; - const isDownloadCheckboxExists = (id) => { - return $(`cbPrio${id}`) !== null; - }; - const createDownloadCheckbox = (id, fileId, checked) => { const checkbox = document.createElement("input"); checkbox.type = "checkbox"; - checkbox.id = `cbPrio${id}`; checkbox.setAttribute("data-id", id); checkbox.setAttribute("data-file-id", fileId); - checkbox.className = "DownloadedCB"; checkbox.addEventListener("click", fileCheckboxClicked); updateCheckbox(checkbox, checked); return checkbox; }; - const updateDownloadCheckbox = (id, checked) => { - const checkbox = $(`cbPrio${id}`); + const updateDownloadCheckbox = (checkbox, id, fileId, checked) => { + checkbox.setAttribute("data-id", id); + checkbox.setAttribute("data-file-id", fileId); updateCheckbox(checkbox, checked); }; @@ -162,10 +152,6 @@ window.qBittorrent.PropFiles ??= (() => { } }; - const isPriorityComboExists = (id) => { - return $(`comboPrio${id}`) !== null; - }; - const createPriorityCombo = (id, fileId, selectedPriority) => { const createOption = (priority, isSelected, text) => { const option = document.createElement("option"); @@ -195,8 +181,10 @@ window.qBittorrent.PropFiles ??= (() => { return select; }; - const updatePriorityCombo = (id, selectedPriority) => { - const combobox = $(`comboPrio${id}`); + const updatePriorityCombo = (combobox, id, fileId, selectedPriority) => { + combobox.id = `comboPrio${id}`; + combobox.setAttribute("data-id", id); + combobox.setAttribute("data-file-id", fileId); if (Number(combobox.value) !== selectedPriority) selectComboboxPriority(combobox, selectedPriority); }; @@ -258,9 +246,9 @@ window.qBittorrent.PropFiles ??= (() => { const updateGlobalCheckbox = () => { const checkbox = $("tristate_cb"); - if (isAllCheckboxesChecked()) + if (torrentFilesTable.isAllCheckboxesChecked()) setCheckboxChecked(checkbox); - else if (isAllCheckboxesUnchecked()) + else if (torrentFilesTable.isAllCheckboxesUnchecked()) setCheckboxUnchecked(checkbox); else setCheckboxPartial(checkbox); @@ -283,10 +271,6 @@ window.qBittorrent.PropFiles ??= (() => { checkbox.indeterminate = true; }; - const isAllCheckboxesChecked = () => Array.prototype.every.call(document.querySelectorAll("input.DownloadedCB"), (checkbox => checkbox.checked)); - - const isAllCheckboxesUnchecked = () => Array.prototype.every.call(document.querySelectorAll("input.DownloadedCB"), (checkbox => !checkbox.checked)); - const setFilePriority = (ids, fileIds, priority) => { if (current_hash === "") return; @@ -317,8 +301,6 @@ window.qBittorrent.PropFiles ??= (() => { if (combobox !== null) selectComboboxPriority(combobox, priority); }); - - torrentFilesTable.updateTable(false); }; let loadTorrentFilesDataTimer = -1; @@ -364,7 +346,7 @@ window.qBittorrent.PropFiles ??= (() => { else { handleNewTorrentFiles(files); if (loadedNewTorrent) - collapseAllNodes(); + torrentFilesTable.collapseAllNodes(); } }) .finally(() => { @@ -470,29 +452,6 @@ window.qBittorrent.PropFiles ??= (() => { torrentFilesTable.reselectRows(selectedFiles); }; - const collapseIconClicked = (event) => { - const id = event.getAttribute("data-id"); - const node = torrentFilesTable.getNode(id); - const isCollapsed = (event.parentElement.getAttribute("data-collapsed") === "true"); - - if (isCollapsed) - expandNode(node); - else - collapseNode(node); - }; - - const expandFolder = (id) => { - const node = torrentFilesTable.getNode(id); - if (node.isFolder) - expandNode(node); - }; - - const collapseFolder = (id) => { - const node = torrentFilesTable.getNode(id); - if (node.isFolder) - collapseNode(node); - }; - const filesPriorityMenuClicked = (priority) => { const selectedRows = torrentFilesTable.selectedRowsIds(); if (selectedRows.length === 0) @@ -501,9 +460,8 @@ window.qBittorrent.PropFiles ??= (() => { const rowIds = []; const fileIds = []; selectedRows.forEach((rowId) => { - const elem = $(`comboPrio${rowId}`); rowIds.push(rowId); - fileIds.push(elem.getAttribute("data-file-id")); + fileIds.push(torrentFilesTable.getRowFileId(rowId)); }); const uniqueRowIds = {}; @@ -607,7 +565,7 @@ window.qBittorrent.PropFiles ??= (() => { } }); - torrentFilesTable.setup("torrentFilesTableDiv", "torrentFilesTableFixedHeaderDiv", torrentFilesContextMenu); + torrentFilesTable.setup("torrentFilesTableDiv", "torrentFilesTableFixedHeaderDiv", torrentFilesContextMenu, true); // inject checkbox into table header const tableHeaders = document.querySelectorAll("#torrentFilesTableFixedHeaderDiv .dynamicTableHeader th"); if (tableHeaders.length > 0) { @@ -641,111 +599,12 @@ window.qBittorrent.PropFiles ??= (() => { torrentFilesTable.updateTable(); if (value.trim() === "") - collapseAllNodes(); + torrentFilesTable.collapseAllNodes(); else - expandAllNodes(); + torrentFilesTable.expandAllNodes(); }, window.qBittorrent.Misc.FILTER_INPUT_DELAY); }); - /** - * Show/hide a node's row - */ - const _hideNode = (node, shouldHide) => { - const span = $(`filesTablefileName${node.rowId}`); - // span won't exist if row has been filtered out - if (span === null) - return; - const rowElem = span.parentElement.parentElement; - rowElem.classList.toggle("invisible", shouldHide); - }; - - /** - * Update a node's collapsed state and icon - */ - const _updateNodeState = (node, isCollapsed) => { - const span = $(`filesTablefileName${node.rowId}`); - // span won't exist if row has been filtered out - if (span === null) - return; - const td = span.parentElement; - - // store collapsed state - td.setAttribute("data-collapsed", isCollapsed); - - // rotate the collapse icon - const collapseIcon = td.getElementsByClassName("filesTableCollapseIcon")[0]; - collapseIcon.classList.toggle("rotate", isCollapsed); - }; - - const _isCollapsed = (node) => { - const span = $(`filesTablefileName${node.rowId}`); - if (span === null) - return true; - - const td = span.parentElement; - return td.getAttribute("data-collapsed") === "true"; - }; - - const expandNode = (node) => { - _collapseNode(node, false, false, false); - }; - - const collapseNode = (node) => { - _collapseNode(node, true, false, false); - }; - - const expandAllNodes = () => { - const root = torrentFilesTable.getRoot(); - root.children.each((node) => { - node.children.each((child) => { - _collapseNode(child, false, true, false); - }); - }); - }; - - const collapseAllNodes = () => { - const root = torrentFilesTable.getRoot(); - root.children.each((node) => { - node.children.each((child) => { - _collapseNode(child, true, true, false); - }); - }); - }; - - /** - * Collapses a folder node with the option to recursively collapse all children - * @param {FolderNode} node the node to collapse/expand - * @param {boolean} shouldCollapse true if the node should be collapsed, false if it should be expanded - * @param {boolean} applyToChildren true if the node's children should also be collapsed, recursively - * @param {boolean} isChildNode true if the current node is a child of the original node we collapsed/expanded - */ - const _collapseNode = (node, shouldCollapse, applyToChildren, isChildNode) => { - if (!node.isFolder) - return; - - const shouldExpand = !shouldCollapse; - const isNodeCollapsed = _isCollapsed(node); - const nodeInCorrectState = ((shouldCollapse && isNodeCollapsed) || (shouldExpand && !isNodeCollapsed)); - const canSkipNode = (isChildNode && (!applyToChildren || nodeInCorrectState)); - if (!isChildNode || applyToChildren || !canSkipNode) - _updateNodeState(node, shouldCollapse); - - node.children.each((child) => { - _hideNode(child, shouldCollapse); - - if (!child.isFolder) - return; - - // don't expand children that have been independently collapsed, unless applyToChildren is true - const shouldExpandChildren = (shouldExpand && applyToChildren); - const isChildCollapsed = _isCollapsed(child); - if (!shouldExpandChildren && isChildCollapsed) - return; - - _collapseNode(child, shouldCollapse, applyToChildren, true); - }); - }; - const clear = () => { torrentFilesTable.clear(); }; diff --git a/src/webui/www/private/scripts/prop-peers.js b/src/webui/www/private/scripts/prop-peers.js index e7fa59ac2..80120c86e 100644 --- a/src/webui/www/private/scripts/prop-peers.js +++ b/src/webui/www/private/scripts/prop-peers.js @@ -187,7 +187,7 @@ window.qBittorrent.PropPeers ??= (() => { } }); - torrentPeersTable.setup("torrentPeersTableDiv", "torrentPeersTableFixedHeaderDiv", torrentPeersContextMenu); + torrentPeersTable.setup("torrentPeersTableDiv", "torrentPeersTableFixedHeaderDiv", torrentPeersContextMenu, true); return exports(); })(); diff --git a/src/webui/www/private/scripts/prop-trackers.js b/src/webui/www/private/scripts/prop-trackers.js index 9b02b6aef..fbf5c0eef 100644 --- a/src/webui/www/private/scripts/prop-trackers.js +++ b/src/webui/www/private/scripts/prop-trackers.js @@ -248,7 +248,7 @@ window.qBittorrent.PropTrackers ??= (() => { } }); - torrentTrackersTable.setup("torrentTrackersTableDiv", "torrentTrackersTableFixedHeaderDiv", torrentTrackersContextMenu); + torrentTrackersTable.setup("torrentTrackersTableDiv", "torrentTrackersTableFixedHeaderDiv", torrentTrackersContextMenu, true); return exports(); })(); diff --git a/src/webui/www/private/scripts/prop-webseeds.js b/src/webui/www/private/scripts/prop-webseeds.js index af674d6b4..96cfbd4ea 100644 --- a/src/webui/www/private/scripts/prop-webseeds.js +++ b/src/webui/www/private/scripts/prop-webseeds.js @@ -223,7 +223,7 @@ window.qBittorrent.PropWebseeds ??= (() => { } }); - torrentWebseedsTable.setup("torrentWebseedsTableDiv", "torrentWebseedsTableFixedHeaderDiv", torrentWebseedsContextMenu); + torrentWebseedsTable.setup("torrentWebseedsTableDiv", "torrentWebseedsTableFixedHeaderDiv", torrentWebseedsContextMenu, true); return exports(); })(); diff --git a/src/webui/www/private/views/log.html b/src/webui/www/private/views/log.html index 0790bc0bb..99fe8660a 100644 --- a/src/webui/www/private/views/log.html +++ b/src/webui/www/private/views/log.html @@ -223,8 +223,8 @@ } }); - tableInfo["main"].instance.setup("logMessageTableDiv", "logMessageTableFixedHeaderDiv", logTableContextMenu); - tableInfo["peer"].instance.setup("logPeerTableDiv", "logPeerTableFixedHeaderDiv", logTableContextMenu); + tableInfo["main"].instance.setup("logMessageTableDiv", "logMessageTableFixedHeaderDiv", logTableContextMenu, true); + tableInfo["peer"].instance.setup("logPeerTableDiv", "logPeerTableFixedHeaderDiv", logTableContextMenu, true); MUI.Panels.instances.LogPanel.contentEl.style.height = "100%"; @@ -331,7 +331,7 @@ if (curTab === undefined) curTab = currentSelectedTab; - $("numFilteredLogs").textContent = tableInfo[curTab].instance.filteredLength(); + $("numFilteredLogs").textContent = tableInfo[curTab].instance.filteredLength; $("numTotalLogs").textContent = tableInfo[curTab].instance.getRowSize(); }; diff --git a/src/webui/www/private/views/preferences.html b/src/webui/www/private/views/preferences.html index 30173ccb0..07109e68e 100644 --- a/src/webui/www/private/views/preferences.html +++ b/src/webui/www/private/views/preferences.html @@ -106,6 +106,10 @@ +
+ + +
@@ -2221,6 +2225,7 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD $("statusBarExternalIP").checked = pref.status_bar_external_ip; $("performanceWarning").checked = pref.performance_warning; document.getElementById("displayFullURLTrackerColumn").checked = (LocalPreferences.get("full_url_tracker_column", "false") === "true"); + document.getElementById("useVirtualList").checked = (LocalPreferences.get("use_virtual_list", "false") === "true"); document.getElementById("hideZeroFiltersCheckbox").checked = (LocalPreferences.get("hide_zero_status_filters", "false") === "true"); $("dblclickDownloadSelect").value = LocalPreferences.get("dblclick_download", "1"); $("dblclickCompleteSelect").value = LocalPreferences.get("dblclick_complete", "1"); @@ -2653,6 +2658,7 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD settings["status_bar_external_ip"] = $("statusBarExternalIP").checked; settings["performance_warning"] = $("performanceWarning").checked; LocalPreferences.set("full_url_tracker_column", document.getElementById("displayFullURLTrackerColumn").checked.toString()); + LocalPreferences.set("use_virtual_list", document.getElementById("useVirtualList").checked.toString()); LocalPreferences.set("hide_zero_status_filters", document.getElementById("hideZeroFiltersCheckbox").checked.toString()); LocalPreferences.set("dblclick_download", $("dblclickDownloadSelect").value); LocalPreferences.set("dblclick_complete", $("dblclickCompleteSelect").value); diff --git a/src/webui/www/private/views/transferlist.html b/src/webui/www/private/views/transferlist.html index 0692a06db..c6feebbb5 100644 --- a/src/webui/www/private/views/transferlist.html +++ b/src/webui/www/private/views/transferlist.html @@ -111,7 +111,7 @@ }); const setup = () => { - torrentsTable.setup("torrentsTableDiv", "torrentsTableFixedHeaderDiv", contextMenu); + torrentsTable.setup("torrentsTableDiv", "torrentsTableFixedHeaderDiv", contextMenu, true); }; return exports();