first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
import { findInTreeOrThrow } from '@/features/file-tree/util/find-in-tree'
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
import { useOutlineContext } from '@/features/ide-react/context/outline-context'
import useNestedOutline from '@/features/outline/hooks/use-nested-outline'
import getChildrenLines from '@/features/outline/util/get-children-lines'
import MaterialIcon from '@/shared/components/material-icon'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { Fragment, useMemo } from 'react'
import { Outline } from '@/features/source-editor/utils/tree-operations/outline'
const constructOutlineHierarchy = (
items: Outline[],
highlightedLine: number,
outlineHierarchy: Outline[] = []
) => {
for (const item of items) {
if (item.line === highlightedLine) {
outlineHierarchy.push(item)
return outlineHierarchy
}
const childLines = getChildrenLines(item.children)
if (childLines.includes(highlightedLine)) {
outlineHierarchy.push(item)
return constructOutlineHierarchy(
item.children as Outline[],
highlightedLine,
outlineHierarchy
)
}
}
return outlineHierarchy
}
export default function Breadcrumbs() {
const { openEntity } = useFileTreeOpenContext()
const { fileTreeData } = useFileTreeData()
const outline = useNestedOutline()
const { highlightedLine, canShowOutline } = useOutlineContext()
const folderHierarchy = useMemo(() => {
if (!openEntity || !fileTreeData) {
return []
}
return openEntity.path
.filter(id => id !== fileTreeData._id) // Filter out the root folder
.map(id => {
return findInTreeOrThrow(fileTreeData, id)?.entity
})
}, [openEntity, fileTreeData])
const outlineHierarchy = useMemo(() => {
if (!canShowOutline || !outline) {
return []
}
return constructOutlineHierarchy(outline.items, highlightedLine)
}, [outline, highlightedLine, canShowOutline])
if (!openEntity || !fileTreeData) {
return null
}
const numOutlineItems = outlineHierarchy.length
return (
<div className="ol-cm-breadcrumbs">
{folderHierarchy.map(folder => (
<Fragment key={folder._id}>
<div>{folder.name}</div>
<Chevron />
</Fragment>
))}
<MaterialIcon unfilled type="description" />
<div>{openEntity.entity.name}</div>
{numOutlineItems > 0 && <Chevron />}
{outlineHierarchy.map((section, idx) => (
<Fragment key={section.line}>
<div>{section.title}</div>
{idx < numOutlineItems - 1 && <Chevron />}
</Fragment>
))}
</div>
)
}
const Chevron = () => (
<MaterialIcon className="ol-cm-breadcrumb-chevron" type="chevron_right" />
)

View File

@@ -0,0 +1,120 @@
import ChatFallbackError from '@/features/chat/components/chat-fallback-error'
import InfiniteScroll from '@/features/chat/components/infinite-scroll'
import MessageInput from '@/features/chat/components/message-input'
import { useChatContext } from '@/features/chat/context/chat-context'
import OLBadge from '@/features/ui/components/ol/ol-badge'
import { FetchError } from '@/infrastructure/fetch-json'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import MaterialIcon from '@/shared/components/material-icon'
import { useUserContext } from '@/shared/context/user-context'
import { lazy, Suspense, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import { RailPanelHeader } from '../rail'
const MessageList = lazy(() => import('../../../chat/components/message-list'))
export const ChatIndicator = () => {
const { unreadMessageCount } = useChatContext()
if (unreadMessageCount === 0) {
return null
}
return <OLBadge bg="info">{unreadMessageCount}</OLBadge>
}
const Loading = () => <FullSizeLoadingSpinner delay={500} className="pt-4" />
export const ChatPane = () => {
const { t } = useTranslation()
const user = useUserContext()
const {
status,
messages,
initialMessagesLoaded,
atEnd,
loadInitialMessages,
loadMoreMessages,
reset,
sendMessage,
markMessagesAsRead,
error,
} = useChatContext()
useEffect(() => {
if (!initialMessagesLoaded) {
loadInitialMessages()
}
}, [loadInitialMessages, initialMessagesLoaded])
const shouldDisplayPlaceholder = status !== 'pending' && messages.length === 0
const messageContentCount = messages.reduce(
(acc, { contents }) => acc + contents.length,
0
)
if (error) {
// let user try recover from fetch errors
if (error instanceof FetchError) {
return <ChatFallbackError reconnect={reset} />
}
throw error
}
if (!user) {
return null
}
return (
<div className="chat-panel">
<RailPanelHeader title={t('collaborator_chat')} />
<div className="chat-wrapper">
<aside className="chat">
<InfiniteScroll
atEnd={atEnd}
className="messages"
fetchData={loadMoreMessages}
isLoading={status === 'pending'}
itemCount={messageContentCount}
>
<div className={classNames({ 'h-100': shouldDisplayPlaceholder })}>
<h2 className="visually-hidden">{t('chat')}</h2>
<Suspense fallback={<Loading />}>
{status === 'pending' && <Loading />}
{shouldDisplayPlaceholder && <Placeholder />}
<MessageList
messages={messages}
resetUnreadMessages={markMessagesAsRead}
newDesign
/>
</Suspense>
</div>
</InfiniteScroll>
<MessageInput
resetUnreadMessages={markMessagesAsRead}
sendMessage={sendMessage}
/>
</aside>
</div>
</div>
)
}
function Placeholder() {
const { t } = useTranslation()
return (
<div className="chat-empty-state-placeholder">
<div>
<span className="chat-empty-state-icon">
<MaterialIcon type="forum" />
</span>
</div>
<div>
<div className="chat-empty-state-title">{t('no_messages_yet')}</div>
<div className="chat-empty-state-body">
{t('start_the_conversation_by_saying_hello_or_sharing_an_update')}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { MessageProps } from '@/features/chat/components/message'
import { User } from '../../../../../../types/user'
import { getHueForUserId } from '@/shared/utils/colors'
import MessageContent from '@/features/chat/components/message-content'
import classNames from 'classnames'
function hue(user?: User) {
return user ? getHueForUserId(user.id) : 0
}
function getAvatarStyle(user?: User) {
return {
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
backgroundColor: `hsl(${hue(user)}, 85%, 40%`,
}
}
function Message({ message, fromSelf }: MessageProps) {
return (
<div className="chat-message-redesign">
<div className="message-row">
<div className="message-avatar-placeholder" />
{!fromSelf && (
<div className="message-author">
<span>{message.user.first_name || message.user.email}</span>
</div>
)}
</div>
{message.contents.map((content, index) => (
<div key={index} className="message-row">
<>
{!fromSelf && index === message.contents.length - 1 ? (
<div className="message-avatar">
<div className="avatar" style={getAvatarStyle(message.user)}>
{message.user.first_name?.charAt(0) ||
message.user.email.charAt(0)}
</div>
</div>
) : (
<div className="message-avatar-placeholder" />
)}
<div
className={classNames('message-container', {
'message-from-self': fromSelf,
'first-row-in-message': index === 0,
'last-row-in-message': index === message.contents.length - 1,
})}
>
<div className="message-content">
<MessageContent content={content} />
</div>
</div>
</>
</div>
))}
</div>
)
}
export default Message

View File

@@ -0,0 +1,23 @@
import NoSelectionPane from '@/features/ide-react/components/editor/no-selection-pane'
import { Editor } from './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)} />
)}
{selectedEntityCount > 1 && (
<MultipleSelectionPane selectedEntityCount={selectedEntityCount} />
)}
<Editor />
</div>
)
}

View File

