Add multi-select drag and drop

This commit is contained in:
Mikei386
2026-06-23 14:25:35 +02:00
parent 49da6c88eb
commit 69367fc0d8
5 changed files with 143 additions and 35 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
set -eu set -eu
PLUGIN="u-navigator" PLUGIN="u-navigator"
VERSION="2026.06.23.r036" VERSION="2026.06.23.r037"
PKG_DIR="packages" PKG_DIR="packages"
WORK_DIR=".plugin-build" WORK_DIR=".plugin-build"
PKG_NAME="${PLUGIN}-${VERSION}.tgz" PKG_NAME="${PLUGIN}-${VERSION}.tgz"
Binary file not shown.
Binary file not shown.
+140 -32
View File
@@ -90,6 +90,8 @@ function openExplorer(initialPath = state.config?.roots?.[0]?.path) {
path: initialPath, path: initialPath,
entries: [], entries: [],
selectedPath: null, selectedPath: null,
selectedPaths: [],
selectionAnchorPath: null,
view: 'icons', view: 'icons',
loading: false, loading: false,
error: null error: null
@@ -271,7 +273,7 @@ function renderExplorer(body, win) {
</div> </div>
<div class="drop-zone ${data.dragOver ? 'drag-over' : ''}"> <div class="drop-zone ${data.dragOver ? 'drag-over' : ''}">
${data.error ? `<p class="muted">${escapeHtml(data.error)}</p>` : ''} ${data.error ? `<p class="muted">${escapeHtml(data.error)}</p>` : ''}
${data.loading ? '<p class="muted">Lade...</p>' : renderFileEntries(data.entries || [], data.view || 'list')} ${data.loading ? '<p class="muted">Lade...</p>' : renderFileEntries(data.entries || [], data.view || 'list', selectedPathSet(win))}
</div> </div>
`; `;
@@ -326,9 +328,9 @@ function renderExplorer(body, win) {
lockExplorerScroll(win, body); lockExplorerScroll(win, body);
startItemPointerDrag(event, win, entry); startItemPointerDrag(event, win, entry);
}); });
row.addEventListener('click', () => { row.addEventListener('click', (event) => {
if (shouldSuppressItemActivation()) return; if (shouldSuppressItemActivation()) return;
selectEntry(win, entry, body); selectEntry(win, entry, body, event);
}); });
row.addEventListener('dblclick', () => { row.addEventListener('dblclick', () => {
if (shouldSuppressItemActivation()) return; if (shouldSuppressItemActivation()) return;
@@ -341,22 +343,22 @@ function renderExplorer(body, win) {
row.addEventListener('contextmenu', (event) => { row.addEventListener('contextmenu', (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
selectEntry(win, entry); selectEntry(win, entry, null, event);
showContextMenu(event, win, entry); showContextMenu(event, win, entry);
}); });
} }
} }
function renderFileEntries(entries, view) { function renderFileEntries(entries, view, selectedPaths = new Set()) {
if (!entries.length) { if (!entries.length) {
return '<p class="muted">Dieser Ordner ist leer. Dateien können hier abgelegt werden.</p>'; return '<p class="muted">Dieser Ordner ist leer. Dateien können hier abgelegt werden.</p>';
} }
return view === 'icons' ? renderFileIcons(entries) : renderFileTable(entries); return view === 'icons' ? renderFileIcons(entries, selectedPaths) : renderFileTable(entries, selectedPaths);
} }
function renderFileTable(entries) { function renderFileTable(entries, selectedPaths) {
const rows = entries.map((entry) => ` const rows = entries.map((entry) => `
<tr class="file-row ${entry.path === state.selectedEntry?.path ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}"> <tr class="file-row ${selectedPaths.has(entry.path) ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}">
<td><span class="name-cell"><span class="badge">${icons[entry.type] || 'Item'}</span>${escapeHtml(entry.name)}</span></td> <td><span class="name-cell"><span class="badge">${icons[entry.type] || 'Item'}</span>${escapeHtml(entry.name)}</span></td>
<td>${entry.type}</td> <td>${entry.type}</td>
<td>${entry.type === 'file' ? formatBytes(entry.size) : ''}</td> <td>${entry.type === 'file' ? formatBytes(entry.size) : ''}</td>
@@ -372,9 +374,9 @@ function renderFileTable(entries) {
`; `;
} }
function renderFileIcons(entries) { function renderFileIcons(entries, selectedPaths) {
const items = entries.map((entry) => ` const items = entries.map((entry) => `
<button type="button" class="file-row icon-item ${entry.path === state.selectedEntry?.path ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}"> <button type="button" class="file-row icon-item ${selectedPaths.has(entry.path) ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}">
<span class="file-icon ${escapeAttr(entry.type)}" aria-label="${escapeAttr(icons[entry.type] || 'Item')}"></span> <span class="file-icon ${escapeAttr(entry.type)}" aria-label="${escapeAttr(icons[entry.type] || 'Item')}"></span>
<span class="icon-name">${escapeHtml(entry.name)}</span> <span class="icon-name">${escapeHtml(entry.name)}</span>
<span class="icon-meta">${entry.type === 'file' ? formatBytes(entry.size) : entry.type}</span> <span class="icon-meta">${entry.type === 'file' ? formatBytes(entry.size) : entry.type}</span>
@@ -838,6 +840,7 @@ async function loadExplorer(win, targetPath) {
const result = await api(apiUrl(`list.php?path=${encodeURIComponent(targetPath)}`)); const result = await api(apiUrl(`list.php?path=${encodeURIComponent(targetPath)}`));
win.data.path = result.path; win.data.path = result.path;
win.data.entries = result.entries; win.data.entries = result.entries;
reconcileExplorerSelection(win);
} catch (error) { } catch (error) {
win.data.error = error.message; win.data.error = error.message;
} finally { } finally {
@@ -1126,13 +1129,13 @@ async function refreshAfterJob(job) {
} }
} }
function selectEntry(win, entry, scrollBody = null) { function selectEntry(win, entry, scrollBody = null, event = null) {
if (state.suppressClickPath === entry.path) { if (state.suppressClickPath === entry.path) {
state.suppressClickPath = null; state.suppressClickPath = null;
restoreExplorerScroll(win, scrollBody); restoreExplorerScroll(win, scrollBody);
return; return;
} }
win.data.selectedPath = entry.path; applyExplorerSelection(win, entry, event);
state.selectedEntry = entry; state.selectedEntry = entry;
focusWindow(win); focusWindow(win);
updateSelectedRows(); updateSelectedRows();
@@ -1140,6 +1143,66 @@ function selectEntry(win, entry, scrollBody = null) {
restoreExplorerScroll(win, scrollBody); restoreExplorerScroll(win, scrollBody);
} }
function applyExplorerSelection(win, entry, event = null) {
const entries = win.data.entries || [];
const current = selectedPathSet(win);
const toggle = Boolean(event?.ctrlKey || event?.metaKey);
const range = Boolean(event?.shiftKey && win.data.selectionAnchorPath);
if (range) {
const anchorIndex = entries.findIndex((item) => item.path === win.data.selectionAnchorPath);
const entryIndex = entries.findIndex((item) => item.path === entry.path);
if (anchorIndex >= 0 && entryIndex >= 0) {
const [start, end] = anchorIndex < entryIndex ? [anchorIndex, entryIndex] : [entryIndex, anchorIndex];
const paths = entries.slice(start, end + 1).map((item) => item.path);
win.data.selectedPaths = toggle ? [...new Set([...current, ...paths])] : paths;
} else {
win.data.selectedPaths = [entry.path];
win.data.selectionAnchorPath = entry.path;
}
} else if (toggle) {
if (current.has(entry.path)) {
current.delete(entry.path);
} else {
current.add(entry.path);
}
win.data.selectedPaths = [...current];
win.data.selectionAnchorPath = entry.path;
} else {
win.data.selectedPaths = [entry.path];
win.data.selectionAnchorPath = entry.path;
}
win.data.selectedPath = entry.path;
if (!win.data.selectedPaths.length) {
win.data.selectedPath = null;
win.data.selectionAnchorPath = null;
}
}
function reconcileExplorerSelection(win) {
const available = new Set((win.data.entries || []).map((entry) => entry.path));
win.data.selectedPaths = (win.data.selectedPaths || []).filter((path) => available.has(path));
if (!available.has(win.data.selectedPath)) {
win.data.selectedPath = win.data.selectedPaths[0] || null;
}
if (!available.has(win.data.selectionAnchorPath)) {
win.data.selectionAnchorPath = win.data.selectedPath;
}
if (state.selectedEntry && !available.has(state.selectedEntry.path) && state.focusedId === win.id) {
state.selectedEntry = null;
}
}
function selectedPathSet(win) {
return new Set(win?.data?.selectedPaths || (win?.data?.selectedPath ? [win.data.selectedPath] : []));
}
function selectedEntries(win) {
const paths = selectedPathSet(win);
return (win?.data?.entries || []).filter((entry) => paths.has(entry.path));
}
function shouldSuppressItemActivation() { function shouldSuppressItemActivation() {
if (Date.now() <= state.suppressItemActivationUntil) { if (Date.now() <= state.suppressItemActivationUntil) {
recordDebug('item.activation.suppressed', { until: state.suppressItemActivationUntil }); recordDebug('item.activation.suppressed', { until: state.suppressItemActivationUntil });
@@ -1161,8 +1224,11 @@ function focusWindow(win) {
} }
function updateSelectedRows() { function updateSelectedRows() {
for (const row of workspace.querySelectorAll('.file-row')) { for (const win of state.windows.filter((item) => item.kind === 'explorer')) {
row.classList.toggle('selected', row.dataset.path === state.selectedEntry?.path); const selected = selectedPathSet(win);
for (const row of workspace.querySelectorAll(`.window[data-window-id="${win.id}"] .file-row`)) {
row.classList.toggle('selected', selected.has(row.dataset.path));
}
} }
} }
@@ -1189,6 +1255,8 @@ function handleTypeAheadKey(event) {
event.preventDefault(); event.preventDefault();
state.typeAhead = { key, windowId: win.id, at: now }; state.typeAhead = { key, windowId: win.id, at: now };
win.data.selectedPath = match.path; win.data.selectedPath = match.path;
win.data.selectedPaths = [match.path];
win.data.selectionAnchorPath = match.path;
state.selectedEntry = match; state.selectedEntry = match;
focusWindow(win); focusWindow(win);
updateSelectedRows(); updateSelectedRows();
@@ -1231,10 +1299,12 @@ function updatePropertiesWindows() {
function startItemPointerDrag(event, win, entry) { function startItemPointerDrag(event, win, entry) {
if (event.button !== 0 || !entry) return; if (event.button !== 0 || !entry) return;
const items = dragItemsForEntry(win, entry);
recordDebug('pointer.start', { recordDebug('pointer.start', {
windowId: win.id, windowId: win.id,
path: entry.path, path: entry.path,
type: entry.type, type: entry.type,
count: items.length,
x: event.clientX, x: event.clientX,
y: event.clientY y: event.clientY
}); });
@@ -1243,6 +1313,8 @@ function startItemPointerDrag(event, win, entry) {
path: entry.path, path: entry.path,
name: entry.name, name: entry.name,
type: entry.type, type: entry.type,
items,
paths: items.map((item) => item.path),
startX: event.clientX, startX: event.clientX,
startY: event.clientY, startY: event.clientY,
active: false, active: false,
@@ -1271,8 +1343,8 @@ function updateItemPointerDrag(event) {
drag.active = true; drag.active = true;
event.preventDefault(); event.preventDefault();
document.body.classList.add('u-nav-dragging'); document.body.classList.add('u-nav-dragging');
markDraggingRow(drag.path, true); markDraggingRows(drag.paths, true);
markDropCandidates(drag.path, true); markDropCandidates(drag.paths, true);
updatePointerDropTarget(event); updatePointerDropTarget(event);
} }
@@ -1285,8 +1357,8 @@ async function finishItemPointerDrag(event, move, up) {
document.body.classList.remove('u-nav-dragging'); document.body.classList.remove('u-nav-dragging');
if (!drag) return; if (!drag) return;
markDraggingRow(drag.path, false); markDraggingRows(drag.paths, false);
markDropCandidates(drag.path, false); markDropCandidates(drag.paths, false);
state.pointerDrag = null; state.pointerDrag = null;
if (!drag.active) { if (!drag.active) {
@@ -1299,6 +1371,7 @@ async function finishItemPointerDrag(event, move, up) {
const target = findPointerDropTarget(event); const target = findPointerDropTarget(event);
recordDebug('pointer.finish', { recordDebug('pointer.finish', {
source: drag.path, source: drag.path,
sources: drag.paths,
target: target ? { windowId: target.windowId, path: target.path, rowPath: target.row?.dataset?.path || null } : null, target: target ? { windowId: target.windowId, path: target.path, rowPath: target.row?.dataset?.path || null } : null,
copy: event.altKey, copy: event.altKey,
x: event.clientX, x: event.clientX,
@@ -1309,26 +1382,52 @@ async function finishItemPointerDrag(event, move, up) {
} }
try { try {
const destination = joinPath(target.path, drag.name); const jobs = drag.items.map((item) => ({
item,
destination: joinPath(target.path, item.name)
}));
recordDebug('pointer.destination', { recordDebug('pointer.destination', {
source: drag.path, source: drag.path,
sources: drag.paths,
targetPath: target.path, targetPath: target.path,
destination, destinations: jobs.map((job) => job.destination),
targetIsRow: Boolean(target.row), targetIsRow: Boolean(target.row),
copy: event.altKey copy: event.altKey
}); });
if (destination === drag.path || target.path === drag.path) { validatePointerDropJobs(jobs, target.path);
throw new Error('Quelle und Ziel sind identisch.'); for (const job of jobs) {
await createJob(event.altKey ? 'copy' : 'move', job.item.path, job.destination, [drag.sourceWindowId, target.windowId]);
} }
if (drag.type === 'directory' && isPathInside(target.path, drag.path)) {
throw new Error('Ein Ordner kann nicht in sich selbst verschoben werden.');
}
await createJob(event.altKey ? 'copy' : 'move', drag.path, destination, [drag.sourceWindowId, target.windowId]);
} catch (error) { } catch (error) {
showError(error); showError(error);
} }
} }
function dragItemsForEntry(win, entry) {
const selected = selectedPathSet(win);
if (selected.has(entry.path)) {
const entries = selectedEntries(win);
if (entries.length) return entries;
}
return [entry];
}
function validatePointerDropJobs(jobs, targetPath) {
const destinations = new Set();
for (const { item, destination } of jobs) {
if (destinations.has(destination)) {
throw new Error('Mehrere Quellen würden am Ziel denselben Namen verwenden.');
}
destinations.add(destination);
if (destination === item.path || targetPath === item.path) {
throw new Error('Quelle und Ziel sind identisch.');
}
if (item.type === 'directory' && isPathInside(targetPath, item.path)) {
throw new Error('Ein Ordner kann nicht in sich selbst verschoben werden.');
}
}
}
function updatePointerDropTarget(event) { function updatePointerDropTarget(event) {
clearPointerDropTarget(); clearPointerDropTarget();
const target = findPointerDropTarget(event); const target = findPointerDropTarget(event);
@@ -1386,10 +1485,10 @@ function findPointerDropTarget(event) {
if (!candidateRow || candidateRow.dataset.type !== 'directory') { if (!candidateRow || candidateRow.dataset.type !== 'directory') {
continue; continue;
} }
if (candidateRow.dataset.path === drag?.path) { if (drag?.paths?.includes(candidateRow.dataset.path)) {
continue; continue;
} }
if (drag?.type === 'directory' && candidateRow.dataset.path.startsWith(`${drag.path.replace(/\/$/, '')}/`)) { if (!isValidPointerTargetPath(candidateRow.dataset.path, drag)) {
continue; continue;
} }
if (candidateRow.closest?.('.window') !== windowEl) { if (candidateRow.closest?.('.window') !== windowEl) {
@@ -1408,23 +1507,32 @@ function findPointerDropTarget(event) {
return { windowId: win.id, path: win.data.path, row: null }; return { windowId: win.id, path: win.data.path, row: null };
} }
function markDraggingRow(path, dragging) { function markDraggingRows(paths, dragging) {
const pathSet = new Set(paths || []);
for (const row of workspace.querySelectorAll('.file-row')) { for (const row of workspace.querySelectorAll('.file-row')) {
if (row.dataset.path === path) { if (pathSet.has(row.dataset.path)) {
row.classList.toggle('dragging', dragging); row.classList.toggle('dragging', dragging);
} }
} }
} }
function markDropCandidates(sourcePath, active) { function markDropCandidates(sourcePaths, active) {
const source = state.pointerDrag; const source = state.pointerDrag;
for (const row of workspace.querySelectorAll('.file-row[data-type="directory"]')) { for (const row of workspace.querySelectorAll('.file-row[data-type="directory"]')) {
const path = row.dataset.path || ''; const path = row.dataset.path || '';
const valid = path !== sourcePath && !(source?.type === 'directory' && path.startsWith(`${sourcePath.replace(/\/$/, '')}/`)); const valid = !sourcePaths.includes(path) && isValidPointerTargetPath(path, source);
row.classList.toggle('drop-candidate', active && valid); row.classList.toggle('drop-candidate', active && valid);
} }
} }
function isValidPointerTargetPath(path, drag = state.pointerDrag) {
if (!drag) return false;
return (drag.items || []).every((item) => {
if (path === item.path) return false;
return !(item.type === 'directory' && isPathInside(path, item.path));
});
}
function lockExplorerScroll(win, body) { function lockExplorerScroll(win, body) {
win.data.lockScroll = true; win.data.lockScroll = true;
win.data.lockScrollTop = body.scrollTop; win.data.lockScrollTop = body.scrollTop;
+2 -2
View File
@@ -1,7 +1,7 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "u-navigator"> <!ENTITY name "u-navigator">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.23.r036"> <!ENTITY version "2026.06.23.r037">
<!ENTITY package "&name;-&version;.tgz"> <!ENTITY package "&name;-&version;.tgz">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/u-navigator.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/u-navigator.plg">
<!ENTITY support "https://git.casaderoll.de/michael/Unraid-Navigator"> <!ENTITY support "https://git.casaderoll.de/michael/Unraid-Navigator">
@@ -24,7 +24,7 @@
<FILE Name="/boot/config/plugins/&name;/&package;"> <FILE Name="/boot/config/plugins/&name;/&package;">
<URL>https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;</URL> <URL>https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;</URL>
<MD5>5ddb77ea5e8b2d4d9ca48060a002c8c3</MD5> <MD5>6ae4016e68bf99a8cb0c64714b8cb647</MD5>
</FILE> </FILE>
<FILE Run="/bin/bash"> <FILE Run="/bin/bash">