diff --git a/apps/app-frontend/src/providers/setup.ts b/apps/app-frontend/src/providers/setup.ts index 56a2ccc7eb..877aa3030b 100644 --- a/apps/app-frontend/src/providers/setup.ts +++ b/apps/app-frontend/src/providers/setup.ts @@ -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) diff --git a/apps/app-frontend/src/providers/setup/file-drop.ts b/apps/app-frontend/src/providers/setup/file-drop.ts new file mode 100644 index 0000000000..bfa9fde605 --- /dev/null +++ b/apps/app-frontend/src/providers/setup/file-drop.ts @@ -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('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))), + ) + }, + }) +} diff --git a/apps/app/build.rs b/apps/app/build.rs index f4d8c14a7b..d98d01de4c 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -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, ), diff --git a/apps/app/src/api/files.rs b/apps/app/src/api/files.rs index dbd7a5980b..81cb235953 100644 --- a/apps/app/src/api/files.rs +++ b/apps/app/src/api/files.rs @@ -11,6 +11,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { .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, } +#[tauri::command] +pub async fn file_read_dragged_file(path: String) -> Result> { + 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, diff --git a/packages/ui/src/composables/file-drop.ts b/packages/ui/src/composables/file-drop.ts new file mode 100644 index 0000000000..f4c2bc1c6f --- /dev/null +++ b/packages/ui/src/composables/file-drop.ts @@ -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 | Ref | ComputedRef + +export interface UseFileDropTargetOptions { + target: Ref + disabled?: MaybeRef + onFiles: (files: File[]) => void | Promise + 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, + }, + } +} diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts index c476e133e1..284130a27d 100644 --- a/packages/ui/src/composables/index.ts +++ b/packages/ui/src/composables/index.ts @@ -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' diff --git a/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue b/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue index c9b5ed6911..91b0c9a14c 100644 --- a/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue +++ b/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue @@ -1,16 +1,17 @@