first commit
This commit is contained in:
@@ -0,0 +1,662 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
FC,
|
||||
} from 'react'
|
||||
|
||||
import { mapSeries } from '../../../infrastructure/promise'
|
||||
|
||||
import {
|
||||
syncRename,
|
||||
syncDelete,
|
||||
syncMove,
|
||||
syncCreateEntity,
|
||||
} from '../util/sync-mutation'
|
||||
import { findInTree, findInTreeOrThrow } from '../util/find-in-tree'
|
||||
import { isNameUniqueInFolder } from '../util/is-name-unique-in-folder'
|
||||
import { isBlockedFilename, isCleanFilename } from '../util/safe-path'
|
||||
|
||||
import { useProjectContext } from '../../../shared/context/project-context'
|
||||
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
|
||||
import { useFileTreeSelectable } from './file-tree-selectable'
|
||||
|
||||
import {
|
||||
InvalidFilenameError,
|
||||
BlockedFilenameError,
|
||||
DuplicateFilenameError,
|
||||
DuplicateFilenameMoveError,
|
||||
} from '../errors'
|
||||
import { Folder } from '../../../../../types/folder'
|
||||
import { useReferencesContext } from '@/features/ide-react/context/references-context'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { fileUrl } from '@/features/utils/fileUrl'
|
||||
|
||||
type DroppedFile = File & {
|
||||
relativePath?: string
|
||||
}
|
||||
|
||||
type DroppedFiles = {
|
||||
files: DroppedFile[]
|
||||
targetFolderId: string
|
||||
}
|
||||
|
||||
const FileTreeActionableContext = createContext<
|
||||
| {
|
||||
isDeleting: boolean
|
||||
isRenaming: boolean
|
||||
isCreatingFile: boolean
|
||||
isCreatingFolder: boolean
|
||||
isMoving: boolean
|
||||
inFlight: boolean
|
||||
actionedEntities: any | null
|
||||
newFileCreateMode: any | null
|
||||
error: any | null
|
||||
canDelete: boolean
|
||||
canRename: boolean
|
||||
canCreate: boolean
|
||||
parentFolderId: string
|
||||
selectedFileName: string | null | undefined
|
||||
isDuplicate: (parentFolderId: string, name: string) => boolean
|
||||
startRenaming: any
|
||||
finishRenaming: any
|
||||
startDeleting: any
|
||||
finishDeleting: any
|
||||
finishMoving: any
|
||||
startCreatingFile: any
|
||||
startCreatingFolder: any
|
||||
finishCreatingFolder: any
|
||||
startCreatingDocOrFile: any
|
||||
startUploadingDocOrFile: any
|
||||
finishCreatingDoc: any
|
||||
finishCreatingLinkedFile: any
|
||||
cancel: () => void
|
||||
droppedFiles: { files: File[]; targetFolderId: string } | null
|
||||
setDroppedFiles: (value: DroppedFiles | null) => void
|
||||
downloadPath?: string
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
enum ACTION_TYPES {
|
||||
START_RENAME = 'START_RENAME',
|
||||
START_DELETE = 'START_DELETE',
|
||||
DELETING = 'DELETING',
|
||||
START_CREATE_FILE = 'START_CREATE_FILE',
|
||||
START_CREATE_FOLDER = 'START_CREATE_FOLDER',
|
||||
CREATING_FILE = 'CREATING_FILE',
|
||||
CREATING_FOLDER = 'CREATING_FOLDER',
|
||||
MOVING = 'MOVING',
|
||||
CANCEL = 'CANCEL',
|
||||
CLEAR = 'CLEAR',
|
||||
ERROR = 'ERROR',
|
||||
}
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
type State = {
|
||||
isDeleting: boolean
|
||||
isRenaming: boolean
|
||||
isCreatingFile: boolean
|
||||
isCreatingFolder: boolean
|
||||
isMoving: boolean
|
||||
inFlight: boolean
|
||||
actionedEntities: any | null
|
||||
newFileCreateMode: any | null
|
||||
error: unknown | null
|
||||
}
|
||||
|
||||
const defaultState: State = {
|
||||
isDeleting: false,
|
||||
isRenaming: false,
|
||||
isCreatingFile: false,
|
||||
isCreatingFolder: false,
|
||||
isMoving: false,
|
||||
inFlight: false,
|
||||
actionedEntities: null,
|
||||
newFileCreateMode: null,
|
||||
error: null,
|
||||
}
|
||||
|
||||
function fileTreeActionableReadOnlyReducer(state: State) {
|
||||
return state
|
||||
}
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ACTION_TYPES.START_RENAME
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.START_DELETE
|
||||
actionedEntities: any | null
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.START_CREATE_FILE
|
||||
newFileCreateMode: any | null
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.START_CREATE_FOLDER
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.CREATING_FILE
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.CREATING_FOLDER
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.DELETING
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.MOVING
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.CLEAR
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.CANCEL
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.ERROR
|
||||
error: unknown
|
||||
}
|
||||
|
||||
function fileTreeActionableReducer(state: State, action: Action) {
|
||||
switch (action.type) {
|
||||
case ACTION_TYPES.START_RENAME:
|
||||
return { ...defaultState, isRenaming: true }
|
||||
case ACTION_TYPES.START_DELETE:
|
||||
return {
|
||||
...defaultState,
|
||||
isDeleting: true,
|
||||
actionedEntities: action.actionedEntities,
|
||||
}
|
||||
case ACTION_TYPES.START_CREATE_FILE:
|
||||
return {
|
||||
...defaultState,
|
||||
isCreatingFile: true,
|
||||
newFileCreateMode: action.newFileCreateMode,
|
||||
}
|
||||
case ACTION_TYPES.START_CREATE_FOLDER:
|
||||
return { ...defaultState, isCreatingFolder: true }
|
||||
case ACTION_TYPES.CREATING_FILE:
|
||||
return {
|
||||
...defaultState,
|
||||
isCreatingFile: true,
|
||||
newFileCreateMode: state.newFileCreateMode,
|
||||
inFlight: true,
|
||||
}
|
||||
case ACTION_TYPES.CREATING_FOLDER:
|
||||
return { ...defaultState, isCreatingFolder: true, inFlight: true }
|
||||
case ACTION_TYPES.DELETING:
|
||||
// keep `actionedEntities` so the entities list remains displayed in the
|
||||
// delete modal
|
||||
return {
|
||||
...defaultState,
|
||||
isDeleting: true,
|
||||
inFlight: true,
|
||||
actionedEntities: state.actionedEntities,
|
||||
}
|
||||
case ACTION_TYPES.MOVING:
|
||||
return {
|
||||
...defaultState,
|
||||
isMoving: true,
|
||||
inFlight: true,
|
||||
}
|
||||
case ACTION_TYPES.CLEAR:
|
||||
return { ...defaultState }
|
||||
case ACTION_TYPES.CANCEL:
|
||||
if (state.inFlight) return state
|
||||
return { ...defaultState }
|
||||
case ACTION_TYPES.ERROR:
|
||||
return { ...state, inFlight: false, error: action.error }
|
||||
default:
|
||||
throw new Error(`Unknown user action type: ${(action as Action).type}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const FileTreeActionableProvider: FC = ({ children }) => {
|
||||
const { _id: projectId } = useProjectContext()
|
||||
const { fileTreeReadOnly } = useFileTreeData()
|
||||
const { indexAllReferences } = useReferencesContext()
|
||||
const { write } = usePermissionsContext()
|
||||
|
||||
const [state, dispatch] = useReducer(
|
||||
fileTreeReadOnly
|
||||
? fileTreeActionableReadOnlyReducer
|
||||
: fileTreeActionableReducer,
|
||||
defaultState
|
||||
)
|
||||
|
||||
const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeData()
|
||||
const { selectedEntityIds, isRootFolderSelected } = useFileTreeSelectable()
|
||||
|
||||
const [droppedFiles, setDroppedFiles] = useState<DroppedFiles | null>(null)
|
||||
|
||||
const startRenaming = useCallback(() => {
|
||||
dispatch({ type: ACTION_TYPES.START_RENAME })
|
||||
}, [])
|
||||
|
||||
// update the entity with the new name immediately in the tree, but revert to
|
||||
// the old name if the sync fails
|
||||
const finishRenaming = useCallback(
|
||||
(newName: string) => {
|
||||
const selectedEntityId = Array.from(selectedEntityIds)[0]
|
||||
const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
|
||||
const oldName = found.entity.name
|
||||
if (newName === oldName) {
|
||||
return dispatch({ type: ACTION_TYPES.CLEAR })
|
||||
}
|
||||
|
||||
const error = validateRename(fileTreeData, found, newName)
|
||||
if (error) return dispatch({ type: ACTION_TYPES.ERROR, error })
|
||||
|
||||
dispatch({ type: ACTION_TYPES.CLEAR })
|
||||
dispatchRename(selectedEntityId, newName)
|
||||
return syncRename(projectId, found.type, found.entity._id, newName).catch(
|
||||
error => {
|
||||
dispatchRename(selectedEntityId, oldName)
|
||||
// The state from this error action isn't used anywhere right now
|
||||
// but we need to handle the error for linting
|
||||
dispatch({ type: ACTION_TYPES.ERROR, error })
|
||||
}
|
||||
)
|
||||
},
|
||||
[dispatchRename, fileTreeData, projectId, selectedEntityIds]
|
||||
)
|
||||
|
||||
const isDuplicate = useCallback(
|
||||
(parentFolderId: string, name: string) => {
|
||||
return !isNameUniqueInFolder(fileTreeData, parentFolderId, name)
|
||||
},
|
||||
[fileTreeData]
|
||||
)
|
||||
|
||||
// init deletion flow (this will open the delete modal).
|
||||
// A copy of the selected entities is set as `actionedEntities` so it is kept
|
||||
// unchanged as the entities are deleted and the selection is updated
|
||||
const startDeleting = useCallback(() => {
|
||||
const actionedEntities = Array.from(selectedEntityIds).map(
|
||||
entityId => findInTreeOrThrow(fileTreeData, entityId).entity
|
||||
)
|
||||
dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities })
|
||||
}, [fileTreeData, selectedEntityIds])
|
||||
|
||||
// deletes entities in series. Tree will be updated via the socket event
|
||||
const finishDeleting = useCallback(() => {
|
||||
dispatch({ type: ACTION_TYPES.DELETING })
|
||||
let shouldReindexReferences = false
|
||||
|
||||
return (
|
||||
mapSeries(Array.from(selectedEntityIds), id => {
|
||||
const found = findInTreeOrThrow(fileTreeData, id)
|
||||
shouldReindexReferences =
|
||||
shouldReindexReferences || /\.bib$/.test(found.entity.name)
|
||||
return syncDelete(projectId, found.type, found.entity._id).catch(
|
||||
error => {
|
||||
// throw unless 404
|
||||
if (error.info.statusCode !== 404) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
// @ts-ignore (TODO: improve mapSeries types)
|
||||
.then(() => {
|
||||
if (shouldReindexReferences) {
|
||||
indexAllReferences(true)
|
||||
}
|
||||
dispatch({ type: ACTION_TYPES.CLEAR })
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
// set an error and allow user to retry
|
||||
dispatch({ type: ACTION_TYPES.ERROR, error })
|
||||
})
|
||||
)
|
||||
}, [fileTreeData, projectId, selectedEntityIds, indexAllReferences])
|
||||
|
||||
// moves entities. Tree is updated immediately and data are sync'd after.
|
||||
const finishMoving = useCallback(
|
||||
(toFolderId: string, draggedEntityIds: Set<string>) => {
|
||||
dispatch({ type: ACTION_TYPES.MOVING })
|
||||
|
||||
// find entities and filter out no-ops and nested files
|
||||
const founds = Array.from(draggedEntityIds)
|
||||
.map(draggedEntityId =>
|
||||
findInTreeOrThrow(fileTreeData, draggedEntityId)
|
||||
)
|
||||
.filter(
|
||||
found =>
|
||||
found.parentFolderId !== toFolderId &&
|
||||
!draggedEntityIds.has(found.parentFolderId)
|
||||
)
|
||||
|
||||
// make sure all entities can be moved, return early otherwise
|
||||
const isMoveToRoot = toFolderId === fileTreeData._id
|
||||
const validationError = founds
|
||||
.map(found =>
|
||||
validateMove(fileTreeData, toFolderId, found, isMoveToRoot)
|
||||
)
|
||||
.find(error => error)
|
||||
if (validationError) {
|
||||
return dispatch({ type: ACTION_TYPES.ERROR, error: validationError })
|
||||
}
|
||||
|
||||
// keep track of old parent folder ids so we can revert entities if sync fails
|
||||
const oldParentFolderIds: Record<string, string> = {}
|
||||
let isMoveFailed = false
|
||||
|
||||
// dispatch moves immediately
|
||||
founds.forEach(found => {
|
||||
oldParentFolderIds[found.entity._id] = found.parentFolderId
|
||||
dispatchMove(found.entity._id, toFolderId)
|
||||
})
|
||||
|
||||
// sync dispatched moves after
|
||||
return (
|
||||
mapSeries(founds, async found => {
|
||||
try {
|
||||
await syncMove(projectId, found.type, found.entity._id, toFolderId)
|
||||
} catch (error) {
|
||||
isMoveFailed = true
|
||||
dispatchMove(found.entity._id, oldParentFolderIds[found.entity._id])
|
||||
dispatch({ type: ACTION_TYPES.ERROR, error })
|
||||
}
|
||||
})
|
||||
// @ts-ignore (TODO: improve mapSeries types)
|
||||
.then(() => {
|
||||
if (!isMoveFailed) {
|
||||
dispatch({ type: ACTION_TYPES.CLEAR })
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
[dispatchMove, fileTreeData, projectId]
|
||||
)
|
||||
|
||||
const startCreatingFolder = useCallback(() => {
|
||||
dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER })
|
||||
}, [])
|
||||
|
||||
const parentFolderId = useMemo(() => {
|
||||
return getSelectedParentFolderId(
|
||||
fileTreeData,
|
||||
selectedEntityIds,
|
||||
isRootFolderSelected
|
||||
)
|
||||
}, [fileTreeData, selectedEntityIds, isRootFolderSelected])
|
||||
|
||||
// return the name of the selected file or doc if there is only one selected
|
||||
const selectedFileName = useMemo(() => {
|
||||
if (selectedEntityIds.size === 1) {
|
||||
const [selectedEntityId] = selectedEntityIds
|
||||
const selectedEntity = findInTree(fileTreeData, selectedEntityId)
|
||||
return selectedEntity?.entity?.name
|
||||
}
|
||||
return null
|
||||
}, [fileTreeData, selectedEntityIds])
|
||||
|
||||
const finishCreatingEntity = useCallback(
|
||||
entity => {
|
||||
const error = validateCreate(fileTreeData, parentFolderId, entity)
|
||||
if (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
return syncCreateEntity(projectId, parentFolderId, entity)
|
||||
},
|
||||
[fileTreeData, parentFolderId, projectId]
|
||||
)
|
||||
|
||||
const finishCreatingFolder = useCallback(
|
||||
name => {
|
||||
dispatch({ type: ACTION_TYPES.CREATING_FOLDER })
|
||||
return finishCreatingEntity({ endpoint: 'folder', name })
|
||||
.then(() => {
|
||||
dispatch({ type: ACTION_TYPES.CLEAR })
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch({ type: ACTION_TYPES.ERROR, error })
|
||||
})
|
||||
},
|
||||
[finishCreatingEntity]
|
||||
)
|
||||
|
||||
const startCreatingFile = useCallback(newFileCreateMode => {
|
||||
dispatch({ type: ACTION_TYPES.START_CREATE_FILE, newFileCreateMode })
|
||||
}, [])
|
||||
|
||||
const startCreatingDocOrFile = useCallback(() => {
|
||||
startCreatingFile('doc')
|
||||
}, [startCreatingFile])
|
||||
|
||||
const startUploadingDocOrFile = useCallback(() => {
|
||||
startCreatingFile('upload')
|
||||
}, [startCreatingFile])
|
||||
|
||||
const finishCreatingDocOrFile = useCallback(
|
||||
entity => {
|
||||
dispatch({ type: ACTION_TYPES.CREATING_FILE })
|
||||
|
||||
return finishCreatingEntity(entity)
|
||||
.then(() => {
|
||||
dispatch({ type: ACTION_TYPES.CLEAR })
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch({ type: ACTION_TYPES.ERROR, error })
|
||||
})
|
||||
},
|
||||
[finishCreatingEntity]
|
||||
)
|
||||
|
||||
const finishCreatingDoc = useCallback(
|
||||
entity => {
|
||||
entity.endpoint = 'doc'
|
||||
return finishCreatingDocOrFile(entity)
|
||||
},
|
||||
[finishCreatingDocOrFile]
|
||||
)
|
||||
|
||||
const finishCreatingLinkedFile = useCallback(
|
||||
entity => {
|
||||
entity.endpoint = 'linked_file'
|
||||
return finishCreatingDocOrFile(entity)
|
||||
},
|
||||
[finishCreatingDocOrFile]
|
||||
)
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
dispatch({ type: ACTION_TYPES.CANCEL })
|
||||
}, [])
|
||||
|
||||
// listen for `file-tree.start-creating` events
|
||||
useEffect(() => {
|
||||
function handleEvent(event: Event) {
|
||||
dispatch({
|
||||
type: ACTION_TYPES.START_CREATE_FILE,
|
||||
newFileCreateMode: (event as CustomEvent<{ mode: string }>).detail.mode,
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('file-tree.start-creating', handleEvent)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('file-tree.start-creating', handleEvent)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// build the path for downloading a single file or doc
|
||||
const downloadPath = useMemo(() => {
|
||||
if (selectedEntityIds.size === 1) {
|
||||
const [selectedEntityId] = selectedEntityIds
|
||||
const selectedEntity = findInTree(fileTreeData, selectedEntityId)
|
||||
|
||||
if (selectedEntity?.type === 'fileRef') {
|
||||
return fileUrl(projectId, selectedEntityId, selectedEntity.entity.hash)
|
||||
}
|
||||
|
||||
if (selectedEntity?.type === 'doc') {
|
||||
return `/project/${projectId}/doc/${selectedEntityId}/download`
|
||||
}
|
||||
}
|
||||
}, [fileTreeData, projectId, selectedEntityIds])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
canDelete: write && selectedEntityIds.size > 0 && !isRootFolderSelected,
|
||||
canRename: write && selectedEntityIds.size === 1 && !isRootFolderSelected,
|
||||
canCreate: write && selectedEntityIds.size < 2,
|
||||
...state,
|
||||
parentFolderId,
|
||||
selectedFileName,
|
||||
isDuplicate,
|
||||
startRenaming,
|
||||
finishRenaming,
|
||||
startDeleting,
|
||||
finishDeleting,
|
||||
finishMoving,
|
||||
startCreatingFile,
|
||||
startCreatingFolder,
|
||||
finishCreatingFolder,
|
||||
startCreatingDocOrFile,
|
||||
startUploadingDocOrFile,
|
||||
finishCreatingDoc,
|
||||
finishCreatingLinkedFile,
|
||||
cancel,
|
||||
droppedFiles,
|
||||
setDroppedFiles,
|
||||
downloadPath,
|
||||
}),
|
||||
[
|
||||
cancel,
|
||||
downloadPath,
|
||||
droppedFiles,
|
||||
finishCreatingDoc,
|
||||
finishCreatingFolder,
|
||||
finishCreatingLinkedFile,
|
||||
finishDeleting,
|
||||
finishMoving,
|
||||
finishRenaming,
|
||||
isDuplicate,
|
||||
isRootFolderSelected,
|
||||
parentFolderId,
|
||||
selectedEntityIds.size,
|
||||
selectedFileName,
|
||||
startCreatingDocOrFile,
|
||||
startCreatingFile,
|
||||
startCreatingFolder,
|
||||
startDeleting,
|
||||
startRenaming,
|
||||
startUploadingDocOrFile,
|
||||
state,
|
||||
write,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<FileTreeActionableContext.Provider value={value}>
|
||||
{children}
|
||||
</FileTreeActionableContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useFileTreeActionable() {
|
||||
const context = useContext(FileTreeActionableContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useFileTreeActionable is only available inside FileTreeActionableProvider'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function getSelectedParentFolderId(
|
||||
fileTreeData: Folder,
|
||||
selectedEntityIds: Set<string>,
|
||||
isRootFolderSelected: boolean
|
||||
) {
|
||||
if (isRootFolderSelected) {
|
||||
return fileTreeData._id
|
||||
}
|
||||
|
||||
// we expect only one entity to be selected in that case, so we pick the first
|
||||
const selectedEntityId = Array.from(selectedEntityIds)[0]
|
||||
if (!selectedEntityId) {
|
||||
// in some cases no entities are selected. Return the root folder id then.
|
||||
return fileTreeData._id
|
||||
}
|
||||
|
||||
const found = findInTree(fileTreeData, selectedEntityId)
|
||||
|
||||
if (!found) {
|
||||
// if the entity isn't in the tree, return the root folder id.
|
||||
return fileTreeData._id
|
||||
}
|
||||
|
||||
return found.type === 'folder' ? found.entity._id : found.parentFolderId
|
||||
}
|
||||
|
||||
function validateCreate(
|
||||
fileTreeData: Folder,
|
||||
parentFolderId: string,
|
||||
entity: { name: string; endpoint: string }
|
||||
) {
|
||||
if (!isCleanFilename(entity.name)) {
|
||||
return new InvalidFilenameError()
|
||||
}
|
||||
|
||||
if (!isNameUniqueInFolder(fileTreeData, parentFolderId, entity.name)) {
|
||||
return new DuplicateFilenameError()
|
||||
}
|
||||
|
||||
// check that the name of a file is allowed, if creating in the root folder
|
||||
const isMoveToRoot = parentFolderId === fileTreeData._id
|
||||
const isFolder = entity.endpoint === 'folder'
|
||||
if (isMoveToRoot && !isFolder && isBlockedFilename(entity.name)) {
|
||||
return new BlockedFilenameError()
|
||||
}
|
||||
}
|
||||
|
||||
function validateRename(
|
||||
fileTreeData: Folder,
|
||||
found: { parentFolderId: string; path: string[]; type: string },
|
||||
newName: string
|
||||
) {
|
||||
if (!isCleanFilename(newName)) {
|
||||
return new InvalidFilenameError()
|
||||
}
|
||||
|
||||
if (!isNameUniqueInFolder(fileTreeData, found.parentFolderId, newName)) {
|
||||
return new DuplicateFilenameError()
|
||||
}
|
||||
|
||||
const isTopLevel = found.path.length === 1
|
||||
const isFolder = found.type === 'folder'
|
||||
if (isTopLevel && !isFolder && isBlockedFilename(newName)) {
|
||||
return new BlockedFilenameError()
|
||||
}
|
||||
}
|
||||
|
||||
function validateMove(
|
||||
fileTreeData: Folder,
|
||||
toFolderId: string,
|
||||
found: { entity: { name: string }; type: string },
|
||||
isMoveToRoot: boolean
|
||||
) {
|
||||
if (!isNameUniqueInFolder(fileTreeData, toFolderId, found.entity.name)) {
|
||||
const error = new DuplicateFilenameMoveError()
|
||||
;(error as DuplicateFilenameMoveError & { entityName: string }).entityName =
|
||||
found.entity.name
|
||||
return error
|
||||
}
|
||||
|
||||
const isFolder = found.type === 'folder'
|
||||
if (isMoveToRoot && !isFolder && isBlockedFilename(found.entity.name)) {
|
||||
return new BlockedFilenameError()
|
||||
}
|
||||
}
|
@@ -0,0 +1,30 @@
|
||||
import { createContext, FC, useContext, useState } from 'react'
|
||||
|
||||
const FileTreeCreateFormContext = createContext<
|
||||
{ valid: boolean; setValid: (value: boolean) => void } | undefined
|
||||
>(undefined)
|
||||
|
||||
export const useFileTreeCreateForm = () => {
|
||||
const context = useContext(FileTreeCreateFormContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useFileTreeCreateForm is only available inside FileTreeCreateFormProvider'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const FileTreeCreateFormProvider: FC = ({ children }) => {
|
||||
// is the form valid
|
||||
const [valid, setValid] = useState(false)
|
||||
|
||||
return (
|
||||
<FileTreeCreateFormContext.Provider value={{ valid, setValid }}>
|
||||
{children}
|
||||
</FileTreeCreateFormContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeCreateFormProvider
|
@@ -0,0 +1,58 @@
|
||||
import { createContext, FC, useContext, useMemo, useReducer } from 'react'
|
||||
import { isCleanFilename } from '../util/safe-path'
|
||||
|
||||
const FileTreeCreateNameContext = createContext<
|
||||
| {
|
||||
name: string
|
||||
touchedName: boolean
|
||||
validName: boolean
|
||||
setName: (name: string) => void
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export const useFileTreeCreateName = () => {
|
||||
const context = useContext(FileTreeCreateNameContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useFileTreeCreateName is only available inside FileTreeCreateNameProvider'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
type State = {
|
||||
name: string
|
||||
touchedName: boolean
|
||||
}
|
||||
|
||||
const FileTreeCreateNameProvider: FC<{ initialName?: string }> = ({
|
||||
children,
|
||||
initialName = '',
|
||||
}) => {
|
||||
const [state, setName] = useReducer(
|
||||
(state: State, name: string) => ({
|
||||
name, // the file name
|
||||
touchedName: true, // whether the name has been edited
|
||||
}),
|
||||
{
|
||||
name: initialName,
|
||||
touchedName: false,
|
||||
}
|
||||
)
|
||||
|
||||
// validate the file name
|
||||
const validName = useMemo(() => isCleanFilename(state.name.trim()), [state])
|
||||
|
||||
return (
|
||||
<FileTreeCreateNameContext.Provider
|
||||
value={{ ...state, setName, validName }}
|
||||
>
|
||||
{children}
|
||||
</FileTreeCreateNameContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeCreateNameProvider
|
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useState, FC, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getDroppedFiles from '@uppy/utils/lib/getDroppedFiles'
|
||||
import { DndProvider, DragSourceMonitor, useDrag, useDrop } from 'react-dnd'
|
||||
import {
|
||||
HTML5Backend,
|
||||
getEmptyImage,
|
||||
NativeTypes,
|
||||
} from 'react-dnd-html5-backend'
|
||||
import {
|
||||
findAllInTreeOrThrow,
|
||||
findAllFolderIdsInFolders,
|
||||
} from '../util/find-in-tree'
|
||||
import { useFileTreeActionable } from './file-tree-actionable'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
|
||||
import { isAcceptableFile } from '@/features/file-tree/util/is-acceptable-file'
|
||||
import { FileTreeFindResult } from '@/features/ide-react/types/file-tree'
|
||||
|
||||
const DRAGGABLE_TYPE = 'ENTITY'
|
||||
export const FileTreeDraggableProvider: FC<{
|
||||
fileTreeContainer?: HTMLDivElement
|
||||
}> = ({ fileTreeContainer, children }) => {
|
||||
const options = useMemo(
|
||||
() => ({ rootElement: fileTreeContainer }),
|
||||
[fileTreeContainer]
|
||||
)
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend} options={options}>
|
||||
{children}
|
||||
</DndProvider>
|
||||
)
|
||||
}
|
||||
|
||||
type DragObject = {
|
||||
type: string
|
||||
title: string
|
||||
forbiddenFolderIds: Set<string>
|
||||
draggedEntityIds: Set<string>
|
||||
}
|
||||
|
||||
type DropResult = {
|
||||
targetEntityId: string
|
||||
dropEffect: DataTransfer['dropEffect']
|
||||
}
|
||||
|
||||
export function useDraggable(draggedEntityId: string) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { fileTreeData, fileTreeReadOnly } = useFileTreeData()
|
||||
const { selectedEntityIds, isRootFolderSelected } = useFileTreeSelectable()
|
||||
const { finishMoving } = useFileTreeActionable()
|
||||
|
||||
const [isDraggable, setIsDraggable] = useState(true)
|
||||
|
||||
const [, dragRef, preview] = useDrag({
|
||||
type: DRAGGABLE_TYPE,
|
||||
item() {
|
||||
const draggedEntityIds = getDraggedEntityIds(
|
||||
isRootFolderSelected ? new Set() : selectedEntityIds,
|
||||
draggedEntityId
|
||||
)
|
||||
|
||||
const draggedItems = findAllInTreeOrThrow(fileTreeData, draggedEntityIds)
|
||||
|
||||
return {
|
||||
type: DRAGGABLE_TYPE,
|
||||
title: getDraggedTitle(draggedItems, t),
|
||||
forbiddenFolderIds: getForbiddenFolderIds(draggedItems),
|
||||
draggedEntityIds,
|
||||
}
|
||||
},
|
||||
canDrag() {
|
||||
return !fileTreeReadOnly && isDraggable
|
||||
},
|
||||
end(item: DragObject, monitor: DragSourceMonitor<DragObject, DropResult>) {
|
||||
if (monitor.didDrop()) {
|
||||
const result = monitor.getDropResult()
|
||||
if (result) {
|
||||
finishMoving(result.targetEntityId, item.draggedEntityIds) // TODO: use result.dropEffect
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// remove the automatic preview as we're using a custom preview via
|
||||
// FileTreeDraggablePreviewLayer
|
||||
useEffect(() => {
|
||||
preview(getEmptyImage())
|
||||
}, [preview])
|
||||
|
||||
return { dragRef, setIsDraggable }
|
||||
}
|
||||
|
||||
export function useDroppable(targetEntityId: string) {
|
||||
const { setDroppedFiles, startUploadingDocOrFile } = useFileTreeActionable()
|
||||
|
||||
const [{ isOver }, dropRef] = useDrop({
|
||||
accept: [DRAGGABLE_TYPE, NativeTypes.FILE],
|
||||
canDrop(item: DragObject, monitor) {
|
||||
if (!monitor.isOver({ shallow: true })) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !(
|
||||
item.type === DRAGGABLE_TYPE &&
|
||||
item.forbiddenFolderIds.has(targetEntityId)
|
||||
)
|
||||
},
|
||||
drop(item, monitor) {
|
||||
// monitor.didDrop() returns true if the drop was already handled by a nested child
|
||||
if (monitor.didDrop()) {
|
||||
return
|
||||
}
|
||||
|
||||
// item(s) dragged within the file tree
|
||||
if (item.type === DRAGGABLE_TYPE) {
|
||||
return { targetEntityId }
|
||||
}
|
||||
|
||||
// native file(s) dragged in from outside
|
||||
getDroppedFiles(item as unknown as DataTransfer)
|
||||
.then(files =>
|
||||
files.filter(file =>
|
||||
// note: getDroppedFiles normalises webkitRelativePath to relativePath
|
||||
isAcceptableFile(file.name, (file as any).relativePath)
|
||||
)
|
||||
)
|
||||
.then(files => {
|
||||
setDroppedFiles({ files, targetFolderId: targetEntityId })
|
||||
startUploadingDocOrFile()
|
||||
})
|
||||
},
|
||||
collect(monitor) {
|
||||
return {
|
||||
isOver: monitor.canDrop(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return { dropRef, isOver }
|
||||
}
|
||||
|
||||
// Get the list of dragged entity ids. If the dragged entity is one of the
|
||||
// selected entities then all the selected entites are dragged entities,
|
||||
// otherwise it's the dragged entity only.
|
||||
function getDraggedEntityIds(
|
||||
selectedEntityIds: Set<string>,
|
||||
draggedEntityId: string
|
||||
) {
|
||||
if (selectedEntityIds.size > 1 && selectedEntityIds.has(draggedEntityId)) {
|
||||
// dragging the multi-selected entities
|
||||
return new Set(selectedEntityIds)
|
||||
} else {
|
||||
// not dragging the selection; only the current item
|
||||
return new Set([draggedEntityId])
|
||||
}
|
||||
}
|
||||
|
||||
// Get the draggable title. This is the name of the dragged entities if there's
|
||||
// only one, otherwise it's the number of dragged entities.
|
||||
function getDraggedTitle(
|
||||
draggedItems: Set<any>,
|
||||
t: (key: string, options: Record<string, any>) => void
|
||||
) {
|
||||
if (draggedItems.size === 1) {
|
||||
const draggedItem = Array.from(draggedItems)[0]
|
||||
return draggedItem.entity.name
|
||||
}
|
||||
return t('n_items', { count: draggedItems.size })
|
||||
}
|
||||
|
||||
// Get all children folder ids of any of the dragged items.
|
||||
function getForbiddenFolderIds(draggedItems: Set<FileTreeFindResult>) {
|
||||
const draggedFoldersArray = Array.from(draggedItems)
|
||||
.filter(draggedItem => {
|
||||
return draggedItem.type === 'folder'
|
||||
})
|
||||
.map(draggedItem => draggedItem.entity)
|
||||
const draggedFolders = new Set(draggedFoldersArray)
|
||||
return findAllFolderIdsInFolders(draggedFolders)
|
||||
}
|
@@ -0,0 +1,54 @@
|
||||
import { createContext, FC, useContext, useState } from 'react'
|
||||
|
||||
type ContextMenuCoords = { top: number; left: number }
|
||||
|
||||
const FileTreeMainContext = createContext<
|
||||
| {
|
||||
refProviders: object
|
||||
setRefProviderEnabled: (provider: string, value: boolean) => void
|
||||
setStartedFreeTrial: (value: boolean) => void
|
||||
contextMenuCoords: ContextMenuCoords | null
|
||||
setContextMenuCoords: (value: ContextMenuCoords | null) => void
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export function useFileTreeMainContext() {
|
||||
const context = useContext(FileTreeMainContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useFileTreeMainContext is only available inside FileTreeMainProvider'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export const FileTreeMainProvider: FC<{
|
||||
refProviders: object
|
||||
setRefProviderEnabled: (provider: string, value: boolean) => void
|
||||
setStartedFreeTrial: (value: boolean) => void
|
||||
}> = ({
|
||||
refProviders,
|
||||
setRefProviderEnabled,
|
||||
setStartedFreeTrial,
|
||||
children,
|
||||
}) => {
|
||||
const [contextMenuCoords, setContextMenuCoords] =
|
||||
useState<ContextMenuCoords | null>(null)
|
||||
|
||||
return (
|
||||
<FileTreeMainContext.Provider
|
||||
value={{
|
||||
refProviders,
|
||||
setRefProviderEnabled,
|
||||
setStartedFreeTrial,
|
||||
contextMenuCoords,
|
||||
setContextMenuCoords,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FileTreeMainContext.Provider>
|
||||
)
|
||||
}
|
@@ -0,0 +1,81 @@
|
||||
import { createContext, FC, useCallback, useContext, useMemo } from 'react'
|
||||
import { Folder } from '../../../../../types/folder'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import getMeta from '@/utils/meta'
|
||||
import {
|
||||
findEntityByPath,
|
||||
previewByPath,
|
||||
dirname,
|
||||
FindResult,
|
||||
pathInFolder,
|
||||
} from '@/features/file-tree/util/path'
|
||||
import { PreviewPath } from '../../../../../types/preview-path'
|
||||
|
||||
type FileTreePathContextValue = {
|
||||
pathInFolder: (id: string) => string | null
|
||||
findEntityByPath: (path: string) => FindResult | null
|
||||
previewByPath: (path: string) => PreviewPath | null
|
||||
dirname: (id: string) => string | null
|
||||
}
|
||||
|
||||
export const FileTreePathContext = createContext<
|
||||
FileTreePathContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
export const FileTreePathProvider: FC = ({ children }) => {
|
||||
const { fileTreeData }: { fileTreeData: Folder } = useFileTreeData()
|
||||
const projectId = getMeta('ol-project_id')
|
||||
|
||||
const pathInFileTree = useCallback(
|
||||
(id: string) => pathInFolder(fileTreeData, id),
|
||||
[fileTreeData]
|
||||
)
|
||||
|
||||
const findEntityByPathInFileTree = useCallback(
|
||||
(path: string) => findEntityByPath(fileTreeData, path),
|
||||
[fileTreeData]
|
||||
)
|
||||
|
||||
const previewByPathInFileTree = useCallback(
|
||||
(path: string) => previewByPath(fileTreeData, projectId, path),
|
||||
[fileTreeData, projectId]
|
||||
)
|
||||
|
||||
const dirnameInFileTree = useCallback(
|
||||
(id: string) => dirname(fileTreeData, id),
|
||||
[fileTreeData]
|
||||
)
|
||||
|
||||
const value = useMemo<FileTreePathContextValue>(
|
||||
() => ({
|
||||
pathInFolder: pathInFileTree,
|
||||
findEntityByPath: findEntityByPathInFileTree,
|
||||
previewByPath: previewByPathInFileTree,
|
||||
dirname: dirnameInFileTree,
|
||||
}),
|
||||
[
|
||||
pathInFileTree,
|
||||
findEntityByPathInFileTree,
|
||||
previewByPathInFileTree,
|
||||
dirnameInFileTree,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<FileTreePathContext.Provider value={value}>
|
||||
{children}
|
||||
</FileTreePathContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useFileTreePathContext(): FileTreePathContextValue {
|
||||
const context = useContext(FileTreePathContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useFileTreePathContext is only available inside FileTreePathProvider'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
@@ -0,0 +1,418 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useReducer,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
FC,
|
||||
} from 'react'
|
||||
import classNames from 'classnames'
|
||||
import _ from 'lodash'
|
||||
import { findInTree, findInTreeOrThrow } from '../util/find-in-tree'
|
||||
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
|
||||
import { useProjectContext } from '../../../shared/context/project-context'
|
||||
import { useLayoutContext } from '../../../shared/context/layout-context'
|
||||
import usePersistedState from '../../../shared/hooks/use-persisted-state'
|
||||
import usePreviousValue from '../../../shared/hooks/use-previous-value'
|
||||
import { useFileTreeMainContext } from '@/features/file-tree/contexts/file-tree-main'
|
||||
import { FindResult } from '@/features/file-tree/util/path'
|
||||
import { fileCollator } from '@/features/file-tree/util/file-collator'
|
||||
import { Folder } from '../../../../../types/folder'
|
||||
import { FileTreeEntity } from '../../../../../types/file-tree-entity'
|
||||
import { isMac } from '@/shared/utils/os'
|
||||
import useEventListener from '@/shared/hooks/use-event-listener'
|
||||
|
||||
const FileTreeSelectableContext = createContext<
|
||||
| {
|
||||
selectedEntityIds: Set<string>
|
||||
isRootFolderSelected: boolean
|
||||
selectOrMultiSelectEntity: (
|
||||
id: string | string[],
|
||||
multiple?: boolean
|
||||
) => void
|
||||
setIsRootFolderSelected: (value: boolean) => void
|
||||
selectedEntityParentIds: Set<string>
|
||||
select: (id: string | string[]) => void
|
||||
unselect: (id: string) => void
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
enum ACTION_TYPES {
|
||||
SELECT = 'SELECT',
|
||||
MULTI_SELECT = 'MULTI_SELECT',
|
||||
UNSELECT = 'UNSELECT',
|
||||
}
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ACTION_TYPES.SELECT
|
||||
id: string
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.MULTI_SELECT
|
||||
id: string
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.UNSELECT
|
||||
id: string
|
||||
}
|
||||
|
||||
function fileTreeSelectableReadWriteReducer(
|
||||
selectedEntityIds: Set<string>,
|
||||
action: Action
|
||||
) {
|
||||
switch (action.type) {
|
||||
case ACTION_TYPES.SELECT: {
|
||||
// reset selection
|
||||
return new Set(Array.isArray(action.id) ? action.id : [action.id])
|
||||
}
|
||||
|
||||
case ACTION_TYPES.MULTI_SELECT: {
|
||||
const selectedEntityIdsCopy = new Set(selectedEntityIds)
|
||||
if (selectedEntityIdsCopy.has(action.id)) {
|
||||
// entity already selected
|
||||
if (selectedEntityIdsCopy.size > 1) {
|
||||
// entity already multi-selected; remove from set
|
||||
selectedEntityIdsCopy.delete(action.id)
|
||||
}
|
||||
} else {
|
||||
// entity not selected: add to set
|
||||
selectedEntityIdsCopy.add(action.id)
|
||||
}
|
||||
|
||||
return selectedEntityIdsCopy
|
||||
}
|
||||
|
||||
case ACTION_TYPES.UNSELECT: {
|
||||
const selectedEntityIdsCopy = new Set(selectedEntityIds)
|
||||
selectedEntityIdsCopy.delete(action.id)
|
||||
return selectedEntityIdsCopy
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown selectable action type: ${(action as Action).type}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function fileTreeSelectableReadOnlyReducer(
|
||||
selectedEntityIds: Set<string>,
|
||||
action: Action
|
||||
) {
|
||||
switch (action.type) {
|
||||
case ACTION_TYPES.SELECT:
|
||||
return new Set([action.id])
|
||||
|
||||
case ACTION_TYPES.MULTI_SELECT:
|
||||
case ACTION_TYPES.UNSELECT:
|
||||
return selectedEntityIds
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown selectable action type: ${(action as Action).type}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const FileTreeSelectableProvider: FC<{
|
||||
onSelect: (value: FindResult[]) => void
|
||||
}> = ({ onSelect, children }) => {
|
||||
const { _id: projectId, rootDocId } = useProjectContext()
|
||||
|
||||
const [initialSelectedEntityId] = usePersistedState(
|
||||
`doc.open_id.${projectId}`,
|
||||
rootDocId
|
||||
)
|
||||
|
||||
const { fileTreeData, setSelectedEntities, fileTreeReadOnly } =
|
||||
useFileTreeData()
|
||||
|
||||
const [isRootFolderSelected, setIsRootFolderSelected] = useState(false)
|
||||
|
||||
const [selectedEntityIds, dispatch] = useReducer(
|
||||
fileTreeReadOnly
|
||||
? fileTreeSelectableReadOnlyReducer
|
||||
: fileTreeSelectableReadWriteReducer,
|
||||
null,
|
||||
() => {
|
||||
if (!initialSelectedEntityId) return new Set<string>()
|
||||
|
||||
// the entity with id=initialSelectedEntityId might not exist in the tree
|
||||
// anymore. This checks that it exists before initialising the reducer
|
||||
// with the id.
|
||||
if (findInTree(fileTreeData, initialSelectedEntityId))
|
||||
return new Set([initialSelectedEntityId])
|
||||
|
||||
// the entity doesn't exist anymore; don't select any files
|
||||
return new Set<string>()
|
||||
}
|
||||
)
|
||||
|
||||
const [selectedEntityParentIds, setSelectedEntityParentIds] = useState<
|
||||
Set<string>
|
||||
>(new Set())
|
||||
|
||||
// fills `selectedEntityParentIds` set
|
||||
useEffect(() => {
|
||||
const ids = new Set<string>()
|
||||
selectedEntityIds.forEach(id => {
|
||||
const found = findInTree(fileTreeData, id)
|
||||
if (found) {
|
||||
found.path.forEach((pathItem: any) => ids.add(pathItem))
|
||||
}
|
||||
})
|
||||
setSelectedEntityParentIds(ids)
|
||||
}, [fileTreeData, selectedEntityIds])
|
||||
|
||||
// calls `onSelect` on entities selection
|
||||
const previousSelectedEntityIds = usePreviousValue(selectedEntityIds)
|
||||
useEffect(() => {
|
||||
if (_.isEqual(selectedEntityIds, previousSelectedEntityIds)) {
|
||||
return
|
||||
}
|
||||
const _selectedEntities = Array.from(selectedEntityIds)
|
||||
.map(id => findInTree(fileTreeData, id))
|
||||
.filter(entity => entity !== null)
|
||||
onSelect(_selectedEntities)
|
||||
setSelectedEntities(_selectedEntities)
|
||||
}, [
|
||||
fileTreeData,
|
||||
selectedEntityIds,
|
||||
previousSelectedEntityIds,
|
||||
onSelect,
|
||||
setSelectedEntities,
|
||||
])
|
||||
|
||||
// Synchronize the file tree when openFileWithId or openDocWithId is called on the editor
|
||||
// manager context from elsewhere. If the file tree does change, it will
|
||||
// trigger the onSelect handler in this component, which will update the local
|
||||
// state.
|
||||
useEventListener(
|
||||
'entity:opened',
|
||||
useCallback(
|
||||
(event: CustomEvent<string>) => {
|
||||
const found = findInTree(fileTreeData, event.detail)
|
||||
if (!found) return
|
||||
|
||||
dispatch({ type: ACTION_TYPES.SELECT, id: found.entity._id })
|
||||
},
|
||||
[fileTreeData]
|
||||
)
|
||||
)
|
||||
|
||||
const select = useCallback(id => {
|
||||
dispatch({ type: ACTION_TYPES.SELECT, id })
|
||||
}, [])
|
||||
|
||||
const unselect = useCallback(id => {
|
||||
dispatch({ type: ACTION_TYPES.UNSELECT, id })
|
||||
}, [])
|
||||
|
||||
const selectOrMultiSelectEntity = useCallback((id, isMultiSelect) => {
|
||||
const actionType = isMultiSelect
|
||||
? ACTION_TYPES.MULTI_SELECT
|
||||
: ACTION_TYPES.SELECT
|
||||
|
||||
dispatch({ type: actionType, id })
|
||||
}, [])
|
||||
|
||||
// TODO: wrap in useMemo
|
||||
const value = {
|
||||
selectedEntityIds,
|
||||
selectedEntityParentIds,
|
||||
select,
|
||||
unselect,
|
||||
selectOrMultiSelectEntity,
|
||||
isRootFolderSelected,
|
||||
setIsRootFolderSelected,
|
||||
}
|
||||
|
||||
return (
|
||||
<FileTreeSelectableContext.Provider value={value}>
|
||||
{children}
|
||||
</FileTreeSelectableContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSelectableEntity(id: string, type: string) {
|
||||
const { view, setView } = useLayoutContext()
|
||||
const { setContextMenuCoords } = useFileTreeMainContext()
|
||||
const { fileTreeData } = useFileTreeData()
|
||||
const {
|
||||
selectedEntityIds,
|
||||
selectOrMultiSelectEntity,
|
||||
isRootFolderSelected,
|
||||
setIsRootFolderSelected,
|
||||
} = useFileTreeSelectable()
|
||||
|
||||
const isSelected = selectedEntityIds.has(id)
|
||||
|
||||
const buildSelectedRange = useCallback(
|
||||
id => {
|
||||
const selected = []
|
||||
|
||||
let started = false
|
||||
|
||||
for (const itemId of sortedItems(fileTreeData)) {
|
||||
if (itemId === id) {
|
||||
selected.push(itemId)
|
||||
if (started) {
|
||||
break
|
||||
} else {
|
||||
started = true
|
||||
}
|
||||
} else if (selectedEntityIds.has(itemId)) {
|
||||
// TODO: should only look at latest ("main") selected item
|
||||
selected.push(itemId)
|
||||
if (started) {
|
||||
break
|
||||
} else {
|
||||
started = true
|
||||
}
|
||||
} else if (started) {
|
||||
selected.push(itemId)
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
},
|
||||
[fileTreeData, selectedEntityIds]
|
||||
)
|
||||
|
||||
const chooseView = useCallback(() => {
|
||||
for (const id of selectedEntityIds) {
|
||||
const selectedEntity = findInTreeOrThrow(fileTreeData, id)
|
||||
|
||||
if (selectedEntity.type === 'doc') {
|
||||
return 'editor'
|
||||
}
|
||||
|
||||
if (selectedEntity.type === 'fileRef') {
|
||||
return 'file'
|
||||
}
|
||||
|
||||
if (selectedEntity.type === 'folder') {
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [fileTreeData, selectedEntityIds, view])
|
||||
|
||||
const handleEvent = useCallback(
|
||||
ev => {
|
||||
ev.stopPropagation()
|
||||
// use Command (macOS) or Ctrl (other OS) to select multiple items,
|
||||
// as long as the root folder wasn't selected
|
||||
const multiSelect =
|
||||
!isRootFolderSelected && (isMac ? ev.metaKey : ev.ctrlKey)
|
||||
setIsRootFolderSelected(false)
|
||||
|
||||
if (ev.shiftKey) {
|
||||
// use Shift to select a range of items
|
||||
selectOrMultiSelectEntity(buildSelectedRange(id))
|
||||
} else {
|
||||
selectOrMultiSelectEntity(id, multiSelect)
|
||||
}
|
||||
|
||||
if (type === 'file') {
|
||||
setView('file')
|
||||
} else if (type === 'doc') {
|
||||
setView('editor')
|
||||
} else if (type === 'folder') {
|
||||
setView(chooseView())
|
||||
}
|
||||
},
|
||||
[
|
||||
id,
|
||||
isRootFolderSelected,
|
||||
setIsRootFolderSelected,
|
||||
selectOrMultiSelectEntity,
|
||||
setView,
|
||||
type,
|
||||
buildSelectedRange,
|
||||
chooseView,
|
||||
]
|
||||
)
|
||||
|
||||
const handleClick = useCallback(
|
||||
ev => {
|
||||
handleEvent(ev)
|
||||
if (!ev.ctrlKey && !ev.metaKey) {
|
||||
setContextMenuCoords(null)
|
||||
}
|
||||
},
|
||||
[handleEvent, setContextMenuCoords]
|
||||
)
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
ev => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
handleEvent(ev)
|
||||
}
|
||||
},
|
||||
[handleEvent]
|
||||
)
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
ev => {
|
||||
// make sure the right-clicked entity gets selected
|
||||
if (!selectedEntityIds.has(id)) {
|
||||
handleEvent(ev)
|
||||
}
|
||||
},
|
||||
[id, handleEvent, selectedEntityIds]
|
||||
)
|
||||
|
||||
const isVisuallySelected =
|
||||
!isRootFolderSelected && isSelected && view !== 'pdf'
|
||||
const props = useMemo(
|
||||
() => ({
|
||||
className: classNames({ selected: isVisuallySelected }),
|
||||
'aria-selected': isVisuallySelected,
|
||||
onClick: handleClick,
|
||||
onContextMenu: handleContextMenu,
|
||||
onKeyPress: handleKeyPress,
|
||||
}),
|
||||
[handleClick, handleContextMenu, handleKeyPress, isVisuallySelected]
|
||||
)
|
||||
|
||||
return { isSelected, props }
|
||||
}
|
||||
|
||||
export function useFileTreeSelectable() {
|
||||
const context = useContext(FileTreeSelectableContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`useFileTreeSelectable is only available inside FileTreeSelectableProvider`
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const alphabetical = (a: FileTreeEntity, b: FileTreeEntity) =>
|
||||
fileCollator.compare(a.name, b.name)
|
||||
|
||||
function* sortedItems(folder: Folder): Generator<string> {
|
||||
yield folder._id
|
||||
|
||||
const folders = [...folder.folders].sort(alphabetical)
|
||||
for (const subfolder of folders) {
|
||||
for (const id of sortedItems(subfolder)) {
|
||||
yield id
|
||||
}
|
||||
}
|
||||
|
||||
const files = [...folder.docs, ...folder.fileRefs].sort(alphabetical)
|
||||
for (const file of files) {
|
||||
yield file._id
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user