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