@@ -0,0 +1,66 @@
import { LoadingPane } from '@/features/ide-react/components/editor/loading-pane'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { EditorScopeValue } from '@/features/ide-react/scope-adapters/editor-manager-context-adapter'
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
import useScopeValue from '@/shared/hooks/use-scope-value'
import classNames from 'classnames'
import SourceEditor from '@/features/source-editor/components/source-editor'
import { Panel, PanelGroup } from 'react-resizable-panels'
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
import { Suspense } from 'react'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import SymbolPalettePane from '@/features/ide-react/components/editor/symbol-palette-pane'
export const Editor = () => {
const [editor] = useScopeValue<EditorScopeValue>('editor')
const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
const { currentDocumentId } = useEditorManagerContext()
if (!currentDocumentId) {
return null
}
const isLoading = Boolean(
(!editor.sharejs_doc || editor.opening) &&
!editor.error_state &&
editor.open_doc_id
)
return (
<div
className={classNames('ide-redesign-editor-content', {
hidden: openEntity?.type !== 'doc' || selectedEntityCount !== 1,
})}
>
<PanelGroup
autoSaveId="ide-redesign-editor-symbol-palette"
direction="vertical"
>
<Panel
id="ide-redesign-panel-source-editor"
order={1}
className="ide-redesign-editor-panel"
>
<SourceEditor />
{isLoading && <LoadingPane />}
</Panel>
{editor.showSymbolPalette && (
<>
<VerticalResizeHandle id="ide-redesign-editor-symbol-palette" />
<Panel
id="ide-redesign-panel-symbol-palette"
order={2}
defaultSize={25}
minSize={10}
maxSize={50}
>
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<SymbolPalettePane />
</Suspense>
</Panel>
</>
)}
</PanelGroup>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import PdfLogsViewer from '@/features/pdf-preview/components/pdf-logs-viewer'
import { PdfPreviewProvider } from '@/features/pdf-preview/components/pdf-preview-provider'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
import OLBadge from '@/features/ui/components/ol/ol-badge'
export const ErrorIndicator = () => {
const { logEntries } = useCompileContext()
if (!logEntries) {
return null
}
const errorCount = Number(logEntries.errors?.length)
const warningCount = Number(logEntries.warnings?.length)
const totalCount = errorCount + warningCount
if (totalCount === 0) {
return null
}
return (
<OLBadge bg={errorCount > 0 ? 'danger' : 'warning'}>{totalCount}</OLBadge>
)
}
export const ErrorPane = () => {
return (
<PdfPreviewProvider>
<PdfLogsViewer alwaysVisible />
</PdfPreviewProvider>
)
}

View File

@@ -0,0 +1,49 @@
import { Panel, PanelGroup } from 'react-resizable-panels'
import { FileTree } from '@/features/ide-react/components/file-tree'
import { OutlineContainer } from '@/features/outline/components/outline-container'
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
import { useOutlinePane } from '@/features/ide-react/hooks/use-outline-pane'
import useCollapsibleFileTree from '../hooks/use-collapsible-file-tree'
import classNames from 'classnames'
function FileTreeOutlinePanel() {
const { outlineEnabled, outlinePanelRef } = useOutlinePane()
const { fileTreeExpanded, fileTreePanelRef } = useCollapsibleFileTree()
return (
<PanelGroup
className="file-tree-outline-panel-group"
autoSaveId="ide-redesign-file-tree-outline"
direction="vertical"
>
<Panel
className={classNames('file-tree-panel', {
'file-tree-panel-collapsed': !fileTreeExpanded,
})}
defaultSize={50}
id="ide-redesign-file-tree"
order={1}
collapsible
ref={fileTreePanelRef}
>
<FileTree />
</Panel>
<VerticalResizeHandle
hitAreaMargins={{ coarse: 0, fine: 0 }}
disabled={!outlineEnabled || !fileTreeExpanded}
/>
<Panel
className="file-outline-panel"
defaultSize={50}
id="ide-redesign-file-outline"
order={2}
collapsible
ref={outlinePanelRef}
>
<OutlineContainer />
</Panel>
</PanelGroup>
)
}
export default FileTreeOutlinePanel

View File

@@ -0,0 +1,149 @@
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon, {
AvailableUnfilledIcon,
} from '@/shared/components/material-icon'
import React from 'react'
import useCollapsibleFileTree from '../hooks/use-collapsible-file-tree'
import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
function FileTreeToolbar() {
const { t } = useTranslation()
const { fileTreeExpanded, toggleFileTreeExpanded } = useCollapsibleFileTree()
return (
<div className="file-tree-toolbar">
<button
className="file-tree-expand-collapse-button"
onClick={toggleFileTreeExpanded}
aria-label={
fileTreeExpanded ? t('hide_file_tree') : t('show_file_tree')
}
>
<MaterialIcon
type={
fileTreeExpanded ? 'keyboard_arrow_down' : 'keyboard_arrow_right'
}
/>
<h4>{t('file_tree')}</h4>
</button>
<FileTreeActionButtons />
</div>
)
}
function FileTreeActionButtons() {
const { t } = useTranslation()
const { fileTreeReadOnly } = useFileTreeData()
const { write } = usePermissionsContext()
const {
canCreate,
startCreatingFolder,
startCreatingDocOrFile,
startUploadingDocOrFile,
} = useFileTreeActionable()
useCommandProvider(() => {
if (!canCreate || fileTreeReadOnly || !write) return
return [
{
label: t('new_file'),
id: 'new_file',
handler: ({ location }) => {
eventTracking.sendMB('new-file-click', { location })
startCreatingDocOrFile()
},
},
{
label: t('new_folder'),
id: 'new_folder',
handler: startCreatingFolder,
},
{
label: t('upload_file'),
id: 'upload_file',
handler: ({ location }) => {
eventTracking.sendMB('upload-click', { location })
startUploadingDocOrFile()
},
},
]
}, [
canCreate,
fileTreeReadOnly,
startCreatingDocOrFile,
t,
startCreatingFolder,
startUploadingDocOrFile,
write,
])
if (!canCreate || fileTreeReadOnly) return null
const createWithAnalytics = () => {
eventTracking.sendMB('new-file-click', { location: 'toolbar' })
startCreatingDocOrFile()
}
const uploadWithAnalytics = () => {
eventTracking.sendMB('upload-click', { location: 'toolbar' })
startUploadingDocOrFile()
}
return (
<div className="file-tree-toolbar-action-buttons">
<FileTreeActionButton
id="new-file"
description={t('new_file')}
onClick={createWithAnalytics}
iconType="note_add"
/>
<FileTreeActionButton
id="new-folder"
description={t('new_folder')}
onClick={startCreatingFolder}
iconType="create_new_folder"
/>
<FileTreeActionButton
id="upload"
description={t('upload')}
onClick={uploadWithAnalytics}
iconType="upload_file"
/>
</div>
)
}
function FileTreeActionButton({
id,
description,
onClick,
iconType,
}: {
id: string
description: string
onClick: () => void
iconType: AvailableUnfilledIcon
}) {
return (
<OLTooltip
id={id}
description={description}
overlayProps={{ placement: 'bottom' }}
>
<button className="btn file-tree-toolbar-action-button" onClick={onClick}>
<MaterialIcon
unfilled
type={iconType}
accessibilityLabel={description}
/>
</button>
</OLTooltip>
)
}
export default FileTreeToolbar

View File

@@ -0,0 +1,26 @@
import { FC, JSXElementConstructor, useCallback } from 'react'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import { useRailContext } from '../../contexts/rail-context'
import getMeta from '@/utils/meta'
const [contactUsModalModules] = importOverleafModules('contactUsModal')
const ContactUsModal: JSXElementConstructor<{
show: boolean
handleHide: () => void
autofillProjectUrl: boolean
}> = contactUsModalModules?.import.default
export const RailHelpContactUsModal: FC<{ show: boolean }> = ({ show }) => {
const { setActiveModal } = useRailContext()
const handleHide = useCallback(() => setActiveModal(null), [setActiveModal])
if (!ContactUsModal) {
return null
}
const showSupport = getMeta('ol-showSupport')
if (!showSupport) {
return null
}
return (
<ContactUsModal show={show} handleHide={handleHide} autofillProjectUrl />
)
}

View File

@@ -0,0 +1,19 @@
import { FC } from 'react'
import { useProjectContext } from '@/shared/context/project-context'
import HotkeysModal from '@/features/hotkeys-modal/components/hotkeys-modal'
import { isMac } from '@/shared/utils/os'
import { useRailContext } from '../../contexts/rail-context'
export const RailHelpShowHotkeysModal: FC<{ show: boolean }> = ({ show }) => {
const { features } = useProjectContext()
const { setActiveModal } = useRailContext()
return (
<HotkeysModal
show={show}
handleHide={() => setActiveModal(null)}
isMac={isMac}
trackChangesVisible={features?.trackChangesVisible}
/>
)
}

View File

@@ -0,0 +1,42 @@
import OLBadge from '@/features/ui/components/ol/ol-badge'
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
export default function IntegrationCard({
onClick,
title,
description,
icon,
showPaywallBadge,
}: {
onClick: () => void
title: string
description: string
icon: React.ReactNode
showPaywallBadge: boolean
}) {
const { t } = useTranslation()
return (
<button onClick={onClick} className="integrations-panel-card-button">
<div className="integrations-panel-card-contents">
{icon}
<div className="integrations-panel-card-inner">
<header className="integrations-panel-card-header">
<div className="integrations-panel-card-title">{title}</div>
{showPaywallBadge && (
<OLBadge
prepend={<MaterialIcon type="star" />}
bg="light"
className="integrations-panel-card-premium-badge"
>
{t('premium')}
</OLBadge>
)}
</header>
<p className="integrations-panel-card-description">{description}</p>
</div>
</div>
</button>
)
}

View File

@@ -0,0 +1,23 @@
import { ElementType } from 'react'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import { RailPanelHeader } from '../rail'
import { useTranslation } from 'react-i18next'
const integrationPanelComponents = importOverleafModules(
'integrationPanelComponents'
) as { import: { default: ElementType }; path: string }[]
export default function IntegrationsPanel() {
const { t } = useTranslation()
return (
<div className="integrations-panel">
<RailPanelHeader title={t('integrations')} />
{integrationPanelComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { useState } from 'react'
import LabsExperimentWidget from '../../../shared/components/labs/labs-experiments-widget'
import { isInExperiment } from '@/utils/labs-utils'
import { useTranslation } from 'react-i18next'
import labsIcon from '../images/labs-icon.svg'
const EditorRedesignLabsWidget = ({
labsProgram,
setErrorMessage,
}: {
labsProgram: boolean
setErrorMessage: (err: string) => void
}) => {
const { t } = useTranslation()
const [optedIn, setOptedIn] = useState(isInExperiment('editor-redesign'))
return (
<LabsExperimentWidget
description={t(
'access_your_favourite_features_faster_with_our_new_streamlined_editor'
)}
experimentName="editor-redesign"
logo={<img src={labsIcon} alt="" aria-hidden="true" />}
labsEnabled={labsProgram}
setErrorMessage={setErrorMessage}
optedIn={optedIn}
setOptedIn={setOptedIn}
title={t('new_overleaf_editor')}
/>
)
}
export default EditorRedesignLabsWidget

View File

@@ -0,0 +1,105 @@
import { Panel, PanelGroup } from 'react-resizable-panels'
import classNames from 'classnames'
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
import PdfPreview from '@/features/pdf-preview/components/pdf-preview'
import { RailLayout } from './rail'
import { Toolbar } from './toolbar/toolbar'
import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
import { useTranslation } from 'react-i18next'
import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useState } from 'react'
import EditorPanel from './editor-panel'
import { useRailContext } from '../contexts/rail-context'
import HistoryContainer from '@/features/ide-react/components/history-container'
export default function MainLayout() {
const [resizing, setResizing] = useState(false)
const { resizing: railResizing } = useRailContext()
const {
togglePdfPane,
handlePdfPaneExpand,
handlePdfPaneCollapse,
setPdfIsOpen: setIsPdfOpen,
pdfIsOpen: isPdfOpen,
pdfPanelRef,
} = usePdfPane()
const { view, pdfLayout } = useLayoutContext()
const editorIsOpen =
view === 'editor' || view === 'file' || pdfLayout === 'sideBySide'
const { t } = useTranslation()
return (
<div className="ide-redesign-main">
<Toolbar />
<div className="ide-redesign-body">
<PanelGroup
autoSaveId="ide-redesign-outer-layout"
direction="horizontal"
className={classNames('ide-redesign-inner', {
'ide-panel-group-resizing': resizing || railResizing,
})}
>
<RailLayout />
<Panel id="ide-redesign-editor-and-pdf-panel" order={2}>
<HistoryContainer />
<PanelGroup
autoSaveId="ide-redesign-editor-and-pdf-panel-group"
direction="horizontal"
>
<Panel
id="ide-redesign-editor-panel"
order={1}
className={classNames({
hidden: !editorIsOpen || view === 'history',
})}
minSize={5}
defaultSize={50}
>
<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')}
/>
</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}
>
<PdfPreview />
</Panel>
</PanelGroup>
</Panel>
</PanelGroup>
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
import { useRailContext } from '../../contexts/rail-context'
import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
import { useIsNewEditorEnabled } from '../../utils/new-editor-utils'
function PdfErrorState() {
const { loadingError } = usePdfPreviewContext()
// TODO ide-redesign-cleanup: rename showLogs to something else and check usages
const { showLogs } = useCompileContext()
const { t } = useTranslation()
const { openTab: openRailTab } = useRailContext()
const newEditor = useIsNewEditorEnabled()
if (!newEditor || (!loadingError && !showLogs)) {
return null
}
return (
<div className="pdf-error-state">
<div className="pdf-error-state-top-section">
<div className="pdf-error-state-warning-icon">
<MaterialIcon type="warning" />
</div>
<div className="pdf-error-state-text">
<p className="pdf-error-state-label">{t('pdf_couldnt_compile')}</p>
<p className="pdf-error-state-description">
{t('we_are_unable_to_generate_the_pdf_at_this_time')}
</p>
</div>
<OLButton
variant="secondary"
size="sm"
onClick={() => {
openRailTab('errors')
}}
>
{t('check_logs')}
</OLButton>
</div>
<div className="pdf-error-state-info-box">
<div className="pdf-error-state-info-box-title">
<MaterialIcon type="info" unfilled />
{t('why_might_this_happen')}
</div>
<ul className="pdf-error-state-info-box-text">
<li>{t('there_is_an_unrecoverable_latex_error')}</li>
<li>{t('the_document_environment_contains_no_content')}</li>
<li>{t('this_project_contains_a_file_called_output')}</li>
</ul>
</div>
</div>
)
}
export default PdfErrorState

View File

@@ -0,0 +1,22 @@
import { memo } from 'react'
import OlButtonToolbar from '@/features/ui/components/ol/ol-button-toolbar'
import PdfCompileButton from '@/features/pdf-preview/components/pdf-compile-button'
import PdfHybridDownloadButton from '@/features/pdf-preview/components/pdf-hybrid-download-button'
function PdfPreviewHybridToolbar() {
// TODO: add detached pdf logic
return (
<OlButtonToolbar className="toolbar toolbar-pdf toolbar-pdf-hybrid">
<div className="toolbar-pdf-left">
<PdfCompileButton />
<PdfHybridDownloadButton />
</div>
<div className="toolbar-pdf-right">
<div className="toolbar-pdf-controls" id="toolbar-pdf-controls" />
{/* TODO: should we have switch to editor/code check/synctex buttons? */}
</div>
</OlButtonToolbar>
)
}
export default memo(PdfPreviewHybridToolbar)

View File

@@ -0,0 +1,403 @@
import { FC, ReactElement, useCallback, useMemo } from 'react'
import { Nav, NavLink, Tab, TabContainer } from 'react-bootstrap-5'
import MaterialIcon, {
AvailableUnfilledIcon,
} from '@/shared/components/material-icon'
import { Panel } from 'react-resizable-panels'
import { useLayoutContext } from '@/shared/context/layout-context'
import { ErrorIndicator, ErrorPane } from './errors'
import {
RailModalKey,
RailTabKey,
useRailContext,
} from '../contexts/rail-context'
import FileTreeOutlinePanel from './file-tree-outline-panel'
import { ChatIndicator, ChatPane } from './chat/chat'
import getMeta from '@/utils/meta'
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import IntegrationsPanel from './integrations-panel/integrations-panel'
import OLButton from '@/features/ui/components/ol/ol-button'
import {
Dropdown,
DropdownDivider,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { RailHelpShowHotkeysModal } from './help/keyboard-shortcuts'
import { RailHelpContactUsModal } from './help/contact-us'
import { HistorySidebar } from '@/features/ide-react/components/history-sidebar'
import DictionarySettingsModal from './settings/editor-settings/dictionary-settings-modal'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
type RailElement = {
icon: AvailableUnfilledIcon
key: RailTabKey
component: ReactElement | null
indicator?: ReactElement
title: string
hide?: boolean
}
type RailActionButton = {
key: string
icon: AvailableUnfilledIcon
title: string
action: () => void
}
type RailDropdown = {
key: string
icon: AvailableUnfilledIcon
title: string
dropdown: ReactElement
}
type RailAction = RailDropdown | RailActionButton
const RAIL_MODALS: {
key: RailModalKey
modalComponentFunction: FC<{ show: boolean }>
}[] = [
{
key: 'keyboard-shortcuts',
modalComponentFunction: RailHelpShowHotkeysModal,
},
{
key: 'contact-us',
modalComponentFunction: RailHelpContactUsModal,
},
{
key: 'dictionary',
modalComponentFunction: DictionarySettingsModal,
},
]
export const RailLayout = () => {
const { t } = useTranslation()
const {
activeModal,
selectedTab,
openTab,
isOpen,
setIsOpen,
panelRef,
handlePaneCollapse,
handlePaneExpand,
togglePane,
setResizing,
} = useRailContext()
const { view, setLeftMenuShown } = useLayoutContext()
const isHistoryView = view === 'history'
const railTabs: RailElement[] = useMemo(
() => [
{
key: 'file-tree',
icon: 'description',
title: t('file_tree'),
component: <FileTreeOutlinePanel />,
},
{
key: 'integrations',
icon: 'integration_instructions',
title: t('integrations'),
component: <IntegrationsPanel />,
},
{
key: 'review-panel',
icon: 'rate_review',
title: t('review_panel'),
component: null,
},
{
key: 'chat',
icon: 'forum',
component: <ChatPane />,
indicator: <ChatIndicator />,
title: t('chat'),
hide: !getMeta('ol-chatEnabled'),
},
{
key: 'errors',
icon: 'report',
title: t('error_log'),
component: <ErrorPane />,
indicator: <ErrorIndicator />,
},
],
[t]
)
const railActions: RailAction[] = useMemo(
() => [
{
key: 'support',
icon: 'help',
title: t('help'),
dropdown: <RailHelpDropdown />,
},
{
key: 'settings',
icon: 'settings',
title: t('settings'),
action: () => setLeftMenuShown(true),
},
],
[setLeftMenuShown, t]
)
const onTabSelect = useCallback(
(key: string | null) => {
if (key === selectedTab) {
togglePane()
} else {
// HACK: Apparently the onSelect event is triggered with href attributes
// from DropdownItems
if (!railTabs.some(tab => !tab.hide && tab.key === key)) {
// Attempting to open a non-existent tab
return
}
// Change the selected tab and make sure it's open
openTab((key ?? 'file-tree') as RailTabKey)
}
},
[openTab, togglePane, selectedTab, railTabs]
)
const isReviewPanelOpen = selectedTab === 'review-panel'
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"
>
<div className={classNames('ide-rail', { hidden: isHistoryView })}>
<Nav activeKey={selectedTab} className="ide-rail-tabs-nav">
{railTabs
.filter(({ hide }) => !hide)
.map(({ icon, key, indicator, title }) => (
<RailTab
open={isOpen && selectedTab === key}
key={key}
eventKey={key}
icon={icon}
indicator={indicator}
title={title}
/>
))}
<div className="flex-grow-1" />
{railActions?.map(action => (
<RailActionElement key={action.key} action={action} />
))}
</Nav>
</div>
<Panel
id="ide-redesign-sidebar-panel"
className={classNames({ hidden: isReviewPanelOpen })}
order={1}
defaultSize={15}
minSize={5}
maxSize={80}
ref={panelRef}
collapsible
onCollapse={handlePaneCollapse}
onExpand={handlePaneExpand}
>
{isHistoryView && <HistorySidebar />}
<div
className={classNames('ide-rail-content', {
hidden: isHistoryView,
})}
>
<Tab.Content>
{railTabs
.filter(({ hide }) => !hide)
.map(({ key, component }) => (
<Tab.Pane eventKey={key} key={key}>
{component}
</Tab.Pane>
))}
</Tab.Content>
</div>
</Panel>
<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>
{RAIL_MODALS.map(({ key, modalComponentFunction: Component }) => (
<Component key={key} show={activeModal === key} />
))}
</TabContainer>
)
}
const RailTab = ({
icon,
eventKey,
open,
indicator,
title,
}: {
icon: AvailableUnfilledIcon
eventKey: string
open: boolean
indicator?: ReactElement
title: string
}) => {
return (
<OLTooltip
id={`rail-tab-tooltip-${eventKey}`}
description={title}
overlayProps={{ delay: 0, placement: 'right' }}
>
<NavLink
eventKey={eventKey}
className={classNames('ide-rail-tab-link', {
'open-rail': open,
})}
>
{open ? (
<MaterialIcon className="ide-rail-tab-link-icon" type={icon} />
) : (
<MaterialIcon
className="ide-rail-tab-link-icon"
type={icon}
unfilled
/>
)}
{indicator}
</NavLink>
</OLTooltip>
)
}
const RailActionElement = ({ action }: { action: RailAction }) => {
const icon = (
<MaterialIcon
className="ide-rail-tab-link-icon"
type={action.icon}
unfilled
/>
)
const onActionClick = useCallback(() => {
if ('action' in action) {
action.action()
}
}, [action])
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
id="rail-help-dropdown-btn"
className="ide-rail-tab-link ide-rail-tab-button ide-rail-tab-dropdown"
as="button"
>
{icon}
</DropdownToggle>
</span>
</OLTooltip>
{action.dropdown}
</Dropdown>
)
} else {
return (
<OLTooltip
id={`rail-tab-tooltip-${action.key}`}
description={action.title}
overlayProps={{ delay: 0, placement: 'right' }}
>
<button
onClick={onActionClick}
className="ide-rail-tab-link ide-rail-tab-button"
type="button"
>
{icon}
</button>
</OLTooltip>
)
}
}
export const RailPanelHeader: FC<{ title: string }> = ({ title }) => {
const { handlePaneCollapse } = useRailContext()
return (
<header className="rail-panel-header">
<h4 className="rail-panel-title">{title}</h4>
<OLButton
onClick={handlePaneCollapse}
className="rail-panel-header-button-subdued"
size="sm"
>
<MaterialIcon type="close" />
</OLButton>
</header>
)
}
const 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>
)}
<DropdownItem
href="https://forms.gle/soyVStc5qDx9na1Z6"
role="menuitem"
target="_blank"
rel="noopener noreferrer"
>
{t('give_feedback')}
</DropdownItem>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,18 @@
import SettingsSection from '../settings-section'
import OverallThemeSetting from '../appearance-settings/overall-theme-setting'
import EditorThemeSetting from './editor-theme-setting'
import FontSizeSetting from './font-size-setting'
import FontFamilySetting from './font-family-setting'
import LineHeightSetting from './line-height-setting'
export default function AppearanceSettings() {
return (
<SettingsSection>
<OverallThemeSetting />
<EditorThemeSetting />
<FontSizeSetting />
<FontFamilySetting />
<LineHeightSetting />
</SettingsSection>
)
}

View File

@@ -0,0 +1,46 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import DropdownSetting from '../dropdown-setting'
import getMeta from '@/utils/meta'
import { useMemo } from 'react'
import type { Option } from '../dropdown-setting'
import { useTranslation } from 'react-i18next'
export default function EditorThemeSetting() {
const editorThemes = getMeta('ol-editorThemes')
const legacyEditorThemes = getMeta('ol-legacyEditorThemes')
const { editorTheme, setEditorTheme } = useProjectSettingsContext()
const { t } = useTranslation()
const options = useMemo(() => {
const editorThemeOptions: Array<Option> =
editorThemes?.map(theme => ({
value: theme,
label: theme.replace(/_/g, ' '),
})) ?? []
const dividerOption: Option = {
value: '-',
label: '—————————————————',
disabled: true,
}
const legacyEditorThemeOptions: Array<Option> =
legacyEditorThemes?.map(theme => ({
value: theme,
label: theme.replace(/_/g, ' ') + ' (Legacy)',
})) ?? []
return [...editorThemeOptions, dividerOption, ...legacyEditorThemeOptions]
}, [editorThemes, legacyEditorThemes])
return (
<DropdownSetting
id="editorTheme"
label={t('editor_theme')}
description={t('the_code_editor_color_scheme')}
options={options}
onChange={setEditorTheme}
value={editorTheme}
/>
)
}

View File

@@ -0,0 +1,32 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import { useTranslation } from 'react-i18next'
import DropdownSetting from '../dropdown-setting'
export default function FontFamilySetting() {
const { fontFamily, setFontFamily } = useProjectSettingsContext()
const { t } = useTranslation()
return (
<DropdownSetting
id="fontFamily"
label={t('editor_font_family')}
options={[
{
value: 'monaco',
label: 'Monaco / Menlo / Consolas',
},
{
value: 'lucida',
label: 'Lucida / Source Code Pro',
},
{
value: 'opendyslexicmono',
label: 'OpenDyslexic Mono',
},
]}
onChange={setFontFamily}
value={fontFamily}
width="wide"
/>
)
}

View File

@@ -0,0 +1,24 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import { useTranslation } from 'react-i18next'
import DropdownSetting from '../dropdown-setting'
const sizes = [10, 11, 12, 13, 14, 16, 18, 20, 22, 24]
const options = sizes.map(size => ({
value: size,
label: `${size}px`,
}))
export default function FontSizeSetting() {
const { fontSize, setFontSize } = useProjectSettingsContext()
const { t } = useTranslation()
return (
<DropdownSetting
id="fontSize"
label={t('editor_font_size')}
options={options}
onChange={setFontSize}
value={fontSize}
/>
)
}

View File

@@ -0,0 +1,31 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import { useTranslation } from 'react-i18next'
import DropdownSetting from '../dropdown-setting'
export default function LineHeightSetting() {
const { lineHeight, setLineHeight } = useProjectSettingsContext()
const { t } = useTranslation()
return (
<DropdownSetting
id="lineHeight"
label={t('editor_line_height')}
options={[
{
value: 'compact',
label: t('compact'),
},
{
value: 'normal',
label: t('normal'),
},
{
value: 'wide',
label: t('wide'),
},
]}
onChange={setLineHeight}
value={lineHeight}
/>
)
}

View File

@@ -0,0 +1,45 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import DropdownSetting from '../dropdown-setting'
import getMeta from '@/utils/meta'
import { useMemo } from 'react'
import type { Option } from '../dropdown-setting'
import { useTranslation } from 'react-i18next'
import { OverallThemeMeta } from '../../../../../../../types/project-settings'
import { isIEEEBranded } from '@/utils/is-ieee-branded'
import { useLayoutContext } from '@/shared/context/layout-context'
import { OverallTheme } from '@/shared/utils/styles'
export default function OverallThemeSetting() {
const { t } = useTranslation()
const overallThemes = getMeta('ol-overallThemes') as
| OverallThemeMeta[]
| undefined
const { loadingStyleSheet } = useLayoutContext()
const { overallTheme, setOverallTheme } = useProjectSettingsContext()
const options: Array<Option<OverallTheme>> = useMemo(
() =>
overallThemes?.map(({ name, val }) => ({
value: val,
label: name,
})) ?? [],
[overallThemes]
)
if (!overallThemes || isIEEEBranded()) {
return null
}
return (
<DropdownSetting
id="overallTheme"
label={t('overall_theme')}
description={t('the_overleaf_color_scheme')}
options={options}
onChange={setOverallTheme}
value={overallTheme}
loading={loadingStyleSheet}
/>
)
}

View File

@@ -0,0 +1,32 @@
import OLButton from '@/features/ui/components/ol/ol-button'
import Setting from './setting'
export default function ButtonSetting({
id,
label,
buttonText,
onClick,
description,
disabled,
}: {
id: string
label: string
buttonText: string
onClick: () => void
description?: string
disabled?: boolean
}) {
return (
<Setting controlId={id} label={label} description={description}>
<OLButton
id={id}
variant="secondary"
size="sm"
onClick={onClick}
disabled={disabled}
>
{buttonText}
</OLButton>
</Setting>
)
}

View File

@@ -0,0 +1,31 @@
import ToggleSetting from '../toggle-setting'
import { useTranslation } from 'react-i18next'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
import { useCallback } from 'react'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
export default function AutoCompileSetting() {
const { autoCompile, setAutoCompile } = useCompileContext()
const { t } = useTranslation()
const sendEventAndSet = useCallback(
(value: boolean) => {
eventTracking.sendMB('recompile-setting-changed', {
setting: 'auto-compile',
settingVal: value,
})
setAutoCompile(value)
},
[setAutoCompile]
)
return (
<ToggleSetting
id="autoCompile"
label={t('autocompile')}
description={t('automatically_recompile_the_project_as_you_edit')}
checked={autoCompile}
onChange={sendEventAndSet}
/>
)
}

View File

@@ -0,0 +1,43 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import DropdownSetting from '../dropdown-setting'
import type { Option } from '../dropdown-setting'
import { useTranslation } from 'react-i18next'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { ProjectCompiler } from '../../../../../../../types/project-settings'
const OPTIONS: Option<ProjectCompiler>[] = [
{
value: 'pdflatex',
label: 'pdfLaTeX',
},
{
value: 'latex',
label: 'LaTeX',
},
{
value: 'xelatex',
label: 'XeLaTeX',
},
{
value: 'lualatex',
label: 'LuaLaTeX',
},
]
export default function CompilerSetting() {
const { compiler, setCompiler } = useProjectSettingsContext()
const { t } = useTranslation()
const { write } = usePermissionsContext()
return (
<DropdownSetting
id="compiler"
label={t('compiler')}
description={t('the_latex_engine_used_for_compiling')}
disabled={!write}
options={OPTIONS}
onChange={setCompiler}
value={compiler}
/>
)
}

View File

@@ -0,0 +1,22 @@
import SettingsSection from '../settings-section'
import AutoCompileSetting from './auto-compile-setting'
import CompilerSetting from './compiler-setting'
import DraftSetting from './draft-setting'
import ImageNameSetting from './image-name-setting'
import RootDocumentSetting from './root-document-setting'
import StopOnFirstErrorSetting from './stop-on-first-error-setting'
export default function CompilerSettings() {
return (
<>
<SettingsSection>
<RootDocumentSetting />
<CompilerSetting />
<ImageNameSetting />
<DraftSetting />
<StopOnFirstErrorSetting />
<AutoCompileSetting />
</SettingsSection>
</>
)
}

View File

@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
import { useCallback, useMemo } from 'react'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import DropdownSetting from '../dropdown-setting'
export default function DraftSetting() {
const { draft, setDraft } = useCompileContext()
const { t } = useTranslation()
const sendEventAndSet = useCallback(
(value: boolean) => {
eventTracking.sendMB('recompile-setting-changed', {
setting: 'compile-mode',
settingVal: value,
})
setDraft(value)
},
[setDraft]
)
const options = useMemo(
() => [
{ label: t('normal'), value: false },
{
label: t('fast_draft'),
value: true,
},
],
[t]
)
return (
<DropdownSetting
id="draft"
label={t('compile_mode')}
options={options}
description={t('switch_compile_mode_for_faster_draft_compilation')}
value={draft}
onChange={sendEventAndSet}
/>
)
}

View File

@@ -0,0 +1,43 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import DropdownSetting from '../dropdown-setting'
import type { Option } from '../dropdown-setting'
import { useTranslation } from 'react-i18next'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useMemo } from 'react'
import getMeta from '@/utils/meta'
export default function ImageNameSetting() {
const { imageName, setImageName } = useProjectSettingsContext()
const { t } = useTranslation()
const { write } = usePermissionsContext()
const allowedImageNames = useMemo(
() => getMeta('ol-allowedImageNames') || [],
[]
)
const options: Array<Option> = useMemo(
() =>
allowedImageNames.map(({ imageName, imageDesc }) => ({
value: imageName,
label: imageDesc,
})),
[allowedImageNames]
)
if (allowedImageNames.length === 0) {
return null
}
return (
<DropdownSetting
id="imageName"
label={t('tex_live_version')}
description={t('the_version_of_tex_live_used_for_compiling')}
disabled={!write}
options={options}
onChange={setImageName}
value={imageName}
/>
)
}

