| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246 |
- import { Prec, Transaction, Annotation, ChangeSpec } from '@codemirror/state'
- import { EditorView, ViewPlugin } from '@codemirror/view'
- import { EventEmitter } from 'events'
- import { ShareDoc } from '../../../../../types/share-doc'
- import { debugConsole } from '@/utils/debugging'
- import { DocumentContainer } from '@/features/ide-react/editor/document-container'
- /*
- * Integrate CodeMirror 6 with the real-time system, via ShareJS.
- *
- * Changes from CodeMirror are passed to the shareDoc
- * via `handleTransaction`, while changes arriving from
- * real-time are passed to CodeMirror via the EditorFacade.
- *
- * We use an `EditorFacade` to integrate with the rest of
- * the IDE, providing an interface the other systems can work with.
- *
- * Related files:
- * - frontend/js/ide/editor/Document.js
- * - frontend/js/ide/editor/ShareJsDoc.js
- * - frontend/js/ide/connection/EditorWatchdogManager.js
- * - frontend/js/features/ide-react/editor/document.ts
- * - frontend/js/features/ide-react/editor/share-js-doc.ts
- * - frontend/js/features/ide-react/connection/editor-watchdog-manager.js
- */
- export type ChangeDescription = {
- origin: 'remote' | 'undo' | 'reject' | undefined
- inserted: boolean
- removed: boolean
- }
- /**
- * A custom extension that connects the CodeMirror 6 editor to the currently open ShareJS document.
- */
- export const realtime = (
- { currentDoc }: { currentDoc: DocumentContainer },
- handleError: (error: Error) => void
- ) => {
- const realtimePlugin = ViewPlugin.define(view => {
- const editor = new EditorFacade(view)
- currentDoc.attachToCM6(editor)
- return {
- update(update) {
- if (update.docChanged) {
- editor.handleUpdateFromCM(update.transactions)
- }
- },
- destroy() {
- // TODO: wrap in a timeout so processing can finish?
- // window.setTimeout(() => {
- currentDoc.detachFromCM6()
- // }, 0)
- },
- }
- })
- // NOTE: not a view plugin, so shouldn't get removed
- const ensureRealtimePlugin = EditorView.updateListener.of(update => {
- if (!update.view.plugin(realtimePlugin)) {
- const message = 'The realtime extension has been destroyed!!'
- debugConsole.warn(message)
- if (currentDoc.doc) {
- // display the "out of sync" modal
- currentDoc.doc.emit('error', message)
- } else {
- // display the error boundary
- handleError(new Error(message))
- }
- }
- })
- return Prec.highest([realtimePlugin, ensureRealtimePlugin])
- }
- export class EditorFacade extends EventEmitter {
- public shareDoc: ShareDoc | null
- public events: EventEmitter
- private maxDocLength?: number
- constructor(public view: EditorView) {
- super()
- this.view = view
- this.shareDoc = null
- this.events = new EventEmitter()
- }
- getValue() {
- return this.view.state.doc.toString()
- }
- // Dispatch changes to CodeMirror view
- cmChange(changes: ChangeSpec, origin?: string) {
- const isRemote = origin === 'remote'
- this.view.dispatch({
- changes,
- annotations: [
- Transaction.remote.of(isRemote),
- Transaction.addToHistory.of(!isRemote),
- ],
- effects:
- // if this is a remote change, restore a snapshot of the current scroll position after the change has been applied
- isRemote
- ? this.view.scrollSnapshot().map(this.view.state.changes(changes))
- : undefined,
- })
- }
- cmInsert(position: number, text: string, origin?: string) {
- this.cmChange({ from: position, insert: text }, origin)
- }
- cmDelete(position: number, text: string, origin?: string) {
- this.cmChange({ from: position, to: position + text.length }, origin)
- }
- // Connect to ShareJS, passing changes to the CodeMirror view
- // as new transactions.
- // This is a broad immitation of helper functions supplied in
- // the sharejs library. (See vendor/libs/sharejs, in particular
- // the 'attach_ace' helper)
- attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) {
- this.shareDoc = shareDoc
- this.maxDocLength = maxDocLength
- const check = () => {
- // run in a timeout so it checks the editor content once this update has been applied
- window.setTimeout(() => {
- const editorText = this.getValue()
- const otText = shareDoc.getText()
- if (editorText !== otText) {
- shareDoc.emit('error', 'Text does not match in CodeMirror 6')
- debugConsole.error('Text does not match!')
- debugConsole.error('editor: ' + editorText)
- debugConsole.error('ot: ' + otText)
- }
- }, 0)
- }
- const onInsert = (pos: number, text: string) => {
- this.cmInsert(pos, text, 'remote')
- check()
- }
- const onDelete = (pos: number, text: string) => {
- this.cmDelete(pos, text, 'remote')
- check()
- }
- check()
- shareDoc.on('insert', onInsert)
- shareDoc.on('delete', onDelete)
- shareDoc.detach_cm6 = () => {
- shareDoc.removeListener('insert', onInsert)
- shareDoc.removeListener('delete', onDelete)
- delete shareDoc.detach_cm6
- this.shareDoc = null
- }
- }
- // Process an update from CodeMirror, applying changes to the
- // ShareJs doc if appropriate
- handleUpdateFromCM(transactions: readonly Transaction[]) {
- const shareDoc = this.shareDoc
- if (!shareDoc) {
- throw new Error('Trying to process updates with no shareDoc')
- }
- for (const transaction of transactions) {
- if (transaction.docChanged) {
- const origin = chooseOrigin(transaction)
- if (origin === 'remote') {
- return
- }
- if (
- this.maxDocLength &&
- transaction.changes.desc.newLength >= this.maxDocLength
- ) {
- shareDoc.emit(
- 'error',
- new Error('document length is greater than maxDocLength')
- )
- return
- }
- let positionShift = 0
- transaction.changes.iterChanges(
- (fromA, toA, fromB, toB, insertedText) => {
- const fromUndo = origin === 'undo' || origin === 'reject'
- const insertedLength = insertedText.length
- const removedLength = toA - fromA
- const inserted = insertedLength > 0
- const removed = removedLength > 0
- const pos = fromA + positionShift
- if (removed) {
- shareDoc.del(pos, removedLength, fromUndo)
- }
- if (inserted) {
- shareDoc.insert(pos, insertedText.toString(), fromUndo)
- }
- // TODO: mapPos instead?
- positionShift = positionShift - removedLength + insertedLength
- const changeDescription: ChangeDescription = {
- origin,
- inserted,
- removed,
- }
- this.emit('change', this, changeDescription)
- }
- )
- }
- }
- }
- }
- export const trackChangesAnnotation = Annotation.define()
- const chooseOrigin = (transaction: Transaction) => {
- if (transaction.annotation(Transaction.remote)) {
- return 'remote'
- }
- if (transaction.annotation(Transaction.userEvent) === 'undo') {
- return 'undo'
- }
- if (transaction.annotation(trackChangesAnnotation) === 'reject') {
- return 'reject'
- }
- }
|