first commit

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

View File

@@ -0,0 +1,93 @@
import { useTranslation } from 'react-i18next'
import { LostConnectionAlert } from './lost-connection-alert'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { debugging } from '@/utils/debugging'
import useScopeValue from '@/shared/hooks/use-scope-value'
import { createPortal } from 'react-dom'
import { useGlobalAlertsContainer } from '@/features/ide-react/context/global-alerts-context'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import OLButton from '@/features/ui/components/ol/ol-button'
export function Alerts() {
const { t } = useTranslation()
const {
connectionState,
isConnected,
isStillReconnecting,
tryReconnectNow,
secondsUntilReconnect,
} = useConnectionContext()
const globalAlertsContainer = useGlobalAlertsContainer()
const [synctexError] = useScopeValue('sync_tex_error')
if (!globalAlertsContainer) {
return null
}
return createPortal(
<>
{connectionState.forceDisconnected &&
// hide "disconnected" banner when displaying out of sync modal
connectionState.error !== 'out-of-sync' ? (
<OLNotification
type="error"
content={<strong>{t('disconnected')}</strong>}
/>
) : null}
{connectionState.reconnectAt ? (
<LostConnectionAlert
reconnectAt={connectionState.reconnectAt}
tryReconnectNow={tryReconnectNow}
/>
) : null}
{isStillReconnecting ? (
<OLNotification
type="warning"
content={<strong>{t('reconnecting')}</strong>}
/>
) : null}
{synctexError ? (
<OLNotification
type="warning"
content={<strong>{t('synctex_failed')}</strong>}
action={
<OLButton
href="/learn/how-to/SyncTeX_Errors"
target="_blank"
id="synctex-more-info-button"
variant="secondary"
size="sm"
>
{t('more_info')}
</OLButton>
}
/>
) : null}
{connectionState.inactiveDisconnect ||
(connectionState.readyState === WebSocket.CLOSED &&
(connectionState.error === 'rate-limited' ||
connectionState.error === 'unable-to-connect') &&
!secondsUntilReconnect()) ? (
<OLNotification
type="warning"
content={
<strong>{t('editor_disconected_click_to_reconnect')}</strong>
}
/>
) : null}
{debugging ? (
<OLNotification
type="warning"
content={<strong>Connected: {isConnected.toString()}</strong>}
/>
) : null}
</>,
globalAlertsContainer
)
}

View File

@@ -0,0 +1,49 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import { secondsUntil } from '@/features/ide-react/connection/utils'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import OLButton from '@/features/ui/components/ol/ol-button'
type LostConnectionAlertProps = {
reconnectAt: number
tryReconnectNow: () => void
}
export function LostConnectionAlert({
reconnectAt,
tryReconnectNow,
}: LostConnectionAlertProps) {
const { t } = useTranslation()
const [secondsUntilReconnect, setSecondsUntilReconnect] = useState(
secondsUntil(reconnectAt)
)
useEffect(() => {
const timer = window.setInterval(() => {
setSecondsUntilReconnect(secondsUntil(reconnectAt))
}, 1000)
return () => window.clearInterval(timer)
}, [reconnectAt])
return (
<OLNotification
type="warning"
content={
<>
<strong>{t('lost_connection')}</strong>{' '}
{t('reconnecting_in_x_secs', { seconds: secondsUntilReconnect })}.
</>
}
action={
<OLButton
id="try-reconnect-now-button"
onClick={() => tryReconnectNow()}
size="sm"
variant="secondary"
>
{t('try_now')}
</OLButton>
}
/>
)
}

View File

@@ -0,0 +1,116 @@
import React, { FC, useState } from 'react'
import { Panel, PanelGroup } from 'react-resizable-panels'
import NoSelectionPane from '@/features/ide-react/components/editor/no-selection-pane'
import FileView from '@/features/file-view/components/file-view'
import MultipleSelectionPane from '@/features/ide-react/components/editor/multiple-selection-pane'
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
import { DefaultSynctexControl } from '@/features/pdf-preview/components/detach-synctex-control'
import PdfPreview from '@/features/pdf-preview/components/pdf-preview'
import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import { fileViewFile } from '@/features/ide-react/util/file-view'
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
import { EditorPane } from '@/features/ide-react/components/editor/editor-pane'
export const EditorAndPdf: FC = () => {
const [resizing, setResizing] = useState(false)
const { t } = useTranslation()
const {
togglePdfPane,
handlePdfPaneExpand,
handlePdfPaneCollapse,
setPdfIsOpen,
pdfIsOpen,
pdfPanelRef,
} = usePdfPane()
const { view, pdfLayout } = useLayoutContext()
const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
const editorIsOpen =
view === 'editor' || view === 'file' || pdfLayout === 'sideBySide'
return (
<PanelGroup
autoSaveId="ide-editor-pdf-layout"
direction="horizontal"
className={classNames({
'ide-panel-group-resizing': resizing,
hidden: view === 'history',
})}
>
{/* ide */}
<Panel
id="panel-ide"
order={1}
defaultSize={50}
minSize={5}
className={classNames('ide-react-panel', {
'ide-panel-group-resizing': resizing,
hidden: !editorIsOpen,
})}
>
{selectedEntityCount === 0 && <NoSelectionPane />}
{selectedEntityCount === 1 && openEntity?.type === 'fileRef' && (
<FileView file={fileViewFile(openEntity.entity)} />
)}
{selectedEntityCount > 1 && (
<MultipleSelectionPane selectedEntityCount={selectedEntityCount} />
)}
<EditorPane />
</Panel>
<HorizontalResizeHandle
resizable={pdfLayout === 'sideBySide'}
onDoubleClick={togglePdfPane}
onDragging={setResizing}
className={classNames({
hidden: !editorIsOpen,
})}
hitAreaMargins={{ coarse: 0, fine: 0 }}
>
<HorizontalToggler
id="editor-pdf"
togglerType="east"
isOpen={pdfIsOpen}
setIsOpen={setPdfIsOpen}
tooltipWhenOpen={t('tooltip_hide_pdf')}
tooltipWhenClosed={t('tooltip_show_pdf')}
/>
{pdfLayout === 'sideBySide' && (
<div className="synctex-controls">
<DefaultSynctexControl />
</div>
)}
</HorizontalResizeHandle>
{/* pdf */}
<Panel
ref={pdfPanelRef}
id="panel-pdf"
order={2}
defaultSize={50}
minSize={5}
collapsible
onCollapse={handlePdfPaneCollapse}
onExpand={handlePdfPaneExpand}
className="ide-react-panel"
>
<PdfPreview />
{/* ensure that "sync to code" is available in PDF only layout */}
{pdfLayout === 'flat' && view === 'pdf' && (
<div className="synctex-controls" hidden>
<DefaultSynctexControl />
</div>
)}
</Panel>
</PanelGroup>
)
}

