first commit
This commit is contained in:
@@ -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" />
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 />
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user