CodeMirror integration and selections

:wave: I’m having some trouble managing selections when it comes to using CodeMirror as a nodeView.

The core of my goal is to merge the tooltip example with the CodeMirror example.

I have a demo of this integration mostly working here on Glitch.

But I have a problem in that selections in the CodeMirror nodeView aren’t stable.

tl;dr: The problem is that immediately after forwardSelection runs, CodeMirror modifies the DOM which causes prosemirror-view to observe the DOM change and reset the selection to the first character before the nodeView.

(Read on for details and demo).

Here’s a couple of screenshots to make it easier to visualize what we’re going for:

20 59

What actually happens:

This nearly works! forwardSelection runs properly and “notifies” ProseMirror of the CodeMirror selection. We can see this if we use the excellent prosemirror-dev-tools:

14

If we investigate the stack trace of this second event, we get the following:

What the above stacktrace shows is that:

  1. CodeMirror is trying to show the new selection and in doing so…
  2. CodeMirror modifies the DOM causing…
  3. Prosemirror’s DOMObserver to notice the DOM change
  4. prosemirror-view decides it needs to check if it should update the selection and calls selectionFromDOM
  5. selectionFromDOM doesn’t recognize the CodeMirror DOM selection and it resets the selection to the first character of the nodeView

It seems to me that my options are either to:

  1. Set the DOM selection in such a way that prosemirror-view will recognize it in step 5
  2. Call forwardSelection only after CodeMirror handled it’s own focus action and “settled”

But I’ll admit I’m not sure how to do either.

Any thoughts? Is this the right approach? I’d love any guidance you have.

You can find the code demo of full running Glitch code demo that shows the “bug” here.

This demo is a minimal merge of the “tooltip” and “CodeMirror” demos.

2 Likes

It seems that if you manage to click directly on the CodeMirror border before highlighting, the delegated selection count works and the tooltip is shown, but the location is off (look at the right side):

code-tooltip-2

CodeMirror maintains a hidden div for the textarea, which is what prosemirror-view picks up with getSelection().

It turns out, if we hack in devtools and 1. make that div visible and 2. click on that textarea then we get the tooltip count and proper locations:

Notice pink “glitch” in the gif below, which is CodeMirror’s invisible textarea made visible. This is an unreliable hack and not a real solution.

code-tooltip-w-hack

… but this is totally a hack just to track down what’s going on and not a real answer to the problem.

The good news is that it seems that sharing selections between CodeMirror and ProseMirror are not fundamentally incompatible – there just seems to be a bug somewhere along the line wrt keeping them in sync.

Have you tried putting an ignoreMutation method on your node view?

Yes. I’ve tried ignoreMutation but the problem is that the flush() function in prosemirror-view tries to update the selection even if all mutations are filtered out.

Here’s the relevant function from flush() in prosemirror-view, but I’ve added my own comments to show my issue:

  flush(mutations) {
    if (!this.view.docView) return
    // 1. We get the mutation records, so far so good
    if (!mutations) mutations = this.observer.takeRecords()
    if (this.queue.length) {
      mutations = this.queue.concat(mutations)
      this.queue.length = 0
    }

    // 2. sel will be set to the `textarea` for CodeMirror in this case
    let sel = this.view.root.getSelection()
    // 3. newSel will be true (we're changing the selection)
    let newSel = !this.currentSelection.eq(sel) && hasSelection(this.view)

    let from = -1, to = -1, typeOver = false
    if (this.view.editable) {
      for (let i = 0; i < mutations.length; i++) {
        // 4. registerMutation _will_ filter out the mutation records
        // because our nodeView is set to ignoreMutation, but
        // it doesn't matter because below...
        let result = this.registerMutation(mutations[i])
        if (result) {
          from = from < 0 ? result.from : Math.min(result.from, from)
          to = to < 0 ? result.to : Math.max(result.to, to)
          if (result.typeOver) typeOver = true
        }
      }
    }
    if (from > -1 || newSel) {
      if (from > -1) this.view.docView.markDirty(from, to)
      // 5. handleDOMChange is picked up by `readDOMChange` which 
      // changes our selection (the key problem)
      this.handleDOMChange(from, to, typeOver)
      if (this.view.docView.dirty) this.view.updateState(this.view.state)
      // 6. we might hope that this call would "fix" the selection, 
      // but the `currentSelection` is already changed -- and the 
      // selections are now equal
      else if (!this.currentSelection.eq(sel)) selectionToDOM(this.view)
    }
  }

