feat: improved file drag-n-drop experience (#1848)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
export enum PopoverTabs {
|
||||
AttachedFiles = 'attached-files-tab',
|
||||
AllFiles = 'all-files-tab',
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -51,6 +51,7 @@ const TagsList: FunctionComponent<Props> = ({ viewControllerManager }: Props) =>
|
||||
tag={tag}
|
||||
tagsState={tagsState}
|
||||
features={viewControllerManager.featuresController}
|
||||
linkingController={viewControllerManager.linkingController}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user