| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- import { FC, RefObject, useCallback, useEffect, useMemo } from 'react'
- import { useTranslation } from 'react-i18next'
- import { Nav, TabContainer } from 'react-bootstrap'
- import { useLayoutContext } from '@/shared/context/layout-context'
- import {
- RailTabKey,
- useRailContext,
- } from '@/features/ide-react/context/rail-context'
- import FileTreeOutlinePanel from '@/features/file-tree/components/file-tree-outline-panel'
- import ChatPane from '@/features/chat/components/chat-pane'
- import ChatIndicator from '@/features/chat/components/chat-indicator'
- import getMeta from '@/utils/meta'
- import classNames from 'classnames'
- import IntegrationsPanel from '@/features/integrations-panel/integrations-panel'
- import { useChatContext } from '@/features/chat/context/chat-context'
- import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
- import {
- FullProjectSearchPanel,
- hasFullProjectSearch,
- } from '@/features/ide-react/components/rail/full-project-search-panel'
- import { sendSearchEvent } from '@/features/event-tracking/search-events'
- import { useProjectContext } from '@/shared/context/project-context'
- import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
- import RailHelpDropdown from './rail-help-dropdown'
- import RailTab from './rail-tab'
- import RailActionElement, { RailAction } from './rail-action-element'
- import { RailElement } from '@/features/ide-react/util/rail-types'
- import RailPanel from './rail-panel'
- import RailResizeHandle from './rail-resize-handle'
- import RailModals from './rail-modals'
- import RailOverflowDropdown from './rail-overflow-dropdown'
- import useRailOverflow from '@/features/ide-react/hooks/use-rail-overflow'
- import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
- import { shouldIncludeElement } from '@/features/ide-react/util/rail-utils'
- import { useEditorContext } from '@/shared/context/editor-context'
- import useEventListener from '@/shared/hooks/use-event-listener'
- const moduleRailEntries = (
- importOverleafModules('railEntries') as {
- import: { default: RailElement }
- path: string
- }[]
- ).map(({ import: { default: element } }) => element)
- const moduleRailPopovers = (
- importOverleafModules('railPopovers') as {
- import: {
- default: {
- key: string
- Component: FC<{ ref: RefObject<HTMLButtonElement> }>
- ref: RefObject<HTMLButtonElement>
- hide: boolean | (() => boolean)
- }
- }
- path: string
- }[]
- ).map(({ import: { default: element } }) => element)
- const moduleRailActions = (
- importOverleafModules('railActions') as {
- import: { default: RailAction }
- path: string
- }[]
- ).map(({ import: { default: action } }) => action)
- export const RailLayout = () => {
- const { sendEvent } = useEditorAnalytics()
- const { t } = useTranslation()
- const { selectedTab, openTab, isOpen, setIsOpen, togglePane, selectTab } =
- useRailContext()
- const { features } = useProjectContext()
- const { isRestrictedTokenMember } = useEditorContext()
- const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
- const { isOverleaf } = getMeta('ol-ExposedSettings')
- const { view, setSettingsShown, focusMode } = useLayoutContext()
- const { markMessagesAsRead } = useChatContext()
- const isHistoryView = view === 'history'
- useEventListener(
- 'ui:select-rail-tab',
- useCallback(
- (event: Event) => {
- const {
- detail: { tab, open },
- } = event as CustomEvent<{
- tab: RailTabKey
- open: boolean
- }>
- selectTab(tab)
- setIsOpen(open)
- },
- [selectTab, setIsOpen]
- )
- )
- const railTabs: RailElement[] = useMemo(
- () => [
- {
- key: 'file-tree',
- icon: 'description',
- title: t('file_tree'),
- component: <FileTreeOutlinePanel />,
- // NOTE: We always need to mount the file tree on first load
- // since it is responsible for opening the initial document.
- mountOnFirstLoad: true,
- },
- {
- key: 'full-project-search',
- icon: 'search',
- title: t('project_search'),
- component: <FullProjectSearchPanel />,
- hide: !hasFullProjectSearch,
- },
- {
- key: 'integrations',
- icon: 'integration_instructions',
- title: t('integrations'),
- component: <IntegrationsPanel />,
- hide: !isOverleaf && !gitBridgeEnabled,
- },
- {
- key: 'review-panel',
- icon: 'rate_review',
- title: t('review_panel'),
- component: null,
- hide: !features.trackChangesVisible,
- disabled: view !== 'editor',
- },
- {
- key: 'chat',
- icon: 'forum',
- component: <ChatPane />,
- indicator: <ChatIndicator />,
- title: t('chat'),
- hide:
- !getMeta('ol-capabilities')?.includes('chat') ||
- isRestrictedTokenMember,
- },
- ...moduleRailEntries,
- ],
- [
- t,
- features.trackChangesVisible,
- view,
- isRestrictedTokenMember,
- isOverleaf,
- gitBridgeEnabled,
- ]
- )
- const railActions: RailAction[] = useMemo(
- () => [
- {
- key: 'support',
- icon: 'help',
- title: t('help'),
- dropdown: <RailHelpDropdown />,
- },
- ...moduleRailActions,
- {
- key: 'settings',
- icon: 'settings',
- title: t('settings'),
- action: () => {
- sendEvent('rail-click', { tab: 'settings' })
- setSettingsShown(true)
- },
- },
- ],
- [setSettingsShown, t, sendEvent]
- )
- useCommandProvider(
- () => [
- {
- id: 'open-settings',
- handler: () => {
- setSettingsShown(true)
- },
- menuLabel: t('settings'),
- label: t('open_settings'),
- },
- ],
- [t, setSettingsShown]
- )
- const onTabSelect = useCallback(
- (key: string | null) => {
- if (key === selectedTab) {
- togglePane()
- sendEvent('rail-click', { tab: key, type: 'toggle' })
- } else {
- // HACK: Apparently the onSelect event is triggered with href attributes
- // from DropdownItems
- if (
- !railTabs.some(tab =>
- typeof tab.hide === 'function'
- ? !tab.hide()
- : !tab.hide && tab.key === key
- )
- ) {
- // Attempting to open a non-existent tab
- return
- }
- const keyOrDefault = (key ?? 'file-tree') as RailTabKey
- // Change the selected tab and make sure it's open
- openTab(keyOrDefault)
- sendEvent('rail-click', { tab: keyOrDefault })
- if (keyOrDefault === 'full-project-search') {
- sendSearchEvent('search-open', {
- searchType: 'full-project',
- method: 'button',
- location: 'rail',
- })
- }
- if (key === 'chat') {
- markMessagesAsRead()
- }
- }
- },
- [openTab, togglePane, selectedTab, railTabs, sendEvent, markMessagesAsRead]
- )
- useEffect(() => {
- const validTabKeys = railTabs
- .filter(shouldIncludeElement)
- .map(tab => tab.key)
- if (!validTabKeys.includes(selectedTab) && isOpen) {
- // If the selected tab is no longer valid (e.g. due to permissions changes),
- // switch back to the file tree
- openTab('file-tree')
- }
- }, [railTabs, selectedTab, openTab, isOpen])
- const isReviewPanelOpen =
- selectedTab === 'review-panel' && isOpen && !isHistoryView
- const { tabsInRail, tabsInOverflow, tabWrapperRef } =
- useRailOverflow(railTabs)
- const moreOptionsAction: RailAction = useMemo(() => {
- return {
- key: 'more-options',
- icon: 'more_vert',
- title: t('more_options'),
- hide: tabsInOverflow.length === 0,
- dropdown: (
- <RailOverflowDropdown
- tabs={tabsInOverflow}
- isOpen={isOpen}
- selectedTab={selectedTab}
- />
- ),
- }
- }, [t, isOpen, selectedTab, tabsInOverflow])
- return (
- <TabContainer
- mountOnEnter // Only render when necessary (so that we can lazy load tab content)
- unmountOnExit={false} // TODO: Should we unmount the tabs when they're not used?
- transition={false}
- activeKey={selectedTab}
- onSelect={onTabSelect}
- id="ide-rail-tabs"
- >
- {/* The <Nav> element is a "div" and has a "role="tablist"".
- But it should be identified as a navigation landmark.
- Therefore, we nest them: the parent <nav> is the landmark, and its child gets the "role="tablist"". */}
- <nav
- className={classNames('ide-rail', {
- hidden: isHistoryView || focusMode,
- })}
- aria-label={t('sidebar')}
- >
- <Nav activeKey={selectedTab} className="ide-rail-tabs-nav">
- <div className="ide-rail-tabs-wrapper" ref={tabWrapperRef}>
- {tabsInRail
- .filter(shouldIncludeElement)
- .map(({ icon, key, indicator, title, disabled, ref, tab }) => {
- const Component = tab ?? RailTab
- return (
- <Component
- open={isOpen && selectedTab === key}
- key={key}
- eventKey={key}
- icon={icon}
- indicator={indicator}
- title={title}
- disabled={disabled}
- ref={ref}
- />
- )
- })}
- <RailActionElement key="more-options" action={moreOptionsAction} />
- </div>
- <nav aria-label={t('help_editor_settings')}>
- {railActions.map(action => (
- <RailActionElement
- key={action.key}
- action={action}
- ref={action.ref}
- />
- ))}
- </nav>
- </Nav>
- </nav>
- {!focusMode &&
- moduleRailPopovers
- .filter(shouldIncludeElement)
- .map(({ key, Component, ref }) => <Component key={key} ref={ref} />)}
- <RailPanel
- isReviewPanelOpen={isReviewPanelOpen}
- isHistoryView={isHistoryView}
- railTabs={railTabs}
- focusMode={focusMode}
- />
- <RailResizeHandle
- isReviewPanelOpen={isReviewPanelOpen}
- focusMode={focusMode}
- />
- <RailModals />
- </TabContainer>
- )
- }
|