Merge pull request #15376 from overleaf/td-ide-page-working-editor

React IDE page: working editor

GitOrigin-RevId: 3ba8cb787a6f7f8435686d8962adb7444d09acb5
This commit is contained in:
Tim Down
2023-10-27 08:03:07 +00:00
committed by Copybot
parent a59e63846d
commit 01439641ca
48 changed files with 3622 additions and 62 deletions
@@ -20,6 +20,7 @@ type ConnectionContextValue = {
secondsUntilReconnect: () => number
tryReconnectNow: () => void
registerUserActivity: () => void
disconnect: () => void
}
const ConnectionContext = createContext<ConnectionContextValue | undefined>(
@@ -64,6 +65,10 @@ export const ConnectionProvider: FC = ({ children }) => {
[connectionManager]
)
const disconnect = useCallback(() => {
connectionManager.disconnect()
}, [connectionManager])
const value = useMemo<ConnectionContextValue>(
() => ({
socket: connectionManager.socket,
@@ -73,6 +78,7 @@ export const ConnectionProvider: FC = ({ children }) => {
secondsUntilReconnect,
tryReconnectNow,
registerUserActivity,
disconnect,
}),
[
connectionManager.socket,
@@ -82,6 +88,7 @@ export const ConnectionProvider: FC = ({ children }) => {
registerUserActivity,
secondsUntilReconnect,
tryReconnectNow,
disconnect,
]
)
@@ -0,0 +1,597 @@
/* eslint-disable no-use-before-define */
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
import {
createContext,
FC,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import customLocalStorage from '@/infrastructure/local-storage'
import { sendMB } from '@/infrastructure/event-tracking'
import useScopeValue from '@/shared/hooks/use-scope-value'
import { useIdeContext } from '@/shared/context/ide-context'
import { OpenDocuments } from '@/features/ide-react/editor/open-documents'
import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { debugConsole } from '@/utils/debugging'
import { Document } from '@/features/ide-react/editor/document'
import { useLayoutContext } from '@/shared/context/layout-context'
import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
import _ from 'lodash'
import { Doc } from '../../../../../types/doc'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { findDocEntityById } from '@/features/ide-react/util/find-doc-entity-by-id'
import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
interface GotoOffsetOptions {
gotoOffset: number
}
interface OpenDocOptions
extends Partial<GotoLineOptions>,
Partial<GotoOffsetOptions> {
gotoOffset?: number
forceReopen?: boolean
}
type EditorManager = {
getEditorType: () => 'cm6' | 'cm6-rich-text' | null
showSymbolPalette: boolean
currentDocument: Document
getCurrentDocValue: () => string | null
getCurrentDocId: () => string | null
startIgnoringExternalUpdates: () => void
stopIgnoringExternalUpdates: () => void
openDocId: (docId: string, options?: OpenDocOptions) => void
openDoc: (document: Document, options?: OpenDocOptions) => void
jumpToLine: (options: GotoLineOptions) => void
}
type DocumentOpenCallback = (error: Error | null, document?: Document) => void
function hasGotoLine(options: OpenDocOptions): options is GotoLineOptions {
return typeof options.gotoLine === 'number'
}
function hasGotoOffset(options: OpenDocOptions): options is GotoOffsetOptions {
return typeof options.gotoOffset === 'number'
}
export type EditorScopeValue = {
showSymbolPalette: false
toggleSymbolPalette: () => void
sharejs_doc: Document | null
open_doc_id: string | null
open_doc_name: string | null
opening: boolean
trackChanges: boolean
wantTrackChanges: boolean
showVisual: boolean
newSourceEditor: boolean
multiSelectedCount: number
error_state: boolean
}
export function populateEditorScope(store: ReactScopeValueStore) {
const projectId = window.project_id
// This value is not used in the React code. It's just here to prevent errors
// from EditorProvider
store.set('state.loading', false)
store.set('project.name', null)
store.set('editor', {
showSymbolPalette: false,
toggleSymbolPalette: () => {},
sharejs_doc: null,
open_doc_id: null,
open_doc_name: null,
opening: true,
trackChanges: false,
wantTrackChanges: false,
// No Ace here
newSourceEditor: true,
// TODO: Ignore multiSelectedCount and just default it to zero for now until
// we get to the file tree. It seems to be stored in two places in the
// Angular scope and needs further investigation.
multiSelectedCount: 0,
error_state: false,
})
store.persisted('editor.showVisual', false, `editor.mode.${projectId}`, {
toPersisted: showVisual => (showVisual ? 'rich-text' : 'source'),
fromPersisted: mode => mode === 'rich-text',
})
}
const EditorManagerContext = createContext<EditorManager | undefined>(undefined)
export const EditorManagerProvider: FC = ({ children }) => {
const ide = useIdeContext()
const { reportError, eventEmitter, eventLog, projectId } =
useIdeReactContext()
const { socket, disconnect } = useConnectionContext()
const { view, setView } = useLayoutContext()
const [showSymbolPalette, setShowSymbolPalette] = useScopeValue<boolean>(
'editor.showSymbolPalette'
)
const [showVisual] = useScopeValue<boolean>('editor.showVisual')
// eslint-disable-next-line no-unused-vars
const [currentDocument, setCurrentDocument] =
useScopeValue<Document>('editor.sharejs_doc')
const [openDocId, setOpenDocId] = useScopeValue<string | null>(
'editor.open_doc_id'
)
const [, setOpenDocName] = useScopeValue<string | null>(
'editor.open_doc_name'
)
const [, setOpening] = useScopeValue<boolean>('editor.opening')
const [, setIsInErrorState] = useScopeValue<boolean>('editor.error_state')
const [, setTrackChanges] = useScopeValue<boolean>('editor.trackChanges')
const [wantTrackChanges] = useScopeValue<boolean>('editor.wantTrackChanges')
const goToLineEmitter = useScopeEventEmitter('editor:gotoLine')
const { fileTreeData } = useFileTreeData()
// eslint-disable-next-line no-unused-vars
const [ignoringExternalUpdates, setIgnoringExternalUpdates] = useState(false)
const [globalEditorWatchdogManager] = useState(
() =>
new EditorWatchdogManager({
onTimeoutHandler: (meta: Record<string, any>) => {
sendMB('losing-edits', meta)
reportError('losing-edits', meta)
},
})
)
const [docTooLongErrorShown, setDocTooLongErrorShown] = useState(false)
const [openDocs] = useState(
() =>
new OpenDocuments(
socket,
globalEditorWatchdogManager,
eventEmitter,
eventLog
)
)
const editorOpenDocEpochRef = useRef(0)
// TODO: This looks dodgy because it wraps a state setter and is itself
// stored in React state in the scope store. The problem is that it needs to
// be exposed via the scope store because some components access it that way;
// it would be better to simply access it from a context, but the current
// implementation in EditorManager interacts with Angular scope to update
// the layout. Once Angular is gone, this can become a context method.
useEffect(() => {
ide.scopeStore.set('editor.toggleSymbolPalette', () => {
setShowSymbolPalette(show => {
const newValue = !show
sendMB(newValue ? 'symbol-palette-show' : 'symbol-palette-hide')
return newValue
})
})
}, [ide.scopeStore, setShowSymbolPalette])
const getEditorType = useCallback(() => {
if (!currentDocument) {
return null
}
return showVisual ? 'cm6-rich-text' : 'cm6'
}, [currentDocument, showVisual])
const getCurrentDocValue = useCallback(() => {
return currentDocument?.getSnapshot() ?? null
}, [currentDocument])
const getCurrentDocId = useCallback(() => openDocId, [openDocId])
const startIgnoringExternalUpdates = useCallback(
() => setIgnoringExternalUpdates(true),
[]
)
const stopIgnoringExternalUpdates = useCallback(
() => setIgnoringExternalUpdates(false),
[]
)
// Ignore insertSymbol from Angular EditorManager because it's only required
// for Ace.
const jumpToLine = useCallback(
(options: GotoLineOptions) => {
goToLineEmitter(
options.gotoLine,
options.gotoColumn ?? 0,
options.syncToPdf ?? false
)
},
[goToLineEmitter]
)
const unbindFromDocumentEvents = (document: Document) => {
document.off()
}
const openDocWithId = useCallback(
(docId: string, options: OpenDocOptions = {}) => {
const doc = findDocEntityById(fileTreeData, docId)
if (!doc) {
return
}
openDoc(doc, options)
},
// @ts-ignore
[fileTreeData, openDoc]
)
// @ts-ignore
const attachErrorHandlerToDocument = useCallback(
(doc: Doc, document: Document) => {
document.on(
'error',
(
error: Error | string,
meta?: Record<string, any>,
editorContent?: string
) => {
const message =
typeof error === 'string' ? error : error?.message ?? ''
if (/maxDocLength/.test(message)) {
setDocTooLongErrorShown(true)
openDoc(doc, { forceReopen: true })
// TODO: MIGRATION: Show generic modal here
// const genericMessageModal = this.ide.showGenericMessageModal(
// 'Document Too Long',
// 'Sorry, this file is too long to be edited manually. Please upload it directly.'
// )
// genericMessageModal.result.finally(() => {
// this.$scope.docTooLongErrorShown = false
// })
} else if (/too many comments or tracked changes/.test(message)) {
// TODO: MIGRATION: Show generic modal here
// this.ide.showGenericMessageModal(
// 'Too many comments or tracked changes',
// 'Sorry, this file has too many comments or tracked changes. Please try accepting or rejecting some existing changes, or resolving and deleting some comments.'
// )
} else if (!docTooLongErrorShown) {
// Do not allow this doc to open another error modal.
document.off('error')
// Preserve the sharejs contents before the teardown.
editorContent =
typeof editorContent === 'string'
? editorContent
: document.doc?._doc.snapshot
// Tear down the ShareJsDoc.
if (document.doc) document.doc.clearInflightAndPendingOps()
// Do not re-join after re-connecting.
document.leaveAndCleanUp()
disconnect()
reportError(error, meta)
// Tell the user about the error state.
setIsInErrorState(true)
// TODO: MIGRATION: Show out-of-sync modal
// this.ide.showOutOfSyncModal(
// 'Out of sync',
// "Sorry, this file has gone out of sync and we need to do a full refresh. <br> <a target='_blank' rel='noopener noreferrer' href='/learn/Kb/Editor_out_of_sync_problems'>Please see this help guide for more information</a>",
// editorContent
// )
// Do not forceReopen the document.
return
}
eventEmitter.once('project:joined', () => {
openDoc(doc, { forceReopen: true })
})
}
)
},
[
disconnect,
docTooLongErrorShown,
eventEmitter,
// @ts-ignore
openDoc,
reportError,
setIsInErrorState,
]
)
const bindToDocumentEvents = useCallback(
(doc: Doc, document: Document) => {
attachErrorHandlerToDocument(doc, document)
// TODO: MIGRATION: Add a type for `update`
document.on('externalUpdate', (update: any) => {
if (ignoringExternalUpdates) {
return
}
if (
_.property(['meta', 'type'])(update) === 'external' &&
_.property(['meta', 'source'])(update) === 'git-bridge'
) {
// eslint-disable-next-line no-useless-return
return
}
// TODO: MIGRATION: Show generic modal here
// this.ide.showGenericMessageModal(
// 'Document Updated Externally',
// 'This document was just updated externally. Any recent changes you have made may have been overwritten. To see previous versions please look in the history.'
// )
})
},
[attachErrorHandlerToDocument, ignoringExternalUpdates]
)
const syncTimeoutRef = useRef<number | null>(null)
const syncTrackChangesState = useCallback(
(doc: Document) => {
if (!doc) {
return
}
if (syncTimeoutRef.current) {
window.clearTimeout(syncTimeoutRef.current)
syncTimeoutRef.current = null
}
const want = wantTrackChanges
const have = doc.getTrackingChanges()
if (wantTrackChanges === have) {
setTrackChanges(want)
return
}
const tryToggle = () => {
const saved = doc.getInflightOp() == null && doc.getPendingOp() == null
if (saved) {
doc.setTrackingChanges(wantTrackChanges)
setTrackChanges(want)
} else {
syncTimeoutRef.current = window.setTimeout(tryToggle, 100)
}
}
tryToggle()
},
[setTrackChanges, wantTrackChanges]
)
const doOpenNewDocument = useCallback(
(doc: Doc, callback: DocumentOpenCallback) => {
debugConsole.log('[doOpenNewDocument] Opening...')
const newDocument = openDocs.getDocument(doc._id)
if (!newDocument) {
debugConsole.error(`No open document with ID '${doc._id}' found`)
return
}
const preJoinEpoch = ++editorOpenDocEpochRef.current
newDocument.join(error => {
if (error) {
debugConsole.log(
`[doOpenNewDocument] error joining doc ${doc._id}`,
error
)
callback(error)
}
if (editorOpenDocEpochRef.current !== preJoinEpoch) {
debugConsole.log(
`[openNewDocument] editorOpenDocEpoch mismatch ${editorOpenDocEpochRef.current} vs ${preJoinEpoch}`
)
newDocument.leaveAndCleanUp()
return callback(new Error('another document was loaded'))
}
bindToDocumentEvents(doc, newDocument)
return callback(null, newDocument)
})
},
[bindToDocumentEvents, openDocs]
)
// @ts-ignore
const openNewDocument = useCallback(
(doc: Doc, callback: DocumentOpenCallback) => {
// Leave the current document
// - when we are opening a different new one, to avoid race conditions
// between leaving and joining the same document
// - when the current one has pending ops that need flushing, to avoid
// race conditions from cleanup
const currentDocId = currentDocument?.doc_id
const hasBufferedOps = currentDocument?.hasBufferedOps()
const changingDoc = currentDocument && currentDocId !== doc._id
if (changingDoc || hasBufferedOps) {
debugConsole.log('[openNewDocument] Leaving existing open doc...')
// Do not trigger any UI changes from remote operations
unbindFromDocumentEvents(currentDocument)
// Keep listening for out-of-sync and similar errors.
attachErrorHandlerToDocument(doc, currentDocument)
// Teardown the Document -> ShareJsDoc -> sharejs doc
// By the time this completes, the Document instance is no longer
// registered in OpenDocuments and doOpenNewDocument can start
// from scratch -- read: no corrupted internal state.
const preLeaveEpoch = ++editorOpenDocEpochRef.current
currentDocument.leaveAndCleanUp(error => {
if (error) {
debugConsole.log(
`[_openNewDocument] error leaving doc ${currentDocId}`,
error
)
return callback(error)
}
if (editorOpenDocEpochRef.current !== preLeaveEpoch) {
debugConsole.log(
`[openNewDocument] editorOpenDocEpoch mismatch ${editorOpenDocEpochRef.current} vs ${preLeaveEpoch}`
)
callback(new Error('another document was loaded'))
}
doOpenNewDocument(doc, callback)
})
} else {
doOpenNewDocument(doc, callback)
}
},
[attachErrorHandlerToDocument, doOpenNewDocument, currentDocument]
)
// @ts-ignore
const openDoc = useCallback(
(doc: Doc, options: OpenDocOptions = {}) => {
debugConsole.log(`[openDoc] Opening ${doc._id}`)
if (view === 'editor') {
// store position of previous doc before switching docs
eventEmitter.emit('store-doc-position')
}
setView('editor')
const done = (isNewDoc: boolean) => {
window.dispatchEvent(
new CustomEvent('doc:after-opened', { detail: isNewDoc })
)
if (hasGotoLine(options)) {
// In CM6, jump to the line again after a stored scroll position has been restored
if (isNewDoc) {
window.addEventListener(
'editor:scroll-position-restored',
() => jumpToLine(options),
{ once: true }
)
}
} else if (hasGotoOffset(options)) {
window.setTimeout(() => {
eventEmitter.emit('editor:gotoOffset', options.gotoOffset)
})
}
}
// If we already have the document open we can return at this point.
// Note: only use forceReopen:true to override this when the document is
// out of sync and needs to be reloaded from the server.
if (doc._id === openDocId && !options.forceReopen) {
window.dispatchEvent(
new CustomEvent('editor.openDoc', { detail: doc._id })
)
done(false)
return
}
// We're now either opening a new document or reloading a broken one.
setOpenDocId(doc._id)
setOpenDocName(doc.name)
customLocalStorage.setItem(`doc.open_id.${projectId}`, doc._id)
setOpening(true)
openNewDocument(doc, (error: Error | null, document: Document) => {
if (error?.message === 'another document was loaded') {
debugConsole.log(
`[openDoc] another document was loaded while ${doc._id} was loading`
)
return
}
if (error != null) {
// TODO: MIGRATION: Show generic modal here
// this.ide.showGenericMessageModal(
// 'Error opening document',
// 'Sorry, something went wrong opening this document. Please try again.'
// )
return
}
syncTrackChangesState(document)
eventEmitter.emit('doc:opened')
setOpening(false)
setCurrentDocument(document)
done(true)
})
},
[
eventEmitter,
jumpToLine,
openDocId,
openNewDocument,
projectId,
setCurrentDocument,
setOpenDocId,
setOpenDocName,
setOpening,
setView,
syncTrackChangesState,
view,
]
)
const editorManager = useMemo(
() => ({
getEditorType,
showSymbolPalette,
currentDocument,
getCurrentDocValue,
getCurrentDocId,
startIgnoringExternalUpdates,
stopIgnoringExternalUpdates,
openDocId: openDocWithId,
openDoc,
jumpToLine,
}),
[
getEditorType,
showSymbolPalette,
currentDocument,
getCurrentDocValue,
getCurrentDocId,
startIgnoringExternalUpdates,
stopIgnoringExternalUpdates,
openDocWithId,
openDoc,
jumpToLine,
]
)
// Expose editorManager via ide object because some React code relies on it,
// for now
ide.editorManager = editorManager
return (
<EditorManagerContext.Provider value={editorManager}>
{children}
</EditorManagerContext.Provider>
)
}
export function useEditorManagerContext(): EditorManager {
const context = useContext(EditorManagerContext)
if (!context) {
throw new Error(
'useEditorManagerContext is only available inside EditorManagerProvider'
)
}
return context
}
@@ -1,12 +1,14 @@
import {
import React, {
createContext,
useContext,
useState,
FC,
useMemo,
useEffect,
useCallback,
} from 'react'
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
import populateLayoutScope from '@/features/ide-react/scope-adapters/layout-context-adapter'
import { IdeProvider } from '@/shared/context/ide-context'
import {
createIdeEventEmitter,
@@ -15,17 +17,37 @@ import {
import { JoinProjectPayload } from '@/features/ide-react/connection/join-project-payload'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { getMockIde } from '@/shared/context/mock/mock-ide'
import { populateEditorScope } from '@/features/ide-react/context/editor-manager-context'
import { debugConsole } from '@/utils/debugging'
import { postJSON } from '@/infrastructure/fetch-json'
import { EventLog } from '@/features/ide-react/editor/event-log'
import { populateSettingsScope } from '@/features/ide-react/scope-adapters/settings-adapter'
import { populateOnlineUsersScope } from '@/features/ide-react/context/online-users-context'
import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
import { populateReferenceScope } from '@/features/ide-react/context/references-context'
type IdeReactContextValue = {
projectId: string
eventEmitter: IdeEventEmitter
eventLog: EventLog
startedFreeTrial: boolean
setStartedFreeTrial: React.Dispatch<
React.SetStateAction<IdeReactContextValue['startedFreeTrial']>
>
reportError: (error: any, meta?: Record<string, any>) => void
}
const IdeReactContext = createContext<IdeReactContextValue | null>(null)
const IdeReactContext = createContext<IdeReactContextValue | undefined>(
undefined
)
function showGenericMessageModal(title: string, message: string) {
debugConsole.log('*** showGenericMessageModal ***', title, message)
}
function populateIdeReactScope(store: ReactScopeValueStore) {
store.set('sync_tex_error', false)
store.set('settings', window.userSettings)
}
function populateProjectScope(store: ReactScopeValueStore) {
@@ -33,6 +55,14 @@ function populateProjectScope(store: ReactScopeValueStore) {
store.set('permissionsLevel', 'readOnly')
}
function populatePdfScope(store: ReactScopeValueStore) {
store.allowNonExistentPath('pdf', true)
}
function populateFileTreeScope(store: ReactScopeValueStore) {
store.set('docs', [])
}
function createReactScopeValueStore() {
const scopeStore = new ReactScopeValueStore()
@@ -42,7 +72,17 @@ function createReactScopeValueStore() {
// initialization code together with the context and would only populate
// necessary values in the store, but this is simpler for now
populateIdeReactScope(scopeStore)
populateEditorScope(scopeStore)
populateLayoutScope(scopeStore)
populateProjectScope(scopeStore)
populatePdfScope(scopeStore)
populateSettingsScope(scopeStore)
populateOnlineUsersScope(scopeStore)
populateReferenceScope(scopeStore)
populateFileTreeScope(scopeStore)
scopeStore.allowNonExistentPath('hasLintingError')
scopeStore.allowNonExistentPath('loadingThreads')
return scopeStore
}
@@ -55,24 +95,42 @@ export const IdeReactProvider: FC = ({ children }) => {
const [scopeEventEmitter] = useState(
() => new ReactScopeEventEmitter(eventEmitter)
)
const [eventLog] = useState(() => new EventLog())
const [startedFreeTrial, setStartedFreeTrial] = useState(false)
const { socket } = useConnectionContext()
// Fire project:joined event
useEffect(() => {
function handleJoinProjectResponse({
project,
permissionsLevel,
}: JoinProjectPayload) {
eventEmitter.emit('project:joined', { project, permissionsLevel })
}
const reportError = useCallback(
(error: any, meta?: Record<string, any>) => {
const metadata = {
...meta,
user_id: window.user_id,
project_id: projectId,
// @ts-ignore
client_id: socket.socket.sessionid,
// @ts-ignore
transport: socket.socket.transport,
client_now: new Date(),
}
socket.on('joinProjectResponse', handleJoinProjectResponse)
return () => {
socket.removeListener('joinProjectResponse', handleJoinProjectResponse)
}
}, [socket, eventEmitter])
const errorObj: Record<string, any> = {}
if (typeof error === 'object') {
for (const key of Object.getOwnPropertyNames(error)) {
errorObj[key] = error[key]
}
} else if (typeof error === 'string') {
errorObj.message = error
}
return postJSON('/error/client', {
body: {
error: errorObj,
meta: metadata,
_csrf: window.csrfToken,
},
})
},
[socket.socket]
)
// Populate scope values when joining project, then fire project:joined event
useEffect(() => {
@@ -98,15 +156,28 @@ export const IdeReactProvider: FC = ({ children }) => {
return {
...getMockIde(),
socket,
showGenericMessageModal,
reportError,
// TODO: MIGRATION: Remove this once it's no longer used
fileTreeManager: {
findEntityByPath: () => null,
selectEntity: () => {},
getPreviewByPath: () => null,
getRootDocDirname: () => '',
},
}
}, [socket])
}, [socket, reportError])
const value = useMemo(
() => ({
eventEmitter,
eventLog,
startedFreeTrial,
setStartedFreeTrial,
projectId,
reportError,
}),
[eventEmitter]
[eventEmitter, eventLog, reportError, startedFreeTrial]
)
return (
@@ -0,0 +1,228 @@
import {
createContext,
useContext,
useEffect,
FC,
useCallback,
useMemo,
useState,
useRef,
} from 'react'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import _ from 'lodash'
import { getJSON, postJSON } from '@/infrastructure/fetch-json'
import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
import { useEditorContext } from '@/shared/context/editor-context'
import { useIdeContext } from '@/shared/context/ide-context'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import useEventListener from '@/shared/hooks/use-event-listener'
type DocumentMetadata = {
labels: string[]
packages: Record<string, any>
}
type DocumentsMetadata = Record<string, DocumentMetadata>
type MetadataContextValue = {
metadata: {
state: {
documents: DocumentsMetadata
}
getAllLabels: () => DocumentMetadata['labels']
getAllPackages: () => DocumentMetadata['packages']
}
}
type DocMetadataResponse = { docId: string; meta: DocumentMetadata }
const MetadataContext = createContext<MetadataContextValue | undefined>(
undefined
)
export const MetadataProvider: FC = ({ children }) => {
const ide = useIdeContext()
const { eventEmitter, projectId } = useIdeReactContext()
const { socket } = useConnectionContext()
const { onlineUsersCount } = useOnlineUsersContext()
const { permissionsLevel } = useEditorContext()
const { currentDocument } = useEditorManagerContext()
const [documents, setDocuments] = useState<DocumentsMetadata>({})
const debouncerRef = useRef<Map<string, number>>(new Map()) // DocId => Timeout
useEffect(() => {
eventEmitter.on('entity:deleted', entity => {
if (entity.type === 'doc') {
setDocuments(documents => _.omit(documents, entity.id))
}
})
}, [eventEmitter])
useEffect(() => {
window.dispatchEvent(
new CustomEvent('project:metadata', { detail: documents })
)
}, [documents])
const onBroadcastDocMeta = useCallback((data: DocMetadataResponse) => {
const { docId, meta } = data
if (docId != null && meta != null) {
setDocuments(documents => ({ ...documents, [docId]: meta }))
}
}, [])
const getAllLabels = useCallback(
() =>
_.flattenDeep(
Array.from(Object.values(documents)).map(meta => meta.labels)
),
[documents]
)
const getAllPackages = useCallback(() => {
const packageCommandMapping: Record<string, any> = {}
for (const meta of Object.values(documents)) {
for (const [packageName, commandSnippets] of Object.entries(
meta.packages
)) {
packageCommandMapping[packageName] = commandSnippets
}
}
return packageCommandMapping
}, [documents])
const loadProjectMetaFromServer = useCallback(() => {
getJSON(`/project/${projectId}/metadata`).then(
(response: { projectMeta: DocumentsMetadata }) => {
const { projectMeta } = response
if (projectMeta) {
setDocuments(projectMeta)
}
}
)
}, [projectId])
const loadDocMetaFromServer = useCallback(
(docId: string) => {
// Don't broadcast metadata when there are no other users in the
// project.
const broadcast = onlineUsersCount > 0
postJSON(`/project/${projectId}/doc/${docId}/metadata`, {
body: {
broadcast,
},
}).then((response: DocMetadataResponse) => {
if (!broadcast && response) {
// handle the POST response like a broadcast event when there are no
// other users in the project.
onBroadcastDocMeta(response)
}
})
},
[onBroadcastDocMeta, onlineUsersCount, projectId]
)
const scheduleLoadDocMetaFromServer = useCallback(
(docId: string) => {
if (permissionsLevel === 'readOnly') {
// The POST request is blocked for users without write permission.
// The user will not be able to consume the metadata for edits anyway.
return
}
// Debounce loading labels with a timeout
const existingTimeout = debouncerRef.current.get(docId)
if (existingTimeout != null) {
window.clearTimeout(existingTimeout)
debouncerRef.current.delete(docId)
}
debouncerRef.current.set(
docId,
window.setTimeout(() => {
// TODO: wait for the document to be saved?
loadDocMetaFromServer(docId)
debouncerRef.current.delete(docId)
}, 2000)
)
},
[loadDocMetaFromServer, permissionsLevel]
)
const handleBroadcastDocMeta = useCallback(
(data: DocMetadataResponse) => {
onBroadcastDocMeta(data)
},
[onBroadcastDocMeta]
)
useSocketListener(socket, 'broadcastDocMeta', handleBroadcastDocMeta)
const handleMetadataOutdated = useCallback(() => {
if (currentDocument) {
scheduleLoadDocMetaFromServer(currentDocument.doc_id)
}
}, [currentDocument, scheduleLoadDocMetaFromServer])
useEventListener('editor:metadata-outdated', handleMetadataOutdated)
useEffect(() => {
eventEmitter.once('project:joined', ({ project }) => {
if (project.deletedByExternalDataSource) {
// TODO: MIGRATION: Show generic message modal here
/*
ide.showGenericMessageModal(
'Project Renamed or Deleted',
`\
This project has either been renamed or deleted by an external data source such as Dropbox.
We don't want to delete your data on Overleaf, so this project still contains your history and collaborators.
If the project has been renamed please look in your project list for a new project under the new name.\
`
)
*/
}
window.setTimeout(() => {
if (permissionsLevel !== 'readOnly') {
loadProjectMetaFromServer()
}
}, 200)
})
}, [eventEmitter, loadProjectMetaFromServer, permissionsLevel])
const value = useMemo<MetadataContextValue>(
() => ({
metadata: {
state: { documents },
getAllLabels,
getAllPackages,
},
}),
[documents, getAllLabels, getAllPackages]
)
// Expose metadataManager via ide object because useCodeMirrorScope relies on
// it, for now
ide.metadataManager = value
return (
<MetadataContext.Provider value={value}>
{children}
</MetadataContext.Provider>
)
}
export function useMetadataContext(): MetadataContextValue {
const context = useContext(MetadataContext)
if (!context) {
throw new Error(
'useMetadataContext is only available inside MetadataProvider'
)
}
return context
}
@@ -0,0 +1,297 @@
import {
createContext,
useContext,
useEffect,
FC,
useCallback,
useMemo,
useState,
} from 'react'
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import useScopeValue from '@/shared/hooks/use-scope-value'
import ColorManager from '@/ide/colors/ColorManager'
import { CursorPosition } from '@/features/ide-react/types/cursor-position'
import { omit } from 'lodash'
import { Doc } from '../../../../../types/doc'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { findDocEntityById } from '@/features/ide-react/util/find-doc-entity-by-id'
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
type OnlineUser = {
id: string
user_id: string
email: string
name: string
initial?: string
doc_id?: string
doc?: Doc | null
row?: number
column?: number
}
type ConnectedUser = {
client_id: string
user_id: string
email: string
first_name: string
last_name: string
cursorData?: {
doc_id: string
row: number
column: number
}
}
type CursorHighlight = {
label: string
cursor: {
row: number
column: number
}
hue: number
}
type OnlineUsersContextValue = {
onlineUsers: Record<string, OnlineUser>
onlineUserCursorHighlights: Record<string, CursorHighlight[]>
onlineUsersArray: OnlineUser[]
onlineUsersCount: number
}
export function populateOnlineUsersScope(store: ReactScopeValueStore) {
store.set('onlineUsers', {})
store.set('onlineUserCursorHighlights', {})
store.set('onlineUsersArray', [])
store.set('onlineUsersCount', 0)
}
const OnlineUsersContext = createContext<OnlineUsersContextValue | undefined>(
undefined
)
export const OnlineUsersProvider: FC = ({ children }) => {
const { eventEmitter } = useIdeReactContext()
const { socket } = useConnectionContext()
const [openDocId] = useScopeValue<string | null>('editor.open_doc_id')
const { fileTreeData } = useFileTreeData()
const [onlineUsers, setOnlineUsers] =
useScopeValue<OnlineUsersContextValue['onlineUsers']>('onlineUsers')
const [onlineUserCursorHighlights, setOnlineUserCursorHighlights] =
useScopeValue<OnlineUsersContextValue['onlineUserCursorHighlights']>(
'onlineUserCursorHighlights'
)
const [onlineUsersArray, setOnlineUsersArray] =
useScopeValue<OnlineUsersContextValue['onlineUsersArray']>(
'onlineUsersArray'
)
const [onlineUsersCount, setOnlineUsersCount] =
useScopeValue<OnlineUsersContextValue['onlineUsersCount']>(
'onlineUsersCount'
)
const [currentPosition, setCurrentPosition] = useState<CursorPosition | null>(
null
)
const [cursorUpdateInterval, setCursorUpdateInterval] = useState(500)
const calculateValues = useCallback(
(onlineUsers: OnlineUsersContextValue['onlineUsers']) => {
const decoratedOnlineUsers: OnlineUsersContextValue['onlineUsers'] = {}
const onlineUsersArray: OnlineUser[] = []
const onlineUserCursorHighlights: OnlineUsersContextValue['onlineUserCursorHighlights'] =
{}
for (const [clientId, user] of Object.entries(onlineUsers)) {
const decoratedUser = { ...user }
const docId = user.doc_id
if (docId) {
decoratedUser.doc = findDocEntityById(fileTreeData, docId)
}
// If the user's name is empty use their email as display name
// Otherwise they're probably an anonymous user
if (user.name === null || user.name.trim().length === 0) {
decoratedUser.name = user.email ? user.email.trim() : 'Anonymous'
}
decoratedUser.initial = user.name?.[0]
if (!decoratedUser.initial || decoratedUser.initial === ' ') {
decoratedUser.initial = '?'
}
onlineUsersArray.push(decoratedUser)
decoratedOnlineUsers[clientId] = decoratedUser
if (docId == null || user.row == null || user.column == null) {
continue
}
if (!onlineUserCursorHighlights[docId]) {
onlineUserCursorHighlights[docId] = []
}
onlineUserCursorHighlights[docId].push({
label: user.name,
cursor: {
row: user.row,
column: user.column,
},
hue: ColorManager.getHueForUserId(user.user_id),
})
}
const cursorUpdateInterval =
onlineUsersArray.length > 0 ? 500 : 60 * 1000 * 5
return {
onlineUsers: decoratedOnlineUsers,
onlineUsersArray,
onlineUserCursorHighlights,
cursorUpdateInterval,
}
},
[fileTreeData]
)
const setAllValues = useCallback(
(newOnlineUsers: OnlineUsersContextValue['onlineUsers']) => {
const values = calculateValues(newOnlineUsers)
setOnlineUsers(values.onlineUsers)
setOnlineUsersArray(values.onlineUsersArray)
setOnlineUsersCount(values.onlineUsersArray.length)
setOnlineUserCursorHighlights(values.onlineUserCursorHighlights)
setCursorUpdateInterval(values.cursorUpdateInterval)
},
[
calculateValues,
setOnlineUserCursorHighlights,
setOnlineUsers,
setOnlineUsersArray,
setOnlineUsersCount,
]
)
useEffect(() => {
const handleProjectJoined = () => {
socket.emit(
'clientTracking.getConnectedUsers',
// eslint-disable-next-line n/handle-callback-err
(error: Error, connectedUsers: ConnectedUser[]) => {
// TODO: MIGRATION: Handle error (although the original code doesn't)
const newOnlineUsers: OnlineUsersContextValue['onlineUsers'] = {}
for (const user of connectedUsers) {
if (user.client_id === socket.publicId) {
// Don't store myself
continue
}
// Store data in the same format returned by clientTracking.clientUpdated
newOnlineUsers[user.client_id] = {
id: user.client_id,
user_id: user.user_id,
email: user.email,
name: `${user.first_name} ${user.last_name}`,
doc_id: user.cursorData?.doc_id,
row: user.cursorData?.row,
column: user.cursorData?.column,
}
}
setAllValues(newOnlineUsers)
}
)
}
eventEmitter.on('project:joined', handleProjectJoined)
return () => {
eventEmitter.off('project:joined', handleProjectJoined)
}
}, [eventEmitter, setAllValues, setOnlineUsers, socket])
// Track the position of the main cursor
useEffect(() => {
const handleCursorUpdate = (position: CursorPosition | null) => {
if (position) {
setCurrentPosition(position)
}
}
eventEmitter.on('cursor:editor:update', handleCursorUpdate)
return () => {
eventEmitter.off('cursor:editor:update', handleCursorUpdate)
}
}, [cursorUpdateInterval, eventEmitter])
// Send the latest position to other clients when currentPosition changes
useEffect(() => {
const timer = window.setTimeout(() => {
socket.emit('clientTracking.updatePosition', {
row: currentPosition?.row,
column: currentPosition?.column,
doc_id: openDocId,
})
}, cursorUpdateInterval)
return () => {
window.clearTimeout(timer)
}
}, [currentPosition, cursorUpdateInterval, openDocId, socket])
const handleClientUpdated = useCallback(
(client: OnlineUser) => {
// Check it's not me!
if (client.id !== socket.publicId) {
setAllValues({ ...onlineUsers, [client.id]: client })
}
},
[onlineUsers, setAllValues, socket.publicId]
)
useSocketListener(socket, 'clientTracking.clientUpdated', handleClientUpdated)
const handleClientDisconnected = useCallback(
(clientId: string) => {
setAllValues(omit(onlineUsers, clientId))
},
[onlineUsers, setAllValues]
)
useSocketListener(
socket,
'clientTracking.clientDisconnected',
handleClientDisconnected
)
const value = useMemo<OnlineUsersContextValue>(
() => ({
onlineUsers,
onlineUsersArray,
onlineUserCursorHighlights,
onlineUsersCount,
}),
[
onlineUsers,
onlineUsersArray,
onlineUserCursorHighlights,
onlineUsersCount,
]
)
return (
<OnlineUsersContext.Provider value={value}>
{children}
</OnlineUsersContext.Provider>
)
}
export function useOnlineUsersContext(): OnlineUsersContextValue {
const context = useContext(OnlineUsersContext)
if (!context) {
throw new Error(
'useOnlineUsersContext is only available inside OnlineUsersProvider'
)
}
return context
}
@@ -1,17 +1,58 @@
import { ConnectionProvider } from './connection-context'
import { FC } from 'react'
import { ConnectionProvider } from './connection-context'
import { IdeReactProvider } from '@/features/ide-react/context/ide-react-context'
import { LayoutProvider } from '@/shared/context/layout-context'
import { DetachProvider } from '@/shared/context/detach-context'
import { ProjectProvider } from '@/shared/context/project-context'
import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/project-settings-context'
import { EditorProvider } from '@/shared/context/editor-context'
import { UserProvider } from '@/shared/context/user-context'
import { EditorManagerProvider } from '@/features/ide-react/context/editor-manager-context'
import { FileTreeDataProvider } from '@/shared/context/file-tree-data-context'
import { DetachCompileProvider } from '@/shared/context/detach-compile-context'
import { ChatProvider } from '@/features/chat/context/chat-context'
import { LocalCompileProvider } from '@/shared/context/local-compile-context'
import { OnlineUsersProvider } from '@/features/ide-react/context/online-users-context'
import { MetadataProvider } from '@/features/ide-react/context/metadata-context'
import { ReferencesProvider } from '@/features/ide-react/context/references-context'
import { SplitTestProvider } from '@/shared/context/split-test-context'
export const ReactContextRoot: FC = ({ children }) => {
return (
<ConnectionProvider>
<IdeReactProvider>
<UserProvider>
<ProjectProvider>{children}</ProjectProvider>
</UserProvider>
</IdeReactProvider>
</ConnectionProvider>
<SplitTestProvider>
<ConnectionProvider>
<IdeReactProvider>
<UserProvider>
<ProjectProvider>
<FileTreeDataProvider>
<ReferencesProvider>
<DetachProvider>
<EditorProvider>
<ProjectSettingsProvider>
<LayoutProvider>
<LocalCompileProvider>
<DetachCompileProvider>
<ChatProvider>
<EditorManagerProvider>
<OnlineUsersProvider>
<MetadataProvider>
{children}
</MetadataProvider>
</OnlineUsersProvider>
</EditorManagerProvider>
</ChatProvider>
</DetachCompileProvider>
</LocalCompileProvider>
</LayoutProvider>
</ProjectSettingsProvider>
</EditorProvider>
</DetachProvider>
</ReferencesProvider>
</FileTreeDataProvider>
</ProjectProvider>
</UserProvider>
</IdeReactProvider>
</ConnectionProvider>
</SplitTestProvider>
)
}
@@ -0,0 +1,197 @@
// @ts-ignore
import CryptoJSSHA1 from 'crypto-js/sha1'
import {
createContext,
useContext,
useEffect,
FC,
useCallback,
useMemo,
useState,
} from 'react'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import _ from 'lodash'
import { postJSON } from '@/infrastructure/fetch-json'
import { ShareJsDoc } from '@/features/ide-react/editor/share-js-doc'
import useScopeValue from '@/shared/hooks/use-scope-value'
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { findDocEntityById } from '@/features/ide-react/util/find-doc-entity-by-id'
type References = {
keys: string[]
}
type ReferencesContextValue = {
indexReferencesIfDocModified: (
doc: ShareJsDoc,
shouldBroadcast: boolean
) => void
indexReferences: (docIds: string[], shouldBroadcast: boolean) => void
indexAllReferences: (shouldBroadcast: boolean) => void
}
type IndexReferencesResponse = References
const ReferencesContext = createContext<ReferencesContextValue | undefined>(
undefined
)
export function populateReferenceScope(store: ReactScopeValueStore) {
store.set('$root._references', { keys: [] })
}
export const ReferencesProvider: FC = ({ children }) => {
const { fileTreeData } = useFileTreeData()
const { eventEmitter, projectId } = useIdeReactContext()
const { socket } = useConnectionContext()
const [references, setReferences] =
useScopeValue<References>('$root._references')
const [existingIndexHash, setExistingIndexHash] = useState<
Record<string, { hash: string; timestamp: number }>
>({})
const storeReferencesKeys = useCallback(
(newKeys: string[], replaceExistingKeys: boolean) => {
const oldKeys = references.keys
const keys = replaceExistingKeys ? newKeys : _.union(oldKeys, newKeys)
window.dispatchEvent(
new CustomEvent('project:references', {
detail: keys,
})
)
setReferences({ keys })
},
[references.keys, setReferences]
)
const indexReferences = useCallback(
(docIds: string[], shouldBroadcast: boolean) => {
postJSON(`/project/${projectId}/references/index`, {
body: {
docIds,
shouldBroadcast,
},
}).then((response: IndexReferencesResponse) => {
storeReferencesKeys(response.keys, false)
})
},
[projectId, storeReferencesKeys]
)
const indexAllReferences = useCallback(
(shouldBroadcast: boolean) => {
postJSON(`/project/${projectId}/references/indexAll`, {
body: {
shouldBroadcast,
},
}).then((response: IndexReferencesResponse) => {
storeReferencesKeys(response.keys, true)
})
},
[projectId, storeReferencesKeys]
)
const indexReferencesIfDocModified = useCallback(
(doc: ShareJsDoc, shouldBroadcast: boolean) => {
// avoid reindexing references if the bib file has not changed since the
// last time they were indexed
const docId = doc.doc_id
const snapshot = doc._doc.snapshot
const now = Date.now()
const sha1 = CryptoJSSHA1(
'blob ' + snapshot.length + '\x00' + snapshot
).toString()
const CACHE_LIFETIME = 6 * 3600 * 1000 // allow reindexing every 6 hours
const cacheEntry = existingIndexHash[docId]
const isCached =
cacheEntry &&
cacheEntry.timestamp > now - CACHE_LIFETIME &&
cacheEntry.hash === sha1
if (!isCached) {
indexReferences([docId], shouldBroadcast)
setExistingIndexHash(existingIndexHash => ({
...existingIndexHash,
[docId]: { hash: sha1, timestamp: now },
}))
}
},
[existingIndexHash, indexReferences]
)
useEffect(() => {
const handleDocClosed = (doc: ShareJsDoc) => {
if (
doc.doc_id &&
findDocEntityById(fileTreeData, doc.doc_id)?.name?.endsWith('.bib')
) {
indexReferencesIfDocModified(doc, true)
}
}
eventEmitter.on('document:closed', handleDocClosed)
return () => {
eventEmitter.off('document:closed', handleDocClosed)
}
}, [eventEmitter, fileTreeData, indexReferencesIfDocModified])
useEffect(() => {
const handleShouldReindex = () => {
indexAllReferences(true)
}
eventEmitter.on('references:should-reindex', handleShouldReindex)
return () => {
eventEmitter.off('references:should-reindex', handleShouldReindex)
}
}, [eventEmitter, indexAllReferences])
useEffect(() => {
const handleProjectJoined = () => {
// We only need to grab the references when the editor first loads,
// not on every reconnect
socket.on('references:keys:updated', (keys, allDocs) =>
storeReferencesKeys(keys, allDocs)
)
indexAllReferences(false)
}
eventEmitter.once('project:joined', handleProjectJoined)
return () => {
eventEmitter.off('project:joined', handleProjectJoined)
}
}, [eventEmitter, indexAllReferences, socket, storeReferencesKeys])
const value = useMemo<ReferencesContextValue>(
() => ({
indexReferencesIfDocModified,
indexReferences,
indexAllReferences,
}),
[indexReferencesIfDocModified, indexReferences, indexAllReferences]
)
return (
<ReferencesContext.Provider value={value}>
{children}
</ReferencesContext.Provider>
)
}
export function useReferencesContext(): ReferencesContextValue {
const context = useContext(ReferencesContext)
if (!context) {
throw new Error(
'useReferencesContext is only available inside ReferencesProvider'
)
}
return context
}