EditorManager.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. import _ from 'lodash'
  2. /* eslint-disable
  3. camelcase,
  4. n/handle-callback-err,
  5. max-len,
  6. no-return-assign,
  7. */
  8. // TODO: This file was created by bulk-decaffeinate.
  9. // Fix any style issues and re-enable lint.
  10. /*
  11. * decaffeinate suggestions:
  12. * DS102: Remove unnecessary code created because of implicit returns
  13. * DS206: Consider reworking classes to avoid initClass
  14. * DS207: Consider shorter variations of null checks
  15. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  16. */
  17. import Document from './Document'
  18. import './components/spellMenu'
  19. import './directives/aceEditor'
  20. import './directives/formattingButtons'
  21. import './directives/toggleSwitch'
  22. import './controllers/SavingNotificationController'
  23. import './controllers/CompileButton'
  24. import './controllers/SwitchToPDFButton'
  25. import getMeta from '../../utils/meta'
  26. import { hasSeenCM6SwitchAwaySurvey } from '../../features/source-editor/utils/switch-away-survey'
  27. import { debugConsole } from '@/utils/debugging'
  28. let EditorManager
  29. export default EditorManager = (function () {
  30. EditorManager = class EditorManager {
  31. static initClass() {
  32. this.prototype._syncTimeout = null
  33. }
  34. constructor(ide, $scope, localStorage, eventTracking) {
  35. this.ide = ide
  36. this.editorOpenDocEpoch = 0 // track pending document loads
  37. this.$scope = $scope
  38. this.localStorage = localStorage
  39. this.$scope.editor = {
  40. sharejs_doc: null,
  41. open_doc_id: null,
  42. open_doc_name: null,
  43. opening: true,
  44. trackChanges: false,
  45. wantTrackChanges: false,
  46. docTooLongErrorShown: false,
  47. showVisual: this.showVisual(),
  48. newSourceEditor: this.newSourceEditor(),
  49. showSymbolPalette: false,
  50. toggleSymbolPalette: () => {
  51. const newValue = !this.$scope.editor.showSymbolPalette
  52. this.$scope.editor.showSymbolPalette = newValue
  53. ide.$scope.$emit('south-pane-toggled', newValue)
  54. eventTracking.sendMB(
  55. newValue ? 'symbol-palette-show' : 'symbol-palette-hide'
  56. )
  57. },
  58. insertSymbol: symbol => {
  59. ide.$scope.$emit('editor:replace-selection', symbol.command)
  60. eventTracking.sendMB('symbol-palette-insert')
  61. },
  62. multiSelectedCount: 0,
  63. }
  64. window.addEventListener('editor:insert-symbol', event => {
  65. this.$scope.editor.insertSymbol(event.detail)
  66. })
  67. this.$scope.$on('entity:selected', (event, entity) => {
  68. if (this.$scope.ui.view !== 'history' && entity.type === 'doc') {
  69. return this.openDoc(entity)
  70. }
  71. })
  72. this.$scope.$on('entity:no-selection', () => {
  73. this.$scope.$apply(() => {
  74. this.$scope.ui.view = null
  75. })
  76. })
  77. this.$scope.$on('entity:deleted', (event, entity) => {
  78. if (this.$scope.editor.open_doc_id === entity.id) {
  79. if (!this.$scope.project.rootDoc_id) {
  80. this.$scope.ui.view = null
  81. return
  82. }
  83. const doc = this.ide.fileTreeManager.findEntityById(
  84. this.$scope.project.rootDoc_id
  85. )
  86. if (doc == null) {
  87. this.$scope.ui.view = null
  88. return
  89. }
  90. return this.openDoc(doc)
  91. }
  92. })
  93. let initialized = false
  94. this.$scope.$on('file-tree:initialized', () => {
  95. if (!initialized) {
  96. initialized = true
  97. return this.autoOpenDoc()
  98. }
  99. })
  100. this.$scope.$on('flush-changes', () => {
  101. return Document.flushAll()
  102. })
  103. // event dispatched by pdf preview
  104. window.addEventListener('flush-changes', () => {
  105. Document.flushAll()
  106. })
  107. window.addEventListener('blur', () => {
  108. // The browser may put the tab into sleep as it looses focus.
  109. // Flushing the documents should help with keeping the documents in
  110. // sync: we can use any new version of the doc that the server may
  111. // present us. There should be no need to insert local changes into
  112. // the doc history as the user comes back.
  113. debugConsole.log('[EditorManager] forcing flush onblur')
  114. Document.flushAll()
  115. })
  116. this.$scope.$watch('editor.wantTrackChanges', value => {
  117. if (value == null) {
  118. return
  119. }
  120. return this._syncTrackChangesState(this.$scope.editor.sharejs_doc)
  121. })
  122. window.addEventListener('editor:open-doc', event => {
  123. const { doc, ...options } = event.detail
  124. this.openDoc(doc, options)
  125. })
  126. window.addEventListener('editor:open-file', event => {
  127. const { name, ...options } = event.detail
  128. for (const extension of ['', '.tex']) {
  129. const path = `${name}${extension}`
  130. const doc = ide.fileTreeManager.findEntityByPath(path)
  131. if (doc) {
  132. this.openDoc(doc, options)
  133. break
  134. }
  135. }
  136. })
  137. }
  138. getEditorType() {
  139. if (!this.$scope.editor.sharejs_doc) {
  140. return null
  141. }
  142. let editorType = this.$scope.editor.sharejs_doc.editorType()
  143. if (editorType === 'cm6' && this.$scope.editor.showVisual) {
  144. editorType = 'cm6-rich-text'
  145. }
  146. return editorType
  147. }
  148. showVisual() {
  149. return (
  150. this.localStorage(`editor.mode.${this.$scope.project_id}`) ===
  151. 'rich-text'
  152. )
  153. }
  154. newSourceEditor() {
  155. // Use the new source editor if the legacy editor is disabled
  156. if (!getMeta('ol-showLegacySourceEditor')) {
  157. return true
  158. }
  159. const storedPrefIsCM6 = () => {
  160. const sourceEditor = this.localStorage(
  161. `editor.source_editor.${this.$scope.project_id}`
  162. )
  163. return sourceEditor === 'cm6' || sourceEditor == null
  164. }
  165. const showCM6SwitchAwaySurvey = getMeta('ol-showCM6SwitchAwaySurvey')
  166. if (!showCM6SwitchAwaySurvey) {
  167. return storedPrefIsCM6()
  168. }
  169. if (hasSeenCM6SwitchAwaySurvey()) {
  170. return storedPrefIsCM6()
  171. } else {
  172. // force user to switch to cm6 if they haven't seen either of the
  173. // switch-away surveys
  174. return true
  175. }
  176. }
  177. autoOpenDoc() {
  178. const open_doc_id =
  179. this.ide.localStorage(`doc.open_id.${this.$scope.project_id}`) ||
  180. this.$scope.project.rootDoc_id
  181. if (open_doc_id == null) {
  182. return
  183. }
  184. const doc = this.ide.fileTreeManager.findEntityById(open_doc_id)
  185. if (doc == null) {
  186. return
  187. }
  188. return this.openDoc(doc)
  189. }
  190. openDocId(doc_id, options) {
  191. if (options == null) {
  192. options = {}
  193. }
  194. const doc = this.ide.fileTreeManager.findEntityById(doc_id)
  195. if (doc == null) {
  196. return
  197. }
  198. return this.openDoc(doc, options)
  199. }
  200. jumpToLine(options) {
  201. return this.$scope.$broadcast(
  202. 'editor:gotoLine',
  203. options.gotoLine,
  204. options.gotoColumn,
  205. options.syncToPdf
  206. )
  207. }
  208. openDoc(doc, options) {
  209. if (options == null) {
  210. options = {}
  211. }
  212. debugConsole.log(`[openDoc] Opening ${doc.id}`)
  213. if (this.$scope.ui.view === 'editor') {
  214. // store position of previous doc before switching docs
  215. this.$scope.$broadcast('store-doc-position')
  216. }
  217. this.$scope.ui.view = 'editor'
  218. const done = isNewDoc => {
  219. const eventName = 'doc:after-opened'
  220. this.$scope.$broadcast(eventName, { isNewDoc })
  221. window.dispatchEvent(new CustomEvent(eventName, { detail: isNewDoc }))
  222. if (options.gotoLine != null) {
  223. // allow Ace to display document before moving, delay until next tick
  224. // added delay to make this happen later that gotoStoredPosition in
  225. // CursorPositionManager
  226. setTimeout(() => this.jumpToLine(options))
  227. // when opening a doc in CM6, jump to the line again after a stored scroll position has been restored
  228. if (isNewDoc) {
  229. window.addEventListener(
  230. 'editor:scroll-position-restored',
  231. () => this.jumpToLine(options),
  232. { once: true }
  233. )
  234. }
  235. } else if (options.gotoOffset != null) {
  236. setTimeout(() => {
  237. this.$scope.$broadcast('editor:gotoOffset', options.gotoOffset)
  238. })
  239. }
  240. }
  241. // If we already have the document open we can return at this point.
  242. // Note: only use forceReopen:true to override this when the document is
  243. // is out of sync and needs to be reloaded from the server.
  244. if (doc.id === this.$scope.editor.open_doc_id && !options.forceReopen) {
  245. // automatically update the file tree whenever the file is opened
  246. this.ide.fileTreeManager.selectEntity(doc)
  247. this.$scope.$broadcast('file-tree.reselectDoc', doc.id)
  248. this.$scope.$apply(() => {
  249. return done(false)
  250. })
  251. return
  252. }
  253. this.$scope.$applyAsync(() => {
  254. // We're now either opening a new document or reloading a broken one.
  255. this.$scope.editor.open_doc_id = doc.id
  256. this.$scope.editor.open_doc_name = doc.name
  257. this.ide.localStorage(`doc.open_id.${this.$scope.project_id}`, doc.id)
  258. this.ide.fileTreeManager.selectEntity(doc)
  259. this.$scope.editor.opening = true
  260. return this._openNewDocument(doc, (error, sharejs_doc) => {
  261. if (error && error.message === 'another document was loaded') {
  262. debugConsole.log(
  263. `[openDoc] another document was loaded while ${doc.id} was loading`
  264. )
  265. return
  266. }
  267. if (error != null) {
  268. this.ide.showGenericMessageModal(
  269. 'Error opening document',
  270. 'Sorry, something went wrong opening this document. Please try again.'
  271. )
  272. return
  273. }
  274. this._syncTrackChangesState(sharejs_doc)
  275. this.$scope.$broadcast('doc:opened')
  276. return this.$scope.$applyAsync(() => {
  277. this.$scope.editor.opening = false
  278. this.$scope.editor.sharejs_doc = sharejs_doc
  279. return done(true)
  280. })
  281. })
  282. })
  283. }
  284. _openNewDocument(doc, callback) {
  285. // Leave the current document
  286. // - when we are opening a different new one, to avoid race conditions
  287. // between leaving and joining the same document
  288. // - when the current one has pending ops that need flushing, to avoid
  289. // race conditions from cleanup
  290. const current_sharejs_doc = this.$scope.editor.sharejs_doc
  291. const currentDocId = current_sharejs_doc && current_sharejs_doc.doc_id
  292. const hasBufferedOps =
  293. current_sharejs_doc && current_sharejs_doc.hasBufferedOps()
  294. const changingDoc = current_sharejs_doc && currentDocId !== doc.id
  295. if (changingDoc || hasBufferedOps) {
  296. debugConsole.log('[_openNewDocument] Leaving existing open doc...')
  297. // Do not trigger any UI changes from remote operations
  298. this._unbindFromDocumentEvents(current_sharejs_doc)
  299. // Keep listening for out-of-sync and similar errors.
  300. this._attachErrorHandlerToDocument(doc, current_sharejs_doc)
  301. // Teardown the Document -> ShareJsDoc -> sharejs doc
  302. // By the time this completes, the Document instance is no longer
  303. // registered in Document.openDocs and _doOpenNewDocument can start
  304. // from scratch -- read: no corrupted internal state.
  305. const editorOpenDocEpoch = ++this.editorOpenDocEpoch
  306. current_sharejs_doc.leaveAndCleanUp(error => {
  307. if (error) {
  308. debugConsole.log(
  309. `[_openNewDocument] error leaving doc ${currentDocId}`,
  310. error
  311. )
  312. return callback(error)
  313. }
  314. if (this.editorOpenDocEpoch !== editorOpenDocEpoch) {
  315. debugConsole.log(
  316. `[openNewDocument] editorOpenDocEpoch mismatch ${this.editorOpenDocEpoch} vs ${editorOpenDocEpoch}`
  317. )
  318. return callback(new Error('another document was loaded'))
  319. }
  320. this._doOpenNewDocument(doc, callback)
  321. })
  322. } else {
  323. this._doOpenNewDocument(doc, callback)
  324. }
  325. }
  326. _doOpenNewDocument(doc, callback) {
  327. if (callback == null) {
  328. callback = function () {}
  329. }
  330. debugConsole.log('[_doOpenNewDocument] Opening...')
  331. const new_sharejs_doc = Document.getDocument(this.ide, doc.id)
  332. const editorOpenDocEpoch = ++this.editorOpenDocEpoch
  333. return new_sharejs_doc.join(error => {
  334. if (error != null) {
  335. debugConsole.log(
  336. `[_doOpenNewDocument] error joining doc ${doc.id}`,
  337. error
  338. )
  339. return callback(error)
  340. }
  341. if (this.editorOpenDocEpoch !== editorOpenDocEpoch) {
  342. debugConsole.log(
  343. `[openNewDocument] editorOpenDocEpoch mismatch ${this.editorOpenDocEpoch} vs ${editorOpenDocEpoch}`
  344. )
  345. new_sharejs_doc.leaveAndCleanUp()
  346. return callback(new Error('another document was loaded'))
  347. }
  348. this._bindToDocumentEvents(doc, new_sharejs_doc)
  349. return callback(null, new_sharejs_doc)
  350. })
  351. }
  352. _attachErrorHandlerToDocument(doc, sharejs_doc) {
  353. sharejs_doc.on('error', (error, meta, editorContent) => {
  354. let message
  355. if ((error != null ? error.message : undefined) != null) {
  356. ;({ message } = error)
  357. } else if (typeof error === 'string') {
  358. message = error
  359. } else {
  360. message = ''
  361. }
  362. if (/maxDocLength/.test(message)) {
  363. this.$scope.docTooLongErrorShown = true
  364. this.openDoc(doc, { forceReopen: true })
  365. const genericMessageModal = this.ide.showGenericMessageModal(
  366. 'Document Too Long',
  367. 'Sorry, this file is too long to be edited manually. Please upload it directly.'
  368. )
  369. genericMessageModal.result.finally(() => {
  370. this.$scope.docTooLongErrorShown = false
  371. })
  372. } else if (/too many comments or tracked changes/.test(message)) {
  373. this.ide.showGenericMessageModal(
  374. 'Too many comments or tracked changes',
  375. 'Sorry, this file has too many comments or tracked changes. Please try accepting or rejecting some existing changes, or resolving and deleting some comments.'
  376. )
  377. } else if (!this.$scope.docTooLongErrorShown) {
  378. // Do not allow this doc to open another error modal.
  379. sharejs_doc.off('error')
  380. // Preserve the sharejs contents before the teardown.
  381. editorContent =
  382. typeof editorContent === 'string'
  383. ? editorContent
  384. : sharejs_doc.doc._doc.snapshot
  385. // Tear down the ShareJsDoc.
  386. if (sharejs_doc.doc) sharejs_doc.doc.clearInflightAndPendingOps()
  387. // Do not re-join after re-connecting.
  388. sharejs_doc.leaveAndCleanUp()
  389. this.ide.connectionManager.disconnect({ permanent: true })
  390. this.ide.reportError(error, meta)
  391. // Tell the user about the error state.
  392. this.$scope.editor.error_state = true
  393. this.ide.showOutOfSyncModal(
  394. 'Out of sync',
  395. "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>",
  396. editorContent
  397. )
  398. // Do not forceReopen the document.
  399. return
  400. }
  401. const removeHandler = this.$scope.$on('project:joined', () => {
  402. this.openDoc(doc, { forceReopen: true })
  403. removeHandler()
  404. })
  405. })
  406. }
  407. _bindToDocumentEvents(doc, sharejs_doc) {
  408. this._attachErrorHandlerToDocument(doc, sharejs_doc)
  409. return sharejs_doc.on('externalUpdate', update => {
  410. if (this._ignoreExternalUpdates) {
  411. return
  412. }
  413. if (
  414. _.property(['meta', 'type'])(update) === 'external' &&
  415. _.property(['meta', 'source'])(update) === 'git-bridge'
  416. ) {
  417. return
  418. }
  419. return this.ide.showGenericMessageModal(
  420. 'Document Updated Externally',
  421. '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.'
  422. )
  423. })
  424. }
  425. _unbindFromDocumentEvents(document) {
  426. return document.off()
  427. }
  428. getCurrentDocValue() {
  429. return this.$scope.editor.sharejs_doc != null
  430. ? this.$scope.editor.sharejs_doc.getSnapshot()
  431. : undefined
  432. }
  433. getCurrentDocId() {
  434. return this.$scope.editor.open_doc_id
  435. }
  436. startIgnoringExternalUpdates() {
  437. return (this._ignoreExternalUpdates = true)
  438. }
  439. stopIgnoringExternalUpdates() {
  440. return (this._ignoreExternalUpdates = false)
  441. }
  442. _syncTrackChangesState(doc) {
  443. let tryToggle
  444. if (doc == null) {
  445. return
  446. }
  447. if (this._syncTimeout != null) {
  448. clearTimeout(this._syncTimeout)
  449. this._syncTimeout = null
  450. }
  451. const want = this.$scope.editor.wantTrackChanges
  452. const have = doc.getTrackingChanges()
  453. if (want === have) {
  454. this.$scope.editor.trackChanges = want
  455. return
  456. }
  457. return (tryToggle = () => {
  458. const saved = doc.getInflightOp() == null && doc.getPendingOp() == null
  459. if (saved) {
  460. doc.setTrackingChanges(want)
  461. return this.$scope.$apply(() => {
  462. return (this.$scope.editor.trackChanges = want)
  463. })
  464. } else {
  465. return (this._syncTimeout = setTimeout(tryToggle, 100))
  466. }
  467. })()
  468. }
  469. }
  470. EditorManager.initClass()
  471. return EditorManager
  472. })()