mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-27 02:51:57 +02:00
[web] Add custom context menu for tabs GitOrigin-RevId: 5ca997bde0c881ad3ba80092915d341f03c609a8
301 lines
7.6 KiB
TypeScript
301 lines
7.6 KiB
TypeScript
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,
|
|
useState,
|
|
} from 'react'
|
|
import { useFileTreeOpenContext } from './file-tree-open-context'
|
|
import { useEditorManagerContext } from './editor-manager-context'
|
|
import { debugConsole } from '@/utils/debugging'
|
|
import { disambiguatePaths } from '../util/disambiguate-paths'
|
|
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
|
|
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
|
|
import {
|
|
FileTreeFindResult,
|
|
isFileRefResult,
|
|
} from '@/features/ide-react/types/file-tree'
|
|
|
|
type PersistedTabInfo = { id: string; lifetime: Lifetime }
|
|
|
|
type Lifetime = 'permanent' | 'temporary'
|
|
|
|
export type EditorFileTab = {
|
|
id: string
|
|
name: string
|
|
displayPath: string
|
|
isLinkedFile: boolean
|
|
lifetime: Lifetime
|
|
}
|
|
|
|
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)
|
|
|
|
export const TabsProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
|
const { projectId } = useProjectContext()
|
|
const { fileTreeData } = useFileTreeData()
|
|
|
|
const { openEntity } = useFileTreeOpenContext()
|
|
const { openDocWithId, openFileWithId } = useEditorManagerContext()
|
|
const tabsEnabled = isSplitTestEnabled('editor-tabs')
|
|
const { userSettings } = useUserSettingsContext()
|
|
const { previewTabs } = userSettings
|
|
|
|
const [openTabs, setOpenTabs] = usePersistedState<PersistedTabInfo[]>(
|
|
`open-tabs:${projectId}`,
|
|
[]
|
|
)
|
|
|
|
const [contextMenuTarget, setContextMenuTarget] =
|
|
useState<TabsContextMenuTarget | null>(null)
|
|
|
|
const tabs = useMemo(() => {
|
|
if (!tabsEnabled) {
|
|
return []
|
|
}
|
|
if (!fileTreeData) {
|
|
return []
|
|
}
|
|
const tabsFileTreeLookup = openTabs
|
|
.map(tab => ({
|
|
lifetime: tab.lifetime,
|
|
result: findInTree(fileTreeData, tab.id),
|
|
}))
|
|
.filter(x => !!x.result) as {
|
|
lifetime: Lifetime
|
|
result: FileTreeFindResult
|
|
}[]
|
|
|
|
const pathLookup = disambiguatePaths(
|
|
tabsFileTreeLookup.map(tab => tab.result),
|
|
fileTreeData
|
|
)
|
|
|
|
return tabsFileTreeLookup.map(tab => {
|
|
const entity = tab.result.entity
|
|
|
|
return {
|
|
id: entity._id,
|
|
name: entity.name,
|
|
displayPath: pathLookup.get(entity._id) || entity.name,
|
|
isLinkedFile:
|
|
isFileRefResult(tab.result) &&
|
|
!!tab.result.entity.linkedFileData?.provider,
|
|
lifetime: tab.lifetime,
|
|
}
|
|
})
|
|
}, [fileTreeData, openTabs, tabsEnabled])
|
|
|
|
const openTab = useCallback(
|
|
async (id: string) => {
|
|
if (!fileTreeData) {
|
|
return
|
|
}
|
|
const file = findInTree(fileTreeData, id)
|
|
if (!file) {
|
|
return
|
|
}
|
|
if (file.type === 'doc') {
|
|
await openDocWithId(file.entity._id)
|
|
} else if (file.type === 'fileRef') {
|
|
openFileWithId(file.entity._id)
|
|
} else {
|
|
debugConsole.error('Attempting to open invalid entity type')
|
|
}
|
|
},
|
|
[fileTreeData, openDocWithId, openFileWithId]
|
|
)
|
|
|
|
const closeTab = useCallback(
|
|
async (id: string) => {
|
|
if (openTabs.length <= 1) {
|
|
// Can't close last file
|
|
return
|
|
}
|
|
|
|
if (id === openEntity?.entity._id) {
|
|
const currentIndex = openTabs.findIndex(tab => tab.id === id)
|
|
if (currentIndex === -1) {
|
|
debugConsole.warn('Attempting to close tab that is not open')
|
|
return
|
|
}
|
|
|
|
const nextTab = openTabs[currentIndex + 1] || openTabs[currentIndex - 1]
|
|
if (!nextTab) {
|
|
debugConsole.warn('No next tab to switch to on close')
|
|
return
|
|
}
|
|
|
|
await openTab(nextTab.id)
|
|
}
|
|
|
|
setOpenTabs(current => current.filter(tab => tab.id !== id))
|
|
},
|
|
[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 })
|
|
if (sourceTabId === targetTabId) {
|
|
debugConsole.debug(
|
|
'Source and target tab ids are the same for moving tab'
|
|
)
|
|
return
|
|
}
|
|
setOpenTabs(current => {
|
|
const sourceTabIndex = current.findIndex(tab => tab.id === sourceTabId)
|
|
const targetTabIndex = current.findIndex(tab => tab.id === targetTabId)
|
|
if (sourceTabIndex === -1 || targetTabIndex === -1) {
|
|
debugConsole.warn('Invalid tab ids for moving tab')
|
|
return current
|
|
}
|
|
|
|
if (
|
|
(position === 'right' && targetTabIndex === sourceTabIndex - 1) ||
|
|
(position === 'left' && targetTabIndex === sourceTabIndex + 1)
|
|
) {
|
|
debugConsole.debug(
|
|
'Source and target tab are already adjacent for move'
|
|
)
|
|
return current
|
|
}
|
|
|
|
return arrayMove(current, sourceTabIndex, targetTabIndex, position)
|
|
})
|
|
},
|
|
[setOpenTabs]
|
|
)
|
|
|
|
const makeTabPermanent = useCallback(
|
|
(id: string) => {
|
|
setOpenTabs(current =>
|
|
current.map(tab =>
|
|
tab.id === id ? { ...tab, lifetime: 'permanent' } : tab
|
|
)
|
|
)
|
|
},
|
|
[setOpenTabs]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!tabsEnabled) {
|
|
return
|
|
}
|
|
|
|
if (!openEntity) {
|
|
return
|
|
}
|
|
|
|
setOpenTabs(current => {
|
|
if (current.find(t => t.id === openEntity?.entity._id)) {
|
|
return current
|
|
}
|
|
return [
|
|
...current.filter(tab => tab.lifetime !== 'temporary'),
|
|
{
|
|
id: openEntity.entity._id,
|
|
lifetime: previewTabs ? 'temporary' : 'permanent',
|
|
},
|
|
]
|
|
})
|
|
}, [openEntity, previewTabs, setOpenTabs, tabsEnabled])
|
|
|
|
const value = useMemo(
|
|
() => ({
|
|
tabs,
|
|
openTab,
|
|
closeTab,
|
|
closeOtherTabs,
|
|
moveTab,
|
|
makeTabPermanent,
|
|
contextMenuTarget,
|
|
setContextMenuTarget,
|
|
}),
|
|
[
|
|
tabs,
|
|
openTab,
|
|
closeTab,
|
|
closeOtherTabs,
|
|
moveTab,
|
|
makeTabPermanent,
|
|
contextMenuTarget,
|
|
setContextMenuTarget,
|
|
]
|
|
)
|
|
|
|
return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>
|
|
}
|
|
|
|
export const useTabsContext = () => {
|
|
const value = useContext(TabsContext)
|
|
if (!value) {
|
|
throw new Error('useTabsContext can only be used inside TabsProvider')
|
|
}
|
|
return value
|
|
}
|
|
|
|
function arrayMove<T>(
|
|
array: T[],
|
|
sourceIndex: number,
|
|
targetIndex: number,
|
|
side: 'left' | 'right'
|
|
): T[] {
|
|
const result = [...array]
|
|
|
|
const [movedItem] = result.splice(sourceIndex, 1)
|
|
|
|
let newIndex = targetIndex
|
|
if (sourceIndex < targetIndex) {
|
|
newIndex -= 1
|
|
}
|
|
|
|
if (side === 'right') {
|
|
newIndex += 1
|
|
}
|
|
|
|
result.splice(newIndex, 0, movedItem)
|
|
|
|
return result
|
|
}
|