View File

@@ -0,0 +1,42 @@
import { useState, useCallback } from 'react'
import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import * as eventTracking from '@/infrastructure/event-tracking'
import EditorNavigationToolbarRoot from '@/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root'
import ShareProjectModal from '@/features/share-project-modal/components/share-project-modal'
import EditorOverLimitModal from '@/features/share-project-modal/components/editor-over-limit-modal'
import ViewOnlyAccessModal from '@/features/share-project-modal/components/view-only-access-modal'
function EditorNavigationToolbar() {
const [showShareModal, setShowShareModal] = useState(false)
const { onlineUsersArray } = useOnlineUsersContext()
const { openDoc } = useEditorManagerContext()
const handleOpenShareModal = useCallback(() => {
eventTracking.sendMBOnce('ide-open-share-modal-once')
setShowShareModal(true)
}, [])
const handleHideShareModal = useCallback(() => {
setShowShareModal(false)
}, [])
return (
<>
<EditorNavigationToolbarRoot
onlineUsersArray={onlineUsersArray}
openDoc={openDoc}
openShareProjectModal={handleOpenShareModal}
/>
<EditorOverLimitModal />
<ViewOnlyAccessModal />
<ShareProjectModal
show={showShareModal}
handleOpen={handleOpenShareModal}
handleHide={handleHideShareModal}
/>
</>
)
}
export default EditorNavigationToolbar

View File

