Use pointer based internal drag and drop
This commit is contained in:
+134
-30
@@ -8,6 +8,8 @@ const state = {
|
||||
selectedEntry: null,
|
||||
contextMenu: null,
|
||||
draggedEntry: null,
|
||||
pointerDrag: null,
|
||||
suppressClickPath: null,
|
||||
jobs: new Map(),
|
||||
nextWindowId: 1,
|
||||
zIndex: 20
|
||||
@@ -233,41 +235,16 @@ function renderExplorer(body, win) {
|
||||
|
||||
for (const row of body.querySelectorAll('.file-row')) {
|
||||
const entry = data.entries.find((item) => item.path === row.dataset.path);
|
||||
row.addEventListener('pointerdown', () => lockExplorerScroll(win, body));
|
||||
row.addEventListener('pointerdown', (event) => {
|
||||
lockExplorerScroll(win, body);
|
||||
startItemPointerDrag(event, win, entry);
|
||||
});
|
||||
row.addEventListener('click', () => selectEntry(win, entry, body));
|
||||
row.addEventListener('dblclick', () => {
|
||||
if (entry.type === 'directory') {
|
||||
loadExplorer(win, entry.path);
|
||||
}
|
||||
});
|
||||
row.addEventListener('dragstart', (event) => {
|
||||
state.draggedEntry = { path: entry.path, name: entry.name, sourceWindowId: win.id };
|
||||
setEffectAllowed(event);
|
||||
row.classList.add('dragging');
|
||||
});
|
||||
row.addEventListener('dragend', () => {
|
||||
row.classList.remove('dragging');
|
||||
setTimeout(() => {
|
||||
state.draggedEntry = null;
|
||||
}, 0);
|
||||
});
|
||||
row.addEventListener('dragover', (event) => {
|
||||
if (entry.type === 'directory') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDropEffect(event, event.altKey ? 'copy' : 'move');
|
||||
}
|
||||
});
|
||||
row.addEventListener('drop', async (event) => {
|
||||
if (entry.type !== 'directory') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await handleDrop(event, win, entry.path);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
});
|
||||
row.addEventListener('contextmenu', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -283,7 +260,7 @@ function renderFileTable(entries) {
|
||||
}
|
||||
|
||||
const rows = entries.map((entry) => `
|
||||
<tr class="file-row ${entry.path === state.selectedEntry?.path ? 'selected' : ''}" draggable="true" data-path="${escapeAttr(entry.path)}">
|
||||
<tr class="file-row ${entry.path === state.selectedEntry?.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>
|
||||
@@ -528,6 +505,11 @@ async function pollJob(id) {
|
||||
}
|
||||
|
||||
function selectEntry(win, entry, scrollBody = null) {
|
||||
if (state.suppressClickPath === entry.path) {
|
||||
state.suppressClickPath = null;
|
||||
restoreExplorerScroll(win, scrollBody);
|
||||
return;
|
||||
}
|
||||
win.data.selectedPath = entry.path;
|
||||
state.selectedEntry = entry;
|
||||
focusWindow(win);
|
||||
@@ -563,6 +545,121 @@ function updatePropertiesWindows() {
|
||||
}
|
||||
}
|
||||
|
||||
function startItemPointerDrag(event, win, entry) {
|
||||
if (event.button !== 0) return;
|
||||
|
||||
state.pointerDrag = {
|
||||
sourceWindowId: win.id,
|
||||
path: entry.path,
|
||||
name: entry.name,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
active: false,
|
||||
targetWindowId: null,
|
||||
targetPath: null
|
||||
};
|
||||
|
||||
const move = (moveEvent) => updateItemPointerDrag(moveEvent);
|
||||
const up = (upEvent) => finishItemPointerDrag(upEvent, move, up);
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', up, { once: true });
|
||||
}
|
||||
|
||||
function updateItemPointerDrag(event) {
|
||||
const drag = state.pointerDrag;
|
||||
if (!drag) return;
|
||||
|
||||
const distance = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY);
|
||||
if (!drag.active && distance < 6) {
|
||||
return;
|
||||
}
|
||||
|
||||
drag.active = true;
|
||||
event.preventDefault();
|
||||
document.body.classList.add('u-nav-dragging');
|
||||
markDraggingRow(drag.path, true);
|
||||
updatePointerDropTarget(event);
|
||||
}
|
||||
|
||||
async function finishItemPointerDrag(event, move, up) {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', up);
|
||||
|
||||
const drag = state.pointerDrag;
|
||||
clearPointerDropTarget();
|
||||
document.body.classList.remove('u-nav-dragging');
|
||||
if (!drag) return;
|
||||
|
||||
markDraggingRow(drag.path, false);
|
||||
state.pointerDrag = null;
|
||||
|
||||
if (!drag.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.suppressClickPath = drag.path;
|
||||
const target = findPointerDropTarget(event);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const destination = `${target.path.replace(/\/$/, '')}/${drag.name}`;
|
||||
if (destination === drag.path) {
|
||||
throw new Error('Quelle und Ziel sind identisch.');
|
||||
}
|
||||
await createJob(event.altKey ? 'copy' : 'move', drag.path, destination);
|
||||
await reloadExplorerWindows([drag.sourceWindowId, target.windowId]);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePointerDropTarget(event) {
|
||||
clearPointerDropTarget();
|
||||
const target = findPointerDropTarget(event);
|
||||
if (!target) return;
|
||||
|
||||
state.pointerDrag.targetWindowId = target.windowId;
|
||||
state.pointerDrag.targetPath = target.path;
|
||||
const windowEl = workspace.querySelector(`.window[data-window-id="${target.windowId}"]`);
|
||||
windowEl?.querySelector('.drop-zone')?.classList.add('drag-over');
|
||||
if (target.row) {
|
||||
target.row.classList.add('drop-target');
|
||||
}
|
||||
}
|
||||
|
||||
function clearPointerDropTarget() {
|
||||
for (const item of workspace.querySelectorAll('.drop-zone.drag-over')) {
|
||||
item.classList.remove('drag-over');
|
||||
}
|
||||
for (const item of workspace.querySelectorAll('.file-row.drop-target')) {
|
||||
item.classList.remove('drop-target');
|
||||
}
|
||||
}
|
||||
|
||||
function findPointerDropTarget(event) {
|
||||
const element = document.elementFromPoint(event.clientX, event.clientY);
|
||||
const windowEl = element?.closest?.('.window');
|
||||
if (!windowEl) return null;
|
||||
|
||||
const win = state.windows.find((item) => item.id === windowEl.dataset.windowId);
|
||||
if (!win || win.kind !== 'explorer') return null;
|
||||
|
||||
const row = element.closest?.('.file-row');
|
||||
if (row?.dataset.type === 'directory') {
|
||||
return { windowId: win.id, path: row.dataset.path, row };
|
||||
}
|
||||
|
||||
return { windowId: win.id, path: win.data.path, row: null };
|
||||
}
|
||||
|
||||
function markDraggingRow(path, dragging) {
|
||||
for (const row of workspace.querySelectorAll(`.file-row[data-path="${cssEscape(path)}"]`)) {
|
||||
row.classList.toggle('dragging', dragging);
|
||||
}
|
||||
}
|
||||
|
||||
function lockExplorerScroll(win, body) {
|
||||
win.data.lockScroll = true;
|
||||
win.data.lockScrollTop = body.scrollTop;
|
||||
@@ -695,6 +792,13 @@ function escapeAttr(value) {
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
if (window.CSS?.escape) {
|
||||
return CSS.escape(String(value));
|
||||
}
|
||||
return String(value).replace(/["\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
@@ -229,16 +229,30 @@
|
||||
|
||||
.u-nav .file-row:hover,
|
||||
.u-nav .file-row.selected,
|
||||
.u-nav .file-row.dragging {
|
||||
.u-nav .file-row.dragging,
|
||||
.u-nav .file-row.drop-target {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.u-nav .file-row:hover td,
|
||||
.u-nav .file-row.selected td,
|
||||
.u-nav .file-row.dragging td {
|
||||
.u-nav .file-row.dragging td,
|
||||
.u-nav .file-row.drop-target td {
|
||||
background: var(--surface-2) !important;
|
||||
}
|
||||
|
||||
.u-nav .file-row {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.u-nav .file-row.dragging {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.u-nav-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.u-nav .name-cell {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user