overleaf/services/web/frontend/js/ide/editor/EditorManager.js
Tim Down 08567c290e Add second CM6 switch-away survey (#10789)
Restart CM6 switch-away survey

GitOrigin-RevId: 1d62aaf27834cb3919f6ca30c269a1ef3c2e9a5e
2022-12-07 09:04:35 +00:00

524 lines
18 KiB
JavaScript

import _ from 'lodash'
/* eslint-disable
camelcase,
n/handle-callback-err,
max-len,
no-return-assign,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import Document from './Document'
import './components/spellMenu'
import './directives/aceEditor'
import './directives/toggleSwitch'
import './controllers/SavingNotificationController'
import './controllers/CompileButton'
import './controllers/SwitchToPDFButton'
import getMeta from '../../utils/meta'
import { hasSeenCM6SwitchAwaySurvey } from '../../features/source-editor/utils/switch-away-survey'
let EditorManager
export default EditorManager = (function () {
EditorManager = class EditorManager {
static initClass() {
this.prototype._syncTimeout = null
}
constructor(ide, $scope, localStorage, eventTracking) {
this.ide = ide
this.editorOpenDocEpoch = 0 // track pending document loads
this.$scope = $scope
this.localStorage = localStorage
this.$scope.editor = {
sharejs_doc: null,
open_doc_id: null,
open_doc_name: null,
opening: true,
trackChanges: false,
wantTrackChanges: false,
docTooLongErrorShown: false,
showRichText: this.showRichText(),
newSourceEditor: this.newSourceEditor(),
showSymbolPalette: false,
toggleSymbolPalette: () => {
const newValue = !this.$scope.editor.showSymbolPalette
this.$scope.editor.showSymbolPalette = newValue
if (newValue && this.$scope.editor.showGalileo) {
this.$scope.editor.toggleGalileoPanel()
}
ide.$scope.$emit('south-pane-toggled', newValue)
eventTracking.sendMB(
newValue ? 'symbol-palette-show' : 'symbol-palette-hide'
)
},
insertSymbol: symbol => {
ide.$scope.$emit('editor:replace-selection', symbol.command)
eventTracking.sendMB('symbol-palette-insert')
},
showGalileo: false,
toggleGalileoPanel: () => {
const newValue = !this.$scope.editor.showGalileo
this.$scope.editor.showGalileo = newValue
if (newValue && this.$scope.editor.showSymbolPalette) {
this.$scope.editor.toggleSymbolPalette()
}
ide.$scope.$emit('south-pane-toggled', newValue)
eventTracking.sendMB(newValue ? 'galileo-show' : 'galileo-hide')
},
galileoActivated: false,
toggleGalileo: () => {
const newValue = !this.$scope.editor.galileoActivated
this.$scope.editor.galileoActivated = newValue
eventTracking.sendMB(
newValue ? 'galileo-activated' : 'galileo-disabled'
)
},
multiSelectedCount: 0,
}
window.addEventListener('editor:insert-symbol', event => {
this.$scope.editor.insertSymbol(event.detail)
})
this.$scope.$on('entity:selected', (event, entity) => {
if (this.$scope.ui.view !== 'history' && entity.type === 'doc') {
return this.openDoc(entity)
}
})
this.$scope.$on('entity:no-selection', () => {
this.$scope.$apply(() => {
this.$scope.ui.view = null
})
})
this.$scope.$on('entity:deleted', (event, entity) => {
if (this.$scope.editor.open_doc_id === entity.id) {
if (!this.$scope.project.rootDoc_id) {
this.$scope.ui.view = null
return
}
const doc = this.ide.fileTreeManager.findEntityById(
this.$scope.project.rootDoc_id
)
if (doc == null) {
this.$scope.ui.view = null
return
}
return this.openDoc(doc)
}
})
let initialized = false
this.$scope.$on('file-tree:initialized', () => {
if (!initialized) {
initialized = true
return this.autoOpenDoc()
}
})
this.$scope.$on('flush-changes', () => {
return Document.flushAll()
})
// event dispatched by pdf preview
window.addEventListener('flush-changes', () => {
Document.flushAll()
})
window.addEventListener('blur', () => {
// The browser may put the tab into sleep as it looses focus.
// Flushing the documents should help with keeping the documents in
// sync: we can use any new version of the doc that the server may
// present us. There should be no need to insert local changes into
// the doc history as the user comes back.
sl_console.log('[EditorManager] forcing flush onblur')
Document.flushAll()
})
this.$scope.$watch('editor.wantTrackChanges', value => {
if (value == null) {
return
}
return this._syncTrackChangesState(this.$scope.editor.sharejs_doc)
})
window.addEventListener('editor:open-doc', event => {
const { doc, ...options } = event.detail
this.openDoc(doc, options)
})
}
getEditorType() {
if (!this.$scope.editor.sharejs_doc) {
return null
}
return this.$scope.editor.sharejs_doc.editorType()
}
showRichText() {
return (
this.localStorage(`editor.mode.${this.$scope.project_id}`) ===
'rich-text'
)
}
newSourceEditor() {
// only use the new source editor if the option to switch is available
if (!getMeta('ol-showNewSourceEditorOption')) {
return false
}
const storedPrefIsCM6 = () => {
const sourceEditor = this.localStorage(
`editor.source_editor.${this.$scope.project_id}`
)
return sourceEditor === 'cm6' || sourceEditor == null
}
const showCM6SwitchAwaySurvey = getMeta('ol-showCM6SwitchAwaySurvey')
if (!showCM6SwitchAwaySurvey) {
return storedPrefIsCM6()
}
if (hasSeenCM6SwitchAwaySurvey()) {
return storedPrefIsCM6()
} else {
// force user to switch to cm6 if they haven't seen either of the
// switch-away surveys
return true
}
}
autoOpenDoc() {
const open_doc_id =
this.ide.localStorage(`doc.open_id.${this.$scope.project_id}`) ||
this.$scope.project.rootDoc_id
if (open_doc_id == null) {
return
}
const doc = this.ide.fileTreeManager.findEntityById(open_doc_id)
if (doc == null) {
return
}
return this.openDoc(doc)
}
openDocId(doc_id, options) {
if (options == null) {
options = {}
}
const doc = this.ide.fileTreeManager.findEntityById(doc_id)
if (doc == null) {
return
}
return this.openDoc(doc, options)
}
jumpToLine(options) {
return this.$scope.$broadcast(
'editor:gotoLine',
options.gotoLine,
options.gotoColumn,
options.syncToPdf
)
}
openDoc(doc, options) {
if (options == null) {
options = {}
}
sl_console.log(`[openDoc] Opening ${doc.id}`)
if (this.$scope.ui.view === 'editor') {
// store position of previous doc before switching docs
this.$scope.$broadcast('store-doc-position')
}
this.$scope.ui.view = 'editor'
const done = isNewDoc => {
const eventName = 'doc:after-opened'
this.$scope.$broadcast(eventName, { isNewDoc })
window.dispatchEvent(new CustomEvent(eventName, { detail: isNewDoc }))
if (options.gotoLine != null) {
// allow Ace to display document before moving, delay until next tick
// added delay to make this happen later that gotoStoredPosition in
// CursorPositionManager
return setTimeout(() => this.jumpToLine(options), 0)
} else if (options.gotoOffset != null) {
return setTimeout(() => {
return this.$scope.$broadcast(
'editor:gotoOffset',
options.gotoOffset
)
}, 0)
}
}
// If we already have the document open we can return at this point.
// Note: only use forceReopen:true to override this when the document is
// is out of sync and needs to be reloaded from the server.
if (doc.id === this.$scope.editor.open_doc_id && !options.forceReopen) {
// automatically update the file tree whenever the file is opened
this.ide.fileTreeManager.selectEntity(doc)
this.$scope.$broadcast('file-tree.reselectDoc', doc.id)
this.$scope.$apply(() => {
return done(false)
})
return
}
this.$scope.$applyAsync(() => {
// We're now either opening a new document or reloading a broken one.
this.$scope.editor.open_doc_id = doc.id
this.$scope.editor.open_doc_name = doc.name
this.ide.localStorage(`doc.open_id.${this.$scope.project_id}`, doc.id)
this.ide.fileTreeManager.selectEntity(doc)
this.$scope.editor.opening = true
return this._openNewDocument(doc, (error, sharejs_doc) => {
if (error && error.message === 'another document was loaded') {
sl_console.log(
`[openDoc] another document was loaded while ${doc.id} was loading`
)
return
}
if (error != null) {
this.ide.showGenericMessageModal(
'Error opening document',
'Sorry, something went wrong opening this document. Please try again.'
)
return
}
this._syncTrackChangesState(sharejs_doc)
this.$scope.$broadcast('doc:opened')
return this.$scope.$applyAsync(() => {
this.$scope.editor.opening = false
this.$scope.editor.sharejs_doc = sharejs_doc
return done(true)
})
})
})
}
_openNewDocument(doc, callback) {
// Leave the current document
// - when we are opening a different new one, to avoid race conditions
// between leaving and joining the same document
// - when the current one has pending ops that need flushing, to avoid
// race conditions from cleanup
const current_sharejs_doc = this.$scope.editor.sharejs_doc
const currentDocId = current_sharejs_doc && current_sharejs_doc.doc_id
const hasBufferedOps =
current_sharejs_doc && current_sharejs_doc.hasBufferedOps()
const changingDoc = current_sharejs_doc && currentDocId !== doc.id
if (changingDoc || hasBufferedOps) {
sl_console.log('[_openNewDocument] Leaving existing open doc...')
// Do not trigger any UI changes from remote operations
this._unbindFromDocumentEvents(current_sharejs_doc)
// Keep listening for out-of-sync and similar errors.
this._attachErrorHandlerToDocument(doc, current_sharejs_doc)
// Teardown the Document -> ShareJsDoc -> sharejs doc
// By the time this completes, the Document instance is no longer
// registered in Document.openDocs and _doOpenNewDocument can start
// from scratch -- read: no corrupted internal state.
const editorOpenDocEpoch = ++this.editorOpenDocEpoch
current_sharejs_doc.leaveAndCleanUp(error => {
if (error) {
sl_console.log(
`[_openNewDocument] error leaving doc ${currentDocId}`,
error
)
return callback(error)
}
if (this.editorOpenDocEpoch !== editorOpenDocEpoch) {
sl_console.log(
`[openNewDocument] editorOpenDocEpoch mismatch ${this.editorOpenDocEpoch} vs ${editorOpenDocEpoch}`
)
return callback(new Error('another document was loaded'))
}
this._doOpenNewDocument(doc, callback)
})
} else {
this._doOpenNewDocument(doc, callback)
}
}
_doOpenNewDocument(doc, callback) {
if (callback == null) {
callback = function () {}
}
sl_console.log('[_doOpenNewDocument] Opening...')
const new_sharejs_doc = Document.getDocument(this.ide, doc.id)
const editorOpenDocEpoch = ++this.editorOpenDocEpoch
return new_sharejs_doc.join(error => {
if (error != null) {
sl_console.log(
`[_doOpenNewDocument] error joining doc ${doc.id}`,
error
)
return callback(error)
}
if (this.editorOpenDocEpoch !== editorOpenDocEpoch) {
sl_console.log(
`[openNewDocument] editorOpenDocEpoch mismatch ${this.editorOpenDocEpoch} vs ${editorOpenDocEpoch}`
)
new_sharejs_doc.leaveAndCleanUp()
return callback(new Error('another document was loaded'))
}
this._bindToDocumentEvents(doc, new_sharejs_doc)
return callback(null, new_sharejs_doc)
})
}
_attachErrorHandlerToDocument(doc, sharejs_doc) {
sharejs_doc.on('error', (error, meta, editorContent) => {
let message
if ((error != null ? error.message : undefined) != null) {
;({ message } = error)
} else if (typeof error === 'string') {
message = error
} else {
message = ''
}
if (/maxDocLength/.test(message)) {
this.$scope.docTooLongErrorShown = true
this.openDoc(doc, { forceReopen: true })
const genericMessageModal = this.ide.showGenericMessageModal(
'Document Too Long',
'Sorry, this file is too long to be edited manually. Please upload it directly.'
)
genericMessageModal.result.finally(() => {
this.$scope.docTooLongErrorShown = false
})
} else if (/too many comments or tracked changes/.test(message)) {
this.ide.showGenericMessageModal(
'Too many comments or tracked changes',
'Sorry, this file has too many comments or tracked changes. Please try accepting or rejecting some existing changes, or resolving and deleting some comments.'
)
} else if (!this.$scope.docTooLongErrorShown) {
// Do not allow this doc to open another error modal.
sharejs_doc.off('error')
// Preserve the sharejs contents before the teardown.
editorContent =
typeof editorContent === 'string'
? editorContent
: sharejs_doc.doc._doc.snapshot
// Tear down the ShareJsDoc.
if (sharejs_doc.doc) sharejs_doc.doc.clearInflightAndPendingOps()
// Do not re-join after re-connecting.
sharejs_doc.leaveAndCleanUp()
this.ide.connectionManager.disconnect({ permanent: true })
this.ide.reportError(error, meta)
// Tell the user about the error state.
this.$scope.editor.error_state = true
this.ide.showOutOfSyncModal(
'Out of sync',
"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>",
editorContent
)
// Do not forceReopen the document.
return
}
const removeHandler = this.$scope.$on('project:joined', () => {
this.openDoc(doc, { forceReopen: true })
removeHandler()
})
})
}
_bindToDocumentEvents(doc, sharejs_doc) {
this._attachErrorHandlerToDocument(doc, sharejs_doc)
return sharejs_doc.on('externalUpdate', update => {
if (this._ignoreExternalUpdates) {
return
}
if (
_.property(['meta', 'type'])(update) === 'external' &&
_.property(['meta', 'source'])(update) === 'git-bridge'
) {
return
}
return this.ide.showGenericMessageModal(
'Document Updated Externally',
'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.'
)
})
}
_unbindFromDocumentEvents(document) {
return document.off()
}
getCurrentDocValue() {
return this.$scope.editor.sharejs_doc != null
? this.$scope.editor.sharejs_doc.getSnapshot()
: undefined
}
getCurrentDocId() {
return this.$scope.editor.open_doc_id
}
startIgnoringExternalUpdates() {
return (this._ignoreExternalUpdates = true)
}
stopIgnoringExternalUpdates() {
return (this._ignoreExternalUpdates = false)
}
_syncTrackChangesState(doc) {
let tryToggle
if (doc == null) {
return
}
if (this._syncTimeout != null) {
clearTimeout(this._syncTimeout)
this._syncTimeout = null
}
const want = this.$scope.editor.wantTrackChanges
const have = doc.getTrackingChanges()
if (want === have) {
this.$scope.editor.trackChanges = want
return
}
return (tryToggle = () => {
const saved = doc.getInflightOp() == null && doc.getPendingOp() == null
if (saved) {
doc.setTrackingChanges(want)
return this.$scope.$apply(() => {
return (this.$scope.editor.trackChanges = want)
})
} else {
return (this._syncTimeout = setTimeout(tryToggle, 100))
}
})()
}
}
EditorManager.initClass()
return EditorManager
})()