Editor Redesign Cleanup: main layout, toolbar, and rail (#31031)

* feat: integrate  main layout, toolbar, and rail from redesign into main ide-react folder

* feat: remove additional files no longer used after ide redesign

GitOrigin-RevId: 8fd77f63cb9c67be91995a9dde13b0fe2376d80f
This commit is contained in:
Jimmy Domagala-Tang
2026-02-03 09:06:37 +00:00
committed by Copybot
parent c790449196
commit fc8d564320
59 changed files with 171 additions and 845 deletions
@@ -1,123 +0,0 @@
import React, { FC, useState } from 'react'
import { Panel, PanelGroup } from 'react-resizable-panels'
import NoSelectionPane from '@/features/ide-react/components/editor/no-selection-pane'
import FileView from '@/features/file-view/components/file-view'
import MultipleSelectionPane from '@/features/ide-react/components/editor/multiple-selection-pane'
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
import { DefaultSynctexControl } from '@/features/pdf-preview/components/detach-synctex-control'
import PdfPreview from '@/features/pdf-preview/components/pdf-preview'
import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import { fileViewFile } from '@/features/ide-react/util/file-view'
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
import { EditorPane } from '@/features/ide-react/components/editor/editor-pane'
export const EditorAndPdf: FC = () => {
const [resizing, setResizing] = useState(false)
const { t } = useTranslation()
const {
togglePdfPane,
handlePdfPaneExpand,
handlePdfPaneCollapse,
setPdfIsOpen,
pdfIsOpen,
pdfPanelRef,
} = usePdfPane()
const { view, pdfLayout } = useLayoutContext()
const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
const editorIsOpen =
view === 'editor' || view === 'file' || pdfLayout === 'sideBySide'
return (
<PanelGroup
autoSaveId="ide-editor-pdf-layout"
direction="horizontal"
className={classNames({
'ide-panel-group-resizing': resizing,
hidden: view === 'history',
})}
>
{/* ide */}
<Panel
id="panel-ide"
order={1}
defaultSize={50}
minSize={5}
className={classNames('ide-react-panel', {
'ide-panel-group-resizing': resizing,
hidden: !editorIsOpen,
})}
tagName="section"
aria-label={t('editor')}
>
{selectedEntityCount === 0 && <NoSelectionPane />}
{selectedEntityCount === 1 && openEntity?.type === 'fileRef' && (
<FileView
file={fileViewFile(openEntity.entity)}
key={openEntity.entity._id}
/>
)}
{selectedEntityCount > 1 && (
<MultipleSelectionPane selectedEntityCount={selectedEntityCount} />
)}
<EditorPane />
</Panel>
<HorizontalResizeHandle
resizable={pdfLayout === 'sideBySide'}
onDoubleClick={togglePdfPane}
onDragging={setResizing}
className={classNames({
hidden: !editorIsOpen,
})}
hitAreaMargins={{ coarse: 0, fine: 0 }}
>
<HorizontalToggler
id="editor-pdf"
togglerType="east"
isOpen={pdfIsOpen}
setIsOpen={setPdfIsOpen}
tooltipWhenOpen={t('tooltip_hide_pdf')}
tooltipWhenClosed={t('tooltip_show_pdf')}
/>
{pdfLayout === 'sideBySide' && (
<div className="synctex-controls">
<DefaultSynctexControl />
</div>
)}
</HorizontalResizeHandle>
{/* pdf */}
<Panel
ref={pdfPanelRef}
id="panel-pdf"
order={2}
defaultSize={50}
minSize={5}
collapsible
onCollapse={handlePdfPaneCollapse}
onExpand={handlePdfPaneExpand}
className="ide-react-panel"
tagName="section"
aria-label={t('pdf_preview_logs')}
>
<PdfPreview />
{/* ensure that "sync to code" is available in PDF only layout */}
{pdfLayout === 'flat' && view === 'pdf' && (
<div className="synctex-controls" hidden>
<DefaultSynctexControl />
</div>
)}
</Panel>
</PanelGroup>
)
}
@@ -1,43 +0,0 @@
import { useState, useCallback } from 'react'
import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import EditorNavigationToolbarRoot from '@/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root'
import ShareProjectModal from '@/features/share-project-modal/components/share-project-modal'
import EditorOverLimitModal from '@/features/share-project-modal/components/editor-over-limit-modal'
import ViewOnlyAccessModal from '@/features/share-project-modal/components/view-only-access-modal'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
function EditorNavigationToolbar() {
const [showShareModal, setShowShareModal] = useState(false)
const { onlineUsersArray } = useOnlineUsersContext()
const { openDoc } = useEditorManagerContext()
const { sendEventOnce } = useEditorAnalytics()
const handleOpenShareModal = useCallback(() => {
sendEventOnce('ide-open-share-modal-once')
setShowShareModal(true)
}, [sendEventOnce])
const handleHideShareModal = useCallback(() => {
setShowShareModal(false)
}, [])
return (
<>
<EditorNavigationToolbarRoot
onlineUsersArray={onlineUsersArray}
openDoc={openDoc}
openShareProjectModal={handleOpenShareModal}
/>
<EditorOverLimitModal />
<ViewOnlyAccessModal />
<ShareProjectModal
show={showShareModal}
handleOpen={handleOpenShareModal}
handleHide={handleHideShareModal}
/>
</>
)
}
export default EditorNavigationToolbar
@@ -1,60 +0,0 @@
import { Panel, PanelGroup } from 'react-resizable-panels'
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
import { FileTree } from '@/features/ide-react/components/file-tree'
import classNames from 'classnames'
import { useLayoutContext } from '@/shared/context/layout-context'
import { OutlineContainer } from '@/features/outline/components/outline-container'
import { useOutlinePane } from '@/features/ide-react/hooks/use-outline-pane'
import React, { ElementType } from 'react'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { t } from 'i18next'
const editorSidebarComponents = importOverleafModules(
'editorSidebarComponents'
) as { import: { default: ElementType }; path: string }[]
export default function EditorSidebar() {
const { view } = useLayoutContext()
const { outlineEnabled, outlinePanelRef } = useOutlinePane()
return (
<nav
className={classNames('ide-react-editor-sidebar', {
hidden: view === 'history',
})}
aria-label={t('project_files_outline')}
>
{editorSidebarComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
<PanelGroup autoSaveId="ide-editor-sidebar-layout" direction="vertical">
<Panel
defaultSize={50}
minSize={25}
className="ide-react-file-tree-panel"
id="panel-file-tree"
order={1}
>
<FileTree />
</Panel>
<VerticalResizeHandle disabled={!outlineEnabled} />
<Panel
defaultSize={50}
maxSize={75}
id="panel-outline"
order={2}
collapsible
ref={outlinePanelRef}
style={{ minHeight: 32 }} // keep the header visible
>
<OutlineContainer />
</Panel>
</PanelGroup>
</nav>
)
}
@@ -1,62 +0,0 @@
import { Panel, PanelGroup } from 'react-resizable-panels'
import React, { FC, lazy, Suspense } from 'react'
import SourceEditor from '@/features/source-editor/components/source-editor'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
import classNames from 'classnames'
import { EditorLoadingPane } from '@/features/ide-react/components/editor/editor-loading-pane'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
const SymbolPalettePane = lazy(
() => import('@/features/ide-react/components/editor/symbol-palette-pane')
)
export const EditorPane: FC = () => {
const { showSymbolPalette } = useEditorPropertiesContext()
const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
const { isLoading } = useEditorManagerContext()
const { currentDocumentId } = useEditorOpenDocContext()
if (!currentDocumentId) {
return null
}
return (
<div
className={classNames('ide-react-editor-content', 'full-size', {
hidden: openEntity?.type !== 'doc' || selectedEntityCount !== 1,
})}
>
<PanelGroup autoSaveId="ide-editor-layout" direction="vertical">
<Panel
id="panel-source-editor"
order={1}
className="ide-react-editor-panel"
>
<SourceEditor />
{isLoading && <EditorLoadingPane />}
</Panel>
{showSymbolPalette && (
<>
<VerticalResizeHandle id="editor-symbol-palette" />
<Panel
id="panel-symbol-palette"
order={2}
defaultSize={25}
minSize={10}
maxSize={50}
>
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<SymbolPalettePane />
</Suspense>
</Panel>
</>
)}
</PanelGroup>
</div>
)
}
@@ -0,0 +1,26 @@
import NoSelectionPane from '@/features/ide-react/components/editor/no-selection-pane'
import { Editor } from '@/features/ide-redesign/components/editor'
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
import FileView from '@/features/file-view/components/file-view'
import { fileViewFile } from '@/features/ide-react/util/file-view'
import MultipleSelectionPane from '@/features/ide-react/components/editor/multiple-selection-pane'
export default function EditorPanel() {
const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
return (
<div className="ide-redesign-editor-container">
{selectedEntityCount === 0 && <NoSelectionPane />}
{selectedEntityCount === 1 && openEntity?.type === 'fileRef' && (
<FileView
file={fileViewFile(openEntity.entity)}
key={openEntity.entity._id}
/>
)}
{selectedEntityCount > 1 && (
<MultipleSelectionPane selectedEntityCount={selectedEntityCount} />
)}
<Editor />
</div>
)
}
@@ -1,7 +1,4 @@
import { lazy, Suspense } from 'react'
import { Alerts } from '@/features/ide-react/components/alerts/alerts'
import { MainLayout } from '@/features/ide-react/components/layout/main-layout'
import EditorLeftMenu from '@/features/editor-left-menu/components/editor-left-menu'
import { useLayoutEventTracking } from '@/features/ide-react/hooks/use-layout-event-tracking'
import useSocketListeners from '@/features/ide-react/hooks/use-socket-listeners'
import { useEditingSessionHeartbeat } from '@/features/ide-react/hooks/use-editing-session-heartbeat'
@@ -10,21 +7,13 @@ import { useHasLintingError } from '@/features/ide-react/hooks/use-has-linting-e
import { Modals } from '@/features/ide-react/components/modals/modals'
import { GlobalAlertsProvider } from '@/features/ide-react/context/global-alerts-context'
import { GlobalToasts } from '../global-toasts'
import {
canUseNewEditor,
useIsNewEditorEnabled,
} from '@/features/ide-redesign/utils/new-editor-utils'
import EditorSurvey from '../editor-survey'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { useStatusFavicon } from '@/features/ide-react/hooks/use-status-favicon'
import useThemedPage from '@/shared/hooks/use-themed-page'
const MainLayoutNew = lazy(
() => import('@/features/ide-redesign/components/main-layout')
)
const SettingsModalNew = lazy(
() => import('@/features/ide-redesign/components/settings/settings-modal')
)
import MainLayout from '@/features/ide-react/components/layout/main-layout'
import SettingsModalNew from '@/features/ide-redesign/components/settings/settings-modal'
export default function IdePage() {
useLayoutEventTracking() // sent event when the layout changes
@@ -35,26 +24,15 @@ export default function IdePage() {
useStatusFavicon() // update the favicon based on the compile status
useThemedPage() // set the page theme based on user settings
const newEditor = useIsNewEditorEnabled()
const canAccessNewEditor = canUseNewEditor()
const editorSurveyFlag = useFeatureFlag('editor-popup-ux-survey')
const showEditorSurvey = editorSurveyFlag && !canAccessNewEditor
const showEditorSurvey = editorSurveyFlag
return (
<GlobalAlertsProvider>
<Alerts />
<Modals />
{newEditor ? (
<Suspense fallback={null}>
<SettingsModalNew />
<MainLayoutNew />
</Suspense>
) : (
<>
<EditorLeftMenu />
<MainLayout />
</>
)}
<SettingsModalNew />
<MainLayout />
<GlobalToasts />
{showEditorSurvey && <EditorSurvey />}
</GlobalAlertsProvider>
@@ -1,134 +1,136 @@
import { Panel, PanelGroup } from 'react-resizable-panels'
import { ElementType, FC } from 'react'
import { HorizontalResizeHandle } from '../resize/horizontal-resize-handle'
import classNames from 'classnames'
import { useLayoutContext } from '@/shared/context/layout-context'
import EditorNavigationToolbar from '@/features/ide-react/components/editor-navigation-toolbar'
import ChatPane from '@/features/chat/components/chat-pane'
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
import PdfPreview from '@/features/pdf-preview/components/pdf-preview'
import { RailLayout } from '../rail/rail'
import { Toolbar } from '../toolbar/toolbar'
import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
import { HistorySidebar } from '@/features/ide-react/components/history-sidebar'
import EditorSidebar from '@/features/ide-react/components/editor-sidebar'
import { useTranslation } from 'react-i18next'
import { useSidebarPane } from '@/features/ide-react/hooks/use-sidebar-pane'
import { useChatPane } from '@/features/ide-react/hooks/use-chat-pane'
import { EditorAndPdf } from '@/features/ide-react/components/editor-and-pdf'
import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
import { useLayoutContext } from '@/shared/context/layout-context'
import { ElementType, useState } from 'react'
import EditorPanel from '../editor/editor-panel'
import { useRailContext } from '../../context/rail-context'
import HistoryContainer from '@/features/ide-react/components/history-container'
import getMeta from '@/utils/meta'
import { useEditorContext } from '@/shared/context/editor-context'
import { DefaultSynctexControl } from '@/features/pdf-preview/components/detach-synctex-control'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
const mainEditorLayoutPanels: Array<{
import: { default: ElementType }
path: string
}> = importOverleafModules('mainEditorLayoutPanels')
const mainEditorLayoutModalsModules: Array<{
import: { default: ElementType }
path: string
}> = importOverleafModules('mainEditorLayoutModals')
export const MainLayout: FC = () => {
const { view } = useLayoutContext()
const { isRestrictedTokenMember } = useEditorContext()
export default function MainLayout() {
const [resizing, setResizing] = useState(false)
const { resizing: railResizing } = useRailContext()
const {
isOpen: sidebarIsOpen,
setIsOpen: setSidebarIsOpen,
panelRef: sidebarPanelRef,
togglePane: toggleSidebar,
handlePaneExpand: handleSidebarExpand,
handlePaneCollapse: handleSidebarCollapse,
resizing: sidebarResizing,
setResizing: setSidebarResizing,
} = useSidebarPane()
togglePdfPane,
handlePdfPaneExpand,
handlePdfPaneCollapse,
setPdfIsOpen: setIsPdfOpen,
pdfIsOpen: isPdfOpen,
pdfPanelRef,
} = usePdfPane()
const { view, pdfLayout } = useLayoutContext()
const {
isOpen: chatIsOpen,
panelRef: chatPanelRef,
togglePane: toggleChat,
resizing: chatResizing,
setResizing: setChatResizing,
handlePaneCollapse: handleChatCollapse,
handlePaneExpand: handleChatExpand,
} = useChatPane()
const chatEnabled =
getMeta('ol-capabilities')?.includes('chat') && !isRestrictedTokenMember
const editorIsOpen =
view === 'editor' || view === 'file' || pdfLayout === 'sideBySide'
const { t } = useTranslation()
return (
<div className="ide-react-main">
<EditorNavigationToolbar />
<div className="ide-react-body">
<div className="ide-redesign-main">
<Toolbar />
<div className="ide-redesign-body">
<PanelGroup
autoSaveId="ide-outer-layout"
autoSaveId="ide-redesign-outer-layout"
direction="horizontal"
className={classNames({
'ide-panel-group-resizing': sidebarResizing || chatResizing,
className={classNames('ide-redesign-inner', {
'ide-panel-group-resizing': resizing || railResizing,
})}
>
{/* sidebar */}
<Panel
ref={sidebarPanelRef}
id="panel-sidebar"
order={1}
defaultSize={15}
minSize={5}
maxSize={80}
collapsible
onCollapse={handleSidebarCollapse}
onExpand={handleSidebarExpand}
>
<EditorSidebar />
{view === 'history' && <HistorySidebar />}
</Panel>
<HorizontalResizeHandle
onDoubleClick={toggleSidebar}
resizable={sidebarIsOpen}
onDragging={setSidebarResizing}
hitAreaMargins={{ coarse: 0, fine: 0 }}
>
<HorizontalToggler
id="panel-sidebar"
togglerType="west"
isOpen={sidebarIsOpen}
setIsOpen={setSidebarIsOpen}
tooltipWhenOpen={t('tooltip_hide_filetree')}
tooltipWhenClosed={t('tooltip_show_filetree')}
/>
</HorizontalResizeHandle>
<Panel id="panel-outer-main" order={2}>
<PanelGroup autoSaveId="ide-inner-layout" direction="horizontal">
<Panel className="ide-react-panel" id="panel-main" order={1}>
<HistoryContainer />
<EditorAndPdf />
<RailLayout />
<Panel id="ide-redesign-editor-and-pdf-panel" order={2}>
<HistoryContainer />
<PanelGroup
autoSaveId="ide-redesign-editor-and-pdf-panel-group"
direction="horizontal"
className={classNames({
hidden: view === 'history',
})}
>
<Panel
id="ide-redesign-editor-panel"
order={1}
className={classNames({
hidden: !editorIsOpen || view === 'history',
})}
minSize={5}
defaultSize={50}
tagName="section"
aria-label={t('editor')}
>
<div className="ide-redesign-editor-container">
<EditorPanel />
</div>
</Panel>
<HorizontalResizeHandle
resizable={pdfLayout === 'sideBySide'}
onDragging={setResizing}
onDoubleClick={togglePdfPane}
hitAreaMargins={{ coarse: 0, fine: 0 }}
className={classNames({
hidden: !editorIsOpen,
})}
>
<HorizontalToggler
id="ide-redesign-pdf-panel"
togglerType="east"
isOpen={isPdfOpen}
setIsOpen={setIsPdfOpen}
tooltipWhenOpen={t('tooltip_hide_pdf')}
tooltipWhenClosed={t('tooltip_show_pdf')}
/>
{pdfLayout === 'sideBySide' && (
<div className="synctex-controls">
<DefaultSynctexControl />
</div>
)}
</HorizontalResizeHandle>
<Panel
collapsible
className={classNames('ide-redesign-pdf-container', {
hidden: view === 'history',
})}
id="ide-redesign-pdf-panel"
order={2}
defaultSize={50}
minSize={5}
ref={pdfPanelRef}
onExpand={handlePdfPaneExpand}
onCollapse={handlePdfPaneCollapse}
tagName="section"
aria-label={t('pdf_preview')}
>
<PdfPreview />
{pdfLayout === 'flat' && view === 'pdf' && (
<div className="synctex-controls" hidden>
<DefaultSynctexControl />
</div>
)}
</Panel>
{chatEnabled && (
<>
<HorizontalResizeHandle
onDoubleClick={toggleChat}
resizable={chatIsOpen}
onDragging={setChatResizing}
hitAreaMargins={{ coarse: 0, fine: 0 }}
/>
{/* chat */}
<Panel
ref={chatPanelRef}
id="panel-chat"
order={2}
defaultSize={20}
minSize={5}
maxSize={30}
collapsible
onCollapse={handleChatCollapse}
onExpand={handleChatExpand}
>
<ChatPane />
</Panel>
</>
)}
</PanelGroup>
</Panel>
{mainEditorLayoutPanels.map(
({ import: { default: Component }, path }, i) => {
return <Component key={path} order={i + 3} />
}
)}
</PanelGroup>
</div>
{mainEditorLayoutModalsModules.map(
@@ -0,0 +1,98 @@
import MaterialIcon, {
AvailableUnfilledIcon,
} from '@/shared/components/material-icon'
import { forwardRef, ReactElement, useCallback } from 'react'
import {
Dropdown,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import { DropdownToggle as BS5DropdownToggle } from 'react-bootstrap'
type RailActionButton = {
key: string
icon: AvailableUnfilledIcon
title: string
action: () => void
hide?: boolean
ref?: React.Ref<HTMLButtonElement>
}
type RailDropdown = {
key: string
icon: AvailableUnfilledIcon
title: string
dropdown: ReactElement
hide?: boolean
ref?: React.Ref<HTMLButtonElement>
}
export type RailAction = RailDropdown | RailActionButton
const RailActionElement = forwardRef<HTMLButtonElement, { action: RailAction }>(
({ action }, ref) => {
const onActionClick = useCallback(() => {
if ('action' in action) {
action.action()
}
}, [action])
if (action.hide) {
return null
}
if ('dropdown' in action) {
return (
<Dropdown align="end" drop="end">
<OLTooltip
id={`rail-dropdown-tooltip-${action.key}`}
description={action.title}
overlayProps={{ delay: 0, placement: 'right' }}
>
<span>
<DropdownToggle
ref={ref as React.ForwardedRef<typeof BS5DropdownToggle>}
id={`rail-dropdown-btn-${action.key}`}
className="ide-rail-tab-link ide-rail-tab-button ide-rail-tab-dropdown"
as="button"
aria-label={action.title}
>
<MaterialIcon
className="ide-rail-tab-link-icon"
type={action.icon}
unfilled
/>
</DropdownToggle>
</span>
</OLTooltip>
{action.dropdown}
</Dropdown>
)
} else {
return (
<OLTooltip
id={`rail-tab-tooltip-${action.key}`}
description={action.title}
overlayProps={{ delay: 0, placement: 'right' }}
>
<button
ref={ref}
onClick={onActionClick}
className="ide-rail-tab-link ide-rail-tab-button"
aria-label={action.title}
>
<MaterialIcon
className="ide-rail-tab-link-icon"
type={action.icon}
unfilled
/>
</button>
</OLTooltip>
)
}
}
)
RailActionElement.displayName = 'RailActionElement'
export default RailActionElement
@@ -0,0 +1,43 @@
import getMeta from '@/utils/meta'
import { useTranslation } from 'react-i18next'
import { useRailContext } from '@/features/ide-react/context/rail-context'
import { useCallback } from 'react'
import {
DropdownDivider,
DropdownItem,
DropdownMenu,
} from '@/shared/components/dropdown/dropdown-menu'
export default function RailHelpDropdown() {
const showSupport = getMeta('ol-showSupport')
const { t } = useTranslation()
const { setActiveModal } = useRailContext()
const openKeyboardShortcutsModal = useCallback(() => {
setActiveModal('keyboard-shortcuts')
}, [setActiveModal])
const openContactUsModal = useCallback(() => {
setActiveModal('contact-us')
}, [setActiveModal])
return (
<DropdownMenu>
<DropdownItem onClick={openKeyboardShortcutsModal}>
{t('keyboard_shortcuts')}
</DropdownItem>
<DropdownItem
href="/learn"
role="menuitem"
target="_blank"
rel="noopener noreferrer"
>
{t('documentation')}
</DropdownItem>
<DropdownDivider />
{showSupport && (
<DropdownItem onClick={openContactUsModal}>
{t('contact_us')}
</DropdownItem>
)}
</DropdownMenu>
)
}
@@ -0,0 +1,17 @@
import OLBadge from '@/shared/components/ol/ol-badge'
type RailIndicatorProps = {
type: 'danger' | 'warning' | 'info'
count: number
}
function formatNumber(num: number) {
if (num > 99) {
return '99+'
}
return Math.floor(num).toString()
}
export const RailIndicator = ({ count, type }: RailIndicatorProps) => {
return <OLBadge bg={type}>{formatNumber(count)}</OLBadge>
}
@@ -0,0 +1,34 @@
import { FC } from 'react'
import {
RailModalKey,
useRailContext,
} from '@/features/ide-react/context/rail-context'
import { RailHelpContactUsModal } from '@/features/ide-redesign/components/help/contact-us'
import { RailHelpShowHotkeysModal } from '@/features/ide-redesign/components/help/keyboard-shortcuts'
import DictionarySettingsModal from '@/features/ide-redesign/components/settings/editor-settings/dictionary-settings-modal'
const RAIL_MODALS: {
key: RailModalKey
modalComponentFunction: FC<{ show: boolean }>
}[] = [
{
key: 'keyboard-shortcuts',
modalComponentFunction: RailHelpShowHotkeysModal,
},
{
key: 'contact-us',
modalComponentFunction: RailHelpContactUsModal,
},
{
key: 'dictionary',
modalComponentFunction: DictionarySettingsModal,
},
]
export default function RailModals() {
const { activeModal } = useRailContext()
return RAIL_MODALS.map(({ key, modalComponentFunction: Component }) => (
<Component key={key} show={activeModal === key} />
))
}
@@ -0,0 +1,33 @@
import { DropdownMenu } from '@/shared/components/dropdown/dropdown-menu'
import { RailTabKey } from '@/features/ide-react/context/rail-context'
import { RailElement } from '@/features/ide-react/util/rail-types'
import RailTab from './rail-tab'
import { shouldIncludeElement } from '@/features/ide-react/util/rail-utils'
export default function RailOverflowDropdown({
tabs,
isOpen,
selectedTab,
}: {
tabs: RailElement[]
isOpen: boolean
selectedTab: RailTabKey
}) {
return (
<DropdownMenu className="ide-rail-overflow-dropdown">
{tabs
.filter(shouldIncludeElement)
.map(({ icon, key, indicator, title, disabled }) => (
<RailTab
open={isOpen && selectedTab === key}
key={key}
eventKey={key}
icon={icon}
indicator={indicator}
title={title}
disabled={disabled}
/>
))}
</DropdownMenu>
)
}
@@ -0,0 +1,48 @@
import { useTranslation } from 'react-i18next'
import { useRailContext } from '@/features/ide-react/context/rail-context'
import OLIconButton from '@/shared/components/ol/ol-icon-button'
import React, { useCallback } from 'react'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
export default function RailPanelHeader({
title,
actions,
onClose,
}: {
title: React.ReactNode
actions?: React.ReactElement
onClose?: () => void
}) {
const { t } = useTranslation()
const { handlePaneCollapse } = useRailContext()
const handleClose = useCallback(() => {
handlePaneCollapse()
if (onClose) {
onClose()
}
}, [handlePaneCollapse, onClose])
return (
<div className="rail-panel-header">
<h4 className="rail-panel-title">{title}</h4>
<div className="rail-panel-header-actions">
{actions}
<OLTooltip
id="close-rail-panel"
description={t('close')}
overlayProps={{ placement: 'bottom' }}
>
<OLIconButton
onClick={handleClose}
className="rail-panel-header-button-subdued"
icon="close"
accessibilityLabel={t('close')}
size="sm"
/>
</OLTooltip>
</div>
</div>
)
}
@@ -0,0 +1,70 @@
import { Panel } from 'react-resizable-panels'
import { useRailContext } from '@/features/ide-react/context/rail-context'
import classNames from 'classnames'
import { useCallback, useMemo } from 'react'
import usePreviousValue from '@/shared/hooks/use-previous-value'
import { HistorySidebar } from '@/features/ide-react/components/history-sidebar'
import { Tab } from 'react-bootstrap'
import { RailElement } from '@/features/ide-react/util/rail-types'
import { shouldIncludeElement } from '@/features/ide-react/util/rail-utils'
export default function RailPanel({
isReviewPanelOpen,
isHistoryView,
railTabs,
}: {
isReviewPanelOpen: boolean
isHistoryView: boolean
railTabs: RailElement[]
}) {
const { selectedTab, panelRef, handlePaneExpand, handlePaneCollapse } =
useRailContext()
const prevTab = usePreviousValue(selectedTab)
const tabHasChanged = useMemo(() => {
return prevTab !== selectedTab
}, [prevTab, selectedTab])
const onCollapse = useCallback(() => {
if (!tabHasChanged) {
handlePaneCollapse()
}
}, [tabHasChanged, handlePaneCollapse])
return (
<Panel
id={`ide-redesign-sidebar-panel-${isHistoryView ? 'file-tree' : selectedTab}`}
className={classNames({ hidden: isReviewPanelOpen })}
order={1}
defaultSize={selectedTab === 'workbench' ? 20 : 15}
minSize={5}
maxSize={80}
ref={panelRef}
collapsible
onCollapse={onCollapse}
onExpand={handlePaneExpand}
>
{isHistoryView && <HistorySidebar />}
<div
className={classNames('ide-rail-content', {
hidden: isHistoryView,
})}
>
<Tab.Content className="ide-rail-tab-content">
{railTabs
.filter(shouldIncludeElement)
.map(({ key, component, mountOnFirstLoad }) => (
<Tab.Pane
eventKey={key}
key={key}
mountOnEnter={!mountOnFirstLoad}
>
{component}
</Tab.Pane>
))}
</Tab.Content>
</div>
</Panel>
)
}
@@ -0,0 +1,33 @@
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
import classNames from 'classnames'
import { useRailContext } from '@/features/ide-react/context/rail-context'
import { useTranslation } from 'react-i18next'
export default function RailResizeHandle({
isReviewPanelOpen,
}: {
isReviewPanelOpen: boolean
}) {
const { isOpen, setIsOpen, togglePane, setResizing } = useRailContext()
const { t } = useTranslation()
return (
<HorizontalResizeHandle
className={classNames({ hidden: isReviewPanelOpen })}
resizable
hitAreaMargins={{ coarse: 0, fine: 0 }}
onDoubleClick={togglePane}
onDragging={setResizing}
>
<HorizontalToggler
id="ide-redesign-sidebar-panel"
togglerType="west"
isOpen={isOpen}
setIsOpen={setIsOpen}
tooltipWhenOpen={t('tooltip_hide_panel')}
tooltipWhenClosed={t('tooltip_show_panel')}
/>
</HorizontalResizeHandle>
)
}
@@ -0,0 +1,71 @@
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import classNames from 'classnames'
import { ComponentProps, forwardRef, ReactElement } from 'react'
import { NavLink } from 'react-bootstrap'
import { RailElement } from '@/features/ide-react/util/rail-types'
const RailTab = forwardRef<
HTMLAnchorElement,
{
icon: RailElement['icon']
eventKey?: string
open: boolean
indicator?: ReactElement
title: string
} & ComponentProps<'button'>
>(({ icon, eventKey, className, open, indicator, title, ...props }, ref) => {
return (
<OLTooltip
id={`rail-tab-tooltip-${eventKey}`}
description={title}
overlayProps={{ delay: 0, placement: 'right' }}
>
<NavLink
{...props}
ref={ref}
eventKey={eventKey}
className={classNames('ide-rail-tab-link', className, {
'open-rail': open,
})}
>
<RailTabIcon icon={icon} title={title} open={open} />
{indicator}
</NavLink>
</OLTooltip>
)
})
const RailTabIcon = ({
icon,
title,
open,
}: {
icon: RailElement['icon']
title: string
open: boolean
}) => {
if (typeof icon === 'string') {
return open ? (
<MaterialIcon
type={icon}
className="ide-rail-tab-link-icon"
accessibilityLabel={title}
/>
) : (
<MaterialIcon
type={icon}
className="ide-rail-tab-link-icon"
unfilled
accessibilityLabel={title}
/>
)
} else {
const Component = icon
return <Component open={open} title={title} />
}
}
RailTab.displayName = 'RailTab'
export default RailTab
@@ -0,0 +1,327 @@
import { FC, RefObject, useCallback, useEffect, useMemo, useRef } 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/ide-redesign/components/file-tree/file-tree-outline-panel'
import {
ChatIndicator,
ChatPane,
} from '@/features/ide-redesign/components/chat/chat'
import getMeta from '@/utils/meta'
import classNames from 'classnames'
import IntegrationsPanel from '@/features/ide-redesign/components/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-redesign/components/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-redesign/hooks/use-rail-overflow'
import EditorTourRailTooltip from '@/features/ide-redesign/components/editor-tour/editor-tour-rail-tooltip'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import EditorTourThemeTooltip from '@/features/ide-redesign/components/editor-tour/editor-tour-theme-tooltip'
import EditorTourGotQuestionsTooltip from '@/features/ide-redesign/components/editor-tour/editor-tour-got-questions'
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<HTMLAnchorElement> }>
ref: RefObject<HTMLAnchorElement>
hide: boolean | (() => boolean)
}
}
path: string
}[]
).map(({ import: { default: element } }) => element)
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, setLeftMenuShown } = useLayoutContext()
const { markMessagesAsRead } = useChatContext()
const isHistoryView = view === 'history'
const fileTreeRef = useRef<HTMLAnchorElement>(null)
const settingsRef = useRef<HTMLButtonElement>(null)
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,
ref: fileTreeRef,
},
{
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 />,
},
{
key: 'settings',
icon: 'settings',
title: t('settings'),
action: () => {
sendEvent('rail-click', { tab: 'settings' })
setLeftMenuShown(true)
},
ref: settingsRef,
},
],
[setLeftMenuShown, t, sendEvent]
)
useCommandProvider(
() => [
{
id: 'open-settings',
handler: () => {
setLeftMenuShown(true)
},
label: t('settings'),
},
],
[t, setLeftMenuShown]
)
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 })}
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>
<EditorTourRailTooltip target={fileTreeRef.current} />
<EditorTourThemeTooltip target={settingsRef.current} />
<EditorTourGotQuestionsTooltip target={settingsRef.current} />
{moduleRailPopovers
.filter(shouldIncludeElement)
.map(({ key, Component, ref }) => (
<Component key={key} ref={ref} />
))}
<RailPanel
isReviewPanelOpen={isReviewPanelOpen}
isHistoryView={isHistoryView}
railTabs={railTabs}
/>
<RailResizeHandle isReviewPanelOpen={isReviewPanelOpen} />
<RailModals />
</TabContainer>
)
}
@@ -0,0 +1,44 @@
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import {
Dropdown,
DropdownMenu,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import ChangeLayoutOptions from './change-layout-options'
import MaterialIcon from '@/shared/components/material-icon'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
export default function ChangeLayoutButton() {
const { t } = useTranslation()
const toggleButtonClassName = classNames(
'ide-redesign-toolbar-button-subdued',
'ide-redesign-toolbar-dropdown-toggle-subdued',
'ide-redesign-toolbar-button-icon'
)
return (
<div className="ide-redesign-toolbar-button-container">
<Dropdown className="toolbar-item layout-dropdown" align="end">
<OLTooltip
id="tooltip-open-layout-options"
description={t('layout_options')}
overlayProps={{ delay: 0, placement: 'bottom' }}
>
<span>
<DropdownToggle
id="layout-dropdown-btn"
className={toggleButtonClassName}
aria-label={t('layout_options')}
>
<MaterialIcon type="space_dashboard" unfilled />
</DropdownToggle>
</span>
</OLTooltip>
<DropdownMenu>
<ChangeLayoutOptions />
</DropdownMenu>
</Dropdown>
</div>
)
}
@@ -0,0 +1,173 @@
import {
DropdownItem,
DropdownHeader,
} from '@/shared/components/dropdown/dropdown-menu'
import {
IdeLayout,
IdeView,
useLayoutContext,
} from '@/shared/context/layout-context'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { DetachRole } from '@/shared/context/detach-context'
import OLSpinner from '@/shared/components/ol/ol-spinner'
import { isMac } from '@/shared/utils/os'
import { Shortcut } from '@/shared/components/shortcut'
import classNames from 'classnames'
type LayoutOption = 'sideBySide' | 'editorOnly' | 'pdfOnly' | 'detachedPdf'
const getActiveLayoutOption = ({
pdfLayout,
view,
detachRole,
}: {
pdfLayout: IdeLayout
view: IdeView | null
detachRole?: DetachRole
}): LayoutOption | null => {
if (view === 'history') {
return null
}
if (detachRole === 'detacher') {
return 'detachedPdf'
}
if (pdfLayout === 'flat' && (view === 'editor' || view === 'file')) {
return 'editorOnly'
}
if (pdfLayout === 'flat' && view === 'pdf') {
return 'pdfOnly'
}
if (pdfLayout === 'sideBySide') {
return 'sideBySide'
}
return null
}
const LayoutDropdownItem = ({
active,
disabled = false,
processing = false,
leadingIcon,
trailingIcon,
onClick,
children,
}: {
active: boolean
leadingIcon: React.ReactNode
trailingIcon?: React.ReactNode
onClick: () => void
children: React.ReactNode
processing?: boolean
disabled?: boolean
}) => {
if (processing) {
leadingIcon = <OLSpinner size="sm" />
} else if (active) {
leadingIcon = 'check'
}
return (
<DropdownItem
active={active}
aria-current={active}
disabled={disabled}
onClick={onClick}
leadingIcon={leadingIcon}
trailingIcon={trailingIcon}
className={classNames({ 'dropdown-item-wide': isMac })}
>
{children}
</DropdownItem>
)
}
const shortcuts: Record<LayoutOption, string[] | null> = isMac
? {
editorOnly: ['⌃', '⌘', '←'],
pdfOnly: ['⌃', '⌘', '→'],
sideBySide: ['⌃', '⌘', '↓'],
detachedPdf: ['⌃', '⌘', '↑'],
}
: {
editorOnly: null,
pdfOnly: null,
sideBySide: null,
detachedPdf: null,
}
export default function ChangeLayoutOptions() {
const {
detachIsLinked,
detachRole,
view,
pdfLayout,
handleChangeLayout,
handleDetach,
} = useLayoutContext()
const { t } = useTranslation()
const detachable = 'BroadcastChannel' in window
const activeLayoutOption = getActiveLayoutOption({
pdfLayout,
view,
detachRole,
})
const waitingForDetachedLink = !detachIsLinked && detachRole === 'detacher'
return (
<>
<DropdownHeader>{t('layout_options')}</DropdownHeader>
<LayoutDropdownItem
onClick={() => handleChangeLayout('sideBySide')}
active={activeLayoutOption === 'sideBySide'}
leadingIcon="splitscreen_right"
trailingIcon={
shortcuts.sideBySide && <Shortcut keys={shortcuts.sideBySide} />
}
>
{t('split_view')}
</LayoutDropdownItem>
<LayoutDropdownItem
onClick={() => handleChangeLayout('flat', 'editor')}
active={activeLayoutOption === 'editorOnly'}
leadingIcon="edit"
trailingIcon={
shortcuts.editorOnly && <Shortcut keys={shortcuts.editorOnly} />
}
>
{t('editor_only')}
</LayoutDropdownItem>
<LayoutDropdownItem
onClick={() => handleChangeLayout('flat', 'pdf')}
active={activeLayoutOption === 'pdfOnly'}
leadingIcon="picture_as_pdf"
trailingIcon={
shortcuts.pdfOnly && <Shortcut keys={shortcuts.pdfOnly} />
}
>
{t('pdf_only')}
</LayoutDropdownItem>
<LayoutDropdownItem
onClick={() => handleDetach()}
active={activeLayoutOption === 'detachedPdf' && detachIsLinked}
disabled={!detachable}
leadingIcon="open_in_new"
trailingIcon={
shortcuts.detachedPdf && <Shortcut keys={shortcuts.detachedPdf} />
}
processing={waitingForDetachedLink}
>
{t('open_pdf_in_separate_tab')}
</LayoutDropdownItem>
</>
)
}
@@ -0,0 +1,190 @@
import {
Command,
formatShortcut,
Shortcuts,
useCommandRegistry,
} from '@/features/ide-react/context/command-registry-context'
import {
DropdownDivider,
DropdownHeader,
} from '@/shared/components/dropdown/dropdown-menu'
import {
MenuBarDropdown,
NestedMenuBarDropdown,
} from '@/shared/components/menu-bar/menu-bar-dropdown'
import { MenuBarOption } from '@/shared/components/menu-bar/menu-bar-option'
import { Fragment, useCallback, useMemo } from 'react'
type CommandId = string
type TaggedCommand = Command & {
type: 'command'
shortcuts?: Shortcuts[CommandId]
}
type Entry<T> = T | GroupStructure<T>
type GroupStructure<T> = {
id: string
title: string
children: Array<Entry<T>>
}
export type MenuSectionStructure<T = CommandId> = {
title?: string
id: string
children: Array<Entry<T>>
}
export type MenuStructure<T = CommandId> = Array<MenuSectionStructure<T>>
const CommandDropdown = ({
menu,
title,
id,
}: {
menu: MenuStructure<CommandId>
title: string
id: string
}) => {
const { registry, shortcuts } = useCommandRegistry()
const populatedSections = useMemo(
() =>
menu
.map(section => populateSectionOrGroup(section, registry, shortcuts))
.filter(x => x.children.length > 0),
[menu, registry, shortcuts]
)
if (populatedSections.length === 0) {
return null
}
return (
<MenuBarDropdown
title={title}
id={id}
className="ide-redesign-toolbar-dropdown-toggle-subdued ide-redesign-toolbar-button-subdued"
>
{populatedSections.map((section, index) => {
return (
<Fragment key={section.id}>
<CommandSectionContent
section={section}
includeDivider={index > 0}
/>
</Fragment>
)
})}
</MenuBarDropdown>
)
}
export const CommandSection = ({
section: sectionStructure,
includeDivider = true,
}: {
section: MenuSectionStructure<CommandId>
includeDivider?: boolean
}) => {
const { registry, shortcuts } = useCommandRegistry()
const section = populateSectionOrGroup(sectionStructure, registry, shortcuts)
return (
<CommandSectionContent section={section} includeDivider={includeDivider} />
)
}
function CommandSectionContent({
section,
includeDivider = true,
}: {
section: MenuSectionStructure<TaggedCommand>
includeDivider?: boolean
}) {
if (section.children.length === 0) {
return null
}
return (
<>
{includeDivider && <DropdownDivider />}
{section.title && <DropdownHeader>{section.title}</DropdownHeader>}
{section.children.map(child => (
<CommandDropdownChild item={child} key={child.id} />
))}
</>
)
}
const CommandDropdownChild = ({ item }: { item: Entry<TaggedCommand> }) => {
const onClickHandler = useCallback(() => {
if (isTaggedCommand(item)) {
item.handler?.({ location: 'menu-bar' })
}
}, [item])
if (isTaggedCommand(item)) {
return (
<MenuBarOption
eventKey={item.id}
key={item.id}
title={item.label}
// eslint-disable-next-line react/jsx-handler-names
onClick={onClickHandler}
href={item.href}
disabled={item.disabled}
trailingIcon={
item.shortcuts && <span>{formatShortcut(item.shortcuts[0])}</span>
}
/>
)
} else {
return (
<NestedMenuBarDropdown title={item.title} id={item.id} key={item.id}>
{item.children.map(subChild => {
return <CommandDropdownChild item={subChild} key={subChild.id} />
})}
</NestedMenuBarDropdown>
)
}
}
export default CommandDropdown
function populateSectionOrGroup<
T extends { children: Array<Entry<CommandId>> },
>(
section: T,
registry: Map<string, Command>,
shortcuts: Shortcuts
): Omit<T, 'children'> & {
children: Array<Entry<TaggedCommand>>
} {
const { children, ...rest } = section
return {
...rest,
children: children
.map(child => {
if (typeof child !== 'string') {
const populatedChild = populateSectionOrGroup(
child,
registry,
shortcuts
)
if (populatedChild.children.length === 0) {
// Skip empty groups
return undefined
}
return populatedChild
}
const command = registry.get(child)
if (command) {
return {
...command,
shortcuts: shortcuts[command.id],
type: 'command' as const,
}
}
return undefined
})
.filter(x => x !== undefined),
}
}
function isTaggedCommand(item: Entry<TaggedCommand>): item is TaggedCommand {
return 'type' in item && item.type === 'command'
}
@@ -0,0 +1,102 @@
import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import { isSmallDevice, sendMB } from '@/infrastructure/event-tracking'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
import { useProjectContext } from '@/shared/context/project-context'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
export const DownloadProjectZip = () => {
const { t } = useTranslation()
const { projectId } = useProjectContext()
const sendDownloadEvent = useCallback(() => {
sendMB('download-zip-button-click', {
projectId,
location: 'project-name-dropdown',
isSmallDevice,
})
}, [projectId])
useCommandProvider(
() => [
{
id: 'download-as-source-zip',
href: `/project/${projectId}/download/zip`,
label: t('download_as_source_zip'),
},
],
[t, projectId]
)
return (
<OLDropdownMenuItem
href={`/project/${projectId}/download/zip`}
target="_blank"
rel="noreferrer"
onClick={sendDownloadEvent}
>
{t('download_as_source_zip')}
</OLDropdownMenuItem>
)
}
export const DownloadProjectPDF = () => {
const { t } = useTranslation()
const { pdfDownloadUrl, pdfUrl } = useCompileContext()
const { projectId } = useProjectContext()
const { sendEvent } = useEditorAnalytics()
const sendDownloadEvent = useCallback(() => {
sendEvent('download-pdf-button-click', {
projectId,
location: 'project-name-dropdown',
isSmallDevice,
})
}, [projectId, sendEvent])
useCommandProvider(
() => [
{
id: 'download-pdf',
disabled: !pdfUrl,
href: pdfDownloadUrl || pdfUrl,
handler: ({ location }) => {
sendEvent('download-pdf-button-click', {
projectId,
location,
isSmallDevice,
})
},
label: t('download_as_pdf'),
},
],
[t, pdfUrl, projectId, pdfDownloadUrl, sendEvent]
)
const button = (
<OLDropdownMenuItem
href={pdfDownloadUrl || pdfUrl}
target="_blank"
rel="noreferrer"
onClick={sendDownloadEvent}
disabled={!pdfUrl}
>
{t('download_as_pdf')}
</OLDropdownMenuItem>
)
if (!pdfUrl) {
return (
<OLTooltip
id="tooltip-download-pdf-unavailable"
description={t('please_compile_pdf_before_download')}
overlayProps={{ placement: 'right', delay: 0 }}
>
<span>{button}</span>
</OLTooltip>
)
} else {
return button
}
}
@@ -0,0 +1,37 @@
import EditorCloneProjectModalWrapper from '@/features/clone-project-modal/components/editor-clone-project-modal-wrapper'
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
import useOpenProject from '@/shared/hooks/use-open-project'
import getMeta from '@/utils/meta'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
export const DuplicateProject = () => {
const { sendEvent } = useEditorAnalytics()
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const anonymous = getMeta('ol-anonymous')
const openProject = useOpenProject()
const handleShowModal = useCallback(() => {
sendEvent('copy-project', { location: 'project-title-dropdown' })
setShowModal(true)
}, [sendEvent])
if (anonymous) {
return null
}
return (
<>
<OLDropdownMenuItem onClick={handleShowModal}>
{t('make_a_copy')}
</OLDropdownMenuItem>
<EditorCloneProjectModalWrapper
show={showModal}
handleHide={() => setShowModal(false)}
openProject={openProject}
/>
</>
)
}
@@ -0,0 +1,70 @@
import OLFormControl from '@/shared/components/ol/ol-form-control'
import {
ChangeEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
type EditableLabelProps = {
initialText: string
className?: string
onChange: (name: string) => void
onCancel: () => void
maxLength?: number
}
const EditableLabel = ({
initialText,
className,
onChange,
onCancel,
maxLength,
}: EditableLabelProps) => {
const [name, setName] = useState(initialText)
const inputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
inputRef.current?.select()
}, [])
const onInputChange: ChangeEventHandler<HTMLInputElement> = useCallback(
event => {
setName(event.target.value)
},
[]
)
const finishRenaming = useCallback(() => {
onChange(name)
}, [onChange, name])
const onKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault()
finishRenaming()
}
if (event.key === 'Escape') {
event.preventDefault()
onCancel()
}
},
[finishRenaming, onCancel]
)
return (
<OLFormControl
className={className}
ref={inputRef}
type="text"
value={name}
onChange={onInputChange}
onKeyDown={onKeyDown}
onBlur={finishRenaming}
maxLength={maxLength}
/>
)
}
export default EditableLabel
@@ -0,0 +1,46 @@
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
import { Cobranding } from '../../../../../../types/cobranding'
type ToolbarLogosProps = {
cobranding?: Cobranding
}
export const ToolbarLogos = ({ cobranding }: ToolbarLogosProps) => {
const { t } = useTranslation()
return (
<div className="ide-redesign-toolbar-logos">
<OLTooltip
id="tooltip-home-button"
description={t('back_to_your_projects')}
overlayProps={{ delay: 0, placement: 'bottom' }}
>
<div className="ide-redesign-toolbar-home-button">
<a href="/project" className="ide-redesign-toolbar-home-link">
<span className="toolbar-ol-logo" aria-label={t('overleaf_logo')} />
<MaterialIcon type="home" className="toolbar-ol-home-button" />
</a>
</div>
</OLTooltip>
{cobranding && cobranding.logoImgUrl && (
<>
<span className="ide-redesign-toolbar-cobranding-separator" />
<a
className="ide-redesign-toolbar-cobranding-link"
href={cobranding.brandVariationHomeUrl}
target="_blank"
rel="noreferrer noopener"
>
<img
src={cobranding.logoImgUrl}
className="ide-redesign-toolbar-cobranding-logo"
alt={cobranding.brandVariationName}
/>
</a>
</>
)}
</div>
)
}
@@ -0,0 +1,300 @@
import {
DropdownDivider,
DropdownHeader,
DropdownItem,
} from '@/shared/components/dropdown/dropdown-menu'
import { MenuBar } from '@/shared/components/menu-bar/menu-bar'
import { MenuBarDropdown } from '@/shared/components/menu-bar/menu-bar-dropdown'
import { MenuBarOption } from '@/shared/components/menu-bar/menu-bar-option'
import { useTranslation } from 'react-i18next'
import ChangeLayoutOptions from './change-layout-options'
import { ElementType, useCallback, useMemo, useState } from 'react'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
import CommandDropdown, {
CommandSection,
MenuSectionStructure,
MenuStructure,
} from './command-dropdown'
import { useRailContext } from '../../context/rail-context'
import WordCountModal from '@/features/word-count-modal/components/word-count-modal'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import getMeta from '@/utils/meta'
import EditorCloneProjectModalWrapper from '@/features/clone-project-modal/components/editor-clone-project-modal-wrapper'
import useOpenProject from '@/shared/hooks/use-open-project'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
const menubarExtraComponents = importOverleafModules(
'menubarExtraComponents'
) as {
import: { default: ElementType }
}[]
export const ToolbarMenuBar = () => {
const { t } = useTranslation()
const { setView, view } = useLayoutContext()
const { pdfUrl } = useCompileContext()
const wordCountEnabled = pdfUrl || isSplitTestEnabled('word-count-client')
const [showWordCountModal, setShowWordCountModal] = useState(false)
const [showCloneProjectModal, setShowCloneProjectModal] = useState(false)
const openProject = useOpenProject()
const anonymous = getMeta('ol-anonymous')
useCommandProvider(
() => [
{
type: 'command',
label: t('show_version_history'),
handler: () => {
setView(view === 'history' ? 'editor' : 'history')
},
id: 'show_version_history',
},
{
type: 'command',
label: t('word_count_lower'),
disabled: !wordCountEnabled,
handler: () => {
setShowWordCountModal(true)
},
id: 'word_count',
},
{
type: 'command',
label: t('make_a_copy'),
disabled: anonymous,
handler: () => {
setShowCloneProjectModal(true)
},
id: 'copy_project',
},
],
[t, setView, view, wordCountEnabled, anonymous]
)
const fileMenuStructure: MenuStructure = useMemo(
() => [
{
id: 'file-file-tree',
children: ['new_file', 'new_folder', 'upload_file', 'copy_project'],
},
{ id: 'file-tools', children: ['show_version_history', 'word_count'] },
{ id: 'submit', children: ['submit-project', 'manage-template'] },
{
id: 'file-download',
children: ['download-as-source-zip', 'download-pdf'],
},
{
id: 'settings',
children: ['open-settings'],
},
],
[]
)
const editMenuStructure: MenuStructure = useMemo(
() => [
{
id: 'edit-undo-redo',
children: ['undo', 'redo'],
},
{
id: 'edit-search',
children: ['find', 'select-all'],
},
],
[]
)
const insertMenuStructure: MenuStructure = useMemo(
() => [
{
id: 'insert-latex',
children: [
{
id: 'insert-math-group',
title: t('math'),
children: ['insert-inline-math', 'insert-display-math'],
},
'insert-symbol',
{
id: 'insert-figure-group',
title: t('figure'),
children: [
'insert-figure-from-computer',
'insert-figure-from-project-files',
'insert-figure-from-another-project',
'insert-figure-from-url',
],
},
'insert-table',
'insert-citation',
'insert-link',
'insert-cross-reference',
],
},
{
id: 'insert-comment',
children: ['comment'],
},
],
[t]
)
const formatMenuStructure: MenuStructure = useMemo(
() => [
{
id: 'format-text',
children: ['format-bold', 'format-italics'],
},
{
id: 'format-list',
children: [
'format-bullet-list',
'format-numbered-list',
'format-increase-indentation',
'format-decrease-indentation',
],
},
{
id: 'format-paragraph',
title: t('paragraph_styles'),
children: [
'format-style-normal',
'format-style-section',
'format-style-subsection',
'format-style-subsubsection',
'format-style-paragraph',
'format-style-subparagraph',
],
},
],
[t]
)
const pdfControlsMenuSectionStructure: MenuSectionStructure = useMemo(
() => ({
title: t('pdf_preview'),
id: 'pdf-controls',
children: [
'view-pdf-presentation-mode',
'view-pdf-zoom-in',
'view-pdf-zoom-out',
'view-pdf-fit-width',
'view-pdf-fit-height',
],
}),
[t]
)
const { mathPreview, setMathPreview, breadcrumbs, setBreadcrumbs } =
useProjectSettingsContext()
const toggleMathPreview = useCallback(() => {
setMathPreview(!mathPreview)
}, [setMathPreview, mathPreview])
const toggleBreadcrumbs = useCallback(() => {
setBreadcrumbs(!breadcrumbs)
}, [setBreadcrumbs, breadcrumbs])
const { setActiveModal } = useRailContext()
const openKeyboardShortcutsModal = useCallback(() => {
setActiveModal('keyboard-shortcuts')
}, [setActiveModal])
const openContactUsModal = useCallback(() => {
setActiveModal('contact-us')
}, [setActiveModal])
return (
<>
<MenuBar
className="ide-redesign-toolbar-menu-bar"
id="toolbar-menu-bar-item"
>
<CommandDropdown menu={fileMenuStructure} title={t('file')} id="file" />
<CommandDropdown menu={editMenuStructure} title={t('edit')} id="edit" />
<CommandDropdown
menu={insertMenuStructure}
title={t('insert')}
id="insert"
/>
<MenuBarDropdown
title={t('view')}
id="view"
className="ide-redesign-toolbar-dropdown-toggle-subdued ide-redesign-toolbar-button-subdued"
>
<ChangeLayoutOptions />
<DropdownDivider />
<DropdownHeader>Editor settings</DropdownHeader>
<MenuBarOption
eventKey="show_breadcrumbs"
title={t('show_breadcrumbs')}
leadingIcon={
breadcrumbs ? 'check' : <DropdownItem.EmptyLeadingIcon />
}
onClick={toggleBreadcrumbs}
/>
<MenuBarOption
eventKey="show_equation_preview"
title={t('show_equation_preview')}
leadingIcon={
mathPreview ? 'check' : <DropdownItem.EmptyLeadingIcon />
}
onClick={toggleMathPreview}
/>
<CommandSection
section={pdfControlsMenuSectionStructure}
includeDivider
/>
</MenuBarDropdown>
<CommandDropdown
menu={formatMenuStructure}
title={t('format')}
id="format"
/>
<MenuBarDropdown
title={t('help')}
id="help"
className="ide-redesign-toolbar-dropdown-toggle-subdued ide-redesign-toolbar-button-subdued"
>
<MenuBarOption
eventKey="keyboard_shortcuts"
title={t('keyboard_shortcuts')}
onClick={openKeyboardShortcutsModal}
/>
<MenuBarOption
title={t('documentation')}
eventKey="documentation"
href="/learn"
target="_blank"
rel="noopener noreferrer"
/>
<DropdownDivider />
<MenuBarOption
eventKey="contact_us"
title={t('contact_us')}
onClick={openContactUsModal}
/>
</MenuBarDropdown>
</MenuBar>
<WordCountModal
show={showWordCountModal}
handleHide={() => setShowWordCountModal(false)}
/>
<EditorCloneProjectModalWrapper
show={showCloneProjectModal}
handleHide={() => setShowCloneProjectModal(false)}
openProject={openProject}
/>
{menubarExtraComponents.map(
({ import: { default: Component } }, index) => (
<Component key={index} />
)
)}
</>
)
}
@@ -0,0 +1,27 @@
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import {
OnlineUser,
useOnlineUsersContext,
} from '@/features/ide-react/context/online-users-context'
import { useCallback } from 'react'
import { OnlineUsersWidget } from '@/features/ide-redesign/components/online-users/online-users-widget'
export const OnlineUsers = () => {
const { openDoc } = useEditorManagerContext()
const { onlineUsersArray } = useOnlineUsersContext()
const goToUser = useCallback(
async (user: OnlineUser) => {
if (user.doc && typeof user.row === 'number') {
return await openDoc(user.doc, { gotoLine: user.row + 1 })
}
},
[openDoc]
)
return (
<div className="ide-redesign-online-users">
<OnlineUsersWidget onlineUsers={onlineUsersArray} goToUser={goToUser} />
</div>
)
}
@@ -0,0 +1,94 @@
import {
Dropdown,
DropdownDivider,
DropdownMenu,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import MaterialIcon from '@/shared/components/material-icon'
import { useProjectContext } from '@/shared/context/project-context'
import { useTranslation } from 'react-i18next'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import { useEditorContext } from '@/shared/context/editor-context'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { DownloadProjectPDF, DownloadProjectZip } from './download-project'
import { useCallback, useState } from 'react'
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
import EditableLabel from './editable-label'
import { DuplicateProject } from './duplicate-project'
const [publishModalModules] = importOverleafModules('publishModal')
const SubmitProjectButton = publishModalModules?.import.NewPublishDropdownButton
export const ToolbarProjectTitle = () => {
const { cobranding } = useEditorContext()
const { t } = useTranslation()
const { renameProject } = useEditorContext()
const { permissionsLevel } = useIdeReactContext()
const { name } = useProjectContext()
const shouldDisplaySubmitButton =
(permissionsLevel === 'owner' || permissionsLevel === 'readAndWrite') &&
SubmitProjectButton
const hasRenamePermissions = permissionsLevel === 'owner'
const [isRenaming, setIsRenaming] = useState(false)
const onRename = useCallback(
(name: string) => {
if (name) {
renameProject(name)
}
setIsRenaming(false)
},
[renameProject]
)
const onCancel = useCallback(() => {
setIsRenaming(false)
}, [])
if (isRenaming) {
return (
<EditableLabel
onChange={onRename}
onCancel={onCancel}
initialText={name}
maxLength={150}
className="ide-redesign-toolbar-editable-project-name"
/>
)
}
return (
<Dropdown align="start" className="ide-redesign-toolbar-project-dropdown">
<DropdownToggle
id="project-title-options"
className="ide-redesign-toolbar-project-dropdown-toggle ide-redesign-toolbar-dropdown-toggle-subdued fw-bold ide-redesign-toolbar-button-subdued"
>
<span className="ide-redesign-toolbar-project-name" translate="no">
{name}
</span>
<MaterialIcon
type="keyboard_arrow_down"
accessibilityLabel={t('project_title_options')}
/>
</DropdownToggle>
<DropdownMenu renderOnMount>
{shouldDisplaySubmitButton && !cobranding && (
<>
<SubmitProjectButton />
<DropdownDivider />
</>
)}
<DownloadProjectPDF />
<DownloadProjectZip />
<DropdownDivider />
<DuplicateProject />
<OLDropdownMenuItem
onClick={() => {
setIsRenaming(true)
}}
disabled={!hasRenamePermissions}
>
{t('rename')}
</OLDropdownMenuItem>
</DropdownMenu>
</Dropdown>
)
}
@@ -0,0 +1,42 @@
import ShareProjectModal from '@/features/share-project-modal/components/share-project-modal'
import OLButton from '@/shared/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
export default function ShareProjectButton() {
const { t } = useTranslation()
const { sendEventOnce } = useEditorAnalytics()
const [showShareModal, setShowShareModal] = useState(false)
const handleOpenShareModal = useCallback(() => {
sendEventOnce('ide-open-share-modal-once')
setShowShareModal(true)
}, [sendEventOnce])
const handleHideShareModal = useCallback(() => {
setShowShareModal(false)
}, [])
return (
<>
<div className="ide-redesign-toolbar-button-container">
<OLButton
size="sm"
variant="primary"
leadingIcon={<MaterialIcon type="person_add" />}
onClick={handleOpenShareModal}
>
{t('share')}
</OLButton>
</div>
<ShareProjectModal
show={showShareModal}
handleOpen={handleOpenShareModal}
handleHide={handleHideShareModal}
/>
</>
)
}
@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next'
import OLIconButton from '@/shared/components/ol/ol-icon-button'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useCallback } from 'react'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
export default function ShowHistoryButton() {
const { t } = useTranslation()
const { view, setView, restoreView } = useLayoutContext()
const { sendEvent } = useEditorAnalytics()
const toggleHistoryOpen = useCallback(() => {
const action = view === 'history' ? 'close' : 'open'
sendEvent('navigation-clicked-history', { action })
if (view === 'history') {
restoreView()
} else {
setView('history')
}
}, [view, setView, sendEvent, restoreView])
return (
<div className="ide-redesign-toolbar-button-container">
<OLTooltip
id="tooltip-open-history"
description={t('history')}
overlayProps={{ delay: 0, placement: 'bottom' }}
>
<OLIconButton
icon="history"
className="ide-redesign-toolbar-button-subdued ide-redesign-toolbar-button-icon"
onClick={toggleHistoryOpen}
accessibilityLabel={t('history')}
/>
</OLTooltip>
</div>
)
}
@@ -0,0 +1,67 @@
import { useTranslation } from 'react-i18next'
import { ToolbarMenuBar } from './menu-bar'
import { ToolbarProjectTitle } from './project-title'
import { OnlineUsers } from './online-users'
import ShareProjectButton from './share-project-button'
import ChangeLayoutButton from './change-layout-button'
import ShowHistoryButton from './show-history-button'
import { useLayoutContext } from '@/shared/context/layout-context'
import BackToEditorButton from '@/features/editor-navigation-toolbar/components/back-to-editor-button'
import { useCallback } from 'react'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import { ToolbarLogos } from './logos'
import { useEditorContext } from '@/shared/context/editor-context'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import UpgradeButton from './upgrade-button'
import getMeta from '@/utils/meta'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
const [publishModalModules] = importOverleafModules('publishModal')
const SubmitProjectButton = publishModalModules?.import.NewPublishToolbarButton
export const Toolbar = () => {
const { view, restoreView } = useLayoutContext()
const { cobranding, isRestrictedTokenMember } = useEditorContext()
const { permissionsLevel } = useIdeReactContext()
const { t } = useTranslation()
const shouldDisplaySubmitButton =
(permissionsLevel === 'owner' || permissionsLevel === 'readAndWrite') &&
SubmitProjectButton
const handleBackToEditorClick = useCallback(() => {
eventTracking.sendMB('navigation-clicked-history', { action: 'close' })
restoreView()
}, [restoreView])
if (view === 'history') {
return (
<nav className="ide-redesign-toolbar" aria-label={t('project_actions')}>
<div className="d-flex align-items-center">
<BackToEditorButton onClick={handleBackToEditorClick} />
</div>
<ToolbarProjectTitle />
<div /> {/* Empty div used for spacing */}
</nav>
)
}
return (
<nav className="ide-redesign-toolbar" aria-label={t('project_actions')}>
<div className="ide-redesign-toolbar-menu">
<ToolbarLogos cobranding={cobranding} />
<ToolbarMenuBar />
</div>
<ToolbarProjectTitle />
<div className="ide-redesign-toolbar-actions">
<OnlineUsers />
{!isRestrictedTokenMember && <ShowHistoryButton />}
<ChangeLayoutButton />
{shouldDisplaySubmitButton && cobranding && (
<SubmitProjectButton cobranding={cobranding} />
)}
<ShareProjectButton />
{getMeta('ol-showUpgradePrompt') && <UpgradeButton />}
</div>
</nav>
)
}
@@ -0,0 +1,26 @@
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import OLButton from '@/shared/components/ol/ol-button'
export default function UpgradeButton() {
const { t } = useTranslation()
function handleClick() {
eventTracking.send('subscription-funnel', 'code-editor', 'upgrade')
eventTracking.sendMB('upgrade-button-click', { source: 'code-editor' })
}
return (
<div className="ide-redesign-toolbar-button-container">
<OLButton
variant="premium"
size="sm"
href="/user/subscription/plans?itm_referrer=editor-header-upgrade-prompt"
target="_blank"
onClick={handleClick}
>
{t('upgrade')}
</OLButton>
</div>
)
}