View File

@@ -0,0 +1,49 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import DropdownSetting from '../dropdown-setting'
import { useMemo } from 'react'
import type { Option } from '../dropdown-setting'
import { useTranslation } from 'react-i18next'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { isValidTeXFile } from '@/main/is-valid-tex-file'
export default function RootDocumentSetting() {
const { rootDocId, setRootDocId } = useProjectSettingsContext()
const { t } = useTranslation()
const { write } = usePermissionsContext()
const { docs } = useFileTreeData()
const validDocsOptions = useMemo(() => {
const filteredDocs =
docs?.filter(
doc => isValidTeXFile(doc.doc.name) || rootDocId === doc.doc.id
) ?? []
const mappedDocs: Array<Option> = filteredDocs.map(doc => ({
value: doc.doc.id,
label: doc.path,
}))
if (!rootDocId) {
mappedDocs.unshift({
value: '',
label: 'None',
disabled: true,
})
}
return mappedDocs
}, [docs, rootDocId])
return (
<DropdownSetting
id="rootDocId"
label={t('main_document')}
description={t('the_primary_file_for_compiling_your_project')}
disabled={!write}
options={validDocsOptions}
onChange={setRootDocId}
value={rootDocId}
/>
)
}

View File

@@ -0,0 +1,18 @@
import ToggleSetting from '../toggle-setting'
import { useTranslation } from 'react-i18next'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
export default function StopOnFirstErrorSetting() {
const { stopOnFirstError, setStopOnFirstError } = useCompileContext()
const { t } = useTranslation()
return (
<ToggleSetting
id="stopOnFirstError"
label={t('stop_on_first_error')}
description={t('identify_errors_with_your_compile')}
checked={stopOnFirstError}
onChange={setStopOnFirstError}
/>
)
}

