file-tree-selectable.tsx 11 KB

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