diff --git a/frontend/package.json b/frontend/package.json index 227bce124..a8be71b73 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -122,6 +122,7 @@ "@vue/eslint-config-typescript": "14.6.0", "@vue/test-utils": "2.4.6", "@vue/tsconfig": "0.8.1", + "@vueuse/shared": "^14.0.0", "autoprefixer": "10.4.22", "browserslist": "4.28.0", "caniuse-lite": "1.0.30001754", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e4c6c0992..d6648f4e3 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -227,6 +227,9 @@ importers: '@vue/tsconfig': specifier: 0.8.1 version: 0.8.1(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)) + '@vueuse/shared': + specifier: ^14.0.0 + version: 14.0.0(vue@3.5.24(typescript@5.9.3)) autoprefixer: specifier: 10.4.22 version: 10.4.22(postcss@8.5.6) diff --git a/frontend/src/composables/useDropzone.ts b/frontend/src/composables/useDropzone.ts new file mode 100644 index 000000000..f471087b6 --- /dev/null +++ b/frontend/src/composables/useDropzone.ts @@ -0,0 +1,141 @@ +import type { MaybeRef, MaybeRefOrGetter, ShallowRef } from 'vue' +import { isClient } from '@vueuse/shared' +// eslint-disable-next-line no-restricted-imports +import { shallowRef, unref } from 'vue' + +import { useEventListener } from '@vueuse/core' + +export interface UseDropZoneReturn { + files: ShallowRef + isOverDropZone: ShallowRef +} + +export interface UseDropZoneOptions { + /** + * Allowed data types, if not set, all data types are allowed. + * Also can be a function to check the data types. + */ + dataTypes?: MaybeRef | ((types: readonly string[]) => boolean) + onDrop?: (files: File[] | null, event: DragEvent) => void + onEnter?: (files: File[] | null, event: DragEvent) => void + onLeave?: (files: File[] | null, event: DragEvent) => void + onOver?: (files: File[] | null, event: DragEvent) => void + /** + * Allow multiple files to be dropped. Defaults to true. + */ + multiple?: boolean + /** + * Prevent default behavior for unhandled events. Defaults to false. + */ + preventDefaultForUnhandled?: boolean +} + +export function useDropZone( + target: MaybeRefOrGetter, + options: UseDropZoneOptions | UseDropZoneOptions['onDrop'] = {}, +): UseDropZoneReturn { + const isOverDropZone = shallowRef(false) + const files = shallowRef(null) + let counter = 0 + let isValid = true + + if (isClient) { + const _options = typeof options === 'function' ? { onDrop: options } : options + const multiple = _options.multiple ?? true + const preventDefaultForUnhandled = _options.preventDefaultForUnhandled ?? false + + const getFiles = (event: DragEvent) => { + const list = Array.from(event.dataTransfer?.files ?? []) + return list.length === 0 ? null : (multiple ? list : [list[0]]) + } + + const checkDataTypes = (types: string[]) => { + const dataTypes = unref(_options.dataTypes) + + if (typeof dataTypes === 'function') + return dataTypes(types) + + if (!dataTypes?.length) + return true + + if (types.length === 0) + return false + + return types.every(type => + dataTypes.some(allowedType => type.includes(allowedType)), + ) + } + + const checkValidity = (items: DataTransferItemList) => { + const types = Array.from(items ?? []).map(item => item.type) + + const dataTypesValid = checkDataTypes(types) + const multipleFilesValid = multiple || items.length <= 1 + + return dataTypesValid && multipleFilesValid + } + + const isSafari = () => ( + /^(?:(?!chrome|android).)*safari/i.test(navigator.userAgent) + && !('chrome' in window) + ) + + const handleDragEvent = (event: DragEvent, eventType: 'enter' | 'over' | 'leave' | 'drop') => { + const dataTransferItemList = event.dataTransfer?.items + isValid = (dataTransferItemList && checkValidity(dataTransferItemList)) ?? false + + if (preventDefaultForUnhandled) { + event.preventDefault() + } + + if (!isSafari() && !isValid) { + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'none' + } + return + } + + event.preventDefault() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy' + } + + const currentFiles = getFiles(event) + + switch (eventType) { + case 'enter': + counter += 1 + isOverDropZone.value = true + _options.onEnter?.(null, event) + break + case 'over': + _options.onOver?.(null, event) + break + case 'leave': + counter -= 1 + if (counter === 0) + isOverDropZone.value = false + _options.onLeave?.(null, event) + break + case 'drop': + counter = 0 + isOverDropZone.value = false + if (isValid) { + files.value = currentFiles + _options.onDrop?.(currentFiles, event) + } + break + } + } + + useEventListener(target, 'dragenter', event => handleDragEvent(event, 'enter')) + useEventListener(target, 'dragover', event => handleDragEvent(event, 'over')) + useEventListener(target, 'dragleave', event => handleDragEvent(event, 'leave')) + useEventListener(target, 'drop', event => handleDragEvent(event, 'drop')) + } + + return { + files, + isOverDropZone, + } +} \ No newline at end of file