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

@@ -221,6 +221,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
notesController={viewControllerManager.notesController} notesController={viewControllerManager.notesController}
selectionController={viewControllerManager.selectionController} selectionController={viewControllerManager.selectionController}
searchOptionsController={viewControllerManager.searchOptionsController} searchOptionsController={viewControllerManager.searchOptionsController}
linkingController={viewControllerManager.linkingController}
/> />
<NoteGroupView application={application} /> <NoteGroupView application={application} />
</FileDragNDropProvider> </FileDragNDropProvider>

View File

@@ -1,171 +0,0 @@
import { WebApplication } from '@/Application/Application'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import AttachedFilesPopover from './AttachedFilesPopover'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { PopoverTabs } from './PopoverTabs'
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'
import { classNames } from '@/Utils/ConcatenateClassNames'
import Popover from '../Popover/Popover'
type Props = {
application: WebApplication
featuresController: FeaturesController
filePreviewModalController: FilePreviewModalController
filesController: FilesController
navigationController: NavigationController
notesController: NotesController
selectionController: SelectedItemsController
onClickPreprocessing?: () => Promise<void>
}
const AttachedFilesButton: FunctionComponent<Props> = ({
application,
featuresController,
filesController,
navigationController,
notesController,
selectionController,
onClickPreprocessing,
}: Props) => {
const { allFiles, attachedFiles } = filesController
const attachedFilesCount = attachedFiles.length
const premiumModal = usePremiumModal()
const note: SNNote | undefined = notesController.firstSelectedNote
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [currentTab, setCurrentTab] = useState(
navigationController.isInFilesView ? PopoverTabs.AllFiles : PopoverTabs.AttachedFiles,
)
const isAttachedTabDisabled = navigationController.isInFilesView || selectionController.selectedItemsCount > 1
useEffect(() => {
if (isAttachedTabDisabled && currentTab === PopoverTabs.AttachedFiles) {
setCurrentTab(PopoverTabs.AllFiles)
}
}, [currentTab, isAttachedTabDisabled])
const toggleAttachedFilesMenu = useCallback(async () => {
const newOpenState = !isOpen
if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing()
}
setIsOpen(newOpenState)
}, [onClickPreprocessing, isOpen])
const prospectivelyShowFilesPremiumModal = useCallback(() => {
if (!featuresController.hasFiles) {
premiumModal.activate('Files')
}
}, [featuresController.hasFiles, premiumModal])
const toggleAttachedFilesMenuWithEntitlementCheck = useCallback(async () => {
prospectivelyShowFilesPremiumModal()
await toggleAttachedFilesMenu()
}, [toggleAttachedFilesMenu, prospectivelyShowFilesPremiumModal])
const attachFileToNote = useCallback(
async (file: FileItem) => {
if (!note) {
addToast({
type: ToastType.Error,
message: 'Could not attach file because selected note was unselected or deleted',
})
return
}
await application.items.associateFileWithNote(file, note)
},
[application.items, note],
)
const { isDraggingFiles, addFilesDragInCallback, addFilesDropCallback } = useFileDragNDrop()
useEffect(() => {
if (isDraggingFiles && !isOpen) {
void toggleAttachedFilesMenu()
}
}, [isDraggingFiles, isOpen, toggleAttachedFilesMenu])
const filesDragInCallback = useCallback((tab: PopoverTabs) => {
setCurrentTab(tab)
}, [])
useEffect(() => {
addFilesDragInCallback(filesDragInCallback)
}, [addFilesDragInCallback, filesDragInCallback])
const filesDropCallback = useCallback(
(uploadedFiles: FileItem[]) => {
if (currentTab === PopoverTabs.AttachedFiles) {
uploadedFiles.forEach((file) => {
attachFileToNote(file).catch(console.error)
})
}
},
[attachFileToNote, currentTab],
)
useEffect(() => {
addFilesDropCallback(filesDropCallback)
}, [addFilesDropCallback, filesDropCallback])
return (
<div ref={containerRef}>
<button
className={classNames(
'bg-text-padding flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast',
attachedFilesCount > 0 ? 'py-1 px-3' : '',
)}
title="Attached files"
aria-label="Attached files"
onClick={toggleAttachedFilesMenuWithEntitlementCheck}
ref={buttonRef}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setIsOpen(false)
}
}}
>
<Icon type="folder" />
{attachedFilesCount > 0 && <span className="ml-2 text-sm">{attachedFilesCount}</span>}
</button>
<Popover
togglePopover={toggleAttachedFilesMenuWithEntitlementCheck}
anchorElement={buttonRef.current}
open={isOpen}
className="pt-2 md:pt-0"
>
<AttachedFilesPopover
application={application}
filesController={filesController}
attachedFiles={attachedFiles}
allFiles={allFiles}
currentTab={currentTab}
isDraggingFiles={isDraggingFiles}
setCurrentTab={setCurrentTab}
attachedTabDisabled={isAttachedTabDisabled}
/>
</Popover>
</div>
)
}
export default observer(AttachedFilesButton)

View File

