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
+140 -32
View File
@@ -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;