From 383f640920ef0e704a90444c58f0f8bc855d08f3 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 20 Jul 2026 23:11:14 +0200 Subject: [PATCH] feat(drag&drop): add support of copy or move --- .../src/routes/files/[...path]/+page.svelte | 71 ++++++++++++++++++- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 684b66b8..4104f807 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -34,6 +34,7 @@ import { addFavorite, removeFavorite } from '$lib/api/endpoints/favorites'; import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi'; import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music'; + import { copyFiles, copyFolders } from '$lib/api/endpoints/batch'; import { apiFetch } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; import { countHidden, filterDotfiles } from '$lib/utils/dotfileFilter'; @@ -1108,6 +1109,33 @@ const CRUMB_HOME_ID = '__home__'; let dropCrumbId = $state(null); + // Copy-vs-move on drop. + // + // Cursor visual: `effectAllowed = 'copyMove'` set at dragstart AND + // the dragover handlers touching only `preventDefault()` (no + // `dropEffect` write) leaves the browser free to negotiate the + // cursor glyph from OS-native modifier keys (macOS Option, Win/Linux + // Ctrl) — arrow-with-plus for copy, plain arrow for move. + // + // Routing: at DROP time we read the modifier keys DIRECTLY on the + // event (DragEvent extends MouseEvent, so `altKey`/`metaKey`/`ctrlKey` + // are all live). This is deliberately independent of + // `dataTransfer.dropEffect` — that value isn't reliably updated by + // every browser for JS-initiated drags, but the raw modifier bits + // on the drop event ARE. One source of truth, one path. + // + // OS convention: + // * macOS: ⌥ Option (altKey) → copy — Finder convention. ⌘ + // (metaKey) accepted too as a forgiving fallback. + // * Windows / Linux: Ctrl (ctrlKey) → copy — Explorer / Nautilus + // convention. + const IS_MAC = + typeof navigator !== 'undefined' && + /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || ''); + function isCopyDrop(e: DragEvent): boolean { + return IS_MAC ? e.altKey || e.metaKey : e.ctrlKey; + } + /** * Begin dragging an item. When the dragged row is part of the current * selection, the whole selection travels (mirrors ui.js' multi-item drag); @@ -1118,7 +1146,13 @@ selected.has(id) && selected.size > 1 ? selectionTargets() : [{ id, name, kind }]; e.dataTransfer?.setData(DRAG_TYPE, JSON.stringify(items)); if (e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move'; + // `copyMove` advertises both operations; the drop-target's + // `dropEffect` (set on dragover by our handlers based on + // modifier key) picks the effective operation and the OS + // cursor reflects it (arrow-with-plus for copy, plain for + // move). Was `'move'`, which forced the cursor to always + // show move even when Ctrl/Cmd was held. + e.dataTransfer.effectAllowed = 'copyMove'; // Custom drag ghost for both single and multi-item drags — // consistent UX. The `.dragged-items-badge` always shows // the count (reads "1" on a single-item drag) so the user @@ -1232,18 +1266,46 @@ await reload(); } + /** + * Copy variant of `moveInto` used when the drop happens with the copy + * modifier held (Ctrl on Win/Linux, ⌘ on macOS). Files and folders go + * through the batch copy endpoints (`copyFiles` / `copyFolders`) — + * there's no per-item copy in the REST API today, only batch. + */ + async function copyInto(targetFolderId: string, e: DragEvent) { + const items = dragPayload(e).filter((it) => it.id !== targetFolderId); + if (items.length === 0) return; + const fileIds = items.filter((it) => it.kind === 'file').map((it) => it.id); + const folderIds = items.filter((it) => it.kind === 'folder').map((it) => it.id); + try { + // Two batch calls in parallel — the endpoints are independent + // and the aggregate error surface is a single toast anyway. + await Promise.all([ + copyFiles(fileIds, targetFolderId), + copyFolders(folderIds, targetFolderId) + ]); + } catch (err) { + errorToast(err); + return; + } + clearSelection(); + await reloadAndTrackNew(); + } + function onFolderDrop(e: DragEvent, folder: FolderItem) { if (!e.dataTransfer?.types.includes(DRAG_TYPE)) return; // external file drop → page dropzone e.preventDefault(); e.stopPropagation(); dropFolderId = null; - void moveInto(folder.id, e); + if (isCopyDrop(e)) void copyInto(folder.id, e); + else void moveInto(folder.id, e); } function onCrumbDrop(e: DragEvent, folderId: string) { if (!e.dataTransfer?.types.includes(DRAG_TYPE)) return; e.preventDefault(); - void moveInto(folderId, e); + if (isCopyDrop(e)) void copyInto(folderId, e); + else void moveInto(folderId, e); } // ── Right-click context menu ───────────────────────────────────────────── @@ -1605,6 +1667,9 @@ function rlOnItemDragOver(e: DragEvent, item: FileItem | FolderItem) { if (isFile(item)) return; if (e.dataTransfer?.types.includes(DRAG_TYPE)) { + // preventDefault is what accepts the drop. Leaving + // `dropEffect` untouched lets the browser negotiate it + // from OS-native modifier keys. e.preventDefault(); dropFolderId = item.id; }