feat: improved file drag-n-drop experience (#1848)

This commit is contained in:
Aman Harwara
2022-10-20 02:18:52 +05:30
committed by GitHub
parent 0282a7958a
commit e99c7b7c51
20 changed files with 534 additions and 1022 deletions

View File

@@ -2,19 +2,22 @@ import { WebApplication } from '@/Application/Application'
import { FeaturesController } from '@/Controllers/FeaturesController'
import { FilesController } from '@/Controllers/FilesController'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { isHandlingFileDrag } from '@/Utils/DragTypeCheck'
import { StreamingFileReader } from '@standardnotes/filepicker'
import { FileItem } from '@standardnotes/snjs'
import { useMemo, useState, createContext, ReactNode, useRef, useCallback, useEffect, useContext } from 'react'
import { PopoverTabs } from '../AttachedFilesPopover/PopoverTabs'
import { useMemo, useState, createContext, ReactNode, useRef, useCallback, useEffect, useContext, memo } from 'react'
import Portal from '../Portal/Portal'
type FilesDragInCallback = (tab: PopoverTabs) => void
type FilesDropCallback = (uploadedFiles: FileItem[]) => void
type FileDragTargetData = {
tooltipText: string
callback: (files: FileItem[]) => void
}
type FileDnDContextData = {
isDraggingFiles: boolean
addFilesDragInCallback: (callback: FilesDragInCallback) => void
addFilesDropCallback: (callback: FilesDropCallback) => void
addDragTarget: (target: HTMLElement, data: FileDragTargetData) => void
removeDragTarget: (target: HTMLElement) => void
}
export const FileDnDContext = createContext<FileDnDContextData | null>(null)
@@ -36,23 +39,57 @@ type Props = {
children: ReactNode
}
const FileDragOverlayClassName =
'overlay pointer-events-none absolute top-0 left-0 z-footer-bar h-full w-full border-2 border-info before:block before:h-full before:w-full before:bg-info before:opacity-20'
const MemoizedChildren = memo(({ children }: { children: ReactNode }) => {
return <>{children}</>
})
const FileDragNDropProvider = ({ application, children, featuresController, filesController }: Props) => {
const premiumModal = usePremiumModal()
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const [tooltipText, setTooltipText] = useState('')
const filesDragInCallbackRef = useRef<FilesDragInCallback>()
const filesDropCallbackRef = useRef<FilesDropCallback>()
const fileDragOverlayRef = useRef<HTMLDivElement>(null)
const addFilesDragInCallback = useCallback((callback: FilesDragInCallback) => {
filesDragInCallbackRef.current = callback
const addOverlayToElement = useCallback((target: Element) => {
if (fileDragOverlayRef.current) {
const targetBoundingRect = target.getBoundingClientRect()
fileDragOverlayRef.current.style.width = `${targetBoundingRect.width}px`
fileDragOverlayRef.current.style.height = `${targetBoundingRect.height}px`
fileDragOverlayRef.current.style.transform = `translate(${targetBoundingRect.x}px, ${targetBoundingRect.y}px)`
}
}, [])
const addFilesDropCallback = useCallback((callback: FilesDropCallback) => {
filesDropCallbackRef.current = callback
const removeOverlayFromElement = useCallback(() => {
if (fileDragOverlayRef.current) {
fileDragOverlayRef.current.style.width = ''
fileDragOverlayRef.current.style.height = ''
fileDragOverlayRef.current.style.transform = ''
}
}, [])
const dragTargets = useRef<Map<Element, FileDragTargetData>>(new Map())
const addDragTarget = useCallback((target: HTMLElement, data: FileDragTargetData) => {
target.setAttribute('data-file-drag-target', '')
dragTargets.current.set(target, data)
}, [])
const removeDragTarget = useCallback((target: HTMLElement) => {
target.removeAttribute('data-file-drag-target')
dragTargets.current.delete(target)
}, [])
const dragCounter = useRef(0)
const resetState = useCallback(() => {
setIsDraggingFiles(false)
setTooltipText('')
removeOverlayFromElement()
}, [removeOverlayFromElement])
const handleDrag = useCallback(
(event: DragEvent) => {
if (isHandlingFileDrag(event, application)) {
@@ -72,22 +109,31 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
event.preventDefault()
event.stopPropagation()
switch ((event.target as HTMLElement).id) {
case PopoverTabs.AllFiles:
filesDragInCallbackRef.current?.(PopoverTabs.AllFiles)
break
case PopoverTabs.AttachedFiles:
filesDragInCallbackRef.current?.(PopoverTabs.AttachedFiles)
break
removeOverlayFromElement()
let closestDragTarget: Element | null = null
if (event.target instanceof HTMLElement) {
closestDragTarget = event.target.closest('[data-file-drag-target]')
}
dragCounter.current = dragCounter.current + 1
if (event.dataTransfer?.items.length) {
setIsDraggingFiles(true)
if (closestDragTarget) {
addOverlayToElement(closestDragTarget)
const tooltipText = dragTargets.current.get(closestDragTarget)?.tooltipText
if (tooltipText) {
setTooltipText(tooltipText)
}
} else {
setTooltipText('')
removeOverlayFromElement()
}
}
},
[application],
[addOverlayToElement, application, removeOverlayFromElement],
)
const handleDragOut = useCallback(
@@ -105,22 +151,22 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
return
}
setIsDraggingFiles(false)
resetState()
},
[application],
[application, resetState],
)
const handleDrop = useCallback(
(event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) {
setIsDraggingFiles(false)
resetState()
return
}
event.preventDefault()
event.stopPropagation()
setIsDraggingFiles(false)
resetState()
if (!featuresController.hasFiles) {
premiumModal.activate('Files')
@@ -143,14 +189,22 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
return
}
filesDropCallbackRef.current?.(uploadedFiles)
let closestDragTarget: Element | null = null
if (event.target instanceof HTMLElement) {
closestDragTarget = event.target.closest('[data-file-drag-target]')
}
if (closestDragTarget && dragTargets.current.has(closestDragTarget)) {
dragTargets.current.get(closestDragTarget)?.callback(uploadedFiles)
}
})
event.dataTransfer.clearData()
dragCounter.current = 0
}
},
[application, featuresController.hasFiles, filesController, premiumModal],
[application, featuresController.hasFiles, filesController, premiumModal, resetState],
)
useEffect(() => {
@@ -170,12 +224,29 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
const contextValue = useMemo(() => {
return {
isDraggingFiles,
addFilesDragInCallback,
addFilesDropCallback,
addDragTarget,
removeDragTarget,
}
}, [addFilesDragInCallback, addFilesDropCallback, isDraggingFiles])
}, [addDragTarget, isDraggingFiles, removeDragTarget])
return <FileDnDContext.Provider value={contextValue}>{children}</FileDnDContext.Provider>
return (
<FileDnDContext.Provider value={contextValue}>
<MemoizedChildren children={children} />
{isDraggingFiles ? (
<>
<div className="pointer-events-none absolute bottom-8 left-1/2 z-dropdown-menu -translate-x-1/2 rounded border-2 border-info bg-default px-5 py-3 shadow-main">
{tooltipText.length ? tooltipText : 'Drop your files to upload them'}
</div>
</>
) : null}
<Portal>
<div
className={classNames(FileDragOverlayClassName, isDraggingFiles ? 'visible' : 'invisible')}
ref={fileDragOverlayRef}
/>
</Portal>
</FileDnDContext.Provider>
)
}
export default FileDragNDropProvider