file-tree-selectable.js 6.5 KB

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