diff --git a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx index 6bbe16091..1900e4d32 100644 --- a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx +++ b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx @@ -24,6 +24,7 @@ import FileContextMenuWrapper from '@/Components/FileContextMenu/FileContextMenu import PermissionsModalWrapper from '@/Components/PermissionsModal/PermissionsModalWrapper' import { PanelResizedData } from '@/Types/PanelResizedData' import TagContextMenuWrapper from '@/Components/Tags/TagContextMenuWrapper' +import FileDragNDropProvider from '../FileDragNDropProvider/FileDragNDropProvider' type Props = { application: WebApplication @@ -176,19 +177,25 @@ const ApplicationView: FunctionComponent = ({ application, mainApplicatio
- - - + > + + + +
<> diff --git a/packages/web/src/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx b/packages/web/src/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx index 7ae10ffb3..b573cb391 100644 --- a/packages/web/src/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx +++ b/packages/web/src/javascripts/Components/AttachedFilesPopover/AttachedFilesButton.tsx @@ -6,19 +6,18 @@ import { observer } from 'mobx-react-lite' import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' import Icon from '@/Components/Icon/Icon' import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur' -import { FileItem, SNNote } from '@standardnotes/snjs' -import { addToast, ToastType } from '@standardnotes/toast' -import { StreamingFileReader } from '@standardnotes/filepicker' import AttachedFilesPopover from './AttachedFilesPopover' import { usePremiumModal } from '@/Hooks/usePremiumModal' import { PopoverTabs } from './PopoverTabs' -import { isHandlingFileDrag } from '@/Utils/DragTypeCheck' import { NotesController } from '@/Controllers/NotesController' import { FilePreviewModalController } from '@/Controllers/FilePreviewModalController' import { NavigationController } from '@/Controllers/Navigation/NavigationController' import { FeaturesController } from '@/Controllers/FeaturesController' import { FilesController } from '@/Controllers/FilesController' import { SelectedItemsController } from '@/Controllers/SelectedItemsController' +import { useFileDragNDrop } from '@/Components/FileDragNDropProvider/FileDragNDropProvider' +import { FileItem, SNNote } from '@standardnotes/snjs' +import { addToast, ToastType } from '@standardnotes/toast' type Props = { application: WebApplication @@ -120,7 +119,7 @@ const AttachedFilesButton: FunctionComponent = ({ if (!note) { addToast({ type: ToastType.Error, - message: 'Could not attach file because selected note was deleted', + message: 'Could not attach file because selected note was unselected or deleted', }) return } @@ -130,135 +129,36 @@ const AttachedFilesButton: FunctionComponent = ({ [application.items, note], ) - const [isDraggingFiles, setIsDraggingFiles] = useState(false) - const dragCounter = useRef(0) + const { isDraggingFiles, addFilesDragInCallback, addFilesDropCallback } = useFileDragNDrop() - const handleDrag = useCallback( - (event: DragEvent) => { - if (isHandlingFileDrag(event, application)) { - event.preventDefault() - event.stopPropagation() - } - }, - [application], - ) + useEffect(() => { + if (isDraggingFiles && !open) { + void toggleAttachedFilesMenu() + } + }, [isDraggingFiles, open, toggleAttachedFilesMenu]) - const handleDragIn = useCallback( - (event: DragEvent) => { - if (!isHandlingFileDrag(event, application)) { - return - } + const filesDragInCallback = useCallback((tab: PopoverTabs) => { + setCurrentTab(tab) + }, []) - event.preventDefault() - event.stopPropagation() + useEffect(() => { + addFilesDragInCallback(filesDragInCallback) + }, [addFilesDragInCallback, filesDragInCallback]) - switch ((event.target as HTMLElement).id) { - case PopoverTabs.AllFiles: - setCurrentTab(PopoverTabs.AllFiles) - break - case PopoverTabs.AttachedFiles: - setCurrentTab(PopoverTabs.AttachedFiles) - break - } - - dragCounter.current = dragCounter.current + 1 - - if (event.dataTransfer?.items.length) { - setIsDraggingFiles(true) - if (!open) { - toggleAttachedFilesMenu().catch(console.error) - } - } - }, - [open, toggleAttachedFilesMenu, application], - ) - - const handleDragOut = useCallback( - (event: DragEvent) => { - if (!isHandlingFileDrag(event, application)) { - return - } - - event.preventDefault() - event.stopPropagation() - - dragCounter.current = dragCounter.current - 1 - - if (dragCounter.current > 0) { - return - } - - setIsDraggingFiles(false) - }, - [application], - ) - - const handleDrop = useCallback( - (event: DragEvent) => { - if (!isHandlingFileDrag(event, application)) { - return - } - - event.preventDefault() - event.stopPropagation() - - setIsDraggingFiles(false) - - if (!featuresController.hasFiles) { - prospectivelyShowFilesPremiumModal() - return - } - - if (event.dataTransfer?.items.length) { - Array.from(event.dataTransfer.items).forEach(async (item) => { - const fileOrHandle = StreamingFileReader.available() - ? ((await item.getAsFileSystemHandle()) as FileSystemFileHandle) - : item.getAsFile() - - if (!fileOrHandle) { - return - } - - const uploadedFiles = await filesController.uploadNewFile(fileOrHandle) - - if (!uploadedFiles) { - return - } - - if (currentTab === PopoverTabs.AttachedFiles) { - uploadedFiles.forEach((file) => { - attachFileToNote(file).catch(console.error) - }) - } + const filesDropCallback = useCallback( + (uploadedFiles: FileItem[]) => { + if (currentTab === PopoverTabs.AttachedFiles) { + uploadedFiles.forEach((file) => { + attachFileToNote(file).catch(console.error) }) - - event.dataTransfer.clearData() - dragCounter.current = 0 } }, - [ - filesController, - featuresController.hasFiles, - attachFileToNote, - currentTab, - application, - prospectivelyShowFilesPremiumModal, - ], + [attachFileToNote, currentTab], ) useEffect(() => { - window.addEventListener('dragenter', handleDragIn) - window.addEventListener('dragleave', handleDragOut) - window.addEventListener('dragover', handleDrag) - window.addEventListener('drop', handleDrop) - - return () => { - window.removeEventListener('dragenter', handleDragIn) - window.removeEventListener('dragleave', handleDragOut) - window.removeEventListener('dragover', handleDrag) - window.removeEventListener('drop', handleDrop) - } - }, [handleDragIn, handleDrop, handleDrag, handleDragOut]) + addFilesDropCallback(filesDropCallback) + }, [addFilesDropCallback, filesDropCallback]) return (
diff --git a/packages/web/src/javascripts/Components/FileDragNDropProvider/FileDragNDropProvider.tsx b/packages/web/src/javascripts/Components/FileDragNDropProvider/FileDragNDropProvider.tsx new file mode 100644 index 000000000..7a5aa5d50 --- /dev/null +++ b/packages/web/src/javascripts/Components/FileDragNDropProvider/FileDragNDropProvider.tsx @@ -0,0 +1,181 @@ +import { WebApplication } from '@/Application/Application' +import { FeaturesController } from '@/Controllers/FeaturesController' +import { FilesController } from '@/Controllers/FilesController' +import { usePremiumModal } from '@/Hooks/usePremiumModal' +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' + +type FilesDragInCallback = (tab: PopoverTabs) => void +type FilesDropCallback = (uploadedFiles: FileItem[]) => void + +type FileDnDContextData = { + isDraggingFiles: boolean + addFilesDragInCallback: (callback: FilesDragInCallback) => void + addFilesDropCallback: (callback: FilesDropCallback) => void +} + +export const FileDnDContext = createContext(null) + +export const useFileDragNDrop = () => { + const value = useContext(FileDnDContext) + + if (!value) { + throw new Error('Current component must be a child of ') + } + + return value +} + +type Props = { + application: WebApplication + featuresController: FeaturesController + filesController: FilesController + children: ReactNode +} + +const FileDragNDropProvider = ({ application, children, featuresController, filesController }: Props) => { + const premiumModal = usePremiumModal() + const [isDraggingFiles, setIsDraggingFiles] = useState(false) + + const filesDragInCallbackRef = useRef() + const filesDropCallbackRef = useRef() + + const addFilesDragInCallback = useCallback((callback: FilesDragInCallback) => { + filesDragInCallbackRef.current = callback + }, []) + + const addFilesDropCallback = useCallback((callback: FilesDropCallback) => { + filesDropCallbackRef.current = callback + }, []) + + const dragCounter = useRef(0) + + const handleDrag = useCallback( + (event: DragEvent) => { + if (isHandlingFileDrag(event, application)) { + event.preventDefault() + event.stopPropagation() + } + }, + [application], + ) + + const handleDragIn = useCallback( + (event: DragEvent) => { + if (!isHandlingFileDrag(event, application)) { + return + } + + 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 + } + + dragCounter.current = dragCounter.current + 1 + + if (event.dataTransfer?.items.length) { + setIsDraggingFiles(true) + } + }, + [application], + ) + + const handleDragOut = useCallback( + (event: DragEvent) => { + if (!isHandlingFileDrag(event, application)) { + return + } + + event.preventDefault() + event.stopPropagation() + + dragCounter.current = dragCounter.current - 1 + + if (dragCounter.current > 0) { + return + } + + setIsDraggingFiles(false) + }, + [application], + ) + + const handleDrop = useCallback( + (event: DragEvent) => { + if (!isHandlingFileDrag(event, application)) { + setIsDraggingFiles(false) + return + } + + event.preventDefault() + event.stopPropagation() + + setIsDraggingFiles(false) + + if (!featuresController.hasFiles) { + premiumModal.activate('Files') + return + } + + if (event.dataTransfer?.items.length) { + Array.from(event.dataTransfer.items).forEach(async (item) => { + const fileOrHandle = StreamingFileReader.available() + ? ((await item.getAsFileSystemHandle()) as FileSystemFileHandle) + : item.getAsFile() + + if (!fileOrHandle) { + return + } + + const uploadedFiles = await filesController.uploadNewFile(fileOrHandle) + + if (!uploadedFiles) { + return + } + + filesDropCallbackRef.current?.(uploadedFiles) + }) + + event.dataTransfer.clearData() + dragCounter.current = 0 + } + }, + [application, featuresController.hasFiles, filesController, premiumModal], + ) + + useEffect(() => { + window.addEventListener('dragenter', handleDragIn) + window.addEventListener('dragleave', handleDragOut) + window.addEventListener('dragover', handleDrag) + window.addEventListener('drop', handleDrop) + + return () => { + window.removeEventListener('dragenter', handleDragIn) + window.removeEventListener('dragleave', handleDragOut) + window.removeEventListener('dragover', handleDrag) + window.removeEventListener('drop', handleDrop) + } + }, [handleDragIn, handleDrop, handleDrag, handleDragOut]) + + const contextValue = useMemo(() => { + return { + isDraggingFiles, + addFilesDragInCallback, + addFilesDropCallback, + } + }, [addFilesDragInCallback, addFilesDropCallback, isDraggingFiles]) + + return {children} +} + +export default FileDragNDropProvider diff --git a/packages/web/src/javascripts/Components/FileView/FileViewWithoutProtection.tsx b/packages/web/src/javascripts/Components/FileView/FileViewWithoutProtection.tsx index cfe0e61f1..2bce7418f 100644 --- a/packages/web/src/javascripts/Components/FileView/FileViewWithoutProtection.tsx +++ b/packages/web/src/javascripts/Components/FileView/FileViewWithoutProtection.tsx @@ -1,7 +1,6 @@ import { ElementIds } from '@/Constants/ElementIDs' import { observer } from 'mobx-react-lite' import { ChangeEventHandler, useCallback, useRef } from 'react' -import AttachedFilesButton from '@/Components/AttachedFilesPopover/AttachedFilesButton' import FileOptionsPanel from '@/Components/FileContextMenu/FileOptionsPanel' import FilePreview from '@/Components/FilePreview/FilePreview' import { FileViewProps } from './FileViewProps' @@ -53,17 +52,6 @@ const FileViewWithoutProtection = ({ application, viewControllerManager, file }:
-
- -
{ +const MultipleSelectedFiles = ({ filesController, selectionController }: Props) => { const count = selectionController.selectedFilesCount const cancelMultipleSelection = useCallback(() => { @@ -41,18 +22,7 @@ const MultipleSelectedFiles = ({

{count} selected files

-
-
- -
+
diff --git a/packages/web/src/javascripts/Components/NoteGroupView/NoteGroupView.tsx b/packages/web/src/javascripts/Components/NoteGroupView/NoteGroupView.tsx index 00c36e2f3..177cde94a 100644 --- a/packages/web/src/javascripts/Components/NoteGroupView/NoteGroupView.tsx +++ b/packages/web/src/javascripts/Components/NoteGroupView/NoteGroupView.tsx @@ -6,6 +6,7 @@ import NoteView from '@/Components/NoteView/NoteView' import MultipleSelectedFiles from '../MultipleSelectedFiles/MultipleSelectedFiles' import { ElementIds } from '@/Constants/ElementIDs' import FileView from '@/Components/FileView/FileView' +import { FileDnDContext } from '@/Components/FileDragNDropProvider/FileDragNDropProvider' type State = { showMultipleSelectedNotes: boolean @@ -19,6 +20,9 @@ type Props = { } class NoteGroupView extends PureComponent { + static override contextType = FileDnDContext + declare context: React.ContextType + private removeChangeObserver!: () => void constructor(props: Props) { @@ -77,6 +81,8 @@ class NoteGroupView extends PureComponent { } override render() { + const fileDragNDropContext = this.context + const shouldNotShowMultipleSelectedItems = !this.state.showMultipleSelectedNotes && !this.state.showMultipleSelectedFiles @@ -98,16 +104,17 @@ class NoteGroupView extends PureComponent { {this.state.showMultipleSelectedFiles && ( )} + {this.viewControllerManager.navigationController.isInFilesView && fileDragNDropContext?.isDraggingFiles && ( +
+ Drop your files to upload them to Standard Notes +
+ )} + {shouldNotShowMultipleSelectedItems && this.state.controllers.length > 0 && ( <> {this.state.controllers.map((controller) => {