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),