editor-manager-context.tsx 19 KB

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