View File

@@ -0,0 +1,108 @@
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
import { ChangeEventHandler, useCallback } from 'react'
import Setting from './setting'
import classNames from 'classnames'
import { Spinner } from 'react-bootstrap-5'
type PossibleValue = string | number | boolean
export type Option<T extends PossibleValue = string> = {
value: T
label: string
ariaHidden?: 'true' | 'false'
disabled?: boolean
}
export type Optgroup<T extends PossibleValue = string> = {
label: string
options: Array<Option<T>>
}
type SettingsMenuSelectProps<T extends PossibleValue = string> = {
id: string
label: string
options: Array<Option<T>>
onChange: (val: T) => void
description?: string
// TODO: We can remove optgroup when the spellcheck setting is
// split into 2 and no longer uses it.
optgroup?: Optgroup<T>
value?: T
disabled?: boolean
width?: 'default' | 'wide'
loading?: boolean
}
export default function DropdownSetting<T extends PossibleValue = string>({
id,
label,
options,
onChange,
value,
optgroup,
description = undefined,
disabled = false,
width = 'default',
loading = false,
}: SettingsMenuSelectProps<T>) {
const handleChange: ChangeEventHandler<HTMLSelectElement> = useCallback(
event => {
const selectedValue = event.target.value
let onChangeValue: PossibleValue = selectedValue
if (typeof value === 'boolean') {
onChangeValue = selectedValue === 'true'
} else if (typeof value === 'number') {
onChangeValue = parseInt(selectedValue, 10)
}
onChange(onChangeValue as T)
},
[onChange, value]
)
return (
<Setting controlId={id} label={label} description={description}>
{loading ? (
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
) : (
<OLFormSelect
id={id}
className={classNames('ide-dropdown-setting', {
'ide-dropdown-setting-wide': width === 'wide',
})}
size="sm"
onChange={handleChange}
value={value?.toString()}
disabled={disabled}
>
{options.map(option => (
<option
key={`${id}-${option.value}`}
value={option.value.toString()}
aria-hidden={option.ariaHidden}
disabled={option.disabled}
>
{option.label}
</option>
))}
{optgroup ? (
<optgroup label={optgroup.label}>
{optgroup.options.map(option => (
<option
value={option.value.toString()}
key={option.value.toString()}
>
{option.label}
</option>
))}
</optgroup>
) : null}
</OLFormSelect>
)}
</Setting>
)
}

View File

