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}
selectionController={viewControllerManager.selectionController}
searchOptionsController={viewControllerManager.searchOptionsController}
linkingController={viewControllerManager.linkingController}
/>
<NoteGroupView application={application} />
</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 { WebApplication } from '@/Application/Application'
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 { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react'
import ContentList from '@/Components/ContentListView/ContentList'
@@ -24,6 +24,8 @@ import SearchBar from '../SearchBar/SearchBar'
import { SearchOptionsController } from '@/Controllers/SearchOptionsController'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider'
import { LinkingController } from '@/Controllers/LinkingController'
type Props = {
accountMenuController: AccountMenuController
@@ -35,6 +37,7 @@ type Props = {
notesController: NotesController
selectionController: SelectedItemsController
searchOptionsController: SearchOptionsController
linkingController: LinkingController
}
const ContentListView: FunctionComponent<Props> = ({
@@ -47,12 +50,54 @@ const ContentListView: FunctionComponent<Props> = ({
notesController,
selectionController,
searchOptionsController,
linkingController,
}) => {
const { isNotesListVisibleOnTablets, toggleAppPane } = useResponsiveAppPane()
const fileInputRef = useRef<HTMLInputElement>(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 {
completedFullSync,
createNewNote,

View File

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

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

View File

@@ -1,7 +1,7 @@
import { WebApplication } from '@/Application/Application'
import { DialogContent, DialogOverlay } from '@reach/dialog'
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 FilePreviewInfoPanel from './FilePreviewInfoPanel'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'

View File

@@ -1,6 +1,6 @@
import { ElementIds } from '@/Constants/ElementIDs'
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 FilePreview from '@/Components/FilePreview/FilePreview'
import { FileViewProps } from './FileViewProps'
@@ -10,6 +10,7 @@ import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContaine
import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover'
import FilePreviewInfoPanel from '../FilePreview/FilePreviewInfoPanel'
import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider'
const SyncTimeoutNoDebounceMs = 100
const SyncTimeoutDebounceMs = 350
@@ -40,8 +41,33 @@ const FileViewWithoutProtection = ({ application, viewControllerManager, 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 (
<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="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 Button from '../Button/Button'
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 { NotesController } from '@/Controllers/NotesController'
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
@@ -17,9 +13,6 @@ import { LinkingController } from '@/Controllers/LinkingController'
type Props = {
application: WebApplication
featuresController: FeaturesController
filePreviewModalController: FilePreviewModalController
filesController: FilesController
navigationController: NavigationController
notesController: NotesController
selectionController: SelectedItemsController
@@ -29,9 +22,6 @@ type Props = {
const MultipleSelectedNotes = ({
application,
featuresController,
filePreviewModalController,
filesController,
navigationController,
notesController,
linkingController,
@@ -49,17 +39,6 @@ const MultipleSelectedNotes = ({
<div className="flex w-full items-center justify-between p-4">
<h1 className="m-0 text-lg font-bold">{count} selected notes</h1>
<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">
<PinNoteButton notesController={notesController} />
</div>

View File

@@ -4,7 +4,6 @@ import { WebApplication } from '@/Application/Application'
import MultipleSelectedNotes from '@/Components/MultipleSelectedNotes/MultipleSelectedNotes'
import MultipleSelectedFiles from '../MultipleSelectedFiles/MultipleSelectedFiles'
import { ElementIds } from '@/Constants/ElementIDs'
import { FileDnDContext } from '@/Components/FileDragNDropProvider/FileDragNDropProvider'
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import ResponsivePaneContent from '../ResponsivePane/ResponsivePaneContent'
import FileView from '../FileView/FileView'
@@ -24,9 +23,6 @@ type Props = {
}
class NoteGroupView extends PureComponent<Props, State> {
static override contextType = FileDnDContext
declare context: React.ContextType<typeof FileDnDContext>
private removeChangeObserver!: () => void
constructor(props: Props) {
@@ -94,8 +90,6 @@ class NoteGroupView extends PureComponent<Props, State> {
}
override render() {
const fileDragNDropContext = this.context
const shouldNotShowMultipleSelectedItems =
!this.state.showMultipleSelectedNotes && !this.state.showMultipleSelectedFiles
@@ -112,10 +106,7 @@ class NoteGroupView extends PureComponent<Props, State> {
{this.state.showMultipleSelectedNotes && (
<MultipleSelectedNotes
application={this.application}
filesController={this.viewControllerManager.filesController}
selectionController={this.viewControllerManager.selectionController}
featuresController={this.viewControllerManager.featuresController}
filePreviewModalController={this.viewControllerManager.filePreviewModalController}
navigationController={this.viewControllerManager.navigationController}
notesController={this.viewControllerManager.notesController}
linkingController={this.viewControllerManager.linkingController}
@@ -128,11 +119,6 @@ class NoteGroupView extends PureComponent<Props, State> {
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 && (
<>
{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 { ElementIds } from '@/Constants/ElementIDs'
import ChangeEditorButton from '@/Components/ChangeEditor/ChangeEditorButton'
import AttachedFilesButton from '@/Components/AttachedFilesPopover/AttachedFilesButton'
import EditingDisabledBanner from './EditingDisabledBanner'
import {
transactionForAssociateComponentWithCurrentNote,
@@ -46,6 +45,7 @@ import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContaine
import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator'
import { PrefDefaults } from '@/Constants/PrefDefaults'
import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
import NoteViewFileDropTarget from './NoteViewFileDropTarget'
const MinimumStatusDuration = 400
const TextareaDebounce = 100
@@ -118,6 +118,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null
private noteViewElementRef: RefObject<HTMLDivElement>
private editorContentRef: RefObject<HTMLDivElement>
constructor(props: NoteViewProps) {
@@ -159,6 +160,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
noteType: this.controller.item.noteType,
}
this.noteViewElementRef = createRef<HTMLDivElement>()
this.editorContentRef = createRef<HTMLDivElement>()
window.addEventListener('scroll', this.handleWindowScroll)
@@ -949,7 +951,15 @@ class NoteView extends PureComponent<NoteViewProps, State> {
}
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 && (
<EditingDisabledBanner
onMouseLeave={() => {
@@ -1014,16 +1024,6 @@ class NoteView extends PureComponent<NoteViewProps, State> {
linkingController={this.viewControllerManager.linkingController}
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
application={this.application}
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}
tagsState={tagsState}
features={viewControllerManager.featuresController}
linkingController={viewControllerManager.linkingController}
onContextMenu={onContextMenu}
/>
)

View File

@@ -23,11 +23,15 @@ import { DropItem, DropProps, ItemTypes } from './DragNDrop'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { mergeRefs } from '@/Hooks/mergeRefs'
import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider'
import { LinkingController } from '@/Controllers/LinkingController'
type Props = {
tag: SNTag
tagsState: NavigationController
features: FeaturesController
linkingController: LinkingController
level: number
onContextMenu: (tag: SNTag, posX: number, posY: number) => void
}
@@ -35,288 +39,316 @@ type Props = {
const PADDING_BASE_PX = 14
const PADDING_PER_LEVEL_PX = 21
export const TagsListItem: FunctionComponent<Props> = observer(({ tag, features, tagsState, level, onContextMenu }) => {
const { toggleAppPane } = useResponsiveAppPane()
export const TagsListItem: FunctionComponent<Props> = observer(
({ tag, features, tagsState, level, onContextMenu, linkingController }) => {
const { toggleAppPane } = useResponsiveAppPane()
const [title, setTitle] = useState(tag.title || '')
const [subtagTitle, setSubtagTitle] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const subtagInputRef = useRef<HTMLInputElement>(null)
const menuButtonRef = useRef<HTMLAnchorElement>(null)
const [title, setTitle] = useState(tag.title || '')
const [subtagTitle, setSubtagTitle] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const subtagInputRef = useRef<HTMLInputElement>(null)
const menuButtonRef = useRef<HTMLAnchorElement>(null)
const isSelected = tagsState.selected === tag
const isEditing = tagsState.editingTag === tag
const isAddingSubtag = tagsState.addingSubtagTo === tag
const noteCounts = computed(() => tagsState.getNotesCount(tag))
const isSelected = tagsState.selected === tag
const isEditing = tagsState.editingTag === tag
const isAddingSubtag = tagsState.addingSubtagTo === tag
const noteCounts = computed(() => tagsState.getNotesCount(tag))
const childrenTags = computed(() => tagsState.getChildren(tag)).get()
const hasChildren = childrenTags.length > 0
const childrenTags = computed(() => tagsState.getChildren(tag)).get()
const hasChildren = childrenTags.length > 0
const hasFolders = features.hasFolders
const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder
const hasFolders = features.hasFolders
const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder
const premiumModal = usePremiumModal()
const premiumModal = usePremiumModal()
const [showChildren, setShowChildren] = useState(tag.expanded)
const [hadChildren, setHadChildren] = useState(hasChildren)
const [showChildren, setShowChildren] = useState(tag.expanded)
const [hadChildren, setHadChildren] = useState(hasChildren)
useEffect(() => {
if (!hadChildren && hasChildren) {
setShowChildren(true)
}
setHadChildren(hasChildren)
}, [hadChildren, hasChildren])
useEffect(() => {
if (!hadChildren && hasChildren) {
setShowChildren(true)
}
setHadChildren(hasChildren)
}, [hadChildren, hasChildren])
useEffect(() => {
setTitle(tag.title || '')
}, [setTitle, tag])
useEffect(() => {
setTitle(tag.title || '')
}, [setTitle, tag])
const toggleChildren: MouseEventHandler = useCallback(
(e) => {
e.stopPropagation()
setShowChildren((x) => {
tagsState.setExpanded(tag, !x)
return !x
})
},
[setShowChildren, tag, tagsState],
)
const toggleChildren: MouseEventHandler = useCallback(
(e) => {
e.stopPropagation()
setShowChildren((x) => {
tagsState.setExpanded(tag, !x)
return !x
})
},
[setShowChildren, tag, tagsState],
)
const selectCurrentTag = useCallback(async () => {
await tagsState.setSelectedTag(tag)
toggleAppPane(AppPaneId.Items)
}, [tagsState, tag, toggleAppPane])
const selectCurrentTag = useCallback(async () => {
await tagsState.setSelectedTag(tag)
toggleAppPane(AppPaneId.Items)
}, [tagsState, tag, toggleAppPane])
const onBlur = useCallback(() => {
tagsState.save(tag, title).catch(console.error)
setTitle(tag.title)
}, [tagsState, tag, title, setTitle])
const onBlur = useCallback(() => {
tagsState.save(tag, title).catch(console.error)
setTitle(tag.title)
}, [tagsState, tag, title, setTitle])
const onInput: FormEventHandler = useCallback(
(e) => {
const onInput: FormEventHandler = useCallback(
(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
setTitle(value)
},
[setTitle],
)
setSubtagTitle(value)
}, [])
const onKeyDown: KeyboardEventHandler = useCallback(
(e) => {
if (e.key === KeyboardKey.Enter) {
inputRef.current?.blur()
e.preventDefault()
}
},
[inputRef],
)
const onSubtagInputBlur = useCallback(() => {
tagsState.createSubtagAndAssignParent(tag, subtagTitle).catch(console.error)
setSubtagTitle('')
}, [subtagTitle, tag, tagsState])
useEffect(() => {
if (isEditing) {
inputRef.current?.focus()
}
}, [inputRef, isEditing])
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
const onSubtagKeyDown: KeyboardEventHandler = useCallback(
(e) => {
if (e.key === KeyboardKey.Enter) {
e.preventDefault()
subtagInputRef.current?.blur()
}
},
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
[subtagInputRef],
)
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>(
() => ({
accept: ItemTypes.TAG,
canDrop: (item) => {
return tagsState.isValidTagParent(tag, item as SNTag)
},
drop: (item) => {
if (!hasFolders) {
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME)
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
() => ({
accept: ItemTypes.TAG,
canDrop: (item) => {
return tagsState.isValidTagParent(tag, item as SNTag)
},
drop: (item) => {
if (!hasFolders) {
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
}
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) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
}),
[tag, tagsState, hasFolders, premiumModal],
)
[onContextMenu, tagsState, tag],
)
const readyToDrop = isOver && canDrop
const tagRef = useRef<HTMLDivElement>(null)
const toggleContextMenu: MouseEventHandler<HTMLAnchorElement> = useCallback(
(event) => {
event.preventDefault()
event.stopPropagation()
const { addDragTarget, removeDragTarget } = useFileDragNDrop()
if (!menuButtonRef.current) {
return
useEffect(() => {
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
const menuButtonRect = menuButtonRef.current?.getBoundingClientRect()
if (contextMenuOpen) {
tagsState.setContextMenuOpen(false)
} else {
onContextMenu(tag, menuButtonRect.right, menuButtonRect.top)
return () => {
if (target) {
removeDragTarget(target)
}
}
},
[onContextMenu, tagsState, tag],
)
}, [addDragTarget, linkingController, removeDragTarget, tag])
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 && (
return (
<>
<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={{
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="flex h-full min-w-[22px] items-center border-0 bg-transparent p-0" />
<div className="tag-icon mr-1">
<Icon type="hashtag" className="mr-1 text-neutral" />
<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>
<input
className="title w-full focus:shadow-none focus:outline-none"
type="text"
ref={subtagInputRef}
onBlur={onSubtagInputBlur}
onKeyDown={onSubtagKeyDown}
value={subtagTitle}
onInput={onSubtagInput}
/>
{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>
)}
{showChildren && (
<>
{childrenTags.map((tag) => {
return (
<TagsListItem
level={level + 1}
key={tag.uuid}
tag={tag}
tagsState={tagsState}
features={features}
onContextMenu={onContextMenu}
{isAddingSubtag && (
<div
className="tag overflow-hidden"
style={{
paddingLeft: `${(level + 1) * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
}}
>
<div className="tag-info">
<div className="flex h-full min-w-[22px] items-center border-0 bg-transparent p-0" />
<div className="tag-icon mr-1">
<Icon type="hashtag" className="mr-1 text-neutral" />
</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'

View File

@@ -282,25 +282,22 @@ export class LinkingController extends AbstractViewController {
}
}
linkItemToSelectedItem = async (itemToLink: LinkableItem) => {
await this.ensureActiveItemIsInserted()
const activeItem = this.activeItem
if (activeItem && itemToLink instanceof SNTag) {
await this.addTagToItem(itemToLink, activeItem)
}
if (activeItem instanceof SNNote) {
linkItems = async (item: LinkableItem, itemToLink: LinkableItem) => {
if (item instanceof SNNote) {
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) {
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) {
await this.application.items.associateFileWithNote(activeItem, itemToLink)
await this.application.items.associateFileWithNote(item, itemToLink)
} 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()
}
linkItemToSelectedItem = async (itemToLink: LinkableItem) => {
await this.ensureActiveItemIsInserted()
const activeItem = this.activeItem
if (!activeItem) {
return
}
await this.linkItems(activeItem, itemToLink)
}
createAndAddNewTag = async (title: string) => {
await this.ensureActiveItemIsInserted()
const activeItem = this.activeItem