206 lines
6.7 KiB
TypeScript
206 lines
6.7 KiB
TypeScript
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<readonly string[]> | ((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<boolean>;
|
|
/**
|
|
* The dropped files, or `null` when nothing has been dropped yet.
|
|
*/
|
|
files: ShallowRef<File[] | null>;
|
|
/**
|
|
* Whether the Drag and Drop API is available in the current environment.
|
|
*/
|
|
isSupported: ComputedRef<boolean>;
|
|
}
|
|
|
|
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<Document | null | undefined>} 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<HTMLElement>('dropZone');
|
|
* const { isOverDropZone, files } = useDropZone(dropZone, {
|
|
* dataTypes: ['image/png'],
|
|
* onDrop: (files) => console.log(files),
|
|
* });
|
|
*
|
|
* @since 0.0.14
|
|
*/
|
|
export function useDropZone(
|
|
target: MaybeComputedElementRef | MaybeRefOrGetter<Document | null | undefined>,
|
|
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<File[] | null>(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<unknown>);
|
|
if (value instanceof Document)
|
|
return value;
|
|
return unrefElement(target as MaybeComputedElementRef);
|
|
};
|
|
|
|
useEventListener<DragEvent>(resolveTarget, 'dragenter', event => handleDragEvent(event, 'enter'));
|
|
useEventListener<DragEvent>(resolveTarget, 'dragover', event => handleDragEvent(event, 'over'));
|
|
useEventListener<DragEvent>(resolveTarget, 'dragleave', event => handleDragEvent(event, 'leave'));
|
|
useEventListener<DragEvent>(resolveTarget, 'drop', event => handleDragEvent(event, 'drop'));
|
|
|
|
return {
|
|
isOverDropZone,
|
|
files,
|
|
isSupported,
|
|
};
|
|
}
|