EditorManager.js 17 KB

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