You've already forked AstralRinth
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:
@@ -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,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'
|
||||
|
||||
+21
-40
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user