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}
|
notesController={viewControllerManager.notesController}
|
||||||
selectionController={viewControllerManager.selectionController}
|
selectionController={viewControllerManager.selectionController}
|
||||||
searchOptionsController={viewControllerManager.searchOptionsController}
|
searchOptionsController={viewControllerManager.searchOptionsController}
|
||||||
|
linkingController={viewControllerManager.linkingController}
|
||||||
/>
|
/>
|
||||||
<NoteGroupView application={application} />
|
<NoteGroupView application={application} />
|
||||||
</FileDragNDropProvider>
|
</FileDragNDropProvider>
|
||||||
|
|||||||
@@ -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 { KeyboardKey, KeyboardModifier } from '@standardnotes/ui-services'
|
||||||
import { WebApplication } from '@/Application/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { PANEL_NAME_NOTES } from '@/Constants/Constants'
|
import { PANEL_NAME_NOTES } from '@/Constants/Constants'
|
||||||
import { PrefKey, SystemViewId } from '@standardnotes/snjs'
|
import { FileItem, PrefKey, SystemViewId } from '@standardnotes/snjs'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react'
|
import { FunctionComponent, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import ContentList from '@/Components/ContentListView/ContentList'
|
import ContentList from '@/Components/ContentListView/ContentList'
|
||||||
@@ -24,6 +24,8 @@ import SearchBar from '../SearchBar/SearchBar'
|
|||||||
import { SearchOptionsController } from '@/Controllers/SearchOptionsController'
|
import { SearchOptionsController } from '@/Controllers/SearchOptionsController'
|
||||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
|
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
|
||||||
|
import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider'
|
||||||
|
import { LinkingController } from '@/Controllers/LinkingController'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
accountMenuController: AccountMenuController
|
accountMenuController: AccountMenuController
|
||||||
@@ -35,6 +37,7 @@ type Props = {
|
|||||||
notesController: NotesController
|
notesController: NotesController
|
||||||
selectionController: SelectedItemsController
|
selectionController: SelectedItemsController
|
||||||
searchOptionsController: SearchOptionsController
|
searchOptionsController: SearchOptionsController
|
||||||
|
linkingController: LinkingController
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentListView: FunctionComponent<Props> = ({
|
const ContentListView: FunctionComponent<Props> = ({
|
||||||
@@ -47,12 +50,54 @@ const ContentListView: FunctionComponent<Props> = ({
|
|||||||
notesController,
|
notesController,
|
||||||
selectionController,
|
selectionController,
|
||||||
searchOptionsController,
|
searchOptionsController,
|
||||||
|
linkingController,
|
||||||
}) => {
|
}) => {
|
||||||
const { isNotesListVisibleOnTablets, toggleAppPane } = useResponsiveAppPane()
|
const { isNotesListVisibleOnTablets, toggleAppPane } = useResponsiveAppPane()
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const itemsViewPanelRef = useRef<HTMLDivElement>(null)
|
const itemsViewPanelRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const { addDragTarget, removeDragTarget } = useFileDragNDrop()
|
||||||
|
|
||||||
|
const fileDropCallback = useCallback(
|
||||||
|
async (files: FileItem[]) => {
|
||||||
|
const currentTag = navigationController.selected
|
||||||
|
|
||||||
|
if (!currentTag) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigationController.isInAnySystemView() || navigationController.isInSmartView()) {
|
||||||
|
console.error('Trying to link uploaded files to smart view')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files.forEach(async (file) => {
|
||||||
|
await linkingController.linkItems(file, currentTag)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[navigationController, linkingController],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const target = itemsViewPanelRef.current
|
||||||
|
const currentTag = navigationController.selected
|
||||||
|
const shouldAddDropTarget = !navigationController.isInAnySystemView() && !navigationController.isInSmartView()
|
||||||
|
|
||||||
|
if (target && shouldAddDropTarget && currentTag) {
|
||||||
|
addDragTarget(target, {
|
||||||
|
tooltipText: `Drop your files to upload and link them to tag "${currentTag.title}"`,
|
||||||
|
callback: fileDropCallback,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (target) {
|
||||||
|
removeDragTarget(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [addDragTarget, fileDropCallback, navigationController, navigationController.selected, removeDragTarget])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
completedFullSync,
|
completedFullSync,
|
||||||
createNewNote,
|
createNewNote,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FileItem } from '@standardnotes/snjs'
|
import { FileItem } from '@standardnotes/snjs'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { FunctionComponent, useCallback, useRef } from 'react'
|
import { FunctionComponent, useCallback, useRef } from 'react'
|
||||||
import { getFileIconComponent } from '../AttachedFilesPopover/getFileIconComponent'
|
import { getFileIconComponent } from '../FilePreview/getFileIconComponent'
|
||||||
import ListItemConflictIndicator from './ListItemConflictIndicator'
|
import ListItemConflictIndicator from './ListItemConflictIndicator'
|
||||||
import ListItemFlagIcons from './ListItemFlagIcons'
|
import ListItemFlagIcons from './ListItemFlagIcons'
|
||||||
import ListItemTags from './ListItemTags'
|
import ListItemTags from './ListItemTags'
|
||||||
|
|||||||
@@ -2,19 +2,22 @@ import { WebApplication } from '@/Application/Application'
|
|||||||
import { FeaturesController } from '@/Controllers/FeaturesController'
|
import { FeaturesController } from '@/Controllers/FeaturesController'
|
||||||
import { FilesController } from '@/Controllers/FilesController'
|
import { FilesController } from '@/Controllers/FilesController'
|
||||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||||
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
import { isHandlingFileDrag } from '@/Utils/DragTypeCheck'
|
import { isHandlingFileDrag } from '@/Utils/DragTypeCheck'
|
||||||
import { StreamingFileReader } from '@standardnotes/filepicker'
|
import { StreamingFileReader } from '@standardnotes/filepicker'
|
||||||
import { FileItem } from '@standardnotes/snjs'
|
import { FileItem } from '@standardnotes/snjs'
|
||||||
import { useMemo, useState, createContext, ReactNode, useRef, useCallback, useEffect, useContext } from 'react'
|
import { useMemo, useState, createContext, ReactNode, useRef, useCallback, useEffect, useContext, memo } from 'react'
|
||||||
import { PopoverTabs } from '../AttachedFilesPopover/PopoverTabs'
|
import Portal from '../Portal/Portal'
|
||||||
|
|
||||||
type FilesDragInCallback = (tab: PopoverTabs) => void
|
type FileDragTargetData = {
|
||||||
type FilesDropCallback = (uploadedFiles: FileItem[]) => void
|
tooltipText: string
|
||||||
|
callback: (files: FileItem[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
type FileDnDContextData = {
|
type FileDnDContextData = {
|
||||||
isDraggingFiles: boolean
|
isDraggingFiles: boolean
|
||||||
addFilesDragInCallback: (callback: FilesDragInCallback) => void
|
addDragTarget: (target: HTMLElement, data: FileDragTargetData) => void
|
||||||
addFilesDropCallback: (callback: FilesDropCallback) => void
|
removeDragTarget: (target: HTMLElement) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileDnDContext = createContext<FileDnDContextData | null>(null)
|
export const FileDnDContext = createContext<FileDnDContextData | null>(null)
|
||||||
@@ -36,23 +39,57 @@ type Props = {
|
|||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FileDragOverlayClassName =
|
||||||
|
'overlay pointer-events-none absolute top-0 left-0 z-footer-bar h-full w-full border-2 border-info before:block before:h-full before:w-full before:bg-info before:opacity-20'
|
||||||
|
|
||||||
|
const MemoizedChildren = memo(({ children }: { children: ReactNode }) => {
|
||||||
|
return <>{children}</>
|
||||||
|
})
|
||||||
|
|
||||||
const FileDragNDropProvider = ({ application, children, featuresController, filesController }: Props) => {
|
const FileDragNDropProvider = ({ application, children, featuresController, filesController }: Props) => {
|
||||||
const premiumModal = usePremiumModal()
|
const premiumModal = usePremiumModal()
|
||||||
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
|
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
|
||||||
|
const [tooltipText, setTooltipText] = useState('')
|
||||||
|
|
||||||
const filesDragInCallbackRef = useRef<FilesDragInCallback>()
|
const fileDragOverlayRef = useRef<HTMLDivElement>(null)
|
||||||
const filesDropCallbackRef = useRef<FilesDropCallback>()
|
|
||||||
|
|
||||||
const addFilesDragInCallback = useCallback((callback: FilesDragInCallback) => {
|
const addOverlayToElement = useCallback((target: Element) => {
|
||||||
filesDragInCallbackRef.current = callback
|
if (fileDragOverlayRef.current) {
|
||||||
|
const targetBoundingRect = target.getBoundingClientRect()
|
||||||
|
fileDragOverlayRef.current.style.width = `${targetBoundingRect.width}px`
|
||||||
|
fileDragOverlayRef.current.style.height = `${targetBoundingRect.height}px`
|
||||||
|
fileDragOverlayRef.current.style.transform = `translate(${targetBoundingRect.x}px, ${targetBoundingRect.y}px)`
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const addFilesDropCallback = useCallback((callback: FilesDropCallback) => {
|
const removeOverlayFromElement = useCallback(() => {
|
||||||
filesDropCallbackRef.current = callback
|
if (fileDragOverlayRef.current) {
|
||||||
|
fileDragOverlayRef.current.style.width = ''
|
||||||
|
fileDragOverlayRef.current.style.height = ''
|
||||||
|
fileDragOverlayRef.current.style.transform = ''
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const dragTargets = useRef<Map<Element, FileDragTargetData>>(new Map())
|
||||||
|
|
||||||
|
const addDragTarget = useCallback((target: HTMLElement, data: FileDragTargetData) => {
|
||||||
|
target.setAttribute('data-file-drag-target', '')
|
||||||
|
dragTargets.current.set(target, data)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeDragTarget = useCallback((target: HTMLElement) => {
|
||||||
|
target.removeAttribute('data-file-drag-target')
|
||||||
|
dragTargets.current.delete(target)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const dragCounter = useRef(0)
|
const dragCounter = useRef(0)
|
||||||
|
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
setIsDraggingFiles(false)
|
||||||
|
setTooltipText('')
|
||||||
|
removeOverlayFromElement()
|
||||||
|
}, [removeOverlayFromElement])
|
||||||
|
|
||||||
const handleDrag = useCallback(
|
const handleDrag = useCallback(
|
||||||
(event: DragEvent) => {
|
(event: DragEvent) => {
|
||||||
if (isHandlingFileDrag(event, application)) {
|
if (isHandlingFileDrag(event, application)) {
|
||||||
@@ -72,22 +109,31 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
switch ((event.target as HTMLElement).id) {
|
removeOverlayFromElement()
|
||||||
case PopoverTabs.AllFiles:
|
|
||||||
filesDragInCallbackRef.current?.(PopoverTabs.AllFiles)
|
let closestDragTarget: Element | null = null
|
||||||
break
|
|
||||||
case PopoverTabs.AttachedFiles:
|
if (event.target instanceof HTMLElement) {
|
||||||
filesDragInCallbackRef.current?.(PopoverTabs.AttachedFiles)
|
closestDragTarget = event.target.closest('[data-file-drag-target]')
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dragCounter.current = dragCounter.current + 1
|
dragCounter.current = dragCounter.current + 1
|
||||||
|
|
||||||
if (event.dataTransfer?.items.length) {
|
if (event.dataTransfer?.items.length) {
|
||||||
setIsDraggingFiles(true)
|
setIsDraggingFiles(true)
|
||||||
|
if (closestDragTarget) {
|
||||||
|
addOverlayToElement(closestDragTarget)
|
||||||
|
const tooltipText = dragTargets.current.get(closestDragTarget)?.tooltipText
|
||||||
|
if (tooltipText) {
|
||||||
|
setTooltipText(tooltipText)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTooltipText('')
|
||||||
|
removeOverlayFromElement()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[application],
|
[addOverlayToElement, application, removeOverlayFromElement],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDragOut = useCallback(
|
const handleDragOut = useCallback(
|
||||||
@@ -105,22 +151,22 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsDraggingFiles(false)
|
resetState()
|
||||||
},
|
},
|
||||||
[application],
|
[application, resetState],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
(event: DragEvent) => {
|
(event: DragEvent) => {
|
||||||
if (!isHandlingFileDrag(event, application)) {
|
if (!isHandlingFileDrag(event, application)) {
|
||||||
setIsDraggingFiles(false)
|
resetState()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
setIsDraggingFiles(false)
|
resetState()
|
||||||
|
|
||||||
if (!featuresController.hasFiles) {
|
if (!featuresController.hasFiles) {
|
||||||
premiumModal.activate('Files')
|
premiumModal.activate('Files')
|
||||||
@@ -143,14 +189,22 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filesDropCallbackRef.current?.(uploadedFiles)
|
let closestDragTarget: Element | null = null
|
||||||
|
|
||||||
|
if (event.target instanceof HTMLElement) {
|
||||||
|
closestDragTarget = event.target.closest('[data-file-drag-target]')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closestDragTarget && dragTargets.current.has(closestDragTarget)) {
|
||||||
|
dragTargets.current.get(closestDragTarget)?.callback(uploadedFiles)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
event.dataTransfer.clearData()
|
event.dataTransfer.clearData()
|
||||||
dragCounter.current = 0
|
dragCounter.current = 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[application, featuresController.hasFiles, filesController, premiumModal],
|
[application, featuresController.hasFiles, filesController, premiumModal, resetState],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -170,12 +224,29 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
|
|||||||
const contextValue = useMemo(() => {
|
const contextValue = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
isDraggingFiles,
|
isDraggingFiles,
|
||||||
addFilesDragInCallback,
|
addDragTarget,
|
||||||
addFilesDropCallback,
|
removeDragTarget,
|
||||||
}
|
}
|
||||||
}, [addFilesDragInCallback, addFilesDropCallback, isDraggingFiles])
|
}, [addDragTarget, isDraggingFiles, removeDragTarget])
|
||||||
|
|
||||||
return <FileDnDContext.Provider value={contextValue}>{children}</FileDnDContext.Provider>
|
return (
|
||||||
|
<FileDnDContext.Provider value={contextValue}>
|
||||||
|
<MemoizedChildren children={children} />
|
||||||
|
{isDraggingFiles ? (
|
||||||
|
<>
|
||||||
|
<div className="pointer-events-none absolute bottom-8 left-1/2 z-dropdown-menu -translate-x-1/2 rounded border-2 border-info bg-default px-5 py-3 shadow-main">
|
||||||
|
{tooltipText.length ? tooltipText : 'Drop your files to upload them'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Portal>
|
||||||
|
<div
|
||||||
|
className={classNames(FileDragOverlayClassName, isDraggingFiles ? 'visible' : 'invisible')}
|
||||||
|
ref={fileDragOverlayRef}
|
||||||
|
/>
|
||||||
|
</Portal>
|
||||||
|
</FileDnDContext.Provider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FileDragNDropProvider
|
export default FileDragNDropProvider
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { WebApplication } from '@/Application/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
import { DialogContent, DialogOverlay } from '@reach/dialog'
|
||||||
import { FunctionComponent, KeyboardEventHandler, useCallback, useMemo, useRef, useState } from 'react'
|
import { FunctionComponent, KeyboardEventHandler, useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import { getFileIconComponent } from '@/Components/AttachedFilesPopover/getFileIconComponent'
|
import { getFileIconComponent } from './getFileIconComponent'
|
||||||
import Icon from '@/Components/Icon/Icon'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import FilePreviewInfoPanel from './FilePreviewInfoPanel'
|
import FilePreviewInfoPanel from './FilePreviewInfoPanel'
|
||||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ElementIds } from '@/Constants/ElementIDs'
|
import { ElementIds } from '@/Constants/ElementIDs'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { ChangeEventHandler, useCallback, useRef, useState } from 'react'
|
import { ChangeEventHandler, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import FileOptionsPanel from '@/Components/FileContextMenu/FileOptionsPanel'
|
import FileOptionsPanel from '@/Components/FileContextMenu/FileOptionsPanel'
|
||||||
import FilePreview from '@/Components/FilePreview/FilePreview'
|
import FilePreview from '@/Components/FilePreview/FilePreview'
|
||||||
import { FileViewProps } from './FileViewProps'
|
import { FileViewProps } from './FileViewProps'
|
||||||
@@ -10,6 +10,7 @@ import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContaine
|
|||||||
import Icon from '../Icon/Icon'
|
import Icon from '../Icon/Icon'
|
||||||
import Popover from '../Popover/Popover'
|
import Popover from '../Popover/Popover'
|
||||||
import FilePreviewInfoPanel from '../FilePreview/FilePreviewInfoPanel'
|
import FilePreviewInfoPanel from '../FilePreview/FilePreviewInfoPanel'
|
||||||
|
import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider'
|
||||||
|
|
||||||
const SyncTimeoutNoDebounceMs = 100
|
const SyncTimeoutNoDebounceMs = 100
|
||||||
const SyncTimeoutDebounceMs = 350
|
const SyncTimeoutDebounceMs = 350
|
||||||
@@ -40,8 +41,33 @@ const FileViewWithoutProtection = ({ application, viewControllerManager, file }:
|
|||||||
[application, file],
|
[application, file],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const fileDragTargetRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const { addDragTarget, removeDragTarget } = useFileDragNDrop()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const target = fileDragTargetRef.current
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
addDragTarget(target, {
|
||||||
|
tooltipText: 'Drop your files to upload and link them to the current file',
|
||||||
|
callback(files) {
|
||||||
|
files.forEach(async (uploadedFile) => {
|
||||||
|
await viewControllerManager.linkingController.linkItems(uploadedFile, file)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (target) {
|
||||||
|
removeDragTarget(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [addDragTarget, file, removeDragTarget, viewControllerManager.linkingController])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sn-component section editor" aria-label="File">
|
<div className="sn-component section editor" aria-label="File" ref={fileDragTargetRef}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div
|
<div
|
||||||
className="content-title-bar section-title-bar section-title-bar z-editor-title-bar w-full"
|
className="content-title-bar section-title-bar section-title-bar z-editor-title-bar w-full"
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import { WebApplication } from '@/Application/Application'
|
|||||||
import PinNoteButton from '@/Components/PinNoteButton/PinNoteButton'
|
import PinNoteButton from '@/Components/PinNoteButton/PinNoteButton'
|
||||||
import Button from '../Button/Button'
|
import Button from '../Button/Button'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import AttachedFilesButton from '../AttachedFilesPopover/AttachedFilesButton'
|
|
||||||
import { FeaturesController } from '@/Controllers/FeaturesController'
|
|
||||||
import { FilePreviewModalController } from '@/Controllers/FilePreviewModalController'
|
|
||||||
import { FilesController } from '@/Controllers/FilesController'
|
|
||||||
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
||||||
import { NotesController } from '@/Controllers/NotesController'
|
import { NotesController } from '@/Controllers/NotesController'
|
||||||
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
|
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
|
||||||
@@ -17,9 +13,6 @@ import { LinkingController } from '@/Controllers/LinkingController'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
featuresController: FeaturesController
|
|
||||||
filePreviewModalController: FilePreviewModalController
|
|
||||||
filesController: FilesController
|
|
||||||
navigationController: NavigationController
|
navigationController: NavigationController
|
||||||
notesController: NotesController
|
notesController: NotesController
|
||||||
selectionController: SelectedItemsController
|
selectionController: SelectedItemsController
|
||||||
@@ -29,9 +22,6 @@ type Props = {
|
|||||||
|
|
||||||
const MultipleSelectedNotes = ({
|
const MultipleSelectedNotes = ({
|
||||||
application,
|
application,
|
||||||
featuresController,
|
|
||||||
filePreviewModalController,
|
|
||||||
filesController,
|
|
||||||
navigationController,
|
navigationController,
|
||||||
notesController,
|
notesController,
|
||||||
linkingController,
|
linkingController,
|
||||||
@@ -49,17 +39,6 @@ const MultipleSelectedNotes = ({
|
|||||||
<div className="flex w-full items-center justify-between p-4">
|
<div className="flex w-full items-center justify-between p-4">
|
||||||
<h1 className="m-0 text-lg font-bold">{count} selected notes</h1>
|
<h1 className="m-0 text-lg font-bold">{count} selected notes</h1>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="mr-3">
|
|
||||||
<AttachedFilesButton
|
|
||||||
application={application}
|
|
||||||
featuresController={featuresController}
|
|
||||||
filePreviewModalController={filePreviewModalController}
|
|
||||||
filesController={filesController}
|
|
||||||
navigationController={navigationController}
|
|
||||||
notesController={notesController}
|
|
||||||
selectionController={selectionController}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mr-3">
|
<div className="mr-3">
|
||||||
<PinNoteButton notesController={notesController} />
|
<PinNoteButton notesController={notesController} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { WebApplication } from '@/Application/Application'
|
|||||||
import MultipleSelectedNotes from '@/Components/MultipleSelectedNotes/MultipleSelectedNotes'
|
import MultipleSelectedNotes from '@/Components/MultipleSelectedNotes/MultipleSelectedNotes'
|
||||||
import MultipleSelectedFiles from '../MultipleSelectedFiles/MultipleSelectedFiles'
|
import MultipleSelectedFiles from '../MultipleSelectedFiles/MultipleSelectedFiles'
|
||||||
import { ElementIds } from '@/Constants/ElementIDs'
|
import { ElementIds } from '@/Constants/ElementIDs'
|
||||||
import { FileDnDContext } from '@/Components/FileDragNDropProvider/FileDragNDropProvider'
|
|
||||||
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
|
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
|
||||||
import ResponsivePaneContent from '../ResponsivePane/ResponsivePaneContent'
|
import ResponsivePaneContent from '../ResponsivePane/ResponsivePaneContent'
|
||||||
import FileView from '../FileView/FileView'
|
import FileView from '../FileView/FileView'
|
||||||
@@ -24,9 +23,6 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class NoteGroupView extends PureComponent<Props, State> {
|
class NoteGroupView extends PureComponent<Props, State> {
|
||||||
static override contextType = FileDnDContext
|
|
||||||
declare context: React.ContextType<typeof FileDnDContext>
|
|
||||||
|
|
||||||
private removeChangeObserver!: () => void
|
private removeChangeObserver!: () => void
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
@@ -94,8 +90,6 @@ class NoteGroupView extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const fileDragNDropContext = this.context
|
|
||||||
|
|
||||||
const shouldNotShowMultipleSelectedItems =
|
const shouldNotShowMultipleSelectedItems =
|
||||||
!this.state.showMultipleSelectedNotes && !this.state.showMultipleSelectedFiles
|
!this.state.showMultipleSelectedNotes && !this.state.showMultipleSelectedFiles
|
||||||
|
|
||||||
@@ -112,10 +106,7 @@ class NoteGroupView extends PureComponent<Props, State> {
|
|||||||
{this.state.showMultipleSelectedNotes && (
|
{this.state.showMultipleSelectedNotes && (
|
||||||
<MultipleSelectedNotes
|
<MultipleSelectedNotes
|
||||||
application={this.application}
|
application={this.application}
|
||||||
filesController={this.viewControllerManager.filesController}
|
|
||||||
selectionController={this.viewControllerManager.selectionController}
|
selectionController={this.viewControllerManager.selectionController}
|
||||||
featuresController={this.viewControllerManager.featuresController}
|
|
||||||
filePreviewModalController={this.viewControllerManager.filePreviewModalController}
|
|
||||||
navigationController={this.viewControllerManager.navigationController}
|
navigationController={this.viewControllerManager.navigationController}
|
||||||
notesController={this.viewControllerManager.notesController}
|
notesController={this.viewControllerManager.notesController}
|
||||||
linkingController={this.viewControllerManager.linkingController}
|
linkingController={this.viewControllerManager.linkingController}
|
||||||
@@ -128,11 +119,6 @@ class NoteGroupView extends PureComponent<Props, State> {
|
|||||||
selectionController={this.viewControllerManager.selectionController}
|
selectionController={this.viewControllerManager.selectionController}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{this.viewControllerManager.navigationController.isInFilesView && fileDragNDropContext?.isDraggingFiles && (
|
|
||||||
<div className="absolute bottom-8 left-1/2 z-dropdown-menu -translate-x-1/2 rounded bg-info px-5 py-3 text-info-contrast shadow-main">
|
|
||||||
Drop your files to upload them
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{shouldNotShowMultipleSelectedItems && hasControllers && canRenderEditorView && (
|
{shouldNotShowMultipleSelectedItems && hasControllers && canRenderEditorView && (
|
||||||
<>
|
<>
|
||||||
{this.state.controllers.map((controller) => {
|
{this.state.controllers.map((controller) => {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import ComponentView from '@/Components/ComponentView/ComponentView'
|
|||||||
import PanelResizer, { PanelSide, PanelResizeType } from '@/Components/PanelResizer/PanelResizer'
|
import PanelResizer, { PanelSide, PanelResizeType } from '@/Components/PanelResizer/PanelResizer'
|
||||||
import { ElementIds } from '@/Constants/ElementIDs'
|
import { ElementIds } from '@/Constants/ElementIDs'
|
||||||
import ChangeEditorButton from '@/Components/ChangeEditor/ChangeEditorButton'
|
import ChangeEditorButton from '@/Components/ChangeEditor/ChangeEditorButton'
|
||||||
import AttachedFilesButton from '@/Components/AttachedFilesPopover/AttachedFilesButton'
|
|
||||||
import EditingDisabledBanner from './EditingDisabledBanner'
|
import EditingDisabledBanner from './EditingDisabledBanner'
|
||||||
import {
|
import {
|
||||||
transactionForAssociateComponentWithCurrentNote,
|
transactionForAssociateComponentWithCurrentNote,
|
||||||
@@ -46,6 +45,7 @@ import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContaine
|
|||||||
import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator'
|
import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator'
|
||||||
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||||
import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
|
import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
|
||||||
|
import NoteViewFileDropTarget from './NoteViewFileDropTarget'
|
||||||
|
|
||||||
const MinimumStatusDuration = 400
|
const MinimumStatusDuration = 400
|
||||||
const TextareaDebounce = 100
|
const TextareaDebounce = 100
|
||||||
@@ -118,6 +118,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
|
|||||||
|
|
||||||
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null
|
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
private noteViewElementRef: RefObject<HTMLDivElement>
|
||||||
private editorContentRef: RefObject<HTMLDivElement>
|
private editorContentRef: RefObject<HTMLDivElement>
|
||||||
|
|
||||||
constructor(props: NoteViewProps) {
|
constructor(props: NoteViewProps) {
|
||||||
@@ -159,6 +160,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
|
|||||||
noteType: this.controller.item.noteType,
|
noteType: this.controller.item.noteType,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.noteViewElementRef = createRef<HTMLDivElement>()
|
||||||
this.editorContentRef = createRef<HTMLDivElement>()
|
this.editorContentRef = createRef<HTMLDivElement>()
|
||||||
|
|
||||||
window.addEventListener('scroll', this.handleWindowScroll)
|
window.addEventListener('scroll', this.handleWindowScroll)
|
||||||
@@ -949,7 +951,15 @@ class NoteView extends PureComponent<NoteViewProps, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-label="Note" className="section editor sn-component">
|
<div aria-label="Note" className="section editor sn-component" ref={this.noteViewElementRef}>
|
||||||
|
{this.note && (
|
||||||
|
<NoteViewFileDropTarget
|
||||||
|
note={this.note}
|
||||||
|
linkingController={this.viewControllerManager.linkingController}
|
||||||
|
noteViewElement={this.noteViewElementRef.current}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{this.state.noteLocked && (
|
{this.state.noteLocked && (
|
||||||
<EditingDisabledBanner
|
<EditingDisabledBanner
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
@@ -1014,16 +1024,6 @@ class NoteView extends PureComponent<NoteViewProps, State> {
|
|||||||
linkingController={this.viewControllerManager.linkingController}
|
linkingController={this.viewControllerManager.linkingController}
|
||||||
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
||||||
/>
|
/>
|
||||||
<AttachedFilesButton
|
|
||||||
application={this.application}
|
|
||||||
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
|
||||||
featuresController={this.viewControllerManager.featuresController}
|
|
||||||
filePreviewModalController={this.viewControllerManager.filePreviewModalController}
|
|
||||||
filesController={this.viewControllerManager.filesController}
|
|
||||||
navigationController={this.viewControllerManager.navigationController}
|
|
||||||
notesController={this.viewControllerManager.notesController}
|
|
||||||
selectionController={this.viewControllerManager.selectionController}
|
|
||||||
/>
|
|
||||||
<ChangeEditorButton
|
<ChangeEditorButton
|
||||||
application={this.application}
|
application={this.application}
|
||||||
viewControllerManager={this.viewControllerManager}
|
viewControllerManager={this.viewControllerManager}
|
||||||
|
|||||||
@@ -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}
|
tag={tag}
|
||||||
tagsState={tagsState}
|
tagsState={tagsState}
|
||||||
features={viewControllerManager.featuresController}
|
features={viewControllerManager.featuresController}
|
||||||
|
linkingController={viewControllerManager.linkingController}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,11 +23,15 @@ import { DropItem, DropProps, ItemTypes } from './DragNDrop'
|
|||||||
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
|
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
|
||||||
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
|
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
|
||||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
|
import { mergeRefs } from '@/Hooks/mergeRefs'
|
||||||
|
import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider'
|
||||||
|
import { LinkingController } from '@/Controllers/LinkingController'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
tag: SNTag
|
tag: SNTag
|
||||||
tagsState: NavigationController
|
tagsState: NavigationController
|
||||||
features: FeaturesController
|
features: FeaturesController
|
||||||
|
linkingController: LinkingController
|
||||||
level: number
|
level: number
|
||||||
onContextMenu: (tag: SNTag, posX: number, posY: number) => void
|
onContextMenu: (tag: SNTag, posX: number, posY: number) => void
|
||||||
}
|
}
|
||||||
@@ -35,288 +39,316 @@ type Props = {
|
|||||||
const PADDING_BASE_PX = 14
|
const PADDING_BASE_PX = 14
|
||||||
const PADDING_PER_LEVEL_PX = 21
|
const PADDING_PER_LEVEL_PX = 21
|
||||||
|
|
||||||
export const TagsListItem: FunctionComponent<Props> = observer(({ tag, features, tagsState, level, onContextMenu }) => {
|
export const TagsListItem: FunctionComponent<Props> = observer(
|
||||||
const { toggleAppPane } = useResponsiveAppPane()
|
({ tag, features, tagsState, level, onContextMenu, linkingController }) => {
|
||||||
|
const { toggleAppPane } = useResponsiveAppPane()
|
||||||
|
|
||||||
const [title, setTitle] = useState(tag.title || '')
|
const [title, setTitle] = useState(tag.title || '')
|
||||||
const [subtagTitle, setSubtagTitle] = useState('')
|
const [subtagTitle, setSubtagTitle] = useState('')
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const subtagInputRef = useRef<HTMLInputElement>(null)
|
const subtagInputRef = useRef<HTMLInputElement>(null)
|
||||||
const menuButtonRef = useRef<HTMLAnchorElement>(null)
|
const menuButtonRef = useRef<HTMLAnchorElement>(null)
|
||||||
|
|
||||||
const isSelected = tagsState.selected === tag
|
const isSelected = tagsState.selected === tag
|
||||||
const isEditing = tagsState.editingTag === tag
|
const isEditing = tagsState.editingTag === tag
|
||||||
const isAddingSubtag = tagsState.addingSubtagTo === tag
|
const isAddingSubtag = tagsState.addingSubtagTo === tag
|
||||||
const noteCounts = computed(() => tagsState.getNotesCount(tag))
|
const noteCounts = computed(() => tagsState.getNotesCount(tag))
|
||||||
|
|
||||||
const childrenTags = computed(() => tagsState.getChildren(tag)).get()
|
const childrenTags = computed(() => tagsState.getChildren(tag)).get()
|
||||||
const hasChildren = childrenTags.length > 0
|
const hasChildren = childrenTags.length > 0
|
||||||
|
|
||||||
const hasFolders = features.hasFolders
|
const hasFolders = features.hasFolders
|
||||||
const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder
|
const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder
|
||||||
|
|
||||||
const premiumModal = usePremiumModal()
|
const premiumModal = usePremiumModal()
|
||||||
|
|
||||||
const [showChildren, setShowChildren] = useState(tag.expanded)
|
const [showChildren, setShowChildren] = useState(tag.expanded)
|
||||||
const [hadChildren, setHadChildren] = useState(hasChildren)
|
const [hadChildren, setHadChildren] = useState(hasChildren)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hadChildren && hasChildren) {
|
if (!hadChildren && hasChildren) {
|
||||||
setShowChildren(true)
|
setShowChildren(true)
|
||||||
}
|
}
|
||||||
setHadChildren(hasChildren)
|
setHadChildren(hasChildren)
|
||||||
}, [hadChildren, hasChildren])
|
}, [hadChildren, hasChildren])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTitle(tag.title || '')
|
setTitle(tag.title || '')
|
||||||
}, [setTitle, tag])
|
}, [setTitle, tag])
|
||||||
|
|
||||||
const toggleChildren: MouseEventHandler = useCallback(
|
const toggleChildren: MouseEventHandler = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setShowChildren((x) => {
|
setShowChildren((x) => {
|
||||||
tagsState.setExpanded(tag, !x)
|
tagsState.setExpanded(tag, !x)
|
||||||
return !x
|
return !x
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[setShowChildren, tag, tagsState],
|
[setShowChildren, tag, tagsState],
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectCurrentTag = useCallback(async () => {
|
const selectCurrentTag = useCallback(async () => {
|
||||||
await tagsState.setSelectedTag(tag)
|
await tagsState.setSelectedTag(tag)
|
||||||
toggleAppPane(AppPaneId.Items)
|
toggleAppPane(AppPaneId.Items)
|
||||||
}, [tagsState, tag, toggleAppPane])
|
}, [tagsState, tag, toggleAppPane])
|
||||||
|
|
||||||
const onBlur = useCallback(() => {
|
const onBlur = useCallback(() => {
|
||||||
tagsState.save(tag, title).catch(console.error)
|
tagsState.save(tag, title).catch(console.error)
|
||||||
setTitle(tag.title)
|
setTitle(tag.title)
|
||||||
}, [tagsState, tag, title, setTitle])
|
}, [tagsState, tag, title, setTitle])
|
||||||
|
|
||||||
const onInput: FormEventHandler = useCallback(
|
const onInput: FormEventHandler = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
|
const value = (e.target as HTMLInputElement).value
|
||||||
|
setTitle(value)
|
||||||
|
},
|
||||||
|
[setTitle],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onKeyDown: KeyboardEventHandler = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (e.key === KeyboardKey.Enter) {
|
||||||
|
inputRef.current?.blur()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[inputRef],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing) {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
}, [inputRef, isEditing])
|
||||||
|
|
||||||
|
const onSubtagInput: FormEventHandler<HTMLInputElement> = useCallback((e) => {
|
||||||
const value = (e.target as HTMLInputElement).value
|
const value = (e.target as HTMLInputElement).value
|
||||||
setTitle(value)
|
setSubtagTitle(value)
|
||||||
},
|
}, [])
|
||||||
[setTitle],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onKeyDown: KeyboardEventHandler = useCallback(
|
const onSubtagInputBlur = useCallback(() => {
|
||||||
(e) => {
|
tagsState.createSubtagAndAssignParent(tag, subtagTitle).catch(console.error)
|
||||||
if (e.key === KeyboardKey.Enter) {
|
setSubtagTitle('')
|
||||||
inputRef.current?.blur()
|
}, [subtagTitle, tag, tagsState])
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[inputRef],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
const onSubtagKeyDown: KeyboardEventHandler = useCallback(
|
||||||
if (isEditing) {
|
(e) => {
|
||||||
inputRef.current?.focus()
|
if (e.key === KeyboardKey.Enter) {
|
||||||
}
|
e.preventDefault()
|
||||||
}, [inputRef, isEditing])
|
subtagInputRef.current?.blur()
|
||||||
|
}
|
||||||
const onSubtagInput: FormEventHandler<HTMLInputElement> = useCallback((e) => {
|
|
||||||
const value = (e.target as HTMLInputElement).value
|
|
||||||
setSubtagTitle(value)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onSubtagInputBlur = useCallback(() => {
|
|
||||||
tagsState.createSubtagAndAssignParent(tag, subtagTitle).catch(console.error)
|
|
||||||
setSubtagTitle('')
|
|
||||||
}, [subtagTitle, tag, tagsState])
|
|
||||||
|
|
||||||
const onSubtagKeyDown: KeyboardEventHandler = useCallback(
|
|
||||||
(e) => {
|
|
||||||
if (e.key === KeyboardKey.Enter) {
|
|
||||||
e.preventDefault()
|
|
||||||
subtagInputRef.current?.blur()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[subtagInputRef],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAddingSubtag) {
|
|
||||||
subtagInputRef.current?.focus()
|
|
||||||
}
|
|
||||||
}, [subtagInputRef, isAddingSubtag])
|
|
||||||
|
|
||||||
const [, dragRef] = useDrag(
|
|
||||||
() => ({
|
|
||||||
type: ItemTypes.TAG,
|
|
||||||
item: { uuid: tag.uuid },
|
|
||||||
canDrag: () => {
|
|
||||||
return true
|
|
||||||
},
|
},
|
||||||
collect: (monitor) => ({
|
[subtagInputRef],
|
||||||
isDragging: !!monitor.isDragging(),
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAddingSubtag) {
|
||||||
|
subtagInputRef.current?.focus()
|
||||||
|
}
|
||||||
|
}, [subtagInputRef, isAddingSubtag])
|
||||||
|
|
||||||
|
const [, dragRef] = useDrag(
|
||||||
|
() => ({
|
||||||
|
type: ItemTypes.TAG,
|
||||||
|
item: { uuid: tag.uuid },
|
||||||
|
canDrag: () => {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isDragging: !!monitor.isDragging(),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
[tag],
|
||||||
[tag],
|
)
|
||||||
)
|
|
||||||
|
|
||||||
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
|
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
|
||||||
() => ({
|
() => ({
|
||||||
accept: ItemTypes.TAG,
|
accept: ItemTypes.TAG,
|
||||||
canDrop: (item) => {
|
canDrop: (item) => {
|
||||||
return tagsState.isValidTagParent(tag, item as SNTag)
|
return tagsState.isValidTagParent(tag, item as SNTag)
|
||||||
},
|
},
|
||||||
drop: (item) => {
|
drop: (item) => {
|
||||||
if (!hasFolders) {
|
if (!hasFolders) {
|
||||||
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME)
|
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tagsState.assignParent(item.uuid, tag.uuid).catch(console.error)
|
||||||
|
},
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isOver: !!monitor.isOver(),
|
||||||
|
canDrop: !!monitor.canDrop(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[tag, tagsState, hasFolders, premiumModal],
|
||||||
|
)
|
||||||
|
|
||||||
|
const readyToDrop = isOver && canDrop
|
||||||
|
|
||||||
|
const toggleContextMenu: MouseEventHandler<HTMLAnchorElement> = useCallback(
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
if (!menuButtonRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tagsState.assignParent(item.uuid, tag.uuid).catch(console.error)
|
|
||||||
|
const contextMenuOpen = tagsState.contextMenuOpen
|
||||||
|
const menuButtonRect = menuButtonRef.current?.getBoundingClientRect()
|
||||||
|
|
||||||
|
if (contextMenuOpen) {
|
||||||
|
tagsState.setContextMenuOpen(false)
|
||||||
|
} else {
|
||||||
|
onContextMenu(tag, menuButtonRect.right, menuButtonRect.top)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
collect: (monitor) => ({
|
[onContextMenu, tagsState, tag],
|
||||||
isOver: !!monitor.isOver(),
|
)
|
||||||
canDrop: !!monitor.canDrop(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
[tag, tagsState, hasFolders, premiumModal],
|
|
||||||
)
|
|
||||||
|
|
||||||
const readyToDrop = isOver && canDrop
|
const tagRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const toggleContextMenu: MouseEventHandler<HTMLAnchorElement> = useCallback(
|
const { addDragTarget, removeDragTarget } = useFileDragNDrop()
|
||||||
(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
|
|
||||||
if (!menuButtonRef.current) {
|
useEffect(() => {
|
||||||
return
|
const target = tagRef.current
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
addDragTarget(target, {
|
||||||
|
tooltipText: `Drop your files to upload and link them to tag "${tag.title}"`,
|
||||||
|
callback(files) {
|
||||||
|
files.forEach(async (file) => {
|
||||||
|
await linkingController.linkItems(file, tag)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const contextMenuOpen = tagsState.contextMenuOpen
|
return () => {
|
||||||
const menuButtonRect = menuButtonRef.current?.getBoundingClientRect()
|
if (target) {
|
||||||
|
removeDragTarget(target)
|
||||||
if (contextMenuOpen) {
|
}
|
||||||
tagsState.setContextMenuOpen(false)
|
|
||||||
} else {
|
|
||||||
onContextMenu(tag, menuButtonRect.right, menuButtonRect.top)
|
|
||||||
}
|
}
|
||||||
},
|
}, [addDragTarget, linkingController, removeDragTarget, tag])
|
||||||
[onContextMenu, tagsState, tag],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className={classNames(
|
|
||||||
'tag py-2 px-3.5 focus:shadow-inner md:py-1',
|
|
||||||
isSelected && 'selected',
|
|
||||||
readyToDrop && 'is-drag-over',
|
|
||||||
)}
|
|
||||||
onClick={selectCurrentTag}
|
|
||||||
ref={dragRef}
|
|
||||||
style={{
|
|
||||||
paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
|
|
||||||
}}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
onContextMenu(tag, e.clientX, e.clientY)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="tag-info" title={title} ref={dropRef}>
|
|
||||||
{hasAtLeastOneFolder && (
|
|
||||||
<div className="tag-fold-container">
|
|
||||||
<a
|
|
||||||
role="button"
|
|
||||||
className={`tag-fold focus:shadow-inner ${showChildren ? 'opened' : 'closed'} ${
|
|
||||||
!hasChildren ? 'invisible' : ''
|
|
||||||
}`}
|
|
||||||
onClick={hasChildren ? toggleChildren : undefined}
|
|
||||||
>
|
|
||||||
<Icon className={'text-neutral'} type={showChildren ? 'menu-arrow-down-alt' : 'menu-arrow-right'} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={'tag-icon draggable mr-2'} ref={dragRef}>
|
|
||||||
<Icon type="hashtag" className={`${isSelected ? 'text-info' : 'text-neutral'}`} />
|
|
||||||
</div>
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
className={'title editing focus:shadow-none focus:outline-none'}
|
|
||||||
id={`react-tag-${tag.uuid}`}
|
|
||||||
onBlur={onBlur}
|
|
||||||
onInput={onInput}
|
|
||||||
value={title}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
spellCheck={false}
|
|
||||||
ref={inputRef}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={'title overflow-hidden text-left focus:shadow-none focus:outline-none'}
|
|
||||||
id={`react-tag-${tag.uuid}`}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<a
|
|
||||||
role="button"
|
|
||||||
className={`mr-2 cursor-pointer border-0 bg-transparent hover:bg-contrast focus:shadow-inner ${
|
|
||||||
isSelected ? 'visible' : 'invisible'
|
|
||||||
}`}
|
|
||||||
onClick={toggleContextMenu}
|
|
||||||
ref={menuButtonRef}
|
|
||||||
>
|
|
||||||
<Icon type="more" className="text-neutral" />
|
|
||||||
</a>
|
|
||||||
<div className="count">{noteCounts.get()}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`meta ${hasAtLeastOneFolder ? 'with-folders' : ''}`}>
|
|
||||||
{tag.conflictOf && <div className="danger text-[0.625rem] font-bold">Conflicted Copy {tag.conflictOf}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isAddingSubtag && (
|
|
||||||
<div
|
<div
|
||||||
className="tag overflow-hidden"
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={classNames(
|
||||||
|
'tag py-2 px-3.5 focus:shadow-inner md:py-1',
|
||||||
|
isSelected && 'selected',
|
||||||
|
readyToDrop && 'is-drag-over',
|
||||||
|
)}
|
||||||
|
onClick={selectCurrentTag}
|
||||||
|
ref={mergeRefs([dragRef, tagRef])}
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: `${(level + 1) * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
|
paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onContextMenu(tag, e.clientX, e.clientY)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="tag-info">
|
<div className="tag-info" title={title} ref={dropRef}>
|
||||||
<div className="flex h-full min-w-[22px] items-center border-0 bg-transparent p-0" />
|
{hasAtLeastOneFolder && (
|
||||||
<div className="tag-icon mr-1">
|
<div className="tag-fold-container">
|
||||||
<Icon type="hashtag" className="mr-1 text-neutral" />
|
<a
|
||||||
|
role="button"
|
||||||
|
className={`tag-fold focus:shadow-inner ${showChildren ? 'opened' : 'closed'} ${
|
||||||
|
!hasChildren ? 'invisible' : ''
|
||||||
|
}`}
|
||||||
|
onClick={hasChildren ? toggleChildren : undefined}
|
||||||
|
>
|
||||||
|
<Icon className={'text-neutral'} type={showChildren ? 'menu-arrow-down-alt' : 'menu-arrow-right'} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={'tag-icon draggable mr-2'} ref={dragRef}>
|
||||||
|
<Icon type="hashtag" className={`${isSelected ? 'text-info' : 'text-neutral'}`} />
|
||||||
</div>
|
</div>
|
||||||
<input
|
{isEditing ? (
|
||||||
className="title w-full focus:shadow-none focus:outline-none"
|
<input
|
||||||
type="text"
|
className={'title editing focus:shadow-none focus:outline-none'}
|
||||||
ref={subtagInputRef}
|
id={`react-tag-${tag.uuid}`}
|
||||||
onBlur={onSubtagInputBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={onSubtagKeyDown}
|
onInput={onInput}
|
||||||
value={subtagTitle}
|
value={title}
|
||||||
onInput={onSubtagInput}
|
onKeyDown={onKeyDown}
|
||||||
/>
|
spellCheck={false}
|
||||||
|
ref={inputRef}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={'title overflow-hidden text-left focus:shadow-none focus:outline-none'}
|
||||||
|
id={`react-tag-${tag.uuid}`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
className={`mr-2 cursor-pointer border-0 bg-transparent hover:bg-contrast focus:shadow-inner ${
|
||||||
|
isSelected ? 'visible' : 'invisible'
|
||||||
|
}`}
|
||||||
|
onClick={toggleContextMenu}
|
||||||
|
ref={menuButtonRef}
|
||||||
|
>
|
||||||
|
<Icon type="more" className="text-neutral" />
|
||||||
|
</a>
|
||||||
|
<div className="count">{noteCounts.get()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`meta ${hasAtLeastOneFolder ? 'with-folders' : ''}`}>
|
||||||
|
{tag.conflictOf && <div className="danger text-[0.625rem] font-bold">Conflicted Copy {tag.conflictOf}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{isAddingSubtag && (
|
||||||
{showChildren && (
|
<div
|
||||||
<>
|
className="tag overflow-hidden"
|
||||||
{childrenTags.map((tag) => {
|
style={{
|
||||||
return (
|
paddingLeft: `${(level + 1) * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
|
||||||
<TagsListItem
|
}}
|
||||||
level={level + 1}
|
>
|
||||||
key={tag.uuid}
|
<div className="tag-info">
|
||||||
tag={tag}
|
<div className="flex h-full min-w-[22px] items-center border-0 bg-transparent p-0" />
|
||||||
tagsState={tagsState}
|
<div className="tag-icon mr-1">
|
||||||
features={features}
|
<Icon type="hashtag" className="mr-1 text-neutral" />
|
||||||
onContextMenu={onContextMenu}
|
</div>
|
||||||
|
<input
|
||||||
|
className="title w-full focus:shadow-none focus:outline-none"
|
||||||
|
type="text"
|
||||||
|
ref={subtagInputRef}
|
||||||
|
onBlur={onSubtagInputBlur}
|
||||||
|
onKeyDown={onSubtagKeyDown}
|
||||||
|
value={subtagTitle}
|
||||||
|
onInput={onSubtagInput}
|
||||||
/>
|
/>
|
||||||
)
|
</div>
|
||||||
})}
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
{showChildren && (
|
||||||
</>
|
<>
|
||||||
)
|
{childrenTags.map((tag) => {
|
||||||
})
|
return (
|
||||||
|
<TagsListItem
|
||||||
|
level={level + 1}
|
||||||
|
key={tag.uuid}
|
||||||
|
tag={tag}
|
||||||
|
tagsState={tagsState}
|
||||||
|
features={features}
|
||||||
|
linkingController={linkingController}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
TagsListItem.displayName = 'TagsListItem'
|
TagsListItem.displayName = 'TagsListItem'
|
||||||
|
|||||||
@@ -282,25 +282,22 @@ export class LinkingController extends AbstractViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
linkItemToSelectedItem = async (itemToLink: LinkableItem) => {
|
linkItems = async (item: LinkableItem, itemToLink: LinkableItem) => {
|
||||||
await this.ensureActiveItemIsInserted()
|
if (item instanceof SNNote) {
|
||||||
const activeItem = this.activeItem
|
|
||||||
|
|
||||||
if (activeItem && itemToLink instanceof SNTag) {
|
|
||||||
await this.addTagToItem(itemToLink, activeItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeItem instanceof SNNote) {
|
|
||||||
if (itemToLink instanceof FileItem) {
|
if (itemToLink instanceof FileItem) {
|
||||||
await this.application.items.associateFileWithNote(itemToLink, activeItem)
|
await this.application.items.associateFileWithNote(itemToLink, item)
|
||||||
} else if (itemToLink instanceof SNNote && this.isEntitledToNoteLinking) {
|
} else if (itemToLink instanceof SNNote && this.isEntitledToNoteLinking) {
|
||||||
await this.application.items.linkNoteToNote(activeItem, itemToLink)
|
await this.application.items.linkNoteToNote(item, itemToLink)
|
||||||
|
} else if (itemToLink instanceof SNTag) {
|
||||||
|
await this.addTagToItem(itemToLink, item)
|
||||||
}
|
}
|
||||||
} else if (activeItem instanceof FileItem) {
|
} else if (item instanceof FileItem) {
|
||||||
if (itemToLink instanceof SNNote) {
|
if (itemToLink instanceof SNNote) {
|
||||||
await this.application.items.associateFileWithNote(activeItem, itemToLink)
|
await this.application.items.associateFileWithNote(item, itemToLink)
|
||||||
} else if (itemToLink instanceof FileItem) {
|
} else if (itemToLink instanceof FileItem) {
|
||||||
await this.application.items.linkFileToFile(activeItem, itemToLink)
|
await this.application.items.linkFileToFile(item, itemToLink)
|
||||||
|
} else if (itemToLink instanceof SNTag) {
|
||||||
|
await this.addTagToItem(itemToLink, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +305,17 @@ export class LinkingController extends AbstractViewController {
|
|||||||
this.reloadAllLinks()
|
this.reloadAllLinks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
linkItemToSelectedItem = async (itemToLink: LinkableItem) => {
|
||||||
|
await this.ensureActiveItemIsInserted()
|
||||||
|
const activeItem = this.activeItem
|
||||||
|
|
||||||
|
if (!activeItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.linkItems(activeItem, itemToLink)
|
||||||
|
}
|
||||||
|
|
||||||
createAndAddNewTag = async (title: string) => {
|
createAndAddNewTag = async (title: string) => {
|
||||||
await this.ensureActiveItemIsInserted()
|
await this.ensureActiveItemIsInserted()
|
||||||
const activeItem = this.activeItem
|
const activeItem = this.activeItem
|
||||||
|
|||||||
Reference in New Issue
Block a user