file-tree-selectable.js 5.7 KB

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