file-tree-selectable.js 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import React, { createContext, useContext, useReducer, useEffect } from 'react'
  2. import PropTypes from 'prop-types'
  3. import classNames from 'classnames'
  4. import { findInTree } from '../util/find-in-tree'
  5. import { useFileTreeMutable } from './file-tree-mutable'
  6. const FileTreeSelectableContext = createContext(new Set())
  7. const ACTION_TYPES = {
  8. SELECT: 'SELECT',
  9. MULTI_SELECT: 'MULTI_SELECT',
  10. UNSELECT: 'UNSELECT'
  11. }
  12. function fileTreeSelectableReadWriteReducer(selectedEntityIds, action) {
  13. switch (action.type) {
  14. case ACTION_TYPES.SELECT: {
  15. // reset selection
  16. return new Set([action.id])
  17. }
  18. case ACTION_TYPES.MULTI_SELECT: {
  19. const selectedEntityIdsCopy = new Set(selectedEntityIds)
  20. if (selectedEntityIdsCopy.has(action.id)) {
  21. // entity already selected
  22. if (selectedEntityIdsCopy.size > 1) {
  23. // entity already multi-selected; remove from set
  24. selectedEntityIdsCopy.delete(action.id)
  25. }
  26. } else {
  27. // entity not selected: add to set
  28. selectedEntityIdsCopy.add(action.id)
  29. }
  30. return selectedEntityIdsCopy
  31. }
  32. case ACTION_TYPES.UNSELECT: {
  33. const selectedEntityIdsCopy = new Set(selectedEntityIds)
  34. selectedEntityIdsCopy.delete(action.id)
  35. return selectedEntityIdsCopy
  36. }
  37. default:
  38. throw new Error(`Unknown selectable action type: ${action.type}`)
  39. }
  40. }
  41. function fileTreeSelectableReadOnlyReducer(selectedEntityIds, action) {
  42. switch (action.type) {
  43. case ACTION_TYPES.SELECT:
  44. return new Set([action.id])
  45. case ACTION_TYPES.MULTI_SELECT:
  46. case ACTION_TYPES.UNSELECT:
  47. return selectedEntityIds
  48. default:
  49. throw new Error(`Unknown selectable action type: ${action.type}`)
  50. }
  51. }
  52. export function FileTreeSelectableProvider({
  53. hasWritePermissions,
  54. initialSelectedEntityId = null,
  55. onSelect,
  56. children
  57. }) {
  58. const [selectedEntityIds, dispatch] = useReducer(
  59. hasWritePermissions
  60. ? fileTreeSelectableReadWriteReducer
  61. : fileTreeSelectableReadOnlyReducer,
  62. initialSelectedEntityId ? new Set([initialSelectedEntityId]) : new Set()
  63. )
  64. const { fileTreeData } = useFileTreeMutable()
  65. // calls `onSelect` on entities selection
  66. useEffect(() => {
  67. const selectedEntities = Array.from(selectedEntityIds).map(id =>
  68. findInTree(fileTreeData, id)
  69. )
  70. onSelect(selectedEntities)
  71. // eslint-disable-next-line react-hooks/exhaustive-deps
  72. }, [fileTreeData, selectedEntityIds])
  73. useEffect(() => {
  74. // listen for `editor.openDoc` and selected that doc
  75. function handleOpenDoc(ev) {
  76. dispatch({ type: ACTION_TYPES.SELECT, id: ev.detail })
  77. }
  78. window.addEventListener('editor.openDoc', handleOpenDoc)
  79. return () => window.removeEventListener('editor.openDoc', handleOpenDoc)
  80. }, [])
  81. return (
  82. <FileTreeSelectableContext.Provider value={{ selectedEntityIds, dispatch }}>
  83. {children}
  84. </FileTreeSelectableContext.Provider>
  85. )
  86. }
  87. FileTreeSelectableProvider.propTypes = {
  88. hasWritePermissions: PropTypes.bool.isRequired,
  89. initialSelectedEntityId: PropTypes.string,
  90. onSelect: PropTypes.func.isRequired,
  91. children: PropTypes.oneOfType([
  92. PropTypes.arrayOf(PropTypes.node),
  93. PropTypes.node
  94. ]).isRequired
  95. }
  96. export function useSelectableEntity(id) {
  97. const { selectedEntityIds, dispatch } = useContext(FileTreeSelectableContext)
  98. const isSelected = selectedEntityIds.has(id)
  99. function selectOrMultiSelectEntity(ev) {
  100. const isMultiSelect = ev.ctrlKey || ev.metaKey
  101. const actionType = isMultiSelect
  102. ? ACTION_TYPES.MULTI_SELECT
  103. : ACTION_TYPES.SELECT
  104. dispatch({ type: actionType, id })
  105. }
  106. function handleClick(ev) {
  107. selectOrMultiSelectEntity(ev)
  108. }
  109. function handleKeyPress(ev) {
  110. if (ev.key === 'Enter' || ev.key === ' ') {
  111. selectOrMultiSelectEntity(ev)
  112. }
  113. }
  114. function handleContextMenu(ev) {
  115. // make sure the right-clicked entity gets selected
  116. if (!selectedEntityIds.has(id)) selectOrMultiSelectEntity(ev)
  117. }
  118. return {
  119. isSelected,
  120. props: {
  121. className: classNames({ selected: isSelected }),
  122. 'aria-selected': isSelected,
  123. onClick: handleClick,
  124. onContextMenu: handleContextMenu,
  125. onKeyPress: handleKeyPress
  126. }
  127. }
  128. }
  129. export function useFileTreeSelectable() {
  130. const { selectedEntityIds, dispatch } = useContext(FileTreeSelectableContext)
  131. function select(id) {
  132. dispatch({ type: ACTION_TYPES.SELECT, id })
  133. }
  134. function unselect(id) {
  135. dispatch({ type: ACTION_TYPES.UNSELECT, id })
  136. }
  137. return {
  138. selectedEntityIds,
  139. select,
  140. unselect
  141. }
  142. }