editor-manager-context.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615
  1. import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
  2. import {
  3. createContext,
  4. FC,
  5. useCallback,
  6. useContext,
  7. useEffect,
  8. useMemo,
  9. useRef,
  10. useState,
  11. } from 'react'
  12. import { sendMB } from '@/infrastructure/event-tracking'
  13. import useScopeValue from '@/shared/hooks/use-scope-value'
  14. import { useIdeContext } from '@/shared/context/ide-context'
  15. import { OpenDocuments } from '@/features/ide-react/editor/open-documents'
  16. import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager'
  17. import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
  18. import { useConnectionContext } from '@/features/ide-react/context/connection-context'
  19. import { debugConsole } from '@/utils/debugging'
  20. import { Document } from '@/features/ide-react/editor/document'
  21. import { useLayoutContext } from '@/shared/context/layout-context'
  22. import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
  23. import _ from 'lodash'
  24. import { Doc } from '../../../../../types/doc'
  25. import { useFileTreeData } from '@/shared/context/file-tree-data-context'
  26. import { findDocEntityById } from '@/features/ide-react/util/find-doc-entity-by-id'
  27. import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
  28. import { useModalsContext } from '@/features/ide-react/context/modals-context'
  29. import { useTranslation } from 'react-i18next'
  30. interface GotoOffsetOptions {
  31. gotoOffset: number
  32. }
  33. interface OpenDocOptions
  34. extends Partial<GotoLineOptions>,
  35. Partial<GotoOffsetOptions> {
  36. gotoOffset?: number
  37. forceReopen?: boolean
  38. }
  39. type EditorManager = {
  40. getEditorType: () => 'cm6' | 'cm6-rich-text' | null
  41. showSymbolPalette: boolean
  42. currentDocument: Document
  43. currentDocumentId: string | null
  44. getCurrentDocValue: () => string | null
  45. getCurrentDocId: () => string | null
  46. startIgnoringExternalUpdates: () => void
  47. stopIgnoringExternalUpdates: () => void
  48. openDocId: (docId: string, options?: OpenDocOptions) => void
  49. openDoc: (document: Doc, options?: OpenDocOptions) => void
  50. jumpToLine: (options: GotoLineOptions) => void
  51. }
  52. function hasGotoLine(options: OpenDocOptions): options is GotoLineOptions {
  53. return typeof options.gotoLine === 'number'
  54. }
  55. function hasGotoOffset(options: OpenDocOptions): options is GotoOffsetOptions {
  56. return typeof options.gotoOffset === 'number'
  57. }
  58. export type EditorScopeValue = {
  59. showSymbolPalette: false
  60. toggleSymbolPalette: () => void
  61. sharejs_doc: Document | null
  62. open_doc_id: string | null
  63. open_doc_name: string | null
  64. opening: boolean
  65. trackChanges: boolean
  66. wantTrackChanges: boolean
  67. showVisual: boolean
  68. newSourceEditor: boolean
  69. error_state: boolean
  70. }
  71. export function populateEditorScope(
  72. store: ReactScopeValueStore,
  73. projectId: string
  74. ) {
  75. // This value is not used in the React code. It's just here to prevent errors
  76. // from EditorProvider
  77. store.set('state.loading', false)
  78. store.set('project.name', null)
  79. store.set('editor', {
  80. showSymbolPalette: false,
  81. toggleSymbolPalette: () => {},
  82. sharejs_doc: null,
  83. open_doc_id: null,
  84. open_doc_name: null,
  85. opening: true,
  86. trackChanges: false,
  87. wantTrackChanges: false,
  88. // No Ace here
  89. newSourceEditor: true,
  90. error_state: false,
  91. })
  92. store.persisted('editor.showVisual', false, `editor.mode.${projectId}`, {
  93. toPersisted: showVisual => (showVisual ? 'rich-text' : 'source'),
  94. fromPersisted: mode => mode === 'rich-text',
  95. })
  96. }
  97. const EditorManagerContext = createContext<EditorManager | undefined>(undefined)
  98. export const EditorManagerProvider: FC = ({ children }) => {
  99. const { t } = useTranslation()
  100. const ide = useIdeContext()
  101. const { reportError, eventEmitter, eventLog } = useIdeReactContext()
  102. const { socket, disconnect } = useConnectionContext()
  103. const { view, setView } = useLayoutContext()
  104. const { showGenericMessageModal, genericModalVisible } = useModalsContext()
  105. const [showSymbolPalette, setShowSymbolPalette] = useScopeValue<boolean>(
  106. 'editor.showSymbolPalette'
  107. )
  108. const [showVisual] = useScopeValue<boolean>('editor.showVisual')
  109. // eslint-disable-next-line no-unused-vars
  110. const [currentDocument, setCurrentDocument] =
  111. useScopeValue<Document>('editor.sharejs_doc')
  112. const [openDocId, setOpenDocId] = useScopeValue<string | null>(
  113. 'editor.open_doc_id'
  114. )
  115. const [, setOpenDocName] = useScopeValue<string | null>(
  116. 'editor.open_doc_name'
  117. )
  118. const [, setOpening] = useScopeValue<boolean>('editor.opening')
  119. const [, setIsInErrorState] = useScopeValue<boolean>('editor.error_state')
  120. const [, setTrackChanges] = useScopeValue<boolean>('editor.trackChanges')
  121. const [wantTrackChanges] = useScopeValue<boolean>('editor.wantTrackChanges')
  122. const goToLineEmitter = useScopeEventEmitter('editor:gotoLine')
  123. const { fileTreeData } = useFileTreeData()
  124. // eslint-disable-next-line no-unused-vars
  125. const [ignoringExternalUpdates, setIgnoringExternalUpdates] = useState(false)
  126. const [globalEditorWatchdogManager] = useState(
  127. () =>
  128. new EditorWatchdogManager({
  129. onTimeoutHandler: (meta: Record<string, any>) => {
  130. sendMB('losing-edits', meta)
  131. reportError('losing-edits', meta)
  132. },
  133. })
  134. )
  135. // Store the most recent document error and consume it in an effect, which
  136. // prevents circular dependencies in useCallbacks
  137. const [docError, setDocError] = useState<{
  138. doc: Doc
  139. document: Document
  140. error: Error | string
  141. meta?: Record<string, any>
  142. editorContent?: string
  143. } | null>(null)
  144. const [docTooLongErrorShown, setDocTooLongErrorShown] = useState(false)
  145. useEffect(() => {
  146. if (!genericModalVisible) {
  147. setDocTooLongErrorShown(false)
  148. }
  149. }, [genericModalVisible])
  150. const [openDocs] = useState(
  151. () =>
  152. new OpenDocuments(
  153. socket,
  154. globalEditorWatchdogManager,
  155. eventEmitter,
  156. eventLog
  157. )
  158. )
  159. const editorOpenDocEpochRef = useRef(0)
  160. // TODO: This looks dodgy because it wraps a state setter and is itself
  161. // stored in React state in the scope store. The problem is that it needs to
  162. // be exposed via the scope store because some components access it that way;
  163. // it would be better to simply access it from a context, but the current
  164. // implementation in EditorManager interacts with Angular scope to update
  165. // the layout. Once Angular is gone, this can become a context method.
  166. useEffect(() => {
  167. ide.scopeStore.set('editor.toggleSymbolPalette', () => {
  168. setShowSymbolPalette(show => {
  169. const newValue = !show
  170. sendMB(newValue ? 'symbol-palette-show' : 'symbol-palette-hide')
  171. return newValue
  172. })
  173. })
  174. }, [ide.scopeStore, setShowSymbolPalette])
  175. const getEditorType = useCallback(() => {
  176. if (!currentDocument) {
  177. return null
  178. }
  179. return showVisual ? 'cm6-rich-text' : 'cm6'
  180. }, [currentDocument, showVisual])
  181. const getCurrentDocValue = useCallback(() => {
  182. return currentDocument?.getSnapshot() ?? null
  183. }, [currentDocument])
  184. const getCurrentDocId = useCallback(() => openDocId, [openDocId])
  185. const startIgnoringExternalUpdates = useCallback(
  186. () => setIgnoringExternalUpdates(true),
  187. []
  188. )
  189. const stopIgnoringExternalUpdates = useCallback(
  190. () => setIgnoringExternalUpdates(false),
  191. []
  192. )
  193. const jumpToLine = useCallback(
  194. (options: GotoLineOptions) => {
  195. goToLineEmitter(
  196. options.gotoLine,
  197. options.gotoColumn ?? 0,
  198. options.syncToPdf ?? false
  199. )
  200. },
  201. [goToLineEmitter]
  202. )
  203. const unbindFromDocumentEvents = (document: Document) => {
  204. document.off()
  205. }
  206. const attachErrorHandlerToDocument = useCallback(
  207. (doc: Doc, document: Document) => {
  208. document.on(
  209. 'error',
  210. (
  211. error: Error | string,
  212. meta?: Record<string, any>,
  213. editorContent?: string
  214. ) => {
  215. setDocError({ doc, document, error, meta, editorContent })
  216. }
  217. )
  218. },
  219. []
  220. )
  221. const bindToDocumentEvents = useCallback(
  222. (doc: Doc, document: Document) => {
  223. attachErrorHandlerToDocument(doc, document)
  224. // TODO: MIGRATION: Add a type for `update`
  225. document.on('externalUpdate', (update: any) => {
  226. if (ignoringExternalUpdates) {
  227. return
  228. }
  229. if (
  230. _.property(['meta', 'type'])(update) === 'external' &&
  231. _.property(['meta', 'source'])(update) === 'git-bridge'
  232. ) {
  233. return
  234. }
  235. showGenericMessageModal(
  236. t('document_updated_externally'),
  237. t('document_updated_externally_detail')
  238. )
  239. })
  240. },
  241. [
  242. attachErrorHandlerToDocument,
  243. ignoringExternalUpdates,
  244. showGenericMessageModal,
  245. t,
  246. ]
  247. )
  248. const syncTimeoutRef = useRef<number | null>(null)
  249. const syncTrackChangesState = useCallback(
  250. (doc: Document) => {
  251. if (!doc) {
  252. return
  253. }
  254. if (syncTimeoutRef.current) {
  255. window.clearTimeout(syncTimeoutRef.current)
  256. syncTimeoutRef.current = null
  257. }
  258. const want = wantTrackChanges
  259. const have = doc.getTrackingChanges()
  260. if (wantTrackChanges === have) {
  261. setTrackChanges(want)
  262. return
  263. }
  264. const tryToggle = () => {
  265. const saved = doc.getInflightOp() == null && doc.getPendingOp() == null
  266. if (saved) {
  267. doc.setTrackingChanges(wantTrackChanges)
  268. setTrackChanges(want)
  269. } else {
  270. syncTimeoutRef.current = window.setTimeout(tryToggle, 100)
  271. }
  272. }
  273. tryToggle()
  274. },
  275. [setTrackChanges, wantTrackChanges]
  276. )
  277. const doOpenNewDocument = useCallback(
  278. (doc: Doc) =>
  279. new Promise<Document>((resolve, reject) => {
  280. debugConsole.log('[doOpenNewDocument] Opening...')
  281. const newDocument = openDocs.getDocument(doc._id)
  282. if (!newDocument) {
  283. debugConsole.error(`No open document with ID '${doc._id}' found`)
  284. reject(new Error('no open document found'))
  285. return
  286. }
  287. const preJoinEpoch = ++editorOpenDocEpochRef.current
  288. newDocument.join(error => {
  289. if (error) {
  290. debugConsole.log(
  291. `[doOpenNewDocument] error joining doc ${doc._id}`,
  292. error
  293. )
  294. reject(error)
  295. return
  296. }
  297. if (editorOpenDocEpochRef.current !== preJoinEpoch) {
  298. debugConsole.log(
  299. `[doOpenNewDocument] editorOpenDocEpoch mismatch ${editorOpenDocEpochRef.current} vs ${preJoinEpoch}`
  300. )
  301. newDocument.leaveAndCleanUp()
  302. reject(new Error('another document was loaded'))
  303. }
  304. bindToDocumentEvents(doc, newDocument)
  305. resolve(newDocument)
  306. })
  307. }),
  308. [bindToDocumentEvents, openDocs]
  309. )
  310. const openNewDocument = useCallback(
  311. async (doc: Doc): Promise<Document> => {
  312. // Leave the current document
  313. // - when we are opening a different new one, to avoid race conditions
  314. // between leaving and joining the same document
  315. // - when the current one has pending ops that need flushing, to avoid
  316. // race conditions from cleanup
  317. const currentDocId = currentDocument?.doc_id
  318. const hasBufferedOps = currentDocument?.hasBufferedOps()
  319. const changingDoc = currentDocument && currentDocId !== doc._id
  320. if (changingDoc || hasBufferedOps) {
  321. debugConsole.log('[openNewDocument] Leaving existing open doc...')
  322. // Do not trigger any UI changes from remote operations
  323. unbindFromDocumentEvents(currentDocument)
  324. // Keep listening for out-of-sync and similar errors.
  325. attachErrorHandlerToDocument(doc, currentDocument)
  326. // Teardown the Document -> ShareJsDoc -> sharejs doc
  327. // By the time this completes, the Document instance is no longer
  328. // registered in OpenDocuments and doOpenNewDocument can start
  329. // from scratch -- read: no corrupted internal state.
  330. const preLeaveEpoch = ++editorOpenDocEpochRef.current
  331. try {
  332. await currentDocument.leaveAndCleanUpPromise()
  333. } catch (error) {
  334. debugConsole.log(
  335. `[openNewDocument] error leaving doc ${currentDocId}`,
  336. error
  337. )
  338. throw error
  339. }
  340. if (editorOpenDocEpochRef.current !== preLeaveEpoch) {
  341. debugConsole.log(
  342. `[openNewDocument] editorOpenDocEpoch mismatch ${editorOpenDocEpochRef.current} vs ${preLeaveEpoch}`
  343. )
  344. throw new Error('another document was loaded')
  345. }
  346. }
  347. return doOpenNewDocument(doc)
  348. },
  349. [attachErrorHandlerToDocument, doOpenNewDocument, currentDocument]
  350. )
  351. const openDoc = useCallback(
  352. async (doc: Doc, options: OpenDocOptions = {}) => {
  353. debugConsole.log(`[openDoc] Opening ${doc._id}`)
  354. if (view === 'editor') {
  355. // store position of previous doc before switching docs
  356. eventEmitter.emit('store-doc-position')
  357. }
  358. setView('editor')
  359. const done = (isNewDoc: boolean) => {
  360. window.dispatchEvent(
  361. new CustomEvent('doc:after-opened', { detail: isNewDoc })
  362. )
  363. if (hasGotoLine(options)) {
  364. // In CM6, jump to the line again after a stored scroll position has been restored
  365. if (isNewDoc) {
  366. window.addEventListener(
  367. 'editor:scroll-position-restored',
  368. () => jumpToLine(options),
  369. { once: true }
  370. )
  371. }
  372. } else if (hasGotoOffset(options)) {
  373. window.setTimeout(() => {
  374. eventEmitter.emit('editor:gotoOffset', options.gotoOffset)
  375. })
  376. }
  377. }
  378. // If we already have the document open we can return at this point.
  379. // Note: only use forceReopen:true to override this when the document is
  380. // out of sync and needs to be reloaded from the server.
  381. if (doc._id === openDocId && !options.forceReopen) {
  382. done(false)
  383. return
  384. }
  385. // We're now either opening a new document or reloading a broken one.
  386. setOpenDocId(doc._id)
  387. setOpenDocName(doc.name)
  388. setOpening(true)
  389. try {
  390. const document = await openNewDocument(doc)
  391. syncTrackChangesState(document)
  392. eventEmitter.emit('doc:opened')
  393. setOpening(false)
  394. setCurrentDocument(document)
  395. done(true)
  396. } catch (error: any) {
  397. if (error?.message === 'another document was loaded') {
  398. debugConsole.log(
  399. `[openDoc] another document was loaded while ${doc._id} was loading`
  400. )
  401. return
  402. }
  403. showGenericMessageModal(
  404. t('error_opening_document'),
  405. t('error_opening_document_detail')
  406. )
  407. }
  408. },
  409. [
  410. eventEmitter,
  411. jumpToLine,
  412. openDocId,
  413. openNewDocument,
  414. setCurrentDocument,
  415. setOpenDocId,
  416. setOpenDocName,
  417. setOpening,
  418. setView,
  419. showGenericMessageModal,
  420. syncTrackChangesState,
  421. t,
  422. view,
  423. ]
  424. )
  425. const openDocWithId = useCallback(
  426. (docId: string, options: OpenDocOptions = {}) => {
  427. const doc = findDocEntityById(fileTreeData, docId)
  428. if (!doc) {
  429. return
  430. }
  431. openDoc(doc, options)
  432. },
  433. [fileTreeData, openDoc]
  434. )
  435. useEffect(() => {
  436. if (docError) {
  437. const { doc, document, error, meta } = docError
  438. let { editorContent } = docError
  439. const message = typeof error === 'string' ? error : error?.message ?? ''
  440. // Clear document error so that it's only handled once
  441. setDocError(null)
  442. if (message.includes('maxDocLength')) {
  443. openDoc(doc, { forceReopen: true })
  444. showGenericMessageModal(
  445. t('document_too_long'),
  446. t('document_too_long_detail')
  447. )
  448. setDocTooLongErrorShown(true)
  449. } else if (/too many comments or tracked changes/.test(message)) {
  450. showGenericMessageModal(
  451. t('too_many_comments_or_tracked_changes'),
  452. t('too_many_comments_or_tracked_changes_detail')
  453. )
  454. } else if (!docTooLongErrorShown) {
  455. // Do not allow this doc to open another error modal.
  456. document.off('error')
  457. // Preserve the sharejs contents before the teardown.
  458. // eslint-disable-next-line no-unused-vars
  459. editorContent =
  460. typeof editorContent === 'string'
  461. ? editorContent
  462. : document.doc?._doc.snapshot
  463. // Tear down the ShareJsDoc.
  464. if (document.doc) document.doc.clearInflightAndPendingOps()
  465. // Do not re-join after re-connecting.
  466. document.leaveAndCleanUp()
  467. disconnect()
  468. reportError(error, meta)
  469. // Tell the user about the error state.
  470. setIsInErrorState(true)
  471. // TODO: MIGRATION: Show out-of-sync modal
  472. // this.ide.showOutOfSyncModal(
  473. // 'Out of sync',
  474. // "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>",
  475. // editorContent
  476. // )
  477. // Do not forceReopen the document.
  478. return
  479. }
  480. const handleProjectJoined = () => {
  481. openDoc(doc, { forceReopen: true })
  482. }
  483. eventEmitter.once('project:joined', handleProjectJoined)
  484. return () => {
  485. eventEmitter.off('project:joined', handleProjectJoined)
  486. }
  487. }
  488. }, [
  489. disconnect,
  490. docError,
  491. docTooLongErrorShown,
  492. eventEmitter,
  493. openDoc,
  494. reportError,
  495. setIsInErrorState,
  496. showGenericMessageModal,
  497. t,
  498. ])
  499. const editorManager = useMemo(
  500. () => ({
  501. getEditorType,
  502. showSymbolPalette,
  503. currentDocument,
  504. currentDocumentId: openDocId,
  505. getCurrentDocValue,
  506. getCurrentDocId,
  507. startIgnoringExternalUpdates,
  508. stopIgnoringExternalUpdates,
  509. openDocId: openDocWithId,
  510. openDoc,
  511. jumpToLine,
  512. }),
  513. [
  514. getEditorType,
  515. showSymbolPalette,
  516. currentDocument,
  517. openDocId,
  518. getCurrentDocValue,
  519. getCurrentDocId,
  520. startIgnoringExternalUpdates,
  521. stopIgnoringExternalUpdates,
  522. openDocWithId,
  523. openDoc,
  524. jumpToLine,
  525. ]
  526. )
  527. // Expose editorManager via ide object because some React code relies on it,
  528. // for now
  529. ide.editorManager = editorManager
  530. return (
  531. <EditorManagerContext.Provider value={editorManager}>
  532. {children}
  533. </EditorManagerContext.Provider>
  534. )
  535. }
  536. export function useEditorManagerContext(): EditorManager {
  537. const context = useContext(EditorManagerContext)
  538. if (!context) {
  539. throw new Error(
  540. 'useEditorManagerContext is only available inside EditorManagerProvider'
  541. )
  542. }
  543. return context
  544. }