refactor(js): move multiSelect into batchToolbar + move residual method into relevant components

This commit is contained in:
Edouard Vanbelle
2026-05-26 22:59:43 +02:00
parent 1cd934d594
commit 6ac4e6177c
13 changed files with 215 additions and 81 deletions
+1 -1
View File
@@ -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");
+4 -4
View File
@@ -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[]} */
+7 -7
View File
@@ -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 = `
<div class="action-buttons batch-selection-bar hidden" id="multi-select-buttons">
<div class="list-header-checkbox">
<button class="batch-bar-close" id="batch-selection-close" title="Cancel selection">
@@ -117,7 +117,7 @@ const ACTIONS_BAR_TEMPLATES = {
<span data-i18n="actions.new_folder">New folder</span>
</button>
</div>
${_multiSelectButons}
${_batchToolbarButons}
${_toggleButtons}
`,
trash: `
@@ -131,7 +131,7 @@ const ACTIONS_BAR_TEMPLATES = {
`,
favorites: `
<div class="action-buttons" id="default-buttons"></div>
${_multiSelectButons}
${_batchToolbarButons}
${_toggleButtons}
`,
recent: `
@@ -141,7 +141,7 @@ const ACTIONS_BAR_TEMPLATES = {
<span data-i18n="actions.clear_recent">Clear recent</span>
</button>
</div>
${_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 () => {
+10 -10
View File
@@ -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() {
<p>Error loading the recent module</p>
`);
}
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 {
+2 -2
View File
@@ -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 = `
+18 -18
View File
@@ -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) {
+145 -13
View File
@@ -86,6 +86,9 @@ export class ResourceListComponent {
/** @type {Set<string>} */
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<FileItem|FolderItem>} */
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<FileItem|FolderItem>} */
const selectedItems = [...this._selected].flatMap((id) => {
const item = this._items.get(id);
return item ? [item] : [];
});
this._cfg.onSelectionChange(selectedItems);
}
}
@@ -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<String, LightItem>} items: Map<id, { id, name, type, parentId }> */
_selected: new Map(),
@@ -536,4 +538,4 @@ const multiSelect = {
}
};
export { multiSelect };
export { batchToolbar };
+9 -9
View File
@@ -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;
}
+2 -2
View File
@@ -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();
+4 -4
View File
@@ -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 = {
</div>
`;
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();
@@ -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();