فهرست منبع

Improve context menu behaviour in the file tree (#15142)

* Tidy up menu button
* Unselect all items when clicking in the file tree root
* Close the context menu when selecting a new item
* Avoid selecting multiple items with Ctrl+click on macOS

GitOrigin-RevId: b67a724909668ec13d7a6d09ffc31574cb42238f
Alf Eaton 2 سال پیش
والد
کامیت
b9a6e7009d

+ 3 - 2
services/web/frontend/js/features/file-tree/components/file-tree-inner.tsx

@@ -1,4 +1,5 @@
 import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
 import { useFileTreeSelectable } from '../contexts/file-tree-selectable'
+import { useCallback } from 'react'
 
 
 type FileTreeInnerProps = {
 type FileTreeInnerProps = {
   children: React.ReactNode
   children: React.ReactNode
@@ -7,9 +8,9 @@ type FileTreeInnerProps = {
 function FileTreeInner({ children }: FileTreeInnerProps) {
 function FileTreeInner({ children }: FileTreeInnerProps) {
   const { setIsRootFolderSelected } = useFileTreeSelectable()
   const { setIsRootFolderSelected } = useFileTreeSelectable()
 
 
-  const handleFileTreeClick = () => {
+  const handleFileTreeClick = useCallback(() => {
     setIsRootFolderSelected(true)
     setIsRootFolderSelected(true)
-  }
+  }, [setIsRootFolderSelected])
 
 
   return (
   return (
     // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
     // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions

+ 9 - 21
services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu.jsx

@@ -1,23 +1,20 @@
-import { useState } from 'react'
-import { findDOMNode } from 'react-dom'
+import { useRef } from 'react'
 import PropTypes from 'prop-types'
 import PropTypes from 'prop-types'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
-import withoutPropagation from '../../../../infrastructure/without-propagation'
 
 
-import { Button } from 'react-bootstrap'
 import Icon from '../../../../shared/components/icon'
 import Icon from '../../../../shared/components/icon'
 
 
 import { useFileTreeMainContext } from '../../contexts/file-tree-main'
 import { useFileTreeMainContext } from '../../contexts/file-tree-main'
 
 
 function FileTreeItemMenu({ id }) {
 function FileTreeItemMenu({ id }) {
   const { t } = useTranslation()
   const { t } = useTranslation()
-
   const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
   const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
-  const [dropdownTarget, setDropdownTarget] = useState()
+  const menuButtonRef = useRef()
 
 
-  function handleClick(_ev) {
-    const target = dropdownTarget.getBoundingClientRect()
+  function handleClick(event) {
+    event.stopPropagation()
     if (!contextMenuCoords) {
     if (!contextMenuCoords) {
+      const target = menuButtonRef.current.getBoundingClientRect()
       setContextMenuCoords({
       setContextMenuCoords({
         top: target.top + target.height / 2,
         top: target.top + target.height / 2,
         left: target.right,
         left: target.right,
@@ -27,25 +24,16 @@ function FileTreeItemMenu({ id }) {
     }
     }
   }
   }
 
 
-  const menuButtonRef = component => {
-    if (component) {
-      // eslint-disable-next-line react/no-find-dom-node
-      setDropdownTarget(findDOMNode(component))
-    }
-  }
-
   return (
   return (
     <div className="menu-button btn-group">
     <div className="menu-button btn-group">
-      <Button
-        className="entity-menu-toggle"
-        bsSize="sm"
+      <button
+        className="entity-menu-toggle btn btn-sm"
         id={`menu-button-${id}`}
         id={`menu-button-${id}`}
-        onClick={withoutPropagation(handleClick)}
+        onClick={handleClick}
         ref={menuButtonRef}
         ref={menuButtonRef}
-        bsStyle={null}
       >
       >
         <Icon type="ellipsis-v" accessibilityLabel={t('menu')} />
         <Icon type="ellipsis-v" accessibilityLabel={t('menu')} />
-      </Button>
+      </button>
     </div>
     </div>
   )
   )
 }
 }

+ 22 - 4
services/web/frontend/js/features/file-tree/contexts/file-tree-selectable.jsx

@@ -18,6 +18,7 @@ import { useEditorContext } from '../../../shared/context/editor-context'
 import { useLayoutContext } from '../../../shared/context/layout-context'
 import { useLayoutContext } from '../../../shared/context/layout-context'
 import usePersistedState from '../../../shared/hooks/use-persisted-state'
 import usePersistedState from '../../../shared/hooks/use-persisted-state'
 import usePreviousValue from '../../../shared/hooks/use-previous-value'
 import usePreviousValue from '../../../shared/hooks/use-previous-value'
+import { useFileTreeMainContext } from '@/features/file-tree/contexts/file-tree-main'
 
 
 const FileTreeSelectableContext = createContext()
 const FileTreeSelectableContext = createContext()
 
 
@@ -31,7 +32,7 @@ function fileTreeSelectableReadWriteReducer(selectedEntityIds, action) {
   switch (action.type) {
   switch (action.type) {
     case ACTION_TYPES.SELECT: {
     case ACTION_TYPES.SELECT: {
       // reset selection
       // reset selection
-      return new Set([action.id])
+      return new Set(Array.isArray(action.id) ? action.id : [action.id])
     }
     }
 
 
     case ACTION_TYPES.MULTI_SELECT: {
     case ACTION_TYPES.MULTI_SELECT: {
@@ -207,8 +208,11 @@ const editorContextPropTypes = {
   permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
   permissionsLevel: PropTypes.oneOf(['readOnly', 'readAndWrite', 'owner']),
 }
 }
 
 
+const isMac = /Mac/.test(window.navigator?.platform)
+
 export function useSelectableEntity(id, isFile) {
 export function useSelectableEntity(id, isFile) {
   const { view, setView } = useLayoutContext(layoutContextPropTypes)
   const { view, setView } = useLayoutContext(layoutContextPropTypes)
+  const { setContextMenuCoords } = useFileTreeMainContext()
   const {
   const {
     selectedEntityIds,
     selectedEntityIds,
     selectOrMultiSelectEntity,
     selectOrMultiSelectEntity,
@@ -221,18 +225,32 @@ export function useSelectableEntity(id, isFile) {
   const handleEvent = useCallback(
   const handleEvent = useCallback(
     ev => {
     ev => {
       ev.stopPropagation()
       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)
       setIsRootFolderSelected(false)
-      selectOrMultiSelectEntity(id, ev.ctrlKey || ev.metaKey)
+      selectOrMultiSelectEntity(id, multiSelect)
       setView(isFile ? 'file' : 'editor')
       setView(isFile ? 'file' : 'editor')
     },
     },
-    [id, setIsRootFolderSelected, selectOrMultiSelectEntity, setView, isFile]
+    [
+      id,
+      isRootFolderSelected,
+      setIsRootFolderSelected,
+      selectOrMultiSelectEntity,
+      setView,
+      isFile,
+    ]
   )
   )
 
 
   const handleClick = useCallback(
   const handleClick = useCallback(
     ev => {
     ev => {
       handleEvent(ev)
       handleEvent(ev)
+      if (!ev.ctrlKey && !ev.metaKey) {
+        setContextMenuCoords(null)
+      }
     },
     },
-    [handleEvent]
+    [handleEvent, setContextMenuCoords]
   )
   )
 
 
   const handleKeyPress = useCallback(
   const handleKeyPress = useCallback(

+ 0 - 6
services/web/frontend/js/infrastructure/without-propagation.js

@@ -1,6 +0,0 @@
-export default function withoutPropagation(callback) {
-  return ev => {
-    ev.stopPropagation()
-    if (callback) callback(ev)
-  }
-}

+ 14 - 3
services/web/test/frontend/features/file-tree/components/file-tree-root.test.jsx

@@ -347,9 +347,9 @@ describe('<FileTreeRoot/>', function () {
       )
       )
 
 
       // select the sub file
       // select the sub file
-      const mainDoc = screen.getByRole('treeitem', { name: 'sub.tex' })
-      fireEvent.click(mainDoc)
-      expect(mainDoc.getAttribute('aria-selected')).to.equal('true')
+      const subDoc = screen.getByRole('treeitem', { name: 'sub.tex' })
+      fireEvent.click(subDoc)
+      expect(subDoc.getAttribute('aria-selected')).to.equal('true')
 
 
       // click on empty area
       // click on empty area
       fireEvent.click(screen.getByTestId('file-tree-inner'))
       fireEvent.click(screen.getByTestId('file-tree-inner'))
@@ -394,5 +394,16 @@ describe('<FileTreeRoot/>', function () {
 
 
       expect(newItem.parentNode).to.equal(rootEl)
       expect(newItem.parentNode).to.equal(rootEl)
     })
     })
+
+    it('starts a new selection', function () {
+      const subDoc = screen.getByRole('treeitem', { name: 'sub.tex' })
+      expect(subDoc.getAttribute('aria-selected')).to.equal('false')
+
+      const mainDoc = screen.getByRole('treeitem', { name: 'main.tex' })
+      fireEvent.click(mainDoc, { ctrlKey: true })
+      expect(mainDoc.getAttribute('aria-selected')).to.equal('true')
+
+      expect(subDoc.getAttribute('aria-selected')).to.equal('false')
+    })
   })
   })
 })
 })

+ 39 - 0
services/web/test/frontend/features/file-tree/flows/context-menu.test.jsx

@@ -59,6 +59,45 @@ describe('FileTree Context Menu Flow', function () {
     screen.getByRole('menu')
     screen.getByRole('menu')
   })
   })
 
 
+  it('closes when a new selection is started', async function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [
+          { _id: '456def', name: 'main.tex' },
+          { _id: '456def', name: 'foo.tex' },
+        ],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+    renderWithEditorContext(
+      <FileTreeRoot
+        refProviders={{}}
+        reindexReferences={() => null}
+        setRefProviderEnabled={() => null}
+        setStartedFreeTrial={() => null}
+        onSelect={onSelect}
+        onInit={onInit}
+        isConnected
+      />,
+      {
+        rootFolder,
+        projectId: '123abc',
+        rootDocId: '456def',
+      }
+    )
+    const treeitem = screen.getByRole('button', { name: 'main.tex' })
+    expect(screen.queryByRole('menu')).to.be.null
+
+    fireEvent.contextMenu(treeitem)
+    screen.getByRole('menu')
+
+    screen.getByRole('button', { name: 'foo.tex' }).click()
+    expect(screen.queryByRole('menu')).to.be.null
+  })
+
   it("doesn't open in read only mode", async function () {
   it("doesn't open in read only mode", async function () {
     const rootFolder = [
     const rootFolder = [
       {
       {