mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 14:13:43 +01:00
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:
committed by
Copybot
parent
c790449196
commit
fc8d564320
@@ -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} />
|
||||
))
|
||||
}
|
||||
+33
@@ -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>
|
||||
)
|
||||
}
|
||||
+44
@@ -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>
|
||||
)
|
||||
}
|
||||
+173
@@ -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>
|
||||
)
|
||||
}
|
||||
+42
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+39
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { sendSearchEvent } from '@/features/event-tracking/search-events'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import useEventListener from '@/shared/hooks/use-event-listener'
|
||||
import usePersistedState from '@/shared/hooks/use-persisted-state'
|
||||
import { isMac } from '@/shared/utils/os'
|
||||
import {
|
||||
createContext,
|
||||
Dispatch,
|
||||
FC,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { ImperativePanelHandle } from 'react-resizable-panels'
|
||||
|
||||
export type RailTabKey =
|
||||
| 'file-tree'
|
||||
| 'integrations'
|
||||
| 'review-panel'
|
||||
| 'chat'
|
||||
| 'full-project-search'
|
||||
| 'workbench'
|
||||
|
||||
export type RailModalKey = 'keyboard-shortcuts' | 'contact-us' | 'dictionary'
|
||||
|
||||
const RailContext = createContext<
|
||||
| {
|
||||
selectedTab: RailTabKey
|
||||
isOpen: boolean
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>
|
||||
panelRef: React.RefObject<ImperativePanelHandle>
|
||||
togglePane: () => void
|
||||
handlePaneExpand: () => void
|
||||
handlePaneCollapse: () => void
|
||||
resizing: boolean
|
||||
setResizing: Dispatch<SetStateAction<boolean>>
|
||||
activeModal: RailModalKey | null
|
||||
setActiveModal: Dispatch<SetStateAction<RailModalKey | null>>
|
||||
openTab: (tab: RailTabKey) => void
|
||||
selectTab: (tab: RailTabKey) => void
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export const RailProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
const { projectId } = useProjectContext()
|
||||
const [isOpen, setIsOpen] = usePersistedState(
|
||||
`rail-is-open-${projectId}`,
|
||||
true
|
||||
)
|
||||
const [resizing, setResizing] = useState(false)
|
||||
const [activeModal, setActiveModalInternal] = useState<RailModalKey | null>(
|
||||
null
|
||||
)
|
||||
const setActiveModal: Dispatch<SetStateAction<RailModalKey | null>> =
|
||||
useCallback(modalKey => {
|
||||
setActiveModalInternal(modalKey)
|
||||
}, [])
|
||||
|
||||
const panelRef = useRef<ImperativePanelHandle>(null)
|
||||
|
||||
const togglePane = useCallback(() => {
|
||||
setIsOpen(value => !value)
|
||||
}, [setIsOpen])
|
||||
|
||||
const handlePaneExpand = useCallback(() => {
|
||||
setIsOpen(true)
|
||||
}, [setIsOpen])
|
||||
|
||||
const handlePaneCollapse = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
}, [setIsOpen])
|
||||
|
||||
const [selectedTab, setSelectedTab] = usePersistedState<RailTabKey>(
|
||||
`selected-rail-tab-${projectId}`,
|
||||
'file-tree'
|
||||
)
|
||||
|
||||
// Keep the panel collapse/expanded state in sync with isOpen and selectedTab
|
||||
useLayoutEffect(() => {
|
||||
const panelHandle = panelRef.current
|
||||
|
||||
if (panelHandle) {
|
||||
if (isOpen) {
|
||||
panelHandle.expand()
|
||||
} else {
|
||||
panelHandle.collapse()
|
||||
}
|
||||
}
|
||||
}, [isOpen, selectedTab])
|
||||
|
||||
const selectTab = useCallback(
|
||||
(tab: RailTabKey) => {
|
||||
setSelectedTab(tab)
|
||||
},
|
||||
[setSelectedTab]
|
||||
)
|
||||
|
||||
const openTab = useCallback(
|
||||
(tab: RailTabKey) => {
|
||||
setSelectedTab(tab)
|
||||
setIsOpen(true)
|
||||
},
|
||||
[setIsOpen, setSelectedTab]
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
'ui.toggle-review-panel',
|
||||
useCallback(() => {
|
||||
if (isOpen && selectedTab === 'review-panel') {
|
||||
handlePaneCollapse()
|
||||
} else {
|
||||
openTab('review-panel')
|
||||
}
|
||||
}, [handlePaneCollapse, selectedTab, isOpen, openTab])
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
'keydown',
|
||||
useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (
|
||||
(isMac ? event.metaKey : event.ctrlKey) &&
|
||||
event.shiftKey &&
|
||||
event.code === 'KeyF'
|
||||
) {
|
||||
event.preventDefault()
|
||||
sendSearchEvent('search-open', {
|
||||
searchType: 'full-project',
|
||||
method: 'keyboard',
|
||||
})
|
||||
openTab('full-project-search')
|
||||
}
|
||||
},
|
||||
[openTab]
|
||||
)
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
selectedTab,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
panelRef,
|
||||
togglePane,
|
||||
handlePaneExpand,
|
||||
handlePaneCollapse,
|
||||
resizing,
|
||||
setResizing,
|
||||
activeModal,
|
||||
setActiveModal,
|
||||
openTab,
|
||||
selectTab,
|
||||
}),
|
||||
[
|
||||
selectedTab,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
panelRef,
|
||||
togglePane,
|
||||
handlePaneExpand,
|
||||
handlePaneCollapse,
|
||||
resizing,
|
||||
setResizing,
|
||||
activeModal,
|
||||
setActiveModal,
|
||||
openTab,
|
||||
selectTab,
|
||||
]
|
||||
)
|
||||
|
||||
return <RailContext.Provider value={value}>{children}</RailContext.Provider>
|
||||
}
|
||||
|
||||
export const useRailContext = () => {
|
||||
const context = useContext(RailContext)
|
||||
if (!context) {
|
||||
throw new Error('useRailContext is only available inside RailProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { OnlineUsersProvider } from '@/features/ide-react/context/online-users-c
|
||||
import { OutlineProvider } from '@/features/ide-react/context/outline-context'
|
||||
import { PermissionsProvider } from '@/features/ide-react/context/permissions-context'
|
||||
import { ProjectProvider } from '@/shared/context/project-context'
|
||||
import { RailProvider } from '@/features/ide-redesign/contexts/rail-context'
|
||||
import { RailProvider } from '@/features/ide-react/context/rail-context'
|
||||
import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/project-settings-context'
|
||||
import { ReferencesProvider } from '@/features/ide-react/context/references-context'
|
||||
import { SnapshotProvider } from '@/features/ide-react/context/snapshot-context'
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
|
||||
import useDebounce from '@/shared/hooks/use-debounce'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ImperativePanelHandle } from 'react-resizable-panels'
|
||||
|
||||
export const useChatPane = () => {
|
||||
const { chatIsOpen: isOpen, setChatIsOpen: setIsOpen } = useLayoutContext()
|
||||
const [resizing, setResizing] = useState(false)
|
||||
const panelRef = useRef<ImperativePanelHandle>(null)
|
||||
|
||||
// Keep track of a debounced local state variable for panel openness and
|
||||
// only update the external openness state when the debounced value changes.
|
||||
// This prevents successive calls to onCollapse and onExpand from
|
||||
// react-resizable-panels updating the openness state multiple times in quick
|
||||
// succession, which causes confusing behaviour that is different in React 17
|
||||
// and 18. Collapsing the chat pane on initialization is necessary because
|
||||
// react-resizable-panels does not provide a way to specify both that a panel
|
||||
// should be collapsed and a default size for the panel when expanded.
|
||||
const [localIsOpen, setLocalIsOpen] = useState(isOpen)
|
||||
const debouncedLocalIsOpen = useDebounce(localIsOpen, 100)
|
||||
|
||||
useCollapsiblePanel(isOpen, panelRef)
|
||||
|
||||
const togglePane = useCallback(() => {
|
||||
setIsOpen(value => !value)
|
||||
}, [setIsOpen])
|
||||
|
||||
const handlePaneExpand = useCallback(() => {
|
||||
setLocalIsOpen(true)
|
||||
}, [])
|
||||
|
||||
const handlePaneCollapse = useCallback(() => {
|
||||
setLocalIsOpen(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(debouncedLocalIsOpen)
|
||||
}, [debouncedLocalIsOpen, setIsOpen])
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
panelRef,
|
||||
resizing,
|
||||
setResizing,
|
||||
togglePane,
|
||||
handlePaneExpand,
|
||||
handlePaneCollapse,
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import {
|
||||
RailTabKey,
|
||||
useRailContext,
|
||||
} from '@/features/ide-redesign/contexts/rail-context'
|
||||
} from '@/features/ide-react/context/rail-context'
|
||||
|
||||
function createEditingSessionHeartbeatData(
|
||||
editorType: EditorType,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
|
||||
import { ImperativePanelHandle } from 'react-resizable-panels'
|
||||
|
||||
export const useSidebarPane = () => {
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const [resizing, setResizing] = useState(false)
|
||||
const panelRef = useRef<ImperativePanelHandle>(null)
|
||||
useCollapsiblePanel(isOpen, panelRef)
|
||||
|
||||
const togglePane = useCallback(() => {
|
||||
setIsOpen(value => !value)
|
||||
}, [])
|
||||
|
||||
const handlePaneExpand = useCallback(() => {
|
||||
setIsOpen(true)
|
||||
}, [])
|
||||
|
||||
const handlePaneCollapse = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
panelRef,
|
||||
togglePane,
|
||||
handlePaneExpand,
|
||||
handlePaneCollapse,
|
||||
resizing,
|
||||
setResizing,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { AvailableUnfilledIcon } from '@/shared/components/material-icon'
|
||||
import { RailTabKey } from '../context/rail-context'
|
||||
import { FC, ReactElement } from 'react'
|
||||
import RailTab from '@/features/ide-react/components/rail/rail-tab'
|
||||
|
||||
export type CustomRailTabIcon = FC<{ open: boolean; title: string }>
|
||||
|
||||
export type RailElement = {
|
||||
icon: AvailableUnfilledIcon | CustomRailTabIcon
|
||||
key: RailTabKey
|
||||
component: ReactElement | null
|
||||
indicator?: ReactElement
|
||||
title: string
|
||||
hide?: boolean | (() => boolean)
|
||||
disabled?: boolean
|
||||
mountOnFirstLoad?: boolean
|
||||
ref?: React.RefObject<HTMLAnchorElement>
|
||||
tab?: typeof RailTab
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function shouldIncludeElement({
|
||||
hide,
|
||||
}: {
|
||||
hide?: boolean | (() => boolean)
|
||||
}): boolean {
|
||||
if (typeof hide === 'function') {
|
||||
return !hide()
|
||||
}
|
||||
return !hide
|
||||
}
|
||||
Reference in New Issue
Block a user