Use pointer based internal drag and drop
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
PLUGIN="u-navigator"
|
PLUGIN="u-navigator"
|
||||||
VERSION="2026.06.23.r001"
|
VERSION="2026.06.23.r002"
|
||||||
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.
+134
-30
@@ -8,6 +8,8 @@ const state = {
|
|||||||
selectedEntry: null,
|
selectedEntry: null,
|
||||||
contextMenu: null,
|
contextMenu: null,
|
||||||
draggedEntry: null,
|
draggedEntry: null,
|
||||||
|
pointerDrag: null,
|
||||||
|
suppressClickPath: null,
|
||||||
jobs: new Map(),
|
jobs: new Map(),
|
||||||
nextWindowId: 1,
|
nextWindowId: 1,
|
||||||
zIndex: 20
|
zIndex: 20
|
||||||
@@ -233,41 +235,16 @@ function renderExplorer(body, win) {
|
|||||||
|
|
||||||
for (const row of body.querySelectorAll('.file-row')) {
|
for (const row of body.querySelectorAll('.file-row')) {
|
||||||
const entry = data.entries.find((item) => item.path === row.dataset.path);
|
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('click', () => selectEntry(win, entry, body));
|
||||||
row.addEventListener('dblclick', () => {
|
row.addEventListener('dblclick', () => {
|
||||||
if (entry.type === 'directory') {
|
if (entry.type === 'directory') {
|
||||||
loadExplorer(win, entry.path);
|
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) => {
|
row.addEventListener('contextmenu', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -283,7 +260,7 @@ function renderFileTable(entries) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rows = entries.map((entry) => `
|
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><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>
|
||||||
@@ -528,6 +505,11 @@ async function pollJob(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectEntry(win, entry, scrollBody = null) {
|
function selectEntry(win, entry, scrollBody = null) {
|
||||||
|
if (state.suppressClickPath === entry.path) {
|
||||||
|
state.suppressClickPath = null;
|
||||||
|
restoreExplorerScroll(win, scrollBody);
|
||||||
|
return;
|
||||||
|
}
|
||||||
win.data.selectedPath = entry.path;
|
win.data.selectedPath = entry.path;
|
||||||
state.selectedEntry = entry;
|
state.selectedEntry = entry;
|
||||||
focusWindow(win);
|
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) {
|
function lockExplorerScroll(win, body) {
|
||||||
win.data.lockScroll = true;
|
win.data.lockScroll = true;
|
||||||
win.data.lockScrollTop = body.scrollTop;
|
win.data.lockScrollTop = body.scrollTop;
|
||||||
@@ -695,6 +792,13 @@ function escapeAttr(value) {
|
|||||||
return escapeHtml(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) {
|
function clamp(value, min, max) {
|
||||||
return Math.max(min, Math.min(max, value));
|
return Math.max(min, Math.min(max, value));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,16 +229,30 @@
|
|||||||
|
|
||||||
.u-nav .file-row:hover,
|
.u-nav .file-row:hover,
|
||||||
.u-nav .file-row.selected,
|
.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);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.u-nav .file-row:hover td,
|
.u-nav .file-row:hover td,
|
||||||
.u-nav .file-row.selected 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;
|
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 {
|
.u-nav .name-cell {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "u-navigator">
|
<!ENTITY name "u-navigator">
|
||||||
<!ENTITY author "michael">
|
<!ENTITY author "michael">
|
||||||
<!ENTITY version "2026.06.23.r001">
|
<!ENTITY version "2026.06.23.r002">
|
||||||
<!ENTITY package "&name;-&version;.tgz">
|
<!ENTITY package "&name;-&version;.tgz">
|
||||||
]>
|
]>
|
||||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/u-navigator.plg">
|
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/u-navigator.plg">
|
||||||
@@ -14,7 +14,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>54c2deddb4643c26d51b86aca896b52d</MD5>
|
<MD5>0a9e56a6a4ddf7648388b25a3dedb264</MD5>
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<FILE Run="/bin/bash">
|
<FILE Run="/bin/bash">
|
||||||
|
|||||||
Reference in New Issue
Block a user