Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/app-frontend/src/providers/setup.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -10,6 +11,7 @@ export function setupProviders(
popupNotificationManager: AbstractPopupNotificationManager,
) {
setupTagsProvider(notificationManager)
setupFileDropProvider()
setupFilePickerProvider()
setupInstanceImportProvider(notificationManager)

Expand Down
62 changes: 62 additions & 0 deletions apps/app-frontend/src/providers/setup/file-drop.ts
Original file line number Diff line number Diff line change
@@ -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))),
)
},
})
}
6 changes: 5 additions & 1 deletion apps/app/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down
14 changes: 14 additions & 0 deletions apps/app/src/api/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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,
Expand Down
177 changes: 177 additions & 0 deletions packages/ui/src/composables/file-drop.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/ui/src/composables/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Loading
Loading