EditorManager.js 16 KB

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