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