mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 14:13:43 +01:00
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:
committed by
Copybot
parent
df3f50d10b
commit
1ac1410b3b
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+109
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user