import type { ComputedRef, MaybeRef, MaybeRefOrGetter, ShallowRef } from 'vue'; import { shallowRef, toValue, unref } from 'vue'; import { isFunction } from '@robonen/stdlib'; import { useEventListener } from '@/composables/browser/useEventListener'; import { useSupported } from '@/composables/utilities/useSupported'; import { unrefElement } from '@/composables/component/unrefElement'; import type { MaybeComputedElementRef } from '@/composables/component/unrefElement'; import { defaultNavigator, defaultWindow } from '@/types'; import type { ConfigurableNavigator, ConfigurableWindow } from '@/types'; export type UseDropZoneDataTypes = MaybeRef | ((types: readonly string[]) => boolean); export interface UseDropZoneOptions extends ConfigurableWindow, ConfigurableNavigator { /** * Allowed data types. If not set, all data types are allowed. * Can also be a predicate that receives the dragged item types and returns whether they are valid. */ dataTypes?: UseDropZoneDataTypes; /** * Allow multiple files to be dropped. * * @default true */ multiple?: boolean; /** * Call `preventDefault` even for drags that fail validation, suppressing the browser's default handling. * * @default false */ preventDefaultForUnhandled?: boolean; /** * Fired when valid files are dropped on the target. */ onDrop?: (files: File[] | null, event: DragEvent) => void; /** * Fired when a drag enters the target. */ onEnter?: (files: File[] | null, event: DragEvent) => void; /** * Fired when a drag leaves the target. */ onLeave?: (files: File[] | null, event: DragEvent) => void; /** * Fired repeatedly while a drag hovers over the target. */ onOver?: (files: File[] | null, event: DragEvent) => void; } export interface UseDropZoneReturn { /** * Whether a valid drag is currently hovering over the target. */ isOverDropZone: ShallowRef; /** * The dropped files, or `null` when nothing has been dropped yet. */ files: ShallowRef; /** * Whether the Drag and Drop API is available in the current environment. */ isSupported: ComputedRef; } type DropZoneEventType = 'enter' | 'over' | 'leave' | 'drop'; /** * @name useDropZone * @category Elements * @description Create a drag-and-drop file drop zone on a target element or document. * * @param {MaybeComputedElementRef | MaybeRefOrGetter} target - The element (or document) acting as the drop zone. * @param {UseDropZoneOptions | UseDropZoneOptions['onDrop']} [options] - Drop zone options, or a shorthand `onDrop` callback. * @returns {UseDropZoneReturn} The reactive drop zone state. * * @example * const dropZone = useTemplateRef('dropZone'); * const { isOverDropZone, files } = useDropZone(dropZone, { * dataTypes: ['image/png'], * onDrop: (files) => console.log(files), * }); * * @since 0.0.14 */ export function useDropZone( target: MaybeComputedElementRef | MaybeRefOrGetter, options: UseDropZoneOptions | UseDropZoneOptions['onDrop'] = {}, ): UseDropZoneReturn { const _options: UseDropZoneOptions = isFunction(options) ? { onDrop: options } : options; const { window = defaultWindow, navigator = defaultNavigator, multiple = true, preventDefaultForUnhandled = false, } = _options; const isOverDropZone = shallowRef(false); const files = shallowRef(null); const isSupported = useSupported(() => window && 'DataTransfer' in window); let counter = 0; let isValid = true; const getFiles = (event: DragEvent): File[] | null => { const list = Array.from(event.dataTransfer?.files ?? []); if (list.length === 0) return null; return multiple ? list : [list[0]!]; }; const checkDataTypes = (types: readonly string[]): boolean => { // `dataTypes` may be a predicate function, so unwrap with `unref` (not `toValue`, // which would call a function as a getter). const dataTypes = unref(_options.dataTypes); if (isFunction(dataTypes)) return dataTypes(types); if (!dataTypes?.length) return true; if (types.length === 0) return false; return types.every(type => dataTypes.some(allowed => type.includes(allowed))); }; const checkValidity = (items: DataTransferItemList): boolean => { const types = Array.from(items ?? []).map(item => item.type); const dataTypesValid = checkDataTypes(types); const multipleFilesValid = multiple || items.length <= 1; return dataTypesValid && multipleFilesValid; }; // Safari fires drag events without populating `dataTransfer.items`, so validation // cannot be trusted there — always accept the drag and let `drop` resolve files. const isSafari = (): boolean => { if (!navigator || !window) return false; return /^(?:(?!chrome|android).)*safari/i.test(navigator.userAgent) && !('chrome' in window); }; const handleDragEvent = (event: DragEvent, type: DropZoneEventType): void => { const items = event.dataTransfer?.items; isValid = (items && checkValidity(items)) ?? 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 (type) { 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; } }; const resolveTarget = (): EventTarget | null | undefined => { const value = toValue(target as MaybeRefOrGetter); if (value instanceof Document) return value; return unrefElement(target as MaybeComputedElementRef); }; useEventListener(resolveTarget, 'dragenter', event => handleDragEvent(event, 'enter')); useEventListener(resolveTarget, 'dragover', event => handleDragEvent(event, 'over')); useEventListener(resolveTarget, 'dragleave', event => handleDragEvent(event, 'leave')); useEventListener(resolveTarget, 'drop', event => handleDragEvent(event, 'drop')); return { isOverDropZone, files, isSupported, }; }