@@ -0,0 +1,19 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import ToggleSetting from '../toggle-setting'
import { useTranslation } from 'react-i18next'
export default function AutoCloseBracketsSetting() {
const { autoPairDelimiters, setAutoPairDelimiters } =
useProjectSettingsContext()
const { t } = useTranslation()
return (
<ToggleSetting
id="autoPairDelimiters"
label={t('auto_close_brackets')}
description={t('automatically_insert_closing_brackets_and_parentheses')}
checked={autoPairDelimiters}
onChange={setAutoPairDelimiters}
/>
)
}

View File

@@ -0,0 +1,18 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import ToggleSetting from '../toggle-setting'
import { useTranslation } from 'react-i18next'
export default function AutoCompleteSetting() {
const { autoComplete, setAutoComplete } = useProjectSettingsContext()
const { t } = useTranslation()
return (
<ToggleSetting
id="autoComplete"
label={t('auto_complete')}
description={t('suggests_code_completions_while_typing')}
checked={autoComplete}
onChange={setAutoComplete}
/>
)
}

View File

@@ -0,0 +1,18 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import ToggleSetting from '../toggle-setting'
import { useTranslation } from 'react-i18next'
export default function CodeCheckSetting() {
const { syntaxValidation, setSyntaxValidation } = useProjectSettingsContext()
const { t } = useTranslation()
return (
<ToggleSetting
id="syntaxValidation"
label={t('syntax_validation')}
description={t('enables_real_time_syntax_checking_in_the_editor')}
checked={syntaxValidation}
onChange={setSyntaxValidation}
/>
)
}

View File

@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next'
import { useCallback } from 'react'
import ButtonSetting from '../button-setting'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
export default function DictionarySetting() {
const { t } = useTranslation()
const { setActiveModal } = useRailContext()
// TODO ide-redesign-cleanup: leftMenu is a misnomer, in the
// redesign it refers to the settings modal
const { setLeftMenuShown } = useLayoutContext()
const onClick = useCallback(() => {
setActiveModal('dictionary')
setLeftMenuShown(false)
}, [setLeftMenuShown, setActiveModal])
return (
<ButtonSetting
id="dictionary-settings"
label={t('dictionary')}
description={t('edit_your_custom_dictionary')}
buttonText={t('edit')}
onClick={onClick}
/>
)
}

View File

@@ -0,0 +1,10 @@
import DictionaryModal from '@/features/dictionary/components/dictionary-modal'
import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
import { useCallback } from 'react'
export default function DictionarySettingsModal({ show }: { show: boolean }) {
const { setActiveModal } = useRailContext()
const handleHide = useCallback(() => setActiveModal(null), [setActiveModal])
return <DictionaryModal show={show} handleHide={handleHide} />
}

View File

@@ -0,0 +1,40 @@
import AutoCompleteSetting from './auto-complete-setting'
import CodeCheckSetting from './code-check-setting'
import AutoCloseBracketsSetting from './auto-close-brackets-setting'
import SettingsSection from '../settings-section'
import MathPreviewSetting from './math-preview-setting'
import { useTranslation } from 'react-i18next'
import KeybindingSetting from './keybinding-setting'
import PDFViewerSetting from './pdf-viewer-setting'
import SpellCheckSetting from './spell-check-setting'
import DictionarySetting from './dictionary-setting'
import importOverleafModules from '../../../../../../macros/import-overleaf-module.macro'
const [referenceSearchSettingModule] = importOverleafModules(
'referenceSearchSetting'
)
const ReferenceSearchSetting = referenceSearchSettingModule?.import.default
export default function EditorSettings() {
const { t } = useTranslation()
return (
<>
<SettingsSection>
<AutoCompleteSetting />
<AutoCloseBracketsSetting />
<CodeCheckSetting />
<KeybindingSetting />
<PDFViewerSetting />
{ReferenceSearchSetting && <ReferenceSearchSetting />}
</SettingsSection>
<SettingsSection title={t('spellcheck')}>
<SpellCheckSetting />
<DictionarySetting />
</SettingsSection>
<SettingsSection title={t('tools')}>
<MathPreviewSetting />
</SettingsSection>
</>
)
}

View File

@@ -0,0 +1,35 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import { useTranslation } from 'react-i18next'
import DropdownSetting from '../dropdown-setting'
import { Keybindings } from '../../../../../../../types/user-settings'
const OPTIONS: { value: Keybindings; label: string }[] = [
{
value: 'default',
label: 'None',
},
{
value: 'vim',
label: 'Vim',
},
{
value: 'emacs',
label: 'Emacs',
},
]
export default function KeybindingSetting() {
const { mode, setMode } = useProjectSettingsContext()
const { t } = useTranslation()
return (
<DropdownSetting
id="mode"
label={t('keybindings')}
description={t('work_in_vim_or_emacs_emulation_mode')}
options={OPTIONS}
onChange={setMode}
value={mode}
/>
)
}

View File

@@ -0,0 +1,18 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import ToggleSetting from '../toggle-setting'
import { useTranslation } from 'react-i18next'
export default function MathPreviewSetting() {
const { mathPreview, setMathPreview } = useProjectSettingsContext()
const { t } = useTranslation()
return (
<ToggleSetting
id="mathPreview"
label={t('equation_preview')}
description={t('show_live_equation_previews_while_typing')}
checked={mathPreview}
onChange={setMathPreview}
/>
)
}

View File

@@ -0,0 +1,27 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import { useTranslation } from 'react-i18next'
import DropdownSetting from '../dropdown-setting'
export default function PDFViewerSetting() {
const { pdfViewer, setPdfViewer } = useProjectSettingsContext()
const { t } = useTranslation()
return (
<DropdownSetting
id="pdfViewer"
label={t('pdf_viewer')}
options={[
{
value: 'pdfjs',
label: t('overleaf'),
},
{
value: 'native',
label: t('browser'),
},
]}
onChange={setPdfViewer}
value={pdfViewer}
/>
)
}

View File

@@ -0,0 +1,40 @@
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
import { useTranslation } from 'react-i18next'
import DropdownSetting, { Optgroup } from '../dropdown-setting'
import { useMemo } from 'react'
import getMeta from '@/utils/meta'
import { supportsWebAssembly } from '@/utils/wasm'
// TODO: Split this into separate setttings for spell check
// language and spell check on/off
export default function SpellCheckSetting() {
const { spellCheckLanguage, setSpellCheckLanguage } =
useProjectSettingsContext()
const { t } = useTranslation()
const optgroup: Optgroup = useMemo(() => {
const options = (getMeta('ol-languages') ?? [])
// only include spell-check languages that are available in the client
.filter(language => language.dic !== undefined)
return {
label: 'Language',
options: options.map(language => ({
value: language.code,
label: language.name,
})),
}
}, [])
return (
<DropdownSetting
id="spellCheckLanguage"
label={t('spellcheck_language')}
options={[{ value: '', label: t('off') }]}
optgroup={optgroup}
onChange={setSpellCheckLanguage}
value={supportsWebAssembly() ? spellCheckLanguage : ''}
width="wide"
/>
)
}

View File

@@ -0,0 +1,25 @@
export default function Setting({
label,
controlId,
children,
description = undefined,
}: {
label: string
description: string | undefined
controlId: string
children: React.ReactNode
}) {
return (
<div className="ide-setting">
<div>
<label htmlFor={controlId} className="ide-setting-title">
{label}
</label>
{description && (
<div className="ide-setting-description">{description}</div>
)}
</div>
{children}
</div>
)
}

View File

@@ -0,0 +1,138 @@
import MaterialIcon, {
AvailableUnfilledIcon,
} from '@/shared/components/material-icon'
import { ReactElement, useMemo, useState } from 'react'
import {
Nav,
NavLink,
TabContainer,
TabContent,
TabPane,
} from 'react-bootstrap-5'
import { useTranslation } from 'react-i18next'
import EditorSettings from './editor-settings/editor-settings'
import AppearanceSettings from './appearance-settings/appearance-settings'
import CompilerSettings from './compiler-settings/compiler-settings'
export type SettingsEntry = SettingsLink | SettingsTab
type SettingsTab = {
icon: AvailableUnfilledIcon
key: string
component: ReactElement
title: string
}
type SettingsLink = {
key: string
icon: AvailableUnfilledIcon
href: string
title: string
}
export const SettingsModalBody = () => {
const { t } = useTranslation()
const settingsTabs: SettingsEntry[] = useMemo(
() => [
{
key: 'editor',
title: t('editor'),
icon: 'code',
component: <EditorSettings />,
},
{
key: 'compiler',
title: t('compiler'),
icon: 'picture_as_pdf',
component: <CompilerSettings />,
},
{
key: 'appearance',
title: t('appearance'),
icon: 'brush',
component: <AppearanceSettings />,
},
{
key: 'account_settings',
title: t('account_settings'),
icon: 'settings',
href: '/user/settings',
},
],
[t]
)
const [activeTab, setActiveTab] = useState<string | null | undefined>(
settingsTabs[0]?.key
)
return (
<TabContainer
transition={false}
onSelect={setActiveTab}
defaultActiveKey={activeTab ?? undefined}
id="ide-settings-tabs"
>
<div className="d-flex flex-row">
<Nav
defaultActiveKey={settingsTabs[0]?.key}
className="d-flex flex-column ide-settings-tab-nav"
>
{settingsTabs.map(entry => (
<SettingsNavLink entry={entry} key={entry.key} />
))}
</Nav>
<TabContent className="ide-settings-tab-content">
{settingsTabs
.filter(t => 'component' in t)
.map(({ key, component }) => (
<TabPane eventKey={key} key={key}>
{component}
</TabPane>
))}
</TabContent>
</div>
</TabContainer>
)
}
const SettingsNavLink = ({ entry }: { entry: SettingsEntry }) => {
if ('href' in entry) {
return (
<a
href={entry.href}
target="_blank"
rel="noopener"
className="ide-settings-tab-link"
>
<MaterialIcon
className="ide-settings-tab-link-icon"
type={entry.icon}
unfilled
/>
<span>{entry.title}</span>
<div className="flex-grow-1" />
<MaterialIcon
type="open_in_new"
className="ide-settings-tab-link-external"
/>
</a>
)
} else {
return (
<>
<NavLink
eventKey={entry.key}
className="ide-settings-tab-link"
key={entry.key}
>
<MaterialIcon
className="ide-settings-tab-link-icon"
type={entry.icon}
unfilled
/>
<span>{entry.title}</span>
</NavLink>
</>
)
}
}

View File

@@ -0,0 +1,31 @@
import OLModal, {
OLModalBody,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useTranslation } from 'react-i18next'
import { SettingsModalBody } from './settings-modal-body'
const SettingsModal = () => {
// TODO ide-redesign-cleanup: Either rename the field, or introduce a separate
// one
const { leftMenuShown, setLeftMenuShown } = useLayoutContext()
const { t } = useTranslation()
return (
<OLModal
show={leftMenuShown}
onHide={() => setLeftMenuShown(false)}
size="lg"
>
<OLModalHeader closeButton>
<OLModalTitle>{t('settings')}</OLModalTitle>
</OLModalHeader>
<OLModalBody className="ide-settings-modal-body">
<SettingsModalBody />
</OLModalBody>
</OLModal>
)
}
export default SettingsModal

View File

