editor-manager-context.tsx 19 KB

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