fix: files tab drag and drop (#6325)

* fix: files drag drop

* fix: standardize drag and drop + fix files tab permissions
This commit is contained in:
Calum H.
2026-06-08 18:03:30 +01:00
committed by GitHub
parent 9729737d7d
commit 926c72de42
10 changed files with 312 additions and 41 deletions
+2
View File
@@ -1,6 +1,7 @@
import type { AbstractPopupNotificationManager, AbstractWebNotificationManager } from '@modrinth/ui'
import { setupCreationModal } from './setup/creation-modal'
import { setupFileDropProvider } from './setup/file-drop'
import { setupFilePickerProvider } from './setup/file-picker'
import { setupInstanceImportProvider } from './setup/instance-import'
import { setupTagsProvider } from './setup/tags'
@@ -10,6 +11,7 @@ export function setupProviders(
popupNotificationManager: AbstractPopupNotificationManager,
) {
setupTagsProvider(notificationManager)
setupFileDropProvider()
setupFilePickerProvider()
setupInstanceImportProvider(notificationManager)
@@ -0,0 +1,62 @@
import { provideFileDrop } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import type { DragDropEvent } from '@tauri-apps/api/webview'
import { getCurrentWebview } from '@tauri-apps/api/webview'
function getFileName(path: string) {
return path.split(/[\\/]/).pop() || 'file'
}
function toLogicalPosition(position: { x: number; y: number }) {
const scale = window.devicePixelRatio || 1
return {
x: position.x / scale,
y: position.y / scale,
}
}
async function readDraggedFile(path: string) {
const data = await invoke<number[]>('plugin:files|file_read_dragged_file', { path })
return new Uint8Array(data)
}
export function setupFileDropProvider() {
let nativeFileDropPaths: string[] = []
provideFileDrop({
async listenNativeFileDrop(handler) {
return await getCurrentWebview().onDragDropEvent((event: { payload: DragDropEvent }) => {
const payload = event.payload
if (payload.type === 'leave') {
nativeFileDropPaths = []
void handler({
type: 'leave',
paths: [],
position: { x: 0, y: 0 },
})
return
}
if (payload.type === 'enter' || payload.type === 'drop') {
nativeFileDropPaths = payload.paths
}
void handler({
type: payload.type,
paths: nativeFileDropPaths,
position: toLogicalPosition(payload.position),
})
if (payload.type === 'drop') {
nativeFileDropPaths = []
}
})
},
async createFilesFromNativePaths(paths) {
return await Promise.all(
paths.map(async (path) => new File([await readDraggedFile(path)], getFileName(path))),
)
},
})
}
+5 -1
View File
@@ -270,7 +270,11 @@ fn main() {
.plugin(
"files",
InlinedPlugin::new()
.commands(&["file_extract_zip", "file_save_as"])
.commands(&[
"file_extract_zip",
"file_save_as",
"file_read_dragged_file",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
+14
View File
@@ -11,6 +11,7 @@ pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![
file_extract_zip,
file_save_as,
file_read_dragged_file,
])
.build()
}
@@ -21,6 +22,19 @@ pub struct ExtractDryRunResult {
conflicting_files: Vec<String>,
}
#[tauri::command]
pub async fn file_read_dragged_file(path: String) -> Result<Vec<u8>> {
let metadata = tokio::fs::metadata(&path).await?;
if !metadata.is_file() {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"Dropped path is not a file".to_string(),
))
.into());
}
Ok(tokio::fs::read(path).await?)
}
#[tauri::command]
pub async fn file_extract_zip(
instance_path: &str,
+177
View File
@@ -0,0 +1,177 @@
import type { ComputedRef, Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, unref } from 'vue'
import type { NativeFileDropEvent } from '#ui/providers/file-drop'
import { injectFileDrop } from '#ui/providers/file-drop'
type MaybeRef<T> = T | Ref<T> | ComputedRef<T>
export interface UseFileDropTargetOptions {
target: Ref<HTMLElement | null | undefined>
disabled?: MaybeRef<boolean>
onFiles: (files: File[]) => void | Promise<void>
onError?: (error: unknown) => void
}
function isFileDrag(event: DragEvent) {
const dataTransfer = event.dataTransfer
if (!dataTransfer) return false
if (Array.from(dataTransfer.types).includes('Files')) return true
return Array.from(dataTransfer.items ?? []).some((item) => item.kind === 'file')
}
function getDroppedFiles(event: DragEvent) {
const files = Array.from(event.dataTransfer?.files ?? [])
if (files.length > 0) return files
return Array.from(event.dataTransfer?.items ?? [])
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null)
}
export function useFileDropTarget(options: UseFileDropTargetOptions) {
const fileDrop = injectFileDrop(null)
const domDragCounter = ref(0)
const domDragging = ref(false)
const nativeDragging = ref(false)
const disabled = computed(() => unref(options.disabled) ?? false)
const isDragging = computed(() => domDragging.value || nativeDragging.value)
function resetDomDrag() {
domDragCounter.value = 0
domDragging.value = false
}
function isPositionOverTarget(position: NativeFileDropEvent['position']) {
const element = options.target.value
if (!element) return false
const rect = element.getBoundingClientRect()
return (
position.x >= rect.left &&
position.x <= rect.right &&
position.y >= rect.top &&
position.y <= rect.bottom
)
}
function canHandleNativeFileDrop(event: NativeFileDropEvent) {
return event.paths.length > 0 && !disabled.value && isPositionOverTarget(event.position)
}
async function handleNativeFileDrop(event: NativeFileDropEvent) {
if (event.type === 'leave') {
nativeDragging.value = false
return
}
const canDrop = canHandleNativeFileDrop(event)
if (event.type === 'enter' || event.type === 'over') {
nativeDragging.value = canDrop
return
}
nativeDragging.value = false
if (!canDrop || !fileDrop) return
try {
const files = await fileDrop.createFilesFromNativePaths(event.paths)
await options.onFiles(files)
} catch (error) {
options.onError?.(error)
}
}
function handleDragEnter(event: DragEvent) {
if (disabled.value || !isFileDrag(event)) return
event.preventDefault()
domDragCounter.value++
domDragging.value = true
}
function handleDragOver(event: DragEvent) {
if (disabled.value || !isFileDrag(event)) return
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy'
}
}
function handleDragLeave(event: DragEvent) {
if (!domDragging.value) return
event.preventDefault()
domDragCounter.value--
if (domDragCounter.value <= 0) {
resetDomDrag()
}
}
async function handleDrop(event: DragEvent) {
if (!domDragging.value && !isFileDrag(event)) return
event.preventDefault()
resetDomDrag()
if (disabled.value) return
const files = getDroppedFiles(event)
if (files.length === 0) return
try {
await options.onFiles(files)
} catch (error) {
options.onError?.(error)
}
}
let nativeFileDropUnlisten: (() => void) | null = null
let unmounted = false
async function setupNativeFileDrop() {
if (!fileDrop) return
let unlisten: () => void
try {
unlisten = await fileDrop.listenNativeFileDrop(handleNativeFileDrop)
} catch {
return
}
if (unmounted) {
unlisten()
return
}
nativeFileDropUnlisten = unlisten
}
onMounted(() => {
void setupNativeFileDrop()
})
onUnmounted(() => {
unmounted = true
nativeDragging.value = false
resetDomDrag()
if (nativeFileDropUnlisten) {
nativeFileDropUnlisten()
nativeFileDropUnlisten = null
}
})
return {
isDragging,
dropTargetProps: {
onDragenter: handleDragEnter,
onDragover: handleDragOver,
onDragleave: handleDragLeave,
onDrop: handleDrop,
},
}
}
+1
View File
@@ -1,5 +1,6 @@
export * from './debug-logger'
export * from './dynamic-font-size'
export * from './file-drop'
export * from './format-bytes'
export * from './format-date-time'
export * from './format-money'
@@ -1,16 +1,17 @@
<template>
<div
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
ref="dropTargetRef"
@dragenter="dropTargetProps.onDragenter"
@dragover="dropTargetProps.onDragover"
@dragleave="dropTargetProps.onDragleave"
@drop="dropTargetProps.onDrop"
>
<slot />
<div
v-if="isDragging"
v-if="showOverlay"
:class="[
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black/60 text-contrast shadow',
overlayClass,
props.overlayClass,
]"
>
<div class="text-center">
@@ -18,7 +19,7 @@
<p class="mt-2 text-xl">
{{
formatMessage(messages.dropToUpload, {
type: formatFileItemType(formatMessage, type?.toLocaleLowerCase(), true),
type: formatFileItemType(formatMessage, props.type?.toLocaleLowerCase(), true),
})
}}
</p>
@@ -29,8 +30,9 @@
<script setup lang="ts">
import { UploadIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useFileDropTarget } from '#ui/composables/file-drop'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { formatFileItemType } from '#ui/utils/common-messages'
@@ -38,9 +40,11 @@ const { formatMessage } = useVIntl()
const emit = defineEmits<{
filesDropped: [files: File[]]
dropError: [error: unknown]
}>()
defineProps<{
const props = defineProps<{
disabled?: boolean
overlayClass?: string
type?: string
}>()
@@ -52,35 +56,12 @@ const messages = defineMessages({
},
})
const isDragging = ref(false)
const dragCounter = ref(0)
const handleDragEnter = (event: DragEvent) => {
event.preventDefault()
dragCounter.value++
isDragging.value = true
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault()
dragCounter.value--
if (dragCounter.value === 0) {
isDragging.value = false
}
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
isDragging.value = false
dragCounter.value = 0
const files = event.dataTransfer?.files
if (files) {
emit('filesDropped', Array.from(files))
}
}
const dropTargetRef = ref<HTMLElement | null>(null)
const { isDragging, dropTargetProps } = useFileDropTarget({
target: dropTargetRef,
disabled: computed(() => props.disabled ?? false),
onFiles: (files) => emit('filesDropped', files),
onError: (error) => emit('dropError', error),
})
const showOverlay = computed(() => isDragging.value)
</script>
@@ -69,6 +69,8 @@
<FileUploadDragAndDrop
ref="fileUploadRef"
class="@container relative flex flex-col overflow-clip rounded-[20px] border border-solid border-surface-4 shadow-sm"
:disabled="isBusy"
@drop-error="handleDropError"
@files-dropped="handleDroppedFiles"
>
<FileTableHeader
@@ -591,6 +593,14 @@ function handleDroppedFiles(files: File[]) {
ctx.uploadFiles(files)
}
function handleDropError(error: unknown) {
addNotification({
title: formatMessage(commonMessages.uploadFailedLabel),
text: error instanceof Error ? error.message : undefined,
type: 'error',
})
}
function initiateFileUpload() {
if (isBusy.value) return
const input = document.createElement('input')
+19
View File
@@ -0,0 +1,19 @@
import { createContext } from '.'
export type NativeFileDropEvent = {
type: 'enter' | 'over' | 'drop' | 'leave'
paths: string[]
position: {
x: number
y: number
}
}
export interface FileDropProvider {
listenNativeFileDrop: (
handler: (event: NativeFileDropEvent) => void | Promise<void>,
) => Promise<() => void>
createFilesFromNativePaths: (paths: string[]) => Promise<File[]>
}
export const [injectFileDrop, provideFileDrop] = createContext<FileDropProvider>('FileDrop')
+1
View File
@@ -3,6 +3,7 @@ export * from './app-backup'
export * from './auth'
export * from './content-manager'
export { createContext } from './create-context'
export * from './file-drop'
export * from './file-picker'
export * from './hosting-purchase-intent'
export * from './i18n'