file-tree-selectable.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  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({
  67. onSelect,
  68. setShouldShowVisualSelection,
  69. children,
  70. }) {
  71. const { _id: projectId, rootDocId } = useProjectContext(
  72. projectContextPropTypes
  73. )
  74. const { permissionsLevel } = useEditorContext(editorContextPropTypes)
  75. const [initialSelectedEntityId] = usePersistedState(
  76. `doc.open_id.${projectId}`,
  77. rootDocId
  78. )
  79. const { fileTreeData, setSelectedEntities } = useFileTreeData()
  80. const [selectedEntityIds, dispatch] = useReducer(
  81. permissionsLevel === 'readOnly'
  82. ? fileTreeSelectableReadOnlyReducer
  83. : fileTreeSelectableReadWriteReducer,
  84. null,
  85. () => {
  86. if (!initialSelectedEntityId) return new Set()
  87. // the entity with id=initialSelectedEntityId might not exist in the tree
  88. // anymore. This checks that it exists before initialising the reducer
  89. // with the id.
  90. if (findInTree(fileTreeData, initialSelectedEntityId))
  91. return new Set([initialSelectedEntityId])
  92. // the entity doesn't exist anymore; don't select any files
  93. return new Set()
  94. }
  95. )
  96. const [selectedEntityParentIds, setSelectedEntityParentIds] = useState(
  97. new Set()
  98. )
  99. // fills `selectedEntityParentIds` set
  100. useEffect(() => {
  101. const ids = new Set()
  102. selectedEntityIds.forEach(id => {
  103. const found = findInTree(fileTreeData, id)
  104. if (found) {
  105. found.path.forEach(pathItem => ids.add(pathItem))
  106. }
  107. })
  108. setSelectedEntityParentIds(ids)
  109. }, [fileTreeData, selectedEntityIds])
  110. // calls `onSelect` on entities selection
  111. const previousSelectedEntityIds = usePreviousValue(selectedEntityIds)
  112. useEffect(() => {
  113. if (_.isEqual(selectedEntityIds, previousSelectedEntityIds)) {
  114. return
  115. }
  116. const _selectedEntities = Array.from(selectedEntityIds)
  117. .map(id => findInTree(fileTreeData, id))
  118. .filter(Boolean)
  119. onSelect(_selectedEntities)
  120. setSelectedEntities(_selectedEntities)
  121. }, [
  122. fileTreeData,
  123. selectedEntityIds,
  124. previousSelectedEntityIds,
  125. onSelect,
  126. setSelectedEntities,
  127. ])
  128. useEffect(() => {
  129. // listen for `editor.openDoc` and selected that doc
  130. function handleOpenDoc(ev) {
  131. const found = findInTree(fileTreeData, ev.detail)
  132. if (!found) return
  133. dispatch({ type: ACTION_TYPES.SELECT, id: found.entity._id })
  134. }
  135. window.addEventListener('editor.openDoc', handleOpenDoc)
  136. return () => window.removeEventListener('editor.openDoc', handleOpenDoc)
  137. }, [fileTreeData])
  138. const select = useCallback(id => {
  139. dispatch({ type: ACTION_TYPES.SELECT, id })
  140. }, [])
  141. const unselect = useCallback(id => {
  142. dispatch({ type: ACTION_TYPES.UNSELECT, id })
  143. }, [])
  144. const selectOrMultiSelectEntity = useCallback((id, isMultiSelect) => {
  145. const actionType = isMultiSelect
  146. ? ACTION_TYPES.MULTI_SELECT
  147. : ACTION_TYPES.SELECT
  148. dispatch({ type: actionType, id })
  149. }, [])
  150. const value = {
  151. selectedEntityIds,
  152. selectedEntityParentIds,
  153. select,
  154. unselect,
  155. selectOrMultiSelectEntity,
  156. setShouldShowVisualSelection,
  157. }
  158. return (
  159. <FileTreeSelectableContext.Provider value={value}>
  160. {children}
  161. </FileTreeSelectableContext.Provider>
  162. )
  163. }
  164. FileTreeSelectableProvider.propTypes = {
  165. onSelect: PropTypes.func.isRequired,
  166. setShouldShowVisualSelection: PropTypes.func.isRequired,
  167. children: PropTypes.oneOfType([
  168. PropTypes.arrayOf(PropTypes.node),
  169. PropTypes.node,
  170. ]).isRequired,
  171. }
  172. const projectContextPropTypes = {
  173. _id: PropTypes.string.isRequired,
  174. rootDocId: PropTypes.string,
  175. }
  176. const editorContextPropTypes = {
  177. permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
  178. }
  179. export function useSelectableEntity(
  180. id,
  181. shouldShowVisualSelection = true,
  182. isFile
  183. ) {
  184. const { view, setView } = useLayoutContext(layoutContextPropTypes)
  185. const {
  186. selectedEntityIds,
  187. selectOrMultiSelectEntity,
  188. setShouldShowVisualSelection,
  189. } = useContext(FileTreeSelectableContext)
  190. const isSelected = selectedEntityIds.has(id)
  191. const handleEvent = useCallback(
  192. ev => {
  193. ev.stopPropagation()
  194. setShouldShowVisualSelection(true)
  195. selectOrMultiSelectEntity(id, ev.ctrlKey || ev.metaKey)
  196. setView(isFile ? 'file' : 'editor')
  197. },
  198. [
  199. id,
  200. setShouldShowVisualSelection,
  201. selectOrMultiSelectEntity,
  202. setView,
  203. isFile,
  204. ]
  205. )
  206. const handleClick = useCallback(
  207. ev => {
  208. handleEvent(ev)
  209. },
  210. [handleEvent]
  211. )
  212. const handleKeyPress = useCallback(
  213. ev => {
  214. if (ev.key === 'Enter' || ev.key === ' ') {
  215. handleEvent(ev)
  216. }
  217. },
  218. [handleEvent]
  219. )
  220. const handleContextMenu = useCallback(
  221. ev => {
  222. // make sure the right-clicked entity gets selected
  223. if (!selectedEntityIds.has(id)) {
  224. handleEvent(ev)
  225. }
  226. },
  227. [id, handleEvent, selectedEntityIds]
  228. )
  229. const isVisuallySelected =
  230. shouldShowVisualSelection && isSelected && view !== 'pdf'
  231. const props = useMemo(
  232. () => ({
  233. className: classNames({ selected: isVisuallySelected }),
  234. 'aria-selected': isVisuallySelected,
  235. onClick: handleClick,
  236. onContextMenu: handleContextMenu,
  237. onKeyPress: handleKeyPress,
  238. }),
  239. [handleClick, handleContextMenu, handleKeyPress, isVisuallySelected]
  240. )
  241. return { isSelected, props }
  242. }
  243. const layoutContextPropTypes = {
  244. view: PropTypes.string,
  245. setView: PropTypes.func.isRequired,
  246. }
  247. export function useFileTreeSelectable() {
  248. const context = useContext(FileTreeSelectableContext)
  249. if (!context) {
  250. throw new Error(
  251. `useFileTreeSelectable is only available inside FileTreeSelectableProvider`
  252. )
  253. }
  254. return context
  255. }