@@ -1,206 +0,0 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { WebApplication } from '@/Application/Application'
import { FileItem } from '@standardnotes/snjs'
import { FilesIllustration } from '@standardnotes/icons'
import { observer } from 'mobx-react-lite'
import { Dispatch, FunctionComponent, SetStateAction, useRef, useState } from 'react'
import Button from '@/Components/Button/Button'
import Icon from '@/Components/Icon/Icon'
import PopoverFileItem from './PopoverFileItem'
import { PopoverFileItemActionType } from './PopoverFileItemAction'
import { PopoverTabs } from './PopoverTabs'
import { FilesController } from '@/Controllers/FilesController'
import { StreamingFileReader } from '@standardnotes/filepicker'
import ClearInputButton from '../ClearInputButton/ClearInputButton'
import DecoratedInput from '../Input/DecoratedInput'
type Props = {
application: WebApplication
filesController: FilesController
allFiles: FileItem[]
attachedFiles: FileItem[]
currentTab: PopoverTabs
isDraggingFiles: boolean
setCurrentTab: Dispatch<SetStateAction<PopoverTabs>>
attachedTabDisabled: boolean
}
const AttachedFilesPopover: FunctionComponent<Props> = ({
application,
filesController,
allFiles,
attachedFiles,
currentTab,
isDraggingFiles,
setCurrentTab,
attachedTabDisabled,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
const filesList = currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles
const filteredList =
searchQuery.length > 0
? filesList.filter((file) => file.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1)
: filesList
const attachFilesIfRequired = (files: FileItem[]) => {
if (currentTab === PopoverTabs.AttachedFiles) {
files.forEach((file) => {
filesController
.handleFileAction({
type: PopoverFileItemActionType.AttachFileToNote,
payload: { file },
})
.catch(console.error)
})
}
}
const handleAttachFilesClick = async () => {
if (!StreamingFileReader.available()) {
fileInputRef.current?.click()
return
}
const uploadedFiles = await filesController.uploadNewFile()
if (!uploadedFiles) {
return
}
attachFilesIfRequired(uploadedFiles)
}
const previewHandler = (file: FileItem) => {
filesController
.handleFileAction({
type: PopoverFileItemActionType.PreviewFile,
payload: { file, otherFiles: currentTab === PopoverTabs.AllFiles ? allFiles : attachedFiles },
})
.catch(console.error)
}
return (
<div
className="flex flex-col"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
style={{
border: isDraggingFiles ? '2px dashed var(--sn-stylekit-info-color)' : '',
}}
>
<div className="flex border-b border-solid border-border">
<button
id={PopoverTabs.AttachedFiles}
className={`relative cursor-pointer border-0 bg-default px-3 py-2.5 text-sm focus:bg-info-backdrop focus:shadow-bottom ${
currentTab === PopoverTabs.AttachedFiles ? 'font-medium text-info shadow-bottom' : 'text-text'
} ${attachedTabDisabled ? 'cursor-not-allowed text-neutral' : ''}`}
onClick={() => {
setCurrentTab(PopoverTabs.AttachedFiles)
}}
disabled={attachedTabDisabled}
>
Attached
</button>
<button
id={PopoverTabs.AllFiles}
className={`relative cursor-pointer border-0 bg-default px-3 py-2.5 text-sm focus:bg-info-backdrop focus:shadow-bottom ${
currentTab === PopoverTabs.AllFiles ? 'font-medium text-info shadow-bottom' : 'text-text'
}`}
onClick={() => {
setCurrentTab(PopoverTabs.AllFiles)
}}
>
All files
</button>
</div>
<div className="max-h-110 min-h-0 overflow-y-auto">
{filteredList.length > 0 || searchQuery.length > 0 ? (
<div className="sticky top-0 left-0 border-b border-solid border-border bg-default p-3">
<DecoratedInput
type="text"
className={{ container: searchQuery.length < 1 ? 'py-1.5 px-0.5' : 'py-0' }}
placeholder="Search items..."
value={searchQuery}
onChange={setSearchQuery}
ref={searchInputRef}
right={[
searchQuery.length > 0 && (
<ClearInputButton
onClick={() => {
setSearchQuery('')
searchInputRef.current?.focus()
}}
/>
),
]}
/>
</div>
) : null}
{filteredList.length > 0 ? (
filteredList.map((file: FileItem) => {
return (
<PopoverFileItem
key={file.uuid}
file={file}
isAttachedToNote={attachedFiles.includes(file)}
handleFileAction={filesController.handleFileAction}
getIconType={application.iconsController.getIconForFileType}
previewHandler={previewHandler}
/>
)
})
) : (
<div className="flex w-full flex-col items-center justify-center py-8">
<div className="mb-2 h-18 w-18">
<FilesIllustration />
</div>
<div className="mb-3 text-sm font-medium">
{searchQuery.length > 0
? 'No result found'
: currentTab === PopoverTabs.AttachedFiles
? 'No files attached to this note'
: 'No files found in this account'}
</div>
<Button onClick={handleAttachFilesClick}>
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
</Button>
<div className="mt-3 text-xs text-passive-0">Or drop your files here</div>
</div>
)}
</div>
<input
type="file"
className="absolute top-0 left-0 -z-50 h-px w-px opacity-0"
multiple
ref={fileInputRef}
onChange={async (event) => {
const files = event.currentTarget.files
if (!files) {
return
}
for (const file of files) {
const uploadedFiles = await filesController.uploadNewFile(file)
if (uploadedFiles) {
attachFilesIfRequired(uploadedFiles)
}
}
}}
/>
{filteredList.length > 0 && (
<button
className="flex w-full cursor-pointer items-center border-0 border-t border-solid border-border bg-transparent px-3 py-3 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={handleAttachFilesClick}
>
<Icon type="add" className="mr-2 text-neutral" />
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
</button>
)}
</div>
)
}
export default observer(AttachedFilesPopover)

View File

@@ -1,124 +0,0 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { KeyboardKey } from '@standardnotes/ui-services'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { FileItem } from '@standardnotes/snjs'
import {
FormEventHandler,
FunctionComponent,
KeyboardEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import Icon from '@/Components/Icon/Icon'
import { PopoverFileItemActionType } from './PopoverFileItemAction'
import PopoverFileSubmenu from './PopoverFileSubmenu'
import { getFileIconComponent } from './getFileIconComponent'
import { PopoverFileItemProps } from './PopoverFileItemProps'
const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
file,
isAttachedToNote,
handleFileAction,
getIconType,
previewHandler,
}) => {
const [fileName, setFileName] = useState(file.name)
const [isRenamingFile, setIsRenamingFile] = useState(false)
const itemRef = useRef<HTMLDivElement>(null)
const fileNameInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (isRenamingFile) {
fileNameInputRef.current?.focus()
}
}, [isRenamingFile])
const renameFile = useCallback(
async (file: FileItem, name: string) => {
if (name.length < 1) {
return
}
await handleFileAction({
type: PopoverFileItemActionType.RenameFile,
payload: {
file,
name,
},
})
setIsRenamingFile(false)
},
[handleFileAction],
)
const handleFileNameInput: FormEventHandler<HTMLInputElement> = useCallback((event) => {
setFileName((event.target as HTMLInputElement).value)
}, [])
const handleFileNameInputKeyDown: KeyboardEventHandler = useCallback(
(event) => {
if (fileName.length > 0 && event.key === KeyboardKey.Enter) {
itemRef.current?.focus()
}
},
[fileName.length],
)
const handleFileNameInputBlur = useCallback(() => {
renameFile(file, fileName).catch(console.error)
}, [file, fileName, renameFile])
const handleClick = useCallback(() => {
if (isRenamingFile) {
return
}
previewHandler(file)
}, [file, isRenamingFile, previewHandler])
return (
<div
ref={itemRef}
className="flex items-center justify-between p-3 focus:shadow-none"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
<div onClick={handleClick} className="flex cursor-pointer items-center">
{getFileIconComponent(getIconType(file.mimeType), 'w-8 h-8 flex-shrink-0')}
<div className="mx-4 flex flex-col">
{isRenamingFile ? (
<input
type="text"
className="text-input mb-1 border border-solid border-border bg-transparent px-1.5 py-1 text-foreground"
value={fileName}
ref={fileNameInputRef}
onInput={handleFileNameInput}
onKeyDown={handleFileNameInputKeyDown}
onBlur={handleFileNameInputBlur}
/>
) : (
<div className="break-word mb-1 text-mobile-menu-item md:text-sm">
<span className="align-middle">{file.name}</span>
{file.protected && (
<Icon type="lock-filled" className="ml-2 inline align-middle text-neutral" size="small" />
)}
</div>
)}
<div className="text-xs text-passive-0">
{file.created_at.toLocaleString()} · {formatSizeToReadableString(file.decryptedSize)}
</div>
</div>
</div>
<PopoverFileSubmenu
file={file}
isAttachedToNote={isAttachedToNote}
handleFileAction={handleFileAction}
setIsRenamingFile={setIsRenamingFile}
previewHandler={previewHandler}
/>
</div>
)
}
export default PopoverFileItem

View File

@@ -1,20 +0,0 @@
import { IconType, FileItem } from '@standardnotes/snjs'
import { Dispatch, SetStateAction } from 'react'
import { PopoverFileItemAction } from './PopoverFileItemAction'
type CommonProps = {
file: FileItem
isAttachedToNote: boolean
handleFileAction: (action: PopoverFileItemAction) => Promise<{
didHandleAction: boolean
}>
previewHandler: (file: FileItem) => void
}
export type PopoverFileItemProps = CommonProps & {
getIconType(type: string): IconType
}
export type PopoverFileSubmenuProps = CommonProps & {
setIsRenamingFile: Dispatch<SetStateAction<boolean>>
}

View File

