file-tree-selectable.tsx 11 KB

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