file-tree-selectable.tsx 11 KB

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