@@ -1,154 +0,0 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { FunctionComponent, useCallback, useRef, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import Switch from '@/Components/Switch/Switch'
import { PopoverFileSubmenuProps } from './PopoverFileItemProps'
import { PopoverFileItemActionType } from './PopoverFileItemAction'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import Popover from '../Popover/Popover'
const PopoverFileSubmenu: FunctionComponent<PopoverFileSubmenuProps> = ({
file,
isAttachedToNote,
handleFileAction,
setIsRenamingFile,
previewHandler,
}) => {
const menuContainerRef = useRef<HTMLDivElement>(null)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [isFileProtected, setIsFileProtected] = useState(file.protected)
const closeMenu = useCallback(() => {
setIsOpen(false)
}, [])
const toggleMenu = useCallback(() => {
setIsOpen((isOpen) => !isOpen)
}, [])
return (
<div ref={menuContainerRef}>
<button
ref={menuButtonRef}
onClick={toggleMenu}
className="h-7 w-7 cursor-pointer rounded-full border-0 bg-transparent p-1 hover:bg-contrast"
>
<Icon type="more" className="text-neutral" />
</button>
<Popover anchorElement={menuButtonRef.current} open={isOpen} togglePopover={toggleMenu} className="py-2">
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-mobile-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-sm"
onClick={() => {
previewHandler(file)
closeMenu()
}}
>
<Icon type="file" className="mr-2 text-neutral" />
Preview file
</button>
{isAttachedToNote ? (
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-mobile-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-sm"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DetachFileToNote,
payload: { file },
}).catch(console.error)
closeMenu()
}}
>
<Icon type="link-off" className="mr-2 text-neutral" />
Detach from note
</button>
) : (
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-mobile-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-sm"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.AttachFileToNote,
payload: { file },
}).catch(console.error)
closeMenu()
}}
>
<Icon type="link" className="mr-2 text-neutral" />
Attach to note
</button>
)}
<HorizontalSeparator classes="my-1" />
<button
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-mobile-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-sm"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.ToggleFileProtection,
payload: { file },
callback: (isProtected: boolean) => {
setIsFileProtected(isProtected)
},
}).catch(console.error)
}}
>
<span className="flex items-center">
<Icon type="password" className="mr-2 text-neutral" />
Password protection
</span>
<Switch
className="pointer-events-none px-0"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
checked={isFileProtected}
/>
</button>
<HorizontalSeparator classes="my-1" />
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-mobile-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-sm"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DownloadFile,
payload: { file },
}).catch(console.error)
closeMenu()
}}
>
<Icon type="download" className="mr-2 text-neutral" />
Download
</button>
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-mobile-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-sm"
onClick={() => {
setIsRenamingFile(true)
closeMenu()
}}
>
<Icon type="pencil" className="mr-2 text-neutral" />
Rename
</button>
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-mobile-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-sm"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DeleteFile,
payload: { file },
}).catch(console.error)
closeMenu()
}}
>
<Icon type="trash" className="mr-2 text-danger" />
<span className="text-danger">Delete permanently</span>
</button>
<div className="px-3 py-1 text-xs font-medium text-neutral">
<div className="mb-1">
<span className="font-semibold">File ID:</span> {file.uuid}
</div>
<div>
<span className="font-semibold">Size:</span> {formatSizeToReadableString(file.decryptedSize)}
</div>
</div>
</Popover>
</div>
)
}
export default PopoverFileSubmenu

View File

@@ -1,4 +0,0 @@
export enum PopoverTabs {
AttachedFiles = 'attached-files-tab',
AllFiles = 'all-files-tab',
}

View File

