file-tree-selectable.tsx 11 KB

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