mirror of
https://github.com/go-vikunja/vikunja.git
synced 2025-12-05 18:57:47 -06:00
fix(editor): prevent upload overlay from intercepting text drag operations (#1890)
This commit is contained in:
@@ -126,7 +126,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="editEnabled"
|
||||
:class="{hidden: !isOverDropZone}"
|
||||
:class="{hidden: !showDropzone}"
|
||||
class="dropzone"
|
||||
>
|
||||
<div class="drop-hint">
|
||||
@@ -172,8 +172,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive, computed} from 'vue'
|
||||
import {useDropZone} from '@/composables/useDropzone'
|
||||
import {ref, shallowReactive, computed, watch} from 'vue'
|
||||
import {useDropZone} from '@vueuse/core'
|
||||
|
||||
import User from '@/components/misc/User.vue'
|
||||
import ProgressBar from '@/components/misc/ProgressBar.vue'
|
||||
@@ -205,6 +205,28 @@ const props = withDefaults(defineProps<{
|
||||
const emit = defineEmits<{
|
||||
'taskChanged': [ITask],
|
||||
}>()
|
||||
|
||||
const EDITOR_SELECTOR = '.tiptap, .tiptap__editor, [contenteditable]'
|
||||
|
||||
function eventTargetsEditor(event: Event | null | undefined): boolean {
|
||||
if (!event) {
|
||||
return false
|
||||
}
|
||||
|
||||
const target = event.target
|
||||
if (target instanceof HTMLElement && target.closest(EDITOR_SELECTOR)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (typeof event.composedPath === 'function') {
|
||||
return event.composedPath().some(element =>
|
||||
element instanceof HTMLElement && element.matches(EDITOR_SELECTOR),
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
@@ -215,22 +237,67 @@ const attachments = computed(() => attachmentStore.attachments)
|
||||
|
||||
const loading = computed(() => attachmentService.loading || taskStore.isLoading)
|
||||
|
||||
function onDrop(files: File[] | null) {
|
||||
if (files && files.length !== 0) {
|
||||
uploadFilesToTask(files)
|
||||
}
|
||||
const isDraggingFiles = ref(false)
|
||||
const isDragOverEditor = ref(false)
|
||||
|
||||
function resetDragState() {
|
||||
isDraggingFiles.value = false
|
||||
isDragOverEditor.value = false
|
||||
}
|
||||
|
||||
const {isOverDropZone} = useDropZone(document, {
|
||||
onDrop,
|
||||
checkValidity: items => {
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file') {
|
||||
return true
|
||||
}
|
||||
onEnter(files, event) {
|
||||
if (!props.editEnabled) {
|
||||
return
|
||||
}
|
||||
return false
|
||||
|
||||
isDraggingFiles.value = true
|
||||
isDragOverEditor.value = eventTargetsEditor(event)
|
||||
},
|
||||
onOver(files, event) {
|
||||
if (!props.editEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
isDragOverEditor.value = eventTargetsEditor(event)
|
||||
},
|
||||
onLeave(files, event) {
|
||||
if (!props.editEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isOverDropZone.value) {
|
||||
resetDragState()
|
||||
return
|
||||
}
|
||||
|
||||
isDragOverEditor.value = eventTargetsEditor(event)
|
||||
},
|
||||
onDrop(files, event) {
|
||||
if (!props.editEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const dropOverEditor = eventTargetsEditor(event)
|
||||
resetDragState()
|
||||
|
||||
// Ignore drops over editor - let TipTap handle them
|
||||
if (dropOverEditor || !files || files.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadFilesToTask(files)
|
||||
},
|
||||
})
|
||||
|
||||
const showDropzone = computed(() =>
|
||||
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value,
|
||||
)
|
||||
|
||||
watch(() => props.editEnabled, enabled => {
|
||||
if (!enabled) {
|
||||
resetDragState()
|
||||
}
|
||||
})
|
||||
|
||||
function downloadAttachment(attachment: IAttachment) {
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import type { MaybeRef, MaybeRefOrGetter, ShallowRef } from 'vue'
|
||||
import { isClient } from '@vueuse/shared'
|
||||
import { shallowRef, unref } from 'vue'
|
||||
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
|
||||
/////////
|
||||
// Temporary copy until https://github.com/vueuse/vueuse/pull/5169 is merged and released
|
||||
////////
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
|
||||
export interface UseDropZoneReturn {
|
||||
files: ShallowRef<File[] | null>
|
||||
isOverDropZone: ShallowRef<boolean>
|
||||
}
|
||||
|
||||
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<readonly string[]> | ((types: readonly string[]) => boolean)
|
||||
/**
|
||||
* Similar to dataTypes, but exposes the DataTransferItemList for custom validation.
|
||||
* If provided, this function takes precedence over dataTypes.
|
||||
*/
|
||||
checkValidity?: (items: DataTransferItemList) => 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<HTMLElement | Document | null | undefined>,
|
||||
options: UseDropZoneOptions | UseDropZoneOptions['onDrop'] = {},
|
||||
): UseDropZoneReturn {
|
||||
const isOverDropZone = shallowRef(false)
|
||||
const files = shallowRef<File[] | null>(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) => {
|
||||
if (_options.checkValidity) {
|
||||
return _options.checkValidity(items)
|
||||
}
|
||||
|
||||
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<DragEvent>(target, 'dragenter', event => handleDragEvent(event, 'enter'))
|
||||
useEventListener<DragEvent>(target, 'dragover', event => handleDragEvent(event, 'over'))
|
||||
useEventListener<DragEvent>(target, 'dragleave', event => handleDragEvent(event, 'leave'))
|
||||
useEventListener<DragEvent>(target, 'drop', event => handleDragEvent(event, 'drop'))
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
isOverDropZone,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user