EditorManager.js 18 KB

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