file-tree-selectable.js 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  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 [selectedEntityIds, dispatch] = useReducer(
  72. hasWritePermissions
  73. ? fileTreeSelectableReadWriteReducer
  74. : fileTreeSelectableReadOnlyReducer,
  75. initialSelectedEntityId ? new Set([initialSelectedEntityId]) : new Set()
  76. )
  77. const { fileTreeData } = useFileTreeMutable()
  78. const [selectedEntityParentIds, setSelectedEntityParentIds] = useState(
  79. new Set()
  80. )
  81. // fills `selectedEntityParentIds` set
  82. useEffect(() => {
  83. const ids = new Set()
  84. selectedEntityIds.forEach(id => {
  85. const found = findInTree(fileTreeData, id)
  86. if (found) {
  87. found.path.forEach(pathItem => ids.add(pathItem))
  88. }
  89. })
  90. setSelectedEntityParentIds(ids)
  91. }, [fileTreeData, selectedEntityIds])
  92. // calls `onSelect` on entities selection
  93. useEffect(() => {
  94. const selectedEntities = Array.from(selectedEntityIds).map(id =>
  95. findInTree(fileTreeData, id)
  96. )
  97. onSelect(selectedEntities)
  98. }, [fileTreeData, selectedEntityIds, onSelect])
  99. useEffect(() => {
  100. // listen for `editor.openDoc` and selected that doc
  101. function handleOpenDoc(ev) {
  102. dispatch({ type: ACTION_TYPES.SELECT, id: ev.detail })
  103. }
  104. window.addEventListener('editor.openDoc', handleOpenDoc)
  105. return () => window.removeEventListener('editor.openDoc', handleOpenDoc)
  106. }, [])
  107. return (
  108. <FileTreeSelectableContext.Provider
  109. value={{ selectedEntityIds, selectedEntityParentIds, dispatch }}
  110. >
  111. {children}
  112. </FileTreeSelectableContext.Provider>
  113. )
  114. }
  115. FileTreeSelectableProvider.propTypes = {
  116. hasWritePermissions: PropTypes.bool.isRequired,
  117. rootDocId: PropTypes.string,
  118. onSelect: PropTypes.func.isRequired,
  119. children: PropTypes.oneOfType([
  120. PropTypes.arrayOf(PropTypes.node),
  121. PropTypes.node
  122. ]).isRequired
  123. }
  124. export function useSelectableEntity(id) {
  125. const { selectedEntityIds, dispatch } = useContext(FileTreeSelectableContext)
  126. const isSelected = selectedEntityIds.has(id)
  127. function selectOrMultiSelectEntity(ev) {
  128. const isMultiSelect = ev.ctrlKey || ev.metaKey
  129. const actionType = isMultiSelect
  130. ? ACTION_TYPES.MULTI_SELECT
  131. : ACTION_TYPES.SELECT
  132. dispatch({ type: actionType, id })
  133. }
  134. function handleClick(ev) {
  135. selectOrMultiSelectEntity(ev)
  136. }
  137. function handleKeyPress(ev) {
  138. if (ev.key === 'Enter' || ev.key === ' ') {
  139. selectOrMultiSelectEntity(ev)
  140. }
  141. }
  142. function handleContextMenu(ev) {
  143. // make sure the right-clicked entity gets selected
  144. if (!selectedEntityIds.has(id)) selectOrMultiSelectEntity(ev)
  145. }
  146. return {
  147. isSelected,
  148. props: {
  149. className: classNames({ selected: isSelected }),
  150. 'aria-selected': isSelected,
  151. onClick: handleClick,
  152. onContextMenu: handleContextMenu,
  153. onKeyPress: handleKeyPress
  154. }
  155. }
  156. }
  157. export function useFileTreeSelectable() {
  158. const { selectedEntityIds, selectedEntityParentIds, dispatch } = useContext(
  159. FileTreeSelectableContext
  160. )
  161. function select(id) {
  162. dispatch({ type: ACTION_TYPES.SELECT, id })
  163. }
  164. function unselect(id) {
  165. dispatch({ type: ACTION_TYPES.UNSELECT, id })
  166. }
  167. return {
  168. selectedEntityIds,
  169. selectedEntityParentIds,
  170. select,
  171. unselect
  172. }
  173. }