diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 8ceb8fbff7..77bbb1dd38 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -721,6 +721,8 @@ const ProjectController = { const allowedImageNames = ProjectHelper.getAllowedImagesForUser( sessionUser ) + const wantsOldFileTreeUI = + req.query && req.query.new_file_tree_ui === 'false' AuthorizationManager.getPrivilegeLevelForProject( userId, projectId, @@ -832,7 +834,8 @@ const ProjectController = { wsUrl, showSupport: Features.hasFeature('support'), showNewLogsUI: req.query && req.query.new_logs_ui === 'true', - showNewChatUI: user.betaProgram && !wantsOldChatUI + showNewChatUI: user.betaProgram && !wantsOldChatUI, + showReactFileTree: user.alphaProgram && !wantsOldFileTreeUI }) timer.done() } diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index 9fe51ed2ac..473825b426 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -92,7 +92,10 @@ block content custom-toggler-msg-when-closed=hasFeature('custom-togglers') ? translate("tooltip_show_filetree") : false ) .ui-layout-west - include ./editor/file-tree + if showReactFileTree + include ./editor/file-tree-react + else + include ./editor/file-tree include ./editor/history/fileTreeV2 .ui-layout-center @@ -183,6 +186,7 @@ block content //- used in public/js/libs/sharejs.js window.useShareJsHash = true window.wsRetryHandshake = #{settings.wsRetryHandshake} + window.showReactFileTree = "!{showReactFileTree}" === 'true' - if (settings.overleaf != null) script(type='text/javascript'). diff --git a/services/web/app/views/project/editor/file-tree-react.pug b/services/web/app/views/project/editor/file-tree-react.pug new file mode 100644 index 0000000000..feb7b04bcc --- /dev/null +++ b/services/web/app/views/project/editor/file-tree-react.pug @@ -0,0 +1,38 @@ +aside.editor-sidebar.full-size( + ng-show="ui.view != 'history' || !history.isV2" + vertical-resizable-panes="outline-resizer" + vertical-resizable-panes-toggled-externally-on="outline-toggled" + vertical-resizable-panes-min-size="32" + vertical-resizable-panes-max-size="75%" + vertical-resizable-panes-resize-on="left-pane-resize-all" +) + + .file-tree( + ng-controller="ReactFileTreeController" + vertical-resizable-top + ) + file-tree-root( + project-id="projectId" + root-folder="rootFolder" + root-doc-id="rootDocId" + has-write-permissions="hasWritePermissions" + on-select="onSelect" + on-init="onInit" + ) + + div(ng-controller="FileTreeController") + include ./new-file-modal + + .outline-container( + vertical-resizable-bottom + ng-controller="OutlineController" + ) + outline-pane( + is-tex-file="isTexFile" + outline="outline" + project-id="project_id" + jump-to-line="jumpToLine" + on-toggle="onToggle" + event-tracking="eventTracking" + highlighted-line="highlightedLine" + ) diff --git a/services/web/app/views/project/editor/header.pug b/services/web/app/views/project/editor/header.pug index f43e6b3ec2..8c0a8f8523 100644 --- a/services/web/app/views/project/editor/header.pug +++ b/services/web/app/views/project/editor/header.pug @@ -28,7 +28,7 @@ header.toolbar.toolbar-header.toolbar-with-labels( span(ng-controller="PdfViewToggleController") a.btn.btn-full-height.btn-full-height-no-border( href, - ng-show="ui.pdfLayout == 'flat' && fileTreeClosed", + ng-show="ui.pdfLayout == 'flat'", tooltip="PDF", tooltip-placement="bottom", tooltip-append-to-body="true", diff --git a/services/web/frontend/extracted-translation-keys.json b/services/web/frontend/extracted-translation-keys.json index c6eced6c9c..037760b973 100644 --- a/services/web/frontend/extracted-translation-keys.json +++ b/services/web/frontend/extracted-translation-keys.json @@ -74,5 +74,22 @@ "view_warnings", "we_cant_find_any_sections_or_subsections_in_this_file", "your_message", - "your_project_has_errors" + "your_project_has_errors", + "recompile_from_scratch", + "run_syntax_check_now", + "toggle_compile_options_menu", + "sure_you_want_to_delete", + "delete", + "deleting", + "cancel", + "new_file", + "new_folder", + "create", + "creating", + "upload", + "rename", + "n_items", + "please_refresh", + "generic_something_went_wrong", + "refresh" ] diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.js b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.js new file mode 100644 index 0000000000..2987579420 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-context-menu.js @@ -0,0 +1,53 @@ +import React, { useContext } from 'react' +import ReactDOM from 'react-dom' + +import { Dropdown } from 'react-bootstrap' +import { FileTreeMainContext } from '../contexts/file-tree-main' + +import FileTreeItemMenuItems from './file-tree-item/file-tree-item-menu-items' + +function FileTreeContextMenu() { + const { + hasWritePermissions, + contextMenuCoords, + setContextMenuCoords + } = useContext(FileTreeMainContext) + + if (!hasWritePermissions || !contextMenuCoords) return null + + function close() { + // reset context menu + setContextMenuCoords(null) + } + + function handleToggle(wantOpen) { + if (!wantOpen) close() + } + + function handleClick() { + handleToggle(false) + } + + return ReactDOM.createPortal( + + + + + + , + document.querySelector('body') + ) +} + +// fake component required as Dropdowns require a Toggle, even tho we don't want +// one for the context menu +const FakeDropDownToggle = React.forwardRef((props, ref) => { + return null +}) + +export default FileTreeContextMenu diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-context.js b/services/web/frontend/js/features/file-tree/components/file-tree-context.js new file mode 100644 index 0000000000..4f0e46bfc6 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-context.js @@ -0,0 +1,55 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import { FileTreeMainProvider } from '../contexts/file-tree-main' +import { FileTreeActionableProvider } from '../contexts/file-tree-actionable' +import { FileTreeMutableProvider } from '../contexts/file-tree-mutable' +import { FileTreeSelectableProvider } from '../contexts/file-tree-selectable' +import { FileTreeDraggableProvider } from '../contexts/file-tree-draggable' + +// renders all the contexts needed for the file tree: +// FileTreeMain: generic store +// FileTreeActionable: global UI state for actions (rename, delete, etc.) +// FileTreeMutable: provides entities mutation operations +// FileTreeSelectable: handles selection and multi-selection +function FileTreeContext({ + projectId, + rootFolder, + hasWritePermissions, + initialSelectedEntityId, + onSelect, + children +}) { + return ( + + + + + {children} + + + + + ) +} + +FileTreeContext.propTypes = { + projectId: PropTypes.string.isRequired, + rootFolder: PropTypes.array.isRequired, + hasWritePermissions: PropTypes.bool.isRequired, + initialSelectedEntityId: PropTypes.string, + onSelect: PropTypes.func.isRequired, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]).isRequired +} + +export default FileTreeContext diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-doc.js b/services/web/frontend/js/features/file-tree/components/file-tree-doc.js new file mode 100644 index 0000000000..78a88b1a47 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-doc.js @@ -0,0 +1,58 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useTranslation } from 'react-i18next' + +import Icon from '../../../shared/components/icon' +import iconTypeFromName from '../util/icon-type-from-name' + +import { useSelectableEntity } from '../contexts/file-tree-selectable' + +import FileTreeItemInner from './file-tree-item/file-tree-item-inner' + +function FileTreeDoc({ name, id, isLinkedFile }) { + const { t } = useTranslation() + + const { isSelected, props: selectableEntityProps } = useSelectableEntity(id) + + const icons = ( + <> + + {isLinkedFile ? ( + + ) : null} + + + ) + return ( +
  • + +
  • + ) +} + +FileTreeDoc.propTypes = { + name: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + isLinkedFile: PropTypes.bool +} + +export default FileTreeDoc diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-draggable-preview-layer.js b/services/web/frontend/js/features/file-tree/components/file-tree-draggable-preview-layer.js new file mode 100644 index 0000000000..7b403c19a4 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-draggable-preview-layer.js @@ -0,0 +1,72 @@ +import React, { useRef } from 'react' +import PropTypes from 'prop-types' +import { useDragLayer } from 'react-dnd' +import classNames from 'classnames' + +// a custom component rendered on top of a draggable area that renders the +// dragged item. See +// https://react-dnd.github.io/react-dnd/examples/drag-around/custom-drag-layer +// for more details. +// Also used to display a container border when hovered. +function FileTreeDraggablePreviewLayer({ isOver }) { + const { isDragging, item, clientOffset } = useDragLayer(monitor => ({ + isDragging: monitor.isDragging(), + item: monitor.getItem(), + clientOffset: monitor.getClientOffset() + })) + const ref = useRef() + + const containerOffset = ref.current + ? ref.current.getBoundingClientRect() + : null + + if (!isDragging) { + return null + } + + return ( +
    +
    + +
    +
    + ) +} + +FileTreeDraggablePreviewLayer.propTypes = { + isOver: PropTypes.bool.isRequired +} + +function DraggablePreviewItem({ title }) { + return
    {title}
    +} + +DraggablePreviewItem.propTypes = { + title: PropTypes.string.isRequired +} + +// makes the preview item follow the cursor. +// See https://react-dnd.github.io/react-dnd/docs/api/drag-layer-monitor +function getItemStyle(clientOffset, containerOffset) { + if (!containerOffset || !clientOffset) { + return { + display: 'none' + } + } + const { x: containerX, y: containerY } = containerOffset + const { x: clientX, y: clientY } = clientOffset + const posX = clientX - containerX - 15 + const posY = clientY - containerY - 15 + const transform = `translate(${posX}px, ${posY}px)` + return { + transform, + WebkitTransform: transform + } +} + +export default FileTreeDraggablePreviewLayer diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-error.js b/services/web/frontend/js/features/file-tree/components/file-tree-error.js new file mode 100644 index 0000000000..9a1022a314 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-error.js @@ -0,0 +1,23 @@ +import React from 'react' +import { Button } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' + +function FileTreeError() { + const { t } = useTranslation() + + function reload() { + location.reload() + } + + return ( +
    +

    {t('generic_something_went_wrong')}

    +

    {t('please_refresh')}

    + +
    + ) +} + +export default FileTreeError diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-folder-list.js b/services/web/frontend/js/features/file-tree/components/file-tree-folder-list.js new file mode 100644 index 0000000000..8ca90b9064 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-folder-list.js @@ -0,0 +1,61 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' + +import FileTreeDoc from './file-tree-doc' +import FileTreeFolder from './file-tree-folder' + +function FileTreeFolderList({ + folders, + docs, + files, + classes = {}, + dropRef = null +}) { + const docsAndFiles = [...docs, ...files].sort( + (one, two) => one.name.toLowerCase() > two.name.toLowerCase() + ) + + return ( + + ) +} + +FileTreeFolderList.propTypes = { + folders: PropTypes.array.isRequired, + docs: PropTypes.array.isRequired, + files: PropTypes.array.isRequired, + classes: PropTypes.exact({ + root: PropTypes.string + }), + dropRef: PropTypes.func +} + +export default FileTreeFolderList diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-folder.js b/services/web/frontend/js/features/file-tree/components/file-tree-folder.js new file mode 100644 index 0000000000..84f430cb4a --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-folder.js @@ -0,0 +1,78 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { useTranslation } from 'react-i18next' +import classNames from 'classnames' + +import Icon from '../../../shared/components/icon' +import { useSelectableEntity } from '../contexts/file-tree-selectable' +import { useDroppable } from '../contexts/file-tree-draggable' + +import FileTreeItemInner from './file-tree-item/file-tree-item-inner' +import FileTreeFolderList from './file-tree-folder-list' + +function FileTreeFolder({ name, id, folders, docs, files }) { + const { t } = useTranslation() + + const { isSelected, props: selectableEntityProps } = useSelectableEntity(id) + const [expanded, setExpanded] = useState(false) + + function handleExpandCollapseClick() { + setExpanded(!expanded) + } + + const { isOver: isOverRoot, dropRef: dropRefRoot } = useDroppable(id) + const { isOver: isOverList, dropRef: dropRefList } = useDroppable(id) + + const icons = ( + <> + + + + ) + + return ( + <> +
  • + +
  • + {expanded ? ( + + ) : null} + + ) +} + +FileTreeFolder.propTypes = { + name: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + folders: PropTypes.array.isRequired, + docs: PropTypes.array.isRequired, + files: PropTypes.array.isRequired +} + +export default FileTreeFolder diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.js b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.js new file mode 100644 index 0000000000..226a8cff2c --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.js @@ -0,0 +1,71 @@ +import React, { useContext, useEffect, createRef } from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed' + +import { FileTreeMainContext } from '../../contexts/file-tree-main' +import { useDraggable } from '../../contexts/file-tree-draggable' + +import FileTreeItemName from './file-tree-item-name' +import FileTreeItemMenu from './file-tree-item-menu' + +function FileTreeItemInner({ id, name, isSelected, icons }) { + const { hasWritePermissions, setContextMenuCoords } = useContext( + FileTreeMainContext + ) + + const hasMenu = hasWritePermissions && isSelected + + const { isDragging, dragRef } = useDraggable(id) + + const itemRef = createRef() + + useEffect( + () => { + if (isSelected && itemRef.current) { + scrollIntoViewIfNeeded(itemRef.current, { + scrollMode: 'if-needed' + }) + } + }, + [isSelected, itemRef] + ) + + function handleContextMenu(ev) { + ev.preventDefault() + setContextMenuCoords({ + top: ev.pageY, + left: ev.pageX + }) + } + + return ( +
    +
    + {icons} + + {hasMenu ? : null} +
    +
    + ) +} + +FileTreeItemInner.propTypes = { + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + isSelected: PropTypes.bool.isRequired, + icons: PropTypes.node +} + +export default FileTreeItemInner diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.js b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.js new file mode 100644 index 0000000000..bf22dc2525 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.js @@ -0,0 +1,41 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' + +import { MenuItem } from 'react-bootstrap' +import { useFileTreeActionable } from '../../contexts/file-tree-actionable' + +function FileTreeItemMenuItems() { + const { t } = useTranslation() + + const { + canRename, + canDelete, + canCreate, + startRenaming, + startDeleting, + startCreatingFolder, + startCreatingDocOrFile, + startUploadingDocOrFile + } = useFileTreeActionable() + + return ( + <> + {canRename ? ( + {t('rename')} + ) : null} + {canDelete ? ( + {t('delete')} + ) : null} + {canCreate ? ( + <> + + {t('new_file')} + {t('new_folder')} + {t('upload')} + + ) : null} + + ) +} + +export default FileTreeItemMenuItems diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu.js b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu.js new file mode 100644 index 0000000000..312e6ec800 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu.js @@ -0,0 +1,50 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { useTranslation } from 'react-i18next' +import withoutPropagation from '../../../../infrastructure/without-propagation' + +import { Dropdown } from 'react-bootstrap' +import Icon from '../../../../shared/components/icon' + +import FileTreeItemMenuItems from './file-tree-item-menu-items' + +function FileTreeItemMenu({ id }) { + const { t } = useTranslation() + + const [dropdownOpen, setDropdownOpen] = useState(false) + + function handleToggle(wantOpen) { + setDropdownOpen(wantOpen) + } + + function handleClick() { + handleToggle(false) + } + + return ( + + + + + + + + + ) +} + +FileTreeItemMenu.propTypes = { + id: PropTypes.string.isRequired +} + +export default FileTreeItemMenu diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name.js b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name.js new file mode 100644 index 0000000000..76669d4f1e --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name.js @@ -0,0 +1,129 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' + +import { useRefWithAutoFocus } from '../../../../infrastructure/auto-focus' + +import { useFileTreeActionable } from '../../contexts/file-tree-actionable' + +function FileTreeItemName({ name, isSelected }) { + const { + isRenaming, + startRenaming, + finishRenaming, + cancel + } = useFileTreeActionable() + + const isRenamingEntity = isRenaming && isSelected + + if (isRenamingEntity) { + return ( + + ) + } + return ( + + ) +} + +FileTreeItemName.propTypes = { + name: PropTypes.string.isRequired, + isSelected: PropTypes.bool.isRequired +} + +function DisplayName({ name, isSelected, startRenaming }) { + const [clicksInSelectedCount, setClicksInSelectedCount] = useState(0) + + function onClick() { + setClicksInSelectedCount(clicksInSelectedCount + 1) + if (!isSelected) setClicksInSelectedCount(0) + } + + function onDoubleClick() { + // only start renaming if the button got two or more clicks while the item + // was selected. This is to prevent starting a rename on an unselected item. + // When the item is being selected it can trigger a loss of focus which + // causes UI problems. + if (clicksInSelectedCount < 2) return + startRenaming() + } + + return ( + + ) +} + +DisplayName.propTypes = { + name: PropTypes.string.isRequired, + startRenaming: PropTypes.func.isRequired, + isSelected: PropTypes.bool.isRequired +} + +function InputName({ initialValue, finishRenaming, cancel }) { + const [value, setValue] = useState(initialValue) + + // The react-bootstrap Dropdown re-focuses on the Dropdown.Toggle + // after a menu item is clicked, following the ARIA authoring practices: + // https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-links.html + // To improve UX, we want to auto-focus to the input when renaming. We use + // requestAnimationFrame to immediately move focus to the input after it is + // shown + const { autoFocusedRef } = useRefWithAutoFocus() + + function handleFocus(ev) { + const lastDotIndex = ev.target.value.lastIndexOf('.') + ev.target.setSelectionRange(0, lastDotIndex) + } + + function handleChange(ev) { + setValue(ev.target.value) + } + + function handleKeyDown(ev) { + if (ev.key === 'Enter') { + finishRenaming(value) + } + if (ev.key === 'Escape') { + cancel() + } + } + + function handleBlur() { + finishRenaming(value) + } + + return ( + + + + ) +} + +InputName.propTypes = { + initialValue: PropTypes.string.isRequired, + finishRenaming: PropTypes.func.isRequired, + cancel: PropTypes.func.isRequired +} + +export default FileTreeItemName diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-root.js b/services/web/frontend/js/features/file-tree/components/file-tree-root.js new file mode 100644 index 0000000000..fc590caa59 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-root.js @@ -0,0 +1,88 @@ +import React, { useEffect } from 'react' +import PropTypes from 'prop-types' + +import withErrorBoundary from '../../../infrastructure/error-boundary' +import FileTreeContext from './file-tree-context' +import FileTreeDraggablePreviewLayer from './file-tree-draggable-preview-layer' +import FileTreeFolderList from './file-tree-folder-list' +import FileTreeToolbar from './file-tree-toolbar' +import FileTreeModalDelete from './modals/file-tree-modal-delete' +import FileTreeModalCreateFolder from './modals/file-tree-modal-create-folder' +import FileTreeContextMenu from './file-tree-context-menu' +import FileTreeError from './file-tree-error' + +import { useFileTreeMutable } from '../contexts/file-tree-mutable' +import { useDroppable } from '../contexts/file-tree-draggable' + +import { useFileTreeAngularListener } from '../hooks/file-tree-angular-listener' +import { useFileTreeSocketListener } from '../hooks/file-tree-socket-listener' + +function FileTreeRoot({ + projectId, + rootFolder, + rootDocId, + hasWritePermissions, + onSelect, + onInit +}) { + const isReady = projectId && rootFolder + + useEffect( + () => { + if (isReady) onInit() + }, + [isReady] + ) + if (!isReady) return null + + return ( + + + +
    + +
    + + +
    + ) +} + +function FileTreeRootFolder() { + useFileTreeSocketListener() + useFileTreeAngularListener() + const { fileTreeData } = useFileTreeMutable() + + const { isOver, dropRef } = useDroppable(fileTreeData._id) + + return ( + <> + + + + ) +} + +FileTreeRoot.propTypes = { + projectId: PropTypes.string, + rootFolder: PropTypes.array, + rootDocId: PropTypes.string, + hasWritePermissions: PropTypes.bool.isRequired, + onSelect: PropTypes.func.isRequired, + onInit: PropTypes.func.isRequired +} + +export default withErrorBoundary(FileTreeRoot, FileTreeError) diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.js b/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.js new file mode 100644 index 0000000000..b6969a6d5d --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/file-tree-toolbar.js @@ -0,0 +1,100 @@ +import React, { useContext } from 'react' +import { useTranslation } from 'react-i18next' + +import Icon from '../../../shared/components/icon' +import TooltipButton from '../../../shared/components/tooltip-button' + +import { FileTreeMainContext } from '../contexts/file-tree-main' +import { useFileTreeActionable } from '../contexts/file-tree-actionable' + +function FileTreeToolbar() { + const { hasWritePermissions } = useContext(FileTreeMainContext) + + if (!hasWritePermissions) return null + + return ( +
    + + +
    + ) +} + +function FileTreeToolbarLeft() { + const { t } = useTranslation() + const { + canCreate, + startCreatingFolder, + startCreatingDocOrFile, + startUploadingDocOrFile + } = useFileTreeActionable() + + if (!canCreate) return null + + return ( + <> + + + + + + + + + + + ) +} + +function FileTreeToolbarRight() { + const { t } = useTranslation() + const { + canRename, + canDelete, + startRenaming, + startDeleting + } = useFileTreeActionable() + + if (!canRename && !canDelete) return null + + return ( +
    + {canRename ? ( + + + + ) : null} + {canDelete ? ( + + + + ) : null} +
    + ) +} + +export default FileTreeToolbar diff --git a/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.js b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.js new file mode 100644 index 0000000000..20444d123d --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-create-folder.js @@ -0,0 +1,105 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' + +import { Button, Modal } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { useRefWithAutoFocus } from '../../../../infrastructure/auto-focus' + +import { useFileTreeActionable } from '../../contexts/file-tree-actionable' + +function FileTreeModalCreateFolder() { + const { t } = useTranslation() + const [name, setName] = useState('') + + const { + isCreatingFolder, + inFlight, + finishCreatingFolder, + cancel, + error + } = useFileTreeActionable() + + if (!isCreatingFolder) return null // the modal will not be rendered; return early + + function handleHide() { + cancel() + } + + function handleCreateFolder() { + finishCreatingFolder(name) + } + + return ( + + + {t('new_folder')} + + + + + {error && ( +
    + {t('generic_something_went_wrong')} +
    + )} +
    + + + {inFlight ? ( + + ) : ( + <> + + + + )} + +
    + ) +} + +function InputName({ name, setName, handleCreateFolder }) { + const { autoFocusedRef } = useRefWithAutoFocus() + + function handleFocus(ev) { + ev.target.setSelectionRange(0, -1) + } + + function handleChange(ev) { + setName(ev.target.value) + } + + function handleKeyDown(ev) { + if (ev.key === 'Enter') { + handleCreateFolder() + } + } + + return ( + + ) +} + +InputName.propTypes = { + name: PropTypes.string.isRequired, + setName: PropTypes.func.isRequired, + handleCreateFolder: PropTypes.func.isRequired +} + +export default FileTreeModalCreateFolder diff --git a/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-delete.js b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-delete.js new file mode 100644 index 0000000000..6d86af7028 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-delete.js @@ -0,0 +1,67 @@ +import React from 'react' +import { Button, Modal } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' + +import { useFileTreeActionable } from '../../contexts/file-tree-actionable' + +function FileTreeModalDelete() { + const { t } = useTranslation() + + const { + isDeleting, + inFlight, + finishDeleting, + actionedEntities, + cancel, + error + } = useFileTreeActionable() + + if (!isDeleting) return null // the modal will not be rendered; return early + + function handleHide() { + cancel() + } + + function handleDelete() { + finishDeleting() + } + + return ( + + + {t('delete')} + + + +

    {t('sure_you_want_to_delete')}

    +
      + {actionedEntities.map(entity => ( +
    • {entity.name}
    • + ))} +
    + {error && ( +
    + {t('generic_something_went_wrong')} +
    + )} +
    + + + {inFlight ? ( + + ) : ( + <> + + + + )} + +
    + ) +} + +export default FileTreeModalDelete diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.js new file mode 100644 index 0000000000..238e0aa970 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.js @@ -0,0 +1,301 @@ +import React, { createContext, useReducer, useContext } from 'react' +import PropTypes from 'prop-types' + +import { mapSeries } from '../../../infrastructure/promise' + +import { + syncRename, + syncDelete, + syncMove, + syncCreateEntity +} from '../util/sync-mutation' +import { findInTreeOrThrow } from '../util/find-in-tree' + +import { FileTreeMainContext } from './file-tree-main' +import { useFileTreeMutable } from './file-tree-mutable' +import { useFileTreeSelectable } from './file-tree-selectable' + +const FileTreeActionableContext = createContext() + +const ACTION_TYPES = { + START_RENAME: 'START_RENAME', + START_DELETE: 'START_DELETE', + DELETING: 'DELETING', + START_CREATE_FOLDER: 'START_CREATE_FOLDER', + CREATING_FOLDER: 'CREATING_FOLDER', + CANCEL: 'CANCEL', + CLEAR: 'CLEAR', + ERROR: 'ERROR' +} + +const defaultState = { + isDeleting: false, + isRenaming: false, + isCreatingFolder: false, + inFlight: false, + actionedEntities: null, + error: null +} + +function fileTreeActionableReadOnlyReducer(state) { + return state +} + +function fileTreeActionableReducer(state, action) { + switch (action.type) { + case ACTION_TYPES.START_RENAME: + return { ...defaultState, isRenaming: true } + case ACTION_TYPES.START_DELETE: + return { + ...defaultState, + isDeleting: true, + actionedEntities: action.actionedEntities + } + case ACTION_TYPES.START_CREATE_FOLDER: + return { ...defaultState, isCreatingFolder: true } + case ACTION_TYPES.CREATING_FOLDER: + return { ...defaultState, isCreatingFolder: true, inFlight: true } + case ACTION_TYPES.DELETING: + // keep `actionedEntities` so the entities list remains displayed in the + // delete modal + return { + ...defaultState, + isDeleting: true, + inFlight: true, + actionedEntities: state.actionedEntities + } + case ACTION_TYPES.CLEAR: + return { ...defaultState } + case ACTION_TYPES.CANCEL: + if (state.inFlight) return state + return { ...defaultState } + case ACTION_TYPES.ERROR: + return { ...state, inFlight: false, error: action.error } + default: + throw new Error(`Unknown user action type: ${action.type}`) + } +} + +export function FileTreeActionableProvider({ hasWritePermissions, children }) { + const [state, dispatch] = useReducer( + hasWritePermissions + ? fileTreeActionableReducer + : fileTreeActionableReadOnlyReducer, + defaultState + ) + + return ( + + {children} + + ) +} + +FileTreeActionableProvider.propTypes = { + hasWritePermissions: PropTypes.bool.isRequired, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]).isRequired +} + +export function useFileTreeActionable() { + const { + isDeleting, + isRenaming, + isCreatingFolder, + inFlight, + error, + actionedEntities, + dispatch + } = useContext(FileTreeActionableContext) + const { projectId } = useContext(FileTreeMainContext) + const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeMutable() + const { selectedEntityIds } = useFileTreeSelectable() + + function startRenaming() { + dispatch({ type: ACTION_TYPES.START_RENAME }) + } + + // update the entity with the new name immediately in the tree, but revert to + // the old name if the sync fails + function finishRenaming(newName) { + dispatch({ type: ACTION_TYPES.CLEAR }) + const selectedEntityId = Array.from(selectedEntityIds)[0] + const found = findInTreeOrThrow(fileTreeData, selectedEntityId) + const oldName = found.entity.name + dispatchRename(selectedEntityId, newName) + return syncRename(projectId, found.type, found.entity._id, newName).catch( + error => { + dispatch({ type: ACTION_TYPES.ERROR, error }) + dispatchRename(selectedEntityId, oldName) + } + ) + } + + // init deletion flow (this will open the delete modal). + // A copy of the selected entities is set as `actionedEntities` so it is kept + // unchanged as the entities are deleted and the selection is updated + function startDeleting() { + const actionedEntities = Array.from(selectedEntityIds).map( + entityId => findInTreeOrThrow(fileTreeData, entityId).entity + ) + dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities }) + } + + // deletes entities in serie. Tree will be updated via the socket event + function finishDeleting() { + dispatch({ type: ACTION_TYPES.DELETING }) + + return mapSeries(Array.from(selectedEntityIds), id => { + const found = findInTreeOrThrow(fileTreeData, id) + return syncDelete(projectId, found.type, found.entity._id).catch( + error => { + // throw unless 404 + if (error.message !== '404') { + throw error + } + } + ) + }) + .then(() => { + dispatch({ type: ACTION_TYPES.CLEAR }) + }) + .catch(error => { + // set an error and allow user to retry + dispatch({ type: ACTION_TYPES.ERROR, error }) + }) + } + + // moves entities. Tree is updated immediately and data are sync'd after. + function finishMoving(toFolderId, draggedEntityIds) { + draggedEntityIds.forEach(selectedEntityId => { + dispatchMove(selectedEntityId, toFolderId) + }) + + return mapSeries(Array.from(draggedEntityIds), id => { + const found = findInTreeOrThrow(fileTreeData, id) + return syncMove(projectId, found.type, found.entity._id, toFolderId) + }) + } + + function startCreatingFolder() { + dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER }) + } + + function finishCreatingEntity(entity) { + const selectedEntityId = Array.from(selectedEntityIds)[0] + const found = findInTreeOrThrow(fileTreeData, selectedEntityId) + const parentFolderId = + found.type === 'folder' ? found.entity._id : found.parentFolderId + + return syncCreateEntity(projectId, parentFolderId, entity) + } + + function finishCreatingFolder(name) { + dispatch({ type: ACTION_TYPES.CREATING_FOLDER }) + return finishCreatingEntity({ endpoint: 'folder', name }) + .then(() => { + dispatch({ type: ACTION_TYPES.CLEAR }) + }) + .catch(error => { + dispatch({ type: ACTION_TYPES.ERROR, error }) + }) + } + + // bypass React file tree entirely; requesting the Angular new doc or file + // modal instead + function startCreatingDocOrFile() { + const selectedEntityId = Array.from(selectedEntityIds)[0] + const found = findInTreeOrThrow(fileTreeData, selectedEntityId) + const parentFolderId = + found.type === 'folder' ? found.entity._id : found.parentFolderId + window.dispatchEvent( + new CustomEvent('FileTreeReactBridge.openNewDocModal', { + detail: { + mode: 'doc', + parentFolderId + } + }) + ) + } + + function startUploadingDocOrFile() { + const selectedEntityId = Array.from(selectedEntityIds)[0] + const found = findInTreeOrThrow(fileTreeData, selectedEntityId) + const parentFolderId = + found.type === 'folder' ? found.entity._id : found.parentFolderId + window.dispatchEvent( + new CustomEvent('FileTreeReactBridge.openNewDocModal', { + detail: { + mode: 'upload', + parentFolderId + } + }) + ) + } + + function finishCreatingDocOrFile(entity) { + return finishCreatingEntity(entity) + .then(() => { + // dispatch FileTreeReactBridge event to update the Angular modal + window.dispatchEvent( + new CustomEvent('FileTreeReactBridge.openNewFileModal', { + detail: { + done: true + } + }) + ) + }) + .catch(error => { + // dispatch FileTreeReactBridge event to update the Angular modal with + // an error + window.dispatchEvent( + new CustomEvent('FileTreeReactBridge.openNewFileModal', { + detail: { + error: true, + data: error.message + } + }) + ) + }) + } + + function finishCreatingDoc(entity) { + entity.endpoint = 'doc' + return finishCreatingDocOrFile(entity) + } + + function finishCreatingLinkedFile(entity) { + entity.endpoint = 'linked_file' + return finishCreatingDocOrFile(entity) + } + + function cancel() { + dispatch({ type: ACTION_TYPES.CANCEL }) + } + + return { + canDelete: selectedEntityIds.size > 0, + canRename: selectedEntityIds.size === 1, + canCreate: selectedEntityIds.size === 1, + isDeleting, + isRenaming, + isCreatingFolder, + inFlight, + actionedEntities, + error, + startRenaming, + finishRenaming, + startDeleting, + finishDeleting, + finishMoving, + startCreatingFolder, + finishCreatingFolder, + startCreatingDocOrFile, + startUploadingDocOrFile, + finishCreatingDoc, + finishCreatingLinkedFile, + cancel + } +} diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.js new file mode 100644 index 0000000000..90e4d6081a --- /dev/null +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-draggable.js @@ -0,0 +1,136 @@ +import React, { useRef, useEffect } from 'react' +import PropTypes from 'prop-types' +import { useTranslation } from 'react-i18next' + +import { DndProvider, createDndContext, useDrag, useDrop } from 'react-dnd' +import { HTML5Backend, getEmptyImage } from 'react-dnd-html5-backend' + +import { + findAllInTreeOrThrow, + findAllFolderIdsInFolders +} from '../util/find-in-tree' + +import { useFileTreeActionable } from './file-tree-actionable' +import { useFileTreeMutable } from './file-tree-mutable' +import { useFileTreeSelectable } from '../contexts/file-tree-selectable' + +const DndContext = createDndContext(HTML5Backend) + +const DRAGGABLE_TYPE = 'ENTITY' + +export function FileTreeDraggableProvider({ children }) { + const DndManager = useRef(DndContext) + + return ( + + {children} + + ) +} + +FileTreeDraggableProvider.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]).isRequired +} + +export function useDraggable(draggedEntityId) { + const { t } = useTranslation() + + const { fileTreeData } = useFileTreeMutable() + const { selectedEntityIds } = useFileTreeSelectable() + + const item = { type: DRAGGABLE_TYPE } + const [{ isDragging }, dragRef, preview] = useDrag({ + item, // required, but overwritten by the return value of `begin` + begin: () => { + const draggedEntityIds = getDraggedEntityIds( + selectedEntityIds, + draggedEntityId + ) + const draggedItems = findAllInTreeOrThrow(fileTreeData, draggedEntityIds) + const title = getDraggedTitle(draggedItems, t) + const forbiddenFolderIds = getForbiddenFolderIds(draggedItems) + return { ...item, title, forbiddenFolderIds, draggedEntityIds } + }, + collect: monitor => ({ + isDragging: !!monitor.isDragging() + }) + }) + + // remove the automatic preview as we're using a custom preview via + // FileTreeDraggablePreviewLayer + useEffect( + () => { + preview(getEmptyImage()) + }, + [preview] + ) + + return { + dragRef, + isDragging + } +} + +export function useDroppable(droppedEntityId) { + const { finishMoving } = useFileTreeActionable() + + const [{ isOver }, dropRef] = useDrop({ + accept: DRAGGABLE_TYPE, + canDrop: (item, monitor) => { + const isOver = monitor.isOver({ shallow: true }) + if (!isOver) return false + if (item.forbiddenFolderIds.has(droppedEntityId)) return false + return true + }, + drop: (item, monitor) => { + const didDropInChild = monitor.didDrop() + if (didDropInChild) return + finishMoving(droppedEntityId, item.draggedEntityIds) + }, + collect: monitor => ({ + isOver: monitor.canDrop() + }) + }) + + return { + dropRef, + isOver + } +} + +// Get the list of dragged entity ids. If the dragged entity is one of the +// selected entities then all the selected entites are dragged entities, +// otherwise it's the dragged entity only. +function getDraggedEntityIds(selectedEntityIds, draggedEntityId) { + if (selectedEntityIds.size > 1 && selectedEntityIds.has(draggedEntityId)) { + // dragging the multi-selected entities + return new Set(selectedEntityIds) + } else { + // not dragging the selection; only the current item + return new Set([draggedEntityId]) + } +} + +// Get the draggable title. This is the name of the dragged entities if there's +// only one, otherwise it's the number of dragged entities. +function getDraggedTitle(draggedItems, t) { + if (draggedItems.size === 1) { + const draggedItem = Array.from(draggedItems)[0] + return draggedItem.entity.name + } + return t('n_items', { count: draggedItems.size }) +} + +// Get all children folder ids of any of the dragged items. +function getForbiddenFolderIds(draggedItems) { + const draggedFoldersArray = Array.from(draggedItems) + .filter(draggedItem => { + return draggedItem.type === 'folder' + }) + .map(draggedItem => draggedItem.entity) + const draggedFolders = new Set(draggedFoldersArray) + return findAllFolderIdsInFolders(draggedFolders) +} diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-main.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-main.js new file mode 100644 index 0000000000..ce0a16315a --- /dev/null +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-main.js @@ -0,0 +1,34 @@ +import React, { createContext, useState } from 'react' +import PropTypes from 'prop-types' + +export const FileTreeMainContext = createContext({}) + +export const FileTreeMainProvider = function({ + projectId, + hasWritePermissions, + children +}) { + const [contextMenuCoords, setContextMenuCoords] = useState() + + return ( + + {children} + + ) +} + +FileTreeMainProvider.propTypes = { + projectId: PropTypes.string.isRequired, + hasWritePermissions: PropTypes.bool.isRequired, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]).isRequired +} diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-mutable.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-mutable.js new file mode 100644 index 0000000000..929c8e2600 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-mutable.js @@ -0,0 +1,128 @@ +import React, { createContext, useReducer, useContext } from 'react' +import PropTypes from 'prop-types' + +import { + renameInTree, + deleteInTree, + moveInTree, + createEntityInTree +} from '../util/mutate-in-tree' + +const FileTreeMutableContext = createContext() + +const ACTION_TYPES = { + RENAME: 'RENAME', + DELETE: 'DELETE', + MOVE: 'MOVE', + CREATE_ENTITY: 'CREATE_ENTITY' +} + +function fileTreeMutableReducer({ fileTreeData }, action) { + switch (action.type) { + case ACTION_TYPES.RENAME: + return { + fileTreeData: renameInTree(fileTreeData, action.id, { + newName: action.newName + }) + } + case ACTION_TYPES.DELETE: + return { + fileTreeData: deleteInTree(fileTreeData, action.id) + } + case ACTION_TYPES.MOVE: + return { + fileTreeData: moveInTree( + fileTreeData, + action.entityId, + action.toFolderId + ) + } + case ACTION_TYPES.CREATE_ENTITY: + return { + fileTreeData: createEntityInTree( + fileTreeData, + action.parentFolderId, + action.entity + ) + } + default: + throw new Error(`Unknown mutable file tree action type: ${action.type}`) + } +} + +export const FileTreeMutableProvider = function({ rootFolder, children }) { + const [{ fileTreeData }, dispatch] = useReducer(fileTreeMutableReducer, { + fileTreeData: rootFolder[0] + }) + + return ( + + {children} + + ) +} + +FileTreeMutableProvider.propTypes = { + rootFolder: PropTypes.array.isRequired, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]).isRequired +} + +export function useFileTreeMutable() { + const { fileTreeData, dispatch } = useContext(FileTreeMutableContext) + + function dispatchCreateFolder(parentFolderId, entity) { + entity.type = 'folder' + dispatch({ + type: ACTION_TYPES.CREATE_ENTITY, + parentFolderId, + entity + }) + } + + function dispatchCreateDoc(parentFolderId, entity) { + entity.type = 'doc' + dispatch({ + type: ACTION_TYPES.CREATE_ENTITY, + parentFolderId, + entity + }) + } + + function dispatchCreateFile(parentFolderId, entity) { + entity.type = 'fileRef' + dispatch({ + type: ACTION_TYPES.CREATE_ENTITY, + parentFolderId, + entity + }) + } + + function dispatchRename(id, newName) { + dispatch({ + type: ACTION_TYPES.RENAME, + newName, + id + }) + } + + function dispatchDelete(id) { + dispatch({ type: ACTION_TYPES.DELETE, id }) + } + + function dispatchMove(entityId, toFolderId) { + dispatch({ type: ACTION_TYPES.MOVE, entityId, toFolderId }) + } + + return { + fileTreeData, + dispatchRename, + dispatchDelete, + dispatchMove, + dispatchCreateFolder, + dispatchCreateDoc, + dispatchCreateFile + } +} diff --git a/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js new file mode 100644 index 0000000000..620eda095c --- /dev/null +++ b/services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.js @@ -0,0 +1,167 @@ +import React, { createContext, useContext, useReducer, useEffect } from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' + +import { findInTree } from '../util/find-in-tree' +import { useFileTreeMutable } from './file-tree-mutable' + +const FileTreeSelectableContext = createContext(new Set()) + +const ACTION_TYPES = { + SELECT: 'SELECT', + MULTI_SELECT: 'MULTI_SELECT', + UNSELECT: 'UNSELECT' +} + +function fileTreeSelectableReadWriteReducer(selectedEntityIds, action) { + switch (action.type) { + case ACTION_TYPES.SELECT: { + // reset selection + return new Set([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.type}`) + } +} + +function fileTreeSelectableReadOnlyReducer(selectedEntityIds, 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.type}`) + } +} + +export function FileTreeSelectableProvider({ + hasWritePermissions, + initialSelectedEntityId = null, + onSelect, + children +}) { + const [selectedEntityIds, dispatch] = useReducer( + hasWritePermissions + ? fileTreeSelectableReadWriteReducer + : fileTreeSelectableReadOnlyReducer, + initialSelectedEntityId ? new Set([initialSelectedEntityId]) : new Set() + ) + const { fileTreeData } = useFileTreeMutable() + + // calls `onSelect` on entities selection + useEffect( + () => { + const selectedEntities = Array.from(selectedEntityIds).map(id => + findInTree(fileTreeData, id) + ) + onSelect(selectedEntities) + }, + [fileTreeData, selectedEntityIds] + ) + + useEffect(() => { + // listen for `editor.openDoc` and selected that doc + function handleOpenDoc(ev) { + dispatch({ type: ACTION_TYPES.SELECT, id: ev.detail }) + } + window.addEventListener('editor.openDoc', handleOpenDoc) + return () => window.removeEventListener('editor.openDoc', handleOpenDoc) + }, []) + + return ( + + {children} + + ) +} + +FileTreeSelectableProvider.propTypes = { + hasWritePermissions: PropTypes.bool.isRequired, + initialSelectedEntityId: PropTypes.string, + onSelect: PropTypes.func.isRequired, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]).isRequired +} + +export function useSelectableEntity(id) { + const { selectedEntityIds, dispatch } = useContext(FileTreeSelectableContext) + + const isSelected = selectedEntityIds.has(id) + + function selectOrMultiSelectEntity(ev) { + const isMultiSelect = ev.ctrlKey || ev.metaKey + const actionType = isMultiSelect + ? ACTION_TYPES.MULTI_SELECT + : ACTION_TYPES.SELECT + + dispatch({ type: actionType, id }) + } + + function handleClick(ev) { + selectOrMultiSelectEntity(ev) + } + + function handleKeyPress(ev) { + if (ev.key === 'Enter' || ev.key === ' ') { + selectOrMultiSelectEntity(ev) + } + } + + function handleContextMenu(ev) { + // make sure the right-clicked entity gets selected + if (!selectedEntityIds.has(id)) selectOrMultiSelectEntity(ev) + } + + return { + isSelected, + props: { + className: classNames({ selected: isSelected }), + 'aria-selected': isSelected, + onClick: handleClick, + onContextMenu: handleContextMenu, + onKeyPress: handleKeyPress + } + } +} + +export function useFileTreeSelectable() { + const { selectedEntityIds, dispatch } = useContext(FileTreeSelectableContext) + + function unselect(id) { + dispatch({ type: ACTION_TYPES.UNSELECT, id }) + } + + return { + selectedEntityIds, + unselect + } +} diff --git a/services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js b/services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js new file mode 100644 index 0000000000..e4122a5824 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/controllers/file-tree-controller.js @@ -0,0 +1,61 @@ +import App from '../../../base' +import { react2angular } from 'react2angular' + +import FileTreeRoot from '../components/file-tree-root' + +App.controller('ReactFileTreeController', function( + $scope, + $timeout, + ide, + eventTracking +) { + $scope.projectId = ide.project_id + $scope.rootFolder = null + $scope.rootDocId = null + $scope.hasWritePermissions = false + + $scope.$on('project:joined', () => { + $scope.rootFolder = $scope.project.rootFolder + $scope.rootDocId = $scope.project.rootDoc_id + $scope.$emit('file-tree:initialized') + }) + + $scope.$watch('permissions.write', hasWritePermissions => { + $scope.hasWritePermissions = hasWritePermissions + }) + + $scope.$watch('editor.open_doc_id', openDocId => { + window.dispatchEvent( + new CustomEvent('editor.openDoc', { detail: openDocId }) + ) + }) + + $scope.onInit = () => { + // HACK: resize the vertical pane on init after a 0ms timeout. We do not + // understand why this is necessary but without this the resized handle is + // stuck at the bottom. The vertical resize will soon be migrated to React + // so we accept to live with this hack for now. + $timeout(() => { + $scope.$emit('left-pane-resize-all') + }) + } + + $scope.onSelect = selectedEntities => { + if (selectedEntities.length === 1) { + const selectedEntity = selectedEntities[0] + $scope.$emit('entity:selected', { + id: selectedEntity.entity._id, + name: selectedEntity.entity.name, + type: selectedEntity.type + }) + + // in the react implementation there is no such concept as "1 + // multi-selected entity" so here we pass a count of 0 + $scope.$emit('entities:multiSelected', { count: 0 }) + } else if (selectedEntities.length > 1) { + $scope.$emit('entities:multiSelected', { count: selectedEntities.length }) + } + } +}) + +App.component('fileTreeRoot', react2angular(FileTreeRoot)) diff --git a/services/web/frontend/js/features/file-tree/hooks/file-tree-angular-listener.js b/services/web/frontend/js/features/file-tree/hooks/file-tree-angular-listener.js new file mode 100644 index 0000000000..c0d4462d46 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/hooks/file-tree-angular-listener.js @@ -0,0 +1,50 @@ +import { useEffect } from 'react' + +import { useFileTreeActionable } from '../contexts/file-tree-actionable' + +// A temporary hack to listen to events dispatched from the Angular and update +// the React file tree accordingly +export function useFileTreeAngularListener() { + const { + finishCreatingDoc, + finishCreatingLinkedFile + } = useFileTreeActionable() + + useEffect( + () => { + function handleDispatchCreateDoc(ev) { + const { ...doc } = ev.detail + finishCreatingDoc(doc) + } + window.addEventListener( + 'FileTreeReactBridge.createDoc', + handleDispatchCreateDoc + ) + return () => + window.removeEventListener( + 'FileTreeReactBridge.createDoc', + handleDispatchCreateDoc + ) + }, + [finishCreatingDoc] + ) + + useEffect( + () => { + function handleDispatchCreateLinkedFile(ev) { + const { ...file } = ev.detail + finishCreatingLinkedFile(file) + } + window.addEventListener( + 'FileTreeReactBridge.createLinkedFile', + handleDispatchCreateLinkedFile + ) + return () => + window.removeEventListener( + 'FileTreeReactBridge.createLinkedFile', + handleDispatchCreateLinkedFile + ) + }, + [finishCreatingLinkedFile] + ) +} diff --git a/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js b/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js new file mode 100644 index 0000000000..1eb2babd2b --- /dev/null +++ b/services/web/frontend/js/features/file-tree/hooks/file-tree-socket-listener.js @@ -0,0 +1,101 @@ +import { useEffect } from 'react' + +import { useFileTreeMutable } from '../contexts/file-tree-mutable' +import { useFileTreeSelectable } from '../contexts/file-tree-selectable' + +export function useFileTreeSocketListener() { + const { + dispatchRename, + dispatchDelete, + dispatchMove, + dispatchCreateFolder, + dispatchCreateDoc, + dispatchCreateFile + } = useFileTreeMutable() + const { unselect } = useFileTreeSelectable() + const socket = window._ide && window._ide.socket + + useEffect( + () => { + function handleDispatchRename(entityId, name) { + dispatchRename(entityId, name) + } + if (socket) socket.on('reciveEntityRename', handleDispatchRename) + return () => { + if (socket) + socket.removeListener('reciveEntityRename', handleDispatchRename) + } + }, + [socket, dispatchRename] + ) + + useEffect( + () => { + function handleDispatchDelete(entityId) { + unselect(entityId) + dispatchDelete(entityId) + } + if (socket) socket.on('removeEntity', handleDispatchDelete) + return () => { + if (socket) socket.removeListener('removeEntity', handleDispatchDelete) + } + }, + [socket, unselect, dispatchDelete] + ) + + useEffect( + () => { + function handleDispatchMove(entityId, toFolderId) { + dispatchMove(entityId, toFolderId) + } + if (socket) socket.on('reciveEntityMove', handleDispatchMove) + return () => { + if (socket) + socket.removeListener('reciveEntityMove', handleDispatchMove) + } + }, + [socket, dispatchMove] + ) + + useEffect( + () => { + function handleDispatchCreateFolder(parentFolderId, folder) { + dispatchCreateFolder(parentFolderId, folder) + } + if (socket) socket.on('reciveNewFolder', handleDispatchCreateFolder) + return () => { + if (socket) + socket.removeListener('reciveNewFolder', handleDispatchCreateFolder) + } + }, + [socket, dispatchCreateFolder] + ) + + useEffect( + () => { + function handleDispatchCreateDoc(parentFolderId, doc) { + dispatchCreateDoc(parentFolderId, doc) + } + if (socket) socket.on('reciveNewDoc', handleDispatchCreateDoc) + return () => { + if (socket) + socket.removeListener('reciveNewDoc', handleDispatchCreateDoc) + } + }, + [socket, dispatchCreateDoc] + ) + + useEffect( + () => { + function handleDispatchCreateFile(parentFolderId, file) { + dispatchCreateFile(parentFolderId, file) + } + if (socket) socket.on('reciveNewFile', handleDispatchCreateFile) + return () => { + if (socket) + socket.removeListener('reciveNewFile', handleDispatchCreateFile) + } + }, + [socket, dispatchCreateFile] + ) +} diff --git a/services/web/frontend/js/features/file-tree/util/find-in-tree.js b/services/web/frontend/js/features/file-tree/util/find-in-tree.js new file mode 100644 index 0000000000..d3e73b9852 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/util/find-in-tree.js @@ -0,0 +1,78 @@ +export function findInTreeOrThrow(tree, id) { + const found = findInTree(tree, id) + if (found) return found + throw new Error(`Entity not found with id=${id}`) +} + +export function findAllInTreeOrThrow(tree, ids) { + let list = new Set() + ids.forEach(id => { + list.add(findInTreeOrThrow(tree, id)) + }) + return list +} + +export function findAllFolderIdsInFolder(folder) { + const list = new Set([folder._id]) + for (const index in folder.folders) { + const subFolder = folder.folders[index] + findAllFolderIdsInFolder(subFolder).forEach(subFolderId => { + list.add(subFolderId) + }) + } + return list +} + +export function findAllFolderIdsInFolders(folders) { + let list = new Set() + folders.forEach(folder => { + findAllFolderIdsInFolder(folder).forEach(folderId => { + list.add(folderId) + }) + }) + return list +} + +export function findInTree(tree, id) { + for (const index in tree.docs) { + const doc = tree.docs[index] + if (doc._id === id) { + return { + entity: doc, + type: 'doc', + parent: tree.docs, + parentFolderId: tree._id, + index + } + } + } + + for (const index in tree.fileRefs) { + const file = tree.fileRefs[index] + if (file._id === id) { + return { + entity: file, + type: 'fileRef', + parent: tree.fileRefs, + parentFolderId: tree._id, + index + } + } + } + + for (const index in tree.folders) { + const folder = tree.folders[index] + if (folder._id === id) { + return { + entity: folder, + type: 'folder', + parent: tree.folders, + parentFolderId: tree._id, + index + } + } + const found = findInTree(folder, id) + if (found) return found + } + return null +} diff --git a/services/web/frontend/js/features/file-tree/util/icon-type-from-name.js b/services/web/frontend/js/features/file-tree/util/icon-type-from-name.js new file mode 100644 index 0000000000..7482ea03bb --- /dev/null +++ b/services/web/frontend/js/features/file-tree/util/icon-type-from-name.js @@ -0,0 +1,16 @@ +export default function iconTypeFromName(name) { + let ext = name.split('.').pop() + ext = ext ? ext.toLowerCase() : ext + + if (['png', 'pdf', 'jpg', 'jpeg', 'gif'].includes(ext)) { + return 'image' + } else if (['csv', 'xls', 'xlsx'].includes(ext)) { + return 'table' + } else if (['py', 'r'].includes(ext)) { + return 'file-text' + } else if (['bib'].includes(ext)) { + return 'book' + } else { + return 'file' + } +} diff --git a/services/web/frontend/js/features/file-tree/util/mutate-in-tree.js b/services/web/frontend/js/features/file-tree/util/mutate-in-tree.js new file mode 100644 index 0000000000..886ba65578 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/util/mutate-in-tree.js @@ -0,0 +1,74 @@ +import { findInTreeOrThrow } from './find-in-tree' + +export function renameInTree(tree, id, { newName }) { + return mutateInTree(tree, id, (parent, entity, index) => { + const newParent = Object.assign([], parent) + const newEntity = { + ...entity, + name: newName + } + newParent[index] = newEntity + return newParent + }) +} + +export function deleteInTree(tree, id) { + return mutateInTree(tree, id, (parent, entity, index) => { + return [...parent.slice(0, index), ...parent.slice(index + 1)] + }) +} + +export function moveInTree(tree, entityId, toFolderId) { + const found = findInTreeOrThrow(tree, entityId) + if (found.parentFolderId === toFolderId) { + // nothing to do (the entity was probably already moved) + return tree + } + const newFileTreeData = deleteInTree(tree, entityId) + return createEntityInTree(newFileTreeData, toFolderId, { + ...found.entity, + type: found.type + }) +} + +export function createEntityInTree(tree, parentFolderId, newEntityData) { + const { type, ...newEntity } = newEntityData + if (!type) throw new Error('Entity has no type') + const entityType = `${type}s` + + return mutateInTree(tree, parentFolderId, (parent, folder, index) => { + parent[index] = { + ...folder, + [entityType]: [...folder[entityType], newEntity] + } + return parent + }) +} + +function mutateInTree(tree, id, mutationFunction) { + if (tree._id === id) { + // covers the root folder case: it has no parent so in order to use + // mutationFunction we pass an empty array as the parent and return the + // mutated tree directly + const [newTree] = mutationFunction([], tree, 0) + return newTree + } + + for (const entityType of ['docs', 'fileRefs', 'folders']) { + for (let index = 0; index < tree[entityType].length; index++) { + const entity = tree[entityType][index] + if (entity._id === id) { + return { + ...tree, + [entityType]: mutationFunction(tree[entityType], entity, index) + } + } + } + } + + const newFolders = tree.folders.map(folder => + mutateInTree(folder, id, mutationFunction) + ) + + return { ...tree, folders: newFolders } +} diff --git a/services/web/frontend/js/features/file-tree/util/sync-mutation.js b/services/web/frontend/js/features/file-tree/util/sync-mutation.js new file mode 100644 index 0000000000..a7e110b090 --- /dev/null +++ b/services/web/frontend/js/features/file-tree/util/sync-mutation.js @@ -0,0 +1,43 @@ +import { postJSON, deleteJSON } from '../../../infrastructure/fetch-json' + +export function syncRename(projectId, entityType, entityId, newName) { + return postJSON( + `/project/${projectId}/${getEntityPathName(entityType)}/${entityId}/rename`, + { + body: { + name: newName + } + } + ) +} + +export function syncDelete(projectId, entityType, entityId) { + return deleteJSON( + `/project/${projectId}/${getEntityPathName(entityType)}/${entityId}` + ) +} + +export function syncMove(projectId, entityType, entityId, toFolderId) { + return postJSON( + `/project/${projectId}/${getEntityPathName(entityType)}/${entityId}/move`, + { + body: { + folder_id: toFolderId + } + } + ) +} + +export function syncCreateEntity(projectId, parentFolderId, newEntityData) { + const { endpoint, ...newEntity } = newEntityData + return postJSON(`/project/${projectId}/${endpoint}`, { + body: { + parent_folder_id: parentFolderId, + ...newEntity + } + }) +} + +function getEntityPathName(entityType) { + return entityType === 'fileRef' ? 'file' : entityType +} diff --git a/services/web/frontend/js/ide/directives/verticalResizablePanes.js b/services/web/frontend/js/ide/directives/verticalResizablePanes.js index d11ae3badf..3844ea878c 100644 --- a/services/web/frontend/js/ide/directives/verticalResizablePanes.js +++ b/services/web/frontend/js/ide/directives/verticalResizablePanes.js @@ -41,6 +41,7 @@ export default App.directive('verticalResizablePanes', localStorage => ({ } const toggledExternally = attrs.verticalResizablePanesToggledExternallyOn + const resizeOn = attrs.verticalResizablePanesResizeOn const resizerDisabledClass = `${layoutOptions.south.resizerClass}-disabled` function enableResizer() { @@ -81,6 +82,12 @@ export default App.directive('verticalResizablePanes', localStorage => ({ }) } + if (resizeOn) { + scope.$on(resizeOn, () => { + layoutHandle.resizeAll() + }) + } + if (maxSize) { layoutOptions.south.maxSize = maxSize } diff --git a/services/web/frontend/js/ide/file-tree/FileTreeManager.js b/services/web/frontend/js/ide/file-tree/FileTreeManager.js index c70bfed1fb..b15fbd1242 100644 --- a/services/web/frontend/js/ide/file-tree/FileTreeManager.js +++ b/services/web/frontend/js/ide/file-tree/FileTreeManager.js @@ -25,6 +25,7 @@ import './controllers/FileTreeController' import './controllers/FileTreeEntityController' import './controllers/FileTreeFolderController' import './controllers/FileTreeRootFolderController' +import '../../features/file-tree/controllers/file-tree-controller' let FileTreeManager export default (FileTreeManager = class FileTreeManager { @@ -37,6 +38,12 @@ export default (FileTreeManager = class FileTreeManager { return this.$scope.$emit('file-tree:initialized') }) + this.$scope.$on('entities:multiSelected', (_event, data) => { + this.$scope.$apply(() => { + this.$scope.multiSelectedCount = data.count + }) + }) + this.$scope.$watch('rootFolder', rootFolder => { if (rootFolder != null) { return this.recalculateDocList() @@ -557,6 +564,22 @@ export default (FileTreeManager = class FileTreeManager { } createDoc(name, parent_folder) { + if (window.showReactFileTree) { + const promise = new Promise((resolve, reject) => { + this.$scope.FileTreeReactBridgePromise = { + resolve, + reject + } + }) + window.dispatchEvent( + new CustomEvent('FileTreeReactBridge.createDoc', { + detail: { + name + } + }) + ) + return promise + } // check if a doc/file/folder already exists with this name if (parent_folder == null) { parent_folder = this.getCurrentFolder() @@ -591,6 +614,24 @@ export default (FileTreeManager = class FileTreeManager { } createLinkedFile(name, parent_folder, provider, data) { + if (window.showReactFileTree) { + const promise = new Promise((resolve, reject) => { + this.$scope.FileTreeReactBridgePromise = { + resolve, + reject + } + }) + window.dispatchEvent( + new CustomEvent('FileTreeReactBridge.createLinkedFile', { + detail: { + name, + provider, + data + } + }) + ) + return promise + } // check if a doc/file/folder already exists with this name if (parent_folder == null) { parent_folder = this.getCurrentFolder() diff --git a/services/web/frontend/js/ide/file-tree/controllers/FileTreeController.js b/services/web/frontend/js/ide/file-tree/controllers/FileTreeController.js index 76d6051fc2..feb0e06cfc 100644 --- a/services/web/frontend/js/ide/file-tree/controllers/FileTreeController.js +++ b/services/web/frontend/js/ide/file-tree/controllers/FileTreeController.js @@ -17,13 +17,16 @@ import _ from 'lodash' */ import App from '../../../base' App.controller('FileTreeController', function($scope, $modal, ide, $rootScope) { - $scope.openNewDocModal = () => + $scope.openNewDocModal = reactBridgeParentFolderId => $modal.open({ templateUrl: 'newFileModalTemplate', controller: 'NewFileModalController', size: 'lg', resolve: { parent_folder() { + if (reactBridgeParentFolderId) { + return { id: reactBridgeParentFolderId } + } return ide.fileTreeManager.getCurrentFolder() }, projectFeatures() { @@ -49,7 +52,7 @@ App.controller('FileTreeController', function($scope, $modal, ide, $rootScope) { } }) - $scope.openUploadFileModal = () => + $scope.openUploadFileModal = reactBridgeParentFolderId => $modal.open({ templateUrl: 'newFileModalTemplate', controller: 'NewFileModalController', @@ -59,6 +62,9 @@ App.controller('FileTreeController', function($scope, $modal, ide, $rootScope) { return ide.$scope.project.features }, parent_folder() { + if (reactBridgeParentFolderId) { + return { id: reactBridgeParentFolderId } + } return ide.fileTreeManager.getCurrentFolder() }, type() { @@ -70,6 +76,19 @@ App.controller('FileTreeController', function($scope, $modal, ide, $rootScope) { } }) + if (window.showReactFileTree) { + window.addEventListener( + 'FileTreeReactBridge.openNewDocModal', + ({ detail }) => { + if (detail.mode === 'upload') { + $scope.openUploadFileModal(detail.parentFolderId) + } else { + $scope.openNewDocModal(detail.parentFolderId) + } + } + ) + } + $scope.orderByFoldersFirst = function(entity) { if ((entity != null ? entity.type : undefined) === 'folder') { return '0' @@ -176,13 +195,27 @@ App.controller('NewFileModalController', function( ) } }) - return $scope.$on('done', (e, opts = {}) => { + $scope.$on('done', (e, opts = {}) => { const isBibFile = opts.name && /^.*\.bib$/.test(opts.name) if (opts.shouldReindexReferences || isBibFile) { ide.$scope.$emit('references:should-reindex', {}) } $modalInstance.dismiss('done') }) + + if (window.showReactFileTree) { + window.addEventListener( + 'FileTreeReactBridge.openNewFileModal', + ({ detail }) => { + if (detail.done) { + ide.$scope.FileTreeReactBridgePromise.resolve() + } + if (detail.error) { + ide.$scope.FileTreeReactBridgePromise.reject(detail) + } + } + ) + } }) App.controller('NewDocModalController', function($scope, ide, $timeout) { @@ -207,6 +240,11 @@ App.controller('NewDocModalController', function($scope, ide, $timeout) { $scope.error = data $scope.state.inflight = false }) + .finally(function() { + if (!$scope.$$phase) { + $scope.$apply() + } + }) }) }) @@ -562,6 +600,11 @@ App.controller('ProjectLinkedFileModalController', function( const { data } = response $scope.error = data }) + .finally(function() { + if (!$scope.$$phase) { + $scope.$apply() + } + }) }) }) @@ -622,6 +665,11 @@ export default App.controller('UrlLinkedFileModalController', function( $scope.error = data return ($scope.state.inflight = false) }) + .finally(function() { + if (!$scope.$$phase) { + $scope.$apply() + } + }) }) }) diff --git a/services/web/frontend/js/infrastructure/auto-focus.js b/services/web/frontend/js/infrastructure/auto-focus.js new file mode 100644 index 0000000000..309b23ad0e --- /dev/null +++ b/services/web/frontend/js/infrastructure/auto-focus.js @@ -0,0 +1,18 @@ +import { createRef, useEffect } from 'react' + +export function useRefWithAutoFocus() { + const autoFocusedRef = createRef() + + useEffect( + () => { + if (autoFocusedRef.current) { + requestAnimationFrame(() => { + if (autoFocusedRef.current) autoFocusedRef.current.focus() + }) + } + }, + [autoFocusedRef] + ) + + return { autoFocusedRef } +} diff --git a/services/web/frontend/js/infrastructure/promise.js b/services/web/frontend/js/infrastructure/promise.js new file mode 100644 index 0000000000..c900b7da6c --- /dev/null +++ b/services/web/frontend/js/infrastructure/promise.js @@ -0,0 +1,9 @@ +// run `fn` in serie for all values, and resolve with an array of the resultss +// inspired by https://stackoverflow.com/a/50506360/1314820 +export function mapSeries(values, fn) { + return values.reduce((promiseChain, value) => { + return promiseChain.then(chainResults => + fn(value).then(currentResult => [...chainResults, currentResult]) + ) + }, Promise.resolve([])) +} diff --git a/services/web/frontend/js/infrastructure/without-propagation.js b/services/web/frontend/js/infrastructure/without-propagation.js new file mode 100644 index 0000000000..5b03c82f01 --- /dev/null +++ b/services/web/frontend/js/infrastructure/without-propagation.js @@ -0,0 +1,6 @@ +export default function withoutPropagation(callback) { + return ev => { + ev.stopPropagation() + if (callback) callback(ev) + } +} diff --git a/services/web/frontend/js/shared/components/icon.js b/services/web/frontend/js/shared/components/icon.js index 40a5c5646d..51aaf0d4fa 100644 --- a/services/web/frontend/js/shared/components/icon.js +++ b/services/web/frontend/js/shared/components/icon.js @@ -2,7 +2,14 @@ import React from 'react' import PropTypes from 'prop-types' import classNames from 'classnames' -function Icon({ type, spin, modifier, classes = {}, accessibilityLabel }) { +function Icon({ + type, + spin, + modifier, + classes = {}, + accessibilityLabel, + children +}) { const iconClassName = classNames( 'fa', `fa-${type}`, @@ -15,7 +22,9 @@ function Icon({ type, spin, modifier, classes = {}, accessibilityLabel }) { return ( <> -