editor-providers.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. // Disable prop type checks for test harnesses
  2. /* eslint-disable react/prop-types */
  3. import { merge } from 'lodash'
  4. import { SocketIOMock } from '@/ide/connection/SocketIoShim'
  5. import { IdeContext } from '@/shared/context/ide-context'
  6. import React, {
  7. useCallback,
  8. useEffect,
  9. useState,
  10. useMemo,
  11. type FC,
  12. type PropsWithChildren,
  13. } from 'react'
  14. import { IdeReactContext } from '@/features/ide-react/context/ide-react-context'
  15. import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
  16. import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
  17. import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
  18. import { ConnectionContext } from '@/features/ide-react/context/connection-context'
  19. import {
  20. EditorOpenDocContext,
  21. type EditorOpenDocContextState,
  22. } from '@/features/ide-react/context/editor-open-doc-context'
  23. import { ProjectContext } from '@/shared/context/project-context'
  24. import { ReactContextRoot } from '@/features/ide-react/context/react-context-root'
  25. import useEventListener from '@/shared/hooks/use-event-listener'
  26. import useDetachLayout from '@/shared/hooks/use-detach-layout'
  27. import useExposedState from '@/shared/hooks/use-exposed-state'
  28. import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
  29. import {
  30. EditorPropertiesContext,
  31. EditorPropertiesContextValue,
  32. } from '@/features/ide-react/context/editor-properties-context'
  33. import {
  34. type IdeLayout,
  35. type IdeView,
  36. LayoutContext,
  37. type LayoutContextValue,
  38. } from '@/shared/context/layout-context'
  39. import type { Socket } from '@/features/ide-react/connection/types/socket'
  40. import type { PermissionsLevel } from '@/features/ide-react/types/permissions'
  41. import type { Folder } from '../../../types/folder'
  42. import type { SocketDebuggingInfo } from '@/features/ide-react/connection/types/connection-state'
  43. import type { DocumentContainer } from '@/features/ide-react/editor/document-container'
  44. import {
  45. ProjectMetadata,
  46. ProjectUpdate,
  47. } from '@/shared/context/types/project-metadata'
  48. import { UserId } from '../../../types/user'
  49. import { ProjectCompiler } from '../../../types/project-settings'
  50. import { ReferencesContext } from '@/features/ide-react/context/references-context'
  51. import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
  52. // these constants can be imported in tests instead of
  53. // using magic strings
  54. export const PROJECT_ID = 'project123'
  55. export const PROJECT_NAME = 'project-name'
  56. export const USER_ID = '123abd' as UserId
  57. export const USER_EMAIL = 'testuser@example.com'
  58. const defaultUserSettings = {
  59. pdfViewer: 'pdfjs',
  60. fontSize: 12,
  61. fontFamily: 'monaco',
  62. lineHeight: 'normal',
  63. editorTheme: 'textmate',
  64. overallTheme: '',
  65. mode: 'default',
  66. autoComplete: true,
  67. autoPairDelimiters: true,
  68. trackChanges: true,
  69. syntaxValidation: false,
  70. mathPreview: true,
  71. }
  72. export type EditorProvidersProps = {
  73. user?: { id: string; email: string; signUpDate?: string }
  74. projectId?: string
  75. projectName?: string
  76. projectOwner?: ProjectMetadata['owner']
  77. rootDocId?: string
  78. imageName?: string
  79. compiler?: ProjectCompiler
  80. socket?: Socket
  81. isRestrictedTokenMember?: boolean
  82. scope?: Record<string, any>
  83. features?: Record<string, boolean>
  84. projectFeatures?: Record<string, boolean>
  85. permissionsLevel?: PermissionsLevel
  86. children?: React.ReactNode
  87. rootFolder?: Folder[]
  88. layoutContext?: Partial<LayoutContextValue>
  89. userSettings?: Record<string, any>
  90. providers?: Record<string, React.FC<React.PropsWithChildren<any>>>
  91. }
  92. export const projectDefaults: ProjectMetadata = {
  93. _id: PROJECT_ID,
  94. name: PROJECT_NAME,
  95. owner: {
  96. _id: '124abd' as UserId,
  97. email: 'owner@example.com',
  98. first_name: 'Test',
  99. last_name: 'Owner',
  100. privileges: 'owner',
  101. signUpDate: new Date('2025-07-07').toISOString(),
  102. },
  103. features: {
  104. referencesSearch: true,
  105. gitBridge: false,
  106. },
  107. rootDocId: '_root_doc_id',
  108. rootFolder: [
  109. {
  110. _id: 'root-folder-id',
  111. name: 'rootFolder',
  112. docs: [
  113. {
  114. _id: '_root_doc_id',
  115. name: 'main.tex',
  116. },
  117. ],
  118. folders: [],
  119. fileRefs: [],
  120. },
  121. ],
  122. imageName: 'texlive-full:2024.1',
  123. compiler: 'pdflatex' as ProjectCompiler,
  124. members: [],
  125. invites: [],
  126. trackChangesState: {} as Record<UserId | '__guests__', boolean>,
  127. spellCheckLanguage: 'en',
  128. }
  129. /**
  130. * @typedef {import('@/shared/context/layout-context').LayoutContextValue} LayoutContextValue
  131. * @type Partial<LayoutContextValue>
  132. */
  133. const layoutContextDefault = {
  134. view: 'editor',
  135. openFile: null,
  136. chatIsOpen: true, // false in the application, true in tests
  137. reviewPanelOpen: false,
  138. miniReviewPanelVisible: false,
  139. leftMenuShown: false,
  140. projectSearchIsOpen: false,
  141. pdfLayout: 'sideBySide',
  142. loadingStyleSheet: false,
  143. } satisfies Partial<LayoutContextValue>
  144. export function EditorProviders({
  145. user = {
  146. id: USER_ID,
  147. email: USER_EMAIL,
  148. signUpDate: '2025-10-10T10:10:10Z',
  149. },
  150. projectId = projectDefaults._id,
  151. projectName = projectDefaults.name,
  152. projectOwner = projectDefaults.owner,
  153. rootDocId = projectDefaults.rootDocId,
  154. imageName = projectDefaults.imageName,
  155. compiler = projectDefaults.compiler,
  156. socket = new SocketIOMock() as any as Socket,
  157. isRestrictedTokenMember = false,
  158. scope: defaultScope = {},
  159. features = {
  160. referencesSearch: true,
  161. gitBridge: false,
  162. },
  163. projectFeatures = features,
  164. permissionsLevel = 'owner',
  165. children,
  166. rootFolder = projectDefaults.rootFolder,
  167. /** @type {Partial<LayoutContext>} */
  168. layoutContext = layoutContextDefault,
  169. userSettings = {},
  170. providers = {},
  171. }: EditorProvidersProps) {
  172. window.metaAttributesCache.set(
  173. 'ol-gitBridgePublicBaseUrl',
  174. 'https://git.overleaf.test'
  175. )
  176. window.metaAttributesCache.set(
  177. 'ol-isRestrictedTokenMember',
  178. isRestrictedTokenMember
  179. )
  180. window.metaAttributesCache.set(
  181. 'ol-userSettings',
  182. merge({}, defaultUserSettings, userSettings)
  183. )
  184. window.metaAttributesCache.set('ol-capabilities', [
  185. 'chat',
  186. 'dropbox',
  187. 'link-sharing',
  188. ])
  189. const scope = merge(
  190. {
  191. user,
  192. editor: {
  193. sharejs_doc: {
  194. doc_id: 'test-doc',
  195. getSnapshot: () => 'some doc content',
  196. hasBufferedOps: () => false,
  197. on: () => {},
  198. off: () => {},
  199. leaveAndCleanUpPromise: async () => {},
  200. } as any as DocumentContainer,
  201. openDocName: null,
  202. currentDocumentId: null,
  203. wantTrackChanges: false,
  204. },
  205. permissionsLevel,
  206. },
  207. defaultScope
  208. )
  209. const project = {
  210. _id: projectId,
  211. name: projectName,
  212. owner: projectOwner,
  213. features: projectFeatures,
  214. rootDocId,
  215. rootFolder,
  216. imageName,
  217. compiler,
  218. members: [],
  219. invites: [],
  220. trackChangesState: false,
  221. spellCheckLanguage: 'en',
  222. }
  223. // Add details for useUserContext
  224. window.metaAttributesCache.set('ol-user', { ...user, features })
  225. window.metaAttributesCache.set('ol-project_id', projectId)
  226. return (
  227. <ReactContextRoot
  228. providers={{
  229. ConnectionProvider: makeConnectionProvider(socket),
  230. IdeReactProvider: makeIdeReactProvider(scope, socket),
  231. EditorOpenDocProvider: makeEditorOpenDocProvider({
  232. currentDocumentId: scope.editor.currentDocumentId,
  233. openDocName: scope.editor.openDocName,
  234. currentDocument: scope.editor.sharejs_doc,
  235. }),
  236. EditorPropertiesProvider: makeEditorPropertiesProvider({
  237. wantTrackChanges: scope.editor.wantTrackChanges,
  238. }),
  239. LayoutProvider: makeLayoutProvider(layoutContext),
  240. ProjectProvider: makeProjectProvider(project),
  241. ReferencesProvider: makeReferencesProvider(),
  242. ...providers,
  243. }}
  244. >
  245. {children}
  246. </ReactContextRoot>
  247. )
  248. }
  249. const makeReferencesProvider = () => {
  250. const ReferencesProvider: FC<PropsWithChildren> = ({ children }) => {
  251. return (
  252. <ReferencesContext.Provider
  253. value={{
  254. referenceKeys: new Set(),
  255. indexAllReferences: () => Promise.resolve(),
  256. searchLocalReferences() {
  257. return Promise.resolve({
  258. hits: [],
  259. })
  260. },
  261. }}
  262. >
  263. {children}
  264. </ReferencesContext.Provider>
  265. )
  266. }
  267. return ReferencesProvider
  268. }
  269. const makeConnectionProvider = (socket: Socket) => {
  270. const ConnectionProvider: FC<PropsWithChildren> = ({ children }) => {
  271. const [value] = useState(() => ({
  272. socket,
  273. connectionState: {
  274. readyState: WebSocket.OPEN,
  275. forceDisconnected: false,
  276. inactiveDisconnect: false,
  277. reconnectAt: null,
  278. forcedDisconnectDelay: 0,
  279. lastConnectionAttempt: 0,
  280. error: '' as const,
  281. },
  282. isConnected: true,
  283. isStillReconnecting: false,
  284. secondsUntilReconnect: () => 0,
  285. tryReconnectNow: () => {},
  286. registerUserActivity: () => {},
  287. disconnect: () => {},
  288. closeConnection: () => {},
  289. getSocketDebuggingInfo: () => ({}) as SocketDebuggingInfo,
  290. }))
  291. return (
  292. <ConnectionContext.Provider value={value}>
  293. {children}
  294. </ConnectionContext.Provider>
  295. )
  296. }
  297. return ConnectionProvider
  298. }
  299. const makeIdeReactProvider = (
  300. scope: Record<string, unknown>,
  301. socket: Socket
  302. ) => {
  303. const IdeReactProvider: FC<PropsWithChildren> = ({ children }) => {
  304. const [startedFreeTrial, setStartedFreeTrial] = useState(false)
  305. const [ideReactContextValue] = useState(() => ({
  306. projectId: PROJECT_ID,
  307. eventEmitter: new IdeEventEmitter(),
  308. startedFreeTrial,
  309. setStartedFreeTrial,
  310. reportError: () => {},
  311. projectJoined: true,
  312. permissionsLevel: scope.permissionsLevel as PermissionsLevel,
  313. setPermissionsLevel: () => {},
  314. setOutOfSync: () => {},
  315. }))
  316. const [ideContextValue] = useState(() => {
  317. const scopeEventEmitter = new ReactScopeEventEmitter(
  318. new IdeEventEmitter()
  319. )
  320. const unstableStore = new ReactScopeValueStore()
  321. return {
  322. socket,
  323. scopeEventEmitter,
  324. unstableStore,
  325. }
  326. })
  327. useEffect(() => {
  328. window.overleaf = {
  329. ...window.overleaf,
  330. unstable: {
  331. ...window.overleaf?.unstable,
  332. store: ideContextValue.unstableStore,
  333. },
  334. }
  335. }, [ideContextValue.unstableStore])
  336. return (
  337. <IdeReactContext.Provider value={ideReactContextValue}>
  338. <IdeContext.Provider value={ideContextValue}>
  339. {children}
  340. </IdeContext.Provider>
  341. </IdeReactContext.Provider>
  342. )
  343. }
  344. return IdeReactProvider
  345. }
  346. export function makeEditorOpenDocProvider(
  347. initialValues: EditorOpenDocContextState
  348. ) {
  349. const {
  350. currentDocumentId: initialCurrentDocumentId,
  351. openDocName: initialOpenDocName,
  352. currentDocument: initialCurrentDocument,
  353. } = initialValues
  354. const EditorOpenDocProvider: FC<PropsWithChildren> = ({ children }) => {
  355. const [currentDocumentId, setCurrentDocumentId] = useExposedState(
  356. initialCurrentDocumentId,
  357. 'editor.open_doc_id'
  358. )
  359. const [openDocName, setOpenDocName] = useExposedState(
  360. initialOpenDocName,
  361. 'editor.open_doc_name'
  362. )
  363. const [currentDocument, setCurrentDocument] = useState(
  364. initialCurrentDocument
  365. )
  366. const value = {
  367. currentDocumentId,
  368. setCurrentDocumentId,
  369. openDocName,
  370. setOpenDocName,
  371. currentDocument,
  372. setCurrentDocument,
  373. }
  374. return (
  375. <EditorOpenDocContext.Provider value={value}>
  376. {children}
  377. </EditorOpenDocContext.Provider>
  378. )
  379. }
  380. return EditorOpenDocProvider
  381. }
  382. const makeLayoutProvider = (
  383. layoutContextOverrides?: Partial<LayoutContextValue>
  384. ) => {
  385. const layout = {
  386. ...layoutContextDefault,
  387. ...layoutContextOverrides,
  388. }
  389. const LayoutProvider: FC<PropsWithChildren> = ({ children }) => {
  390. const [view, setView] = useState<IdeView | null>(layout.view)
  391. const [openFile, setOpenFile] = useState(layout.openFile)
  392. const [chatIsOpen, setChatIsOpen] = useState(layout.chatIsOpen)
  393. const [reviewPanelOpen, setReviewPanelOpen] = useState(
  394. layout.reviewPanelOpen
  395. )
  396. const [miniReviewPanelVisible, setMiniReviewPanelVisible] = useState(
  397. layout.miniReviewPanelVisible
  398. )
  399. const [leftMenuShown, setLeftMenuShown] = useState(layout.leftMenuShown)
  400. const [projectSearchIsOpen, setProjectSearchIsOpen] = useState(
  401. layout.projectSearchIsOpen
  402. )
  403. const [pdfLayout, setPdfLayout] = useState(layout.pdfLayout)
  404. const [loadingStyleSheet, setLoadingStyleSheet] = useState(
  405. layout.loadingStyleSheet
  406. )
  407. const { sendEvent } = useEditorAnalytics()
  408. useEventListener(
  409. 'ui.toggle-review-panel',
  410. useCallback(() => {
  411. setReviewPanelOpen(open => !open)
  412. }, [setReviewPanelOpen])
  413. )
  414. const changeLayout = useCallback(
  415. (newLayout: IdeLayout, newView: IdeView = 'editor') => {
  416. setPdfLayout(newLayout)
  417. setView(newLayout === 'sideBySide' ? 'editor' : newView)
  418. },
  419. [setPdfLayout, setView]
  420. )
  421. const restoreView = useCallback(() => {
  422. setView('editor')
  423. }, [])
  424. const {
  425. reattach,
  426. detach,
  427. isLinked: detachIsLinked,
  428. role: detachRole,
  429. } = useDetachLayout()
  430. const handleDetach = useCallback(() => {
  431. detach()
  432. sendEvent('project-layout-detach')
  433. }, [detach, sendEvent])
  434. const handleReattach = useCallback(() => {
  435. if (detachRole !== 'detacher') {
  436. return
  437. }
  438. reattach()
  439. sendEvent('project-layout-reattach')
  440. }, [detachRole, reattach, sendEvent])
  441. const handleChangeLayout = useCallback(
  442. (newLayout: IdeLayout, newView?: IdeView) => {
  443. handleReattach()
  444. changeLayout(newLayout, newView)
  445. sendEvent('project-layout-change', {
  446. layout: newLayout,
  447. view: newView,
  448. })
  449. },
  450. [changeLayout, handleReattach, sendEvent]
  451. )
  452. const pdfPreviewOpen =
  453. pdfLayout === 'sideBySide' || view === 'pdf' || detachRole === 'detacher'
  454. const value = useMemo(
  455. () => ({
  456. reattach,
  457. detach,
  458. detachIsLinked,
  459. detachRole,
  460. changeLayout,
  461. chatIsOpen,
  462. leftMenuShown,
  463. openFile,
  464. pdfLayout,
  465. pdfPreviewOpen,
  466. projectSearchIsOpen,
  467. setProjectSearchIsOpen,
  468. reviewPanelOpen,
  469. miniReviewPanelVisible,
  470. loadingStyleSheet,
  471. setChatIsOpen,
  472. setLeftMenuShown,
  473. setOpenFile,
  474. setPdfLayout,
  475. setReviewPanelOpen,
  476. setMiniReviewPanelVisible,
  477. setLoadingStyleSheet,
  478. setView,
  479. view,
  480. restoreView,
  481. handleChangeLayout,
  482. handleDetach,
  483. }),
  484. [
  485. reattach,
  486. detach,
  487. detachIsLinked,
  488. detachRole,
  489. changeLayout,
  490. chatIsOpen,
  491. leftMenuShown,
  492. openFile,
  493. pdfLayout,
  494. pdfPreviewOpen,
  495. projectSearchIsOpen,
  496. setProjectSearchIsOpen,
  497. reviewPanelOpen,
  498. miniReviewPanelVisible,
  499. loadingStyleSheet,
  500. setChatIsOpen,
  501. setLeftMenuShown,
  502. setOpenFile,
  503. setPdfLayout,
  504. setReviewPanelOpen,
  505. setMiniReviewPanelVisible,
  506. setLoadingStyleSheet,
  507. setView,
  508. view,
  509. restoreView,
  510. handleChangeLayout,
  511. handleDetach,
  512. ]
  513. )
  514. return (
  515. <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>
  516. )
  517. }
  518. return LayoutProvider
  519. }
  520. export function makeEditorPropertiesProvider(
  521. initialValues: Partial<
  522. Pick<
  523. EditorPropertiesContextValue,
  524. 'showVisual' | 'showSymbolPalette' | 'wantTrackChanges'
  525. >
  526. >
  527. ) {
  528. const EditorPropertiesProvider: FC<PropsWithChildren> = ({ children }) => {
  529. const {
  530. showVisual: initialShowVisual,
  531. showSymbolPalette: initialShowSymbolPalette,
  532. wantTrackChanges: initialWantTrackChanges,
  533. } = initialValues
  534. const [showVisual, setShowVisual] = useState(initialShowVisual || false)
  535. const [showSymbolPalette, setShowSymbolPalette] = useState(
  536. initialShowSymbolPalette || false
  537. )
  538. function toggleSymbolPalette() {
  539. setShowSymbolPalette(show => !show)
  540. }
  541. const [opening, setOpening] = useState(true)
  542. const [trackChanges, setTrackChanges] = useState(false)
  543. const [wantTrackChanges, setWantTrackChanges] = useState(
  544. initialWantTrackChanges || false
  545. )
  546. const [errorState, setErrorState] = useState(false)
  547. const value = {
  548. showVisual,
  549. setShowVisual,
  550. showSymbolPalette,
  551. setShowSymbolPalette,
  552. toggleSymbolPalette,
  553. opening,
  554. setOpening,
  555. trackChanges,
  556. setTrackChanges,
  557. wantTrackChanges,
  558. setWantTrackChanges,
  559. errorState,
  560. setErrorState,
  561. }
  562. return (
  563. <EditorPropertiesContext.Provider value={value}>
  564. {children}
  565. </EditorPropertiesContext.Provider>
  566. )
  567. }
  568. return EditorPropertiesProvider
  569. }
  570. export function makeProjectProvider(initialProject: ProjectMetadata) {
  571. const ProjectProvider: FC<PropsWithChildren> = ({ children }) => {
  572. const [project, setProject] = useState(initialProject)
  573. const updateProject = useCallback((projectUpdateData: ProjectUpdate) => {
  574. setProject(projectData =>
  575. Object.assign({}, projectData, projectUpdateData)
  576. )
  577. }, [])
  578. const value = {
  579. projectId: project._id,
  580. project,
  581. joinProject: () => {},
  582. updateProject,
  583. joinedOnce: true,
  584. projectSnapshot: new ProjectSnapshot(project._id),
  585. tags: [],
  586. features: project.features,
  587. name: project.name,
  588. }
  589. return (
  590. <ProjectContext.Provider value={value}>
  591. {children}
  592. </ProjectContext.Provider>
  593. )
  594. }
  595. return ProjectProvider
  596. }