feat: You can now drag-n-drop notes onto tags to move them from one tag to another (#2334)
This commit is contained in:
@@ -13,6 +13,8 @@ import { ListItemTitle } from './ListItemTitle'
|
|||||||
import { log, LoggingDomain } from '@/Logging'
|
import { log, LoggingDomain } from '@/Logging'
|
||||||
import { classNames } from '@standardnotes/utils'
|
import { classNames } from '@standardnotes/utils'
|
||||||
import { getIconAndTintForNoteType } from '@/Utils/Items/Icons/getIconAndTintForNoteType'
|
import { getIconAndTintForNoteType } from '@/Utils/Items/Icons/getIconAndTintForNoteType'
|
||||||
|
import { NoteDragDataFormat } from '../Tags/DragNDrop'
|
||||||
|
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
|
||||||
|
|
||||||
const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
|
const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
|
||||||
application,
|
application,
|
||||||
@@ -70,6 +72,34 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
|
|||||||
|
|
||||||
const hasOffsetBorder = !isNextItemTiled
|
const hasOffsetBorder = !isNextItemTiled
|
||||||
|
|
||||||
|
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
|
||||||
|
const dragPreview = useRef<HTMLDivElement>()
|
||||||
|
|
||||||
|
const createDragPreview = () => {
|
||||||
|
if (!listItemRef.current) {
|
||||||
|
throw new Error('List item ref is not set')
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = listItemRef.current.cloneNode(true)
|
||||||
|
// Only keep icon & title in drag preview
|
||||||
|
Array.from(element.childNodes[1].childNodes).forEach((node, key) => {
|
||||||
|
if (key !== 0) {
|
||||||
|
node.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
element.childNodes[2].remove()
|
||||||
|
if (element instanceof HTMLDivElement) {
|
||||||
|
element.style.width = `${listItemRef.current.clientWidth}px`
|
||||||
|
element.style.position = 'absolute'
|
||||||
|
element.style.top = '0'
|
||||||
|
element.style.left = '0'
|
||||||
|
element.style.zIndex = '-100000'
|
||||||
|
document.body.appendChild(element)
|
||||||
|
dragPreview.current = element
|
||||||
|
}
|
||||||
|
return element as HTMLDivElement
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={listItemRef}
|
ref={listItemRef}
|
||||||
@@ -81,6 +111,23 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
|
|||||||
)}
|
)}
|
||||||
id={item.uuid}
|
id={item.uuid}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
draggable={!isMobileScreen}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (!listItemRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dataTransfer } = event
|
||||||
|
|
||||||
|
const element = createDragPreview()
|
||||||
|
dataTransfer.setDragImage(element, 0, 0)
|
||||||
|
dataTransfer.setData(NoteDragDataFormat, item.uuid)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => {
|
||||||
|
if (dragPreview.current) {
|
||||||
|
dragPreview.current.remove()
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{!hideIcon ? (
|
{!hideIcon ? (
|
||||||
<div className="mr-0 flex flex-col items-center justify-between gap-2 p-4 pr-4">
|
<div className="mr-0 flex flex-col items-center justify-between gap-2 p-4 pr-4">
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export const TagDragDataFormat = 'application/x-sn-drag-tag'
|
export const TagDragDataFormat = 'application/x-sn-drag-tag'
|
||||||
|
export const NoteDragDataFormat = 'application/x-sn-drag-note'
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FOCUSABLE_BUT_NOT_TABBABLE, TAG_FOLDERS_FEATURE_NAME } from '@/Constant
|
|||||||
import { KeyboardKey } from '@standardnotes/ui-services'
|
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||||
import { FeaturesController } from '@/Controllers/FeaturesController'
|
import { FeaturesController } from '@/Controllers/FeaturesController'
|
||||||
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
|
||||||
import { IconType, SNTag } from '@standardnotes/snjs'
|
import { IconType, SNNote, SNTag } from '@standardnotes/snjs'
|
||||||
import { computed } from 'mobx'
|
import { computed } from 'mobx'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import {
|
import {
|
||||||
@@ -23,8 +23,9 @@ import { useFileDragNDrop } from '../FileDragNDropProvider'
|
|||||||
import { LinkingController } from '@/Controllers/LinkingController'
|
import { LinkingController } from '@/Controllers/LinkingController'
|
||||||
import { TagListSectionType } from './TagListSection'
|
import { TagListSectionType } from './TagListSection'
|
||||||
import { log, LoggingDomain } from '@/Logging'
|
import { log, LoggingDomain } from '@/Logging'
|
||||||
import { TagDragDataFormat } from './DragNDrop'
|
import { NoteDragDataFormat, TagDragDataFormat } from './DragNDrop'
|
||||||
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||||
|
import { useApplication } from '../ApplicationProvider'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
tag: SNTag
|
tag: SNTag
|
||||||
@@ -40,7 +41,9 @@ const PADDING_BASE_PX = 14
|
|||||||
const PADDING_PER_LEVEL_PX = 21
|
const PADDING_PER_LEVEL_PX = 21
|
||||||
|
|
||||||
export const TagsListItem: FunctionComponent<Props> = observer(
|
export const TagsListItem: FunctionComponent<Props> = observer(
|
||||||
({ tag, type, features, navigationController: navigationController, level, onContextMenu, linkingController }) => {
|
({ tag, type, features, navigationController, level, onContextMenu, linkingController }) => {
|
||||||
|
const application = useApplication()
|
||||||
|
|
||||||
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)
|
||||||
@@ -200,7 +203,10 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onDragEnter: DragEventHandler<HTMLDivElement> = useCallback((event): void => {
|
const onDragEnter: DragEventHandler<HTMLDivElement> = useCallback((event): void => {
|
||||||
if (event.dataTransfer.types.includes(TagDragDataFormat)) {
|
if (
|
||||||
|
event.dataTransfer.types.includes(TagDragDataFormat) ||
|
||||||
|
event.dataTransfer.types.includes(NoteDragDataFormat)
|
||||||
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setIsBeingDraggedOver(true)
|
setIsBeingDraggedOver(true)
|
||||||
}
|
}
|
||||||
@@ -211,30 +217,42 @@ export const TagsListItem: FunctionComponent<Props> = observer(
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onDragOver: DragEventHandler<HTMLDivElement> = useCallback((event): void => {
|
const onDragOver: DragEventHandler<HTMLDivElement> = useCallback((event): void => {
|
||||||
if (event.dataTransfer.types.includes(TagDragDataFormat)) {
|
if (
|
||||||
|
event.dataTransfer.types.includes(TagDragDataFormat) ||
|
||||||
|
event.dataTransfer.types.includes(NoteDragDataFormat)
|
||||||
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onDrop: DragEventHandler<HTMLDivElement> = useCallback(
|
const onDrop: DragEventHandler<HTMLDivElement> = useCallback(
|
||||||
(event): void => {
|
async (event) => {
|
||||||
setIsBeingDraggedOver(false)
|
setIsBeingDraggedOver(false)
|
||||||
const draggedTagUuid = event.dataTransfer.getData(TagDragDataFormat)
|
const draggedTagUuid = event.dataTransfer.getData(TagDragDataFormat)
|
||||||
if (!draggedTagUuid) {
|
const draggedNoteUuid = event.dataTransfer.getData(NoteDragDataFormat)
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!navigationController.isValidTagParent(tag, { uuid: draggedTagUuid } as SNTag)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!hasFolders) {
|
|
||||||
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (draggedTagUuid) {
|
if (draggedTagUuid) {
|
||||||
|
if (!navigationController.isValidTagParent(tag, { uuid: draggedTagUuid } as SNTag)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!hasFolders) {
|
||||||
|
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
void navigationController.assignParent(draggedTagUuid, tag.uuid)
|
void navigationController.assignParent(draggedTagUuid, tag.uuid)
|
||||||
|
return
|
||||||
|
} else if (draggedNoteUuid) {
|
||||||
|
const currentTag = navigationController.selected
|
||||||
|
const shouldSwapTags = currentTag instanceof SNTag && currentTag.uuid !== tag.uuid
|
||||||
|
const note = application.items.findSureItem<SNNote>(draggedNoteUuid)
|
||||||
|
await linkingController.linkItems(note, tag)
|
||||||
|
if (shouldSwapTags) {
|
||||||
|
await linkingController.unlinkItems(note, currentTag)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[hasFolders, navigationController, premiumModal, tag],
|
[application.items, hasFolders, linkingController, navigationController, premiumModal, tag],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user