@@ -0,0 +1,18 @@
export default function SettingsSection({
children,
title,
}: {
children: React.ReactNode | React.ReactNode[]
title?: string
}) {
if (!children) {
return null
}
return (
<div className="ide-settings-section">
{title && <div className="ide-settings-section-title">{title}</div>}
{children}
</div>
)
}

View File

@@ -0,0 +1,34 @@
import Setting from './setting'
import OLFormSwitch from '@/features/ui/components/ol/ol-form-switch'
export default function ToggleSetting({
id,
label,
description,
checked,
onChange,
disabled,
}: {
id: string
label: string
description: string
checked: boolean | undefined
onChange: (newValue: boolean) => void
disabled?: boolean
}) {
const handleChange = () => {
onChange(!checked)
}
return (
<Setting controlId={id} label={label} description={description}>
<OLFormSwitch
id={id}
onChange={handleChange}
checked={checked}
label={label}
disabled={disabled}
/>
</Setting>
)
}

View File

@@ -0,0 +1,181 @@
import { useIdeRedesignSwitcherContext } from '@/features/ide-react/context/ide-redesign-switcher-context'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import { FC, useCallback } from 'react'
import {
canUseNewEditor,
useIsNewEditorEnabled,
} from '../../utils/new-editor-utils'
import Notification from '@/shared/components/notification'
import { useSwitchEnableNewEditorState } from '../../hooks/use-switch-enable-new-editor-state'
import { Trans, useTranslation } from 'react-i18next'
export const IdeRedesignSwitcherModal = () => {
const { t } = useTranslation()
const { showSwitcherModal, setShowSwitcherModal } =
useIdeRedesignSwitcherContext()
const onHide = useCallback(
() => setShowSwitcherModal(false),
[setShowSwitcherModal]
)
const { loading, error, setEditorRedesignStatus } =
useSwitchEnableNewEditorState()
const enabled = useIsNewEditorEnabled()
const hasAccess = canUseNewEditor()
if (!hasAccess) {
return null
}
const Content = enabled
? SwitcherModalContentEnabled
: SwitcherModalContentDisabled
return (
<OLModal
show={showSwitcherModal}
onHide={onHide}
className="ide-redesign-switcher-modal"
>
<OLModalHeader closeButton>
<OLModalTitle>{t('the_new_overleaf_editor')}</OLModalTitle>
</OLModalHeader>
{error && <Notification type="error" content={error} isDismissible />}
<Content
setEditorRedesignStatus={setEditorRedesignStatus}
hide={onHide}
loading={loading}
/>
</OLModal>
)
}
type ModalContentProps = {
setEditorRedesignStatus: (enabled: boolean) => Promise<void>
hide: () => void
loading: boolean
}
const SwitcherModalContentEnabled: FC<ModalContentProps> = ({
setEditorRedesignStatus,
hide,
loading,
}) => {
const { t } = useTranslation()
const disable = useCallback(() => {
setEditorRedesignStatus(false)
.then(hide)
.catch(() => {
// do nothing, we're already showing the error
})
}, [setEditorRedesignStatus, hide])
return (
<>
<OLModalBody>
<h3>{t('youre_helping_us_shape_the_future_of_overleaf')}</h3>
<p>
{t(
'thanks_for_being_part_of_this_labs_experiment_your_feedback_will_help_us_make_the_new_editor_the_best_yet'
)}
</p>
<SwitcherWhatsNew />
<LeavingNote />
</OLModalBody>
<OLModalFooter>
<OLButton
onClick={disable}
variant="secondary"
className="me-auto"
disabled={loading}
>
{t('switch_to_old_editor')}
</OLButton>
<OLButton onClick={hide} variant="secondary">
{t('cancel')}
</OLButton>
<OLButton
href="https://forms.gle/soyVStc5qDx9na1Z6"
target="_blank"
rel="noopener noreferrer"
variant="primary"
>
{t('give_feedback')}
</OLButton>
</OLModalFooter>
</>
)
}
const SwitcherModalContentDisabled: FC<ModalContentProps> = ({
setEditorRedesignStatus,
hide,
loading,
}) => {
const { t } = useTranslation()
const enable = useCallback(() => {
setEditorRedesignStatus(true)
.then(hide)
.catch(() => {
// do nothing, we're already showing the error
})
}, [setEditorRedesignStatus, hide])
return (
<>
<OLModalBody>
<h3>{t('help_shape_the_future_of_overleaf')}</h3>
<p>{t('were_redesigning_our_editor_to_make_it_easier_to_use')}</p>
<SwitcherWhatsNew />
<LeavingNote />
</OLModalBody>
<OLModalFooter>
<OLButton onClick={hide} variant="secondary">
{t('cancel')}
</OLButton>
<OLButton onClick={enable} variant="primary" disabled={loading}>
{t('switch_to_new_editor')}
</OLButton>
</OLModalFooter>
</>
)
}
const SwitcherWhatsNew = () => {
const { t } = useTranslation()
return (
<div className="ide-redesign-switcher-modal-whats-new">
<h4>{t('whats_new')}</h4>
<ul>
<li>{t('new_look_and_feel')}</li>
<li>
{t('new_navigation_introducing_left_hand_side_rail_and_top_menus')}
</li>
<li>{t('new_look_and_placement_of_the_settings')}</li>
<li>{t('improved_dark_mode')}</li>
<li>{t('review_panel_and_error_logs_moved_to_the_left')}</li>
</ul>
<hr />
<h4>{t('whats_next')}</h4>
<ul>
<li>{t('more_changes_based_on_your_feedback')}</li>
</ul>
</div>
)
}
const LeavingNote = () => {
return (
<p className="ide-redesign-switcher-modal-leave-text">
<Trans
i18nKey="you_can_leave_the_experiment_from_your_account_settings_at_any_time"
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
components={[<a href="/user/settings" />]}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
)
}

View File

@@ -0,0 +1,45 @@
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import {
Dropdown,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import React, { forwardRef } from 'react'
import ChangeLayoutOptions from './change-layout-options'
const LayoutDropdownToggleButton = forwardRef<
HTMLButtonElement,
{
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void
}
>(({ onClick }, ref) => {
return (
<OLButton
size="sm"
className="ide-redesign-toolbar-button-subdued"
ref={ref}
onClick={onClick}
leadingIcon={<MaterialIcon unfilled type="space_dashboard" />}
/>
)
})
LayoutDropdownToggleButton.displayName = 'LayoutDropdownToggleButton'
export default function ChangeLayoutButton() {
return (
<div className="ide-redesign-toolbar-button-container">
<Dropdown className="toolbar-item layout-dropdown" align="end">
<DropdownToggle
id="layout-dropdown-btn"
className="btn-full-height"
as={LayoutDropdownToggleButton}
/>
<DropdownMenu>
<ChangeLayoutOptions />
</DropdownMenu>
</Dropdown>
</div>
)
}

View File

@@ -0,0 +1,175 @@
import {
DropdownItem,
DropdownHeader,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import {
IdeLayout,
IdeView,
useLayoutContext,
} from '@/shared/context/layout-context'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import useEventListener from '@/shared/hooks/use-event-listener'
import { DetachRole } from '@/shared/context/detach-context'
import { Spinner } from 'react-bootstrap-5'
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,
onClick,
children,
}: {
active: boolean
leadingIcon: string
onClick: () => void
children: React.ReactNode
processing?: boolean
disabled?: boolean
}) => {
let trailingIcon: string | React.ReactNode | null = null
if (processing) {
trailingIcon = (
<Spinner animation="border" aria-hidden="true" size="sm" role="status" />
)
} else if (active) {
trailingIcon = 'check'
}
return (
<DropdownItem
active={active}
aria-current={active}
trailingIcon={trailingIcon}
disabled={disabled}
onClick={onClick}
leadingIcon={leadingIcon}
>
{children}
</DropdownItem>
)
}
export default function ChangeLayoutOptions() {
const {
reattach,
detach,
detachIsLinked,
detachRole,
changeLayout,
view,
pdfLayout,
} = useLayoutContext()
const handleDetach = useCallback(() => {
detach()
eventTracking.sendMB('project-layout-detach')
}, [detach])
const handleReattach = useCallback(() => {
if (detachRole !== 'detacher') {
return
}
reattach()
eventTracking.sendMB('project-layout-reattach')
}, [detachRole, reattach])
// reattach when the PDF pane opens
useEventListener('ui:pdf-open', handleReattach)
const handleChangeLayout = useCallback(
(newLayout: IdeLayout, newView?: IdeView) => {
handleReattach()
changeLayout(newLayout, newView)
eventTracking.sendMB('project-layout-change', {
layout: newLayout,
view: newView,
})
},
[changeLayout, handleReattach]
)
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"
>
{t('split_view')}
</LayoutDropdownItem>
<LayoutDropdownItem
onClick={() => handleChangeLayout('flat', 'editor')}
active={activeLayoutOption === 'editorOnly'}
leadingIcon="edit"
>
{t('editor_only')}
</LayoutDropdownItem>
<LayoutDropdownItem
onClick={() => handleChangeLayout('flat', 'pdf')}
active={activeLayoutOption === 'pdfOnly'}
leadingIcon="picture_as_pdf"
>
{t('pdf_only')}
</LayoutDropdownItem>
<LayoutDropdownItem
onClick={() => handleDetach()}
active={activeLayoutOption === 'detachedPdf' && detachIsLinked}
disabled={!detachable}
leadingIcon="open_in_new"
processing={waitingForDetachedLink}
>
{t('open_pdf_in_separate_tab')}
</LayoutDropdownItem>
</>
)
}

View File

