file-tree-selectable.jsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. import {
  2. createContext,
  3. useCallback,
  4. useContext,
  5. useReducer,
  6. useEffect,
  7. useMemo,
  8. useState,
  9. } from 'react'
  10. import PropTypes from 'prop-types'
  11. import classNames from 'classnames'
  12. import _ from 'lodash'
  13. import { findInTree } from '../util/find-in-tree'
  14. import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
  15. import { useProjectContext } from '../../../shared/context/project-context'
  16. import { useEditorContext } from '../../../shared/context/editor-context'
  17. import { useLayoutContext } from '../../../shared/context/layout-context'
  18. import usePersistedState from '../../../shared/hooks/use-persisted-state'
  19. import usePreviousValue from '../../../shared/hooks/use-previous-value'
  20. const FileTreeSelectableContext = createContext()
  21. const ACTION_TYPES = {
  22. SELECT: 'SELECT',
  23. MULTI_SELECT: 'MULTI_SELECT',
  24. UNSELECT: 'UNSELECT',
  25. }
  26. function fileTreeSelectableReadWriteReducer(selectedEntityIds, action) {
  27. switch (action.type) {
  28. case ACTION_TYPES.SELECT: {
  29. // reset selection
  30. return new Set([action.id])
  31. }
  32. case ACTION_TYPES.MULTI_SELECT: {
  33. const selectedEntityIdsCopy = new Set(selectedEntityIds)
  34. if (selectedEntityIdsCopy.has(action.id)) {
  35. // entity already selected
  36. if (selectedEntityIdsCopy.size > 1) {
  37. // entity already multi-selected; remove from set
  38. selectedEntityIdsCopy.delete(action.id)
  39. }
  40. } else {
  41. // entity not selected: add to set
  42. selectedEntityIdsCopy.add(action.id)
  43. }
  44. return selectedEntityIdsCopy
  45. }
  46. case ACTION_TYPES.UNSELECT: {
  47. const selectedEntityIdsCopy = new Set(selectedEntityIds)
  48. selectedEntityIdsCopy.delete(action.id)
  49. return selectedEntityIdsCopy
  50. }
  51. default:
  52. throw new Error(`Unknown selectable action type: ${action.type}`)
  53. }
  54. }
  55. function fileTreeSelectableReadOnlyReducer(selectedEntityIds, action) {
  56. switch (action.type) {
  57. case ACTION_TYPES.SELECT:
  58. return new Set([action.id])
  59. case ACTION_TYPES.MULTI_SELECT:
  60. case ACTION_TYPES.UNSELECT:
  61. return selectedEntityIds
  62. default:
  63. throw new Error(`Unknown selectable action type: ${action.type}`)
  64. }
  65. }
  66. export function FileTreeSelectableProvider({ onSelect, children }) {
  67. const { _id: projectId, rootDocId } = useProjectContext(
  68. projectContextPropTypes
  69. )
  70. const { permissionsLevel } = useEditorContext(editorContextPropTypes)
  71. const [initialSelectedEntityId] = usePersistedState(
  72. `doc.open_id.${projectId}`,
  73. rootDocId
  74. )
  75. const { fileTreeData, setSelectedEntities } = useFileTreeData()
  76. const [isRootFolderSelected, setIsRootFolderSelected] = useState(false)
  77. const [selectedEntityIds, dispatch] = useReducer(
  78. permissionsLevel === 'readOnly'
  79. ? fileTreeSelectableReadOnlyReducer
  80. : fileTreeSelectableReadWriteReducer,
  81. null,
  82. () => {
  83. if (!initialSelectedEntityId) return new Set()
  84. // the entity with id=initialSelectedEntityId might not exist in the tree
  85. // anymore. This checks that it exists before initialising the reducer
  86. // with the id.
  87. if (findInTree(fileTreeData, initialSelectedEntityId))
  88. return new Set([initialSelectedEntityId])
  89. // the entity doesn't exist anymore; don't select any files
  90. return new Set()
  91. }
  92. )
  93. const [selectedEntityParentIds, setSelectedEntityParentIds] = useState(
  94. new Set()
  95. )
  96. // fills `selectedEntityParentIds` set
  97. useEffect(() => {
  98. const ids = new Set()
  99. selectedEntityIds.forEach(id => {
  100. const found = findInTree(fileTreeData, id)
  101. if (found) {
  102. found.path.forEach(pathItem => ids.add(pathItem))
  103. }
  104. })
  105. setSelectedEntityParentIds(ids)
  106. }, [fileTreeData, selectedEntityIds])
  107. // calls `onSelect` on entities selection
  108. const previousSelectedEntityIds = usePreviousValue(selectedEntityIds)
  109. useEffect(() => {
  110. if (_.isEqual(selectedEntityIds, previousSelectedEntityIds)) {
  111. return
  112. }
  113. const _selectedEntities = Array.from(selectedEntityIds)
  114. .map(id => findInTree(fileTreeData, id))
  115. .filter(Boolean)
  116. onSelect(_selectedEntities)
  117. setSelectedEntities(_selectedEntities)
  118. }, [
  119. fileTreeData,
  120. selectedEntityIds,
  121. previousSelectedEntityIds,
  122. onSelect,
  123. setSelectedEntities,
  124. ])
  125. useEffect(() => {
  126. // listen for `editor.openDoc` and selected that doc
  127. function handleOpenDoc(ev) {
  128. const found = findInTree(fileTreeData, ev.detail)
  129. if (!found) return
  130. dispatch({ type: ACTION_TYPES.SELECT, id: found.entity._id })
  131. }
  132. window.addEventListener('editor.openDoc', handleOpenDoc)
  133. return () => window.removeEventListener('editor.openDoc', handleOpenDoc)
  134. }, [fileTreeData])
  135. const select = useCallback(id => {
  136. dispatch({ type: ACTION_TYPES.SELECT, id })
  137. }, [])
  138. const unselect = useCallback(id => {
  139. dispatch({ type: ACTION_TYPES.UNSELECT, id })
  140. }, [])
  141. const selectOrMultiSelectEntity = useCallback((id, isMultiSelect) => {
  142. const actionType = isMultiSelect
  143. ? ACTION_TYPES.MULTI_SELECT
  144. : ACTION_TYPES.SELECT
  145. dispatch({ type: actionType, id })
  146. }, [])
  147. const value = {
  148. selectedEntityIds,
  149. selectedEntityParentIds,
  150. select,
  151. unselect,
  152. selectOrMultiSelectEntity,
  153. isRootFolderSelected,
  154. setIsRootFolderSelected,
  155. }
  156. return (
  157. <FileTreeSelectableContext.Provider value={value}>
  158. {children}
  159. </FileTreeSelectableContext.Provider>
  160. )
  161. }
  162. FileTreeSelectableProvider.propTypes = {
  163. onSelect: PropTypes.func.isRequired,
  164. children: PropTypes.oneOfType([
  165. PropTypes.arrayOf(PropTypes.node),
  166. PropTypes.node,
  167. ]).isRequired,
  168. }
  169. const projectContextPropTypes = {
  170. _id: PropTypes.string.isRequired,
  171. rootDocId: PropTypes.string,
  172. }
  173. const editorContextPropTypes = {
  174. permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
  175. }
  176. export function useSelectableEntity(id, isFile) {
  177. const { view, setView } = useLayoutContext(layoutContextPropTypes)
  178. const {
  179. selectedEntityIds,
  180. selectOrMultiSelectEntity,
  181. isRootFolderSelected,
  182. setIsRootFolderSelected,
  183. } = useContext(FileTreeSelectableContext)
  184. const isSelected = selectedEntityIds.has(id)
  185. const handleEvent = useCallback(
  186. ev => {
  187. ev.stopPropagation()
  188. setIsRootFolderSelected(false)
  189. selectOrMultiSelectEntity(id, ev.ctrlKey || ev.metaKey)
  190. setView(isFile ? 'file' : 'editor')
  191. },
  192. [id, setIsRootFolderSelected, selectOrMultiSelectEntity, setView, isFile]
  193. )
  194. const handleClick = useCallback(
  195. ev => {
  196. handleEvent(ev)
  197. },
  198. [handleEvent]
  199. )
  200. const handleKeyPress = useCallback(
  201. ev => {
  202. if (ev.key === 'Enter' || ev.key === ' ') {
  203. handleEvent(ev)
  204. }
  205. },
  206. [handleEvent]
  207. )
  208. const handleContextMenu = useCallback(
  209. ev => {
  210. // make sure the right-clicked entity gets selected
  211. if (!selectedEntityIds.has(id)) {
  212. handleEvent(ev)
  213. }
  214. },
  215. [id, handleEvent, selectedEntityIds]
  216. )
  217. const isVisuallySelected =
  218. !isRootFolderSelected && isSelected && view !== 'pdf'
  219. const props = useMemo(
  220. () => ({
  221. className: classNames({ selected: isVisuallySelected }),
  222. 'aria-selected': isVisuallySelected,
  223. onClick: handleClick,
  224. onContextMenu: handleContextMenu,
  225. onKeyPress: handleKeyPress,
  226. }),
  227. [handleClick, handleContextMenu, handleKeyPress, isVisuallySelected]
  228. )
  229. return { isSelected, props }
  230. }
  231. const layoutContextPropTypes = {
  232. view: PropTypes.string,
  233. setView: PropTypes.func.isRequired,
  234. }
  235. export function useFileTreeSelectable() {
  236. const context = useContext(FileTreeSelectableContext)
  237. if (!context) {
  238. throw new Error(
  239. `useFileTreeSelectable is only available inside FileTreeSelectableProvider`
  240. )
  241. }
  242. return context
  243. }