@@ -1,7 +1,7 @@
import { KeyboardKey, KeyboardModifier } from '@standardnotes/ui-services' import { KeyboardKey, KeyboardModifier } from '@standardnotes/ui-services'
import { WebApplication } from '@/Application/Application' import { WebApplication } from '@/Application/Application'
import { PANEL_NAME_NOTES } from '@/Constants/Constants' import { PANEL_NAME_NOTES } from '@/Constants/Constants'
import { PrefKey, SystemViewId } from '@standardnotes/snjs' import { FileItem, PrefKey, SystemViewId } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react' import { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react'
import ContentList from '@/Components/ContentListView/ContentList' import ContentList from '@/Components/ContentListView/ContentList'
@@ -24,6 +24,8 @@ import SearchBar from '../SearchBar/SearchBar'
import { SearchOptionsController } from '@/Controllers/SearchOptionsController' import { SearchOptionsController } from '@/Controllers/SearchOptionsController'
import { classNames } from '@/Utils/ConcatenateClassNames' import { classNames } from '@/Utils/ConcatenateClassNames'
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider'
import { LinkingController } from '@/Controllers/LinkingController'
type Props = { type Props = {
accountMenuController: AccountMenuController accountMenuController: AccountMenuController
@@ -35,6 +37,7 @@ type Props = {
notesController: NotesController notesController: NotesController
selectionController: SelectedItemsController selectionController: SelectedItemsController
searchOptionsController: SearchOptionsController searchOptionsController: SearchOptionsController
linkingController: LinkingController
} }
const ContentListView: FunctionComponent<Props> = ({ const ContentListView: FunctionComponent<Props> = ({
@@ -47,12 +50,54 @@ const ContentListView: FunctionComponent<Props> = ({
notesController, notesController,
selectionController, selectionController,
searchOptionsController, searchOptionsController,
linkingController,
}) => { }) => {
const { isNotesListVisibleOnTablets, toggleAppPane } = useResponsiveAppPane() const { isNotesListVisibleOnTablets, toggleAppPane } = useResponsiveAppPane()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const itemsViewPanelRef = useRef<HTMLDivElement>(null) const itemsViewPanelRef = useRef<HTMLDivElement>(null)
const { addDragTarget, removeDragTarget } = useFileDragNDrop()
const fileDropCallback = useCallback(
async (files: FileItem[]) => {
const currentTag = navigationController.selected
if (!currentTag) {
return
}
if (navigationController.isInAnySystemView() || navigationController.isInSmartView()) {
console.error('Trying to link uploaded files to smart view')
return
}
files.forEach(async (file) => {
await linkingController.linkItems(file, currentTag)
})
},
[navigationController, linkingController],
)
useEffect(() => {
const target = itemsViewPanelRef.current
const currentTag = navigationController.selected
const shouldAddDropTarget = !navigationController.isInAnySystemView() && !navigationController.isInSmartView()
if (target && shouldAddDropTarget && currentTag) {
addDragTarget(target, {
tooltipText: `Drop your files to upload and link them to tag "${currentTag.title}"`,
callback: fileDropCallback,
})
}
return () => {
if (target) {
removeDragTarget(target)
}
}
}, [addDragTarget, fileDropCallback, navigationController, navigationController.selected, removeDragTarget])
const { const {
completedFullSync, completedFullSync,
createNewNote, createNewNote,

View File

@@ -1,7 +1,7 @@
import { FileItem } from '@standardnotes/snjs' import { FileItem } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useRef } from 'react' import { FunctionComponent, useCallback, useRef } from 'react'
import { getFileIconComponent } from '../AttachedFilesPopover/getFileIconComponent' import { getFileIconComponent } from '../FilePreview/getFileIconComponent'
import ListItemConflictIndicator from './ListItemConflictIndicator' import ListItemConflictIndicator from './ListItemConflictIndicator'
import ListItemFlagIcons from './ListItemFlagIcons' import ListItemFlagIcons from './ListItemFlagIcons'
import ListItemTags from './ListItemTags' import ListItemTags from './ListItemTags'

View File

@@ -2,19 +2,22 @@ import { WebApplication } from '@/Application/Application'
import { FeaturesController } from '@/Controllers/FeaturesController' import { FeaturesController } from '@/Controllers/FeaturesController'
import { FilesController } from '@/Controllers/FilesController' import { FilesController } from '@/Controllers/FilesController'
import { usePremiumModal } from '@/Hooks/usePremiumModal' import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { isHandlingFileDrag } from '@/Utils/DragTypeCheck' import { isHandlingFileDrag } from '@/Utils/DragTypeCheck'
import { StreamingFileReader } from '@standardnotes/filepicker' import { StreamingFileReader } from '@standardnotes/filepicker'
import { FileItem } from '@standardnotes/snjs' import { FileItem } from '@standardnotes/snjs'
import { useMemo, useState, createContext, ReactNode, useRef, useCallback, useEffect, useContext } from 'react' import { useMemo, useState, createContext, ReactNode, useRef, useCallback, useEffect, useContext, memo } from 'react'
import { PopoverTabs } from '../AttachedFilesPopover/PopoverTabs' import Portal from '../Portal/Portal'
type FilesDragInCallback = (tab: PopoverTabs) => void type FileDragTargetData = {
type FilesDropCallback = (uploadedFiles: FileItem[]) => void tooltipText: string
callback: (files: FileItem[]) => void
}
type FileDnDContextData = { type FileDnDContextData = {
isDraggingFiles: boolean isDraggingFiles: boolean
addFilesDragInCallback: (callback: FilesDragInCallback) => void addDragTarget: (target: HTMLElement, data: FileDragTargetData) => void
addFilesDropCallback: (callback: FilesDropCallback) => void removeDragTarget: (target: HTMLElement) => void
} }
export const FileDnDContext = createContext<FileDnDContextData | null>(null) export const FileDnDContext = createContext<FileDnDContextData | null>(null)
@@ -36,23 +39,57 @@ type Props = {
children: ReactNode 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 FileDragNDropProvider = ({ application, children, featuresController, filesController }: Props) => {
const premiumModal = usePremiumModal() const premiumModal = usePremiumModal()
const [isDraggingFiles, setIsDraggingFiles] = useState(false) const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const [tooltipText, setTooltipText] = useState('')
const filesDragInCallbackRef = useRef<FilesDragInCallback>() const fileDragOverlayRef = useRef<HTMLDivElement>(null)
const filesDropCallbackRef = useRef<FilesDropCallback>()
const addFilesDragInCallback = useCallback((callback: FilesDragInCallback) => { const addOverlayToElement = useCallback((target: Element) => {
filesDragInCallbackRef.current = callback 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) => { const removeOverlayFromElement = useCallback(() => {
filesDropCallbackRef.current = callback 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 dragCounter = useRef(0)
const resetState = useCallback(() => {
setIsDraggingFiles(false)
setTooltipText('')
removeOverlayFromElement()
}, [removeOverlayFromElement])
const handleDrag = useCallback( const handleDrag = useCallback(
(event: DragEvent) => { (event: DragEvent) => {
if (isHandlingFileDrag(event, application)) { if (isHandlingFileDrag(event, application)) {
@@ -72,22 +109,31 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
switch ((event.target as HTMLElement).id) { removeOverlayFromElement()
case PopoverTabs.AllFiles:
filesDragInCallbackRef.current?.(PopoverTabs.AllFiles) let closestDragTarget: Element | null = null
break
case PopoverTabs.AttachedFiles: if (event.target instanceof HTMLElement) {
filesDragInCallbackRef.current?.(PopoverTabs.AttachedFiles) closestDragTarget = event.target.closest('[data-file-drag-target]')
break
} }
dragCounter.current = dragCounter.current + 1 dragCounter.current = dragCounter.current + 1
if (event.dataTransfer?.items.length) { if (event.dataTransfer?.items.length) {
setIsDraggingFiles(true) 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( const handleDragOut = useCallback(
@@ -105,22 +151,22 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
return return
} }
setIsDraggingFiles(false) resetState()
}, },
[application], [application, resetState],
) )
const handleDrop = useCallback( const handleDrop = useCallback(
(event: DragEvent) => { (event: DragEvent) => {
if (!isHandlingFileDrag(event, application)) { if (!isHandlingFileDrag(event, application)) {
setIsDraggingFiles(false) resetState()
return return
} }
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
setIsDraggingFiles(false) resetState()
if (!featuresController.hasFiles) { if (!featuresController.hasFiles) {
premiumModal.activate('Files') premiumModal.activate('Files')
@@ -143,14 +189,22 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
return 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() event.dataTransfer.clearData()
dragCounter.current = 0 dragCounter.current = 0
} }
}, },
[application, featuresController.hasFiles, filesController, premiumModal], [application, featuresController.hasFiles, filesController, premiumModal, resetState],
) )
useEffect(() => { useEffect(() => {
@@ -170,12 +224,29 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
const contextValue = useMemo(() => { const contextValue = useMemo(() => {
return { return {
isDraggingFiles, isDraggingFiles,
addFilesDragInCallback, addDragTarget,
addFilesDropCallback, 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 export default FileDragNDropProvider

View File

@@ -1,7 +1,7 @@
import { WebApplication } from '@/Application/Application' import { WebApplication } from '@/Application/Application'
import { DialogContent, DialogOverlay } from '@reach/dialog' import { DialogContent, DialogOverlay } from '@reach/dialog'
import { FunctionComponent, KeyboardEventHandler, useCallback, useMemo, useRef, useState } from 'react' import { FunctionComponent, KeyboardEventHandler, useCallback, useMemo, useRef, useState } from 'react'
import { getFileIconComponent } from '@/Components/AttachedFilesPopover/getFileIconComponent' import { getFileIconComponent } from './getFileIconComponent'
import Icon from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import FilePreviewInfoPanel from './FilePreviewInfoPanel' import FilePreviewInfoPanel from './FilePreviewInfoPanel'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'

View File

@@ -1,6 +1,6 @@
import { ElementIds } from '@/Constants/ElementIDs' import { ElementIds } from '@/Constants/ElementIDs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { ChangeEventHandler, useCallback, useRef, useState } from 'react' import { ChangeEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import FileOptionsPanel from '@/Components/FileContextMenu/FileOptionsPanel' import FileOptionsPanel from '@/Components/FileContextMenu/FileOptionsPanel'
import FilePreview from '@/Components/FilePreview/FilePreview' import FilePreview from '@/Components/FilePreview/FilePreview'
import { FileViewProps } from './FileViewProps' import { FileViewProps } from './FileViewProps'
@@ -10,6 +10,7 @@ import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContaine
import Icon from '../Icon/Icon' import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover' import Popover from '../Popover/Popover'
import FilePreviewInfoPanel from '../FilePreview/FilePreviewInfoPanel' import FilePreviewInfoPanel from '../FilePreview/FilePreviewInfoPanel'
import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider'
const SyncTimeoutNoDebounceMs = 100 const SyncTimeoutNoDebounceMs = 100
const SyncTimeoutDebounceMs = 350 const SyncTimeoutDebounceMs = 350
@@ -40,8 +41,33 @@ const FileViewWithoutProtection = ({ application, viewControllerManager, file }:
[application, file], [application, file],
) )
const fileDragTargetRef = useRef<HTMLDivElement>(null)
const { addDragTarget, removeDragTarget } = useFileDragNDrop()
useEffect(() => {
const target = fileDragTargetRef.current
if (target) {
addDragTarget(target, {
tooltipText: 'Drop your files to upload and link them to the current file',
callback(files) {
files.forEach(async (uploadedFile) => {
await viewControllerManager.linkingController.linkItems(uploadedFile, file)
})
},
})
}
return () => {
if (target) {
removeDragTarget(target)
}
}
}, [addDragTarget, file, removeDragTarget, viewControllerManager.linkingController])
return ( return (
<div className="sn-component section editor" aria-label="File"> <div className="sn-component section editor" aria-label="File" ref={fileDragTargetRef}>
<div className="flex flex-col"> <div className="flex flex-col">
<div <div
className="content-title-bar section-title-bar section-title-bar z-editor-title-bar w-full" className="content-title-bar section-title-bar section-title-bar z-editor-title-bar w-full"

View File

@@ -5,10 +5,6 @@ import { WebApplication } from '@/Application/Application'
import PinNoteButton from '@/Components/PinNoteButton/PinNoteButton' import PinNoteButton from '@/Components/PinNoteButton/PinNoteButton'
import Button from '../Button/Button' import Button from '../Button/Button'
import { useCallback } from 'react' import { useCallback } from 'react'
import AttachedFilesButton from '../AttachedFilesPopover/AttachedFilesButton'
import { FeaturesController } from '@/Controllers/FeaturesController'
import { FilePreviewModalController } from '@/Controllers/FilePreviewModalController'
import { FilesController } from '@/Controllers/FilesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController' import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { NotesController } from '@/Controllers/NotesController' import { NotesController } from '@/Controllers/NotesController'
import { SelectedItemsController } from '@/Controllers/SelectedItemsController' import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
@@ -17,9 +13,6 @@ import { LinkingController } from '@/Controllers/LinkingController'
type Props = { type Props = {
application: WebApplication application: WebApplication
featuresController: FeaturesController
filePreviewModalController: FilePreviewModalController
filesController: FilesController
navigationController: NavigationController navigationController: NavigationController
notesController: NotesController notesController: NotesController
selectionController: SelectedItemsController selectionController: SelectedItemsController
@@ -29,9 +22,6 @@ type Props = {
const MultipleSelectedNotes = ({ const MultipleSelectedNotes = ({
application, application,
featuresController,
filePreviewModalController,
filesController,
navigationController, navigationController,
notesController, notesController,
linkingController, linkingController,
@@ -49,17 +39,6 @@ const MultipleSelectedNotes = ({
<div className="flex w-full items-center justify-between p-4"> <div className="flex w-full items-center justify-between p-4">
<h1 className="m-0 text-lg font-bold">{count} selected notes</h1> <h1 className="m-0 text-lg font-bold">{count} selected notes</h1>
<div className="flex"> <div className="flex">
<div className="mr-3">
<AttachedFilesButton
application={application}
featuresController={featuresController}
filePreviewModalController={filePreviewModalController}
filesController={filesController}
navigationController={navigationController}
notesController={notesController}
selectionController={selectionController}
/>
</div>
<div className="mr-3"> <div className="mr-3">
<PinNoteButton notesController={notesController} /> <PinNoteButton notesController={notesController} />
</div> </div>

View File

@@ -4,7 +4,6 @@ import { WebApplication } from '@/Application/Application'
import MultipleSelectedNotes from '@/Components/MultipleSelectedNotes/MultipleSelectedNotes' import MultipleSelectedNotes from '@/Components/MultipleSelectedNotes/MultipleSelectedNotes'
import MultipleSelectedFiles from '../MultipleSelectedFiles/MultipleSelectedFiles' import MultipleSelectedFiles from '../MultipleSelectedFiles/MultipleSelectedFiles'
import { ElementIds } from '@/Constants/ElementIDs' import { ElementIds } from '@/Constants/ElementIDs'
import { FileDnDContext } from '@/Components/FileDragNDropProvider/FileDragNDropProvider'
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata' import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import ResponsivePaneContent from '../ResponsivePane/ResponsivePaneContent' import ResponsivePaneContent from '../ResponsivePane/ResponsivePaneContent'
import FileView from '../FileView/FileView' import FileView from '../FileView/FileView'
@@ -24,9 +23,6 @@ type Props = {
} }
class NoteGroupView extends PureComponent<Props, State> { class NoteGroupView extends PureComponent<Props, State> {
static override contextType = FileDnDContext
declare context: React.ContextType<typeof FileDnDContext>
private removeChangeObserver!: () => void private removeChangeObserver!: () => void
constructor(props: Props) { constructor(props: Props) {
@@ -94,8 +90,6 @@ class NoteGroupView extends PureComponent<Props, State> {
} }
override render() { override render() {
const fileDragNDropContext = this.context
const shouldNotShowMultipleSelectedItems = const shouldNotShowMultipleSelectedItems =
!this.state.showMultipleSelectedNotes && !this.state.showMultipleSelectedFiles !this.state.showMultipleSelectedNotes && !this.state.showMultipleSelectedFiles
@@ -112,10 +106,7 @@ class NoteGroupView extends PureComponent<Props, State> {
{this.state.showMultipleSelectedNotes && ( {this.state.showMultipleSelectedNotes && (
<MultipleSelectedNotes <MultipleSelectedNotes
application={this.application} application={this.application}
filesController={this.viewControllerManager.filesController}
selectionController={this.viewControllerManager.selectionController} selectionController={this.viewControllerManager.selectionController}
featuresController={this.viewControllerManager.featuresController}
filePreviewModalController={this.viewControllerManager.filePreviewModalController}
navigationController={this.viewControllerManager.navigationController} navigationController={this.viewControllerManager.navigationController}
notesController={this.viewControllerManager.notesController} notesController={this.viewControllerManager.notesController}
linkingController={this.viewControllerManager.linkingController} linkingController={this.viewControllerManager.linkingController}
@@ -128,11 +119,6 @@ class NoteGroupView extends PureComponent<Props, State> {
selectionController={this.viewControllerManager.selectionController} selectionController={this.viewControllerManager.selectionController}
/> />
)} )}
{this.viewControllerManager.navigationController.isInFilesView && fileDragNDropContext?.isDraggingFiles && (
<div className="absolute bottom-8 left-1/2 z-dropdown-menu -translate-x-1/2 rounded bg-info px-5 py-3 text-info-contrast shadow-main">
Drop your files to upload them
</div>
)}
{shouldNotShowMultipleSelectedItems && hasControllers && canRenderEditorView && ( {shouldNotShowMultipleSelectedItems && hasControllers && canRenderEditorView && (
<> <>
{this.state.controllers.map((controller) => { {this.state.controllers.map((controller) => {

View File

@@ -30,7 +30,6 @@ import ComponentView from '@/Components/ComponentView/ComponentView'
import PanelResizer, { PanelSide, PanelResizeType } from '@/Components/PanelResizer/PanelResizer' import PanelResizer, { PanelSide, PanelResizeType } from '@/Components/PanelResizer/PanelResizer'
import { ElementIds } from '@/Constants/ElementIDs' import { ElementIds } from '@/Constants/ElementIDs'
import ChangeEditorButton from '@/Components/ChangeEditor/ChangeEditorButton' import ChangeEditorButton from '@/Components/ChangeEditor/ChangeEditorButton'
import AttachedFilesButton from '@/Components/AttachedFilesPopover/AttachedFilesButton'
import EditingDisabledBanner from './EditingDisabledBanner' import EditingDisabledBanner from './EditingDisabledBanner'
import { import {
transactionForAssociateComponentWithCurrentNote, transactionForAssociateComponentWithCurrentNote,
@@ -46,6 +45,7 @@ import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContaine
import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator' import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator'
import { PrefDefaults } from '@/Constants/PrefDefaults' import { PrefDefaults } from '@/Constants/PrefDefaults'
import LinkedItemsButton from '../LinkedItems/LinkedItemsButton' import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
import NoteViewFileDropTarget from './NoteViewFileDropTarget'
const MinimumStatusDuration = 400 const MinimumStatusDuration = 400
const TextareaDebounce = 100 const TextareaDebounce = 100
@@ -118,6 +118,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null
private noteViewElementRef: RefObject<HTMLDivElement>
private editorContentRef: RefObject<HTMLDivElement> private editorContentRef: RefObject<HTMLDivElement>
constructor(props: NoteViewProps) { constructor(props: NoteViewProps) {
@@ -159,6 +160,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
noteType: this.controller.item.noteType, noteType: this.controller.item.noteType,
} }
this.noteViewElementRef = createRef<HTMLDivElement>()
this.editorContentRef = createRef<HTMLDivElement>() this.editorContentRef = createRef<HTMLDivElement>()
window.addEventListener('scroll', this.handleWindowScroll) window.addEventListener('scroll', this.handleWindowScroll)
@@ -949,7 +951,15 @@ class NoteView extends PureComponent<NoteViewProps, State> {
} }
return ( return (
<div aria-label="Note" className="section editor sn-component"> <div aria-label="Note" className="section editor sn-component" ref={this.noteViewElementRef}>
{this.note && (
<NoteViewFileDropTarget
note={this.note}
linkingController={this.viewControllerManager.linkingController}
noteViewElement={this.noteViewElementRef.current}
/>
)}
{this.state.noteLocked && ( {this.state.noteLocked && (
<EditingDisabledBanner <EditingDisabledBanner
onMouseLeave={() => { onMouseLeave={() => {
@@ -1014,16 +1024,6 @@ class NoteView extends PureComponent<NoteViewProps, State> {
linkingController={this.viewControllerManager.linkingController} linkingController={this.viewControllerManager.linkingController}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction} onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/> />
<AttachedFilesButton
application={this.application}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
featuresController={this.viewControllerManager.featuresController}
filePreviewModalController={this.viewControllerManager.filePreviewModalController}
filesController={this.viewControllerManager.filesController}
navigationController={this.viewControllerManager.navigationController}
notesController={this.viewControllerManager.notesController}
selectionController={this.viewControllerManager.selectionController}
/>
<ChangeEditorButton <ChangeEditorButton
application={this.application} application={this.application}
viewControllerManager={this.viewControllerManager} viewControllerManager={this.viewControllerManager}

View File

@@ -0,0 +1,42 @@
import { LinkingController } from '@/Controllers/LinkingController'
import { SNNote } from '@standardnotes/snjs'
import { useEffect } from 'react'
import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider'
type Props = {
note: SNNote
linkingController: LinkingController
noteViewElement: HTMLElement | null
}
const NoteViewFileDropTarget = ({ note, linkingController, noteViewElement }: Props) => {
const { isDraggingFiles, addDragTarget, removeDragTarget } = useFileDragNDrop()
useEffect(() => {
const target = noteViewElement
if (target) {
addDragTarget(target, {
tooltipText: 'Drop your files to upload and link them to the current note',
callback(files) {
files.forEach(async (uploadedFile) => {
await linkingController.linkItems(uploadedFile, note)
})
},
})
}
return () => {
if (target) {
removeDragTarget(target)
}
}
}, [addDragTarget, linkingController, note, noteViewElement, removeDragTarget])
return isDraggingFiles ? (
// Required to block drag events to editor iframe
<div id="file-drag-iframe-overlay" className="absolute top-0 left-0 z-dropdown-menu h-full w-full" />
) : null
}
export default NoteViewFileDropTarget

View File

@@ -51,6 +51,7 @@ const TagsList: FunctionComponent<Props> = ({ viewControllerManager }: Props) =>
tag={tag} tag={tag}
tagsState={tagsState} tagsState={tagsState}
features={viewControllerManager.featuresController} features={viewControllerManager.featuresController}
linkingController={viewControllerManager.linkingController}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
/> />
) )

View File

@@ -23,11 +23,15 @@ import { DropItem, DropProps, ItemTypes } from './DragNDrop'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider' import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata' import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import { classNames } from '@/Utils/ConcatenateClassNames' import { classNames } from '@/Utils/ConcatenateClassNames'
import { mergeRefs } from '@/Hooks/mergeRefs'
import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider'
import { LinkingController } from '@/Controllers/LinkingController'
type Props = { type Props = {
tag: SNTag tag: SNTag
tagsState: NavigationController tagsState: NavigationController
features: FeaturesController features: FeaturesController
linkingController: LinkingController
level: number level: number
onContextMenu: (tag: SNTag, posX: number, posY: number) => void onContextMenu: (tag: SNTag, posX: number, posY: number) => void
} }
@@ -35,288 +39,316 @@ type Props = {
const PADDING_BASE_PX = 14 const PADDING_BASE_PX = 14
const PADDING_PER_LEVEL_PX = 21 const PADDING_PER_LEVEL_PX = 21
export const TagsListItem: FunctionComponent<Props> = observer(({ tag, features, tagsState, level, onContextMenu }) => { export const TagsListItem: FunctionComponent<Props> = observer(
const { toggleAppPane } = useResponsiveAppPane() ({ tag, features, tagsState, level, onContextMenu, linkingController }) => {
const { toggleAppPane } = useResponsiveAppPane()
const [title, setTitle] = useState(tag.title || '') const [title, setTitle] = useState(tag.title || '')
const [subtagTitle, setSubtagTitle] = useState('') const [subtagTitle, setSubtagTitle] = useState('')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const subtagInputRef = useRef<HTMLInputElement>(null) const subtagInputRef = useRef<HTMLInputElement>(null)
const menuButtonRef = useRef<HTMLAnchorElement>(null) const menuButtonRef = useRef<HTMLAnchorElement>(null)
const isSelected = tagsState.selected === tag const isSelected = tagsState.selected === tag
const isEditing = tagsState.editingTag === tag const isEditing = tagsState.editingTag === tag
const isAddingSubtag = tagsState.addingSubtagTo === tag const isAddingSubtag = tagsState.addingSubtagTo === tag
const noteCounts = computed(() => tagsState.getNotesCount(tag)) const noteCounts = computed(() => tagsState.getNotesCount(tag))
const childrenTags = computed(() => tagsState.getChildren(tag)).get() const childrenTags = computed(() => tagsState.getChildren(tag)).get()
const hasChildren = childrenTags.length > 0 const hasChildren = childrenTags.length > 0
const hasFolders = features.hasFolders const hasFolders = features.hasFolders
const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder
const premiumModal = usePremiumModal() const premiumModal = usePremiumModal()
const [showChildren, setShowChildren] = useState(tag.expanded) const [showChildren, setShowChildren] = useState(tag.expanded)
const [hadChildren, setHadChildren] = useState(hasChildren) const [hadChildren, setHadChildren] = useState(hasChildren)
useEffect(() => { useEffect(() => {
if (!hadChildren && hasChildren) { if (!hadChildren && hasChildren) {
setShowChildren(true) setShowChildren(true)
} }
setHadChildren(hasChildren) setHadChildren(hasChildren)
}, [hadChildren, hasChildren]) }, [hadChildren, hasChildren])
useEffect(() => { useEffect(() => {
setTitle(tag.title || '') setTitle(tag.title || '')
}, [setTitle, tag]) }, [setTitle, tag])
const toggleChildren: MouseEventHandler = useCallback( const toggleChildren: MouseEventHandler = useCallback(
(e) => { (e) => {
e.stopPropagation() e.stopPropagation()
setShowChildren((x) => { setShowChildren((x) => {
tagsState.setExpanded(tag, !x) tagsState.setExpanded(tag, !x)
return !x return !x
}) })
}, },
[setShowChildren, tag, tagsState], [setShowChildren, tag, tagsState],
) )
const selectCurrentTag = useCallback(async () => { const selectCurrentTag = useCallback(async () => {
await tagsState.setSelectedTag(tag) await tagsState.setSelectedTag(tag)
toggleAppPane(AppPaneId.Items) toggleAppPane(AppPaneId.Items)
}, [tagsState, tag, toggleAppPane]) }, [tagsState, tag, toggleAppPane])
const onBlur = useCallback(() => { const onBlur = useCallback(() => {
tagsState.save(tag, title).catch(console.error) tagsState.save(tag, title).catch(console.error)
setTitle(tag.title) setTitle(tag.title)
}, [tagsState, tag, title, setTitle]) }, [tagsState, tag, title, setTitle])
const onInput: FormEventHandler = useCallback( const onInput: FormEventHandler = useCallback(
(e) => { (e) => {
const value = (e.target as HTMLInputElement).value
setTitle(value)
},
[setTitle],
)
const onKeyDown: KeyboardEventHandler = useCallback(
(e) => {
if (e.key === KeyboardKey.Enter) {
inputRef.current?.blur()
e.preventDefault()
}
},
[inputRef],
)
useEffect(() => {
if (isEditing) {
inputRef.current?.focus()
}
}, [inputRef, isEditing])
const onSubtagInput: FormEventHandler<HTMLInputElement> = useCallback((e) => {
const value = (e.target as HTMLInputElement).value const value = (e.target as HTMLInputElement).value
setTitle(value) setSubtagTitle(value)
}, }, [])
[setTitle],
)
const onKeyDown: KeyboardEventHandler = useCallback( const onSubtagInputBlur = useCallback(() => {
(e) => { tagsState.createSubtagAndAssignParent(tag, subtagTitle).catch(console.error)
if (e.key === KeyboardKey.Enter) { setSubtagTitle('')
inputRef.current?.blur() }, [subtagTitle, tag, tagsState])
e.preventDefault()
}
},
[inputRef],
)
useEffect(() => { const onSubtagKeyDown: KeyboardEventHandler = useCallback(
if (isEditing) { (e) => {
inputRef.current?.focus() if (e.key === KeyboardKey.Enter) {
} e.preventDefault()
}, [inputRef, isEditing]) subtagInputRef.current?.blur()
}
const onSubtagInput: FormEventHandler<HTMLInputElement> = useCallback((e) => {
const value = (e.target as HTMLInputElement).value
setSubtagTitle(value)
}, [])
const onSubtagInputBlur = useCallback(() => {
tagsState.createSubtagAndAssignParent(tag, subtagTitle).catch(console.error)
setSubtagTitle('')
}, [subtagTitle, tag, tagsState])
const onSubtagKeyDown: KeyboardEventHandler = useCallback(
(e) => {
if (e.key === KeyboardKey.Enter) {
e.preventDefault()
subtagInputRef.current?.blur()
}
},
[subtagInputRef],
)
useEffect(() => {
if (isAddingSubtag) {
subtagInputRef.current?.focus()
}
}, [subtagInputRef, isAddingSubtag])
const [, dragRef] = useDrag(
() => ({
type: ItemTypes.TAG,
item: { uuid: tag.uuid },
canDrag: () => {
return true
}, },
collect: (monitor) => ({ [subtagInputRef],
isDragging: !!monitor.isDragging(), )
useEffect(() => {
if (isAddingSubtag) {
subtagInputRef.current?.focus()
}
}, [subtagInputRef, isAddingSubtag])
const [, dragRef] = useDrag(
() => ({
type: ItemTypes.TAG,
item: { uuid: tag.uuid },
canDrag: () => {
return true
},
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
}), }),
}), [tag],
[tag], )
)
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>( const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
() => ({ () => ({
accept: ItemTypes.TAG, accept: ItemTypes.TAG,
canDrop: (item) => { canDrop: (item) => {
return tagsState.isValidTagParent(tag, item as SNTag) return tagsState.isValidTagParent(tag, item as SNTag)
}, },
drop: (item) => { drop: (item) => {
if (!hasFolders) { if (!hasFolders) {
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME) premiumModal.activate(TAG_FOLDERS_FEATURE_NAME)
return
}
tagsState.assignParent(item.uuid, tag.uuid).catch(console.error)
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
}),
[tag, tagsState, hasFolders, premiumModal],
)
const readyToDrop = isOver && canDrop
const toggleContextMenu: MouseEventHandler<HTMLAnchorElement> = useCallback(
(event) => {
event.preventDefault()
event.stopPropagation()
if (!menuButtonRef.current) {
return return
} }
tagsState.assignParent(item.uuid, tag.uuid).catch(console.error)
const contextMenuOpen = tagsState.contextMenuOpen
const menuButtonRect = menuButtonRef.current?.getBoundingClientRect()
if (contextMenuOpen) {
tagsState.setContextMenuOpen(false)
} else {
onContextMenu(tag, menuButtonRect.right, menuButtonRect.top)
}
}, },
collect: (monitor) => ({ [onContextMenu, tagsState, tag],
isOver: !!monitor.isOver(), )
canDrop: !!monitor.canDrop(),
}),
}),
[tag, tagsState, hasFolders, premiumModal],
)
const readyToDrop = isOver && canDrop const tagRef = useRef<HTMLDivElement>(null)
const toggleContextMenu: MouseEventHandler<HTMLAnchorElement> = useCallback( const { addDragTarget, removeDragTarget } = useFileDragNDrop()
(event) => {
event.preventDefault()
event.stopPropagation()
if (!menuButtonRef.current) { useEffect(() => {
return const target = tagRef.current
if (target) {
addDragTarget(target, {
tooltipText: `Drop your files to upload and link them to tag "${tag.title}"`,
callback(files) {
files.forEach(async (file) => {
await linkingController.linkItems(file, tag)
})
},
})
} }
const contextMenuOpen = tagsState.contextMenuOpen return () => {
const menuButtonRect = menuButtonRef.current?.getBoundingClientRect() if (target) {
removeDragTarget(target)
if (contextMenuOpen) { }
tagsState.setContextMenuOpen(false)
} else {
onContextMenu(tag, menuButtonRect.right, menuButtonRect.top)
} }
}, }, [addDragTarget, linkingController, removeDragTarget, tag])
[onContextMenu, tagsState, tag],
)
return ( return (
<> <>
<div
role="button"
tabIndex={0}
className={classNames(
'tag py-2 px-3.5 focus:shadow-inner md:py-1',
isSelected && 'selected',
readyToDrop && 'is-drag-over',
)}
onClick={selectCurrentTag}
ref={dragRef}
style={{
paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
}}
onContextMenu={(e) => {
e.preventDefault()
onContextMenu(tag, e.clientX, e.clientY)
}}
>
<div className="tag-info" title={title} ref={dropRef}>
{hasAtLeastOneFolder && (
<div className="tag-fold-container">
<a
role="button"
className={`tag-fold focus:shadow-inner ${showChildren ? 'opened' : 'closed'} ${
!hasChildren ? 'invisible' : ''
}`}
onClick={hasChildren ? toggleChildren : undefined}
>
<Icon className={'text-neutral'} type={showChildren ? 'menu-arrow-down-alt' : 'menu-arrow-right'} />
</a>
</div>
)}
<div className={'tag-icon draggable mr-2'} ref={dragRef}>
<Icon type="hashtag" className={`${isSelected ? 'text-info' : 'text-neutral'}`} />
</div>
{isEditing ? (
<input
className={'title editing focus:shadow-none focus:outline-none'}
id={`react-tag-${tag.uuid}`}
onBlur={onBlur}
onInput={onInput}
value={title}
onKeyDown={onKeyDown}
spellCheck={false}
ref={inputRef}
/>
) : (
<div
className={'title overflow-hidden text-left focus:shadow-none focus:outline-none'}
id={`react-tag-${tag.uuid}`}
>
{title}
</div>
)}
<div className="flex items-center">
<a
role="button"
className={`mr-2 cursor-pointer border-0 bg-transparent hover:bg-contrast focus:shadow-inner ${
isSelected ? 'visible' : 'invisible'
}`}
onClick={toggleContextMenu}
ref={menuButtonRef}
>
<Icon type="more" className="text-neutral" />
</a>
<div className="count">{noteCounts.get()}</div>
</div>
</div>
<div className={`meta ${hasAtLeastOneFolder ? 'with-folders' : ''}`}>
{tag.conflictOf && <div className="danger text-[0.625rem] font-bold">Conflicted Copy {tag.conflictOf}</div>}
</div>
</div>
{isAddingSubtag && (
<div <div
className="tag overflow-hidden" role="button"
tabIndex={0}
className={classNames(
'tag py-2 px-3.5 focus:shadow-inner md:py-1',
isSelected && 'selected',
readyToDrop && 'is-drag-over',
)}
onClick={selectCurrentTag}
ref={mergeRefs([dragRef, tagRef])}
style={{ style={{
paddingLeft: `${(level + 1) * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`, paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
}}
onContextMenu={(e) => {
e.preventDefault()
onContextMenu(tag, e.clientX, e.clientY)
}} }}
> >
<div className="tag-info"> <div className="tag-info" title={title} ref={dropRef}>
<div className="flex h-full min-w-[22px] items-center border-0 bg-transparent p-0" /> {hasAtLeastOneFolder && (
<div className="tag-icon mr-1"> <div className="tag-fold-container">
<Icon type="hashtag" className="mr-1 text-neutral" /> <a
role="button"
className={`tag-fold focus:shadow-inner ${showChildren ? 'opened' : 'closed'} ${
!hasChildren ? 'invisible' : ''
}`}
onClick={hasChildren ? toggleChildren : undefined}
>
<Icon className={'text-neutral'} type={showChildren ? 'menu-arrow-down-alt' : 'menu-arrow-right'} />
</a>
</div>
)}
<div className={'tag-icon draggable mr-2'} ref={dragRef}>
<Icon type="hashtag" className={`${isSelected ? 'text-info' : 'text-neutral'}`} />
</div> </div>
<input {isEditing ? (
className="title w-full focus:shadow-none focus:outline-none" <input
type="text" className={'title editing focus:shadow-none focus:outline-none'}
ref={subtagInputRef} id={`react-tag-${tag.uuid}`}
onBlur={onSubtagInputBlur} onBlur={onBlur}
onKeyDown={onSubtagKeyDown} onInput={onInput}
value={subtagTitle} value={title}
onInput={onSubtagInput} onKeyDown={onKeyDown}
/> spellCheck={false}
ref={inputRef}
/>
) : (
<div
className={'title overflow-hidden text-left focus:shadow-none focus:outline-none'}
id={`react-tag-${tag.uuid}`}
>
{title}
</div>
)}
<div className="flex items-center">
<a
role="button"
className={`mr-2 cursor-pointer border-0 bg-transparent hover:bg-contrast focus:shadow-inner ${
isSelected ? 'visible' : 'invisible'
}`}
onClick={toggleContextMenu}
ref={menuButtonRef}
>
<Icon type="more" className="text-neutral" />
</a>
<div className="count">{noteCounts.get()}</div>
</div>
</div>
<div className={`meta ${hasAtLeastOneFolder ? 'with-folders' : ''}`}>
{tag.conflictOf && <div className="danger text-[0.625rem] font-bold">Conflicted Copy {tag.conflictOf}</div>}
</div> </div>
</div> </div>
)} {isAddingSubtag && (
{showChildren && ( <div
<> className="tag overflow-hidden"
{childrenTags.map((tag) => { style={{
return ( paddingLeft: `${(level + 1) * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
<TagsListItem }}
level={level + 1} >
key={tag.uuid} <div className="tag-info">
tag={tag} <div className="flex h-full min-w-[22px] items-center border-0 bg-transparent p-0" />
tagsState={tagsState} <div className="tag-icon mr-1">
features={features} <Icon type="hashtag" className="mr-1 text-neutral" />
onContextMenu={onContextMenu} </div>
<input
className="title w-full focus:shadow-none focus:outline-none"
type="text"
ref={subtagInputRef}
onBlur={onSubtagInputBlur}
onKeyDown={onSubtagKeyDown}
value={subtagTitle}
onInput={onSubtagInput}
/> />
) </div>
})} </div>
</> )}
)} {showChildren && (
</> <>
) {childrenTags.map((tag) => {
}) return (
<TagsListItem
level={level + 1}
key={tag.uuid}
tag={tag}
tagsState={tagsState}
features={features}
linkingController={linkingController}
onContextMenu={onContextMenu}
/>
)
})}
</>
)}
</>
)
},
)
TagsListItem.displayName = 'TagsListItem' TagsListItem.displayName = 'TagsListItem'

View File

@@ -282,25 +282,22 @@ export class LinkingController extends AbstractViewController {
} }
} }
linkItemToSelectedItem = async (itemToLink: LinkableItem) => { linkItems = async (item: LinkableItem, itemToLink: LinkableItem) => {
await this.ensureActiveItemIsInserted() if (item instanceof SNNote) {
const activeItem = this.activeItem
if (activeItem && itemToLink instanceof SNTag) {
await this.addTagToItem(itemToLink, activeItem)
}
if (activeItem instanceof SNNote) {
if (itemToLink instanceof FileItem) { if (itemToLink instanceof FileItem) {
await this.application.items.associateFileWithNote(itemToLink, activeItem) await this.application.items.associateFileWithNote(itemToLink, item)
} else if (itemToLink instanceof SNNote && this.isEntitledToNoteLinking) { } else if (itemToLink instanceof SNNote && this.isEntitledToNoteLinking) {
await this.application.items.linkNoteToNote(activeItem, itemToLink) await this.application.items.linkNoteToNote(item, itemToLink)
} else if (itemToLink instanceof SNTag) {
await this.addTagToItem(itemToLink, item)
} }
} else if (activeItem instanceof FileItem) { } else if (item instanceof FileItem) {
if (itemToLink instanceof SNNote) { if (itemToLink instanceof SNNote) {
await this.application.items.associateFileWithNote(activeItem, itemToLink) await this.application.items.associateFileWithNote(item, itemToLink)
} else if (itemToLink instanceof FileItem) { } else if (itemToLink instanceof FileItem) {
await this.application.items.linkFileToFile(activeItem, itemToLink) await this.application.items.linkFileToFile(item, itemToLink)
} else if (itemToLink instanceof SNTag) {
await this.addTagToItem(itemToLink, item)
} }
} }
@@ -308,6 +305,17 @@ export class LinkingController extends AbstractViewController {
this.reloadAllLinks() this.reloadAllLinks()
} }
linkItemToSelectedItem = async (itemToLink: LinkableItem) => {
await this.ensureActiveItemIsInserted()
const activeItem = this.activeItem
if (!activeItem) {
return
}
await this.linkItems(activeItem, itemToLink)
}
createAndAddNewTag = async (title: string) => { createAndAddNewTag = async (title: string) => {
await this.ensureActiveItemIsInserted() await this.ensureActiveItemIsInserted()
const activeItem = this.activeItem const activeItem = this.activeItem