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

[web] Add custom context menu for tabs

GitOrigin-RevId: 5ca997bde0c881ad3ba80092915d341f03c609a8
This commit is contained in:
Mathias Jakobsen
2026-04-21 08:05:35 +00:00
committed by Copybot
parent df3f50d10b
commit 1ac1410b3b
8 changed files with 359 additions and 6 deletions
@@ -298,6 +298,7 @@
"clone_with_git": "",
"close": "",
"close_dialog": "",
"close_others": "",
"close_tab": "",
"clsi_maintenance": "",
"clsi_unavailable": "",
@@ -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>
@@ -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,
@@ -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>
)
}
@@ -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
)
}
@@ -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
View File
@@ -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.",
@@ -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