As per step 5. above, handleDOMChange ends up being observed by readDOMChange in domchange.js which then sends out a new selection (!).

Roughly speaking, it’s almost like selection is a special “mutation” that isn’t ignored because the observers will try to update the selection automatically.

The basic problem seems to be that readDOMChange tries to read the selection from the DOM, which is CodeMirror’s textarea.

Of course, when ProseMirror tries to find the position in selectionFromDOM the best it can do is find the head of the custom nodeView.

It almost seems that what we need is a hook within the custom nodeView to return the appropriate selection. E.g. modify selectionFromDOM to something like:

    // ...
    while (nearestDesc && !nearestDesc.node) nearestDesc = nearestDesc.parent

    // add this
    if( nearestDesc && nearestDesc.spec.selectionFromDOM) {
      selection = nearestDesc.spec.selectionFromDOM()
    } else 
    // current code below
    if (nearestDesc && nearestDesc.node.isAtom && NodeSelection.isSelectable(nearestDesc.node) && nearestDesc.parent) {
      let pos = nearestDesc.posBefore
      selection = new NodeSelection(head == pos ? $head : doc.resolve(pos))
    }
    // ...

Then within the custom nodeView we can just specify selectionFromDOM to return the correct value.

What do you think about this idea? (Or am I doing something wrong earlier in the process?)

1 Like

Thanks for diagnosing the issue. I agree that that’s a misbehavior.

I think the right thing to do would be to ignore DOM selection changes inside content that isn’t managed by ProseMirror itself, just like we ignore DOM changes.

What do you think about calling .ignoreMutation with a custom type of mutation record that has {type: "selection", target: nearestElementAroundSelection()} properties? That way, ignoreMutation methods that just check whether record.target is in some unmanaged sub-node will automatically do the right thing, and those that want to handle selections specially can do so by checking the type property.

It sounds like what you’re suggesting would work, but I’ll defer to you as to the solution as I don’t have a complete picture of the rest of the library.

Interestingly, I found that I could make it work even with the current code by making two small (but hacky) changes:

Glitch Demo and Code

  1. In CodeBlockView (essentially the NodeView implementation), when creating the CodeMirror view:
class CodeBlockView {
  constructor(node, view, getPos) {
    // ...
    // other initialization
    // ...
    this.cm.on("focus", () => {
      const selection = this.view.root.getSelection();
      if (selection) {
        this.view.domObserver.setCurSelection();
      }
      this.forwardSelection()
    })
    // ...

The “hack” here is reaching into domObserver and telling it to setCurSelection – this effectively “syncs” up the view of the DOM selection between CodeMirror and Prosemirror (down the line).

But for whatever reason, this didn’t completely fix the issue. I also had to:

  1. Make the CodeMirror textarea containing div have a non-zero size:
  // lol
  .CodeMirror > :first-child {
    height: 1px !important;
    width: 1px !important;
    z-index: 100;
  }

(To be honest, I’m not sure why this matters or even works)

But together we do get the right interaction! A properly positioned hovering tooltip within CodeMirror!

code-tooltip

I’m not proposing the above code as an actual solution, but rather I’m just sharing it to provide insight into the problematic interaction.

I think your suggestion of being able to explicitly ignore selection mutations within the library is a good one, as far as I can tell, though it is interesting that with the above changes we get synced selections nearly “automatically”.

2 Likes

I’ve created this RFC proposing the solution I outlined.

1 Like

I have what is probably a similar issue, where the CodeMirror cursor blinking (which modifies the DOM to show and hide the cursor div) causes the ProseMirror selection to move in and out of the node view containing the editor.

Might there be a branch implementing the RFC that could be tested to see if it solves this issue?

Not yet, and I’m probably not going to be able to get to it in the coming week, but if someone wants to create a PR that’d be nice.

I’ve created a PR with a minimal implementation of the RFC.