Merge pull request #32330 from overleaf/mj-tabs-survey

[web] Tweaks for editor tabs

GitOrigin-RevId: fed9a500b871fa68a158c2e7ab42030117775161
This commit is contained in:
Mathias Jakobsen
2026-03-23 09:35:57 +00:00
committed by Copybot
parent 01f7bba166
commit 6b01183bba
4 changed files with 147 additions and 24 deletions
@@ -20,6 +20,8 @@ export type EditorFileTab = {
lifetime: Lifetime
}
export const TAB_TRANSFER_TYPE = 'text/x.tab-id'
const TabsContext = React.createContext<
| {
tabs: EditorFileTab[]
@@ -1,9 +1,20 @@
import { EditorFileTab } from '@/features/ide-react/context/tabs-context'
import {
EditorFileTab,
TAB_TRANSFER_TYPE,
} from '@/features/ide-react/context/tabs-context'
import MaterialIcon from '@/shared/components/material-icon'
import { debugConsole } from '@/utils/debugging'
import classNames from 'classnames'
import { throttle } from 'lodash'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
type TabProps = {
@@ -31,8 +42,6 @@ function getSideOfTargetFromEvent(
}
}
const TAB_TRANSFER_TYPE = 'text/x.tab-id'
export const Tab = memo(function Tab({
tab,
openTab,
@@ -42,12 +51,14 @@ export const Tab = memo(function Tab({
onTabDrop,
}: TabProps) {
const { t } = useTranslation()
const tabRef = useRef<HTMLDivElement>(null)
const [dropTargetPosition, setDropTargetPosition] = useState<
'left' | 'right' | null
>(null)
const onDragStart = useCallback(
(e: React.DragEvent) => {
e.stopPropagation()
e.dataTransfer.setData(TAB_TRANSFER_TYPE, tab.id)
e.dataTransfer.effectAllowed = 'move'
},
@@ -65,22 +76,28 @@ export const Tab = memo(function Tab({
const onDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
throttledOnDragOver(e.currentTarget, e.clientX)
},
[throttledOnDragOver]
)
const onDragLeave = useCallback(() => {
throttledOnDragOver.cancel()
setDropTargetPosition(null)
}, [throttledOnDragOver])
const onDragLeave = useCallback(
(e: React.DragEvent) => {
e.stopPropagation()
throttledOnDragOver.cancel()
setDropTargetPosition(null)
},
[throttledOnDragOver]
)
const onDrop = useCallback(
(e: React.DragEvent) => {
throttledOnDragOver.cancel()
setDropTargetPosition(null)
e.preventDefault()
e.stopPropagation()
const draggedTabId = e.dataTransfer.getData(TAB_TRANSFER_TYPE)
if (!draggedTabId) {
debugConsole.warn('No dragged tab id found in dataTransfer')
@@ -135,6 +152,15 @@ export const Tab = memo(function Tab({
[closeTab, tab]
)
useLayoutEffect(() => {
if (isSelected && tabRef.current) {
tabRef.current.scrollIntoView({
block: 'nearest',
inline: 'nearest',
})
}
}, [isSelected])
useEffect(() => {
if (isSelected && tab.lifetime === 'temporary') {
const handler = () => {
@@ -149,6 +175,7 @@ export const Tab = memo(function Tab({
return (
<div
ref={tabRef}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
@@ -1,25 +1,94 @@
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
import { useTabsContext } from '@/features/ide-react/context/tabs-context'
import {
TAB_TRANSFER_TYPE,
useTabsContext,
} from '@/features/ide-react/context/tabs-context'
import { Tab } from './tab'
import SplitTestBadge from '@/shared/components/split-test-badge'
import { useCallback, useMemo, useState } from 'react'
import { throttle } from 'lodash'
import { debugConsole } from '@/utils/debugging'
import classNames from 'classnames'
export const TabsContainer = () => {
const { tabs, openTab, closeTab, moveTab, makeTabPermanent } =
useTabsContext()
const { openEntity } = useFileTreeOpenContext()
const [hovered, setHovered] = useState<boolean>(false)
const throttledOnDragOver = useMemo(
() =>
throttle(() => {
setHovered(true)
}, 50),
[]
)
const onDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
throttledOnDragOver()
},
[throttledOnDragOver]
)
const onDrop = useCallback(
(e: React.DragEvent) => {
throttledOnDragOver.cancel()
e.stopPropagation()
e.preventDefault()
setHovered(false)
const draggedTabId = e.dataTransfer.getData(TAB_TRANSFER_TYPE)
if (!draggedTabId) {
debugConsole.warn('No dragged tab id found in dataTransfer')
return
}
const targetTabId = tabs[tabs.length - 1]?.id
if (!targetTabId) {
debugConsole.warn('No target tab id found for drop')
return
}
moveTab(draggedTabId, targetTabId, 'right')
},
[tabs, moveTab, throttledOnDragOver]
)
const onDragLeave = useCallback(() => {
throttledOnDragOver.cancel()
setHovered(false)
}, [throttledOnDragOver])
return (
<div className="editor-tabs-container" role="tablist">
{tabs.map(tab => (
<Tab
key={tab.id}
tab={tab}
openTab={openTab}
closeTab={closeTab}
isSelected={openEntity?.entity._id === tab.id}
onTabDrop={moveTab}
makeTabPermanent={makeTabPermanent}
<div className="editor-tabs-container">
<div
className={classNames('editor-tabs-row', {
'editor-tabs-row-hovered': hovered,
})}
role="tablist"
onDragOver={onDragOver}
onDrop={onDrop}
onDragLeave={onDragLeave}
tabIndex={-1}
>
{tabs.map(tab => (
<Tab
key={tab.id}
tab={tab}
openTab={openTab}
closeTab={closeTab}
isSelected={openEntity?.entity._id === tab.id}
onTabDrop={moveTab}
makeTabPermanent={makeTabPermanent}
/>
))}
</div>
<div className="editor-tabs-labs-icon">
<SplitTestBadge
splitTestName="editor-tabs"
displayOnVariants={['enabled']}
/>
))}
</div>
</div>
)
}
@@ -5,13 +5,38 @@
.editor-tabs-container {
display: flex;
flex-direction: row;
list-style: none;
padding: 0;
border-bottom: 1px solid var(--border-divider-themed);
margin: 0;
align-items: center;
gap: var(--spacing-02);
flex: 0 0 auto;
background: var(--bg-primary-themed);
justify-content: space-between;
border-bottom: 1px solid var(--border-divider-themed);
.editor-tabs-labs-icon {
flex: 0 0 auto;
padding: 0 var(--spacing-02);
.material-symbols {
font-size: 16px;
}
}
}
.editor-tabs-row {
display: flex;
flex-direction: row;
list-style: none;
padding: 0;
margin: 0;
flex: 1;
background: var(--bg-primary-themed);
overflow-x: auto;
&.editor-tabs-row-hovered {
.editor-file-tab:last-child {
border-right-color: var(--border-primary-themed);
}
}
}
.editor-file-tab {