UNPKG

@vdnd/v3

Version:

A Vue drag-and-drop component library that is easy to use.

926 lines (916 loc) 32.2 kB
import { unref, inject, ref, shallowRef, markRaw, defineComponent, onMounted, h, watchPostEffect, provide, onBeforeUnmount, watchSyncEffect, computed } from 'vue'; import { findHandlesIn, NativeDnd } from '@vdnd/native'; function unwrapDraggable(draggable, source) { return typeof draggable === 'function' ? draggable(source) : unref(draggable); } function unwrapDroppable(droppable, source, dropzone) { return typeof droppable === 'function' ? droppable(dropzone, source) : unref(droppable); } function unwrapDropEffect(dropEffect, source, dropzone) { return typeof dropEffect === 'function' ? dropEffect(dropzone, source) : unref(dropEffect); } function inferDropEffect(effectAllowed) { switch (effectAllowed) { case 'none': default: return 'none'; case 'copy': case 'copyLink': case 'copyMove': case 'all': case 'uninitialized': return 'copy'; case 'link': case 'linkMove': return 'link'; case 'move': return 'move'; } } function useDndModel$1(options) { let _cls; let _interactions; if (Array.isArray(options)) { _cls = {}; _interactions = options; } else { _cls = options?.classes || {}; _interactions = options?.interactions || []; } _cls.container ||= 'dnd-container'; _cls.source ||= 'dnd-source'; _cls.dropzone ||= 'dnd-dropzone'; _cls.handle ||= 'dnd-handle'; const classes = { container: _cls.container, source: _cls.source, dropzone: _cls.dropzone, handle: _cls.handle, 'source:dragging': _cls['source:dragging'] || `${_cls.source}--dragging`, 'source:draggable': _cls['source:draggable'] || `${_cls.source}--draggable`, 'source:disabled': _cls['source:disabled'] || `${_cls.source}--disabled`, 'dropzone:over': _cls['dropzone:over'] || `${_cls.dropzone}--over`, 'dropzone:droppable': _cls['dropzone:droppable'] || `${_cls.dropzone}--droppable`, 'dropzone:disabled': _cls['dropzone:disabled'] || `${_cls.dropzone}--disabled`, }; const initialized = ref(false); function setInitialized(flag) { initialized.value = flag; if (!flag) return; const POWERS = { '*': 99, s: 11, d: 10, 's+d': 1, }; interactions = [...interactions].sort((a, b) => { const power1 = POWERS[a.scope]; const power2 = POWERS[b.scope]; return power2 - power1; }); } let interactions = _interactions; function defineInteraction(interaction) { if (initialized.value) { console.warn(`[vdnd warn]: Can't define the interaction, because the model has completed initialization.`); return; } interactions.push(interaction); } const currentSource = shallowRef(); function setCurrentSource(value) { currentSource.value = value; } const currentTarget = shallowRef(); function setCurrentTarget(value) { currentTarget.value = value; } function isDragging(...args) { if (typeof currentSource.value === 'undefined') return false; const [a, b] = args; if (typeof a === 'undefined') { return true; } else if (typeof a === 'string') { if (typeof b === 'undefined') { return currentSource.value.label === a; } else { const label1 = a; const data1 = b; const { label: label2, data: data2 } = currentSource.value; return label1 === label2 && data1 === data2; } } else if (typeof a === 'object') { const { label: label1, data: data1 } = a; const { label: label2, data: data2 } = currentSource.value; return label1 === label2 && data1 === data2; } else { return a(currentSource.value); } } function isDraggable(...args) { const [a, b] = args; const source = (typeof a === 'object' ? a : { label: a, data: b }); for (const i of interactions) { if (i.scope === 'd' || i.scope === 's+d') continue; else if (typeof i.draggable !== 'undefined') { if (i.scope === 's') { if (i.source !== source.label) continue; } if (!unwrapDraggable(i.draggable, source)) { return false; } } } return true; } function isOver(...args) { if (typeof currentTarget.value === 'undefined') return false; const [a, b] = args; if (typeof a === 'undefined') { return true; } else if (typeof a === 'string') { if (typeof b === 'undefined') { return currentTarget.value.label === a; } else { const label1 = a; const data1 = b; const { label: label2, data: data2 } = currentTarget.value; return label1 === label2 && data1 === data2; } } else if (typeof a === 'object') { const { label: label1, data: data1 } = a; const { label: label2, data: data2 } = currentTarget.value; return label1 === label2 && data1 === data2; } else { return a(currentTarget.value); } } function isDroppable(...args) { if (!isDragging()) { console.warn('[vdnd warn]: We can only call `isDroppable` during the drag-and-drop operation, ' + 'because one of the `droppable` signatures takes the current drag source as a parameter.'); return false; } const [a, b] = args; const dropzone = (typeof a === 'object' ? a : { label: a, data: b }); for (const i of interactions) { if (i.scope === 's') continue; else if (typeof i.droppable !== 'undefined') { if (i.scope === 's+d') { if (i.source !== currentSource.value.label) continue; } if (i.scope === 'd' || i.scope === 's+d') { if (i.dropzone !== dropzone.label) continue; } if (!unwrapDroppable(i.droppable, currentSource.value, dropzone)) { return false; } } } return true; } const nativeElements = []; function addNativeElement(role, htmlEl, dndEl) { nativeElements.push({ role, htmlEl, dndEl }); } function removeNativeElement(role, htmlEl) { const index = nativeElements.findIndex((nativeEl) => nativeEl.role === role && nativeEl.htmlEl === htmlEl); if (index >= 0) { nativeElements.splice(index, 1); } } function findDndElement(role, htmlEl) { const nativeEl = nativeElements.find((nativeEl) => nativeEl.role === role && nativeEl.htmlEl === htmlEl); return nativeEl?.dndEl; } function findHTMLElement(...args) { const [role, b] = args; if (role === 'container') { return nativeElements.find((nativeEl) => { return nativeEl.role === role; })?.htmlEl; } else { const label1 = b.label; const data1 = b.data; return nativeElements.find((nativeEl) => { if (!nativeEl.dndEl) return false; const { label: label2, data: data2 } = nativeEl.dndEl; return nativeEl.role === role && label1 === label2 && data1 === data2; })?.htmlEl; } } function findHTMLElements(role, label) { return nativeElements .filter((nativeEl) => { if (nativeEl.role !== role) return false; if (typeof label !== 'undefined' && nativeEl.dndEl) { return nativeEl.dndEl.label === label; } return true; }) .map(({ htmlEl }) => htmlEl); } function findHandlesIn$1(source) { const sources = findHTMLElements('source'); const handles = findHTMLElements('handle'); const isSource = (source) => sources.includes(source); const isHandle = (handle) => handles.includes(handle); if (source instanceof HTMLElement) { return findHandlesIn(source, isSource, isHandle); } else { const htmlEl = findHTMLElement('source', source); return htmlEl ? findHandlesIn(htmlEl, isSource, isHandle) : []; } } return markRaw({ get classes() { return classes; }, get interactions() { return interactions; }, get initialized() { return initialized.value; }, get currentSource() { return currentSource.value; }, get currentTarget() { return currentTarget.value; }, isDragging, isDraggable, isOver, isDroppable, defineInteraction, findHTMLElement, findHTMLElements, findHandlesIn: findHandlesIn$1, $findDndElement: findDndElement, $addNativeElement: addNativeElement, $removeNativeElement: removeNativeElement, $setInitialized: setInitialized, $setCurrentTarget: setCurrentTarget, $setCurrentSource: setCurrentSource, }); } const DndModelSymbol = Symbol('DndModelSymbol'); function injectDndModel$1() { return inject(DndModelSymbol); } function validateHandle(handle, sources) { let parent = handle.parentElement; while (parent) { if (sources.includes(parent)) { return true; } parent = parent.parentElement; } return false; } const DndHandle$1 = defineComponent((props, { slots }) => { const model = injectDndModel$1(); const roolElRef = shallowRef(); if (!model) { onMounted(() => { console.warn('[vdnd warn]: If the <DndHandle />(%o) is not nested within the <DndContainer />, it will not function as expected.', roolElRef.value); }); return () => { return h(props.tag, { ref: roolElRef }, { default: slots.default }); }; } watchPostEffect((onCleanup) => { if (!roolElRef.value) return; model.$addNativeElement('handle', roolElRef.value, undefined); onCleanup(() => { model.$removeNativeElement('handle', roolElRef.value); }); }); onMounted(() => { if (model.initialized) { const sources = model.findHTMLElements('source'); if (!validateHandle(roolElRef.value, sources)) { console.warn('[vdnd warn]: If the <DndHandle />(%o) is not nested within the <DndSource />, it will not function as expected.', roolElRef.value); } } }); return () => { return h(props.tag, { ref: roolElRef, draggable: true, class: model.classes.handle, }, { default: slots.default }); }; }, { name: 'DndHandle', props: { tag: { type: String, default: 'div', }, }, }); const DndContainer$1 = defineComponent((props, { slots }) => { const model = props.model; const cls = model.classes; const roolElRef = shallowRef(); const nativeDndRef = shallowRef(); provide(DndModelSymbol, model); watchPostEffect((onCleanup) => { if (!roolElRef.value) return; model.$addNativeElement('container', roolElRef.value, undefined); onCleanup(() => { model.$removeNativeElement('container', roolElRef.value); }); }); const onDrag = (e) => { if (!model.currentSource) return; const dragEvent = { type: 'drag', source: model.currentSource, over: model.currentTarget, originalEvent: e.originalEvent, }; for (const i of model.interactions) { if (i.scope === '*') { i.onDrag?.(dragEvent); } else if (i.scope === 's') { if (model.isDragging(i.source)) { i.onDrag?.(dragEvent); } } else if (i.scope === 'd') { if (model.isOver(i.dropzone)) { i.onDrag?.(dragEvent); } } else { if (model.isDragging(i.source) && model.isOver(i.dropzone)) { i.onDrag?.(dragEvent); } } } }; const _onDragPrevent = (e) => { const source = model.$findDndElement('source', e.source); const dragpreventEvent = { type: 'dragprevent', source, originalEvent: e.originalEvent, }; for (const i of model.interactions) { if (i.scope === '*') { i.onDragPrevent?.(dragpreventEvent); } else if (i.scope == 's') { if (i.source === source.label) { i.onDragPrevent?.(dragpreventEvent); } } } }; const onDragStart = (e) => { const source = model.$findDndElement('source', e.source); if (!source) { console.warn('[vdnd warn]: Currently attempting to drag a source(%o) that was not created via the <DndSource />. ' + 'vdnd will ignore such an uncontrolled source.', e.source); return; } if (!model.isDraggable(source)) { e.cancel(); _onDragPrevent(e); return; } model.$setCurrentSource(source); const dragstartEvent = { type: 'dragstart', source, originalEvent: e.originalEvent, }; for (const i of model.interactions) { if (i.scope === '*') { i.onDragStart?.(dragstartEvent); } else if (i.scope == 's') { if (model.isDragging(i.source)) { i.onDragStart?.(dragstartEvent); } } } const dataTransfer = e.originalEvent.dataTransfer; const effectAllowed = dataTransfer.effectAllowed; if (effectAllowed !== 'all' && effectAllowed !== 'uninitialized') { dataTransfer.effectAllowed = 'all'; const DEFINE_DROPEFFECT = `useDndModel().defineInteraction({ scope: 's+d', source: 'image', dropzone: 'canvas', dropEffect: 'copy' })`; console.warn("[vdnd warn]: Don't modify the effectAllowed, vdnd will fix it as `all`. " + `If you want to control the drag-and-drop feedback, you can define the dropEffect using the \`${DEFINE_DROPEFFECT}\`.`); } }; const onDragEnter = (e) => { if (!model.currentSource) return; const enter = model.$findDndElement('dropzone', e.enter); if (!enter) { console.warn('[vdnd warn]: Currently attempting to indicate a dropzone(%o) that was not created via the <DndDropzone /> as the current drop target. ' + 'vdnd will ignore such an uncontrolled dropzone.', e.enter); return; } model.$setCurrentTarget(enter); const droppable = model.isDroppable(enter); const dataTransfer = e.originalEvent.dataTransfer; const effectAllowed = dataTransfer.effectAllowed; let dropEffect; if (!droppable) { dataTransfer.dropEffect = dropEffect = 'none'; } else { for (const i of model.interactions) { if (i.scope !== 's+d') continue; if (!i.dropEffect || i.dropzone !== enter.label) continue; dropEffect = unwrapDropEffect(i.dropEffect, model.currentSource, enter); } dropEffect ||= inferDropEffect(effectAllowed); dataTransfer.dropEffect = dropEffect; } const dragenterEvent = { type: 'dragenter', source: model.currentSource, enter, originalEvent: e.originalEvent, }; for (const i of model.interactions) { if (i.scope === '*') { i.onDragEnter?.(dragenterEvent); } else if (i.scope === 's') { if (model.isDragging(i.source)) { i.onDragEnter?.(dragenterEvent); } } else if (i.scope === 'd') { if (model.isOver(i.dropzone)) { i.onDragEnter?.(dragenterEvent); } } else { if (model.isDragging(i.source) && model.isOver(i.dropzone)) { i.onDragEnter?.(dragenterEvent); } } } if (dropEffect !== dataTransfer.dropEffect) { const DEFINE_DROPEFFECT = `useDndModel().defineInteraction({ scope: 's+d', source: 'image', dropzone: 'canvas', dropEffect: 'copy' })`; const DO_NOT_MODIFY_DROPEFFECT_IN_HANDLERS = "[vdnd warn]: Don't modify the dropEffect in `dragover` or `dragenter` event handlers. " + `If you want to control the drag-and-drop feedback, you can define the dropEffect using the \`${DEFINE_DROPEFFECT}\`.`; console.warn(DO_NOT_MODIFY_DROPEFFECT_IN_HANDLERS); } if (droppable) { if (dataTransfer.dropEffect === 'none') { dataTransfer.dropEffect = dropEffect || inferDropEffect(effectAllowed); console.warn(`[vdnd warn]: The dropEffect for a droppable dropzone must not be \`none\`, ` + `vdnd will reset it to \`${dataTransfer.dropEffect}\`.`); } } else { if (dataTransfer.dropEffect !== 'none') { dataTransfer.dropEffect = 'none'; console.warn('[vdnd warn]: The dropEffect for a non-droppable dropzone must be `none`, vdnd will force it to be `none`.'); } } }; const onDragOver = (e) => { if (!model.currentSource || !model.currentTarget) return; const droppable = model.isDroppable(model.currentTarget); const dataTransfer = e.originalEvent.dataTransfer; const effectAllowed = dataTransfer.effectAllowed; let dropEffect; if (!droppable) { dataTransfer.dropEffect = dropEffect = 'none'; } else { for (const i of model.interactions) { if (i.scope !== 's+d') continue; if (!i.dropEffect || i.dropzone !== model.currentTarget.label) { continue; } dropEffect = unwrapDropEffect(i.dropEffect, model.currentSource, model.currentTarget); } dropEffect ||= inferDropEffect(effectAllowed); dataTransfer.dropEffect = dropEffect; } const dragoverEvent = { type: 'dragover', source: model.currentSource, over: model.currentTarget, originalEvent: e.originalEvent, }; for (const i of model.interactions) { if (i.scope === '*') { i.onDragOver?.(dragoverEvent); } else if (i.scope === 's') { if (model.isDragging(i.source)) { i.onDragOver?.(dragoverEvent); } } else if (i.scope === 'd') { if (model.isOver(i.dropzone)) { i.onDragOver?.(dragoverEvent); } } else { if (model.isDragging(i.source) && model.isOver(i.dropzone)) { i.onDragOver?.(dragoverEvent); } } } if (droppable) { if (dataTransfer.dropEffect === 'none') { dataTransfer.dropEffect = inferDropEffect(effectAllowed); } } else { if (dataTransfer.dropEffect !== 'none') { dataTransfer.dropEffect = 'none'; } } }; const onDragLeave = (e) => { if (!model.currentSource || !model.currentTarget) return; const enter = e.enter ? model.$findDndElement('dropzone', e.enter) : undefined; if (e.enter) { if (!enter) { model.$setCurrentTarget(undefined); } } else { if (e.originalEvent.relatedTarget !== null) { model.$setCurrentTarget(undefined); } } const leave = model.$findDndElement('dropzone', e.leave); const dragleaveEvent = { type: 'dragleave', source: model.currentSource, leave, enter, originalEvent: e.originalEvent, }; for (const i of model.interactions) { if (i.scope === '*') { i.onDragLeave?.(dragleaveEvent); } else if (i.scope === 's') { if (model.isDragging(i.source)) { i.onDragLeave?.(dragleaveEvent); } } else if (i.scope === 'd') { if (leave.label === i.dropzone) { i.onDragLeave?.(dragleaveEvent); } } else { if (model.isDragging(i.source) && leave.label === i.dropzone) { i.onDragLeave?.(dragleaveEvent); } } } }; const onDrop = (e) => { if (!model.currentSource || !model.currentTarget) return; const dropEvent = { type: 'drop', source: model.currentSource, dropzone: model.currentTarget, originalEvent: e.originalEvent, }; for (const i of model.interactions) { if (i.scope === '*') { i.onDrop?.(dropEvent); } else if (i.scope == 's') { if (model.isDragging(i.source)) { i.onDrop?.(dropEvent); } } else if (i.scope === 'd') { if (model.isOver(i.dropzone)) { i.onDrop?.(dropEvent); } } else if (i.scope === 's+d') { if (model.isDragging(i.source) && model.isOver(i.dropzone)) { i.onDrop?.(dropEvent); } } } }; const onDragEnd = (e) => { if (!model.currentSource) return; const dragendEvent = { type: 'dragend', source: model.currentSource, over: model.currentTarget, originalEvent: e.originalEvent, }; for (const i of model.interactions) { if (i.scope === '*') { i.onDragEnd?.(dragendEvent); } else if (i.scope === 's') { if (model.isDragging(i.source)) { i.onDragEnd?.(dragendEvent); } } else if (i.scope === 'd') { if (model.isOver(i.dropzone)) { i.onDragEnd?.(dragendEvent); } } else { if (model.isDragging(i.source) && model.isOver(i.dropzone)) { i.onDragEnd?.(dragendEvent); } } } model.$setCurrentSource(undefined); model.$setCurrentTarget(undefined); }; onMounted(() => { const nativeDnd = new NativeDnd(roolElRef.value, { source: cls.source, dropzone: cls.dropzone, handle: cls.handle, isRecognizedSource(source) { const sources = model.findHTMLElements('source'); return sources.includes(source); }, isRecognizedDropzone(dropzone) { const dropzones = model.findHTMLElements('dropzone'); return dropzones.includes(dropzone); }, }); nativeDnd.on('drag', onDrag); nativeDnd.on('dragstart', onDragStart); nativeDnd.on('dragenter', onDragEnter); nativeDnd.on('dragover', onDragOver); nativeDnd.on('dragleave', onDragLeave); nativeDnd.on('drop', onDrop); nativeDnd.on('dragend', onDragEnd); nativeDndRef.value = nativeDnd; }); onBeforeUnmount(() => { nativeDndRef.value.destroy(); }); onMounted(() => { model.$setInitialized(true); }); onBeforeUnmount(() => { model.$setInitialized(false); model.$setCurrentSource(undefined); model.$setCurrentTarget(undefined); }); onMounted(() => { const container = roolElRef.value; const observer = new MutationObserver((mutations) => { const source = model.findHTMLElement('source', model.currentSource); const dropzone = model.currentTarget && model.findHTMLElement('dropzone', model.currentTarget); for (const { removedNodes } of mutations) { removedNodes.forEach((removedNode) => { if (removedNode === source) { console.warn('[vdnd warn]: The current drag source(%o) was removed from the document during the drag-and-drop operation, ' + 'which will result in the inability to dispatch the `drag` and `dragend` events.', removedNode); } else if (removedNode === dropzone) { console.warn('[vdnd warn]: The current drop target(%o) was removed from the document during the drag-and-drop operation, ' + 'which will result in the inability to dispatch the relevant `dragover`, `dragleave` and `drop` events.', removedNode); } }); } }); watchSyncEffect((onCleanup) => { if (model.isDragging()) { observer.observe(container, { subtree: true, childList: true, attributes: false, characterData: false, }); onCleanup(() => observer.disconnect()); } }); }); onMounted(() => { const handles = model.findHTMLElements('handle'); const sources = model.findHTMLElements('source'); for (const handle of handles) { if (!validateHandle(handle, sources)) { console.warn('[vdnd warn]: If the <DndHandle />(%o) is not nested within the <DndSource />, it will not function as expected.', handle); } } }); return () => { return h(props.tag, { ref: roolElRef, class: [cls.container], }, { default: slots.default, }); }; }, { name: 'DndContainer', props: { tag: { type: String, default: 'div', }, model: { type: Object, required: true, }, }, }); function ensureArray$1(value) { return Array.isArray(value) ? value : [value]; } const DndSource$1 = defineComponent((props, { slots }) => { const model = injectDndModel$1(); const roolElRef = shallowRef(); if (!model) { onMounted(() => { console.warn('[vdnd warn]: If the <DndSource />(%o) is not nested within the <DndContainer />, it will not function as expected.', roolElRef.value); }); return () => { return h(props.tag, { ref: roolElRef }, { default: slots.default }); }; } watchPostEffect((onCleanup) => { if (!roolElRef.value) return; model.$addNativeElement('source', roolElRef.value, { label: props.label, data: props.data, }); onCleanup(() => { model.$removeNativeElement('source', roolElRef.value); }); }); const classes = computed(() => { const cls = model.classes; const result = []; result.push(cls.source); if (model.isDraggable(props.label, props.data)) { result.push(...ensureArray$1(cls['source:draggable'])); if (model.isDragging(props.label, props.data)) { result.push(...ensureArray$1(cls['source:dragging'])); } } else { result.push(...ensureArray$1(cls['source:disabled'])); } return result; }); return () => { return h(props.tag, { ref: roolElRef, class: classes.value, draggable: true, }, { default: slots.default }); }; }, { name: 'DndSource', props: { tag: { type: String, default: 'div', }, label: { type: String, required: true, }, data: { type: null, default: undefined, }, }, }); function ensureArray(value) { return Array.isArray(value) ? value : [value]; } const DndDropzone$1 = defineComponent((props, { slots }) => { const model = injectDndModel$1(); const roolElRef = shallowRef(); if (!model) { onMounted(() => { console.warn('[vdnd warn]: If the <DndDropzone />(%o) is not nested within the <DndContainer />, it will not function as expected.', roolElRef.value); }); return () => { return h(props.tag, { ref: roolElRef }, { default: slots.default }); }; } watchPostEffect((onCleanup) => { if (!roolElRef.value) return; model.$addNativeElement('dropzone', roolElRef.value, { label: props.label, data: props.data, }); onCleanup(() => { model.$removeNativeElement('dropzone', roolElRef.value); }); }); const classes = computed(() => { const cls = model.classes; const result = []; result.push(cls.dropzone); if (model.isDragging()) { const dropzone = model.$findDndElement('dropzone', roolElRef.value); if (model.isDroppable(dropzone)) { result.push(...ensureArray(cls['dropzone:droppable'])); } else { result.push(...ensureArray(cls['dropzone:disabled'])); } if (model.isOver(dropzone)) { result.push(...ensureArray(cls['dropzone:over'])); } } return result; }); return () => { return h(props.tag, { ref: roolElRef, class: classes.value, }, { default: slots.default }); }; }, { name: 'DndDropzone', props: { tag: { type: String, default: 'div', }, label: { type: String, required: true, }, data: { type: null, default: undefined, }, }, }); const DndSuite = { useDndModel: useDndModel$1, injectDndModel: injectDndModel$1, DndContainer: DndContainer$1, DndSource: DndSource$1, DndDropzone: DndDropzone$1, DndHandle: DndHandle$1, }; DndSuite.useDndModel; const { useDndModel, injectDndModel, DndContainer, DndSource, DndDropzone, DndHandle, } = DndSuite; export { DndContainer, DndDropzone, DndHandle, DndSource, DndSuite, injectDndModel, useDndModel };