file-tree-selectable.js 6.2 KB

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