@@ -0,0 +1,58 @@
import { Panel, PanelGroup } from 'react-resizable-panels'
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
import { FileTree } from '@/features/ide-react/components/file-tree'
import classNames from 'classnames'
import { useLayoutContext } from '@/shared/context/layout-context'
import { OutlineContainer } from '@/features/outline/components/outline-container'
import { useOutlinePane } from '@/features/ide-react/hooks/use-outline-pane'
import React, { ElementType } from 'react'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
const editorSidebarComponents = importOverleafModules(
'editorSidebarComponents'
) as { import: { default: ElementType }; path: string }[]
export default function EditorSidebar() {
const { view } = useLayoutContext()
const { outlineEnabled, outlinePanelRef } = useOutlinePane()
return (
<aside
className={classNames('ide-react-editor-sidebar', {
hidden: view === 'history',
})}
>
{editorSidebarComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
<PanelGroup autoSaveId="ide-editor-sidebar-layout" direction="vertical">
<Panel
defaultSize={50}
minSize={25}
className="ide-react-file-tree-panel"
id="panel-file-tree"
order={1}
>
<FileTree />
</Panel>
<VerticalResizeHandle disabled={!outlineEnabled} />
<Panel
defaultSize={50}
maxSize={75}
id="panel-outline"
order={2}
collapsible
ref={outlinePanelRef}
style={{ minHeight: 32 }} // keep the header visible
>
<OutlineContainer />
</Panel>
</PanelGroup>
</aside>
)
}

View File

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

View File

@@ -0,0 +1,10 @@
import { FC } from 'react'
import LoadingSpinner from '@/shared/components/loading-spinner'
export const LoadingPane: FC = () => {
return (
<div className="loading-panel">
<LoadingSpinner />
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next'
export default function MultipleSelectionPane({
selectedEntityCount,
}: {
selectedEntityCount: number
}) {
const { t } = useTranslation()
return (
<div className="multi-selection-ongoing">
<div className="multi-selection-message">
<h4>{`${selectedEntityCount} ${t('files_selected')}`}</h4>
</div>
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { useTranslation } from 'react-i18next'
export default function NoSelectionPane() {
const { t } = useTranslation()
return (
<div className="no-file-selection">
<div className="no-file-selection-message">
<h3>{t('no_selection_select_file')}</h3>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import React, { ElementType, FC } from 'react'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
const symbolPaletteComponents = importOverleafModules(
'sourceEditorSymbolPalette'
) as { import: { default: ElementType }; path: string }[]
const SymbolPalettePane: FC = () => {
return (
<div className="ide-react-symbol-palette">
{symbolPaletteComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
</div>
)
}
export default SymbolPalettePane

View File

@@ -0,0 +1,38 @@
import React, { memo, useCallback, useState } from 'react'
import { useUserContext } from '@/shared/context/user-context'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { RefProviders } from '../../../../../types/user'
import FileTreeRoot from '@/features/file-tree/components/file-tree-root'
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
export const FileTree = memo(function FileTree() {
const user = useUserContext()
const { setStartedFreeTrial } = useIdeReactContext()
const { isConnected, connectionState } = useConnectionContext()
const { handleFileTreeInit, handleFileTreeSelect, handleFileTreeDelete } =
useFileTreeOpenContext()
const [refProviders, setRefProviders] = useState<RefProviders>(
() => user.refProviders || {}
)
const setRefProviderEnabled = useCallback(
(provider: keyof RefProviders, value = true) => {
setRefProviders(refProviders => ({ ...refProviders, [provider]: value }))
},
[]
)
return (
<FileTreeRoot
refProviders={refProviders}
setRefProviderEnabled={setRefProviderEnabled}
setStartedFreeTrial={setStartedFreeTrial}
isConnected={isConnected || connectionState.reconnectAt !== null}
onInit={handleFileTreeInit}
onSelect={handleFileTreeSelect}
onDelete={handleFileTreeDelete}
/>
)
})

View File

@@ -0,0 +1,99 @@
import { OLToast, OLToastProps } from '@/features/ui/components/ol/ol-toast'
import useEventListener from '@/shared/hooks/use-event-listener'
import { Fragment, ReactElement, useCallback, useState } from 'react'
import { debugConsole } from '@/utils/debugging'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { OLToastContainer } from '@/features/ui/components/ol/ol-toast-container'
const moduleGeneratorsImport = importOverleafModules('toastGenerators') as {
import: { default: GlobalToastGeneratorEntry[] }
}[]
const moduleGenerators = moduleGeneratorsImport.map(
({ import: { default: listEntry } }) => listEntry
)
export type GlobalToastGeneratorEntry = {
key: string
generator: GlobalToastGenerator
}
type GlobalToastGenerator = (
args: Record<string, any>
) => Omit<OLToastProps, 'onDismiss'>
const GENERATOR_LIST: GlobalToastGeneratorEntry[] = moduleGenerators.flat()
const GENERATOR_MAP: Map<string, GlobalToastGenerator> = new Map(
GENERATOR_LIST.map(({ key, generator }) => [key, generator])
)
let toastCounter = 1
export const GlobalToasts = () => {
const [toasts, setToasts] = useState<
{ component: ReactElement; id: string }[]
>([])
const removeToast = useCallback((id: string) => {
setToasts(current => current.filter(toast => toast.id !== id))
}, [])
const createToast = useCallback(
(id: string, key: string, data: any): ReactElement | null => {
const generator = GENERATOR_MAP.get(key)
if (!generator) {
debugConsole.error('No toast generator found for key:', key)
return null
}
const props = generator(data)
if (!props.autoHide && !props.isDismissible) {
// We don't want any toasts that are not dismissible and don't auto-hide
props.isDismissible = true
}
if (props.autoHide && !props.isDismissible && props.delay !== undefined) {
// If the toast is auto-hiding but not dismissible, we need to make sure the delay is not too long
props.delay = Math.min(props.delay, 60_000)
}
return <OLToast {...props} onDismiss={() => removeToast(id)} />
},
[removeToast]
)
const addToast = useCallback(
(key: string, data?: any) => {
const id = `toast-${toastCounter++}`
const component = createToast(id, key, data)
if (!component) {
return
}
setToasts(current => [...current, { id, component }])
},
[createToast]
)
const showToastListener = useCallback(
(event: CustomEvent) => {
if (!event.detail?.key) {
debugConsole.error('No key provided for toast')
return
}
const { key, ...rest } = event.detail
addToast(key, rest)
},
[addToast]
)
useEventListener('ide:show-toast', showToastListener)
return (
<OLToastContainer className="global-toasts">
{toasts.map(({ component, id }) => (
<Fragment key={id}>{component}</Fragment>
))}
</OLToastContainer>
)
}

View File

@@ -0,0 +1,23 @@
import { useLayoutContext } from '../../../shared/context/layout-context'
import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
import { lazy, Suspense } from 'react'
const HistoryRoot = lazy(
() => import('@/features/ide-react/components/history-root')
)
function HistoryContainer() {
const { view } = useLayoutContext()
if (view !== 'history') {
return null
}
return (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<HistoryRoot />
</Suspense>
)
}
export default HistoryContainer

View File

@@ -0,0 +1,13 @@
import { memo } from 'react'
import { HistoryProvider } from '@/features/history/context/history-context'
import withErrorBoundary from '@/infrastructure/error-boundary'
import { ErrorBoundaryFallback } from '@/shared/components/error-boundary-fallback'
import History from './history'
const HistoryRoot = () => (
<HistoryProvider>
<History />
</HistoryProvider>
)
export default withErrorBoundary(memo(HistoryRoot), ErrorBoundaryFallback)

View File

@@ -0,0 +1,10 @@
import React from 'react'
export function HistorySidebar() {
return (
<aside
id="history-file-tree"
className="ide-react-editor-sidebar history-file-tree"
/>
)
}

View File

@@ -0,0 +1,28 @@
import { createPortal } from 'react-dom'
import HistoryFileTree from '@/features/history/components/history-file-tree'
import LoadingSpinner from '@/shared/components/loading-spinner'
import DiffView from '@/features/history/components/diff-view/diff-view'
import ChangeList from '@/features/history/components/change-list/change-list'
import { useHistoryContext } from '@/features/history/context/history-context'
export default function History() {
const { updatesInfo } = useHistoryContext()
const fileTreeContainer = document.getElementById('history-file-tree')
return (
<>
{fileTreeContainer &&
createPortal(<HistoryFileTree />, fileTreeContainer)}
<div className="history-react">
{updatesInfo.loadingState === 'loadingInitial' ? (
<LoadingSpinner />
) : (
<>
<DiffView />
<ChangeList />
</>
)}
</div>
</>
)
}

View File

@@ -0,0 +1,18 @@
import { FC, memo, useState } from 'react'
import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
import withErrorBoundary from '@/infrastructure/error-boundary'
import IdePage from '@/features/ide-react/components/layout/ide-page'
import { ReactContextRoot } from '@/features/ide-react/context/react-context-root'
import { Loading } from '@/features/ide-react/components/loading'
const IdeRoot: FC = () => {
const [loaded, setLoaded] = useState(false)
return (
<ReactContextRoot>
{loaded ? <IdePage /> : <Loading setLoaded={setLoaded} />}
</ReactContextRoot>
)
}
export default withErrorBoundary(memo(IdeRoot), GenericErrorBoundaryFallback)

View File

@@ -0,0 +1,49 @@
import { lazy, Suspense } from 'react'
import { Alerts } from '@/features/ide-react/components/alerts/alerts'
import { MainLayout } from '@/features/ide-react/components/layout/main-layout'
import EditorLeftMenu from '@/features/editor-left-menu/components/editor-left-menu'
import { useLayoutEventTracking } from '@/features/ide-react/hooks/use-layout-event-tracking'
import useSocketListeners from '@/features/ide-react/hooks/use-socket-listeners'
import { useEditingSessionHeartbeat } from '@/features/ide-react/hooks/use-editing-session-heartbeat'
import { useRegisterUserActivity } from '@/features/ide-react/hooks/use-register-user-activity'
import { useHasLintingError } from '@/features/ide-react/hooks/use-has-linting-error'
import { Modals } from '@/features/ide-react/components/modals/modals'
import { GlobalAlertsProvider } from '@/features/ide-react/context/global-alerts-context'
import { GlobalToasts } from '../global-toasts'
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
const MainLayoutNew = lazy(
() => import('@/features/ide-redesign/components/main-layout')
)
const SettingsModalNew = lazy(
() => import('@/features/ide-redesign/components/settings/settings-modal')
)
export default function IdePage() {
useLayoutEventTracking() // sent event when the layout changes
useSocketListeners() // listen for project-related websocket messages
useEditingSessionHeartbeat() // send a batched event when user is active
useRegisterUserActivity() // record activity and ensure connection when user is active
useHasLintingError() // pass editor:lint hasLintingError to the compiler
const newEditor = useIsNewEditorEnabled()
return (
<GlobalAlertsProvider>
<Alerts />
<Modals />
{newEditor ? (
<Suspense fallback={null}>
<SettingsModalNew />
<MainLayoutNew />
</Suspense>
) : (
<>
<EditorLeftMenu />
<MainLayout />
</>
)}
<GlobalToasts />
</GlobalAlertsProvider>
)
}

View File

@@ -0,0 +1,140 @@
import { Panel, PanelGroup } from 'react-resizable-panels'
import { ElementType, FC } from 'react'
import { HorizontalResizeHandle } from '../resize/horizontal-resize-handle'
import classNames from 'classnames'
import { useLayoutContext } from '@/shared/context/layout-context'
import EditorNavigationToolbar from '@/features/ide-react/components/editor-navigation-toolbar'
import ChatPane from '@/features/chat/components/chat-pane'
import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
import { HistorySidebar } from '@/features/ide-react/components/history-sidebar'
import EditorSidebar from '@/features/ide-react/components/editor-sidebar'
import { useTranslation } from 'react-i18next'
import { useSidebarPane } from '@/features/ide-react/hooks/use-sidebar-pane'
import { useChatPane } from '@/features/ide-react/hooks/use-chat-pane'
import { EditorAndPdf } from '@/features/ide-react/components/editor-and-pdf'
import HistoryContainer from '@/features/ide-react/components/history-container'
import getMeta from '@/utils/meta'
import { useEditorContext } from '@/shared/context/editor-context'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
const mainEditorLayoutModalsModules: Array<{
import: { default: ElementType }
path: string
}> = importOverleafModules('mainEditorLayoutModals')
export const MainLayout: FC = () => {
const { view } = useLayoutContext()
const { isRestrictedTokenMember } = useEditorContext()
const {
isOpen: sidebarIsOpen,
setIsOpen: setSidebarIsOpen,
panelRef: sidebarPanelRef,
togglePane: toggleSidebar,
handlePaneExpand: handleSidebarExpand,
handlePaneCollapse: handleSidebarCollapse,
resizing: sidebarResizing,
setResizing: setSidebarResizing,
} = useSidebarPane()
const {
isOpen: chatIsOpen,
panelRef: chatPanelRef,
togglePane: toggleChat,
resizing: chatResizing,
setResizing: setChatResizing,
handlePaneCollapse: handleChatCollapse,
handlePaneExpand: handleChatExpand,
} = useChatPane()
const chatEnabled = getMeta('ol-chatEnabled') && !isRestrictedTokenMember
const { t } = useTranslation()
return (
<div className="ide-react-main">
<EditorNavigationToolbar />
<div className="ide-react-body">
<PanelGroup
autoSaveId="ide-outer-layout"
direction="horizontal"
className={classNames({
'ide-panel-group-resizing': sidebarResizing || chatResizing,
})}
>
{/* sidebar */}
<Panel
ref={sidebarPanelRef}
id="panel-sidebar"
order={1}
defaultSize={15}
minSize={5}
maxSize={80}
collapsible
onCollapse={handleSidebarCollapse}
onExpand={handleSidebarExpand}
>
<EditorSidebar />
{view === 'history' && <HistorySidebar />}
</Panel>
<HorizontalResizeHandle
onDoubleClick={toggleSidebar}
resizable={sidebarIsOpen}
onDragging={setSidebarResizing}
hitAreaMargins={{ coarse: 0, fine: 0 }}
>
<HorizontalToggler
id="panel-sidebar"
togglerType="west"
isOpen={sidebarIsOpen}
setIsOpen={setSidebarIsOpen}
tooltipWhenOpen={t('tooltip_hide_filetree')}
tooltipWhenClosed={t('tooltip_show_filetree')}
/>
</HorizontalResizeHandle>
<Panel id="panel-outer-main" order={2}>
<PanelGroup autoSaveId="ide-inner-layout" direction="horizontal">
<Panel className="ide-react-panel" id="panel-main" order={1}>
<HistoryContainer />
<EditorAndPdf />
</Panel>
{chatEnabled && (
<>
<HorizontalResizeHandle
onDoubleClick={toggleChat}
resizable={chatIsOpen}
onDragging={setChatResizing}
hitAreaMargins={{ coarse: 0, fine: 0 }}
/>
{/* chat */}
<Panel
ref={chatPanelRef}
id="panel-chat"
order={2}
defaultSize={20}
minSize={5}
maxSize={30}
collapsible
onCollapse={handleChatCollapse}
onExpand={handleChatExpand}
>
<ChatPane />
</Panel>
</>
)}
</PanelGroup>
</Panel>
</PanelGroup>
</div>
{mainEditorLayoutModalsModules.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { FC } from 'react'
import { ConnectionError } from '@/features/ide-react/connection/types/connection-state'
import getMeta from '@/utils/meta'
const errorMessages = {
'io-not-loaded': 'ol-translationIoNotLoaded',
'unable-to-join': 'ol-translationUnableToJoin',
'i18n-error': 'ol-translationLoadErrorMessage',
} as const
const isHandledCode = (key: string): key is keyof typeof errorMessages =>
key in errorMessages
export type LoadingErrorProps = {
errorCode: ConnectionError | 'i18n-error' | ''
}
// NOTE: i18n translations might not be loaded in the client at this point,
// so these translations have to be loaded from meta tags
export const LoadingError: FC<LoadingErrorProps> = ({ errorCode }) => {
return isHandledCode(errorCode) ? (
<p className="loading-screen-error">{getMeta(errorMessages[errorCode])}</p>
) : null
}

View File

@@ -0,0 +1,87 @@
import { FC, useEffect, useState } from 'react'
import LoadingBranded from '@/shared/components/loading-branded'
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import getMeta from '@/utils/meta'
import { useConnectionContext } from '../context/connection-context'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { LoadingError, LoadingErrorProps } from './loading-error'
type Part = 'initial' | 'render' | 'connection' | 'translations' | 'project'
const initialParts = new Set<Part>(['initial'])
const totalParts = new Set<Part>([
'initial',
'render',
'connection',
'translations',
'project',
])
export const Loading: FC<{
setLoaded: (value: boolean) => void
}> = ({ setLoaded }) => {
const [loadedParts, setLoadedParts] = useState(initialParts)
const progress = (loadedParts.size / totalParts.size) * 100
useEffect(() => {
setLoaded(progress === 100)
}, [progress, setLoaded])
const { connectionState, isConnected } = useConnectionContext()
const i18n = useWaitForI18n()
const { projectJoined } = useIdeReactContext()
useEffect(() => {
setLoadedParts(value => new Set(value).add('render'))
}, [])
useEffect(() => {
if (isConnected) {
setLoadedParts(value => new Set(value).add('connection'))
}
}, [isConnected])
useEffect(() => {
if (i18n.isReady) {
setLoadedParts(value => new Set(value).add('translations'))
}
}, [i18n.isReady])
useEffect(() => {
if (projectJoined) {
setLoadedParts(value => new Set(value).add('project'))
}
}, [projectJoined])
// Use loading text from the server, because i18n will not be ready initially
const label = getMeta('ol-loadingText')
const errorCode = connectionState.error ?? (i18n.error ? 'i18n-error' : '')
return <LoadingUI progress={progress} label={label} errorCode={errorCode} />
}
type LoadingUiProps = {
progress: number
label: string
errorCode: LoadingErrorProps['errorCode']
}
export const LoadingUI: FC<LoadingUiProps> = ({
progress,
label,
errorCode,
}) => {
return (
<div className="loading-screen">
<LoadingBranded
loadProgress={progress}
label={label}
hasError={Boolean(errorCode)}
/>
{Boolean(errorCode) && <LoadingError errorCode={errorCode} />}
</div>
)
}

View File

@@ -0,0 +1,68 @@
import { useTranslation } from 'react-i18next'
import { memo, useEffect, useState } from 'react'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import OLModal, {
OLModalBody,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
// show modal when editor is forcefully disconnected
function ForceDisconnected() {
const { connectionState } = useConnectionContext()
const { t } = useTranslation()
const [secondsUntilRefresh, setSecondsUntilRefresh] = useState(0)
const [show, setShow] = useState(false)
useEffect(() => {
if (
connectionState.forceDisconnected &&
// out of sync has its own modal
connectionState.error !== 'out-of-sync'
) {
setShow(true)
}
}, [connectionState.forceDisconnected, connectionState.error])
useEffect(() => {
if (connectionState.forceDisconnected) {
setSecondsUntilRefresh(connectionState.forcedDisconnectDelay)
}
}, [connectionState.forceDisconnected, connectionState.forcedDisconnectDelay])
useEffect(() => {
if (show) {
const timer = window.setInterval(() => {
setSecondsUntilRefresh(seconds => Math.max(0, seconds - 1))
}, 1000)
return () => {
window.clearInterval(timer)
}
}
}, [show])
if (!show) {
return null
}
return (
<OLModal
show
// It's not possible to hide this modal, but it's a required prop
onHide={() => {}}
className="lock-editor-modal"
backdrop={false}
keyboard={false}
>
<OLModalHeader>
<OLModalTitle>{t('please_wait')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{t('were_performing_maintenance', { seconds: secondsUntilRefresh })}
</OLModalBody>
</OLModal>
)
}
export default memo(ForceDisconnected)

View File

@@ -0,0 +1,53 @@
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import { ButtonProps } from '@/features/ui/components/types/button-props'
export type GenericConfirmModalOwnProps = {
title: string
message: string
onConfirm: () => void
confirmLabel?: string
primaryVariant?: ButtonProps['variant']
}
type GenericConfirmModalProps = React.ComponentProps<typeof OLModal> &
GenericConfirmModalOwnProps
function GenericConfirmModal({
title,
message,
confirmLabel,
primaryVariant = 'primary',
...modalProps
}: GenericConfirmModalProps) {
const { t } = useTranslation()
const handleConfirmClick = modalProps.onConfirm
return (
<OLModal {...modalProps}>
<OLModalHeader closeButton>
<OLModalTitle>{title}</OLModalTitle>
</OLModalHeader>
<OLModalBody className="modal-generic-confirm">{message}</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={() => modalProps.onHide()}>
{t('cancel')}
</OLButton>
<OLButton variant={primaryVariant} onClick={handleConfirmClick}>
{confirmLabel || t('ok')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default memo(GenericConfirmModal)

View File

@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
export type GenericMessageModalOwnProps = {
title: string
message: string
}
type GenericMessageModalProps = React.ComponentProps<typeof OLModal> &
GenericMessageModalOwnProps
function GenericMessageModal({
title,
message,
...modalProps
}: GenericMessageModalProps) {
const { t } = useTranslation()
return (
<OLModal {...modalProps}>
<OLModalHeader closeButton>
<OLModalTitle>{title}</OLModalTitle>
</OLModalHeader>
<OLModalBody className="modal-body-share">{message}</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={() => modalProps.onHide()}>
{t('ok')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default memo(GenericMessageModal)

View File

@@ -0,0 +1,17 @@
import { memo } from 'react'
import ForceDisconnected from '@/features/ide-react/components/modals/force-disconnected'
import { UnsavedDocs } from '@/features/ide-react/components/unsaved-docs/unsaved-docs'
import SystemMessages from '@/shared/components/system-messages'
import { IdeRedesignSwitcherModal } from '@/features/ide-redesign/components/switcher-modal/modal'
export const Modals = memo(() => {
return (
<>
<ForceDisconnected />
<UnsavedDocs />
<SystemMessages />
<IdeRedesignSwitcherModal />
</>
)
})
Modals.displayName = 'Modals'

View File

@@ -0,0 +1,86 @@
import { Trans, useTranslation } from 'react-i18next'
import { memo, useState } from 'react'
import { useLocation } from '@/shared/hooks/use-location'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
export type OutOfSyncModalProps = {
editorContent: string
show: boolean
onHide: () => void
}
function OutOfSyncModal({ editorContent, show, onHide }: OutOfSyncModalProps) {
const { t } = useTranslation()
const location = useLocation()
const [editorContentShown, setEditorContentShown] = useState(false)
const editorContentRows = (editorContent.match(/\n/g)?.length || 0) + 1
// Reload the page to avoid staying in an inconsistent state.
// https://github.com/overleaf/issues/issues/3694
function done() {
onHide()
location.reload()
}
return (
<OLModal
show={show}
onHide={done}
className="out-of-sync-modal"
backdrop={false}
keyboard={false}
>
<OLModalHeader closeButton>
<OLModalTitle>{t('out_of_sync')}</OLModalTitle>
</OLModalHeader>
<OLModalBody className="modal-body-share">
<Trans
i18nKey="out_of_sync_detail"
components={[
// eslint-disable-next-line react/jsx-key
<br />,
// eslint-disable-next-line jsx-a11y/anchor-has-content,react/jsx-key
<a
target="_blank"
rel="noopener noreferrer"
href="/learn/Kb/Editor_out_of_sync_problems"
/>,
]}
/>
</OLModalBody>
<OLModalBody>
<OLButton
variant="secondary"
onClick={() => setEditorContentShown(shown => !shown)}
>
{editorContentShown
? t('hide_local_file_contents')
: t('show_local_file_contents')}
</OLButton>
{editorContentShown ? (
<div className="text-preview">
<textarea
className="scroll-container"
readOnly
rows={editorContentRows}
value={editorContent}
/>
</div>
) : null}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={done}>
{t('reload_editor')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default memo(OutOfSyncModal)

View File

@@ -0,0 +1,57 @@
import { PanelResizeHandle } from 'react-resizable-panels'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PanelResizeHandleProps } from 'react-resizable-panels/dist/declarations/src/PanelResizeHandle'
import classNames from 'classnames'
type HorizontalResizeHandleOwnProps = {
resizable?: boolean
onDoubleClick?: () => void
}
export const HorizontalResizeHandle: FC<
HorizontalResizeHandleOwnProps & PanelResizeHandleProps
> = ({ children, resizable = true, onDoubleClick, ...props }) => {
const { t } = useTranslation()
const [isDragging, setIsDragging] = useState(false)
function handleDragging(isDraggingParam: boolean) {
if (isDragging || resizable) {
setIsDragging(isDraggingParam)
}
}
// Only call onDragging prop when the pointer moves after starting a drag
useEffect(() => {
if (isDragging) {
const handlePointerMove = () => {
props.onDragging?.(true)
}
document.addEventListener('pointermove', handlePointerMove)
return () => {
document.removeEventListener('pointermove', handlePointerMove)
}
} else {
props.onDragging?.(false)
}
}, [isDragging, props])
return (
<PanelResizeHandle
disabled={!resizable && !isDragging}
{...props}
onDragging={handleDragging}
>
<div
className={classNames('horizontal-resize-handle', {
'horizontal-resize-handle-enabled': resizable,
})}
title={t('resize')}
onDoubleClick={() => onDoubleClick?.()}
>
{children}
</div>
</PanelResizeHandle>
)
}

View File

@@ -0,0 +1,48 @@
import classNames from 'classnames'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
type HorizontalTogglerType = 'west' | 'east'
type HorizontalTogglerProps = {
id: string
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
togglerType: HorizontalTogglerType
tooltipWhenOpen: string
tooltipWhenClosed: string
}
export function HorizontalToggler({
id,
isOpen,
setIsOpen,
togglerType,
tooltipWhenOpen,
tooltipWhenClosed,
}: HorizontalTogglerProps) {
const description = isOpen ? tooltipWhenOpen : tooltipWhenClosed
return (
<OLTooltip
id={id}
description={description}
overlayProps={{
placement: togglerType === 'east' ? 'left' : 'right',
}}
>
<button
className={classNames(
'custom-toggler',
`custom-toggler-${togglerType}`,
{
'custom-toggler-open': isOpen,
'custom-toggler-closed': !isOpen,
}
)}
aria-label={description}
title=""
onClick={() => setIsOpen(!isOpen)}
/>
</OLTooltip>
)
}

View File

@@ -0,0 +1,19 @@
import { PanelResizeHandle } from 'react-resizable-panels'
import { useTranslation } from 'react-i18next'
import { PanelResizeHandleProps } from 'react-resizable-panels/dist/declarations/src/PanelResizeHandle'
import classNames from 'classnames'
export function VerticalResizeHandle(props: PanelResizeHandleProps) {
const { t } = useTranslation()
return (
<PanelResizeHandle {...props}>
<div
className={classNames('vertical-resize-handle', {
'vertical-resize-handle-enabled': !props.disabled,
})}
title={t('resize')}
/>
</PanelResizeHandle>
)
}

View File

@@ -0,0 +1,61 @@
import { FC, useEffect, useMemo, useRef } from 'react'
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import { sendMB } from '@/infrastructure/event-tracking'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
const MAX_UNSAVED_ALERT_SECONDS = 15
export const UnsavedDocsAlert: FC<{ unsavedDocs: Map<string, number> }> = ({
unsavedDocs,
}) => (
<>
{[...unsavedDocs.entries()].map(
([docId, seconds]) =>
seconds >= MAX_UNSAVED_ALERT_SECONDS && (
<UnsavedDocAlert key={docId} docId={docId} seconds={seconds} />
)
)}
</>
)
const UnsavedDocAlert: FC<{ docId: string; seconds: number }> = ({
docId,
seconds,
}) => {
const { pathInFolder, findEntityByPath } = useFileTreePathContext()
const { socket } = useConnectionContext()
const { t } = useTranslation()
const recordedRef = useRef(false)
useEffect(() => {
if (!recordedRef.current) {
recordedRef.current = true
sendMB('unsaved-doc-alert-shown', {
docId,
transport: socket.socket.transport?.name,
})
}
}, [docId, socket])
const doc = useMemo(() => {
const path = pathInFolder(docId)
return path ? findEntityByPath(path) : null
}, [docId, findEntityByPath, pathInFolder])
if (!doc) {
return null
}
return (
<OLNotification
type="warning"
content={t('saving_notification_with_seconds', {
docname: doc.entity.name,
seconds,
})}
/>
)
}

View File

@@ -0,0 +1,19 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
export const UnsavedDocsLockedAlert: FC = () => {
const { t } = useTranslation()
return (
<OLNotification
type="warning"
content={
<>
<strong>{t('connection_lost_with_unsaved_changes')}</strong>{' '}
{t('dont_reload_or_close_this_tab')} {t('your_changes_will_save')}
</>
}
/>
)
}

View File

@@ -0,0 +1,121 @@
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useEditorContext } from '@/shared/context/editor-context'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
import { UnsavedDocsLockedAlert } from '@/features/ide-react/components/unsaved-docs/unsaved-docs-locked-alert'
import { UnsavedDocsAlert } from '@/features/ide-react/components/unsaved-docs/unsaved-docs-alert'
import useEventListener from '@/shared/hooks/use-event-listener'
import { createPortal } from 'react-dom'
import { useGlobalAlertsContainer } from '@/features/ide-react/context/global-alerts-context'
const MAX_UNSAVED_SECONDS = 30 // lock the editor after this time if unsaved
export const UnsavedDocs: FC = () => {
const { openDocs, debugTimers } = useEditorManagerContext()
const { permissionsLevel, setPermissionsLevel } = useEditorContext()
const [isLocked, setIsLocked] = useState(false)
const [unsavedDocs, setUnsavedDocs] = useState(new Map<string, number>())
const globalAlertsContainer = useGlobalAlertsContainer()
// always contains the latest value
const previousUnsavedDocsRef = useRef(unsavedDocs)
// always contains the latest value
const permissionsLevelRef = useRef(permissionsLevel)
useEffect(() => {
permissionsLevelRef.current = permissionsLevel
}, [permissionsLevel])
// warn if the window is being closed with unsaved changes
useEventListener(
'beforeunload',
useCallback(
event => {
if (openDocs.hasUnsavedChanges()) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
event.preventDefault()
}
},
[openDocs]
)
)
// keep track of which docs are currently unsaved, and how long they've been unsaved for
// NOTE: openDocs should never change, so it's safe to use as a dependency here
useEffect(() => {
const interval = window.setInterval(() => {
debugTimers.current.CheckUnsavedDocs = Date.now()
const unsavedDocs = new Map()
const docs = openDocs.unsavedDocs()
for (const doc of docs) {
const oldestOpCreatedAt =
doc.getInflightOpCreatedAt() ?? doc.getPendingOpCreatedAt()
if (oldestOpCreatedAt) {
const unsavedSeconds = Math.floor(
(performance.now() - oldestOpCreatedAt) / 1000
)
unsavedDocs.set(doc.doc_id, unsavedSeconds)
}
}
// avoid setting the unsavedDocs state to a new empty Map every second
if (unsavedDocs.size > 0 || previousUnsavedDocsRef.current.size > 0) {
previousUnsavedDocsRef.current = unsavedDocs
setUnsavedDocs(unsavedDocs)
}
}, 1000)
return () => {
window.clearInterval(interval)
}
}, [openDocs, debugTimers])
const maxUnsavedSeconds = Math.max(0, ...unsavedDocs.values())
// lock the editor if at least one doc has been unsaved for too long
useEffect(() => {
setIsLocked(maxUnsavedSeconds > MAX_UNSAVED_SECONDS)
}, [maxUnsavedSeconds])
// display a modal and set the permissions level to readOnly if docs have been unsaved for too long
const originalPermissionsLevelRef = useRef<PermissionsLevel | null>(null)
useEffect(() => {
if (isLocked) {
originalPermissionsLevelRef.current = permissionsLevelRef.current
// TODO: what if the real permissions level changes in the meantime?
// TODO: perhaps the "locked" state should be stored in the editor context instead?
setPermissionsLevel('readOnly')
setIsLocked(true)
} else {
if (originalPermissionsLevelRef.current) {
setPermissionsLevel(originalPermissionsLevelRef.current)
}
}
}, [isLocked, setPermissionsLevel])
// remove the modal (and unlock the page) if the connection has been re-established and all the docs have been saved
useEffect(() => {
if (unsavedDocs.size === 0 && permissionsLevelRef.current === 'readOnly') {
setIsLocked(false)
}
}, [unsavedDocs])
if (!globalAlertsContainer) {
return null
}
return (
<>
{isLocked &&
createPortal(<UnsavedDocsLockedAlert />, globalAlertsContainer)}
{unsavedDocs.size > 0 &&
createPortal(
<UnsavedDocsAlert unsavedDocs={unsavedDocs} />,
globalAlertsContainer
)}
</>
)
}