@@ -0,0 +1,158 @@
import {
Command,
useCommandRegistry,
} from '@/features/ide-react/context/command-registry-context'
import {
DropdownDivider,
DropdownHeader,
} from '@/features/ui/components/bootstrap-5/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' }
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 } = useCommandRegistry()
const populatedSections = useMemo(
() =>
menu
.map(section => populateSectionOrGroup(section, registry))
.filter(x => x.children.length > 0),
[menu, registry]
)
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}>
{index > 0 && <DropdownDivider />}
{section.title && <DropdownHeader>{section.title}</DropdownHeader>}
{section.children.map(child => (
<CommandDropdownChild item={child} key={child.id} />
))}
</Fragment>
)
})}
</MenuBarDropdown>
)
}
export const CommandSection = ({
section: sectionStructure,
}: {
section: MenuSectionStructure<CommandId>
}) => {
const { registry } = useCommandRegistry()
const section = populateSectionOrGroup(sectionStructure, registry)
if (section.children.length === 0) {
return null
}
return (
<>
{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
key={item.id}
title={item.label}
// eslint-disable-next-line react/jsx-handler-names
onClick={onClickHandler}
href={item.href}
disabled={item.disabled}
/>
)
} 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>
): 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)
if (populatedChild.children.length === 0) {
// Skip empty groups
return undefined
}
return populatedChild
}
const command = registry.get(child)
if (command) {
return { ...command, 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'
}

View File

@@ -0,0 +1,100 @@
import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import OLTooltip from '@/features/ui/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'
export const DownloadProjectZip = () => {
const { t } = useTranslation()
const { _id: 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 { _id: projectId } = useProjectContext()
const sendDownloadEvent = useCallback(() => {
sendMB('download-pdf-button-click', {
projectId,
location: 'project-name-dropdown',
isSmallDevice,
})
}, [projectId])
useCommandProvider(
() => [
{
id: 'download-pdf',
disabled: !pdfUrl,
href: pdfDownloadUrl || pdfUrl,
handler: ({ location }) => {
sendMB('download-pdf-button-click', {
projectId,
location,
isSmallDevice,
})
},
label: t('download_as_pdf'),
},
],
[t, pdfUrl, projectId, pdfDownloadUrl]
)
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
}
}

View File

@@ -0,0 +1,70 @@
import OLFormControl from '@/features/ui/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

View File

@@ -0,0 +1,47 @@
import { useIdeRedesignSwitcherContext } from '@/features/ide-react/context/ide-redesign-switcher-context'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
export const LabsActions = () => {
const { t } = useTranslation()
const { setShowSwitcherModal } = useIdeRedesignSwitcherContext()
const openEditorRedesignSwitcherModal = useCallback(() => {
setShowSwitcherModal(true)
}, [setShowSwitcherModal])
return (
<>
<div className="ide-redesign-toolbar-button-container">
<OLTooltip
id="tooltip-labs-button"
description={t(
'this_is_a_labs_experiment_for_the_new_overleaf_editor_some_features_are_still_in_progress'
)}
overlayProps={{ delay: 0, placement: 'bottom' }}
>
<OLButton
size="sm"
variant="info"
className="ide-redesign-labs-button"
onClick={openEditorRedesignSwitcherModal}
leadingIcon={<MaterialIcon type="experiment" unfilled />}
>
{t('labs')}
</OLButton>
</OLTooltip>
</div>
<div className="ide-redesign-toolbar-button-container">
<a
href="https://forms.gle/soyVStc5qDx9na1Z6"
rel="noopener noreferrer"
target="_blank"
className="ide-redesign-toolbar-labs-feedback-link"
>
{t('give_feedback')}
</a>
</div>
</>
)
}

View File

@@ -0,0 +1,268 @@
import {
DropdownDivider,
DropdownHeader,
} from '@/features/ui/components/bootstrap-5/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 { MouseEventHandler, useCallback, useMemo } from 'react'
import { useIdeRedesignSwitcherContext } from '@/features/ide-react/context/ide-redesign-switcher-context'
import { useSwitchEnableNewEditorState } from '../../hooks/use-switch-enable-new-editor-state'
import MaterialIcon from '@/shared/components/material-icon'
import OLSpinner from '@/features/ui/components/ol/ol-spinner'
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 { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { useRailContext } from '../../contexts/rail-context'
export const ToolbarMenuBar = () => {
const { t } = useTranslation()
const { setShowSwitcherModal } = useIdeRedesignSwitcherContext()
const openEditorRedesignSwitcherModal = useCallback(() => {
setShowSwitcherModal(true)
}, [setShowSwitcherModal])
const { setView, view } = useLayoutContext()
useCommandProvider(
() => [
{
type: 'command',
label: t('show_version_history'),
handler: () => {
setView(view === 'history' ? 'editor' : 'history')
},
id: 'show_version_history',
},
],
[t, setView, view]
)
const fileMenuStructure: MenuStructure = useMemo(
() => [
{
id: 'file-file-tree',
children: ['new_file', 'new_folder', 'upload_file'],
},
{ id: 'file-history', children: ['show_version_history'] },
{
id: 'file-download',
children: ['download-as-source-zip', 'download-pdf'],
},
],
[]
)
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 {
userSettings: { mathPreview },
setUserSettings,
} = useUserSettingsContext()
const toggleMathPreview = useCallback(() => {
setUserSettings(prev => {
return {
...prev,
mathPreview: !prev.mathPreview,
}
})
}, [setUserSettings])
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 />
<DropdownHeader>Editor settings</DropdownHeader>
<MenuBarOption
title={t('show_equation_preview')}
trailingIcon={mathPreview ? 'check' : undefined}
onClick={toggleMathPreview}
/>
<CommandSection section={pdfControlsMenuSectionStructure} />
</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
title={t('keyboard_shortcuts')}
onClick={openKeyboardShortcutsModal}
/>
<MenuBarOption
title={t('documentation')}
href="/learn"
target="_blank"
rel="noopener noreferrer"
/>
<DropdownDivider />
<MenuBarOption title={t('contact_us')} onClick={openContactUsModal} />
<MenuBarOption
title={t('give_feedback')}
href="https://forms.gle/soyVStc5qDx9na1Z6"
target="_blank"
rel="noopener noreferrer"
/>
<DropdownDivider />
<SwitchToOldEditorMenuBarOption />
<MenuBarOption
title="What's new?"
onClick={openEditorRedesignSwitcherModal}
/>
</MenuBarDropdown>
</MenuBar>
)
}
const SwitchToOldEditorMenuBarOption = () => {
const { loading, error, setEditorRedesignStatus } =
useSwitchEnableNewEditorState()
const disable: MouseEventHandler = useCallback(
event => {
// Don't close the dropdown
event.stopPropagation()
setEditorRedesignStatus(false)
},
[setEditorRedesignStatus]
)
let icon = null
if (loading) {
icon = <OLSpinner size="sm" />
} else if (error) {
icon = <MaterialIcon type="error" title={error} className="text-danger" />
}
return (
<MenuBarOption
title="Switch to old editor"
onClick={disable}
disabled={loading}
trailingIcon={icon}
/>
)
}

View File

@@ -0,0 +1,27 @@
import OnlineUsersWidget from '@/features/editor-navigation-toolbar/components/online-users-widget'
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'
export const OnlineUsers = () => {
const { openDoc } = useEditorManagerContext()
const { onlineUsersArray } = useOnlineUsersContext()
const goToUser = useCallback(
(user: OnlineUser) => {
if (user.doc && typeof user.row === 'number') {
openDoc(user.doc, { gotoLine: user.row + 1 })
}
},
[openDoc]
)
return (
<div className="ide-redesign-online-users">
<OnlineUsersWidget onlineUsers={onlineUsersArray} goToUser={goToUser} />
</div>
)
}

View File

@@ -0,0 +1,87 @@
import {
Dropdown,
DropdownDivider,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/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 { DownloadProjectPDF, DownloadProjectZip } from './download-project'
import { useCallback, useState } from 'react'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import EditableLabel from './editable-label'
const [publishModalModules] = importOverleafModules('publishModal')
const SubmitProjectButton = publishModalModules?.import.NewPublishToolbarButton
export const ToolbarProjectTitle = () => {
const { t } = useTranslation()
const { permissionsLevel, renameProject } = useEditorContext()
const { name } = useProjectContext()
const shouldDisplaySubmitButton =
(permissionsLevel === 'owner' || permissionsLevel === 'readAndWrite') &&
SubmitProjectButton
const hasRenamePermissions = permissionsLevel === 'owner'
const [isRenaming, setIsRenaming] = useState(false)
const onRename = useCallback(
name => {
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">
<DropdownToggle
id="project-title-options"
className="ide-redesign-toolbar-dropdown-toggle-subdued fw-bold ide-redesign-toolbar-button-subdued"
>
{name}
<MaterialIcon
type="keyboard_arrow_down"
accessibilityLabel={t('project_title_options')}
/>
</DropdownToggle>
<DropdownMenu renderOnMount>
{shouldDisplaySubmitButton && (
<>
<SubmitProjectButton />
<DropdownDivider />
</>
)}
<DownloadProjectPDF />
<DownloadProjectZip />
<DropdownDivider />
<OLDropdownMenuItem
onClick={() => {
setIsRenaming(true)
}}
disabled={!hasRenamePermissions}
>
{t('rename')}
</OLDropdownMenuItem>
</DropdownMenu>
</Dropdown>
)
}

View File

@@ -0,0 +1,41 @@
import ShareProjectModal from '@/features/share-project-modal/components/share-project-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import * as eventTracking from '@/infrastructure/event-tracking'
export default function ShareProjectButton() {
const { t } = useTranslation()
const [showShareModal, setShowShareModal] = useState(false)
const handleOpenShareModal = useCallback(() => {
eventTracking.sendMBOnce('ide-open-share-modal-once')
setShowShareModal(true)
}, [])
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}
/>
</>
)
}

View File

@@ -0,0 +1,37 @@
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useCallback } from 'react'
export default function ShowHistoryButton() {
const { t } = useTranslation()
const { view, setView } = useLayoutContext()
const toggleHistoryOpen = useCallback(() => {
const action = view === 'history' ? 'close' : 'open'
eventTracking.sendMB('navigation-clicked-history', { action })
setView(view === 'history' ? 'editor' : 'history')
}, [view, setView])
return (
<div className="ide-redesign-toolbar-button-container">
<OLTooltip
id="tooltip-open-history"
description={t('history')}
overlayProps={{ delay: 0, placement: 'bottom' }}
>
<OLButton
size="sm"
variant="ghost"
className="ide-redesign-toolbar-button-subdued"
leadingIcon={<MaterialIcon type="history" />}
onClick={toggleHistoryOpen}
/>
</OLTooltip>
</div>
)
}

View File

@@ -0,0 +1,69 @@
import MaterialIcon from '@/shared/components/material-icon'
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 { LabsActions } from './labs-actions'
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'
export const Toolbar = () => {
const { view, setView } = useLayoutContext()
const handleBackToEditorClick = useCallback(() => {
eventTracking.sendMB('navigation-clicked-history', { action: 'close' })
setView('editor')
}, [setView])
if (view === 'history') {
return (
<div className="ide-redesign-toolbar">
<div className="d-flex align-items-center">
<BackToEditorButton onClick={handleBackToEditorClick} />
</div>
<ToolbarProjectTitle />
<div /> {/* Empty div used for spacing */}
</div>
)
}
return (
<div className="ide-redesign-toolbar">
<ToolbarMenus />
<ToolbarProjectTitle />
<ToolbarButtons />
</div>
)
}
const ToolbarMenus = () => {
const { t } = useTranslation()
return (
<div className="ide-redesign-toolbar-menu">
<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>
<ToolbarMenuBar />
</div>
)
}
const ToolbarButtons = () => {
return (
<div className="ide-redesign-toolbar-actions">
<LabsActions />
<OnlineUsers />
<ShowHistoryButton />
<ChangeLayoutButton />
<ShareProjectButton />
</div>
)
}

View File

@@ -0,0 +1,120 @@
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
import {
createContext,
Dispatch,
FC,
SetStateAction,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react'
import { ImperativePanelHandle } from 'react-resizable-panels'
export type RailTabKey =
| 'file-tree'
| 'integrations'
| 'review-panel'
| 'chat'
| 'errors'
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
}
| undefined
>(undefined)
export const RailProvider: FC = ({ children }) => {
const [isOpen, setIsOpen] = useState(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)
useCollapsiblePanel(isOpen, panelRef)
const togglePane = useCallback(() => {
setIsOpen(value => !value)
}, [])
const handlePaneExpand = useCallback(() => {
setIsOpen(true)
}, [])
const handlePaneCollapse = useCallback(() => {
setIsOpen(false)
}, [])
// NOTE: The file tree **MUST** be the first tab to be opened
// since it is responsible for opening the initial document.
const [selectedTab, setSelectedTab] = useState<RailTabKey>('file-tree')
const openTab = useCallback(
(tab: RailTabKey) => {
setSelectedTab(tab)
setIsOpen(true)
},
[setIsOpen, setSelectedTab]
)
const value = useMemo(
() => ({
selectedTab,
isOpen,
setIsOpen,
panelRef,
togglePane,
handlePaneExpand,
handlePaneCollapse,
resizing,
setResizing,
activeModal,
setActiveModal,
openTab,
}),
[
selectedTab,
isOpen,
setIsOpen,
panelRef,
togglePane,
handlePaneExpand,
handlePaneCollapse,
resizing,
setResizing,
activeModal,
setActiveModal,
openTab,
]
)
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
}

