CodeMirror 6: Proper way to listen for changes

What is the intended way to listen for changes to editor state?

I need to sync the document contents with some other state, and I noticed that while I can listen for input events on the editor DOM, the state.doc doesn’t hold the correct value until at least a microtask after the event fires. So my code to listen for changes looks like this:

    this._editorView.dom.addEventListener('input', async (e) => {
      e.stopPropagation();
      await 0;
      this._value = this._editorView.state.doc.toString();
      this.dispatchEvent(new InputEvent('input'));
    });

Is this the right way?

2 Likes

Absolutely not. Not only is it a giant hack, it’ll also fail to notice programmatic changes. Depending on what you are trying to do, it may make sense to provide a dispatch function when creating your editor view, or register a view plugin that starts the action you want when its update method is called.

Ah, sorry, I thought dispatch was more meant for customizing how transactions were applied. I was looking through the docs for a more notification-only API point.

Is this._editorView.state.doc.toString() an ok way to get the document contents, or is there something better?

That’s the way. Though for giant documents it’s going to do quite a lot of string concatenation, so depending on your use case you might not want to constantly do it when you can avoid it.

3 Likes

I read your conversation and decided to use a ViewPlugin. When reading the documentation I struggled a bit to figure out that a plugin can be added as an extension.
I came up with the following solution to watch the editor:

initEditor() {
	const compThis = this //save this context
	const editor = new EditorView({
        state: EditorState.create({
          doc: 'Loading the real content',
          extensions: [
            ViewPlugin.fromClass(class {
              constructor(view) {}

              update(update) {
                if (update.docChanged)
                  compThis.onDocumentChanged() //escort the update event up one level. This function saves the contents and interact with the other UI.
              }
            }),
            lineNumbers(),
            highlightSpecialChars(),
            history(),
            foldGutter(),
            multipleSelections(),
            defaultHighlighter,
            bracketMatching(),
            closeBrackets(),
            autocomplete(),
            rectangularSelection(),
            highlightActiveLine(),
            highlightSelectionMatches(),
            keymap([
              ...defaultKeymap,
              ...searchKeymap,
              ...historyKeymap,
              ...foldKeymap,
              ...commentKeymap,
              ...gotoLineKeymap,
              ...autocompleteKeymap,
              ...lintKeymap
            ])
          ]
        })
	this.$refs.editor.append(this.editorView.dom)
	//other init stuff
}

I doubt that this is used in the way its intended to be used? Is there a better way to do this? How can I pass information down to my plugin?

1 Like

See EditorView.updateListener for a shorthand way to do this.

3 Likes

Thanks for your response. The documentation is very minimal. Could you provide a small example?

1 Like

You provide EditorView.updateListener.of(update => ...) as an extension, and your function gets called with a view update object every time the view is updated.

6 Likes

Ok thank you for clarifying. I understand how to listen for changes. I still don’t understand the codemirror modular system. A graphic like this one would help a lot.

I modifed the split view example. I can now keep an arbitrary amount of editors in sync. They can be opened and closed in any order. Awesome. Thank you for that example. It helped me understand that the editor states diverge and are immutable.

I would like to create completely different UIs for various file types.
.bin -> Hex editor
.json -> Json editor
.custom -> Custom editor
It should be possible to have a codemirror and a hex editor of the same file open at the same time.

I currently have the following:

  /**
   * https://codemirror.net/6/examples/split/
   */
  private syncDispatch(fromEditorViewRef: { editorView?: EditorView }) {
    return (tr: Transaction): void => {
      // this should never happen as the editorref is populated directly after the constructor
      if (!fromEditorViewRef.editorView) {
        console.log(tr)
        throw Error('Trans but editor undefined.')
      }

      // the EditorView that caused the transaction
      const fromEditorView = fromEditorViewRef.editorView
      fromEditorView.update([tr])

      if (!tr.changes.empty && !tr.annotation(syncAnnotation)) {
        // share the transactions with all other editorViews
        for (const editorView of this.editorViews)
          if (editorView !== fromEditorView) {
            editorView.dispatch({
              changes: tr.changes,
              annotations: syncAnnotation.of(true)
            })
          }

        // I could deliver the changes to my other editors from here but that feels hacky and they would have to register and unregister their listeners

        // debounce file changes to avoid saving everytime a key is pressed
        if (this.saveTimeout)
          clearTimeout(this.saveTimeout)
        this.saveTimeout = setTimeout(() => { this.save(fromEditorView) }, this.saveDelay)
      }
    }
  }

  /**
   * All other editors should also call this function and modify the file through that editor instance.
   */
  async createEditor(): Promise<EditorView> {
    let editorState: EditorState
    if (this.editorViews.length)
      // a editor has already been created get use its state as a base
      editorState = this.editorViews[0].state
    else
      // this is the first editor create a new state with the default extensions for a text editor
      editorState = await this.initialEditorState()

    // As dispatch functions can only be provided to the constructor and the editorView doesn't exist yet a reference object has to be used.
    const editorViewRef: {
      editorView?: EditorView
    } = {}
    const newEditorView = new EditorView({
      state: editorState,
      // create a dispatch function which knowns what editorView is responsible for the transactions.
      dispatch: this.syncDispatch(editorViewRef)
    })
    // set the created editorView so that the dispatch function knowns where the transactions come from.
    editorViewRef.editorView = newEditorView

    // add the editorView to the list of editorViews to keep in sync by the dispatch function
    this.editorViews.push(newEditorView)
    return newEditorView
  }

The normal TextEditor calls the createEditor function and attaches it to the dom.
The HexEditor calls the createEditor function gets a EditorView and can update the other TextEditor instances through the dispatch function. It could listen to changes via the syncDispatch function. That however seems like I am doing it wrong because the EditorView would have a nonattached .dom and update it right? I also can’t use EditorView.updateListener.of(update => ...) because the base EditorState is already created and extensions can’t be added anymore.

How would you go about creating a completely custom UI that should stay in sync with other editors?

Hello, I’m reading all of this, but I’m still confused about how to listen for changes. Can someone provide a very minimal example? Something like when we listen for a click or anything, just to get the new document text on every change, would be great! Thanks!

1 Like

This is how I managed to catch changes to the document: (it’s TypeScript)

import { EditorState } from "@codemirror/basic-setup"
import { EditorView, ViewUpdate } from "@codemirror/view"
import { CodeMirrorSetup } from "./codemirrorsetup"; // Custom setup

    this.edit = new EditorView({
      state: EditorState.create({
        doc: initial_text,
        extensions: [ 
          CodeMirrorSetup, 
          EditorView.updateListener.of((v:ViewUpdate) => {
            if (v.docChanged) {
              // Document changed
            }
        }) ]
      }),
      parent: this.element
    });
8 Likes

@larsp Do you have a shareable version of your codemirrorsetup.ts or .tsx file?

Here you go: codemirrorsetup.ts - Pastebin.com