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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
|
||||
import {
|
||||
createContext,
|
||||
Dispatch,
|
||||
FC,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { ImperativePanelHandle } from 'react-resizable-panels'
|
||||
|
||||
export type RailTabKey =
|
||||
| 'file-tree'
|
||||
| 'integrations'
|
||||
| 'review-panel'
|
||||
| 'chat'
|
||||
| 'errors'
|
||||
|
||||
export type RailModalKey = 'keyboard-shortcuts' | 'contact-us' | 'dictionary'
|
||||
|
||||
const RailContext = createContext<
|
||||
| {
|
||||
selectedTab: RailTabKey
|
||||
isOpen: boolean
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>
|
||||
panelRef: React.RefObject<ImperativePanelHandle>
|
||||
togglePane: () => void
|
||||
handlePaneExpand: () => void
|
||||
handlePaneCollapse: () => void
|
||||
resizing: boolean
|
||||
setResizing: Dispatch<SetStateAction<boolean>>
|
||||
activeModal: RailModalKey | null
|
||||
setActiveModal: Dispatch<SetStateAction<RailModalKey | null>>
|
||||
openTab: (tab: RailTabKey) => void
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export const RailProvider: FC = ({ children }) => {
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const [resizing, setResizing] = useState(false)
|
||||
const [activeModal, setActiveModalInternal] = useState<RailModalKey | null>(
|
||||
null
|
||||
)
|
||||
const setActiveModal: Dispatch<SetStateAction<RailModalKey | null>> =
|
||||
useCallback(modalKey => {
|
||||
setActiveModalInternal(modalKey)
|
||||
}, [])
|
||||
|
||||
const panelRef = useRef<ImperativePanelHandle>(null)
|
||||
useCollapsiblePanel(isOpen, panelRef)
|
||||
|
||||
const togglePane = useCallback(() => {
|
||||
setIsOpen(value => !value)
|
||||
}, [])
|
||||
|
||||
const handlePaneExpand = useCallback(() => {
|
||||
setIsOpen(true)
|
||||
}, [])
|
||||
|
||||
const handlePaneCollapse = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
}, [])
|
||||
|
||||
// NOTE: The file tree **MUST** be the first tab to be opened
|
||||
// since it is responsible for opening the initial document.
|
||||
const [selectedTab, setSelectedTab] = useState<RailTabKey>('file-tree')
|
||||
|
||||
const openTab = useCallback(
|
||||
(tab: RailTabKey) => {
|
||||
setSelectedTab(tab)
|
||||
setIsOpen(true)
|
||||
},
|
||||
[setIsOpen, setSelectedTab]
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
selectedTab,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
panelRef,
|
||||
togglePane,
|
||||
handlePaneExpand,
|
||||
handlePaneCollapse,
|
||||
resizing,
|
||||
setResizing,
|
||||
activeModal,
|
||||
setActiveModal,
|
||||
openTab,
|
||||
}),
|
||||
[
|
||||
selectedTab,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
panelRef,
|
||||
togglePane,
|
||||
handlePaneExpand,
|
||||
handlePaneCollapse,
|
||||
resizing,
|
||||
setResizing,
|
||||
activeModal,
|
||||
setActiveModal,
|
||||
openTab,
|
||||
]
|
||||
)
|
||||
|
||||
return <RailContext.Provider value={value}>{children}</RailContext.Provider>
|
||||
}
|
||||
|
||||
export const useRailContext = () => {
|
||||
const context = useContext(RailContext)
|
||||
if (!context) {
|
||||
throw new Error('useRailContext is only available inside RailProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ImperativePanelHandle } from 'react-resizable-panels'
|
||||
import { useRef } from 'react'
|
||||
import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
|
||||
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
|
||||
|
||||
export default function useCollapsibleFileTree() {
|
||||
const { fileTreeExpanded, toggleFileTreeExpanded } = useFileTreeOpenContext()
|
||||
const fileTreePanelRef = useRef<ImperativePanelHandle>(null)
|
||||
useCollapsiblePanel(fileTreeExpanded, fileTreePanelRef)
|
||||
|
||||
return { fileTreeExpanded, fileTreePanelRef, toggleFileTreeExpanded }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export const useSwitchEnableNewEditorState = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const { setUserSettings } = useUserSettingsContext()
|
||||
const setEditorRedesignStatus = useCallback(
|
||||
(status: boolean): Promise<void> => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
return new Promise((resolve, reject) => {
|
||||
postJSON('/user/settings', { body: { enableNewEditor: status } })
|
||||
.then(() => {
|
||||
setUserSettings(current => ({
|
||||
...current,
|
||||
enableNewEditor: status,
|
||||
}))
|
||||
resolve()
|
||||
})
|
||||
.catch(e => {
|
||||
setError('Failed to update settings')
|
||||
reject(e)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
})
|
||||
},
|
||||
[setUserSettings]
|
||||
)
|
||||
return { loading, error, setEditorRedesignStatus }
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
|
||||
import {
|
||||
useCodeMirrorStateContext,
|
||||
useCodeMirrorViewContext,
|
||||
} from '@/features/source-editor/components/codemirror-context'
|
||||
import { FigureModalSource } from '@/features/source-editor/components/figure-modal/figure-modal-context'
|
||||
import * as commands from '@/features/source-editor/extensions/toolbar/commands'
|
||||
import { setSectionHeadingLevel } from '@/features/source-editor/extensions/toolbar/sections'
|
||||
import { useEditorContext } from '@/shared/context/editor-context'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { redo, selectAll, undo } from '@codemirror/commands'
|
||||
import { openSearchPanel } from '@codemirror/search'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useIsNewEditorEnabled } from '../utils/new-editor-utils'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { language } from '@codemirror/language'
|
||||
|
||||
export const useToolbarMenuBarEditorCommands = () => {
|
||||
const view = useCodeMirrorViewContext()
|
||||
const state = useCodeMirrorStateContext()
|
||||
const { t } = useTranslation()
|
||||
const { view: layoutView } = useLayoutContext()
|
||||
const editorIsVisible = layoutView === 'editor'
|
||||
const { trackedWrite } = usePermissionsContext()
|
||||
const languageName = state.facet(language)?.name
|
||||
const isTeXFile = languageName === 'latex'
|
||||
|
||||
const openFigureModal = useCallback((source: FigureModalSource) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('figure-modal:open', {
|
||||
detail: { source },
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
const newEditor = useIsNewEditorEnabled()
|
||||
|
||||
useCommandProvider(() => {
|
||||
if (!newEditor) {
|
||||
return
|
||||
}
|
||||
|
||||
return [
|
||||
/************************************
|
||||
* Edit menu
|
||||
************************************/
|
||||
{
|
||||
id: 'undo',
|
||||
label: t('undo'),
|
||||
handler: () => {
|
||||
undo(view)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible || !trackedWrite,
|
||||
},
|
||||
{
|
||||
id: 'redo',
|
||||
label: t('redo'),
|
||||
handler: () => {
|
||||
redo(view)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible || !trackedWrite,
|
||||
},
|
||||
{
|
||||
id: 'find',
|
||||
label: t('find'),
|
||||
handler: () => {
|
||||
openSearchPanel(view)
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'select-all',
|
||||
label: t('select_all'),
|
||||
handler: () => {
|
||||
selectAll(view)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
]
|
||||
}, [editorIsVisible, t, view, trackedWrite, newEditor])
|
||||
|
||||
// LaTeX commands
|
||||
useCommandProvider(() => {
|
||||
if (!newEditor) {
|
||||
return
|
||||
}
|
||||
if (!isTeXFile || !trackedWrite) {
|
||||
return
|
||||
}
|
||||
|
||||
return [
|
||||
/************************************
|
||||
* Insert menu
|
||||
************************************/
|
||||
{
|
||||
id: 'insert-inline-math',
|
||||
label: t('inline_math'),
|
||||
handler: () => {
|
||||
commands.wrapInInlineMath(view)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'insert-display-math',
|
||||
label: t('display_math'),
|
||||
handler: () => {
|
||||
commands.wrapInDisplayMath(view)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
label: t('upload_from_computer'),
|
||||
id: 'insert-figure-from-computer',
|
||||
handler: () => {
|
||||
openFigureModal(FigureModalSource.FILE_UPLOAD)
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
label: t('from_project_files'),
|
||||
id: 'insert-figure-from-project-files',
|
||||
handler: () => {
|
||||
openFigureModal(FigureModalSource.FILE_TREE)
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
label: t('from_another_project'),
|
||||
id: 'insert-figure-from-another-project',
|
||||
handler: () => {
|
||||
openFigureModal(FigureModalSource.OTHER_PROJECT)
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
label: t('from_url'),
|
||||
id: 'insert-figure-from-url',
|
||||
handler: () => {
|
||||
openFigureModal(FigureModalSource.FROM_URL)
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'insert-table',
|
||||
label: t('table'),
|
||||
handler: () => {
|
||||
commands.insertTable(view, 3, 3)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'insert-citation',
|
||||
label: t('citation'),
|
||||
handler: () => {
|
||||
commands.insertCite(view)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'insert-link',
|
||||
label: t('link'),
|
||||
handler: () => {
|
||||
commands.wrapInHref(view)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'insert-cross-reference',
|
||||
label: t('cross_reference'),
|
||||
handler: () => {
|
||||
commands.insertRef(view)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'comment',
|
||||
label: t('comment'),
|
||||
handler: () => {
|
||||
commands.addComment()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
/************************************
|
||||
* Format menu
|
||||
************************************/
|
||||
{
|
||||
id: 'format-bold',
|
||||
label: t('bold'),
|
||||
handler: () => {
|
||||
commands.toggleBold(view)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'format-italics',
|
||||
label: t('italics'),
|
||||
handler: () => {
|
||||
commands.toggleItalic(view)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'format-bullet-list',
|
||||
label: t('bullet_list'),
|
||||
handler: () => {
|
||||
commands.toggleBulletList(view)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'format-numbered-list',
|
||||
label: t('numbered_list'),
|
||||
handler: () => {
|
||||
commands.toggleNumberedList(view)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'format-increase-indentation',
|
||||
label: t('increase_indent'),
|
||||
handler: () => {
|
||||
commands.indentIncrease(view)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'format-decrease-indentation',
|
||||
label: t('decrease_indent'),
|
||||
handler: () => {
|
||||
commands.indentDecrease(view)
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'format-style-normal',
|
||||
label: t('normal'),
|
||||
handler: () => {
|
||||
setSectionHeadingLevel(view, 'text')
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'format-style-section',
|
||||
label: 'Section',
|
||||
handler: () => {
|
||||
setSectionHeadingLevel(view, 'section')
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'format-style-subsection',
|
||||
label: 'Subsection',
|
||||
handler: () => {
|
||||
setSectionHeadingLevel(view, 'subsection')
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'format-style-subsubsection',
|
||||
label: 'Subsubsection',
|
||||
handler: () => {
|
||||
setSectionHeadingLevel(view, 'subsubsection')
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'format-style-paragraph',
|
||||
label: 'Paragraph',
|
||||
handler: () => {
|
||||
setSectionHeadingLevel(view, 'paragraph')
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
{
|
||||
id: 'format-style-subparagraph',
|
||||
label: 'Subparagraph',
|
||||
handler: () => {
|
||||
setSectionHeadingLevel(view, 'subparagraph')
|
||||
view.focus()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
]
|
||||
}, [
|
||||
view,
|
||||
t,
|
||||
editorIsVisible,
|
||||
openFigureModal,
|
||||
newEditor,
|
||||
trackedWrite,
|
||||
isTeXFile,
|
||||
])
|
||||
|
||||
const { toggleSymbolPalette } = useEditorContext()
|
||||
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
|
||||
useCommandProvider(() => {
|
||||
if (!symbolPaletteAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isTeXFile || !trackedWrite) {
|
||||
return
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'insert-symbol',
|
||||
label: t('symbol'),
|
||||
handler: () => {
|
||||
toggleSymbolPalette?.()
|
||||
},
|
||||
disabled: !editorIsVisible,
|
||||
},
|
||||
]
|
||||
}, [
|
||||
symbolPaletteAvailable,
|
||||
t,
|
||||
toggleSymbolPalette,
|
||||
editorIsVisible,
|
||||
isTeXFile,
|
||||
trackedWrite,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 8C0 3.58172 3.58172 0 8 0H28C32.4183 0 36 3.58172 36 8V28C36 32.4183 32.4183 36 28 36H8C3.58172 36 0 32.4183 0 28V8Z" fill="#EAF6EF"/>
|
||||
<path d="M14.8 16.95L16.95 14.775L15.55 13.35L15.15 13.75C14.9667 13.9333 14.7333 14.0333 14.45 14.05C14.1833 14.05 13.95 13.95 13.75 13.75C13.55 13.55 13.45 13.3167 13.45 13.05C13.45 12.7667 13.55 12.525 13.75 12.325L14.125 11.95L13 10.825L10.825 13L14.8 16.95ZM23 25.175L25.175 23L24.05 21.875L23.65 22.25C23.45 22.45 23.2167 22.55 22.95 22.55C22.6833 22.55 22.45 22.45 22.25 22.25C22.05 22.05 21.95 21.8167 21.95 21.55C21.95 21.2833 22.05 21.05 22.25 20.85L22.625 20.45L21.2 19.05L19.05 21.2L23 25.175ZM22.225 12.425L23.625 13.825L25.025 12.425L23.6 11L22.225 12.425ZM10 27C9.71667 27 9.475 26.9083 9.275 26.725C9.09167 26.525 9 26.2833 9 26V23.175C9 23.0417 9.025 22.9167 9.075 22.8C9.125 22.6667 9.2 22.55 9.3 22.45L13.375 18.375L9.05 14.05C8.76667 13.7667 8.625 13.4167 8.625 13C8.625 12.5833 8.76667 12.2333 9.05 11.95L11.95 9.05C12.2333 8.76667 12.5833 8.63333 13 8.65C13.4167 8.65 13.7667 8.79167 14.05 9.075L18.4 13.4L22.175 9.6C22.375 9.4 22.6 9.25 22.85 9.15C23.1 9.05 23.3583 9 23.625 9C23.8917 9 24.15 9.05 24.4 9.15C24.65 9.25 24.875 9.4 25.075 9.6L26.4 10.95C26.6 11.15 26.75 11.375 26.85 11.625C26.95 11.875 27 12.1333 27 12.4C27 12.6667 26.95 12.925 26.85 13.175C26.75 13.4083 26.6 13.625 26.4 13.825L22.625 17.625L26.95 21.95C27.2333 22.2333 27.375 22.5833 27.375 23C27.375 23.4167 27.2333 23.7667 26.95 24.05L24.05 26.95C23.7667 27.2333 23.4167 27.375 23 27.375C22.5833 27.375 22.2333 27.2333 21.95 26.95L17.625 22.625L13.55 26.7C13.45 26.8 13.3333 26.875 13.2 26.925C13.0833 26.975 12.9583 27 12.825 27H10Z" fill="#195936"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,11 @@
|
||||
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
|
||||
import { isInExperiment } from '@/utils/labs-utils'
|
||||
|
||||
export const canUseNewEditor = () => isInExperiment('editor-redesign')
|
||||
|
||||
export const useIsNewEditorEnabled = () => {
|
||||
const { userSettings } = useUserSettingsContext()
|
||||
const hasAccess = canUseNewEditor()
|
||||
const enabled = userSettings.enableNewEditor
|
||||
return hasAccess && enabled
|
||||
}
|
||||
Reference in New Issue
Block a user