editor-providers.tsx 16 KB

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