| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414 |
- import {
- createContext,
- useCallback,
- useContext,
- useReducer,
- useEffect,
- useMemo,
- useState,
- FC,
- } from 'react'
- import classNames from 'classnames'
- import _ from 'lodash'
- import { findInTree } from '../util/find-in-tree'
- import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
- import { useProjectContext } from '../../../shared/context/project-context'
- import { useLayoutContext } from '../../../shared/context/layout-context'
- import usePersistedState from '../../../shared/hooks/use-persisted-state'
- import usePreviousValue from '../../../shared/hooks/use-previous-value'
- import { useFileTreeMainContext } from '@/features/file-tree/contexts/file-tree-main'
- import { FindResult } from '@/features/file-tree/util/path'
- import { fileCollator } from '@/features/file-tree/util/file-collator'
- import { Folder } from '../../../../../types/folder'
- import { FileTreeEntity } from '../../../../../types/file-tree-entity'
- const FileTreeSelectableContext = createContext<
- | {
- selectedEntityIds: Set<string>
- isRootFolderSelected: boolean
- selectOrMultiSelectEntity: (
- id: string | string[],
- multiple?: boolean
- ) => void
- setIsRootFolderSelected: (value: boolean) => void
- selectedEntityParentIds: Set<string>
- select: (id: string | string[]) => void
- unselect: (id: string) => void
- }
- | undefined
- >(undefined)
- /* eslint-disable no-unused-vars */
- enum ACTION_TYPES {
- SELECT = 'SELECT',
- MULTI_SELECT = 'MULTI_SELECT',
- UNSELECT = 'UNSELECT',
- }
- /* eslint-enable no-unused-vars */
- type Action =
- | {
- type: ACTION_TYPES.SELECT
- id: string
- }
- | {
- type: ACTION_TYPES.MULTI_SELECT
- id: string
- }
- | {
- type: ACTION_TYPES.UNSELECT
- id: string
- }
- function fileTreeSelectableReadWriteReducer(
- selectedEntityIds: Set<string>,
- action: Action
- ) {
- switch (action.type) {
- case ACTION_TYPES.SELECT: {
- // reset selection
- return new Set(Array.isArray(action.id) ? action.id : [action.id])
- }
- case ACTION_TYPES.MULTI_SELECT: {
- const selectedEntityIdsCopy = new Set(selectedEntityIds)
- if (selectedEntityIdsCopy.has(action.id)) {
- // entity already selected
- if (selectedEntityIdsCopy.size > 1) {
- // entity already multi-selected; remove from set
- selectedEntityIdsCopy.delete(action.id)
- }
- } else {
- // entity not selected: add to set
- selectedEntityIdsCopy.add(action.id)
- }
- return selectedEntityIdsCopy
- }
- case ACTION_TYPES.UNSELECT: {
- const selectedEntityIdsCopy = new Set(selectedEntityIds)
- selectedEntityIdsCopy.delete(action.id)
- return selectedEntityIdsCopy
- }
- default:
- throw new Error(
- `Unknown selectable action type: ${(action as Action).type}`
- )
- }
- }
- function fileTreeSelectableReadOnlyReducer(
- selectedEntityIds: Set<string>,
- action: Action
- ) {
- switch (action.type) {
- case ACTION_TYPES.SELECT:
- return new Set([action.id])
- case ACTION_TYPES.MULTI_SELECT:
- case ACTION_TYPES.UNSELECT:
- return selectedEntityIds
- default:
- throw new Error(
- `Unknown selectable action type: ${(action as Action).type}`
- )
- }
- }
- export const FileTreeSelectableProvider: FC<{
- onSelect: (value: FindResult[]) => void
- }> = ({ onSelect, children }) => {
- const { _id: projectId, rootDocId } = useProjectContext()
- const [initialSelectedEntityId] = usePersistedState(
- `doc.open_id.${projectId}`,
- rootDocId
- )
- const { fileTreeData, setSelectedEntities, fileTreeReadOnly } =
- useFileTreeData()
- const [isRootFolderSelected, setIsRootFolderSelected] = useState(false)
- const [selectedEntityIds, dispatch] = useReducer(
- fileTreeReadOnly
- ? fileTreeSelectableReadOnlyReducer
- : fileTreeSelectableReadWriteReducer,
- null,
- () => {
- if (!initialSelectedEntityId) return new Set<string>()
- // the entity with id=initialSelectedEntityId might not exist in the tree
- // anymore. This checks that it exists before initialising the reducer
- // with the id.
- if (findInTree(fileTreeData, initialSelectedEntityId))
- return new Set([initialSelectedEntityId])
- // the entity doesn't exist anymore; don't select any files
- return new Set<string>()
- }
- )
- const [selectedEntityParentIds, setSelectedEntityParentIds] = useState<
- Set<string>
- >(new Set())
- // fills `selectedEntityParentIds` set
- useEffect(() => {
- const ids = new Set<string>()
- selectedEntityIds.forEach(id => {
- const found = findInTree(fileTreeData, id)
- if (found) {
- found.path.forEach((pathItem: any) => ids.add(pathItem))
- }
- })
- setSelectedEntityParentIds(ids)
- }, [fileTreeData, selectedEntityIds])
- // calls `onSelect` on entities selection
- const previousSelectedEntityIds = usePreviousValue(selectedEntityIds)
- useEffect(() => {
- if (_.isEqual(selectedEntityIds, previousSelectedEntityIds)) {
- return
- }
- const _selectedEntities = Array.from(selectedEntityIds)
- .map(id => findInTree(fileTreeData, id))
- .filter(Boolean)
- onSelect(_selectedEntities)
- setSelectedEntities(_selectedEntities)
- }, [
- fileTreeData,
- selectedEntityIds,
- previousSelectedEntityIds,
- onSelect,
- setSelectedEntities,
- ])
- useEffect(() => {
- // listen for `editor.openDoc` and selected that doc
- function handleOpenDoc(ev: any) {
- const found = findInTree(fileTreeData, ev.detail)
- if (!found) return
- dispatch({ type: ACTION_TYPES.SELECT, id: found.entity._id })
- }
- window.addEventListener('editor.openDoc', handleOpenDoc)
- return () => window.removeEventListener('editor.openDoc', handleOpenDoc)
- }, [fileTreeData])
- const select = useCallback(id => {
- dispatch({ type: ACTION_TYPES.SELECT, id })
- }, [])
- const unselect = useCallback(id => {
- dispatch({ type: ACTION_TYPES.UNSELECT, id })
- }, [])
- const selectOrMultiSelectEntity = useCallback((id, isMultiSelect) => {
- const actionType = isMultiSelect
- ? ACTION_TYPES.MULTI_SELECT
- : ACTION_TYPES.SELECT
- dispatch({ type: actionType, id })
- }, [])
- // TODO: wrap in useMemo
- const value = {
- selectedEntityIds,
- selectedEntityParentIds,
- select,
- unselect,
- selectOrMultiSelectEntity,
- isRootFolderSelected,
- setIsRootFolderSelected,
- }
- return (
- <FileTreeSelectableContext.Provider value={value}>
- {children}
- </FileTreeSelectableContext.Provider>
- )
- }
- const isMac = /Mac/.test(window.navigator?.platform)
- export function useSelectableEntity(id: string, type: string) {
- const { view, setView } = useLayoutContext()
- const { setContextMenuCoords } = useFileTreeMainContext()
- const { fileTreeData } = useFileTreeData()
- const {
- selectedEntityIds,
- selectOrMultiSelectEntity,
- isRootFolderSelected,
- setIsRootFolderSelected,
- } = useFileTreeSelectable()
- const isSelected = selectedEntityIds.has(id)
- const buildSelectedRange = useCallback(
- id => {
- const selected = []
- let started = false
- for (const itemId of sortedItems(fileTreeData)) {
- if (itemId === id) {
- selected.push(itemId)
- if (started) {
- break
- } else {
- started = true
- }
- } else if (selectedEntityIds.has(itemId)) {
- // TODO: should only look at latest ("main") selected item
- selected.push(itemId)
- if (started) {
- break
- } else {
- started = true
- }
- } else if (started) {
- selected.push(itemId)
- }
- }
- return selected
- },
- [fileTreeData, selectedEntityIds]
- )
- const chooseView = useCallback(() => {
- for (const id of selectedEntityIds) {
- const selectedEntity = findInTree(fileTreeData, id)
- if (selectedEntity.type === 'doc') {
- return 'editor'
- }
- if (selectedEntity.type === 'fileRef') {
- return 'file'
- }
- if (selectedEntity.type === 'folder') {
- return view
- }
- }
- return null
- }, [fileTreeData, selectedEntityIds, view])
- const handleEvent = useCallback(
- ev => {
- ev.stopPropagation()
- // use Command (macOS) or Ctrl (other OS) to select multiple items,
- // as long as the root folder wasn't selected
- const multiSelect =
- !isRootFolderSelected && (isMac ? ev.metaKey : ev.ctrlKey)
- setIsRootFolderSelected(false)
- if (ev.shiftKey) {
- // use Shift to select a range of items
- selectOrMultiSelectEntity(buildSelectedRange(id))
- } else {
- selectOrMultiSelectEntity(id, multiSelect)
- }
- if (type === 'file') {
- setView('file')
- } else if (type === 'doc') {
- setView('editor')
- } else if (type === 'folder') {
- setView(chooseView())
- }
- },
- [
- id,
- isRootFolderSelected,
- setIsRootFolderSelected,
- selectOrMultiSelectEntity,
- setView,
- type,
- buildSelectedRange,
- chooseView,
- ]
- )
- const handleClick = useCallback(
- ev => {
- handleEvent(ev)
- if (!ev.ctrlKey && !ev.metaKey) {
- setContextMenuCoords(null)
- }
- },
- [handleEvent, setContextMenuCoords]
- )
- const handleKeyPress = useCallback(
- ev => {
- if (ev.key === 'Enter' || ev.key === ' ') {
- handleEvent(ev)
- }
- },
- [handleEvent]
- )
- const handleContextMenu = useCallback(
- ev => {
- // make sure the right-clicked entity gets selected
- if (!selectedEntityIds.has(id)) {
- handleEvent(ev)
- }
- },
- [id, handleEvent, selectedEntityIds]
- )
- const isVisuallySelected =
- !isRootFolderSelected && isSelected && view !== 'pdf'
- const props = useMemo(
- () => ({
- className: classNames({ selected: isVisuallySelected }),
- 'aria-selected': isVisuallySelected,
- onClick: handleClick,
- onContextMenu: handleContextMenu,
- onKeyPress: handleKeyPress,
- }),
- [handleClick, handleContextMenu, handleKeyPress, isVisuallySelected]
- )
- return { isSelected, props }
- }
- export function useFileTreeSelectable() {
- const context = useContext(FileTreeSelectableContext)
- if (!context) {
- throw new Error(
- `useFileTreeSelectable is only available inside FileTreeSelectableProvider`
- )
- }
- return context
- }
- const alphabetical = (a: FileTreeEntity, b: FileTreeEntity) =>
- fileCollator.compare(a.name, b.name)
- function* sortedItems(folder: Folder): Generator<string> {
- yield folder._id
- const folders = [...folder.folders].sort(alphabetical)
- for (const subfolder of folders) {
- for (const id of sortedItems(subfolder)) {
- yield id
- }
- }
- const files = [...folder.docs, ...folder.fileRefs].sort(alphabetical)
- for (const file of files) {
- yield file._id
- }
- }
|