overleaf/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.jsx
Alf Eaton 0572535a7e Avoid changing the view when selecting a folder (#15315)
GitOrigin-RevId: 272c6f87507c65581041150905406f5c38e490d4
2023-10-23 08:04:08 +00:00

330 lines
8.8 KiB
JavaScript

import {
createContext,
useCallback,
useContext,
useReducer,
useEffect,
useMemo,
useState,
} from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import _ from 'lodash'
import { findInTree } from '../util/find-in-tree'
import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
import { useProjectContext } from '../../../shared/context/project-context'
import { useEditorContext } from '../../../shared/context/editor-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'
const FileTreeSelectableContext = createContext()
const ACTION_TYPES = {
SELECT: 'SELECT',
MULTI_SELECT: 'MULTI_SELECT',
UNSELECT: 'UNSELECT',
}
function fileTreeSelectableReadWriteReducer(selectedEntityIds, 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.type}`)
}
}
function fileTreeSelectableReadOnlyReducer(selectedEntityIds, 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.type}`)
}
}
export function FileTreeSelectableProvider({ onSelect, children }) {
const { _id: projectId, rootDocId } = useProjectContext(
projectContextPropTypes
)
const { permissionsLevel } = useEditorContext(editorContextPropTypes)
const [initialSelectedEntityId] = usePersistedState(
`doc.open_id.${projectId}`,
rootDocId
)
const { fileTreeData, setSelectedEntities } = useFileTreeData()
const [isRootFolderSelected, setIsRootFolderSelected] = useState(false)
const [selectedEntityIds, dispatch] = useReducer(
permissionsLevel === 'readOnly'
? fileTreeSelectableReadOnlyReducer
: fileTreeSelectableReadWriteReducer,
null,
() => {
if (!initialSelectedEntityId) return new Set()
// 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()
}
)
const [selectedEntityParentIds, setSelectedEntityParentIds] = useState(
new Set()
)
// fills `selectedEntityParentIds` set
useEffect(() => {
const ids = new Set()
selectedEntityIds.forEach(id => {
const found = findInTree(fileTreeData, id)
if (found) {
found.path.forEach(pathItem => 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(Boolean)
onSelect(_selectedEntities)
setSelectedEntities(_selectedEntities)
}, [
fileTreeData,
selectedEntityIds,
previousSelectedEntityIds,
onSelect,
setSelectedEntities,
])
useEffect(() => {
// listen for `editor.openDoc` and selected that doc
function handleOpenDoc(ev) {
const found = findInTree(fileTreeData, ev.detail)
if (!found) return
dispatch({ type: ACTION_TYPES.SELECT, id: found.entity._id })
}
window.addEventListener('editor.openDoc', handleOpenDoc)
return () => window.removeEventListener('editor.openDoc', handleOpenDoc)
}, [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 })
}, [])
const value = {
selectedEntityIds,
selectedEntityParentIds,
select,
unselect,
selectOrMultiSelectEntity,
isRootFolderSelected,
setIsRootFolderSelected,
}
return (
<FileTreeSelectableContext.Provider value={value}>
{children}
</FileTreeSelectableContext.Provider>
)
}
FileTreeSelectableProvider.propTypes = {
onSelect: PropTypes.func.isRequired,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
}
const projectContextPropTypes = {
_id: PropTypes.string.isRequired,
rootDocId: PropTypes.string,
}
const editorContextPropTypes = {
permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
}
const isMac = /Mac/.test(window.navigator?.platform)
export function useSelectableEntity(id, type) {
const { view, setView } = useLayoutContext()
const { setContextMenuCoords } = useFileTreeMainContext()
const { fileTreeData } = useFileTreeData()
const {
selectedEntityIds,
selectOrMultiSelectEntity,
isRootFolderSelected,
setIsRootFolderSelected,
} = useContext(FileTreeSelectableContext)
const isSelected = selectedEntityIds.has(id)
const chooseView = useCallback(() => {
for (const id of selectedEntityIds) {
const selectedEntity = findInTree(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)
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,
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
}