Add drag and drop to file manager

This commit is contained in:
2026-10-02 15:10:34 +02:00
parent 9a7ed0a347
commit f5b9f543c6
14 changed files with 432 additions and 466 deletions

View File

@@ -1,5 +1,5 @@
import { loading_finish, loading_start } from "lib/Loading";
import { fs_get_node, fs_encode_path, fs_split_path, fs_path_from_json } from "lib/FilesystemAPI.svelte";
import { loading_finish, loading_run, loading_start } from "lib/Loading";
import { fs_get_node, fs_encode_path, fs_split_path, fs_path_from_json, fs_rename } from "lib/FilesystemAPI.svelte";
import type { FSNode, FSPath, FSPermissions, FSContext } from "lib/FilesystemAPI.svelte";
export class FSNavigator {
@@ -251,6 +251,84 @@ export class FSNavigator {
// the reactivity
this.notify_subscribers()
}
// The selection is kept by path, so it survives reloads and navigating to
// other directories. The directory views look each entry up in it
selecting = false
selection = new Map<string, FSNode>()
last_selected = ""
// Leaving selection mode clears the selection
set_selecting = (on: boolean) => {
this.selecting = on
if (!on) {
this.selection.clear()
}
this.notify_subscribers()
}
// Toggles a node. With range, everything between it and the node which was
// toggled before it is selected. Hidden nodes are left out of the range when
// the view doesn't show them
select = (node: FSNode, range = false, hidden = true) => {
const from = range ? this.children.findIndex(c => c.path === this.last_selected) : -1
if (from !== -1) {
const to = this.children.indexOf(node)
for (let i = Math.min(from, to); i <= Math.max(from, to); i++) {
if (hidden || !this.children[i].is_hidden()) {
this.selection.set(this.children[i].path, this.children[i])
}
}
} else if (!this.selection.delete(node.path)) {
this.selection.set(node.path, node)
}
this.last_selected = node.path
this.set_selecting(true)
}
// A directory can't be moved into itself, and there has to be at least one
// node which is not in dir already
can_move = (dir: string) => {
let moves = false
for (const node of this.selection.values()) {
if ((dir + "/").startsWith(node.path + "/")) {
return false
}
moves ||= node.path !== dir + "/" + node.name
}
return moves
}
move_selection = async (dir: string) => {
try {
await loading_run(() => Promise.all(
[...this.selection.values()]
.filter(node => node.path !== dir + "/" + node.name)
.map(node => fs_rename(node.path, dir + "/" + node.name))
))
} catch (err: any) {
console.error(err)
alert("Move failed: " + err.message + " (" + err.value + ")")
}
this.set_selecting(false)
await this.reload()
}
// Dragging a node drags the whole selection. The drag itself only carries a
// marker, as the selection outlives the element which is being dragged when
// the user navigates during the drag
drag_start = (e: DragEvent, node: FSNode) => {
e.dataTransfer.setData(drag_type, node.path)
e.dataTransfer.effectAllowed = "move"
if (!this.selecting) {
// The drag started the selection, so it ends it as well
e.target.addEventListener("dragend", () => this.set_selecting(false), { once: true })
}
if (!this.selection.has(node.path)) {
this.select(node)
}
}
}
const sort_children = (children: FSNode[], field: string, asc: boolean) => {
@@ -287,7 +365,55 @@ const sort_children = (children: FSNode[], field: string, asc: boolean) => {
export let global_navigator = new FSNavigator(true)
const drag_type = "application/x-nova-selection"
const is_drag = (e: DragEvent) => e.dataTransfer.types.includes(drag_type)
// Moves the selection into a directory when it's dropped on the element. The
// node is a function because an action only gets its parameters once. Bookmarks
// have no type, the server refuses the move if one turns out to be a file
export const move_target = (
el: HTMLElement,
data: { nav: FSNavigator, node: () => { path: string, type?: string } },
) => {
const accepts = (e: DragEvent) => {
if (!is_drag(e) || data.node().type === "file" || !data.nav.can_move(data.node().path)) {
return false
}
e.preventDefault()
e.stopPropagation()
return true
}
el.addEventListener("dragover", e => {
if (accepts(e)) {
el.style.boxShadow = "var(--accent_color) 0 0 0 2px inset"
}
})
el.addEventListener("dragleave", () => el.style.boxShadow = "")
el.addEventListener("drop", e => {
el.style.boxShadow = ""
if (accepts(e)) {
data.nav.move_selection(data.node().path)
}
})
}
// Clicks the element when the selection is held over it, so the navigation
// buttons work during a drag
export const drag_click = (el: HTMLElement) => {
let timer: number
el.addEventListener("dragenter", e => {
if (is_drag(e)) {
clearTimeout(timer)
timer = window.setTimeout(() => el.click(), 500)
}
})
el.addEventListener("dragleave", e => {
// Moving onto a child of the element is not leaving it
if (!el.contains(e.relatedTarget as Node)) {
clearTimeout(timer)
}
})
}
export const path_link = (node: HTMLAnchorElement, data: { nav: FSNavigator, node: FSNode }) => {
// Set url