Add multi-select drag and drop
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
||||
set -eu
|
||||
|
||||
PLUGIN="u-navigator"
|
||||
VERSION="2026.06.23.r036"
|
||||
VERSION="2026.06.23.r037"
|
||||
PKG_DIR="packages"
|
||||
WORK_DIR=".plugin-build"
|
||||
PKG_NAME="${PLUGIN}-${VERSION}.tgz"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+140
-32
@@ -90,6 +90,8 @@ function openExplorer(initialPath = state.config?.roots?.[0]?.path) {
|
||||
path: initialPath,
|
||||
entries: [],
|
||||
selectedPath: null,
|
||||
selectedPaths: [],
|
||||
selectionAnchorPath: null,
|
||||
view: 'icons',
|
||||
loading: false,
|
||||
error: null
|
||||
@@ -271,7 +273,7 @@ function renderExplorer(body, win) {
|
||||
</div>
|
||||
<div class="drop-zone ${data.dragOver ? 'drag-over' : ''}">
|
||||
${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>
|
||||
`;
|
||||
|
||||
@@ -326,9 +328,9 @@ function renderExplorer(body, win) {
|
||||
lockExplorerScroll(win, body);
|
||||
startItemPointerDrag(event, win, entry);
|
||||
});
|
||||
row.addEventListener('click', () => {
|
||||
row.addEventListener('click', (event) => {
|
||||
if (shouldSuppressItemActivation()) return;
|
||||
selectEntry(win, entry, body);
|
||||
selectEntry(win, entry, body, event);
|
||||
});
|
||||
row.addEventListener('dblclick', () => {
|
||||
if (shouldSuppressItemActivation()) return;
|
||||
@@ -341,22 +343,22 @@ function renderExplorer(body, win) {
|
||||
row.addEventListener('contextmenu', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectEntry(win, entry);
|
||||
selectEntry(win, entry, null, event);
|
||||
showContextMenu(event, win, entry);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileEntries(entries, view) {
|
||||
function renderFileEntries(entries, view, selectedPaths = new Set()) {
|
||||
if (!entries.length) {
|
||||
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) => `
|
||||
<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>${entry.type}</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) => `
|
||||
<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="icon-name">${escapeHtml(entry.name)}</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)}`));
|
||||
win.data.path = result.path;
|
||||
win.data.entries = result.entries;
|
||||
reconcileExplorerSelection(win);
|
||||
} catch (error) {
|
||||
win.data.error = error.message;
|
||||
} 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) {
|
||||
state.suppressClickPath = null;
|
||||
restoreExplorerScroll(win, scrollBody);
|
||||
return;
|
||||
}
|
||||
win.data.selectedPath = entry.path;
|
||||
applyExplorerSelection(win, entry, event);
|
||||
state.selectedEntry = entry;
|
||||
focusWindow(win);
|
||||
updateSelectedRows();
|
||||
@@ -1140,6 +1143,66 @@ function selectEntry(win, entry, scrollBody = null) {
|
||||
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() {
|
||||
if (Date.now() <= state.suppressItemActivationUntil) {
|
||||
recordDebug('item.activation.suppressed', { until: state.suppressItemActivationUntil });
|
||||
@@ -1161,8 +1224,11 @@ function focusWindow(win) {
|
||||
}
|
||||
|
||||
function updateSelectedRows() {
|
||||
for (const row of workspace.querySelectorAll('.file-row')) {
|
||||
row.classList.toggle('selected', row.dataset.path === state.selectedEntry?.path);
|
||||
for (const win of state.windows.filter((item) => item.kind === 'explorer')) {
|
||||
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();
|
||||
state.typeAhead = { key, windowId: win.id, at: now };
|
||||
win.data.selectedPath = match.path;
|
||||
win.data.selectedPaths = [match.path];
|
||||
win.data.selectionAnchorPath = match.path;
|
||||
state.selectedEntry = match;
|
||||
focusWindow(win);
|
||||
updateSelectedRows();
|
||||
@@ -1231,10 +1299,12 @@ function updatePropertiesWindows() {
|
||||
function startItemPointerDrag(event, win, entry) {
|
||||
if (event.button !== 0 || !entry) return;
|
||||
|
||||
const items = dragItemsForEntry(win, entry);
|
||||
recordDebug('pointer.start', {
|
||||
windowId: win.id,
|
||||
path: entry.path,
|
||||
type: entry.type,
|
||||
count: items.length,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
});
|
||||
@@ -1243,6 +1313,8 @@ function startItemPointerDrag(event, win, entry) {
|
||||
path: entry.path,
|
||||
name: entry.name,
|
||||
type: entry.type,
|
||||
items,
|
||||
paths: items.map((item) => item.path),
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
active: false,
|
||||
@@ -1271,8 +1343,8 @@ function updateItemPointerDrag(event) {
|
||||
drag.active = true;
|
||||
event.preventDefault();
|
||||
document.body.classList.add('u-nav-dragging');
|
||||
markDraggingRow(drag.path, true);
|
||||
markDropCandidates(drag.path, true);
|
||||
markDraggingRows(drag.paths, true);
|
||||
markDropCandidates(drag.paths, true);
|
||||
updatePointerDropTarget(event);
|
||||
}
|
||||
|
||||
@@ -1285,8 +1357,8 @@ async function finishItemPointerDrag(event, move, up) {
|
||||
document.body.classList.remove('u-nav-dragging');
|
||||
if (!drag) return;
|
||||
|
||||
markDraggingRow(drag.path, false);
|
||||
markDropCandidates(drag.path, false);
|
||||
markDraggingRows(drag.paths, false);
|
||||
markDropCandidates(drag.paths, false);
|
||||
state.pointerDrag = null;
|
||||
|
||||
if (!drag.active) {
|
||||
@@ -1299,6 +1371,7 @@ async function finishItemPointerDrag(event, move, up) {
|
||||
const target = findPointerDropTarget(event);
|
||||
recordDebug('pointer.finish', {
|
||||
source: drag.path,
|
||||
sources: drag.paths,
|
||||
target: target ? { windowId: target.windowId, path: target.path, rowPath: target.row?.dataset?.path || null } : null,
|
||||
copy: event.altKey,
|
||||
x: event.clientX,
|
||||
@@ -1309,26 +1382,52 @@ async function finishItemPointerDrag(event, move, up) {
|
||||
}
|
||||
|
||||
try {
|
||||
const destination = joinPath(target.path, drag.name);
|
||||
const jobs = drag.items.map((item) => ({
|
||||
item,
|
||||
destination: joinPath(target.path, item.name)
|
||||
}));
|
||||
recordDebug('pointer.destination', {
|
||||
source: drag.path,
|
||||
sources: drag.paths,
|
||||
targetPath: target.path,
|
||||
destination,
|
||||
destinations: jobs.map((job) => job.destination),
|
||||
targetIsRow: Boolean(target.row),
|
||||
copy: event.altKey
|
||||
});
|
||||
if (destination === drag.path || target.path === drag.path) {
|
||||
throw new Error('Quelle und Ziel sind identisch.');
|
||||
validatePointerDropJobs(jobs, target.path);
|
||||
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) {
|
||||
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) {
|
||||
clearPointerDropTarget();
|
||||
const target = findPointerDropTarget(event);
|
||||
@@ -1386,10 +1485,10 @@ function findPointerDropTarget(event) {
|
||||
if (!candidateRow || candidateRow.dataset.type !== 'directory') {
|
||||
continue;
|
||||
}
|
||||
if (candidateRow.dataset.path === drag?.path) {
|
||||
if (drag?.paths?.includes(candidateRow.dataset.path)) {
|
||||
continue;
|
||||
}
|
||||
if (drag?.type === 'directory' && candidateRow.dataset.path.startsWith(`${drag.path.replace(/\/$/, '')}/`)) {
|
||||
if (!isValidPointerTargetPath(candidateRow.dataset.path, drag)) {
|
||||
continue;
|
||||
}
|
||||
if (candidateRow.closest?.('.window') !== windowEl) {
|
||||
@@ -1408,23 +1507,32 @@ function findPointerDropTarget(event) {
|
||||
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')) {
|
||||
if (row.dataset.path === path) {
|
||||
if (pathSet.has(row.dataset.path)) {
|
||||
row.classList.toggle('dragging', dragging);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function markDropCandidates(sourcePath, active) {
|
||||
function markDropCandidates(sourcePaths, active) {
|
||||
const source = state.pointerDrag;
|
||||
for (const row of workspace.querySelectorAll('.file-row[data-type="directory"]')) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
win.data.lockScroll = true;
|
||||
win.data.lockScrollTop = body.scrollTop;
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "u-navigator">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.06.23.r036">
|
||||
<!ENTITY version "2026.06.23.r037">
|
||||
<!ENTITY package "&name;-&version;.tgz">
|
||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/u-navigator.plg">
|
||||
<!ENTITY support "https://git.casaderoll.de/michael/Unraid-Navigator">
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<FILE Name="/boot/config/plugins/&name;/&package;">
|
||||
<URL>https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;</URL>
|
||||
<MD5>5ddb77ea5e8b2d4d9ca48060a002c8c3</MD5>
|
||||
<MD5>6ae4016e68bf99a8cb0c64714b8cb647</MD5>
|
||||
</FILE>
|
||||
|
||||
<FILE Run="/bin/bash">
|
||||
|
||||
Reference in New Issue
Block a user