View File

@@ -0,0 +1,12 @@
import { ImperativePanelHandle } from 'react-resizable-panels'
import { useRef } from 'react'
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
export default function useCollapsibleFileTree() {
const { fileTreeExpanded, toggleFileTreeExpanded } = useFileTreeOpenContext()
const fileTreePanelRef = useRef<ImperativePanelHandle>(null)
useCollapsiblePanel(fileTreeExpanded, fileTreePanelRef)
return { fileTreeExpanded, fileTreePanelRef, toggleFileTreeExpanded }
}

View File

@@ -0,0 +1,34 @@
import { postJSON } from '@/infrastructure/fetch-json'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { useCallback, useState } from 'react'
export const useSwitchEnableNewEditorState = () => {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const { setUserSettings } = useUserSettingsContext()
const setEditorRedesignStatus = useCallback(
(status: boolean): Promise<void> => {
setLoading(true)
setError('')
return new Promise((resolve, reject) => {
postJSON('/user/settings', { body: { enableNewEditor: status } })
.then(() => {
setUserSettings(current => ({
...current,
enableNewEditor: status,
}))
resolve()
})
.catch(e => {
setError('Failed to update settings')
reject(e)
})
.finally(() => {
setLoading(false)
})
})
},
[setUserSettings]
)
return { loading, error, setEditorRedesignStatus }
}

View File

@@ -0,0 +1,345 @@
import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '@/features/source-editor/components/codemirror-context'
import { FigureModalSource } from '@/features/source-editor/components/figure-modal/figure-modal-context'
import * as commands from '@/features/source-editor/extensions/toolbar/commands'
import { setSectionHeadingLevel } from '@/features/source-editor/extensions/toolbar/sections'
import { useEditorContext } from '@/shared/context/editor-context'
import { useLayoutContext } from '@/shared/context/layout-context'
import getMeta from '@/utils/meta'
import { redo, selectAll, undo } from '@codemirror/commands'
import { openSearchPanel } from '@codemirror/search'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useIsNewEditorEnabled } from '../utils/new-editor-utils'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { language } from '@codemirror/language'
export const useToolbarMenuBarEditorCommands = () => {
const view = useCodeMirrorViewContext()
const state = useCodeMirrorStateContext()
const { t } = useTranslation()
const { view: layoutView } = useLayoutContext()
const editorIsVisible = layoutView === 'editor'
const { trackedWrite } = usePermissionsContext()
const languageName = state.facet(language)?.name
const isTeXFile = languageName === 'latex'
const openFigureModal = useCallback((source: FigureModalSource) => {
window.dispatchEvent(
new CustomEvent('figure-modal:open', {
detail: { source },
})
)
}, [])
const newEditor = useIsNewEditorEnabled()
useCommandProvider(() => {
if (!newEditor) {
return
}
return [
/************************************
* Edit menu
************************************/
{
id: 'undo',
label: t('undo'),
handler: () => {
undo(view)
view.focus()
},
disabled: !editorIsVisible || !trackedWrite,
},
{
id: 'redo',
label: t('redo'),
handler: () => {
redo(view)
view.focus()
},
disabled: !editorIsVisible || !trackedWrite,
},
{
id: 'find',
label: t('find'),
handler: () => {
openSearchPanel(view)
},
disabled: !editorIsVisible,
},
{
id: 'select-all',
label: t('select_all'),
handler: () => {
selectAll(view)
view.focus()
},
disabled: !editorIsVisible,
},
]
}, [editorIsVisible, t, view, trackedWrite, newEditor])
// LaTeX commands
useCommandProvider(() => {
if (!newEditor) {
return
}
if (!isTeXFile || !trackedWrite) {
return
}
return [
/************************************
* Insert menu
************************************/
{
id: 'insert-inline-math',
label: t('inline_math'),
handler: () => {
commands.wrapInInlineMath(view)
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'insert-display-math',
label: t('display_math'),
handler: () => {
commands.wrapInDisplayMath(view)
view.focus()
},
disabled: !editorIsVisible,
},
{
label: t('upload_from_computer'),
id: 'insert-figure-from-computer',
handler: () => {
openFigureModal(FigureModalSource.FILE_UPLOAD)
},
disabled: !editorIsVisible,
},
{
label: t('from_project_files'),
id: 'insert-figure-from-project-files',
handler: () => {
openFigureModal(FigureModalSource.FILE_TREE)
},
disabled: !editorIsVisible,
},
{
label: t('from_another_project'),
id: 'insert-figure-from-another-project',
handler: () => {
openFigureModal(FigureModalSource.OTHER_PROJECT)
},
disabled: !editorIsVisible,
},
{
label: t('from_url'),
id: 'insert-figure-from-url',
handler: () => {
openFigureModal(FigureModalSource.FROM_URL)
},
disabled: !editorIsVisible,
},
{
id: 'insert-table',
label: t('table'),
handler: () => {
commands.insertTable(view, 3, 3)
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'insert-citation',
label: t('citation'),
handler: () => {
commands.insertCite(view)
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'insert-link',
label: t('link'),
handler: () => {
commands.wrapInHref(view)
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'insert-cross-reference',
label: t('cross_reference'),
handler: () => {
commands.insertRef(view)
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'comment',
label: t('comment'),
handler: () => {
commands.addComment()
},
disabled: !editorIsVisible,
},
/************************************
* Format menu
************************************/
{
id: 'format-bold',
label: t('bold'),
handler: () => {
commands.toggleBold(view)
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'format-italics',
label: t('italics'),
handler: () => {
commands.toggleItalic(view)
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'format-bullet-list',
label: t('bullet_list'),
handler: () => {
commands.toggleBulletList(view)
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'format-numbered-list',
label: t('numbered_list'),
handler: () => {
commands.toggleNumberedList(view)
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'format-increase-indentation',
label: t('increase_indent'),
handler: () => {
commands.indentIncrease(view)
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'format-decrease-indentation',
label: t('decrease_indent'),
handler: () => {
commands.indentDecrease(view)
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'format-style-normal',
label: t('normal'),
handler: () => {
setSectionHeadingLevel(view, 'text')
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'format-style-section',
label: 'Section',
handler: () => {
setSectionHeadingLevel(view, 'section')
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'format-style-subsection',
label: 'Subsection',
handler: () => {
setSectionHeadingLevel(view, 'subsection')
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'format-style-subsubsection',
label: 'Subsubsection',
handler: () => {
setSectionHeadingLevel(view, 'subsubsection')
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'format-style-paragraph',
label: 'Paragraph',
handler: () => {
setSectionHeadingLevel(view, 'paragraph')
view.focus()
},
disabled: !editorIsVisible,
},
{
id: 'format-style-subparagraph',
label: 'Subparagraph',
handler: () => {
setSectionHeadingLevel(view, 'subparagraph')
view.focus()
},
disabled: !editorIsVisible,
},
]
}, [
view,
t,
editorIsVisible,
openFigureModal,
newEditor,
trackedWrite,
isTeXFile,
])
const { toggleSymbolPalette } = useEditorContext()
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
useCommandProvider(() => {
if (!symbolPaletteAvailable) {
return
}
if (!isTeXFile || !trackedWrite) {
return
}
return [
{
id: 'insert-symbol',
label: t('symbol'),
handler: () => {
toggleSymbolPalette?.()
},
disabled: !editorIsVisible,
},
]
}, [
symbolPaletteAvailable,
t,
toggleSymbolPalette,
editorIsVisible,
isTeXFile,
trackedWrite,
])
}

View File

@@ -0,0 +1,4 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 8C0 3.58172 3.58172 0 8 0H28C32.4183 0 36 3.58172 36 8V28C36 32.4183 32.4183 36 28 36H8C3.58172 36 0 32.4183 0 28V8Z" fill="#EAF6EF"/>
<path d="M14.8 16.95L16.95 14.775L15.55 13.35L15.15 13.75C14.9667 13.9333 14.7333 14.0333 14.45 14.05C14.1833 14.05 13.95 13.95 13.75 13.75C13.55 13.55 13.45 13.3167 13.45 13.05C13.45 12.7667 13.55 12.525 13.75 12.325L14.125 11.95L13 10.825L10.825 13L14.8 16.95ZM23 25.175L25.175 23L24.05 21.875L23.65 22.25C23.45 22.45 23.2167 22.55 22.95 22.55C22.6833 22.55 22.45 22.45 22.25 22.25C22.05 22.05 21.95 21.8167 21.95 21.55C21.95 21.2833 22.05 21.05 22.25 20.85L22.625 20.45L21.2 19.05L19.05 21.2L23 25.175ZM22.225 12.425L23.625 13.825L25.025 12.425L23.6 11L22.225 12.425ZM10 27C9.71667 27 9.475 26.9083 9.275 26.725C9.09167 26.525 9 26.2833 9 26V23.175C9 23.0417 9.025 22.9167 9.075 22.8C9.125 22.6667 9.2 22.55 9.3 22.45L13.375 18.375L9.05 14.05C8.76667 13.7667 8.625 13.4167 8.625 13C8.625 12.5833 8.76667 12.2333 9.05 11.95L11.95 9.05C12.2333 8.76667 12.5833 8.63333 13 8.65C13.4167 8.65 13.7667 8.79167 14.05 9.075L18.4 13.4L22.175 9.6C22.375 9.4 22.6 9.25 22.85 9.15C23.1 9.05 23.3583 9 23.625 9C23.8917 9 24.15 9.05 24.4 9.15C24.65 9.25 24.875 9.4 25.075 9.6L26.4 10.95C26.6 11.15 26.75 11.375 26.85 11.625C26.95 11.875 27 12.1333 27 12.4C27 12.6667 26.95 12.925 26.85 13.175C26.75 13.4083 26.6 13.625 26.4 13.825L22.625 17.625L26.95 21.95C27.2333 22.2333 27.375 22.5833 27.375 23C27.375 23.4167 27.2333 23.7667 26.95 24.05L24.05 26.95C23.7667 27.2333 23.4167 27.375 23 27.375C22.5833 27.375 22.2333 27.2333 21.95 26.95L17.625 22.625L13.55 26.7C13.45 26.8 13.3333 26.875 13.2 26.925C13.0833 26.975 12.9583 27 12.825 27H10Z" fill="#195936"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,11 @@
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { isInExperiment } from '@/utils/labs-utils'
export const canUseNewEditor = () => isInExperiment('editor-redesign')
export const useIsNewEditorEnabled = () => {
const { userSettings } = useUserSettingsContext()
const hasAccess = canUseNewEditor()
const enabled = userSettings.enableNewEditor
return hasAccess && enabled
}