From 6ac4e6177c6fd366661ff29758631aa38c7c1861 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 26 May 2026 22:59:43 +0200 Subject: [PATCH] refactor(js): move multiSelect into batchToolbar + move residual method into relevant components --- .../{multiSelect.css => batchToolbar.css} | 0 static/css/main.css | 2 +- static/js/app/filesView.js | 8 +- static/js/app/main.js | 14 +- static/js/app/navigation.js | 20 +-- static/js/app/trashView.js | 4 +- static/js/app/ui.js | 36 ++-- static/js/components/resourceList.js | 158 ++++++++++++++++-- .../files/{multiSelect.js => batchToolbar.js} | 20 ++- static/js/features/files/contextMenus.js | 18 +- static/js/features/library/favorites.js | 4 +- static/js/features/library/recent.js | 8 +- .../js/views/sharedWithMe/sharedWithMeView.js | 4 +- 13 files changed, 215 insertions(+), 81 deletions(-) rename static/css/components/{multiSelect.css => batchToolbar.css} (100%) rename static/js/features/files/{multiSelect.js => batchToolbar.js} (97%) diff --git a/static/css/components/multiSelect.css b/static/css/components/batchToolbar.css similarity index 100% rename from static/css/components/multiSelect.css rename to static/css/components/batchToolbar.css diff --git a/static/css/main.css b/static/css/main.css index 380b2f4b..83aa67a1 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -25,7 +25,7 @@ @import url("./components/notifications.css"); @import url("./components/userMenu.css"); @import url("./components/languageSelector.css"); -@import url("./components/multiSelect.css"); +@import url("./components/batchToolbar.css"); @import url("./components/spinner.css"); @import url("./components/search.css"); @import url("./components/icons.css"); diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index ddff0609..12be1db0 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -2,7 +2,7 @@ import { i18n } from '../core/i18n.js'; import { inlineViewer } from '../features/files/inlineViewer.js'; -import { multiSelect } from '../features/files/multiSelect.js'; +import { batchToolbar } from '../features/files/batchToolbar.js'; import { resolveHomeFolder } from './authSession.js'; import { updateHistory } from './main.js'; import { app } from './state.js'; @@ -217,9 +217,9 @@ async function loadFiles(options = { insertHistory: true }) { ui._items.clear(); ui.resetFilesList(); - if (multiSelect) { - multiSelect.clear(); - multiSelect.init(); // this will wire buttons & select-all-checkbox + if (batchToolbar) { + batchToolbar.clear(); + batchToolbar.init(); // this will wire buttons & select-all-checkbox } /** @type {FolderItem[]} */ diff --git a/static/js/app/main.js b/static/js/app/main.js index 037712ac..e6c8768d 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -12,7 +12,7 @@ import { formatFileSize, formatQuotaSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { oxiIconsInit } from '../core/icons.js'; import { fileOps } from '../features/files/fileOperations.js'; -import { multiSelect } from '../features/files/multiSelect.js'; +import { batchToolbar } from '../features/files/batchToolbar.js'; import { favorites } from '../features/library/favorites.js'; import { recent } from '../features/library/recent.js'; import { fileSharing } from '../features/sharing/fileSharing.js'; @@ -50,7 +50,7 @@ let uploadDropdownDocumentClickHandler = null; let uploadDropdownBindingsController = null; let actionsBarDelegationBound = false; -const _multiSelectButons = ` +const _batchToolbarButons = ` - ${_multiSelectButons} + ${_batchToolbarButons} ${_toggleButtons} `, sharedwithme: ` @@ -386,9 +386,9 @@ function initApp() { } // Initialize multi-select / batch actions - if (multiSelect?.init) { + if (batchToolbar?.init) { console.log('Initializing multi-select module'); - multiSelect.init(); + batchToolbar.init(); } window.addEventListener('authenticationDone', async () => { diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index 08ac0f33..3be34396 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -4,7 +4,7 @@ */ import { i18n } from '../core/i18n.js'; -import { multiSelect } from '../features/files/multiSelect.js'; +import { batchToolbar } from '../features/files/batchToolbar.js'; import { favorites } from '../features/library/favorites.js'; import { musicView } from '../features/library/music.js'; import { photosView } from '../features/library/photos.js'; @@ -201,7 +201,7 @@ function switchToSharedSection() { sharedView.show(); }); - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); } function switchToSharedWithMeSection() { @@ -221,7 +221,7 @@ function switchToSharedWithMeSection() { toggleFileContainer(true); syncViewContainers(); - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); // Load and render items into the files container sharedWithMeView.init(); @@ -253,7 +253,7 @@ function switchToFilesSection() { app.currentPath = app.userHomeFolderId || ''; app.breadcrumbPath = []; ui.updateBreadcrumb(); - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); // temp solution sharedView.loadItems().then(() => { @@ -296,7 +296,7 @@ function switchToFavoritesSection() { `); } - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); } function switchToRecentFilesSection() { @@ -329,7 +329,7 @@ function switchToRecentFilesSection() {

Error loading the recent module

`); } - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); } function switchToPhotosSection() { @@ -352,7 +352,7 @@ function switchToPhotosSection() { if (photosView) { photosView.show(); } - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); } function switchToTrashSection() { @@ -377,7 +377,7 @@ function switchToTrashSection() { // Load trash items loadTrashItems(); - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); } function switchToMusicSection() { @@ -404,7 +404,7 @@ function switchToMusicSection() { if (musicView) { musicView.show(); } - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); } /** @@ -423,7 +423,7 @@ function activateFilesUI() { breadcrumb?.classList.remove('hidden'); toggleFileContainer(true); syncViewContainers(); - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); } export { diff --git a/static/js/app/trashView.js b/static/js/app/trashView.js index b7066906..c9cb2d33 100644 --- a/static/js/app/trashView.js +++ b/static/js/app/trashView.js @@ -5,7 +5,7 @@ import { escapeHtml, formatDateTime } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { fileOps } from '../features/files/fileOperations.js'; -import { multiSelect } from '../features/files/multiSelect.js'; +import { batchToolbar } from '../features/files/batchToolbar.js'; import * as pathTooltip from '../features/pathTooltip.js'; import { appElements } from './state.js'; import { ui } from './ui.js'; @@ -22,7 +22,7 @@ async function loadTrashItems() { const elements = appElements; try { - if (multiSelect) multiSelect.clear(); + if (batchToolbar) batchToolbar.clear(); pathTooltip.destroy(elements.filesList); ui.resetFilesList(); // ensure also list visible & error hidden elements.filesList.innerHTML = ` diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 7862e0dd..9daa7624 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -13,7 +13,7 @@ import { OxiIcons } from '../core/icons.js'; import { contextMenus } from '../features/files/contextMenus.js'; import { fileOps } from '../features/files/fileOperations.js'; import { inlineViewer } from '../features/files/inlineViewer.js'; -import { multiSelect } from '../features/files/multiSelect.js'; +import { batchToolbar } from '../features/files/batchToolbar.js'; import { wopiEditor } from '../features/files/wopiEditor.js'; import { favorites } from '../features/library/favorites.js'; import { recent } from '../features/library/recent.js'; @@ -727,9 +727,9 @@ const ui = { * @param {any} dataTransfer fallback if nothing is selected */ async _dropToFolder(action, targetFolderId, dataTransfer) { - const selection = multiSelect.getSelection(targetFolderId); + const selection = batchToolbar.getSelection(targetFolderId); - multiSelect.clear(); + batchToolbar.clear(); if (selection.fileIds.length === 0 && selection.folderIds.length === 0) { // try to use dataTransfer (direct move without selection) @@ -774,7 +774,7 @@ const ui = { console.error(`drag and drop: action ${action} unknown`); return; } - multiSelect.showBatchResult(action, result); + batchToolbar.showBatchResult(action, result); console.log(result); }, @@ -941,8 +941,8 @@ const ui = { } // shiftkey is used to complete selection - if (e.shiftKey && multiSelect) { - multiSelect.handleToggleItem(card, e); + if (e.shiftKey && batchToolbar) { + batchToolbar.handleToggleItem(card, e); return; } @@ -1512,13 +1512,13 @@ const ui = { /** * Toggle selection state of a file/folder card. - * Routes through the multiSelect module so batch actions know about selected items. + * Routes through the batchToolbar module so batch actions know about selected items. * @param {HTMLDivElement} card * @param {MouseEvent} event */ function toggleCardSelection(card, event) { - if (multiSelect) { - multiSelect.handleToggleItem(card, event); + if (batchToolbar) { + batchToolbar.handleToggleItem(card, event); } else { card.classList.toggle('selected'); } @@ -1641,17 +1641,17 @@ function initRubberBandSelection() { if (intersects) { card.classList.add('selected'); - // Sync with multiSelect module - if (multiSelect) { - const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card)); - if (info) multiSelect.select(info.id, info.name, info.type, info.parentId); + // Sync with batchToolbar module + if (batchToolbar) { + const info = batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (card)); + if (info) batchToolbar.select(info.id, info.name, info.type, info.parentId); } } else { card.classList.remove('selected'); - // Deselect from multiSelect module - if (multiSelect) { - const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card)); - if (info) multiSelect.deselect(info.id); + // Deselect from batchToolbar module + if (batchToolbar) { + const info = batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (card)); + if (info) batchToolbar.deselect(info.id); } } }); @@ -1663,7 +1663,7 @@ function initRubberBandSelection() { const hadSelection = selRect.style.display === 'block'; selRect.style.display = 'none'; // Update the batch bar after rubber band selection completes - if (multiSelect) multiSelect._syncUI(); + if (batchToolbar) batchToolbar._syncUI(); // Suppress the click event that follows mouseup so the global // deselect handler doesn't immediately clear the selection. if (hadSelection) { diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index 7d4af5b0..f2ddb286 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -86,6 +86,9 @@ export class ResourceListComponent { /** @type {Set} */ this._selected = new Set(); + /** Index of the last clicked item — used for shift-click range selection. */ + this._lastClickedIndex = -1; + this._ownerVisible = this._cfg.showOwner; this._initDelegation(); @@ -110,8 +113,10 @@ export class ResourceListComponent { this._selected.clear(); this._items.clear(); + this._lastClickedIndex = -1; this._appendItems(folders, files, groupFn); + this._wireSelectAll(); } /** @@ -132,6 +137,40 @@ export class ResourceListComponent { if (header) this._container.appendChild(header); this._selected.clear(); this._items.clear(); + this._lastClickedIndex = -1; + } + + /** + * Deselect all items without removing them from the DOM. + * Used by the batch toolbar after an operation completes. + */ + clearSelection() { + this._selected.clear(); + this._lastClickedIndex = -1; + this._container.querySelectorAll('.file-item.selected').forEach((card) => { + card.classList.remove('selected'); + const cb = /** @type {HTMLInputElement | null} */ (card.querySelector('.item-checkbox')); + if (cb) cb.checked = false; + }); + this._syncSelectAllCheckbox(); + this._cfg.onSelectionChange?.([]); + } + + /** + * Select all visible items in the container. + */ + selectAll() { + this._container.querySelectorAll('.file-item').forEach((card) => { + const el = /** @type {HTMLElement} */ (card); + const id = el.dataset.fileId || el.dataset.folderId || ''; + if (!id) return; + el.classList.add('selected'); + const cb = /** @type {HTMLInputElement | null} */ (el.querySelector('.item-checkbox')); + if (cb) cb.checked = true; + this._selected.add(id); + }); + this._syncSelectAllCheckbox(); + this._notifySelectionChange(); } /** @@ -406,9 +445,13 @@ export class ResourceListComponent { return; } - // Checkbox cell → selection + // Checkbox cell → selection (shift extends range) if (cfg.selectable && target.closest('.checkbox-cell')) { - this._toggleSelection(card, /** @type {MouseEvent} */ (e)); + if (e.shiftKey) { + this._handleShiftSelect(card); + } else { + this._toggleSelection(card); + } return; } @@ -417,7 +460,13 @@ export class ResourceListComponent { // Modifier-key click → selection toggle if (e.metaKey || e.altKey || e.ctrlKey) { - if (cfg.selectable) this._toggleSelection(card, /** @type {MouseEvent} */ (e)); + if (cfg.selectable) this._toggleSelection(card); + return; + } + + // Shift-click anywhere on the card → extend selection range + if (e.shiftKey && cfg.selectable) { + this._handleShiftSelect(card); return; } @@ -454,11 +503,11 @@ export class ResourceListComponent { } /** - * Toggle selection state on a card and notify via `onSelectionChange`. + * Toggle selection on a single card and notify. + * Tracks `_lastClickedIndex` for subsequent shift-clicks. * @param {HTMLElement} card - * @param {MouseEvent} _e - Reserved for future shift-click range selection. */ - _toggleSelection(card, _e) { + _toggleSelection(card) { const id = card.dataset.fileId || card.dataset.folderId || ''; if (!id) return; @@ -474,13 +523,96 @@ export class ResourceListComponent { this._selected.delete(id); } - if (this._cfg.onSelectionChange) { - /** @type {Array} */ - const selectedItems = [...this._selected].flatMap((sid) => { - const item = this._items.get(sid); - return item ? [item] : []; - }); - this._cfg.onSelectionChange(selectedItems); + // Record position for the next shift-click + const items = [...this._container.querySelectorAll('.file-item')]; + this._lastClickedIndex = items.indexOf(card); + + this._syncSelectAllCheckbox(); + this._notifySelectionChange(); + } + + /** + * Extend the selection from `_lastClickedIndex` to `card` (inclusive). + * If no previous click exists, falls back to a plain toggle. + * @param {HTMLElement} card + */ + _handleShiftSelect(card) { + const items = /** @type {HTMLElement[]} */ ([...this._container.querySelectorAll('.file-item')]); + const index = items.indexOf(card); + + if (this._lastClickedIndex >= 0 && index >= 0) { + const start = Math.min(this._lastClickedIndex, index); + const end = Math.max(this._lastClickedIndex, index); + for (let i = start; i <= end; i++) { + const el = items[i]; + const id = el.dataset.fileId || el.dataset.folderId || ''; + if (!id) continue; + el.classList.add('selected'); + const cb = /** @type {HTMLInputElement | null} */ (el.querySelector('.item-checkbox')); + if (cb) cb.checked = true; + this._selected.add(id); + } + } else { + this._toggleSelection(card); + return; + } + + this._lastClickedIndex = index; + this._syncSelectAllCheckbox(); + this._notifySelectionChange(); + } + + /** + * Find the select-all checkbox in the container header and wire its + * `change` event. Called after every `render()`. + */ + _wireSelectAll() { + if (!this._cfg.selectable) return; + const cb = /** @type {HTMLInputElement | null} */ (this._container.querySelector('#select-all-checkbox')); + if (!cb) return; + // Replace with a fresh listener to avoid duplicates across re-renders + const fresh = /** @type {HTMLInputElement} */ (cb.cloneNode(true)); + cb.parentNode?.replaceChild(fresh, cb); + fresh.addEventListener('change', () => { + if (fresh.checked) { + this.selectAll(); + } else { + this.clearSelection(); + } + }); + } + + /** + * Sync the three-state select-all checkbox in the list header. + * Checked = all selected, indeterminate = some selected, unchecked = none. + */ + _syncSelectAllCheckbox() { + const cb = /** @type {HTMLInputElement | null} */ (this._container.querySelector('#select-all-checkbox')); + if (!cb) return; + const total = this._container.querySelectorAll('.file-item').length; + if (total === 0) { + cb.checked = false; + cb.indeterminate = false; + } else if (this._selected.size >= total) { + cb.checked = true; + cb.indeterminate = false; + } else if (this._selected.size > 0) { + cb.checked = false; + cb.indeterminate = true; + } else { + cb.checked = false; + cb.indeterminate = false; } } + + /** Build the selected-items array and fire `onSelectionChange`. */ + _notifySelectionChange() { + if (!this._cfg.onSelectionChange) return; + /** @type {Array} */ + const selectedItems = [...this._selected].flatMap((id) => { + const item = this._items.get(id); + return item ? [item] : []; + }); + this._cfg.onSelectionChange(selectedItems); + } } diff --git a/static/js/features/files/multiSelect.js b/static/js/features/files/batchToolbar.js similarity index 97% rename from static/js/features/files/multiSelect.js rename to static/js/features/files/batchToolbar.js index de9c9525..ceaebecc 100644 --- a/static/js/features/files/multiSelect.js +++ b/static/js/features/files/batchToolbar.js @@ -1,14 +1,16 @@ /** - * OxiCloud - Multi-Select & Batch Actions Module + * OxiCloud — Batch Toolbar Module * - * Adds checkboxes to grid and list views, replaces the list-view header - * with a NextCloud-style selection bar when items are selected, and - * provides batch delete / move / download / favorites operations. + * Manages the floating selection bar that appears when items are selected, + * and executes batch operations (delete, move, download, favorites). + * + * Selection state (_selected, handleToggleItem, selectAll, …) is kept here + * while the main file manager still uses its own delegation (ui.js). + * Once ui.js is migrated to ResourceListComponent (plan step B5), all + * selection mechanics will live in the component and this module will + * shrink to only the toolbar UI and batch-operation API calls. */ -// TODO: rename into selection-bar ? -// TODO: merge with photo part - import { loadFiles } from '../../app/filesView.js'; import { app } from '../../app/state.js'; import { showConfirmDialog, ui } from '../../app/ui.js'; @@ -22,7 +24,7 @@ import { getAuthHeaders } from './fileOperations.js'; * @import {BatchResult} from './fileOperations.js' */ -const multiSelect = { +const batchToolbar = { /** @type {Map} items: Map */ _selected: new Map(), @@ -536,4 +538,4 @@ const multiSelect = { } }; -export { multiSelect }; +export { batchToolbar }; diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index e4395fb1..de6c493d 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -18,7 +18,7 @@ import { musicView } from '../library/music.js'; import { fileSharing } from '../sharing/fileSharing.js'; import { fileOps } from './fileOperations.js'; import { inlineViewer } from './inlineViewer.js'; -import { multiSelect } from './multiSelect.js'; +import { batchToolbar } from './batchToolbar.js'; import { wopiEditor } from './wopiEditor.js'; /** @@ -330,8 +330,8 @@ const contextMenus = { // Copy button handler copyConfirmBtn.addEventListener('click', async () => { - // Batch copy mode (from multiSelect) - if (app.moveDialogMode === 'batch' && multiSelect) { + // Batch copy mode (from batchToolbar) + if (app.moveDialogMode === 'batch' && batchToolbar) { const targetId = app.selectedTargetFolderId; const items = app.batchMoveItems || []; @@ -341,10 +341,10 @@ const contextMenus = { const result = await fileOps.batchCopy(fileIds, folderIds, targetId); this.closeMoveDialog(); - multiSelect.clear(); + batchToolbar.clear(); loadFiles(); - multiSelect.showBatchResult('copy', result); + batchToolbar.showBatchResult('copy', result); return; } @@ -363,8 +363,8 @@ const contextMenus = { }); moveConfirmBtn.addEventListener('click', async () => { - // Batch move mode (from multiSelect) - if (app.moveDialogMode === 'batch' && multiSelect) { + // Batch move mode (from batchToolbar) + if (app.moveDialogMode === 'batch' && batchToolbar) { const targetId = app.selectedTargetFolderId; const items = app.batchMoveItems || []; @@ -374,9 +374,9 @@ const contextMenus = { const result = await fileOps.batchMove(fileIds, folderIds, targetId); this.closeMoveDialog(); - multiSelect.clear(); + batchToolbar.clear(); loadFiles(); - multiSelect.showBatchResult('move', result); + batchToolbar.showBatchResult('move', result); return; } diff --git a/static/js/features/library/favorites.js b/static/js/features/library/favorites.js index 86939cc7..28fabf64 100644 --- a/static/js/features/library/favorites.js +++ b/static/js/features/library/favorites.js @@ -9,7 +9,7 @@ import { ui } from '../../app/ui.js'; import { getCsrfHeaders } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; -import { multiSelect } from '../files/multiSelect.js'; +import { batchToolbar } from '../files/batchToolbar.js'; import * as pathTooltip from '../pathTooltip.js'; /** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */ @@ -181,7 +181,7 @@ const favorites = { ui.resetFilesList(); // ensure also list visible & error hidden // wire buttons & select-all-checkbox as list header has changed in ui.resetFilesList() // FIXME: this case is not easy to understand, should apply better implementation - multiSelect.init(); + batchToolbar.init(); ui.updateBreadcrumb(); diff --git a/static/js/features/library/recent.js b/static/js/features/library/recent.js index f6e21494..08cec86f 100644 --- a/static/js/features/library/recent.js +++ b/static/js/features/library/recent.js @@ -9,7 +9,7 @@ import { ui } from '../../app/ui.js'; import { getCsrfHeaders } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; -import { multiSelect } from '../files/multiSelect.js'; +import { batchToolbar } from '../files/batchToolbar.js'; import * as pathTooltip from '../pathTooltip.js'; /** @import {FileItem, FolderItem, ItemTypeEnum, RecentItem} from '../../core/types.js' */ @@ -111,9 +111,9 @@ const recent = { `; - if (multiSelect) { - multiSelect.clear(); - multiSelect.init(); // this will wire buttons & select-all-checkbox + if (batchToolbar) { + batchToolbar.clear(); + batchToolbar.init(); // this will wire buttons & select-all-checkbox } ui.updateBreadcrumb(); diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index 4e3f8f9e..2b5dc371 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -16,7 +16,7 @@ import { ui } from '../../app/ui.js'; import { i18n } from '../../core/i18n.js'; -import { multiSelect } from '../../features/files/multiSelect.js'; +import { batchToolbar } from '../../features/files/batchToolbar.js'; import { ownerTooltip } from '../../features/ownerTooltip.js'; import { grants } from '../../model/grants.js'; import { systemUsers } from '../../model/systemUsers.js'; @@ -52,7 +52,7 @@ const sharedWithMeView = { // Standard files-view setup: clear list, show container, init multiselect ui.resetFilesList(); - multiSelect.init(); + batchToolbar.init(); ui.updateBreadcrumb(); await this._loadPage();