diff --git a/src/components/atoms/NavigatorHeader.tsx b/src/components/atoms/NavigatorHeader.tsx index b8eac8d1fb..98b43ba49e 100644 --- a/src/components/atoms/NavigatorHeader.tsx +++ b/src/components/atoms/NavigatorHeader.tsx @@ -3,6 +3,7 @@ import styled from '../../lib/styled' import { textOverflow } from '../../lib/styled/styleFunctions' import Icon from './Icon' import { mdiChevronRight, mdiChevronDown } from '@mdi/js' +import cc from 'classcat' const HeaderContainer = styled.header` position: relative; @@ -73,28 +74,50 @@ const ClickableContainer = styled.div` &.subtle { color: ${({ theme }) => theme.disabledUiTextColor}; } + + &.dragged-over { + .dragged-over { + border-color: ${({ theme }) => theme.secondaryBorderColor}; + } + background-color: ${({ theme }) => + theme.secondaryButtonBackgroundColor} !important; + } ` interface NavigatorHeaderProps { label: string active?: boolean + draggedOver?: boolean control?: React.ReactNode onContextMenu?: React.MouseEventHandler folded?: boolean onClick?: React.MouseEventHandler + onDrop?: (event: React.DragEvent) => void + onDragOver?: (event: React.DragEvent) => void + onDragLeave?: (event: React.DragEvent) => void } const NavigatorHeader = ({ folded, label, active = false, + draggedOver, onContextMenu, onClick, + onDrop, + onDragOver, + onDragLeave, control, }: NavigatorHeaderProps) => { return ( - + {folded != null && ( )} diff --git a/src/components/atoms/NavigatorItem.tsx b/src/components/atoms/NavigatorItem.tsx index 37138b1f14..c89aa2c2b9 100644 --- a/src/components/atoms/NavigatorItem.tsx +++ b/src/components/atoms/NavigatorItem.tsx @@ -32,6 +32,15 @@ const Container = styled.div` opacity: 1; } } + + &.dragged-over { + border-radius: 3px; + .dragged-over { + border-color: ${({ theme }) => theme.secondaryBorderColor}; + } + background-color: ${({ theme }) => + theme.secondaryButtonBackgroundColor} !important; + } ` const FoldButton = styled.button` @@ -90,7 +99,7 @@ const ClickableContainer = styled.button` ` const Label = styled.div` - ${textOverflow} + ${textOverflow}; flex: 1; font-size: 14px; @@ -128,12 +137,16 @@ interface NavigatorItemProps { active?: boolean subtle?: boolean alert?: boolean + draggable?: boolean + draggedOver?: boolean onFoldButtonClick?: (event: React.MouseEvent) => void onClick?: (event: React.MouseEvent) => void onContextMenu?: (event: React.MouseEvent) => void + onDragStart?: (event: React.DragEvent) => void onDrop?: (event: React.DragEvent) => void onDragOver?: (event: React.DragEvent) => void onDragEnd?: (event: React.DragEvent) => void + onDragLeave?: (event: React.DragEvent) => void onDoubleClick?: (event: React.MouseEvent) => void } @@ -148,15 +161,19 @@ const NavigatorItem = ({ // TODO: Delete dot placeholder style dotPlaceholder, active, + draggable, subtle, alert, onFoldButtonClick, onClick, onDoubleClick, onContextMenu, + onDragStart, onDrop, onDragOver, onDragEnd, + onDragLeave, + draggedOver, }: NavigatorItemProps) => { return ( {!dotPlaceholder && folded != null && ( { - const { toggleSideNavOpenedItem, sideNavOpenedItemSet } = useGeneralStatus() + const [draggedOver, setDraggedOver] = useState(false) + const { + toggleSideNavOpenedItem, + sideNavOpenedItemSet, + openSideNavFolderItemRecursively, + } = useGeneralStatus() const { push } = useRouter() const { messageBox } = useDialog() const { t } = useTranslation() + const { pushMessage } = useToast() const { createNote, updateNote, removeFolder, moveNoteToOtherStorage, + renameFolder, } = useDb() const itemId = useMemo(() => { @@ -179,6 +193,7 @@ const FolderNavigatorItem = ({ const handleDrop = async (event: React.DragEvent) => { const transferrableNoteData = getTransferrableNoteData(event) if (transferrableNoteData == null) { + handleDropNavigatorItem(event) return } @@ -224,6 +239,81 @@ const FolderNavigatorItem = ({ } } + const handleDragOverNavigatorItem = useCallback((e) => { + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'move' + setDraggedOver(true) + }, []) + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + console.log('Drag is over!') + setDraggedOver(false) + } + + const handleDragStartNavigatorItem = (event: React.DragEvent) => { + setTransferableTextData(event, folderPathnameFormat, folderPathname) + } + + const handleDropFolderToNavigatorItem = ( + sourceItemPathname: string, + destinationFolderPathname: string + ) => { + const sourceFolderName = sourceItemPathname.substring( + sourceItemPathname.lastIndexOf('/') + ) + const newFolderPathname = `${destinationFolderPathname}${sourceFolderName}` + renameFolder( + storageId, + sourceItemPathname == '' ? '/' : sourceItemPathname, + newFolderPathname + ) + .then(() => { + // refresh works for file system (FSNote) database + push(`/app/storages/${storageId}/notes${newFolderPathname}`) + openSideNavFolderItemRecursively(storageId, newFolderPathname) + }) + .catch((err) => { + pushMessage({ + title: 'Folder Structure Update Failed', + description: `Updating folder location failed. Reason: ${ + err != null && err.message != null ? err.message : 'Unknown' + }`, + }) + }) + } + const handleDropNoteToNavigatorItem = ( + noteId: string, + destinationFolderPathname: string + ) => { + // move folder pathname of the note to new location (target destination) + updateNote(storageId, noteId, { + folderPathname: destinationFolderPathname, + }) + } + + const handleDropNavigatorItem = (event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + setDraggedOver(false) + + const sourceFolderPathname = getTransferableTextData( + event, + folderPathnameFormat + ) + if (sourceFolderPathname != null) { + handleDropFolderToNavigatorItem(sourceFolderPathname, folderPathname) + } else { + const noteId = getTransferableTextData(event, noteIdFormat) + if (noteId == null) { + return + } + handleDropNoteToNavigatorItem(noteId, folderPathname) + } + } + return ( } - onDragOver={preventDefault} - onDrop={handleDrop} + draggable={true} + draggedOver={draggedOver} + onDragStart={handleDragStartNavigatorItem} + onDragOver={handleDragOverNavigatorItem} + onDrop={(e) => handleDrop(e)} + onDragLeave={(e) => handleDragLeave(e)} /> ) } -function preventDefault(event: MouseEvent) { - event.preventDefault() -} - export default FolderNavigatorItem diff --git a/src/components/molecules/FolderNoteNavigatorFragment.tsx b/src/components/molecules/FolderNoteNavigatorFragment.tsx index 5f28408e36..c29717a783 100644 --- a/src/components/molecules/FolderNoteNavigatorFragment.tsx +++ b/src/components/molecules/FolderNoteNavigatorFragment.tsx @@ -4,6 +4,7 @@ import { NoteDoc, PopulatedFolderDoc, ObjectMap, + NoteDocEditibleProps, } from '../../lib/db/types' import { useRouteParams, StorageNotesRouteParams } from '../../lib/routeParams' import { useGeneralStatus } from '../../lib/generalStatus' @@ -25,6 +26,16 @@ interface FolderNoteNavigatorFragment { noteId: string ) => Promise trashNote: (storageId: string, noteId: string) => Promise + updateNote( + storageId: string, + noteId: string, + noteProps: Partial + ): Promise + renameFolder: ( + storageName: string, + pathname: string, + newName: string + ) => Promise } const FolderNoteNavigatorFragment = ({ @@ -35,6 +46,8 @@ const FolderNoteNavigatorFragment = ({ bookmarkNote, unbookmarkNote, trashNote, + updateNote, + renameFolder, }: FolderNoteNavigatorFragment) => { const { folderMap, id: storageId } = storage @@ -145,6 +158,8 @@ const FolderNoteNavigatorFragment = ({ bookmarkNote={bookmarkNote} unbookmarkNote={unbookmarkNote} trashNote={trashNote} + renameFolder={renameFolder} + updateNote={updateNote} /> ) })} diff --git a/src/components/molecules/NoteNavigatorItem.tsx b/src/components/molecules/NoteNavigatorItem.tsx index c6d3f062b5..0eaf8ff870 100644 --- a/src/components/molecules/NoteNavigatorItem.tsx +++ b/src/components/molecules/NoteNavigatorItem.tsx @@ -1,10 +1,19 @@ -import React, { useCallback, MouseEventHandler } from 'react' +import React, { useCallback, MouseEventHandler, useState } from 'react' import NavigatorItem from '../atoms/NavigatorItem' import { mdiCardTextOutline, mdiDotsVertical } from '@mdi/js' import { useStorageRouter } from '../../lib/storageRouter' -import { NoteDoc } from '../../lib/db/types' +import { NoteDoc, NoteDocEditibleProps } from '../../lib/db/types' import NavigatorButton from '../atoms/NavigatorButton' import { openContextMenu } from '../../lib/electronOnly' +import { useToast } from '../../lib/toast' +import { useRouter } from '../../lib/router' +import { useGeneralStatus } from '../../lib/generalStatus' +import { + folderPathnameFormat, + getTransferableTextData, + noteIdFormat, + setTransferableTextData, +} from '../../lib/dnd' interface NoteNavigatorItemProps { storageId: string @@ -23,6 +32,16 @@ interface NoteNavigatorItemProps { noteId: string ) => Promise trashNote: (storageId: string, noteId: string) => Promise + updateNote( + storageId: string, + noteId: string, + noteProps: Partial + ): Promise + renameFolder: ( + storageName: string, + pathname: string, + newName: string + ) => Promise } const NoteNavigatorItem = ({ @@ -36,9 +55,15 @@ const NoteNavigatorItem = ({ bookmarkNote, unbookmarkNote, trashNote, + updateNote, + renameFolder, }: NoteNavigatorItemProps) => { + const [draggedOver, setDraggedOver] = useState(false) const emptyTitle = noteTitle.trim().length === 0 const { navigateToNote } = useStorageRouter() + const { pushMessage } = useToast() + const { push } = useRouter() + const { openSideNavFolderItemRecursively } = useGeneralStatus() const navigate = useCallback(() => { navigateToNote(storageId, noteId, noteFolderPath) @@ -75,6 +100,81 @@ const NoteNavigatorItem = ({ [storageId, noteId, noteBookmarked, bookmarkNote, unbookmarkNote, trashNote] ) + const handleDragOverNoteItem = useCallback((e) => { + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'move' + setDraggedOver(true) + }, []) + + const handleDragStartNoteItem = (event: React.DragEvent) => { + setTransferableTextData(event, noteIdFormat, noteId) + } + + const handleDropFolderToNoteItem = ( + sourceFolderPathname: string, + destinationFolderPathname: string + ) => { + const sourceFolderName = sourceFolderPathname.substring( + sourceFolderPathname.lastIndexOf('/') + ) + const newFolderPathname = `${destinationFolderPathname}${sourceFolderName}` + renameFolder( + storageId, + sourceFolderPathname == '' ? '/' : sourceFolderPathname, + newFolderPathname + ) + .then(() => { + // refresh works for file system (FSNote) database + push(`/app/storages/${storageId}/notes${newFolderPathname}`) + openSideNavFolderItemRecursively(storageId, newFolderPathname) + }) + .catch((err) => { + pushMessage({ + title: 'Folder Structure Update Failed', + description: `Updating folder location failed. Reason: ${ + err != null && err.message != null ? err.message : 'Unknown' + }`, + }) + }) + } + + const handleDropNoteToNoteFolder = ( + noteId: string, + destinationFolderPathname: string + ) => { + // move folder pathname of the note to new location (target destination) + updateNote(storageId, noteId, { + folderPathname: destinationFolderPathname, + }).then((r) => console.log('updated note', r)) + } + + const handleDropNavigatorItem = (event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + setDraggedOver(false) + + const sourceFolderPathname = getTransferableTextData( + event, + folderPathnameFormat + ) + if (sourceFolderPathname != null) { + handleDropFolderToNoteItem(sourceFolderPathname, noteFolderPath) + } else { + const noteId = getTransferableTextData(event, noteIdFormat) + if (noteId == null) { + return + } + handleDropNoteToNoteFolder(noteId, noteFolderPath) + } + } + + const handleDragLeaveNoteItem = useCallback((event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + setDraggedOver(false) + }, []) + return ( } + draggable={true} + draggedOver={draggedOver} + onDragOver={(e) => handleDragOverNoteItem(e)} + onDragStart={handleDragStartNoteItem} + onDrop={handleDropNavigatorItem} + onDragLeave={(e) => handleDragLeaveNoteItem(e)} /> ) } diff --git a/src/components/molecules/StorageNavigatorFragment.tsx b/src/components/molecules/StorageNavigatorFragment.tsx index 7c78f11ac0..6e9c8de33f 100644 --- a/src/components/molecules/StorageNavigatorFragment.tsx +++ b/src/components/molecules/StorageNavigatorFragment.tsx @@ -3,6 +3,7 @@ import React, { useCallback, MouseEventHandler, useEffect, + useState, } from 'react' import { useGeneralStatus } from '../../lib/generalStatus' import { useDialog, DialogIconTypes } from '../../lib/dialog' @@ -35,6 +36,11 @@ import FolderNoteNavigatorFragment from './FolderNoteNavigatorFragment' import { useRouteParams, usePathnameWithoutNoteId } from '../../lib/routeParams' import NavigatorHeader from '../atoms/NavigatorHeader' import NavigatorSeparator from '../atoms/NavigatorSeparator' +import { + folderPathnameFormat, + getTransferableTextData, + noteIdFormat, +} from '../../lib/dnd' interface StorageNavigatorFragmentProps { storage: NoteStorage @@ -43,6 +49,7 @@ interface StorageNavigatorFragmentProps { const StorageNavigatorFragment = ({ storage, }: StorageNavigatorFragmentProps) => { + const [draggedOver, setDraggedOver] = useState(false) const { openSideNavFolderItemRecursively } = useGeneralStatus() const { prompt } = useDialog() const { @@ -53,6 +60,7 @@ const StorageNavigatorFragment = ({ bookmarkNote, unbookmarkNote, trashNote, + updateNote, } = useDb() const { push } = useRouter() const { t } = useTranslation() @@ -227,12 +235,63 @@ const StorageNavigatorFragment = ({ routeParams.name === 'storages.notes' && routeParams.noteId == null + const rootFolderDropHandler = (event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + setDraggedOver(false) + + const sourceFolderPathname = getTransferableTextData( + event, + folderPathnameFormat + ) + if (sourceFolderPathname != null) { + const sourceFolderName = sourceFolderPathname.substring( + sourceFolderPathname.lastIndexOf('/') + ) + renameFolder(storageId, sourceFolderPathname, sourceFolderName).catch( + (err) => { + pushMessage({ + title: 'Folder Structure Update Failed', + description: `Updating folder location failed. Reason: ${ + err != null && err.message != null ? err.message : 'Unknown' + }`, + }) + } + ) + } else { + const noteId = getTransferableTextData(event, noteIdFormat) + if (noteId == null) { + return + } + updateNote(storageId, noteId, { + folderPathname: '/', + }) + } + } + + const handleDragLeaveNavigatorItem = useCallback((event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + setDraggedOver(false) + }, []) + + const handleDragOverNavigatorItem = useCallback((event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = 'move' + setDraggedOver(true) + }, []) + return ( <> push(rootFolderPathname)} + draggedOver={draggedOver} + onDrop={(e) => rootFolderDropHandler(e)} + onDragOver={(e) => handleDragOverNavigatorItem(e)} + onDragLeave={(e) => handleDragLeaveNavigatorItem(e)} onContextMenu={openWorkspaceContextMenu} control={ <> @@ -274,6 +333,8 @@ const StorageNavigatorFragment = ({ bookmarkNote={bookmarkNote} unbookmarkNote={unbookmarkNote} trashNote={trashNote} + renameFolder={renameFolder} + updateNote={updateNote} /> )} diff --git a/src/lib/dnd.ts b/src/lib/dnd.ts index 111a07bc94..9962f9c37f 100644 --- a/src/lib/dnd.ts +++ b/src/lib/dnd.ts @@ -2,12 +2,39 @@ import { NoteDoc } from './db/types' import { convertFileListToArray } from './dom' const noteFormat = 'application/x-boost-note-json' +export const noteIdFormat = 'text/note-id' +export const folderPathnameFormat = 'text/folder-path' export interface TransferrableNoteData { storageId: string note: NoteDoc } +export function getTransferableTextData( + event: React.DragEvent | DragEvent, + format: string +): string | null { + if (event.dataTransfer == null) return null + + const data = event.dataTransfer.getData(format) + if (data.length === 0) { + return null + } + + return data +} + +export function setTransferableTextData( + event: React.DragEvent | DragEvent, + format: string, + data: string +) { + if (event.dataTransfer == null) { + return + } + event.dataTransfer.setData(format, data) +} + export function getTransferrableNoteData( event: React.DragEvent | DragEvent ): TransferrableNoteData | null {