drag files on folders to move them

Massimo Melina committed Apr 4, 2025 at 20:01 UTC 18aa884416936bd00e319994f64541bdc4a9bb46
7 files changed +100 -25
frontend/src/Breadcrumbs.ts
+2
@@ -7,6 +7,7 @@ import { DirEntry, state, useSnapState } from './state'
7 import { usePath, reloadList } from './useFetchList'
8 import { openFileMenu } from './fileMenu'
9 import { createFolder } from './upload'
10 +import { dragFilesDestination } from './dragFiles'
11 import i18n from './i18n'
12 const { useI18N } = i18n
13
@@ -43,6 +44,7 @@ function Breadcrumb({ path, label, current, ...rest }: { current?: boolean, path
44 return h(Link, {
45 className: 'breadcrumb',
46 to: path || '/',
47 + ...!current && dragFilesDestination, // we don't really know if this folder allows upload, but in the worst case the user will get an error
48 ...rest,
49 async onClick(ev) {
50 if (!current) return
frontend/src/BrowseFiles.ts
+10 -2
@@ -20,6 +20,7 @@ import { makeOnClickOpen, openFileMenu } from './fileMenu'
20 import { ClipBar } from './clip'
21 import { fileShow, getShowComponent } from './show'
22 import i18n from './i18n'
23 +import { dragFilesSource } from './dragFiles'
24 const { t, useI18N } = i18n
25
26 export const MISSING_PERM = "Missing permission"
@@ -300,6 +301,7 @@ const Entry = ({ entry, midnight, separator }: EntryProps) => {
301 const showingButton = !file_menu_on_link || isFolder && !hasHover
302 const ariaId = useId()
303 const ariaProps = { id: ariaId, 'aria-label': prefix(name + ', ', isFolder ? t`Folder` : entry.web ? t`Web page` : isLink ? t`Link` : '') }
304 + const dragProps = dragFilesSource(entry)
305 return h(CustomCode, {
306 name: 'entry',
307 entry,
@@ -318,14 +320,20 @@ const Entry = ({ entry, midnight, separator }: EntryProps) => {
320 h('span', { className: 'link-wrapper' }, // container to handle mouse over for both children
321 // we treat webpages as folders, with menu to comment
322 isFolder ? h(Fragment, {}, // internal navigation, use Link component
321 - h(Link, { to: uri, reloadDocument: entry.web, onClick, ...ariaProps }, // without reloadDocument, once you enter the web page, the back button won't bring you back to the frontend
323 + h(Link, {
324 + to: uri,
325 + onClick,
326 + reloadDocument: entry.web, // without reloadDocument, once you enter the web page, the back button won't bring you back to the frontend
327 + ...dragProps,
328 + ...ariaProps,
329 + },
330 ico, h('span', { className: 'container-folder' }, containerName), name), // don't use name, as we want to include whole path in case of search
331 // popup button is here to be able to detect link-wrapper:hover
332 file_menu_on_link && !showingButton && h('button', {
333 className: 'popup-menu-button',
334 onClick: fileMenu
335 }, hIcon('menu'), t`Menu`)
328 - ) : h('a', { href: uri, onClick, target: entry.target, ...ariaProps },
336 + ) : h('a', { href: uri, onClick, target: entry.target, ...ariaProps, ...dragProps },
337 ico, h('span', { className: 'container-folder' }, containerName), name ),
338 ),
339 h(CustomCode, { name: 'afterEntryName', entry }),
frontend/src/clip.ts
+24 -22
@@ -23,13 +23,13 @@ export function ClipBar() {
23 h(Btn, { label: t('clipboard', { content: t('n_items', { n: clip.length }, "{n,plural, one{# item} other{# items}}"), }, `Clipboard ({content})`),
24 onClick: show, style: { flex: 1 } }),
25 h(Btn, { label: t`Paste`, icon: 'paste', onClick: paste, disabled: here === there || !props?.can_upload }),
26 - h(Btn, { label: t`Cancel clipboard`, icon: 'close', onClick: cancel }),
26 + h(Btn, { label: t`Cancel clipboard`, icon: 'close', onClick: emptyIt }),
27 h(Btn, { label: t('to_clipboard_source', "Back to source folder"), icon: 'parent', onClick: goBack, disabled: here === there,
28 tooltip: t('to_clipboard_source_tooltip', "Go to the folder where the clipboard contents are located"),
29 }),
30 )
31
32 - function cancel() {
32 + function emptyIt() {
33 cut([])
34 }
35
@@ -44,27 +44,10 @@ export function ClipBar() {
44 ))
45 }
46
47 - function paste() {
47 + async function paste() {
48 if (hfsEvent('paste', { from: state.clip, to: here }).isDefaultPrevented()) return
49 - return apiCall('move_files', {
50 - uri_from: clip.map(x => x.uri),
51 - uri_to: here,
52 - }).then(res => {
53 - const bad = _.sumBy(res.errors, x => x ? 1 : 0)
54 - const msg = t(['move_results', 'good_bad'], { bad, good: clip.length - bad }, "{good} moved{bad,plural, =0{} other{, # failed}}")
55 - if (!bad)
56 - toast(msg, 'success')
57 - else
58 - alertDialog(h(Fragment, {},
59 - msg,
60 - h('ul', {}, res.errors.map(((e: any, i: number) => {
61 - e = xlate(e, HTTP_MESSAGES)
62 - return e && h('li', {}, clip[i].name + ': ' + e)
63 - }))),
64 - ), 'warning')
65 - cancel()
66 - reloadList()
67 - }, alertDialog)
49 + if (await moveFiles(clip.map(x => x.uri), here))
50 + emptyIt()
51 }
52 }
53
@@ -72,4 +55,23 @@ export function cut(files: DirList) {
55 state.clip = files
56 if (files.length)
57 return toast(t('after_cut', "Your selection is now in the clipboard.\nGo to destination folder to paste."), 'info')
58 +}
59 +
60 +export function moveFiles(uri_from: string[], uri_to: string) {
61 + return apiCall('move_files', { uri_from, uri_to }).then(res => {
62 + const bad = _.sumBy(res.errors, x => x ? 1 : 0)
63 + const msg = t(['move_results', 'good_bad'], { bad, good: uri_from.length - bad }, "{good} moved{bad,plural, =0{} other{, # failed}}")
64 + if (!bad)
65 + toast(msg, 'success')
66 + else
67 + alertDialog(h(Fragment, {},
68 + msg,
69 + h('ul', {}, res.errors.map(((e: any, i: number) => {
70 + e = xlate(e, HTTP_MESSAGES)
71 + return e && h('li', {}, decodeURI(uri_from[i]) + ': ' + e)
72 + }))),
73 + ), 'warning')
74 + reloadList()
75 + return true
76 + }, e => void alertDialog(e))
77 }
\ No newline at end of file
frontend/src/dragFiles.ts new
+56
@@ -0,0 +1,56 @@
1 +import { DragEvent } from 'react'
2 +import { moveFiles } from './clip'
3 +import { DirEntry } from './state'
4 +
5 +let entry = '' // dataTransfer.getData is not available onDragOver, so we use this var to keep track
6 +let accept = false
7 +let classedEl: HTMLElement | undefined
8 +const className = 'drop-over'
9 +
10 +export const dragFilesSource = (de: DirEntry) => de.canDelete() ? {
11 + draggable: true,
12 + onDragStart(ev: DragEvent) {
13 + entry = (ev.target as HTMLElement).getAttribute('href') || ''
14 + },
15 + ...de.canUpload() && dragFilesDestination,
16 +} : { draggable: false} // avoid showing translucent dom elements, that is the default behavior when dragging
17 +
18 +export const dragFilesDestination = {
19 + onDragOver(ev: DragEvent) {
20 + if (!accept) return
21 + ev.preventDefault()
22 + ev.stopPropagation()
23 + ev.dataTransfer.dropEffect = 'move' // on most browser this just avoids the "+" icon of the 'copy' operation
24 + },
25 + onDrop(ev: DragEvent) {
26 + classedEl?.classList.remove(className)
27 + const el = ev.currentTarget as HTMLElement
28 + const src = entry
29 + if (!src) return
30 + const dst = el.getAttribute('href') || '/'
31 + if (src === dst) return
32 + ev.preventDefault()
33 + void moveFiles([src], dst)
34 + },
35 + onDragEnter(ev: DragEvent) { // we "accept" here and in dropOver, but this is fired first, so we calculate it here
36 + accept = false
37 + const src = entry
38 + if (!src) return
39 + const dst = (ev.currentTarget as HTMLElement).getAttribute('href') || '/'
40 + if (src === dst) return
41 + accept = true
42 + const el = ev.currentTarget as HTMLElement
43 + if (el.tagName !== 'A') return
44 + classedEl?.classList.remove(className)
45 + classedEl = el
46 + el.classList.add(className) // manipulating the dom is a risk with react, and would cause problems if React is changing classes in the meantime, but for now this is not the case, so we keep the code simpler
47 + },
48 + onDragLeave(ev: DragEvent) {
49 + if (ev.relatedTarget && ev.currentTarget.contains(ev.relatedTarget as any)) return // with the nested dom (SPAN in A) we can get a second enter before the leave of the previous, and getting the correct behavior was actually empirical: test thoroughly for any change
50 + if (!accept) return
51 + const el = ev.currentTarget as HTMLElement
52 + if (el !== classedEl) return
53 + if (el.tagName !== 'A') return
54 + classedEl?.classList.remove(className)
55 + },
56 +}
\ No newline at end of file
frontend/src/index.scss
+3
@@ -204,6 +204,9 @@ kbd {
204 white-space: nowrap;
205 margin-right: .5em;
206 }
207 +.drop-over {
208 + box-shadow: 0 0 .3em .3em var(--warning);
209 +}
210
211 .before-sliding {
212 width: 0 !important;
frontend/src/state.ts
+3
@@ -156,6 +156,9 @@ export class DirEntry implements ServerDirEntry {
156 canDelete() {
157 return this.p?.includes('D') || state.props?.can_delete && !this.p?.includes('d')
158 }
159 + canUpload() {
160 + return this.isFolder && (this.p?.includes('U') || state.props?.can_upload && !this.p?.includes('u'))
161 + }
162 canSelect() {
163 if (this.url) return false
164 return this.canArchive() || this.canDelete() // selection is used only by zip and delete, but consider custom logic from plugins
src/api.get_file_list.ts
+2 -1
@@ -125,12 +125,13 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, c, onl
125 : ''
126 const pd = Boolean(can_delete) === hasPermission(node, 'can_delete', ctx) ? '' : can_delete ? 'd' : 'D'
127 const pa = Boolean(can_archive) === hasPermission(node, 'can_archive', ctx) ? '' : can_archive ? 'a' : 'A'
128 + const pu = !isFolder || Boolean(can_upload) === hasPermission(node, 'can_upload', ctx) ? '' : can_upload ? 'u' : 'U'
129 return {
130 n: name + (isFolder ? '/' : ''),
131 c: st?.birthtime,
132 m: !st || Math.abs(st.mtimeMs - st.birthtimeMs) < 1000 ? undefined : st.mtime,
133 s: isFolder ? undefined : st?.size,
133 - p: (pr + pl + pd + pa) || undefined,
134 + p: (pr + pl + pd + pa + pu) || undefined,
135 order: node.order,
136 comment: node.comment ?? await getCommentFor(source),
137 icon: getNodeIcon(node),