Quellcode durchsuchen

Merge pull request #32901 from overleaf/mj-tabs-context-menu

[web] Add custom context menu for tabs

GitOrigin-RevId: 5ca997bde0c881ad3ba80092915d341f03c609a8
Mathias Jakobsen vor 3 Monaten
Ursprung
Commit
1ac1410b3b

+ 1 - 0
services/web/frontend/extracted-translations.json

@@ -298,6 +298,7 @@
   "clone_with_git": "",
   "close": "",
   "close_dialog": "",
+  "close_others": "",
   "close_tab": "",
   "clsi_maintenance": "",
   "clsi_unavailable": "",

+ 52 - 3
services/web/frontend/js/features/ide-react/context/tabs-context.tsx

@@ -2,7 +2,14 @@ import { findInTree } from '@/features/file-tree/util/find-in-tree'
 import { useFileTreeData } from '@/shared/context/file-tree-data-context'
 import { useProjectContext } from '@/shared/context/project-context'
 import usePersistedState from '@/shared/hooks/use-persisted-state'
-import React, { FC, useCallback, useContext, useEffect, useMemo } from 'react'
+import React, {
+  FC,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from 'react'
 import { useFileTreeOpenContext } from './file-tree-open-context'
 import { useEditorManagerContext } from './editor-manager-context'
 import { debugConsole } from '@/utils/debugging'
@@ -28,17 +35,28 @@ export type EditorFileTab = {
 
 export const TAB_TRANSFER_TYPE = 'text/x.tab-id'
 
+export type TabsContextMenuTarget = {
+  top: number
+  left: number
+  tabId: string
+}
+
 const TabsContext = React.createContext<
   | {
       tabs: EditorFileTab[]
       openTab: (id: string) => void
       closeTab: (id: string) => void
+      closeOtherTabs: (id: string) => void
       makeTabPermanent: (id: string) => void
       moveTab: (
         sourceTabId: string,
         targetTabId: string,
         position: 'left' | 'right'
       ) => void
+      contextMenuTarget: TabsContextMenuTarget | null
+      setContextMenuTarget: React.Dispatch<
+        React.SetStateAction<TabsContextMenuTarget | null>
+      >
     }
   | undefined
 >(undefined)
@@ -58,6 +76,9 @@ export const TabsProvider: FC<React.PropsWithChildren> = ({ children }) => {
     []
   )
 
+  const [contextMenuTarget, setContextMenuTarget] =
+    useState<TabsContextMenuTarget | null>(null)
+
   const tabs = useMemo(() => {
     if (!tabsEnabled) {
       return []
@@ -143,6 +164,16 @@ export const TabsProvider: FC<React.PropsWithChildren> = ({ children }) => {
     [openTabs, openEntity, setOpenTabs, openTab]
   )
 
+  const closeOtherTabs = useCallback(
+    async (id: string) => {
+      if (id !== openEntity?.entity._id) {
+        await openTab(id)
+      }
+      setOpenTabs(current => current.filter(tab => tab.id === id))
+    },
+    [openEntity, openTab, setOpenTabs]
+  )
+
   const moveTab = useCallback(
     (sourceTabId: string, targetTabId: string, position: 'left' | 'right') => {
       debugConsole.log({ sourceTabId, targetTabId, position })
@@ -211,8 +242,26 @@ export const TabsProvider: FC<React.PropsWithChildren> = ({ children }) => {
   }, [openEntity, previewTabs, setOpenTabs, tabsEnabled])
 
   const value = useMemo(
-    () => ({ tabs, openTab, closeTab, moveTab, makeTabPermanent }),
-    [tabs, openTab, closeTab, moveTab, makeTabPermanent]
+    () => ({
+      tabs,
+      openTab,
+      closeTab,
+      closeOtherTabs,
+      moveTab,
+      makeTabPermanent,
+      contextMenuTarget,
+      setContextMenuTarget,
+    }),
+    [
+      tabs,
+      openTab,
+      closeTab,
+      closeOtherTabs,
+      moveTab,
+      makeTabPermanent,
+      contextMenuTarget,
+      setContextMenuTarget,
+    ]
   )
 
   return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>

+ 21 - 0
services/web/frontend/js/features/source-editor/components/tabs/tab.tsx

@@ -23,6 +23,11 @@ type TabProps = {
   openTab: (id: string) => void
   closeTab: (id: string) => void
   makeTabPermanent: (id: string) => void
+  openContextMenu: (
+    coords: { top: number; left: number },
+    tabId: string
+  ) => void
+  closeContextMenu: () => void
   isSelected: boolean
   onTabDrop: (
     sourceTabId: string,
@@ -48,6 +53,8 @@ export const Tab = memo(function Tab({
   openTab,
   closeTab,
   makeTabPermanent,
+  openContextMenu,
+  closeContextMenu,
   isSelected,
   onTabDrop,
 }: TabProps) {
@@ -153,6 +160,18 @@ export const Tab = memo(function Tab({
     [closeTab, tab]
   )
 
+  const onContextMenu = useCallback(
+    (e: React.MouseEvent) => {
+      if (e.shiftKey) {
+        closeContextMenu()
+        return
+      }
+      e.preventDefault()
+      openContextMenu({ top: e.pageY, left: e.pageX }, tab.id)
+    },
+    [openContextMenu, closeContextMenu, tab]
+  )
+
   useLayoutEffect(() => {
     if (isSelected && tabRef.current) {
       tabRef.current.scrollIntoView({
@@ -189,6 +208,8 @@ export const Tab = memo(function Tab({
       onClick={onClick}
       onMouseUp={onMouseUp}
       onKeyDown={onKeyDown}
+      onContextMenu={onContextMenu}
+      data-tab-id={tab.id}
       tabIndex={0}
       className={classNames('editor-file-tab', {
         'tab-selected': isSelected,

+ 23 - 2
services/web/frontend/js/features/source-editor/components/tabs/tabs-container.tsx

@@ -4,6 +4,7 @@ import {
   useTabsContext,
 } from '@/features/ide-react/context/tabs-context'
 import { Tab } from './tab'
+import { TabsContextMenu } from './tabs-context-menu'
 import SplitTestBadge from '@/shared/components/split-test-badge'
 import { useCallback, useMemo, useState } from 'react'
 import { throttle } from 'lodash'
@@ -11,11 +12,28 @@ import { debugConsole } from '@/utils/debugging'
 import classNames from 'classnames'
 
 export const TabsContainer = () => {
-  const { tabs, openTab, closeTab, moveTab, makeTabPermanent } =
-    useTabsContext()
+  const {
+    tabs,
+    openTab,
+    closeTab,
+    moveTab,
+    makeTabPermanent,
+    setContextMenuTarget,
+  } = useTabsContext()
   const { openEntity } = useFileTreeOpenContext()
   const [hovered, setHovered] = useState<boolean>(false)
 
+  const openContextMenu = useCallback(
+    (coords: { top: number; left: number }, tabId: string) => {
+      setContextMenuTarget({ ...coords, tabId })
+    },
+    [setContextMenuTarget]
+  )
+
+  const closeContextMenu = useCallback(() => {
+    setContextMenuTarget(null)
+  }, [setContextMenuTarget])
+
   const throttledOnDragOver = useMemo(
     () =>
       throttle(() => {
@@ -89,6 +107,8 @@ export const TabsContainer = () => {
             isSelected={openEntity?.entity._id === tab.id}
             onTabDrop={moveTab}
             makeTabPermanent={makeTabPermanent}
+            openContextMenu={openContextMenu}
+            closeContextMenu={closeContextMenu}
           />
         ))}
       </div>
@@ -98,6 +118,7 @@ export const TabsContainer = () => {
           displayOnVariants={['enabled']}
         />
       </div>
+      <TabsContextMenu />
     </div>
   )
 }

+ 109 - 0
services/web/frontend/js/features/source-editor/components/tabs/tabs-context-menu.tsx

@@ -0,0 +1,109 @@
+import { useEffect, useRef } from 'react'
+import ReactDOM from 'react-dom'
+import { useTranslation } from 'react-i18next'
+import {
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+} from '@/shared/components/dropdown/dropdown-menu'
+import { useTabsContext } from '@/features/ide-react/context/tabs-context'
+
+export function TabsContextMenu() {
+  const { t } = useTranslation()
+  const {
+    tabs,
+    closeTab,
+    closeOtherTabs,
+    contextMenuTarget,
+    setContextMenuTarget,
+  } = useTabsContext()
+
+  const menuRef = useRef<HTMLDivElement>(null)
+
+  // Close the context menu when opening another one
+  useEffect(() => {
+    if (!contextMenuTarget) return
+    const handler = (event: MouseEvent) => {
+      if (event.button !== 2) return
+      const target = event.target as Element | null
+      // Right-clicking another tab should re-open the menu on that tab;
+      // its onContextMenu handler will set the new target.
+      if (target?.closest('.context-menu')) return
+      if (target?.closest('.editor-file-tab')) return
+      setContextMenuTarget(null)
+    }
+    // Listen on mousedown (not contextmenu) so we still close when another
+    // component's handler calls preventDefault on the contextmenu event.
+    document.addEventListener('mousedown', handler)
+    return () => document.removeEventListener('mousedown', handler)
+  }, [contextMenuTarget, setContextMenuTarget])
+
+  // Move focus to the menu on open, and back to the originating tab on close.
+  const lastTabIdRef = useRef<string | null>(null)
+  useEffect(() => {
+    if (contextMenuTarget) {
+      lastTabIdRef.current = contextMenuTarget.tabId
+      menuRef.current?.querySelector<HTMLElement>('[role="menu"]')?.focus()
+    } else if (lastTabIdRef.current) {
+      document
+        .querySelector<HTMLElement>(`[data-tab-id="${lastTabIdRef.current}"]`)
+        ?.focus()
+      lastTabIdRef.current = null
+    }
+  }, [contextMenuTarget])
+
+  if (!contextMenuTarget) return null
+
+  const close = () => setContextMenuTarget(null)
+
+  const handleKeyDown = (event: React.KeyboardEvent<Element>) => {
+    if (event.key === 'Tab' || event.key === 'Escape') {
+      event.preventDefault()
+      close()
+    }
+  }
+
+  const handleToggle = (wantOpen: boolean) => {
+    if (!wantOpen) close()
+  }
+
+  return ReactDOM.createPortal(
+    <div
+      ref={menuRef}
+      style={{ top: contextMenuTarget.top, left: contextMenuTarget.left }}
+      // TODO ide-redesign-cleanup: remove 'ide-redesign-main' class when old editor is removed
+      className="context-menu ide-redesign-main"
+    >
+      <Dropdown
+        show
+        drop="down"
+        onKeyDown={handleKeyDown}
+        onToggle={handleToggle}
+      >
+        <DropdownMenu className="dropdown-menu-sm-width" tabIndex={-1}>
+          <DropdownItem
+            disabled={tabs.length <= 1}
+            as="button"
+            onClick={() => {
+              closeTab(contextMenuTarget.tabId)
+              close()
+            }}
+          >
+            {t('close_tab')}
+          </DropdownItem>
+          <DropdownItem
+            disabled={tabs.length <= 1}
+            as="button"
+            onClick={() => {
+              closeOtherTabs(contextMenuTarget.tabId)
+              close()
+            }}
+          >
+            {t('close_others')}
+          </DropdownItem>
+        </DropdownMenu>
+      </Dropdown>
+    </div>,
+    document.body
+  )
+}

+ 0 - 1
services/web/frontend/stylesheets/pages/editor/tabs.scss

@@ -50,7 +50,6 @@
   box-sizing: border-box;
   padding: var(--spacing-02) var(--spacing-03);
   background: var(--tab-bg-color);
-  outline: none;
   color: var(--content-primary-themed);
   border: none;
   font-size: var(--font-size-02);

+ 1 - 0
services/web/locales/en.json

@@ -395,6 +395,7 @@
   "clone_with_git": "Clone with Git",
   "close": "Close",
   "close_dialog": "Close dialog",
+  "close_others": "Close others",
   "close_tab": "Close tab",
   "clsi_maintenance": "The compile servers are down for maintenance, and will be back shortly.",
   "clsi_unavailable": "Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.",

+ 152 - 0
services/web/test/frontend/features/source-editor/components/tabs.spec.tsx

@@ -432,6 +432,158 @@ describe('File Tabs', function () {
     })
   })
 
+  describe('Context menu', function () {
+    it('opens the context menu on right-click of a tab', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.then(() => selectDoc(DOC_IDS.intro))
+
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
+
+      cy.findByRole('menu').should('exist')
+      cy.findByRole('menuitem', { name: 'Close tab' }).should('exist')
+      cy.findByRole('menuitem', { name: 'Close others' }).should('exist')
+    })
+
+    it('closes the clicked tab via "Close tab"', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.then(() => selectDoc(DOC_IDS.intro))
+
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
+      cy.findByRole('menuitem', { name: 'Close tab' }).click()
+
+      cy.findByRole('tab', { name: /intro\.tex/ }).should('not.exist')
+      cy.findByRole('tab', { name: /main\.tex/ }).should('exist')
+      cy.findByRole('menu').should('not.exist')
+    })
+
+    it('closes all other tabs via "Close others"', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.then(() => selectDoc(DOC_IDS.intro))
+      cy.then(() => selectDoc(DOC_IDS.appendix))
+
+      cy.findAllByRole('tab').should('have.length', 3)
+
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
+      cy.findByRole('menuitem', { name: 'Close others' }).click()
+
+      cy.findAllByRole('tab').should('have.length', 1)
+      cy.findByRole('tab', { name: /intro\.tex/ }).should('exist')
+    })
+
+    it('navigates to the target tab when closing others from a non-active tab', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.then(() => selectDoc(DOC_IDS.intro))
+      cy.then(() => selectDoc(DOC_IDS.appendix))
+
+      // appendix is the currently active tab; close others from intro
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
+      cy.findByRole('menuitem', { name: 'Close others' }).click()
+
+      cy.get('@openDocWithId').should('have.been.calledWith', DOC_IDS.intro)
+    })
+
+    it('disables both items when only one tab is open', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+
+      cy.findByRole('tab', { name: /main\.tex/ }).rightclick()
+
+      cy.findByRole('menuitem', { name: 'Close tab' }).should(
+        'have.attr',
+        'aria-disabled',
+        'true'
+      )
+      cy.findByRole('menuitem', { name: 'Close others' }).should(
+        'have.attr',
+        'aria-disabled',
+        'true'
+      )
+    })
+
+    it('closes the menu on Escape', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.then(() => selectDoc(DOC_IDS.intro))
+
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
+      cy.findByRole('menu').should('exist')
+
+      cy.findByRole('menu').trigger('keydown', {
+        key: 'Escape',
+      })
+
+      cy.findByRole('menu').should('not.exist')
+    })
+
+    it('closes the menu on right-click outside', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.then(() => selectDoc(DOC_IDS.intro))
+
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
+      cy.findByRole('menu').should('exist')
+
+      // Right-click outside any tab
+      cy.get('.editor-tabs-container').rightclick('right')
+
+      cy.findByRole('menu').should('not.exist')
+    })
+
+    it('focuses the menu when opening', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.then(() => selectDoc(DOC_IDS.intro))
+
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
+
+      cy.findByRole('menu').should('be.focused')
+    })
+
+    it('returns focus to the originating tab when closing', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.then(() => selectDoc(DOC_IDS.intro))
+
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
+      cy.findByRole('menu').trigger('keydown', { key: 'Escape' })
+
+      cy.findByRole('tab', { name: /intro\.tex/ }).should('be.focused')
+    })
+
+    it('moves the menu to another tab on right-click', function () {
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.then(() => selectDoc(DOC_IDS.intro))
+      cy.then(() => selectDoc(DOC_IDS.appendix))
+
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
+      cy.findByRole('menu').should('exist')
+
+      // Right-click appendix the menu should retarget to that tab
+      cy.findByRole('tab', { name: /appendix\.tex/ }).rightclick({
+        force: true,
+      })
+      cy.findByRole('menuitem', { name: 'Close others' }).click()
+
+      cy.findAllByRole('tab').should('have.length', 1)
+      cy.findByRole('tab', { name: /appendix\.tex/ }).should('exist')
+    })
+
+    it('should not open the context menu if shift is held', function () {
+      cy.then(() => selectDoc(DOC_IDS.intro))
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick({
+        shiftKey: true,
+      })
+      cy.findByRole('menu').should('not.exist')
+    })
+
+    it('should close already open context menu if shift is held', function () {
+      cy.then(() => selectDoc(DOC_IDS.intro))
+      cy.then(() => selectDoc(DOC_IDS.main))
+      cy.findByRole('tab', { name: /intro\.tex/ }).rightclick()
+      cy.findByRole('menu').should('exist')
+      cy.findByRole('tab', { name: /main\.tex/ }).rightclick({
+        force: true,
+        shiftKey: true,
+      })
+      cy.findByRole('menu').should('not.exist')
+    })
+  })
+
   describe('Tab interaction', function () {
     it('calls openDocWithId when clicking a non-selected doc tab', function () {
       // Open two tabs