feat: improve drag & drop

* permits multiple drag & drop
 * synchronize grid & list view on selection
 * permits copy during ddrag & drop (use sift/alt key according your OS)
 * use batch move / copy on drag & drop
This commit is contained in:
Edouard Vanbelle
2026-03-25 18:49:38 +01:00
parent 5b821a7eab
commit 534d4dc190
10 changed files with 386 additions and 173 deletions
+8 -69
View File
@@ -288,40 +288,13 @@ const contextMenus = {
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
const folderIds = items.filter(i => i.type === 'folder').map(i => i.id);
let success = 0, errors = 0;
try {
// Batch copy files
if (fileIds.length > 0) {
const res = await fetch('/api/batch/files/copy', {
method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ file_ids: fileIds, target_folder_id: targetId })
});
const data = await res.json();
success += data.stats?.successful || 0;
errors += data.stats?.failed || 0;
}
// Note: Folder copy is not yet implemented in batch API
if (folderIds.length > 0) {
window.ui.showNotification('Info', 'Folder copy is not yet supported in batch mode');
}
} catch (err) {
console.error('Batch copy error:', err);
errors++;
}
let result = await window.fileOps.batchCopy( fileIds, folderIds, targetId);
this.closeMoveDialog();
window.multiSelect.clear();
window.loadFiles();
if (errors > 0) {
window.ui.showNotification('Batch copy', `${success} copied, ${errors} failed`);
} else {
window.ui.showNotification('Items copied',
`${success} item${success !== 1 ? 's' : ''} copied successfully`);
}
window.multiSelect.showBatchResult( "copy", result);
return;
}
@@ -353,48 +326,14 @@ const contextMenus = {
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
const folderIds = items.filter(i => i.type === 'folder' && i.id !== targetId).map(i => i.id);
let success = 0, errors = 0;
try {
// Batch move files in a single request
if (fileIds.length > 0) {
const res = await fetch('/api/batch/files/move', {
method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ file_ids: fileIds, target_folder_id: targetId })
});
const data = await res.json();
success += data.stats?.successful || 0;
errors += data.stats?.failed || 0;
}
// Batch move folders in a single request
if (folderIds.length > 0) {
const res = await fetch('/api/batch/folders/move', {
method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_ids: folderIds, target_folder_id: targetId })
});
const data = await res.json();
success += data.stats?.successful || 0;
errors += data.stats?.failed || 0;
}
} catch (err) {
console.error('Batch move error:', err);
errors++;
}
let result = await window.fileOps.batchMove( fileIds, folderIds, targetId);
this.closeMoveDialog();
window.multiSelect.clear();
window.loadFiles();
if (errors > 0) {
window.ui.showNotification('Batch move', `${success} moved, ${errors} failed`);
} else {
window.ui.showNotification('Items moved',
`${success} item${success !== 1 ? 's' : ''} moved successfully`);
}
window.multiSelect.showBatchResult( "move", result);
return;
}
@@ -771,6 +771,60 @@ const fileOps = {
}
},
/**
* @typedef {Object} BatchResult
* @property {number} success number of files|folders sucessfully updated
* @property {number} errors number of files|folders in error
* /
/**
* Move files & folders
* @param {string[]} fileIds - File IDs
* @param {string[]} folderIds - Folder IDs
* @param {string} targetFolderId - Target folder ID
* @returns {Promise<BatchResult>} - Success status
*/
async batchMove(fileIds, folderIds, targetFolderId) {
// TODO ensure not moving a folder into itself
let success = 0, errors = 0;
try {
// Batch move files in a single request
if (fileIds.length > 0) {
const res = await fetch('/api/batch/files/move', {
method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ file_ids: fileIds, target_folder_id: targetFolderId })
});
const data = await res.json();
success += data.stats?.successful || 0;
errors += data.stats?.failed || 0;
}
// Batch move folders in a single request
if (folderIds.length > 0) {
const res = await fetch('/api/batch/folders/move', {
method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_ids: folderIds, target_folder_id: targetFolderId })
});
const data = await res.json();
success += data.stats?.successful || 0;
errors += data.stats?.failed || 0;
}
}
catch (err) {
console.error('Batch move error:', err);
errors++;
}
return {
success,
errors
};
},
/**
* Copy a file to another folder
* @param {string} fileId - File ID
@@ -828,6 +882,47 @@ const fileOps = {
return false;
},
/**
* Copy files & folders
* @param {string[]} fileIds - File IDs
* @param {string[]} folderIds - Folder IDs
* @param {string} targetFolderId - Target folder ID
* @returns {Promise<boolean>} - Success status
*/
async batchCopy(fileIds, folderIds, targetFolderId) {
// FIXME ensure not moving a folder into itself
let success = 0, errors = 0;
try {
// Batch copy files
if (fileIds.length > 0) {
const res = await fetch('/api/batch/files/copy', {
method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ file_ids: fileIds, target_folder_id: targetFolderId })
});
const data = await res.json();
success += data.stats?.successful || 0;
errors += data.stats?.failed || 0;
}
// Note: Folder copy is not yet implemented in batch API
if (folderIds.length > 0) {
window.ui.showNotification('Info', 'Folder copy is not yet supported in batch mode');
errors += folderIds.lenngth;
}
} catch (err) {
console.error('Batch copy error:', err);
errors++;
}
return {
success,
errors
};
},
/**
* Rename a file
* @param {string} fileId - File ID
+62 -19
View File
@@ -76,6 +76,61 @@ const multiSelect = {
}
},
/**
* @typedef {Object} ItemSelection
* @property {string[]} fileIds list of files' id
* @property {string[]} folderIds list of folders' id
*/
/**
* get selection
* @param {string} [targtFolderId] an optional targget (will be removed from selected item)
* @return {ItemSelection}
*/
getSelection(targtFolderId) {
let fileIds=[];
let folderIds=[];
// TODO optimize & check if _selected is a better use
document.querySelectorAll(`div.file-item.selected`).forEach( (item) => {
if (item.dataset.fileId) {
fileIds.push(item.dataset.fileId);
}
else {
// ignore selectedItem if this is the target
if (targtFolderId && targtFolderId !== item.dataset.folderId)
folderIds.push(item.dataset.folderId);
}
});
return {
"fileIds": fileIds,
"folderIds": folderIds,
};
},
/**
* @param {string} action move|copy
* @param {BatchResult} result result of batch
*/
showBatchResult(action, result) {
if (action === "copy") {
if (result.errors > 0) {
window.ui.showNotification('Batch copy', `${result.success} copied, ${result.errors} failed`);
} else {
window.ui.showNotification('Items copied',
`${result.success} item${result.success !== 1 ? 's' : ''} copied successfully`);
}
} else {
if (result.errors > 0) {
window.ui.showNotification('Batch move', `${result.success} moved, ${result.errors} failed`);
} else {
window.ui.showNotification('Items moved',
`${result.success} item${result.success !== 1 ? 's' : ''} moved successfully`);
}
}
},
// ── DOM helpers ─────────────────────────────────────────
_selectElement(el) {
@@ -94,13 +149,6 @@ const multiSelect = {
_getAllVisibleItems() {
return [...document.querySelectorAll('.file-item, .file-card')];
/*
const grid = document.getElementById('files-grid');
if (grid && grid.style.display !== 'none') {
return [...grid.querySelectorAll('.file-card')];
}
return [...document.querySelectorAll('#files-list-view .file-item')];
*/
},
_extractInfo(el) {
@@ -192,6 +240,7 @@ const multiSelect = {
const n = this._selected.size;
const batchSelectionBar = document.getElementById('batch-selection-bar');
const actionsBar = document.getElementById('actions-bar');
if (n > 0) {
this._barVisible = true;
@@ -201,14 +250,18 @@ const multiSelect = {
: (this._t('batch.n_selected', { count: n }) || `${n} items selected`);
document.getElementById("batch-bar-count").innerText = countText;
batchSelectionBar.classList.add('visible');
actionsBar.classList.add('hidden');
batchSelectionBar.classList.remove('hidden');
} else {
this._barVisible = false;
// Hide grid bar
batchSelectionBar.classList.remove('visible');
batchSelectionBar.classList.add('hidden');
if (actionsBar.dataset.mode !== "hidden")
actionsBar.classList.remove('hidden');
}
// Sync individual item checkboxes
@@ -425,9 +478,6 @@ const multiSelect = {
// Wire the initial select-all checkbox
this._injectListHeaderCheckbox();
// Global deselect on empty-area click
this._hookGlobalDeselect();
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
@@ -459,13 +509,6 @@ const multiSelect = {
cb.addEventListener('change', () => this.toggleAll());
},
_hookGlobalDeselect() {
document.addEventListener('click', (e) => {
if (window.__rubberBandJustFinished) return;
if (e.target.closest('.file-card, .file-item, .context-menu, .batch-selection-bar, .list-header.selection-mode, .about-modal, .rename-dialog, .share-dialog, .confirm-dialog, .modal-overlay, input, button')) return;
if (this.hasSelection) this.clear();
});
}
};
// Expose globally