2021-01-07 09:22:40 -05:00
|
|
|
import React, {
|
|
|
|
createContext,
|
|
|
|
useContext,
|
|
|
|
useReducer,
|
|
|
|
useEffect,
|
|
|
|
useState
|
|
|
|
} from 'react'
|
2020-11-26 09:22:30 -05:00
|
|
|
import PropTypes from 'prop-types'
|
|
|
|
import classNames from 'classnames'
|
|
|
|
|
|
|
|
import { findInTree } from '../util/find-in-tree'
|
|
|
|
import { useFileTreeMutable } from './file-tree-mutable'
|
2021-01-07 09:22:40 -05:00
|
|
|
import { FileTreeMainContext } from './file-tree-main'
|
|
|
|
import usePersistedState from '../../../infrastructure/persisted-state-hook'
|
2020-11-26 09:22:30 -05:00
|
|
|
|
2021-01-07 09:22:40 -05:00
|
|
|
const FileTreeSelectableContext = createContext()
|
2020-11-26 09:22:30 -05:00
|
|
|
|
|
|
|
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([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({
|
|
|
|
hasWritePermissions,
|
2021-01-07 09:22:40 -05:00
|
|
|
rootDocId,
|
2020-11-26 09:22:30 -05:00
|
|
|
onSelect,
|
|
|
|
children
|
|
|
|
}) {
|
2021-01-07 09:22:40 -05:00
|
|
|
const { projectId } = useContext(FileTreeMainContext)
|
|
|
|
|
|
|
|
const [initialSelectedEntityId] = usePersistedState(
|
|
|
|
`doc.open_id.${projectId}`,
|
|
|
|
rootDocId
|
|
|
|
)
|
|
|
|
|
2021-01-08 05:03:10 -05:00
|
|
|
const { fileTreeData } = useFileTreeMutable()
|
|
|
|
|
2020-11-26 09:22:30 -05:00
|
|
|
const [selectedEntityIds, dispatch] = useReducer(
|
|
|
|
hasWritePermissions
|
|
|
|
? fileTreeSelectableReadWriteReducer
|
|
|
|
: fileTreeSelectableReadOnlyReducer,
|
2021-01-08 05:03:10 -05:00
|
|
|
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()
|
|
|
|
}
|
2020-11-26 09:22:30 -05:00
|
|
|
)
|
|
|
|
|
2021-01-07 09:22:40 -05:00
|
|
|
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])
|
|
|
|
|
2020-11-26 09:22:30 -05:00
|
|
|
// calls `onSelect` on entities selection
|
2020-12-15 05:23:54 -05:00
|
|
|
useEffect(() => {
|
|
|
|
const selectedEntities = Array.from(selectedEntityIds).map(id =>
|
|
|
|
findInTree(fileTreeData, id)
|
|
|
|
)
|
|
|
|
onSelect(selectedEntities)
|
2021-01-07 09:22:19 -05:00
|
|
|
}, [fileTreeData, selectedEntityIds, onSelect])
|
2020-11-26 09:22:30 -05:00
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
// listen for `editor.openDoc` and selected that doc
|
|
|
|
function handleOpenDoc(ev) {
|
2021-01-27 04:54:29 -05:00
|
|
|
const found = findInTree(fileTreeData, ev.detail)
|
|
|
|
if (!found) return
|
|
|
|
|
|
|
|
dispatch({ type: ACTION_TYPES.SELECT, id: found.entity._id })
|
2020-11-26 09:22:30 -05:00
|
|
|
}
|
|
|
|
window.addEventListener('editor.openDoc', handleOpenDoc)
|
|
|
|
return () => window.removeEventListener('editor.openDoc', handleOpenDoc)
|
2021-01-27 04:54:29 -05:00
|
|
|
}, [fileTreeData])
|
2020-11-26 09:22:30 -05:00
|
|
|
|
|
|
|
return (
|
2021-01-07 09:22:40 -05:00
|
|
|
<FileTreeSelectableContext.Provider
|
|
|
|
value={{ selectedEntityIds, selectedEntityParentIds, dispatch }}
|
|
|
|
>
|
2020-11-26 09:22:30 -05:00
|
|
|
{children}
|
|
|
|
</FileTreeSelectableContext.Provider>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
FileTreeSelectableProvider.propTypes = {
|
|
|
|
hasWritePermissions: PropTypes.bool.isRequired,
|
2021-01-07 09:22:40 -05:00
|
|
|
rootDocId: PropTypes.string,
|
2020-11-26 09:22:30 -05:00
|
|
|
onSelect: PropTypes.func.isRequired,
|
|
|
|
children: PropTypes.oneOfType([
|
|
|
|
PropTypes.arrayOf(PropTypes.node),
|
|
|
|
PropTypes.node
|
|
|
|
]).isRequired
|
|
|
|
}
|
|
|
|
|
|
|
|
export function useSelectableEntity(id) {
|
|
|
|
const { selectedEntityIds, dispatch } = useContext(FileTreeSelectableContext)
|
|
|
|
|
|
|
|
const isSelected = selectedEntityIds.has(id)
|
|
|
|
|
|
|
|
function selectOrMultiSelectEntity(ev) {
|
|
|
|
const isMultiSelect = ev.ctrlKey || ev.metaKey
|
|
|
|
const actionType = isMultiSelect
|
|
|
|
? ACTION_TYPES.MULTI_SELECT
|
|
|
|
: ACTION_TYPES.SELECT
|
|
|
|
|
|
|
|
dispatch({ type: actionType, id })
|
|
|
|
}
|
|
|
|
|
|
|
|
function handleClick(ev) {
|
|
|
|
selectOrMultiSelectEntity(ev)
|
|
|
|
}
|
|
|
|
|
|
|
|
function handleKeyPress(ev) {
|
|
|
|
if (ev.key === 'Enter' || ev.key === ' ') {
|
|
|
|
selectOrMultiSelectEntity(ev)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function handleContextMenu(ev) {
|
|
|
|
// make sure the right-clicked entity gets selected
|
|
|
|
if (!selectedEntityIds.has(id)) selectOrMultiSelectEntity(ev)
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
isSelected,
|
|
|
|
props: {
|
|
|
|
className: classNames({ selected: isSelected }),
|
|
|
|
'aria-selected': isSelected,
|
|
|
|
onClick: handleClick,
|
|
|
|
onContextMenu: handleContextMenu,
|
|
|
|
onKeyPress: handleKeyPress
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export function useFileTreeSelectable() {
|
2021-01-07 09:22:40 -05:00
|
|
|
const { selectedEntityIds, selectedEntityParentIds, dispatch } = useContext(
|
|
|
|
FileTreeSelectableContext
|
|
|
|
)
|
2020-11-26 09:22:30 -05:00
|
|
|
|
2021-01-05 05:56:58 -05:00
|
|
|
function select(id) {
|
|
|
|
dispatch({ type: ACTION_TYPES.SELECT, id })
|
|
|
|
}
|
|
|
|
|
2020-11-26 09:22:30 -05:00
|
|
|
function unselect(id) {
|
|
|
|
dispatch({ type: ACTION_TYPES.UNSELECT, id })
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
selectedEntityIds,
|
2021-01-07 09:22:40 -05:00
|
|
|
selectedEntityParentIds,
|
2021-01-05 05:56:58 -05:00
|
|
|
select,
|
2020-11-26 09:22:30 -05:00
|
|
|
unselect
|
|
|
|
}
|
|
|
|
}
|