first commit
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
import { createContext, FC, useContext, useMemo } from 'react'
|
||||
import { CompileContext, useLocalCompileContext } from './local-compile-context'
|
||||
import useDetachStateWatcher from '../hooks/use-detach-state-watcher'
|
||||
import useDetachAction from '../hooks/use-detach-action'
|
||||
import useCompileTriggers from '../../features/pdf-preview/hooks/use-compile-triggers'
|
||||
import { useLogEvents } from '@/features/pdf-preview/hooks/use-log-events'
|
||||
|
||||
export const DetachCompileContext = createContext<CompileContext | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
export const DetachCompileProvider: FC = ({ children }) => {
|
||||
const localCompileContext = useLocalCompileContext()
|
||||
if (!localCompileContext) {
|
||||
throw new Error(
|
||||
'DetachCompileProvider is only available inside LocalCompileProvider'
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
animateCompileDropdownArrow: _animateCompileDropdownArrow,
|
||||
autoCompile: _autoCompile,
|
||||
clearingCache: _clearingCache,
|
||||
clsiServerId: _clsiServerId,
|
||||
codeCheckFailed: _codeCheckFailed,
|
||||
compiling: _compiling,
|
||||
deliveryLatencies: _deliveryLatencies,
|
||||
draft: _draft,
|
||||
editedSinceCompileStarted: _editedSinceCompileStarted,
|
||||
error: _error,
|
||||
fileList: _fileList,
|
||||
hasChanges: _hasChanges,
|
||||
hasShortCompileTimeout: _hasShortCompileTimeout,
|
||||
highlights: _highlights,
|
||||
isProjectOwner: _isProjectOwner,
|
||||
lastCompileOptions: _lastCompileOptions,
|
||||
logEntries: _logEntries,
|
||||
logEntryAnnotations: _logEntryAnnotations,
|
||||
pdfFile: _pdfFile,
|
||||
pdfViewer: _pdfViewer,
|
||||
position: _position,
|
||||
rawLog: _rawLog,
|
||||
setAnimateCompileDropdownArrow: _setAnimateCompileDropdownArrow,
|
||||
setAutoCompile: _setAutoCompile,
|
||||
setDraft: _setDraft,
|
||||
setError: _setError,
|
||||
setHasLintingError: _setHasLintingError,
|
||||
setHighlights: _setHighlights,
|
||||
setPosition: _setPosition,
|
||||
setShowCompileTimeWarning: _setShowCompileTimeWarning,
|
||||
setShowLogs: _setShowLogs,
|
||||
toggleLogs: _toggleLogs,
|
||||
setStopOnFirstError: _setStopOnFirstError,
|
||||
setStopOnValidationError: _setStopOnValidationError,
|
||||
showLogs: _showLogs,
|
||||
showCompileTimeWarning: _showCompileTimeWarning,
|
||||
stopOnFirstError: _stopOnFirstError,
|
||||
stopOnValidationError: _stopOnValidationError,
|
||||
stoppedOnFirstError: _stoppedOnFirstError,
|
||||
uncompiled: _uncompiled,
|
||||
validationIssues: _validationIssues,
|
||||
firstRenderDone: _firstRenderDone,
|
||||
cleanupCompileResult: _cleanupCompileResult,
|
||||
recompileFromScratch: _recompileFromScratch,
|
||||
setCompiling: _setCompiling,
|
||||
startCompile: _startCompile,
|
||||
stopCompile: _stopCompile,
|
||||
setChangedAt: _setChangedAt,
|
||||
clearCache: _clearCache,
|
||||
syncToEntry: _syncToEntry,
|
||||
recordAction: _recordAction,
|
||||
} = localCompileContext
|
||||
|
||||
const [animateCompileDropdownArrow] = useDetachStateWatcher(
|
||||
'animateCompileDropdownArrow',
|
||||
_animateCompileDropdownArrow,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [autoCompile] = useDetachStateWatcher(
|
||||
'autoCompile',
|
||||
_autoCompile,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [clearingCache] = useDetachStateWatcher(
|
||||
'clearingCache',
|
||||
_clearingCache,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [clsiServerId] = useDetachStateWatcher(
|
||||
'clsiServerId',
|
||||
_clsiServerId,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [codeCheckFailed] = useDetachStateWatcher(
|
||||
'codeCheckFailed',
|
||||
_codeCheckFailed,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [compiling] = useDetachStateWatcher(
|
||||
'compiling',
|
||||
_compiling,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [deliveryLatencies] = useDetachStateWatcher(
|
||||
'deliveryLatencies',
|
||||
_deliveryLatencies,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [draft] = useDetachStateWatcher('draft', _draft, 'detacher', 'detached')
|
||||
const [error] = useDetachStateWatcher('error', _error, 'detacher', 'detached')
|
||||
const [fileList] = useDetachStateWatcher(
|
||||
'fileList',
|
||||
_fileList,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [hasChanges] = useDetachStateWatcher(
|
||||
'hasChanges',
|
||||
_hasChanges,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [hasShortCompileTimeout] = useDetachStateWatcher(
|
||||
'hasShortCompileTimeout',
|
||||
_hasShortCompileTimeout,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [highlights] = useDetachStateWatcher(
|
||||
'highlights',
|
||||
_highlights,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [isProjectOwner] = useDetachStateWatcher(
|
||||
'isProjectOwner',
|
||||
_isProjectOwner,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [lastCompileOptions] = useDetachStateWatcher(
|
||||
'lastCompileOptions',
|
||||
_lastCompileOptions,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [logEntries] = useDetachStateWatcher(
|
||||
'logEntries',
|
||||
_logEntries,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [logEntryAnnotations] = useDetachStateWatcher(
|
||||
'logEntryAnnotations',
|
||||
_logEntryAnnotations,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [pdfFile] = useDetachStateWatcher(
|
||||
'pdfFile',
|
||||
_pdfFile,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [pdfViewer] = useDetachStateWatcher(
|
||||
'pdfViewer',
|
||||
_pdfViewer,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [position] = useDetachStateWatcher(
|
||||
'position',
|
||||
_position,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [rawLog] = useDetachStateWatcher(
|
||||
'rawLog',
|
||||
_rawLog,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [showCompileTimeWarning] = useDetachStateWatcher(
|
||||
'showCompileTimeWarning',
|
||||
_showCompileTimeWarning,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [showLogs] = useDetachStateWatcher(
|
||||
'showLogs',
|
||||
_showLogs,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [stopOnFirstError] = useDetachStateWatcher(
|
||||
'stopOnFirstError',
|
||||
_stopOnFirstError,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [stopOnValidationError] = useDetachStateWatcher(
|
||||
'stopOnValidationError',
|
||||
_stopOnValidationError,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [stoppedOnFirstError] = useDetachStateWatcher(
|
||||
'stoppedOnFirstError',
|
||||
_stoppedOnFirstError,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [uncompiled] = useDetachStateWatcher(
|
||||
'uncompiled',
|
||||
_uncompiled,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [editedSinceCompileStarted] = useDetachStateWatcher(
|
||||
'editedSinceCompileStarted',
|
||||
_editedSinceCompileStarted,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const [validationIssues] = useDetachStateWatcher(
|
||||
'validationIssues',
|
||||
_validationIssues,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
|
||||
const setAnimateCompileDropdownArrow = useDetachAction(
|
||||
'setAnimateCompileDropdownArrow',
|
||||
_setAnimateCompileDropdownArrow,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const setAutoCompile = useDetachAction(
|
||||
'setAutoCompile',
|
||||
_setAutoCompile,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const setDraft = useDetachAction(
|
||||
'setDraft',
|
||||
_setDraft,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const setError = useDetachAction(
|
||||
'setError',
|
||||
_setError,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const setPosition = useDetachAction(
|
||||
'setPosition',
|
||||
_setPosition,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const firstRenderDone = useDetachAction(
|
||||
'firstRenderDone',
|
||||
_firstRenderDone,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const setHasLintingError = useDetachAction(
|
||||
'setHasLintingError',
|
||||
_setHasLintingError,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const setHighlights = useDetachAction(
|
||||
'setHighlights',
|
||||
_setHighlights,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const setShowCompileTimeWarning = useDetachAction(
|
||||
'setShowCompileTimeWarning',
|
||||
_setShowCompileTimeWarning,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const setShowLogs = useDetachAction(
|
||||
'setShowLogs',
|
||||
_setShowLogs,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const toggleLogs = useDetachAction(
|
||||
'toggleLogs',
|
||||
_toggleLogs,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const setStopOnFirstError = useDetachAction(
|
||||
'setStopOnFirstError',
|
||||
_setStopOnFirstError,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const setStopOnValidationError = useDetachAction(
|
||||
'setStopOnValidationError',
|
||||
_setStopOnValidationError,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const cleanupCompileResult = useDetachAction(
|
||||
'cleanupCompileResult',
|
||||
_cleanupCompileResult,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const recompileFromScratch = useDetachAction(
|
||||
'recompileFromScratch',
|
||||
_recompileFromScratch,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const setCompiling = useDetachAction(
|
||||
'setCompiling',
|
||||
_setCompiling,
|
||||
'detacher',
|
||||
'detached'
|
||||
)
|
||||
const startCompile = useDetachAction(
|
||||
'startCompile',
|
||||
_startCompile,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const stopCompile = useDetachAction(
|
||||
'stopCompile',
|
||||
_stopCompile,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const setChangedAt = useDetachAction(
|
||||
'setChangedAt',
|
||||
_setChangedAt,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
const clearCache = useDetachAction(
|
||||
'clearCache',
|
||||
_clearCache,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
|
||||
const syncToEntry = useDetachAction(
|
||||
'sync-to-entry',
|
||||
_syncToEntry,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
|
||||
const recordAction = useDetachAction(
|
||||
'record-action',
|
||||
_recordAction,
|
||||
'detached',
|
||||
'detacher'
|
||||
)
|
||||
|
||||
useCompileTriggers(startCompile, setChangedAt)
|
||||
useLogEvents(setShowLogs)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
animateCompileDropdownArrow,
|
||||
autoCompile,
|
||||
clearCache,
|
||||
clearingCache,
|
||||
clsiServerId,
|
||||
codeCheckFailed,
|
||||
compiling,
|
||||
deliveryLatencies,
|
||||
draft,
|
||||
editedSinceCompileStarted,
|
||||
error,
|
||||
fileList,
|
||||
hasChanges,
|
||||
hasShortCompileTimeout,
|
||||
highlights,
|
||||
isProjectOwner,
|
||||
lastCompileOptions,
|
||||
logEntryAnnotations,
|
||||
logEntries,
|
||||
pdfDownloadUrl: pdfFile?.pdfDownloadUrl,
|
||||
pdfFile,
|
||||
pdfUrl: pdfFile?.pdfUrl,
|
||||
pdfViewer,
|
||||
position,
|
||||
rawLog,
|
||||
recompileFromScratch,
|
||||
setAnimateCompileDropdownArrow,
|
||||
setAutoCompile,
|
||||
setCompiling,
|
||||
setDraft,
|
||||
setError,
|
||||
setHasLintingError,
|
||||
setHighlights,
|
||||
setPosition,
|
||||
setShowCompileTimeWarning,
|
||||
setShowLogs,
|
||||
toggleLogs,
|
||||
setStopOnFirstError,
|
||||
setStopOnValidationError,
|
||||
showLogs,
|
||||
showCompileTimeWarning,
|
||||
startCompile,
|
||||
stopCompile,
|
||||
stopOnFirstError,
|
||||
stopOnValidationError,
|
||||
stoppedOnFirstError,
|
||||
uncompiled,
|
||||
validationIssues,
|
||||
firstRenderDone,
|
||||
setChangedAt,
|
||||
cleanupCompileResult,
|
||||
syncToEntry,
|
||||
recordAction,
|
||||
}),
|
||||
[
|
||||
animateCompileDropdownArrow,
|
||||
autoCompile,
|
||||
clearCache,
|
||||
clearingCache,
|
||||
clsiServerId,
|
||||
codeCheckFailed,
|
||||
compiling,
|
||||
deliveryLatencies,
|
||||
draft,
|
||||
editedSinceCompileStarted,
|
||||
error,
|
||||
fileList,
|
||||
hasChanges,
|
||||
hasShortCompileTimeout,
|
||||
highlights,
|
||||
isProjectOwner,
|
||||
lastCompileOptions,
|
||||
logEntryAnnotations,
|
||||
logEntries,
|
||||
pdfFile,
|
||||
pdfViewer,
|
||||
position,
|
||||
rawLog,
|
||||
recompileFromScratch,
|
||||
setAnimateCompileDropdownArrow,
|
||||
setAutoCompile,
|
||||
setCompiling,
|
||||
setDraft,
|
||||
setError,
|
||||
setHasLintingError,
|
||||
setHighlights,
|
||||
setPosition,
|
||||
setShowCompileTimeWarning,
|
||||
setShowLogs,
|
||||
toggleLogs,
|
||||
setStopOnFirstError,
|
||||
setStopOnValidationError,
|
||||
showCompileTimeWarning,
|
||||
showLogs,
|
||||
startCompile,
|
||||
stopCompile,
|
||||
stopOnFirstError,
|
||||
stopOnValidationError,
|
||||
stoppedOnFirstError,
|
||||
uncompiled,
|
||||
validationIssues,
|
||||
firstRenderDone,
|
||||
setChangedAt,
|
||||
cleanupCompileResult,
|
||||
syncToEntry,
|
||||
recordAction,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<DetachCompileContext.Provider value={value}>
|
||||
{children}
|
||||
</DetachCompileContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDetachCompileContext() {
|
||||
const context = useContext(DetachCompileContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useDetachCompileContext is ony available inside DetachCompileProvider'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
155
services/web/frontend/js/shared/context/detach-context.tsx
Normal file
155
services/web/frontend/js/shared/context/detach-context.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useState,
|
||||
FC,
|
||||
} from 'react'
|
||||
import getMeta from '../../utils/meta'
|
||||
import { buildUrlWithDetachRole } from '../utils/url-helper'
|
||||
import useCallbackHandlers from '../hooks/use-callback-handlers'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
export type DetachRole = 'detacher' | 'detached' | null
|
||||
|
||||
type Message = {
|
||||
role: DetachRole
|
||||
event: string
|
||||
data?: any
|
||||
}
|
||||
|
||||
export const DetachContext = createContext<
|
||||
| {
|
||||
role: DetachRole
|
||||
setRole: (role: DetachRole) => void
|
||||
broadcastEvent: (event: string, data?: any) => void
|
||||
addEventHandler: (handler: (...args: any[]) => void) => void
|
||||
deleteEventHandler: (handler: (...args: any[]) => void) => void
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
const debugPdfDetach = getMeta('ol-debugPdfDetach')
|
||||
|
||||
const projectId = getMeta('ol-project_id')
|
||||
export const detachChannelId = `detach-${projectId}`
|
||||
export const detachChannel =
|
||||
'BroadcastChannel' in window
|
||||
? new BroadcastChannel(detachChannelId)
|
||||
: undefined
|
||||
|
||||
export const DetachProvider: FC = ({ children }) => {
|
||||
const [lastDetachedConnectedAt, setLastDetachedConnectedAt] = useState<Date>()
|
||||
const [role, setRole] = useState(() => getMeta('ol-detachRole') || null)
|
||||
const {
|
||||
addHandler: addEventHandler,
|
||||
deleteHandler: deleteEventHandler,
|
||||
callHandlers: callEventHandlers,
|
||||
} = useCallbackHandlers()
|
||||
|
||||
useEffect(() => {
|
||||
if (debugPdfDetach) {
|
||||
debugConsole.warn('Effect', { role })
|
||||
}
|
||||
window.history.replaceState({}, '', buildUrlWithDetachRole(role).toString())
|
||||
}, [role])
|
||||
|
||||
useEffect(() => {
|
||||
if (detachChannel) {
|
||||
const listener = (event: MessageEvent) => {
|
||||
if (debugPdfDetach) {
|
||||
debugConsole.warn(`Receiving:`, event.data)
|
||||
}
|
||||
callEventHandlers(event.data)
|
||||
}
|
||||
|
||||
detachChannel.addEventListener('message', listener)
|
||||
|
||||
return () => {
|
||||
detachChannel.removeEventListener('message', listener)
|
||||
}
|
||||
}
|
||||
}, [callEventHandlers])
|
||||
|
||||
const broadcastEvent = useCallback(
|
||||
(event: string, data?: any) => {
|
||||
if (!role) {
|
||||
if (debugPdfDetach) {
|
||||
debugConsole.warn('Not Broadcasting (no role)', {
|
||||
role,
|
||||
event,
|
||||
data,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (debugPdfDetach) {
|
||||
debugConsole.warn('Broadcasting', {
|
||||
role,
|
||||
event,
|
||||
data,
|
||||
})
|
||||
}
|
||||
const message: Message = { role, event }
|
||||
if (data) {
|
||||
message.data = data
|
||||
}
|
||||
|
||||
detachChannel?.postMessage(message)
|
||||
},
|
||||
[role]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
broadcastEvent('connected')
|
||||
}, [broadcastEvent])
|
||||
|
||||
useEffect(() => {
|
||||
const onBeforeUnload = () => broadcastEvent('closed')
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
}, [broadcastEvent])
|
||||
|
||||
useEffect(() => {
|
||||
const updateLastDetachedConnectedAt = (message: Message) => {
|
||||
if (message.role === 'detached' && message.event === 'connected') {
|
||||
setLastDetachedConnectedAt(new Date())
|
||||
}
|
||||
}
|
||||
addEventHandler(updateLastDetachedConnectedAt)
|
||||
return () => deleteEventHandler(updateLastDetachedConnectedAt)
|
||||
}, [addEventHandler, deleteEventHandler])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
role,
|
||||
setRole,
|
||||
broadcastEvent,
|
||||
lastDetachedConnectedAt,
|
||||
addEventHandler,
|
||||
deleteEventHandler,
|
||||
}),
|
||||
[
|
||||
role,
|
||||
setRole,
|
||||
broadcastEvent,
|
||||
lastDetachedConnectedAt,
|
||||
addEventHandler,
|
||||
deleteEventHandler,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<DetachContext.Provider value={value}>{children}</DetachContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDetachContext() {
|
||||
const data = useContext(DetachContext)
|
||||
if (!data) {
|
||||
throw new Error('useDetachContext is only available inside DetachProvider')
|
||||
}
|
||||
return data
|
||||
}
|
||||
263
services/web/frontend/js/shared/context/editor-context.tsx
Normal file
263
services/web/frontend/js/shared/context/editor-context.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import {
|
||||
createContext,
|
||||
Dispatch,
|
||||
FC,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import useScopeValue from '../hooks/use-scope-value'
|
||||
import useBrowserWindow from '../hooks/use-browser-window'
|
||||
import { useIdeContext } from './ide-context'
|
||||
import { useProjectContext } from './project-context'
|
||||
import { useDetachContext } from './detach-context'
|
||||
import getMeta from '../../utils/meta'
|
||||
import { useUserContext } from './user-context'
|
||||
import { saveProjectSettings } from '@/features/editor-left-menu/utils/api'
|
||||
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
|
||||
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||
import { WritefullAPI } from './types/writefull-instance'
|
||||
|
||||
export const EditorContext = createContext<
|
||||
| {
|
||||
cobranding?: {
|
||||
logoImgUrl: string
|
||||
brandVariationName: string
|
||||
brandVariationId: number
|
||||
brandId: number
|
||||
brandVariationHomeUrl: string
|
||||
publishGuideHtml?: string
|
||||
partner?: string
|
||||
brandedMenu?: boolean
|
||||
submitBtnHtml?: string
|
||||
}
|
||||
hasPremiumCompile?: boolean
|
||||
renameProject: (newName: string) => void
|
||||
setPermissionsLevel: (permissionsLevel: PermissionsLevel) => void
|
||||
showSymbolPalette?: boolean
|
||||
toggleSymbolPalette?: () => void
|
||||
insertSymbol?: (symbol: string) => void
|
||||
isProjectOwner: boolean
|
||||
isRestrictedTokenMember?: boolean
|
||||
isPendingEditor: boolean
|
||||
permissionsLevel: PermissionsLevel
|
||||
deactivateTutorial: (tutorial: string) => void
|
||||
inactiveTutorials: string[]
|
||||
currentPopup: string | null
|
||||
setCurrentPopup: Dispatch<SetStateAction<string | null>>
|
||||
setOutOfSync: (value: boolean) => void
|
||||
assistantUpgraded: boolean
|
||||
setAssistantUpgraded: (value: boolean) => void
|
||||
hasPremiumSuggestion: boolean
|
||||
setHasPremiumSuggestion: (value: boolean) => void
|
||||
setPremiumSuggestionResetDate: (date: Date) => void
|
||||
premiumSuggestionResetDate: Date
|
||||
writefullInstance: WritefullAPI | null
|
||||
setWritefullInstance: (instance: WritefullAPI) => void
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export const EditorProvider: FC = ({ children }) => {
|
||||
const { socket } = useIdeContext()
|
||||
const { id: userId, featureUsage } = useUserContext()
|
||||
const { role } = useDetachContext()
|
||||
const { showGenericMessageModal } = useModalsContext()
|
||||
|
||||
const { owner, features, _id: projectId, members } = useProjectContext()
|
||||
|
||||
const cobranding = useMemo(() => {
|
||||
const brandVariation = getMeta('ol-brandVariation')
|
||||
return (
|
||||
brandVariation && {
|
||||
logoImgUrl: brandVariation.logo_url,
|
||||
brandVariationName: brandVariation.name,
|
||||
brandVariationId: brandVariation.id,
|
||||
brandId: brandVariation.brand_id,
|
||||
brandVariationHomeUrl: brandVariation.home_url,
|
||||
publishGuideHtml: brandVariation.publish_guide_html,
|
||||
partner: brandVariation.partner,
|
||||
brandedMenu: brandVariation.branded_menu,
|
||||
submitBtnHtml: brandVariation.submit_button_html,
|
||||
}
|
||||
)
|
||||
}, [])
|
||||
|
||||
const [projectName, setProjectName] = useScopeValue('project.name')
|
||||
const [permissionsLevel, setPermissionsLevel] =
|
||||
useScopeValue('permissionsLevel')
|
||||
const [outOfSync, setOutOfSync] = useState(false)
|
||||
const [showSymbolPalette] = useScopeValue('editor.showSymbolPalette')
|
||||
const [toggleSymbolPalette] = useScopeValue('editor.toggleSymbolPalette')
|
||||
|
||||
const [inactiveTutorials, setInactiveTutorials] = useState(
|
||||
() => getMeta('ol-inactiveTutorials') || []
|
||||
)
|
||||
|
||||
const [currentPopup, setCurrentPopup] = useState<string | null>(null)
|
||||
const [assistantUpgraded, setAssistantUpgraded] = useState(false)
|
||||
const [hasPremiumSuggestion, setHasPremiumSuggestion] = useState<boolean>(
|
||||
() => {
|
||||
return Boolean(
|
||||
featureUsage?.aiErrorAssistant &&
|
||||
featureUsage?.aiErrorAssistant.remainingUsage > 0
|
||||
)
|
||||
}
|
||||
)
|
||||
const [premiumSuggestionResetDate, setPremiumSuggestionResetDate] =
|
||||
useState<Date>(() => {
|
||||
return featureUsage?.aiErrorAssistant?.resetDate
|
||||
? new Date(featureUsage.aiErrorAssistant.resetDate)
|
||||
: new Date()
|
||||
})
|
||||
|
||||
const isPendingEditor = useMemo(
|
||||
() =>
|
||||
members?.some(
|
||||
member =>
|
||||
member._id === userId &&
|
||||
(member.pendingEditor || member.pendingReviewer)
|
||||
),
|
||||
[members, userId]
|
||||
)
|
||||
|
||||
const deactivateTutorial = useCallback(
|
||||
tutorialKey => {
|
||||
setInactiveTutorials([...inactiveTutorials, tutorialKey])
|
||||
},
|
||||
[inactiveTutorials]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
socket.on('projectNameUpdated', setProjectName)
|
||||
return () => socket.removeListener('projectNameUpdated', setProjectName)
|
||||
}
|
||||
}, [socket, setProjectName])
|
||||
|
||||
const renameProject = useCallback(
|
||||
(newName: string) => {
|
||||
setProjectName((oldName: string) => {
|
||||
if (oldName !== newName) {
|
||||
saveProjectSettings(projectId, { name: newName }).catch(
|
||||
(response: any) => {
|
||||
setProjectName(oldName)
|
||||
const { data, status } = response
|
||||
|
||||
showGenericMessageModal(
|
||||
'Error renaming project',
|
||||
status === 400 ? data : 'Please try again in a moment'
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
return newName
|
||||
})
|
||||
},
|
||||
[setProjectName, projectId, showGenericMessageModal]
|
||||
)
|
||||
|
||||
const { setTitle } = useBrowserWindow()
|
||||
useEffect(() => {
|
||||
const parts = []
|
||||
|
||||
if (role === 'detached') {
|
||||
parts.push('[PDF]')
|
||||
}
|
||||
|
||||
if (projectName) {
|
||||
parts.push(projectName)
|
||||
parts.push('-')
|
||||
}
|
||||
|
||||
parts.push('Online LaTeX Editor')
|
||||
parts.push(getMeta('ol-ExposedSettings').appName)
|
||||
|
||||
const title = parts.join(' ')
|
||||
|
||||
setTitle(title)
|
||||
}, [projectName, setTitle, role])
|
||||
|
||||
const insertSymbol = useCallback((symbol: string) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('editor:insert-symbol', {
|
||||
detail: symbol,
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
const [writefullInstance, setWritefullInstance] =
|
||||
useState<WritefullAPI | null>(null)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
cobranding,
|
||||
hasPremiumCompile: features?.compileGroup === 'priority',
|
||||
renameProject,
|
||||
permissionsLevel: outOfSync ? 'readOnly' : permissionsLevel,
|
||||
setPermissionsLevel,
|
||||
isProjectOwner: owner?._id === userId,
|
||||
isRestrictedTokenMember: getMeta('ol-isRestrictedTokenMember'),
|
||||
isPendingEditor,
|
||||
showSymbolPalette,
|
||||
toggleSymbolPalette,
|
||||
insertSymbol,
|
||||
inactiveTutorials,
|
||||
deactivateTutorial,
|
||||
currentPopup,
|
||||
setCurrentPopup,
|
||||
setOutOfSync,
|
||||
hasPremiumSuggestion,
|
||||
setHasPremiumSuggestion,
|
||||
premiumSuggestionResetDate,
|
||||
setPremiumSuggestionResetDate,
|
||||
assistantUpgraded,
|
||||
setAssistantUpgraded,
|
||||
writefullInstance,
|
||||
setWritefullInstance,
|
||||
}),
|
||||
[
|
||||
cobranding,
|
||||
features?.compileGroup,
|
||||
owner,
|
||||
userId,
|
||||
renameProject,
|
||||
permissionsLevel,
|
||||
setPermissionsLevel,
|
||||
isPendingEditor,
|
||||
showSymbolPalette,
|
||||
toggleSymbolPalette,
|
||||
insertSymbol,
|
||||
inactiveTutorials,
|
||||
deactivateTutorial,
|
||||
currentPopup,
|
||||
setCurrentPopup,
|
||||
outOfSync,
|
||||
setOutOfSync,
|
||||
hasPremiumSuggestion,
|
||||
setHasPremiumSuggestion,
|
||||
premiumSuggestionResetDate,
|
||||
setPremiumSuggestionResetDate,
|
||||
assistantUpgraded,
|
||||
setAssistantUpgraded,
|
||||
writefullInstance,
|
||||
setWritefullInstance,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<EditorContext.Provider value={value}>{children}</EditorContext.Provider>
|
||||
)
|
||||
}
|
||||
export function useEditorContext() {
|
||||
const context = useContext(EditorContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useEditorContext is only available inside EditorProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useReducer,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
FC,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import useScopeValue from '../hooks/use-scope-value'
|
||||
import {
|
||||
renameInTree,
|
||||
deleteInTree,
|
||||
moveInTree,
|
||||
createEntityInTree,
|
||||
} from '../../features/file-tree/util/mutate-in-tree'
|
||||
import { countFiles } from '../../features/file-tree/util/count-in-tree'
|
||||
import useDeepCompareEffect from '../../shared/hooks/use-deep-compare-effect'
|
||||
import { docsInFolder } from '@/features/file-tree/util/docs-in-folder'
|
||||
import useScopeValueSetterOnly from '@/shared/hooks/use-scope-value-setter-only'
|
||||
import { Folder } from '../../../../types/folder'
|
||||
import { Project } from '../../../../types/project'
|
||||
import { MainDocument } from '../../../../types/project-settings'
|
||||
import { FindResult } from '@/features/file-tree/util/path'
|
||||
import {
|
||||
StubSnapshotUtils,
|
||||
useSnapshotContext,
|
||||
} from '@/features/ide-react/context/snapshot-context'
|
||||
import importOverleafModules from '../../../macros/import-overleaf-module.macro'
|
||||
const { buildFileTree, createFolder } =
|
||||
(importOverleafModules('snapshotUtils')[0]
|
||||
?.import as typeof StubSnapshotUtils) || StubSnapshotUtils
|
||||
|
||||
const FileTreeDataContext = createContext<
|
||||
| {
|
||||
// fileTreeData is the up-to-date representation of the files list, updated
|
||||
// by the file tree
|
||||
fileTreeData: Folder
|
||||
fileCount: { value: number; status: string; limit: number } | number
|
||||
fileTreeReadOnly: boolean
|
||||
hasFolders: boolean
|
||||
selectedEntities: FindResult[]
|
||||
setSelectedEntities: (selectedEntities: FindResult[]) => void
|
||||
dispatchRename: (id: string, name: string) => void
|
||||
dispatchMove: (id: string, target: string) => void
|
||||
dispatchDelete: (id: string) => void
|
||||
dispatchCreateFolder: (name: string, folder: any) => void
|
||||
dispatchCreateDoc: (name: string, doc: any) => void
|
||||
dispatchCreateFile: (name: string, file: any) => void
|
||||
docs?: MainDocument[]
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
enum ACTION_TYPES {
|
||||
RENAME = 'RENAME',
|
||||
RESET = 'RESET',
|
||||
DELETE = 'DELETE',
|
||||
MOVE = 'MOVE',
|
||||
CREATE = 'CREATE',
|
||||
}
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ACTION_TYPES.RESET
|
||||
fileTreeData?: Folder
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.RENAME
|
||||
id: string
|
||||
newName: string
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.DELETE
|
||||
id: string
|
||||
}
|
||||
| {
|
||||
type: ACTION_TYPES.MOVE
|
||||
entityId: string
|
||||
toFolderId: string
|
||||
}
|
||||
| {
|
||||
type: typeof ACTION_TYPES.CREATE
|
||||
parentFolderId: string
|
||||
entity: any // TODO
|
||||
}
|
||||
|
||||
function fileTreeMutableReducer(
|
||||
{ fileTreeData }: { fileTreeData: Folder },
|
||||
action: Action
|
||||
) {
|
||||
switch (action.type) {
|
||||
case ACTION_TYPES.RESET: {
|
||||
const newFileTreeData = action.fileTreeData
|
||||
|
||||
return {
|
||||
fileTreeData: newFileTreeData,
|
||||
fileCount: countFiles(newFileTreeData),
|
||||
}
|
||||
}
|
||||
|
||||
case ACTION_TYPES.RENAME: {
|
||||
const newFileTreeData = renameInTree(fileTreeData, action.id, {
|
||||
newName: action.newName,
|
||||
})
|
||||
|
||||
return {
|
||||
fileTreeData: newFileTreeData,
|
||||
fileCount: countFiles(newFileTreeData),
|
||||
}
|
||||
}
|
||||
|
||||
case ACTION_TYPES.DELETE: {
|
||||
const newFileTreeData = deleteInTree(fileTreeData, action.id)
|
||||
|
||||
return {
|
||||
fileTreeData: newFileTreeData,
|
||||
fileCount: countFiles(newFileTreeData),
|
||||
}
|
||||
}
|
||||
|
||||
case ACTION_TYPES.MOVE: {
|
||||
const newFileTreeData = moveInTree(
|
||||
fileTreeData,
|
||||
action.entityId,
|
||||
action.toFolderId
|
||||
)
|
||||
|
||||
return {
|
||||
fileTreeData: newFileTreeData,
|
||||
fileCount: countFiles(newFileTreeData),
|
||||
}
|
||||
}
|
||||
|
||||
case ACTION_TYPES.CREATE: {
|
||||
const newFileTreeData = createEntityInTree(
|
||||
fileTreeData,
|
||||
action.parentFolderId,
|
||||
action.entity
|
||||
)
|
||||
|
||||
return {
|
||||
fileTreeData: newFileTreeData,
|
||||
fileCount: countFiles(newFileTreeData),
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(
|
||||
`Unknown mutable file tree action type: ${(action as Action).type}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const initialState = (rootFolder?: Folder[]) => {
|
||||
const fileTreeData = rootFolder?.[0]
|
||||
return {
|
||||
fileTreeData,
|
||||
fileCount: countFiles(fileTreeData),
|
||||
}
|
||||
}
|
||||
|
||||
export function useFileTreeData() {
|
||||
const context = useContext(FileTreeDataContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useFileTreeData is only available inside FileTreeDataProvider'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export const FileTreeDataProvider: FC = ({ children }) => {
|
||||
const [project] = useScopeValue<Project>('project')
|
||||
const [currentDocumentId] = useScopeValue('editor.open_doc_id')
|
||||
const [, setOpenDocName] = useScopeValueSetterOnly('editor.open_doc_name')
|
||||
const [permissionsLevel] = useScopeValue('permissionsLevel')
|
||||
const { fileTreeFromHistory, snapshot, snapshotVersion } =
|
||||
useSnapshotContext()
|
||||
const fileTreeReadOnly =
|
||||
permissionsLevel === 'readOnly' || fileTreeFromHistory
|
||||
|
||||
const [rootFolder, setRootFolder] = useState(project?.rootFolder)
|
||||
|
||||
useEffect(() => {
|
||||
if (fileTreeFromHistory) return
|
||||
setRootFolder(project?.rootFolder)
|
||||
}, [project, fileTreeFromHistory])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileTreeFromHistory) return
|
||||
if (!rootFolder || rootFolder?.[0]?._id) {
|
||||
// Init or replace mongo rootFolder with stub while we load the snapshot.
|
||||
// In the future, project:joined should only fire once the snapshot is ready.
|
||||
setRootFolder([createFolder('', '')])
|
||||
}
|
||||
}, [fileTreeFromHistory, rootFolder])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileTreeFromHistory || !snapshot) return
|
||||
setRootFolder([buildFileTree(snapshot)])
|
||||
}, [fileTreeFromHistory, snapshot, snapshotVersion])
|
||||
|
||||
const [{ fileTreeData, fileCount }, dispatch] = useReducer(
|
||||
fileTreeMutableReducer,
|
||||
rootFolder,
|
||||
initialState
|
||||
)
|
||||
|
||||
const [selectedEntities, setSelectedEntities] = useState<FindResult[]>([])
|
||||
|
||||
const docs = useMemo(
|
||||
() => (fileTreeData ? docsInFolder(fileTreeData) : undefined),
|
||||
[fileTreeData]
|
||||
)
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
dispatch({
|
||||
type: ACTION_TYPES.RESET,
|
||||
fileTreeData: rootFolder?.[0],
|
||||
})
|
||||
}, [rootFolder])
|
||||
|
||||
const dispatchCreateFolder = useCallback((parentFolderId, entity) => {
|
||||
entity.type = 'folder'
|
||||
dispatch({
|
||||
type: ACTION_TYPES.CREATE,
|
||||
parentFolderId,
|
||||
entity,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const dispatchCreateDoc = useCallback(
|
||||
(parentFolderId: string, entity: any) => {
|
||||
entity.type = 'doc'
|
||||
dispatch({
|
||||
type: ACTION_TYPES.CREATE,
|
||||
parentFolderId,
|
||||
entity,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const dispatchCreateFile = useCallback(
|
||||
(parentFolderId: string, entity: any) => {
|
||||
entity.type = 'fileRef'
|
||||
dispatch({
|
||||
type: ACTION_TYPES.CREATE,
|
||||
parentFolderId,
|
||||
entity,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const dispatchRename = useCallback(
|
||||
(id: string, newName: string) => {
|
||||
dispatch({
|
||||
type: ACTION_TYPES.RENAME,
|
||||
newName,
|
||||
id,
|
||||
})
|
||||
if (id === currentDocumentId) {
|
||||
setOpenDocName(newName)
|
||||
}
|
||||
},
|
||||
[currentDocumentId, setOpenDocName]
|
||||
)
|
||||
|
||||
const dispatchDelete = useCallback((id: string) => {
|
||||
dispatch({ type: ACTION_TYPES.DELETE, id })
|
||||
}, [])
|
||||
|
||||
const dispatchMove = useCallback((entityId: string, toFolderId: string) => {
|
||||
dispatch({ type: ACTION_TYPES.MOVE, entityId, toFolderId })
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => {
|
||||
return {
|
||||
dispatchCreateDoc,
|
||||
dispatchCreateFile,
|
||||
dispatchCreateFolder,
|
||||
dispatchDelete,
|
||||
dispatchMove,
|
||||
dispatchRename,
|
||||
fileCount,
|
||||
fileTreeData,
|
||||
fileTreeReadOnly,
|
||||
hasFolders: fileTreeData?.folders.length > 0,
|
||||
selectedEntities,
|
||||
setSelectedEntities,
|
||||
docs,
|
||||
}
|
||||
}, [
|
||||
dispatchCreateDoc,
|
||||
dispatchCreateFile,
|
||||
dispatchCreateFolder,
|
||||
dispatchDelete,
|
||||
dispatchMove,
|
||||
dispatchRename,
|
||||
fileCount,
|
||||
fileTreeData,
|
||||
fileTreeReadOnly,
|
||||
selectedEntities,
|
||||
setSelectedEntities,
|
||||
docs,
|
||||
])
|
||||
|
||||
return (
|
||||
<FileTreeDataContext.Provider value={value}>
|
||||
{children}
|
||||
</FileTreeDataContext.Provider>
|
||||
)
|
||||
}
|
||||
66
services/web/frontend/js/shared/context/ide-context.tsx
Normal file
66
services/web/frontend/js/shared/context/ide-context.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createContext, FC, useContext, useEffect, useMemo } from 'react'
|
||||
import { ScopeValueStore } from '../../../../types/ide/scope-value-store'
|
||||
import { ScopeEventEmitter } from '../../../../types/ide/scope-event-emitter'
|
||||
import { Socket } from '@/features/ide-react/connection/types/socket'
|
||||
|
||||
export type Ide = {
|
||||
$scope: Record<string, any>
|
||||
socket: Socket
|
||||
}
|
||||
|
||||
type IdeContextValue = Ide & {
|
||||
scopeStore: ScopeValueStore
|
||||
scopeEventEmitter: ScopeEventEmitter
|
||||
}
|
||||
|
||||
export const IdeContext = createContext<IdeContextValue | undefined>(undefined)
|
||||
|
||||
export const IdeProvider: FC<{
|
||||
ide: Ide
|
||||
scopeStore: ScopeValueStore
|
||||
scopeEventEmitter: ScopeEventEmitter
|
||||
}> = ({ ide, scopeStore, scopeEventEmitter, children }) => {
|
||||
/**
|
||||
* Expose scopeStore via `window.overleaf.unstable.store`, so it can be accessed by external extensions.
|
||||
*
|
||||
* These properties are expected to be available:
|
||||
* - `editor.view`
|
||||
* - `project.spellcheckLanguage`
|
||||
* - `editor.open_doc_name`,
|
||||
* - `editor.open_doc_id`,
|
||||
* - `settings.theme`
|
||||
* - `settings.keybindings`
|
||||
* - `settings.fontSize`
|
||||
* - `settings.fontFamily`
|
||||
* - `settings.lineHeight`
|
||||
*/
|
||||
useEffect(() => {
|
||||
window.overleaf = {
|
||||
...window.overleaf,
|
||||
unstable: {
|
||||
...window.overleaf?.unstable,
|
||||
store: scopeStore,
|
||||
},
|
||||
}
|
||||
}, [scopeStore])
|
||||
|
||||
const value = useMemo<IdeContextValue>(() => {
|
||||
return {
|
||||
...ide,
|
||||
scopeStore,
|
||||
scopeEventEmitter,
|
||||
}
|
||||
}, [ide, scopeStore, scopeEventEmitter])
|
||||
|
||||
return <IdeContext.Provider value={value}>{children}</IdeContext.Provider>
|
||||
}
|
||||
|
||||
export function useIdeContext(): IdeContextValue {
|
||||
const context = useContext(IdeContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useIdeContext is only available inside IdeProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
295
services/web/frontend/js/shared/context/layout-context.tsx
Normal file
295
services/web/frontend/js/shared/context/layout-context.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
FC,
|
||||
useState,
|
||||
} from 'react'
|
||||
import useScopeValue from '../hooks/use-scope-value'
|
||||
import useDetachLayout from '../hooks/use-detach-layout'
|
||||
import localStorage from '../../infrastructure/local-storage'
|
||||
import getMeta from '../../utils/meta'
|
||||
import { DetachRole } from './detach-context'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { BinaryFile } from '@/features/file-view/types/binary-file'
|
||||
import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
|
||||
import useEventListener from '@/shared/hooks/use-event-listener'
|
||||
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
|
||||
import { isMac } from '@/shared/utils/os'
|
||||
import { sendSearchEvent } from '@/features/event-tracking/search-events'
|
||||
import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
|
||||
|
||||
export type IdeLayout = 'sideBySide' | 'flat'
|
||||
export type IdeView = 'editor' | 'file' | 'pdf' | 'history'
|
||||
|
||||
export type LayoutContextValue = {
|
||||
reattach: () => void
|
||||
detach: () => void
|
||||
detachIsLinked: boolean
|
||||
detachRole: DetachRole
|
||||
changeLayout: (newLayout: IdeLayout, newView?: IdeView) => void
|
||||
view: IdeView | null
|
||||
setView: (view: IdeView | null) => void
|
||||
chatIsOpen: boolean
|
||||
setChatIsOpen: Dispatch<SetStateAction<LayoutContextValue['chatIsOpen']>>
|
||||
reviewPanelOpen: boolean
|
||||
setReviewPanelOpen: Dispatch<
|
||||
SetStateAction<LayoutContextValue['reviewPanelOpen']>
|
||||
>
|
||||
miniReviewPanelVisible: boolean
|
||||
setMiniReviewPanelVisible: Dispatch<
|
||||
SetStateAction<LayoutContextValue['miniReviewPanelVisible']>
|
||||
>
|
||||
leftMenuShown: boolean
|
||||
setLeftMenuShown: Dispatch<
|
||||
SetStateAction<LayoutContextValue['leftMenuShown']>
|
||||
>
|
||||
loadingStyleSheet: boolean
|
||||
setLoadingStyleSheet: Dispatch<
|
||||
SetStateAction<LayoutContextValue['loadingStyleSheet']>
|
||||
>
|
||||
pdfLayout: IdeLayout
|
||||
pdfPreviewOpen: boolean
|
||||
projectSearchIsOpen: boolean
|
||||
setProjectSearchIsOpen: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
const debugPdfDetach = getMeta('ol-debugPdfDetach')
|
||||
|
||||
export const LayoutContext = createContext<LayoutContextValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
function setLayoutInLocalStorage(pdfLayout: IdeLayout) {
|
||||
localStorage.setItem(
|
||||
'pdf.layout',
|
||||
pdfLayout === 'sideBySide' ? 'split' : 'flat'
|
||||
)
|
||||
}
|
||||
|
||||
export const LayoutProvider: FC = ({ children }) => {
|
||||
// what to show in the "flat" view (editor or pdf)
|
||||
const [view, _setView] = useScopeValue<IdeView | null>('ui.view')
|
||||
const [openFile] = useScopeValue<BinaryFile | null>('openFile')
|
||||
const historyToggleEmitter = useScopeEventEmitter('history:toggle', true)
|
||||
const { isOpen: railIsOpen, setIsOpen: setRailIsOpen } = useRailContext()
|
||||
const [prevRailIsOpen, setPrevRailIsOpen] = useState(railIsOpen)
|
||||
|
||||
const setView = useCallback(
|
||||
(value: IdeView | null) => {
|
||||
_setView(oldValue => {
|
||||
// ensure that the "history:toggle" event is broadcast when switching in or out of history view
|
||||
if (value === 'history' || oldValue === 'history') {
|
||||
historyToggleEmitter()
|
||||
}
|
||||
|
||||
if (value === 'history') {
|
||||
setPrevRailIsOpen(railIsOpen)
|
||||
setRailIsOpen(true)
|
||||
}
|
||||
|
||||
if (oldValue === 'history') {
|
||||
setRailIsOpen(prevRailIsOpen)
|
||||
}
|
||||
|
||||
if (value === 'editor' && openFile) {
|
||||
// if a file is currently opened, ensure the view is 'file' instead of
|
||||
// 'editor' when the 'editor' view is requested. This is to ensure
|
||||
// that the entity selected in the file tree is the one visible and
|
||||
// that docs don't take precedence over files.
|
||||
return 'file'
|
||||
}
|
||||
|
||||
return value
|
||||
})
|
||||
},
|
||||
[
|
||||
_setView,
|
||||
setRailIsOpen,
|
||||
openFile,
|
||||
historyToggleEmitter,
|
||||
prevRailIsOpen,
|
||||
setPrevRailIsOpen,
|
||||
railIsOpen,
|
||||
]
|
||||
)
|
||||
|
||||
// whether the chat pane is open
|
||||
const [chatIsOpen, setChatIsOpen] = useScopeValue<boolean>('ui.chatOpen')
|
||||
|
||||
// whether the review pane is open
|
||||
const [reviewPanelOpen, setReviewPanelOpen] =
|
||||
useScopeValue<boolean>('ui.reviewPanelOpen')
|
||||
|
||||
// whether the review pane is collapsed
|
||||
const [miniReviewPanelVisible, setMiniReviewPanelVisible] =
|
||||
useScopeValue<boolean>('ui.miniReviewPanelVisible')
|
||||
|
||||
// whether the menu pane is open
|
||||
const [leftMenuShown, setLeftMenuShown] =
|
||||
useScopeValue<boolean>('ui.leftMenuShown')
|
||||
|
||||
// whether the project search is open
|
||||
const [projectSearchIsOpen, setProjectSearchIsOpen] = useState(false)
|
||||
|
||||
useEventListener(
|
||||
'ui.toggle-left-menu',
|
||||
useCallback(
|
||||
event => {
|
||||
setLeftMenuShown((event as CustomEvent<boolean>).detail)
|
||||
},
|
||||
[setLeftMenuShown]
|
||||
)
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
'ui.toggle-review-panel',
|
||||
useCallback(() => {
|
||||
setReviewPanelOpen(open => !open)
|
||||
}, [setReviewPanelOpen])
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
'keydown',
|
||||
useCallback((event: KeyboardEvent) => {
|
||||
if (
|
||||
(isMac ? event.metaKey : event.ctrlKey) &&
|
||||
event.shiftKey &&
|
||||
event.code === 'KeyF'
|
||||
) {
|
||||
if (isSplitTestEnabled('full-project-search')) {
|
||||
event.preventDefault()
|
||||
sendSearchEvent('search-open', {
|
||||
searchType: 'full-project',
|
||||
method: 'keyboard',
|
||||
})
|
||||
setProjectSearchIsOpen(true)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
)
|
||||
|
||||
// whether to display the editor and preview side-by-side or full-width ("flat")
|
||||
const [pdfLayout, setPdfLayout] = useScopeValue<IdeLayout>('ui.pdfLayout')
|
||||
|
||||
// whether stylesheet on theme is loading
|
||||
const [loadingStyleSheet, setLoadingStyleSheet] = useState(false)
|
||||
|
||||
const changeLayout = useCallback(
|
||||
(newLayout: IdeLayout, newView: IdeView = 'editor') => {
|
||||
setPdfLayout(newLayout)
|
||||
setView(newLayout === 'sideBySide' ? 'editor' : newView)
|
||||
setLayoutInLocalStorage(newLayout)
|
||||
},
|
||||
[setPdfLayout, setView]
|
||||
)
|
||||
|
||||
const {
|
||||
reattach,
|
||||
detach,
|
||||
isLinking: detachIsLinking,
|
||||
isLinked: detachIsLinked,
|
||||
role: detachRole,
|
||||
isRedundant: detachIsRedundant,
|
||||
} = useDetachLayout()
|
||||
|
||||
const pdfPreviewOpen =
|
||||
pdfLayout === 'sideBySide' || view === 'pdf' || detachRole === 'detacher'
|
||||
|
||||
useEffect(() => {
|
||||
if (debugPdfDetach) {
|
||||
debugConsole.warn('Layout Effect', {
|
||||
detachIsRedundant,
|
||||
detachRole,
|
||||
detachIsLinking,
|
||||
detachIsLinked,
|
||||
})
|
||||
}
|
||||
|
||||
if (detachRole !== 'detacher') return // not in a PDF detacher layout
|
||||
|
||||
if (detachIsRedundant) {
|
||||
changeLayout('sideBySide')
|
||||
return
|
||||
}
|
||||
|
||||
if (detachIsLinking || detachIsLinked) {
|
||||
// the tab is linked to a detached tab (or about to be linked); show
|
||||
// editor only
|
||||
changeLayout('flat', 'editor')
|
||||
}
|
||||
}, [
|
||||
detachIsRedundant,
|
||||
detachRole,
|
||||
detachIsLinking,
|
||||
detachIsLinked,
|
||||
changeLayout,
|
||||
])
|
||||
|
||||
const value = useMemo<LayoutContextValue>(
|
||||
() => ({
|
||||
reattach,
|
||||
detach,
|
||||
detachIsLinked,
|
||||
detachRole,
|
||||
changeLayout,
|
||||
chatIsOpen,
|
||||
leftMenuShown,
|
||||
pdfLayout,
|
||||
pdfPreviewOpen,
|
||||
projectSearchIsOpen,
|
||||
setProjectSearchIsOpen,
|
||||
reviewPanelOpen,
|
||||
miniReviewPanelVisible,
|
||||
loadingStyleSheet,
|
||||
setChatIsOpen,
|
||||
setLeftMenuShown,
|
||||
setPdfLayout,
|
||||
setReviewPanelOpen,
|
||||
setMiniReviewPanelVisible,
|
||||
setLoadingStyleSheet,
|
||||
setView,
|
||||
view,
|
||||
}),
|
||||
[
|
||||
reattach,
|
||||
detach,
|
||||
detachIsLinked,
|
||||
detachRole,
|
||||
changeLayout,
|
||||
chatIsOpen,
|
||||
leftMenuShown,
|
||||
pdfLayout,
|
||||
pdfPreviewOpen,
|
||||
projectSearchIsOpen,
|
||||
setProjectSearchIsOpen,
|
||||
reviewPanelOpen,
|
||||
miniReviewPanelVisible,
|
||||
loadingStyleSheet,
|
||||
setChatIsOpen,
|
||||
setLeftMenuShown,
|
||||
setPdfLayout,
|
||||
setReviewPanelOpen,
|
||||
setMiniReviewPanelVisible,
|
||||
setLoadingStyleSheet,
|
||||
setView,
|
||||
view,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLayoutContext() {
|
||||
const context = useContext(LayoutContext)
|
||||
if (!context) {
|
||||
throw new Error('useLayoutContext is only available inside LayoutProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
import {
|
||||
FC,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react'
|
||||
import useScopeValue from '../hooks/use-scope-value'
|
||||
import useScopeValueSetterOnly from '../hooks/use-scope-value-setter-only'
|
||||
import usePersistedState from '../hooks/use-persisted-state'
|
||||
import useAbortController from '../hooks/use-abort-controller'
|
||||
import DocumentCompiler from '../../features/pdf-preview/util/compiler'
|
||||
import {
|
||||
send,
|
||||
sendMB,
|
||||
sendMBOnce,
|
||||
sendMBSampled,
|
||||
} from '../../infrastructure/event-tracking'
|
||||
import {
|
||||
buildLogEntryAnnotations,
|
||||
buildRuleCounts,
|
||||
buildRuleDeltas,
|
||||
handleLogFiles,
|
||||
handleOutputFiles,
|
||||
} from '../../features/pdf-preview/util/output-files'
|
||||
import { useProjectContext } from './project-context'
|
||||
import { useEditorContext } from './editor-context'
|
||||
import { buildFileList } from '../../features/pdf-preview/util/file-list'
|
||||
import { useLayoutContext } from './layout-context'
|
||||
import { useUserContext } from './user-context'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import { useDetachContext } from '@/shared/context/detach-context'
|
||||
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
|
||||
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
import { getJSON } from '@/infrastructure/fetch-json'
|
||||
import { CompileResponseData } from '../../../../types/compile'
|
||||
import {
|
||||
PdfScrollPosition,
|
||||
usePdfScrollPosition,
|
||||
} from '@/shared/hooks/use-pdf-scroll-position'
|
||||
import { PdfFileDataList } from '@/features/pdf-preview/util/types'
|
||||
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
|
||||
import { captureException } from '@/infrastructure/error-reporter'
|
||||
import OError from '@overleaf/o-error'
|
||||
|
||||
type PdfFile = Record<string, any>
|
||||
|
||||
export type CompileContext = {
|
||||
autoCompile: boolean
|
||||
clearingCache: boolean
|
||||
clsiServerId?: string
|
||||
codeCheckFailed: boolean
|
||||
compiling: boolean
|
||||
deliveryLatencies: Record<string, any>
|
||||
draft: boolean
|
||||
error?: string
|
||||
fileList?: PdfFileDataList
|
||||
hasChanges: boolean
|
||||
hasShortCompileTimeout: boolean
|
||||
highlights?: Record<string, any>[]
|
||||
isProjectOwner: boolean
|
||||
logEntries?: Record<string, any>
|
||||
logEntryAnnotations?: Record<string, any>
|
||||
outputFilesArchive?: string
|
||||
pdfDownloadUrl?: string
|
||||
pdfFile?: PdfFile
|
||||
pdfUrl?: string
|
||||
pdfViewer?: string
|
||||
position?: PdfScrollPosition
|
||||
rawLog?: string
|
||||
setAutoCompile: (value: boolean) => void
|
||||
setDraft: (value: any) => void
|
||||
setError: (value: any) => void
|
||||
setHasLintingError: (value: any) => void // only for storybook
|
||||
setHighlights: (value: any) => void
|
||||
setPosition: Dispatch<SetStateAction<PdfScrollPosition>>
|
||||
setShowCompileTimeWarning: (value: any) => void
|
||||
setShowLogs: (value: boolean) => void
|
||||
toggleLogs: () => void
|
||||
setStopOnFirstError: (value: boolean) => void
|
||||
setStopOnValidationError: (value: boolean) => void
|
||||
showCompileTimeWarning: boolean
|
||||
showLogs: boolean
|
||||
stopOnFirstError: boolean
|
||||
stopOnValidationError: boolean
|
||||
stoppedOnFirstError: boolean
|
||||
uncompiled?: boolean
|
||||
validationIssues?: Record<string, any>
|
||||
firstRenderDone: (metrics: {
|
||||
latencyFetch: number
|
||||
latencyRender: number | undefined
|
||||
pdfCachingMetrics: { viewerId: string }
|
||||
}) => void
|
||||
cleanupCompileResult?: () => void
|
||||
animateCompileDropdownArrow: boolean
|
||||
editedSinceCompileStarted: boolean
|
||||
lastCompileOptions: any
|
||||
setAnimateCompileDropdownArrow: (value: boolean) => void
|
||||
recompileFromScratch: () => void
|
||||
setCompiling: (value: boolean) => void
|
||||
startCompile: (options?: any) => void
|
||||
stopCompile: () => void
|
||||
setChangedAt: (value: any) => void
|
||||
clearCache: () => void
|
||||
syncToEntry: (value: any, keepCurrentView?: boolean) => void
|
||||
recordAction: (action: string) => void
|
||||
}
|
||||
|
||||
export const LocalCompileContext = createContext<CompileContext | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
export const LocalCompileProvider: FC = ({ children }) => {
|
||||
const { hasPremiumCompile, isProjectOwner } = useEditorContext()
|
||||
const { openDocWithId, openDocs, currentDocument } = useEditorManagerContext()
|
||||
const { role } = useDetachContext()
|
||||
|
||||
const {
|
||||
_id: projectId,
|
||||
rootDocId,
|
||||
joinedOnce,
|
||||
imageName,
|
||||
compiler: compilerName,
|
||||
} = useProjectContext()
|
||||
|
||||
const { pdfPreviewOpen } = useLayoutContext()
|
||||
|
||||
const { features, alphaProgram, labsProgram } = useUserContext()
|
||||
|
||||
const { fileTreeData } = useFileTreeData()
|
||||
const { findEntityByPath } = useFileTreePathContext()
|
||||
|
||||
// whether a compile is in progress
|
||||
const [compiling, setCompiling] = useState(false)
|
||||
|
||||
// whether to show the compile time warning
|
||||
const [showCompileTimeWarning, setShowCompileTimeWarning] = useState(false)
|
||||
|
||||
const [hasShortCompileTimeout, setHasShortCompileTimeout] = useState(false)
|
||||
|
||||
// the log entries parsed from the compile output log
|
||||
const [logEntries, setLogEntries] = useScopeValueSetterOnly('pdf.logEntries')
|
||||
|
||||
// annotations for display in the editor, built from the log entries
|
||||
const [logEntryAnnotations, setLogEntryAnnotations] = useScopeValue(
|
||||
'pdf.logEntryAnnotations'
|
||||
)
|
||||
|
||||
// the PDF viewer and whether syntax validation is enabled globally
|
||||
const { userSettings } = useUserSettingsContext()
|
||||
const { pdfViewer, syntaxValidation } = userSettings
|
||||
|
||||
// the URL for downloading the PDF
|
||||
const [, setPdfDownloadUrl] =
|
||||
useScopeValueSetterOnly<string>('pdf.downloadUrl')
|
||||
|
||||
// the URL for loading the PDF in the preview pane
|
||||
const [, setPdfUrl] = useScopeValueSetterOnly<string>('pdf.url')
|
||||
|
||||
// low level details for metrics
|
||||
const [pdfFile, setPdfFile] = useState<PdfFile | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
setPdfDownloadUrl(pdfFile?.pdfDownloadUrl)
|
||||
setPdfUrl(pdfFile?.pdfUrl)
|
||||
}, [pdfFile, setPdfDownloadUrl, setPdfUrl])
|
||||
|
||||
// the project is considered to be "uncompiled" if a doc has changed, or finished saving, since the last compile started.
|
||||
const [uncompiled, setUncompiled] = useScopeValue('pdf.uncompiled')
|
||||
|
||||
// whether a doc has been edited since the last compile started
|
||||
const [editedSinceCompileStarted, setEditedSinceCompileStarted] =
|
||||
useState(false)
|
||||
|
||||
// the id of the CLSI server which ran the compile
|
||||
const [clsiServerId, setClsiServerId] = useState<string>()
|
||||
|
||||
// data received in response to a compile request
|
||||
const [data, setData] = useState<CompileResponseData>()
|
||||
|
||||
// the rootDocId used in the most recent compile request, which may not be the
|
||||
// same as the project rootDocId. This is used to calculate correct paths when
|
||||
// parsing the compile logs
|
||||
const lastCompileRootDocId = data ? (data.rootDocId ?? rootDocId) : null
|
||||
|
||||
// callback to be invoked for PdfJsMetrics
|
||||
const [firstRenderDone, setFirstRenderDone] = useState(() => () => {})
|
||||
|
||||
// latencies of compile/pdf download/rendering
|
||||
const [deliveryLatencies, setDeliveryLatencies] = useState({})
|
||||
|
||||
// whether the project has been compiled yet
|
||||
const [compiledOnce, setCompiledOnce] = useState(false)
|
||||
// fetch initial compile response from cache
|
||||
const [initialCompileFromCache, setInitialCompileFromCache] = useState(
|
||||
isSplitTestEnabled('initial-compile-from-clsi-cache') &&
|
||||
// Avoid fetching the initial compile from cache in PDF detach tab
|
||||
role !== 'detached'
|
||||
)
|
||||
// fetch of initial compile from cache is pending
|
||||
const [pendingInitialCompileFromCache, setPendingInitialCompileFromCache] =
|
||||
useState(false)
|
||||
// Raw data from clsi-cache, will need post-processing and check settings
|
||||
const [dataFromCache, setDataFromCache] = useState<CompileResponseData>()
|
||||
|
||||
// whether the cache is being cleared
|
||||
const [clearingCache, setClearingCache] = useState(false)
|
||||
|
||||
// whether the logs should be visible
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
|
||||
// whether the compile dropdown arrow should be animated
|
||||
const [animateCompileDropdownArrow, setAnimateCompileDropdownArrow] =
|
||||
useState(false)
|
||||
|
||||
const toggleLogs = useCallback(() => {
|
||||
setShowLogs(prev => {
|
||||
if (!prev) {
|
||||
sendMBOnce('ide-open-logs-once')
|
||||
}
|
||||
return !prev
|
||||
})
|
||||
}, [setShowLogs])
|
||||
|
||||
// an error that occurred
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
// the list of files that can be downloaded
|
||||
const [fileList, setFileList] = useState<PdfFileDataList>()
|
||||
|
||||
// the raw contents of the log file
|
||||
const [rawLog, setRawLog] = useState<string>()
|
||||
|
||||
// validation issues from CLSI
|
||||
const [validationIssues, setValidationIssues] = useState()
|
||||
|
||||
// areas to highlight on the PDF, from synctex
|
||||
const [highlights, setHighlights] = useState()
|
||||
|
||||
const [position, setPosition] = usePdfScrollPosition(lastCompileRootDocId)
|
||||
|
||||
// whether autocompile is switched on
|
||||
const [autoCompile, setAutoCompile] = usePersistedState(
|
||||
`autocompile_enabled:${projectId}`,
|
||||
false,
|
||||
true
|
||||
)
|
||||
|
||||
// whether the compile should run in draft mode
|
||||
const [draft, setDraft] = usePersistedState(`draft:${projectId}`, false, true)
|
||||
|
||||
// whether compiling should stop on first error
|
||||
const [stopOnFirstError, setStopOnFirstError] = usePersistedState(
|
||||
`stop_on_first_error:${projectId}`,
|
||||
false,
|
||||
true
|
||||
)
|
||||
|
||||
// whether the last compiles stopped on first error
|
||||
const [stoppedOnFirstError, setStoppedOnFirstError] = useState(false)
|
||||
|
||||
// whether compiling should be prevented if there are linting errors
|
||||
const [stopOnValidationError, setStopOnValidationError] = usePersistedState(
|
||||
`stop_on_validation_error:${projectId}`,
|
||||
true,
|
||||
true
|
||||
)
|
||||
|
||||
// whether the editor linter found errors
|
||||
const [hasLintingError, setHasLintingError] = useScopeValue('hasLintingError')
|
||||
|
||||
// the timestamp that a doc was last changed
|
||||
const [changedAt, setChangedAt] = useState(0)
|
||||
|
||||
const { signal } = useAbortController()
|
||||
|
||||
const cleanupCompileResult = useCallback(() => {
|
||||
setPdfFile(undefined)
|
||||
setLogEntries(null)
|
||||
setLogEntryAnnotations({})
|
||||
}, [setPdfFile, setLogEntries, setLogEntryAnnotations])
|
||||
|
||||
const compilingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
compilingRef.current = compiling
|
||||
}, [compiling])
|
||||
|
||||
const _buildLogEntryAnnotations = useCallback(
|
||||
entries =>
|
||||
buildLogEntryAnnotations(entries, fileTreeData, lastCompileRootDocId),
|
||||
[fileTreeData, lastCompileRootDocId]
|
||||
)
|
||||
|
||||
const buildLogEntryAnnotationsRef = useRef(_buildLogEntryAnnotations)
|
||||
|
||||
useEffect(() => {
|
||||
buildLogEntryAnnotationsRef.current = _buildLogEntryAnnotations
|
||||
}, [_buildLogEntryAnnotations])
|
||||
|
||||
// the document compiler
|
||||
const [compiler] = useState(() => {
|
||||
return new DocumentCompiler({
|
||||
projectId,
|
||||
setChangedAt,
|
||||
setCompiling,
|
||||
setData,
|
||||
setFirstRenderDone,
|
||||
setDeliveryLatencies,
|
||||
setError,
|
||||
cleanupCompileResult,
|
||||
compilingRef,
|
||||
signal,
|
||||
openDocs,
|
||||
})
|
||||
})
|
||||
|
||||
// keep currentDoc in sync with the compiler
|
||||
useEffect(() => {
|
||||
compiler.currentDoc = currentDocument
|
||||
}, [compiler, currentDocument])
|
||||
|
||||
// keep the project rootDocId in sync with the compiler
|
||||
useEffect(() => {
|
||||
compiler.projectRootDocId = rootDocId
|
||||
}, [compiler, rootDocId])
|
||||
|
||||
// keep draft setting in sync with the compiler
|
||||
useEffect(() => {
|
||||
compiler.setOption('draft', draft)
|
||||
}, [compiler, draft])
|
||||
|
||||
// keep stop on first error setting in sync with the compiler
|
||||
useEffect(() => {
|
||||
compiler.setOption('stopOnFirstError', stopOnFirstError)
|
||||
}, [compiler, stopOnFirstError])
|
||||
|
||||
useEffect(() => {
|
||||
setUncompiled(changedAt > 0)
|
||||
}, [setUncompiled, changedAt])
|
||||
|
||||
useEffect(() => {
|
||||
setEditedSinceCompileStarted(changedAt > 0)
|
||||
}, [setEditedSinceCompileStarted, changedAt])
|
||||
|
||||
// try to fetch the last compile result after opening the project, potentially before joining the project.
|
||||
useEffect(() => {
|
||||
if (initialCompileFromCache && !pendingInitialCompileFromCache) {
|
||||
setPendingInitialCompileFromCache(true)
|
||||
getJSON(`/project/${projectId}/output/cached/output.overleaf.json`)
|
||||
.then((data: any) => {
|
||||
// Hand data over to next effect, it will wait for project/doc loading.
|
||||
setDataFromCache(data)
|
||||
})
|
||||
.catch(() => {
|
||||
// Let the isAutoCompileOnLoad effect take over
|
||||
setInitialCompileFromCache(false)
|
||||
setPendingInitialCompileFromCache(false)
|
||||
})
|
||||
}
|
||||
}, [projectId, initialCompileFromCache, pendingInitialCompileFromCache])
|
||||
|
||||
// Maybe adopt the compile from cache
|
||||
useEffect(() => {
|
||||
if (!dataFromCache) return // no compile from cache available
|
||||
if (!joinedOnce) return // wait for joinProject, it populates the file-tree.
|
||||
if (!currentDocument) return // wait for current doc to load, it affects the rootDoc override
|
||||
if (compiledOnce) return // regular compile triggered
|
||||
|
||||
// Gracefully access file-tree and getRootDocOverride
|
||||
let settingsUpToDate = false
|
||||
try {
|
||||
dataFromCache.rootDocId = findEntityByPath(
|
||||
dataFromCache.options?.rootResourcePath || ''
|
||||
)?.entity?._id
|
||||
const rootDocOverride = compiler.getRootDocOverrideId() || rootDocId
|
||||
settingsUpToDate =
|
||||
rootDocOverride === dataFromCache.rootDocId &&
|
||||
dataFromCache.options.imageName === imageName &&
|
||||
dataFromCache.options.compiler === compilerName &&
|
||||
dataFromCache.options.stopOnFirstError === stopOnFirstError &&
|
||||
dataFromCache.options.draft === draft
|
||||
} catch (err) {
|
||||
captureException(
|
||||
OError.tag(err as unknown as Error, 'validate compile options', {
|
||||
options: dataFromCache.options,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (settingsUpToDate) {
|
||||
setData(dataFromCache)
|
||||
setCompiledOnce(true)
|
||||
}
|
||||
setDataFromCache(undefined)
|
||||
setInitialCompileFromCache(false)
|
||||
setPendingInitialCompileFromCache(false)
|
||||
}, [
|
||||
dataFromCache,
|
||||
joinedOnce,
|
||||
currentDocument,
|
||||
compiledOnce,
|
||||
rootDocId,
|
||||
findEntityByPath,
|
||||
compiler,
|
||||
compilerName,
|
||||
imageName,
|
||||
stopOnFirstError,
|
||||
draft,
|
||||
])
|
||||
|
||||
// always compile the PDF once after opening the project, after the doc has loaded
|
||||
useEffect(() => {
|
||||
if (
|
||||
!compiledOnce &&
|
||||
currentDocument &&
|
||||
!initialCompileFromCache &&
|
||||
!pendingInitialCompileFromCache
|
||||
) {
|
||||
setCompiledOnce(true)
|
||||
compiler.compile({ isAutoCompileOnLoad: true })
|
||||
}
|
||||
}, [
|
||||
compiledOnce,
|
||||
currentDocument,
|
||||
initialCompileFromCache,
|
||||
pendingInitialCompileFromCache,
|
||||
compiler,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
setHasShortCompileTimeout(
|
||||
features?.compileTimeout !== undefined && features.compileTimeout <= 60
|
||||
)
|
||||
}, [features])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasShortCompileTimeout && compiling && isProjectOwner) {
|
||||
const timeout = window.setTimeout(() => {
|
||||
setShowCompileTimeWarning(true)
|
||||
}, 30000)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [compiling, isProjectOwner, hasShortCompileTimeout])
|
||||
|
||||
const hasCompileLogsEvents = useFeatureFlag('compile-log-events')
|
||||
|
||||
// compare log entry counts with the previous compile, and record actions between compiles
|
||||
// these are refs rather than state so they don't trigger the effect to run
|
||||
const previousRuleCountsRef = useRef<{
|
||||
ruleCounts: Record<string, number>
|
||||
rootDocId: string
|
||||
} | null>(null)
|
||||
const recordedActionsRef = useRef<Record<string, boolean>>({})
|
||||
const recordAction = useCallback((action: string) => {
|
||||
recordedActionsRef.current[action] = true
|
||||
}, [])
|
||||
|
||||
// handle the data returned from a compile request
|
||||
// note: this should _only_ run when `data` changes,
|
||||
// the other dependencies must all be static
|
||||
useEffect(() => {
|
||||
if (!joinedOnce) return // wait for joinProject, it populates the premium flags.
|
||||
const abortController = new AbortController()
|
||||
|
||||
const recordedActions = recordedActionsRef.current
|
||||
recordedActionsRef.current = {}
|
||||
|
||||
if (data) {
|
||||
if (data.clsiServerId) {
|
||||
setClsiServerId(data.clsiServerId) // set in scope, for PdfSynctexController
|
||||
}
|
||||
|
||||
if (data.outputFiles) {
|
||||
const outputFiles = new Map()
|
||||
|
||||
for (const outputFile of data.outputFiles) {
|
||||
// Use a shadow-copy, we will update it in place and append to .url.
|
||||
outputFiles.set(outputFile.path, { ...outputFile })
|
||||
}
|
||||
|
||||
// set the PDF context
|
||||
if (data.status === 'success') {
|
||||
setPdfFile(handleOutputFiles(outputFiles, projectId, data))
|
||||
}
|
||||
|
||||
setFileList(buildFileList(outputFiles, data))
|
||||
|
||||
// handle log files
|
||||
// asynchronous (TODO: cancel on new compile?)
|
||||
setLogEntryAnnotations(null)
|
||||
setLogEntries(null)
|
||||
setRawLog(undefined)
|
||||
|
||||
handleLogFiles(outputFiles, data, abortController.signal).then(
|
||||
(result: Record<string, any>) => {
|
||||
setRawLog(result.log)
|
||||
setLogEntries(result.logEntries)
|
||||
setLogEntryAnnotations(
|
||||
buildLogEntryAnnotationsRef.current(result.logEntries.all)
|
||||
)
|
||||
|
||||
// sample compile stats for real users
|
||||
if (!alphaProgram) {
|
||||
if (['success', 'stopped-on-first-error'].includes(data.status)) {
|
||||
sendMBSampled(
|
||||
'compile-result',
|
||||
{
|
||||
errors: result.logEntries.errors.length,
|
||||
warnings: result.logEntries.warnings.length,
|
||||
typesetting: result.logEntries.typesetting.length,
|
||||
newPdfPreview: true, // TODO: is this useful?
|
||||
stopOnFirstError: data.options.stopOnFirstError,
|
||||
},
|
||||
0.01
|
||||
)
|
||||
}
|
||||
|
||||
if (hasCompileLogsEvents || labsProgram) {
|
||||
const ruleCounts = buildRuleCounts(
|
||||
result.logEntries.all
|
||||
) as Record<string, number>
|
||||
|
||||
const rootDocId = data.rootDocId || compiler.projectRootDocId
|
||||
|
||||
const previousRuleCounts = previousRuleCountsRef.current
|
||||
previousRuleCountsRef.current = { ruleCounts, rootDocId }
|
||||
|
||||
const ruleDeltas =
|
||||
previousRuleCounts &&
|
||||
previousRuleCounts.rootDocId === rootDocId
|
||||
? buildRuleDeltas(ruleCounts, previousRuleCounts.ruleCounts)
|
||||
: {}
|
||||
|
||||
sendMB('compile-log-entries', {
|
||||
status: data.status,
|
||||
stopOnFirstError: data.options.stopOnFirstError,
|
||||
isAutoCompileOnLoad: !!data.options.isAutoCompileOnLoad,
|
||||
isAutoCompileOnChange: !!data.options.isAutoCompileOnChange,
|
||||
rootDocId,
|
||||
...recordedActions,
|
||||
...ruleCounts,
|
||||
...ruleDeltas,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
switch (data.status) {
|
||||
case 'success':
|
||||
setError(undefined)
|
||||
setShowLogs(false)
|
||||
break
|
||||
|
||||
case 'stopped-on-first-error':
|
||||
setError(undefined)
|
||||
setShowLogs(true)
|
||||
break
|
||||
|
||||
case 'clsi-maintenance':
|
||||
case 'compile-in-progress':
|
||||
case 'exited':
|
||||
case 'failure':
|
||||
case 'project-too-large':
|
||||
case 'rate-limited':
|
||||
case 'terminated':
|
||||
case 'too-recently-compiled':
|
||||
setError(data.status)
|
||||
break
|
||||
|
||||
case 'timedout':
|
||||
setError('timedout')
|
||||
|
||||
if (!hasPremiumCompile && isProjectOwner) {
|
||||
send(
|
||||
'subscription-funnel',
|
||||
'editor-click-feature',
|
||||
'compile-timeout'
|
||||
)
|
||||
}
|
||||
break
|
||||
|
||||
case 'autocompile-backoff':
|
||||
if (!data.options.isAutoCompileOnLoad) {
|
||||
setError('autocompile-disabled')
|
||||
setAutoCompile(false)
|
||||
}
|
||||
break
|
||||
|
||||
case 'unavailable':
|
||||
setError('clsi-unavailable')
|
||||
break
|
||||
|
||||
case 'validation-problems':
|
||||
setError('validation-problems')
|
||||
setValidationIssues(data.validationProblems)
|
||||
break
|
||||
|
||||
default:
|
||||
setError('error')
|
||||
break
|
||||
}
|
||||
|
||||
setStoppedOnFirstError(data.status === 'stopped-on-first-error')
|
||||
}
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [
|
||||
joinedOnce,
|
||||
data,
|
||||
alphaProgram,
|
||||
labsProgram,
|
||||
features,
|
||||
hasCompileLogsEvents,
|
||||
hasPremiumCompile,
|
||||
isProjectOwner,
|
||||
projectId,
|
||||
setAutoCompile,
|
||||
setClsiServerId,
|
||||
setLogEntries,
|
||||
setLogEntryAnnotations,
|
||||
setPdfFile,
|
||||
compiler,
|
||||
])
|
||||
|
||||
// switch to logs if there's an error
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setShowLogs(true)
|
||||
}
|
||||
}, [error])
|
||||
|
||||
// whether there has been an autocompile linting error, if syntax validation is switched on
|
||||
const autoCompileLintingError = Boolean(
|
||||
autoCompile && syntaxValidation && hasLintingError
|
||||
)
|
||||
|
||||
const codeCheckFailed = stopOnValidationError && autoCompileLintingError
|
||||
|
||||
// the project is available for auto-compiling
|
||||
// (autocompile is enabled, the PDF preview is open, and the code check (if enabled) hasn't failed)
|
||||
const canAutoCompile = Boolean(
|
||||
autoCompile && pdfPreviewOpen && !codeCheckFailed
|
||||
)
|
||||
|
||||
// show that the project has pending changes
|
||||
const hasChanges = Boolean(canAutoCompile && uncompiled && compiledOnce)
|
||||
|
||||
// call the debounced autocompile function if the project is available for auto-compiling and it has changed
|
||||
useEffect(() => {
|
||||
if (canAutoCompile) {
|
||||
if (changedAt > 0) {
|
||||
compiler.debouncedAutoCompile()
|
||||
}
|
||||
} else {
|
||||
compiler.debouncedAutoCompile.cancel()
|
||||
}
|
||||
}, [compiler, canAutoCompile, changedAt])
|
||||
|
||||
// cancel debounced recompile on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
compiler.debouncedAutoCompile.cancel()
|
||||
}
|
||||
}, [compiler])
|
||||
|
||||
// start a compile manually
|
||||
const startCompile = useCallback(
|
||||
options => {
|
||||
setCompiledOnce(true)
|
||||
compiler.compile(options)
|
||||
},
|
||||
[compiler, setCompiledOnce]
|
||||
)
|
||||
|
||||
// stop a compile manually
|
||||
const stopCompile = useCallback(() => {
|
||||
compiler.stopCompile()
|
||||
}, [compiler])
|
||||
|
||||
// clear the compile cache
|
||||
const clearCache = useCallback(() => {
|
||||
setClearingCache(true)
|
||||
|
||||
return compiler
|
||||
.clearCache()
|
||||
.then(() => {
|
||||
setFileList(undefined)
|
||||
setPdfFile(undefined)
|
||||
})
|
||||
.finally(() => {
|
||||
setClearingCache(false)
|
||||
})
|
||||
}, [compiler])
|
||||
|
||||
const syncToEntry = useCallback(
|
||||
(entry, keepCurrentView = false) => {
|
||||
const result = findEntityByPath(entry.file)
|
||||
|
||||
if (result && result.type === 'doc') {
|
||||
openDocWithId(result.entity._id, {
|
||||
gotoLine: entry.line ?? undefined,
|
||||
gotoColumn: entry.column ?? undefined,
|
||||
keepCurrentView,
|
||||
})
|
||||
}
|
||||
},
|
||||
[findEntityByPath, openDocWithId]
|
||||
)
|
||||
|
||||
// clear the cache then run a compile, triggered by a menu item
|
||||
const recompileFromScratch = useCallback(() => {
|
||||
clearCache().then(() => {
|
||||
compiler.compile()
|
||||
})
|
||||
}, [clearCache, compiler])
|
||||
|
||||
// After a compile, the compiler sets `data.options` to the options that were
|
||||
// used for that compile.
|
||||
const lastCompileOptions = useMemo(() => data?.options || {}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: Event) => {
|
||||
setShowLogs((event as CustomEvent<boolean>).detail as boolean)
|
||||
}
|
||||
|
||||
window.addEventListener('editor:show-logs', listener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('editor:show-logs', listener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
animateCompileDropdownArrow,
|
||||
autoCompile,
|
||||
clearCache,
|
||||
clearingCache,
|
||||
clsiServerId,
|
||||
codeCheckFailed,
|
||||
compiling,
|
||||
deliveryLatencies,
|
||||
draft,
|
||||
editedSinceCompileStarted,
|
||||
error,
|
||||
fileList,
|
||||
hasChanges,
|
||||
hasShortCompileTimeout,
|
||||
highlights,
|
||||
isProjectOwner,
|
||||
lastCompileOptions,
|
||||
logEntryAnnotations,
|
||||
logEntries,
|
||||
pdfDownloadUrl: pdfFile?.pdfDownloadUrl,
|
||||
pdfFile,
|
||||
pdfUrl: pdfFile?.pdfUrl,
|
||||
pdfViewer,
|
||||
position,
|
||||
rawLog,
|
||||
recompileFromScratch,
|
||||
setAnimateCompileDropdownArrow,
|
||||
setAutoCompile,
|
||||
setCompiling,
|
||||
setDraft,
|
||||
setError,
|
||||
setHasLintingError, // only for stories
|
||||
setHighlights,
|
||||
setPosition,
|
||||
showCompileTimeWarning,
|
||||
setShowCompileTimeWarning,
|
||||
setShowLogs,
|
||||
toggleLogs,
|
||||
setStopOnFirstError,
|
||||
setStopOnValidationError,
|
||||
showLogs,
|
||||
startCompile,
|
||||
stopCompile,
|
||||
stopOnFirstError,
|
||||
stopOnValidationError,
|
||||
stoppedOnFirstError,
|
||||
uncompiled,
|
||||
validationIssues,
|
||||
firstRenderDone,
|
||||
setChangedAt,
|
||||
cleanupCompileResult,
|
||||
syncToEntry,
|
||||
recordAction,
|
||||
}),
|
||||
[
|
||||
animateCompileDropdownArrow,
|
||||
autoCompile,
|
||||
clearCache,
|
||||
clearingCache,
|
||||
clsiServerId,
|
||||
codeCheckFailed,
|
||||
compiling,
|
||||
deliveryLatencies,
|
||||
draft,
|
||||
editedSinceCompileStarted,
|
||||
error,
|
||||
fileList,
|
||||
hasChanges,
|
||||
hasShortCompileTimeout,
|
||||
highlights,
|
||||
isProjectOwner,
|
||||
lastCompileOptions,
|
||||
logEntries,
|
||||
logEntryAnnotations,
|
||||
position,
|
||||
pdfFile,
|
||||
pdfViewer,
|
||||
rawLog,
|
||||
recompileFromScratch,
|
||||
setAnimateCompileDropdownArrow,
|
||||
setAutoCompile,
|
||||
setDraft,
|
||||
setError,
|
||||
setHasLintingError, // only for stories
|
||||
setHighlights,
|
||||
setPosition,
|
||||
setShowCompileTimeWarning,
|
||||
setStopOnFirstError,
|
||||
setStopOnValidationError,
|
||||
showCompileTimeWarning,
|
||||
showLogs,
|
||||
startCompile,
|
||||
stopCompile,
|
||||
stopOnFirstError,
|
||||
stopOnValidationError,
|
||||
stoppedOnFirstError,
|
||||
uncompiled,
|
||||
validationIssues,
|
||||
firstRenderDone,
|
||||
setChangedAt,
|
||||
cleanupCompileResult,
|
||||
setShowLogs,
|
||||
toggleLogs,
|
||||
syncToEntry,
|
||||
recordAction,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<LocalCompileContext.Provider value={value}>
|
||||
{children}
|
||||
</LocalCompileContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLocalCompileContext() {
|
||||
const context = useContext(LocalCompileContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useLocalCompileContext is only available inside LocalCompileProvider'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
56
services/web/frontend/js/shared/context/mock/mock-ide.js
Normal file
56
services/web/frontend/js/shared/context/mock/mock-ide.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import getMeta from '../../../utils/meta'
|
||||
|
||||
// When rendered without Angular, ide isn't defined. In that case we use
|
||||
// a mock object that only has the required properties to pass proptypes
|
||||
// checks and the values needed for the app. In the longer term, the mock
|
||||
// object will replace ide completely.
|
||||
export const getMockIde = () => {
|
||||
return {
|
||||
_id: getMeta('ol-project_id'),
|
||||
$scope: {
|
||||
$on: () => {},
|
||||
$watch: () => {},
|
||||
$applyAsync: () => {},
|
||||
user: {},
|
||||
project: {
|
||||
_id: getMeta('ol-project_id'),
|
||||
name: getMeta('ol-projectName'),
|
||||
rootDocId: '',
|
||||
members: [],
|
||||
invites: [],
|
||||
features: {
|
||||
collaborators: 0,
|
||||
compileGroup: 'standard',
|
||||
trackChangesVisible: false,
|
||||
references: false,
|
||||
mendeley: false,
|
||||
zotero: false,
|
||||
},
|
||||
publicAccessLevel: '',
|
||||
owner: {
|
||||
_id: '',
|
||||
email: '',
|
||||
},
|
||||
},
|
||||
permissionsLevel: 'readOnly',
|
||||
editor: {
|
||||
sharejs_doc: null,
|
||||
showSymbolPalette: false,
|
||||
toggleSymbolPalette: () => {},
|
||||
},
|
||||
ui: {
|
||||
view: 'pdf',
|
||||
chatOpen: false,
|
||||
reviewPanelOpen: false,
|
||||
leftMenuShown: false,
|
||||
pdfLayout: 'flat',
|
||||
},
|
||||
pdf: {
|
||||
uncompiled: true,
|
||||
logEntryAnnotations: {},
|
||||
},
|
||||
settings: { syntaxValidation: false, pdfViewer: 'pdfjs' },
|
||||
hasLintingError: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext, Dispatch, FC, SetStateAction, useState } from 'react'
|
||||
|
||||
export type NestableDropdownContextType = {
|
||||
selected: string | null
|
||||
setSelected: Dispatch<SetStateAction<string | null>>
|
||||
menuId: string
|
||||
}
|
||||
|
||||
export const NestableDropdownContext = createContext<
|
||||
NestableDropdownContextType | undefined
|
||||
>(undefined)
|
||||
|
||||
export const NestableDropdownContextProvider: FC<{ id: string }> = ({
|
||||
id,
|
||||
children,
|
||||
}) => {
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
return (
|
||||
<NestableDropdownContext.Provider
|
||||
value={{ selected, setSelected, menuId: id }}
|
||||
>
|
||||
{children}
|
||||
</NestableDropdownContext.Provider>
|
||||
)
|
||||
}
|
||||
98
services/web/frontend/js/shared/context/project-context.tsx
Normal file
98
services/web/frontend/js/shared/context/project-context.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { FC, createContext, useContext, useMemo, useState } from 'react'
|
||||
import useScopeValue from '../hooks/use-scope-value'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { ProjectContextValue } from './types/project-context'
|
||||
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
|
||||
|
||||
const ProjectContext = createContext<ProjectContextValue | undefined>(undefined)
|
||||
|
||||
export function useProjectContext() {
|
||||
const context = useContext(ProjectContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useProjectContext is only available inside ProjectProvider'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
// when the provider is created the project is still not added to the Angular
|
||||
// scope. A few props are populated to prevent errors in existing React
|
||||
// components
|
||||
const projectFallback = {
|
||||
_id: getMeta('ol-project_id'),
|
||||
name: '',
|
||||
features: {},
|
||||
}
|
||||
|
||||
export const ProjectProvider: FC = ({ children }) => {
|
||||
const [project] = useScopeValue('project')
|
||||
const joinedOnce = !!project
|
||||
|
||||
const {
|
||||
_id,
|
||||
compiler,
|
||||
imageName,
|
||||
name,
|
||||
rootDoc_id: rootDocId,
|
||||
members,
|
||||
invites,
|
||||
features,
|
||||
publicAccesLevel: publicAccessLevel,
|
||||
owner,
|
||||
trackChangesState,
|
||||
mainBibliographyDoc_id: mainBibliographyDocId,
|
||||
} = project || projectFallback
|
||||
|
||||
const [projectSnapshot] = useState(() => new ProjectSnapshot(_id))
|
||||
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
(getMeta('ol-projectTags') || [])
|
||||
// `tag.name` data may be null for some old users
|
||||
.map((tag: any) => ({ ...tag, name: tag.name ?? '' })),
|
||||
[]
|
||||
)
|
||||
|
||||
const value = useMemo(() => {
|
||||
return {
|
||||
_id,
|
||||
compiler,
|
||||
imageName,
|
||||
name,
|
||||
rootDocId,
|
||||
members,
|
||||
invites,
|
||||
features,
|
||||
publicAccessLevel,
|
||||
owner,
|
||||
tags,
|
||||
trackChangesState,
|
||||
mainBibliographyDocId,
|
||||
projectSnapshot,
|
||||
joinedOnce,
|
||||
}
|
||||
}, [
|
||||
_id,
|
||||
compiler,
|
||||
imageName,
|
||||
name,
|
||||
rootDocId,
|
||||
members,
|
||||
invites,
|
||||
features,
|
||||
publicAccessLevel,
|
||||
owner,
|
||||
tags,
|
||||
trackChangesState,
|
||||
mainBibliographyDocId,
|
||||
projectSnapshot,
|
||||
joinedOnce,
|
||||
])
|
||||
|
||||
return (
|
||||
<ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createContext, FC, useContext, useMemo } from 'react'
|
||||
import getMeta from '../../utils/meta'
|
||||
import { SplitTestInfo } from '../../../../types/split-test'
|
||||
|
||||
export const SplitTestContext = createContext<
|
||||
| {
|
||||
splitTestVariants: Record<string, string>
|
||||
splitTestInfo: Record<string, SplitTestInfo>
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export const SplitTestProvider: FC = ({ children }) => {
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
splitTestVariants: getMeta('ol-splitTestVariants') || {},
|
||||
splitTestInfo: getMeta('ol-splitTestInfo') || {},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<SplitTestContext.Provider value={value}>
|
||||
{children}
|
||||
</SplitTestContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSplitTestContext() {
|
||||
const context = useContext(SplitTestContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useSplitTestContext is only available within SplitTestProvider'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export function useFeatureFlag(name: string) {
|
||||
const { splitTestVariants } = useSplitTestContext()
|
||||
return splitTestVariants[name] === 'enabled'
|
||||
}
|
||||
|
||||
export function useSplitTest(name: string): {
|
||||
variant: string | undefined
|
||||
info: SplitTestInfo | undefined
|
||||
} {
|
||||
const { splitTestVariants, splitTestInfo } = useSplitTestContext()
|
||||
|
||||
return {
|
||||
variant: splitTestVariants[name],
|
||||
info: splitTestInfo[name],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { UserId } from '../../../../../types/user'
|
||||
import { PublicAccessLevel } from '../../../../../types/public-access-level'
|
||||
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
|
||||
|
||||
export type ProjectContextMember = {
|
||||
_id: UserId
|
||||
privileges: 'readOnly' | 'readAndWrite' | 'review'
|
||||
email: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
pendingEditor?: boolean
|
||||
pendingReviewer?: boolean
|
||||
}
|
||||
|
||||
export type ProjectContextValue = {
|
||||
_id: string
|
||||
name: string
|
||||
rootDocId?: string
|
||||
mainBibliographyDocId?: string
|
||||
compiler: string
|
||||
imageName: string
|
||||
members: ProjectContextMember[]
|
||||
invites: ProjectContextMember[]
|
||||
features: {
|
||||
collaborators?: number
|
||||
compileGroup?: 'alpha' | 'standard' | 'priority'
|
||||
trackChanges?: boolean
|
||||
trackChangesVisible?: boolean
|
||||
references?: boolean
|
||||
mendeley?: boolean
|
||||
zotero?: boolean
|
||||
versioning?: boolean
|
||||
gitBridge?: boolean
|
||||
referencesSearch?: boolean
|
||||
github?: boolean
|
||||
}
|
||||
publicAccessLevel?: PublicAccessLevel
|
||||
owner: {
|
||||
_id: UserId
|
||||
email: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
privileges: string
|
||||
signUpDate: string
|
||||
}
|
||||
tags: {
|
||||
_id: string
|
||||
name: string
|
||||
color?: string
|
||||
}[]
|
||||
trackChangesState: boolean | Record<UserId | '__guests__', boolean>
|
||||
projectSnapshot: ProjectSnapshot
|
||||
joinedOnce: boolean
|
||||
}
|
||||
|
||||
export type ProjectContextUpdateValue = Partial<ProjectContextValue>
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface WritefullEvents {
|
||||
'writefull-login-complete': {
|
||||
method: 'email-password' | 'login-with-overleaf'
|
||||
}
|
||||
'writefull-received-suggestions': { numberOfSuggestions: number }
|
||||
'writefull-register-as-auto-account': { email: string }
|
||||
'writefull-shared-analytics': { eventName: string; segmentation: object }
|
||||
'writefull-ai-assist-show-paywall': { origin?: string }
|
||||
}
|
||||
|
||||
type InsertPosition = {
|
||||
parentSelector: string
|
||||
insertBeforeSelector?: string
|
||||
}
|
||||
|
||||
export interface WritefullAPI {
|
||||
init({
|
||||
toolbarPosition,
|
||||
iconPosition,
|
||||
hasAgreedToTOS,
|
||||
overleafUserId,
|
||||
}: {
|
||||
toolbarPosition: InsertPosition
|
||||
iconPosition: InsertPosition
|
||||
hasAgreedToTOS: boolean
|
||||
overleafUserId: string
|
||||
}): Promise<void>
|
||||
addEventListener<eventName extends keyof WritefullEvents>(
|
||||
name: eventName,
|
||||
callback: (detail: WritefullEvents[eventName]) => void
|
||||
): void
|
||||
removeEventListener<eventName extends keyof WritefullEvents>(
|
||||
name: eventName,
|
||||
callback: (detail: WritefullEvents[eventName]) => void
|
||||
): void
|
||||
openTableGenerator(): void
|
||||
openEquationGenerator(): void
|
||||
}
|
||||
25
services/web/frontend/js/shared/context/user-context.tsx
Normal file
25
services/web/frontend/js/shared/context/user-context.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createContext, FC, useContext, useMemo } from 'react'
|
||||
import getMeta from '../../utils/meta'
|
||||
import { LoggedOutUser, User } from '../../../../types/user'
|
||||
|
||||
export const UserContext = createContext<User | LoggedOutUser | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
export const UserProvider: FC = ({ children }) => {
|
||||
const user = useMemo(() => getMeta('ol-user'), [])
|
||||
|
||||
return <UserContext.Provider value={user}>{children}</UserContext.Provider>
|
||||
}
|
||||
|
||||
export function useUserContext() {
|
||||
const context = useContext(UserContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useUserContext is only available inside UserContext, or `ol-user` meta is not defined'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
FC,
|
||||
useState,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
|
||||
import { UserSettings, Keybindings } from '../../../../types/user-settings'
|
||||
import getMeta from '@/utils/meta'
|
||||
import useScopeValue from '@/shared/hooks/use-scope-value'
|
||||
import { userStyles } from '../utils/styles'
|
||||
|
||||
const defaultSettings: UserSettings = {
|
||||
pdfViewer: 'pdfjs',
|
||||
autoComplete: true,
|
||||
autoPairDelimiters: true,
|
||||
syntaxValidation: false,
|
||||
editorTheme: 'textmate',
|
||||
overallTheme: '',
|
||||
mode: 'default',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monaco',
|
||||
lineHeight: 'normal',
|
||||
mathPreview: true,
|
||||
referencesSearchMode: 'advanced',
|
||||
enableNewEditor: true,
|
||||
}
|
||||
|
||||
type UserSettingsContextValue = {
|
||||
userSettings: UserSettings
|
||||
setUserSettings: Dispatch<
|
||||
SetStateAction<UserSettingsContextValue['userSettings']>
|
||||
>
|
||||
}
|
||||
|
||||
type ScopeSettings = {
|
||||
overallTheme: 'light' | 'dark'
|
||||
keybindings: Keybindings
|
||||
fontSize: number
|
||||
fontFamily: string
|
||||
lineHeight: number
|
||||
}
|
||||
|
||||
export const UserSettingsContext = createContext<
|
||||
UserSettingsContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
export const UserSettingsProvider: FC = ({ children }) => {
|
||||
const [userSettings, setUserSettings] = useState<UserSettings>(
|
||||
() => getMeta('ol-userSettings') || defaultSettings
|
||||
)
|
||||
|
||||
// update the global scope 'settings' value, for extensions
|
||||
const [, setScopeSettings] = useScopeValue<ScopeSettings>('settings')
|
||||
useEffect(() => {
|
||||
const { fontFamily, lineHeight } = userStyles(userSettings)
|
||||
setScopeSettings({
|
||||
overallTheme: userSettings.overallTheme === 'light-' ? 'light' : 'dark',
|
||||
keybindings: userSettings.mode === 'none' ? 'default' : userSettings.mode,
|
||||
fontFamily,
|
||||
lineHeight,
|
||||
fontSize: userSettings.fontSize,
|
||||
})
|
||||
}, [setScopeSettings, userSettings])
|
||||
|
||||
const value = useMemo<UserSettingsContextValue>(
|
||||
() => ({
|
||||
userSettings,
|
||||
setUserSettings,
|
||||
}),
|
||||
[userSettings, setUserSettings]
|
||||
)
|
||||
|
||||
return (
|
||||
<UserSettingsContext.Provider value={value}>
|
||||
{children}
|
||||
</UserSettingsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useUserSettingsContext() {
|
||||
const context = useContext(UserSettingsContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useUserSettingsContext is only available inside UserSettingsProvider'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
Reference in New Issue
Block a user