file-tree-selectable.js 6.8 KB

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