file-tree-selectable.jsx 8.1 KB

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