editor-providers.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  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 {
  15. createReactScopeValueStore,
  16. IdeReactContext,
  17. } from '@/features/ide-react/context/ide-react-context'
  18. import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
  19. import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
  20. import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
  21. import { ConnectionContext } from '@/features/ide-react/context/connection-context'
  22. import {
  23. EditorOpenDocContext,
  24. type EditorOpenDocContextState,
  25. } from '@/features/ide-react/context/editor-open-doc-context'
  26. import { ReactContextRoot } from '@/features/ide-react/context/react-context-root'
  27. import useEventListener from '@/shared/hooks/use-event-listener'
  28. import useDetachLayout from '@/shared/hooks/use-detach-layout'
  29. import useExposedState from '@/shared/hooks/use-exposed-state'
  30. import {
  31. type IdeLayout,
  32. type IdeView,
  33. LayoutContext,
  34. type LayoutContextValue,
  35. } from '@/shared/context/layout-context'
  36. import type { Socket } from '@/features/ide-react/connection/types/socket'
  37. import type { PermissionsLevel } from '@/features/ide-react/types/permissions'
  38. import type { Folder } from '../../../types/folder'
  39. import type { SocketDebuggingInfo } from '@/features/ide-react/connection/types/connection-state'
  40. import type { DocumentContainer } from '@/features/ide-react/editor/document-container'
  41. // these constants can be imported in tests instead of
  42. // using magic strings
  43. export const PROJECT_ID = 'project123'
  44. export const PROJECT_NAME = 'project-name'
  45. export const USER_ID = '123abd'
  46. export const USER_EMAIL = 'testuser@example.com'
  47. const defaultUserSettings = {
  48. pdfViewer: 'pdfjs',
  49. fontSize: 12,
  50. fontFamily: 'monaco',
  51. lineHeight: 'normal',
  52. editorTheme: 'textmate',
  53. overallTheme: '',
  54. mode: 'default',
  55. autoComplete: true,
  56. autoPairDelimiters: true,
  57. trackChanges: true,
  58. syntaxValidation: false,
  59. mathPreview: true,
  60. }
  61. export type EditorProvidersProps = {
  62. user?: { id: string; email: string }
  63. projectId?: string
  64. projectOwner?: { _id: string; email: string }
  65. rootDocId?: string
  66. imageName?: string
  67. compiler?: string
  68. socket?: Socket
  69. isRestrictedTokenMember?: boolean
  70. scope?: Record<string, any>
  71. features?: Record<string, boolean>
  72. projectFeatures?: Record<string, boolean>
  73. permissionsLevel?: PermissionsLevel
  74. children?: React.ReactNode
  75. rootFolder?: Folder[]
  76. layoutContext?: Partial<LayoutContextValue>
  77. userSettings?: Record<string, any>
  78. providers?: Record<string, React.FC<React.PropsWithChildren<any>>>
  79. }
  80. const layoutContextDefault = {
  81. view: 'editor',
  82. openFile: null,
  83. chatIsOpen: true, // false in the application, true in tests
  84. reviewPanelOpen: false,
  85. miniReviewPanelVisible: false,
  86. leftMenuShown: false,
  87. projectSearchIsOpen: false,
  88. pdfLayout: 'sideBySide',
  89. loadingStyleSheet: false,
  90. } satisfies Partial<LayoutContextValue>
  91. export function EditorProviders({
  92. user = { id: USER_ID, email: USER_EMAIL },
  93. projectId = PROJECT_ID,
  94. projectOwner = {
  95. _id: '124abd',
  96. email: 'owner@example.com',
  97. },
  98. rootDocId = '_root_doc_id',
  99. imageName = 'texlive-full:2024.1',
  100. compiler = 'pdflatex',
  101. socket = new SocketIOMock() as any as Socket,
  102. isRestrictedTokenMember = false,
  103. scope: defaultScope = {},
  104. features = {
  105. referencesSearch: true,
  106. },
  107. projectFeatures = features,
  108. permissionsLevel = 'owner',
  109. children,
  110. rootFolder = [
  111. {
  112. _id: 'root-folder-id',
  113. name: 'rootFolder',
  114. docs: [
  115. {
  116. _id: '_root_doc_id',
  117. name: 'main.tex',
  118. },
  119. ],
  120. folders: [],
  121. fileRefs: [],
  122. },
  123. ],
  124. /** @type {Partial<LayoutContext>} */
  125. layoutContext = layoutContextDefault,
  126. userSettings = {},
  127. providers = {},
  128. }: EditorProvidersProps) {
  129. window.metaAttributesCache.set(
  130. 'ol-gitBridgePublicBaseUrl',
  131. 'https://git.overleaf.test'
  132. )
  133. window.metaAttributesCache.set(
  134. 'ol-isRestrictedTokenMember',
  135. isRestrictedTokenMember
  136. )
  137. window.metaAttributesCache.set(
  138. 'ol-userSettings',
  139. merge({}, defaultUserSettings, userSettings)
  140. )
  141. window.metaAttributesCache.set('ol-capabilities', ['chat', 'dropbox'])
  142. const scope = merge(
  143. {
  144. user,
  145. editor: {
  146. sharejs_doc: {
  147. doc_id: 'test-doc',
  148. getSnapshot: () => 'some doc content',
  149. hasBufferedOps: () => false,
  150. on: () => {},
  151. off: () => {},
  152. leaveAndCleanUpPromise: async () => {},
  153. } as any as DocumentContainer,
  154. openDocName: null,
  155. currentDocumentId: null,
  156. },
  157. project: {
  158. _id: projectId,
  159. name: PROJECT_NAME,
  160. owner: projectOwner,
  161. features: projectFeatures,
  162. rootDocId,
  163. rootFolder,
  164. imageName,
  165. compiler,
  166. },
  167. permissionsLevel,
  168. },
  169. defaultScope
  170. )
  171. // Add details for useUserContext
  172. window.metaAttributesCache.set('ol-user', { ...user, features })
  173. window.metaAttributesCache.set('ol-project_id', projectId)
  174. return (
  175. <ReactContextRoot
  176. providers={{
  177. ConnectionProvider: makeConnectionProvider(socket),
  178. IdeReactProvider: makeIdeReactProvider(scope, socket),
  179. EditorOpenDocProvider: makeEditorOpenDocProvider({
  180. currentDocumentId: scope.editor.currentDocumentId,
  181. openDocName: scope.editor.openDocName,
  182. currentDocument: scope.editor.sharejs_doc,
  183. }),
  184. LayoutProvider: makeLayoutProvider(layoutContext),
  185. ...providers,
  186. }}
  187. >
  188. {children}
  189. </ReactContextRoot>
  190. )
  191. }
  192. const makeConnectionProvider = (socket: Socket) => {
  193. const ConnectionProvider: FC<PropsWithChildren> = ({ children }) => {
  194. const [value] = useState(() => ({
  195. socket,
  196. connectionState: {
  197. readyState: WebSocket.OPEN,
  198. forceDisconnected: false,
  199. inactiveDisconnect: false,
  200. reconnectAt: null,
  201. forcedDisconnectDelay: 0,
  202. lastConnectionAttempt: 0,
  203. error: '' as const,
  204. },
  205. isConnected: true,
  206. isStillReconnecting: false,
  207. secondsUntilReconnect: () => 0,
  208. tryReconnectNow: () => {},
  209. registerUserActivity: () => {},
  210. disconnect: () => {},
  211. closeConnection: () => {},
  212. getSocketDebuggingInfo: () => ({}) as SocketDebuggingInfo,
  213. }))
  214. return (
  215. <ConnectionContext.Provider value={value}>
  216. {children}
  217. </ConnectionContext.Provider>
  218. )
  219. }
  220. return ConnectionProvider
  221. }
  222. const makeIdeReactProvider = (
  223. scope: Record<string, unknown>,
  224. socket: Socket
  225. ) => {
  226. const IdeReactProvider: FC<PropsWithChildren> = ({ children }) => {
  227. const [startedFreeTrial, setStartedFreeTrial] = useState(false)
  228. const [ideReactContextValue] = useState(() => ({
  229. projectId: PROJECT_ID,
  230. eventEmitter: new IdeEventEmitter(),
  231. startedFreeTrial,
  232. setStartedFreeTrial,
  233. reportError: () => {},
  234. projectJoined: true,
  235. permissionsLevel: scope.permissionsLevel as PermissionsLevel,
  236. setPermissionsLevel: () => {},
  237. setOutOfSync: () => {},
  238. }))
  239. const [ideContextValue] = useState(() => {
  240. const scopeStore = createReactScopeValueStore(PROJECT_ID)
  241. for (const [key, value] of Object.entries(scope)) {
  242. // TODO: path for nested entries
  243. scopeStore.set(key, value)
  244. }
  245. const scopeEventEmitter = new ReactScopeEventEmitter(
  246. new IdeEventEmitter()
  247. )
  248. const unstableStore = new ReactScopeValueStore()
  249. return {
  250. socket,
  251. scopeStore,
  252. scopeEventEmitter,
  253. unstableStore,
  254. }
  255. })
  256. useEffect(() => {
  257. window.overleaf = {
  258. ...window.overleaf,
  259. unstable: {
  260. ...window.overleaf?.unstable,
  261. store: ideContextValue.unstableStore,
  262. },
  263. }
  264. }, [ideContextValue.unstableStore])
  265. return (
  266. <IdeReactContext.Provider value={ideReactContextValue}>
  267. <IdeContext.Provider value={ideContextValue}>
  268. {children}
  269. </IdeContext.Provider>
  270. </IdeReactContext.Provider>
  271. )
  272. }
  273. return IdeReactProvider
  274. }
  275. export function makeEditorOpenDocProvider(
  276. initialValues: EditorOpenDocContextState
  277. ) {
  278. const {
  279. currentDocumentId: initialCurrentDocumentId,
  280. openDocName: initialOpenDocName,
  281. currentDocument: initialCurrentDocument,
  282. } = initialValues
  283. const EditorOpenDocProvider: FC<PropsWithChildren> = ({ children }) => {
  284. const [currentDocumentId, setCurrentDocumentId] = useExposedState(
  285. initialCurrentDocumentId,
  286. 'editor.open_doc_id'
  287. )
  288. const [openDocName, setOpenDocName] = useExposedState(
  289. initialOpenDocName,
  290. 'editor.open_doc_name'
  291. )
  292. const [currentDocument, setCurrentDocument] = useState(
  293. initialCurrentDocument
  294. )
  295. const value = {
  296. currentDocumentId,
  297. setCurrentDocumentId,
  298. openDocName,
  299. setOpenDocName,
  300. currentDocument,
  301. setCurrentDocument,
  302. }
  303. return (
  304. <EditorOpenDocContext.Provider value={value}>
  305. {children}
  306. </EditorOpenDocContext.Provider>
  307. )
  308. }
  309. return EditorOpenDocProvider
  310. }
  311. const makeLayoutProvider = (
  312. layoutContextOverrides?: Partial<LayoutContextValue>
  313. ) => {
  314. const layout = {
  315. ...layoutContextDefault,
  316. ...layoutContextOverrides,
  317. }
  318. const LayoutProvider: FC<PropsWithChildren> = ({ children }) => {
  319. const [view, setView] = useState<IdeView | null>(layout.view)
  320. const [openFile, setOpenFile] = useState(layout.openFile)
  321. const [chatIsOpen, setChatIsOpen] = useState(layout.chatIsOpen)
  322. const [reviewPanelOpen, setReviewPanelOpen] = useState(
  323. layout.reviewPanelOpen
  324. )
  325. const [miniReviewPanelVisible, setMiniReviewPanelVisible] = useState(
  326. layout.miniReviewPanelVisible
  327. )
  328. const [leftMenuShown, setLeftMenuShown] = useState(layout.leftMenuShown)
  329. const [projectSearchIsOpen, setProjectSearchIsOpen] = useState(
  330. layout.projectSearchIsOpen
  331. )
  332. const [pdfLayout, setPdfLayout] = useState(layout.pdfLayout)
  333. const [loadingStyleSheet, setLoadingStyleSheet] = useState(
  334. layout.loadingStyleSheet
  335. )
  336. useEventListener(
  337. 'ui.toggle-review-panel',
  338. useCallback(() => {
  339. setReviewPanelOpen(open => !open)
  340. }, [setReviewPanelOpen])
  341. )
  342. const changeLayout = useCallback(
  343. (newLayout: IdeLayout, newView: IdeView = 'editor') => {
  344. setPdfLayout(newLayout)
  345. setView(newLayout === 'sideBySide' ? 'editor' : newView)
  346. },
  347. [setPdfLayout, setView]
  348. )
  349. const {
  350. reattach,
  351. detach,
  352. isLinked: detachIsLinked,
  353. role: detachRole,
  354. } = useDetachLayout()
  355. const pdfPreviewOpen =
  356. pdfLayout === 'sideBySide' || view === 'pdf' || detachRole === 'detacher'
  357. const value = useMemo(
  358. () => ({
  359. reattach,
  360. detach,
  361. detachIsLinked,
  362. detachRole,
  363. changeLayout,
  364. chatIsOpen,
  365. leftMenuShown,
  366. openFile,
  367. pdfLayout,
  368. pdfPreviewOpen,
  369. projectSearchIsOpen,
  370. setProjectSearchIsOpen,
  371. reviewPanelOpen,
  372. miniReviewPanelVisible,
  373. loadingStyleSheet,
  374. setChatIsOpen,
  375. setLeftMenuShown,
  376. setOpenFile,
  377. setPdfLayout,
  378. setReviewPanelOpen,
  379. setMiniReviewPanelVisible,
  380. setLoadingStyleSheet,
  381. setView,
  382. view,
  383. }),
  384. [
  385. reattach,
  386. detach,
  387. detachIsLinked,
  388. detachRole,
  389. changeLayout,
  390. chatIsOpen,
  391. leftMenuShown,
  392. openFile,
  393. pdfLayout,
  394. pdfPreviewOpen,
  395. projectSearchIsOpen,
  396. setProjectSearchIsOpen,
  397. reviewPanelOpen,
  398. miniReviewPanelVisible,
  399. loadingStyleSheet,
  400. setChatIsOpen,
  401. setLeftMenuShown,
  402. setOpenFile,
  403. setPdfLayout,
  404. setReviewPanelOpen,
  405. setMiniReviewPanelVisible,
  406. setLoadingStyleSheet,
  407. setView,
  408. view,
  409. ]
  410. )
  411. return (
  412. <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>
  413. )
  414. }
  415. return LayoutProvider
  416. }