file-tree-selectable.jsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  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. import { fileCollator } from '@/features/file-tree/util/file-collator'
  22. const FileTreeSelectableContext = createContext()
  23. const ACTION_TYPES = {
  24. SELECT: 'SELECT',
  25. MULTI_SELECT: 'MULTI_SELECT',
  26. UNSELECT: 'UNSELECT',
  27. }
  28. function fileTreeSelectableReadWriteReducer(selectedEntityIds, action) {
  29. switch (action.type) {
  30. case ACTION_TYPES.SELECT: {
  31. // reset selection
  32. return new Set(Array.isArray(action.id) ? action.id : [action.id])
  33. }
  34. case ACTION_TYPES.MULTI_SELECT: {
  35. const selectedEntityIdsCopy = new Set(selectedEntityIds)
  36. if (selectedEntityIdsCopy.has(action.id)) {
  37. // entity already selected
  38. if (selectedEntityIdsCopy.size > 1) {
  39. // entity already multi-selected; remove from set
  40. selectedEntityIdsCopy.delete(action.id)
  41. }
  42. } else {
  43. // entity not selected: add to set
  44. selectedEntityIdsCopy.add(action.id)
  45. }
  46. return selectedEntityIdsCopy
  47. }
  48. case ACTION_TYPES.UNSELECT: {
  49. const selectedEntityIdsCopy = new Set(selectedEntityIds)
  50. selectedEntityIdsCopy.delete(action.id)
  51. return selectedEntityIdsCopy
  52. }
  53. default:
  54. throw new Error(`Unknown selectable action type: ${action.type}`)
  55. }
  56. }
  57. function fileTreeSelectableReadOnlyReducer(selectedEntityIds, action) {
  58. switch (action.type) {
  59. case ACTION_TYPES.SELECT:
  60. return new Set([action.id])
  61. case ACTION_TYPES.MULTI_SELECT:
  62. case ACTION_TYPES.UNSELECT:
  63. return selectedEntityIds
  64. default:
  65. throw new Error(`Unknown selectable action type: ${action.type}`)
  66. }
  67. }
  68. export function FileTreeSelectableProvider({ onSelect, children }) {
  69. const { _id: projectId, rootDocId } = useProjectContext(
  70. projectContextPropTypes
  71. )
  72. const { permissionsLevel } = useEditorContext(editorContextPropTypes)
  73. const [initialSelectedEntityId] = usePersistedState(
  74. `doc.open_id.${projectId}`,
  75. rootDocId
  76. )
  77. const { fileTreeData, setSelectedEntities } = useFileTreeData()
  78. const [isRootFolderSelected, setIsRootFolderSelected] = useState(false)
  79. const [selectedEntityIds, dispatch] = useReducer(
  80. permissionsLevel === 'readOnly'
  81. ? fileTreeSelectableReadOnlyReducer
  82. : fileTreeSelectableReadWriteReducer,
  83. null,
  84. () => {
  85. if (!initialSelectedEntityId) return new Set()
  86. // the entity with id=initialSelectedEntityId might not exist in the tree
  87. // anymore. This checks that it exists before initialising the reducer
  88. // with the id.
  89. if (findInTree(fileTreeData, initialSelectedEntityId))
  90. return new Set([initialSelectedEntityId])
  91. // the entity doesn't exist anymore; don't select any files
  92. return new Set()
  93. }
  94. )
  95. const [selectedEntityParentIds, setSelectedEntityParentIds] = useState(
  96. new Set()
  97. )
  98. // fills `selectedEntityParentIds` set
  99. useEffect(() => {
  100. const ids = new Set()
  101. selectedEntityIds.forEach(id => {
  102. const found = findInTree(fileTreeData, id)
  103. if (found) {
  104. found.path.forEach(pathItem => ids.add(pathItem))
  105. }
  106. })
  107. setSelectedEntityParentIds(ids)
  108. }, [fileTreeData, selectedEntityIds])
  109. // calls `onSelect` on entities selection
  110. const previousSelectedEntityIds = usePreviousValue(selectedEntityIds)
  111. useEffect(() => {
  112. if (_.isEqual(selectedEntityIds, previousSelectedEntityIds)) {
  113. return
  114. }
  115. const _selectedEntities = Array.from(selectedEntityIds)
  116. .map(id => findInTree(fileTreeData, id))
  117. .filter(Boolean)
  118. onSelect(_selectedEntities)
  119. setSelectedEntities(_selectedEntities)
  120. }, [
  121. fileTreeData,
  122. selectedEntityIds,
  123. previousSelectedEntityIds,
  124. onSelect,
  125. setSelectedEntities,
  126. ])
  127. useEffect(() => {
  128. // listen for `editor.openDoc` and selected that doc
  129. function handleOpenDoc(ev) {
  130. const found = findInTree(fileTreeData, ev.detail)
  131. if (!found) return
  132. dispatch({ type: ACTION_TYPES.SELECT, id: found.entity._id })
  133. }
  134. window.addEventListener('editor.openDoc', handleOpenDoc)
  135. return () => window.removeEventListener('editor.openDoc', handleOpenDoc)
  136. }, [fileTreeData])
  137. const select = useCallback(id => {
  138. dispatch({ type: ACTION_TYPES.SELECT, id })
  139. }, [])
  140. const unselect = useCallback(id => {
  141. dispatch({ type: ACTION_TYPES.UNSELECT, id })
  142. }, [])
  143. const selectOrMultiSelectEntity = useCallback((id, isMultiSelect) => {
  144. const actionType = isMultiSelect
  145. ? ACTION_TYPES.MULTI_SELECT
  146. : ACTION_TYPES.SELECT
  147. dispatch({ type: actionType, id })
  148. }, [])
  149. const value = {
  150. selectedEntityIds,
  151. selectedEntityParentIds,
  152. select,
  153. unselect,
  154. selectOrMultiSelectEntity,
  155. isRootFolderSelected,
  156. setIsRootFolderSelected,
  157. }
  158. return (
  159. <FileTreeSelectableContext.Provider value={value}>
  160. {children}
  161. </FileTreeSelectableContext.Provider>
  162. )
  163. }
  164. FileTreeSelectableProvider.propTypes = {
  165. onSelect: PropTypes.func.isRequired,
  166. children: PropTypes.oneOfType([
  167. PropTypes.arrayOf(PropTypes.node),
  168. PropTypes.node,
  169. ]).isRequired,
  170. }
  171. const projectContextPropTypes = {
  172. _id: PropTypes.string.isRequired,
  173. rootDocId: PropTypes.string,
  174. }
  175. const editorContextPropTypes = {
  176. permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
  177. }
  178. const isMac = /Mac/.test(window.navigator?.platform)
  179. export function useSelectableEntity(id, type) {
  180. const { view, setView } = useLayoutContext()
  181. const { setContextMenuCoords } = useFileTreeMainContext()
  182. const { fileTreeData } = useFileTreeData()
  183. const {
  184. selectedEntityIds,
  185. selectOrMultiSelectEntity,
  186. isRootFolderSelected,
  187. setIsRootFolderSelected,
  188. } = useContext(FileTreeSelectableContext)
  189. const isSelected = selectedEntityIds.has(id)
  190. const buildSelectedRange = useCallback(
  191. id => {
  192. const selected = []
  193. let started = false
  194. for (const itemId of sortedItems(fileTreeData)) {
  195. if (itemId === id) {
  196. selected.push(itemId)
  197. if (started) {
  198. break
  199. } else {
  200. started = true
  201. }
  202. } else if (selectedEntityIds.has(itemId)) {
  203. // TODO: should only look at latest ("main") selected item
  204. selected.push(itemId)
  205. if (started) {
  206. break
  207. } else {
  208. started = true
  209. }
  210. } else if (started) {
  211. selected.push(itemId)
  212. }
  213. }
  214. return selected
  215. },
  216. [fileTreeData, selectedEntityIds]
  217. )
  218. const chooseView = useCallback(() => {
  219. for (const id of selectedEntityIds) {
  220. const selectedEntity = findInTree(fileTreeData, id)
  221. if (selectedEntity.type === 'doc') {
  222. return 'editor'
  223. }
  224. if (selectedEntity.type === 'fileRef') {
  225. return 'file'
  226. }
  227. if (selectedEntity.type === 'folder') {
  228. return view
  229. }
  230. }
  231. return null
  232. }, [fileTreeData, selectedEntityIds, view])
  233. const handleEvent = useCallback(
  234. ev => {
  235. ev.stopPropagation()
  236. // use Command (macOS) or Ctrl (other OS) to select multiple items,
  237. // as long as the root folder wasn't selected
  238. const multiSelect =
  239. !isRootFolderSelected && (isMac ? ev.metaKey : ev.ctrlKey)
  240. setIsRootFolderSelected(false)
  241. if (ev.shiftKey) {
  242. // use Shift to select a range of items
  243. selectOrMultiSelectEntity(buildSelectedRange(id))
  244. } else {
  245. selectOrMultiSelectEntity(id, multiSelect)
  246. }
  247. if (type === 'file') {
  248. setView('file')
  249. } else if (type === 'doc') {
  250. setView('editor')
  251. } else if (type === 'folder') {
  252. setView(chooseView())
  253. }
  254. },
  255. [
  256. id,
  257. isRootFolderSelected,
  258. setIsRootFolderSelected,
  259. selectOrMultiSelectEntity,
  260. setView,
  261. type,
  262. buildSelectedRange,
  263. chooseView,
  264. ]
  265. )
  266. const handleClick = useCallback(
  267. ev => {
  268. handleEvent(ev)
  269. if (!ev.ctrlKey && !ev.metaKey) {
  270. setContextMenuCoords(null)
  271. }
  272. },
  273. [handleEvent, setContextMenuCoords]
  274. )
  275. const handleKeyPress = useCallback(
  276. ev => {
  277. if (ev.key === 'Enter' || ev.key === ' ') {
  278. handleEvent(ev)
  279. }
  280. },
  281. [handleEvent]
  282. )
  283. const handleContextMenu = useCallback(
  284. ev => {
  285. // make sure the right-clicked entity gets selected
  286. if (!selectedEntityIds.has(id)) {
  287. handleEvent(ev)
  288. }
  289. },
  290. [id, handleEvent, selectedEntityIds]
  291. )
  292. const isVisuallySelected =
  293. !isRootFolderSelected && isSelected && view !== 'pdf'
  294. const props = useMemo(
  295. () => ({
  296. className: classNames({ selected: isVisuallySelected }),
  297. 'aria-selected': isVisuallySelected,
  298. onClick: handleClick,
  299. onContextMenu: handleContextMenu,
  300. onKeyPress: handleKeyPress,
  301. }),
  302. [handleClick, handleContextMenu, handleKeyPress, isVisuallySelected]
  303. )
  304. return { isSelected, props }
  305. }
  306. export function useFileTreeSelectable() {
  307. const context = useContext(FileTreeSelectableContext)
  308. if (!context) {
  309. throw new Error(
  310. `useFileTreeSelectable is only available inside FileTreeSelectableProvider`
  311. )
  312. }
  313. return context
  314. }
  315. const alphabetical = (a, b) => fileCollator.compare(a.name, b.name)
  316. function* sortedItems(folder) {
  317. yield folder._id
  318. const folders = [...folder.folders].sort(alphabetical)
  319. for (const subfolder of folders) {
  320. for (const id of sortedItems(subfolder)) {
  321. yield id
  322. }
  323. }
  324. const files = [...folder.docs, ...folder.fileRefs].sort(alphabetical)
  325. for (const file of files) {
  326. yield file._id
  327. }
  328. }