first commit

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

View File

@@ -0,0 +1,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
}

View 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
}

View 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
}

View File

@@ -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>
)
}

View 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
}

View 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
}

View File

@@ -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
}

View 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,
},
}
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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],
}
}

View File

@@ -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>

View File

@@ -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
}

View 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
}

View File

@@ -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
}