Understanding the interaction between parseDOM, DOMChange and NodeView

I’m setting up PM to be able to easily edit and render math (using http://mathquill.com/ ). I created a custom node type that uses a contained text node to persist the latex generated by mathquill. Here’s the spec:

const mqSpec : NodeSpec = {
  inline: true,
  group: "inline",
  content: "text*",
  selectable: true,
  toDOM: () => {
    return ["span", {"mathquill": true}, 0]
  },
  parseDOM: [{
    tag: "span[mathquill]"
  }]
}

I discovered an issue today, however. When I place the cursor directly after the node and type a (non-return) character, I get the following state transition:

{
  "doc": {
    "type": "doc",
    "content": [
      {
        "type": "paragraph",
        "content": [
          {
            "type": "mq",
            "content": [
              {
                "type": "text",
                "text": "\\frac{\\sin x}{\\cos x}"
              }
            ]
          }
        ]
      }
    ]
  },
  "selection": {
    "head": 24,
    "anchor": 24
  }
}

{
  "doc": {
    "type": "doc",
    "content": [
      {
        "type": "paragraph",
        "content": [
          {
            "type": "mq",
            "content": [
              {
                "type": "text",
                "text": "sinxcosx​"
              }
            ]
          },
          {
            "type": "text",
            "text": " "
          }
        ]
      }
    ]
  },
  "selection": {
    "head": 13,
    "anchor": 13
  }
}

As you can see, along with a new " " text node being inserted into the paragraph, it also seems that the content of the mq node gets changed. It looks like it’s maybe getting escaped.

I’m wondering if this is an expected behavior that I need to account for somehow? Or if this is perhaps unexpected behavior that should be filed as a bug.

Thanks!

an extra observation - this only happens when you type a space and the mq node is the last node in the paragraph. If there is more text following the node already, there is no escaping.

Progress!

The issue centers around the way that dom mutations are parsed by PM, and this interacting poorly with my custom node view.

When you enter a character directly after the node view (and I suspect in other cases), PM tries to figure out what changed by parsing the content of the view, and analyzing the diff against the previous state. During general operation, this seems to happen pretty infrequently (the transactions are generated through some other means, that don’t rely on diffing the dom).

My custom view has a rather complex DOM structure - much more complicated than the toDOM and fromDOM representation above. I initially didn’t realize that my NodeView would be parsed this way, and it seems that I lose the latex annotations inside my node during this parsing.

Currently, however I am having some trouble fixing this behavior. In particular, I added the appropriate attribute on my custom view:

    this.dom = document.createElement('span')
    $(this.dom).attr('mq-node', 'true')

So, the top-level node for my NodeView now looks like this:

<span mq-node="true" contenteditable="false">

Further, I modified my spec:

// An mqnode has a single text child (the latex of this node)
const mqSpec : NodeSpec = {
  inline: true,
  group: "inline",
  content: "text*",
  selectable: true,
  toDOM: () => {
    return ["span", {"mq-node": true}, 0]
  },
  parseDOM: [{
    tag: "span[mq-node=true]",
    getContent: (node: HTMLElement) => {
      debugger
    }
  }]
}

I expected that during the DOMChange computation this would breakpoint inside the getContent function, which would allow me to inspect the contents of the node and create the fragment correctly during both pasting and computing a DOMChange.

However, that’s not happening. I’m hitting the breakpoint during paste, but not during DOMChange parsing. It seems that PM is using the fromSchema parser during the DOMChange computation, but somehow it’s not triggering the getContent function.

More progress…

It seems that rather than using the rule from the schema, we derive a rule from the nodeView dom node itself, via:

function ruleFromNode(dom) {
  var desc = dom.pmViewDesc
  if (desc) { return desc.parseRule() }
  else if (dom.nodeName == "BR" && dom.parentNode && dom.parentNode.lastChild == dom) { return {ignore: true} }
}

My custom node contains the following rule {"node":"mq","attrs":{}}, which doesn’t seem to provide a way from me to control how the content is parsed.

My current plan is to add a hidden node, and set the contentDOM property on my NodeView to let PM put the content of my nodeView there, for bookkeeping purposes. I’d appreciate any input on whether my understanding of the situation is correct, and any suggestions for cleaner implementations.

Thanks!

Could you show your node view? Specifically, are you giving it a contentDOM property?

Regardless of that, there’s a bug in 0.18 around the parse rules produced for node views, and you might be running into that.

Here’s the view:

  constructor(node: Node, view: EditorView, getPos: () => number) {
    this.node = node
    this.view = view
    this.getPos = getPos

    this.dom = document.createElement('span')
    this.value = node.textContent

    touchtracking.monitor(this.dom)

    // When mathquill receives focus, update the prosemirror selection
    $(this.dom).on('focusin', () => {
      this.view.dispatch(
        this.view.state.tr.setSelection(NodeSelection.create(this.view.state.doc, this.getPos()))
      )
    })

    this.updating = true
    this.mathquill = MathField(this.dom, {
      handlers: {
        deleteOutOf: (d, m) => {this.deleteHandler(d, m)},
        moveOutOf: (d) => {this.moveHandler(d)},
        reflow: () => {this.onChange()}
      }
    })

    this.mathquill.latex(node.textContent)
    this.updating = false
  }

It’s very similar structurally to the codemirror example (in how the view and node values are synced)

This results in the following DOM structure…

What I’m finding is that based on the parseRule that gets created for this nodeView, PM tries to parse the contents of one of the inner spans as a text node. I need to override that behavior to return the latex represented by the node instead.

Same problem here: i have a nodeView with dom, contentDOM properties, and i specifically use ignoreMutation to return true when it concerns an attribute change on dom.

When i change an attribute on that DOM node, the ignoreMutation is correctly called and returns true, and DOMChange.finish() seems to be parsing again the corresponding range, but the concerned node’s getAttrs parseRule is not called.

Is this a bug or a feature ?

It’s odd that you get a DOM change at all, if you ignore the mutation. Did anything else happen?

That’s expected – the parser will get the node’s type and attributes from the node view.

I meant “the ignoreMutation is correctly called and returns false”.

Good to know, so i can trigger whatever i need to from the ignoreMutation call.

Probably not. What do you mean by ‘trigger’?

This nodeView needs to update one of its node.attrs (using setNodeMarkup(getPos(), ...) when a specific dom attribute changes.

Ah, right, as long as you go through a transaction you should be fine.

Indeed, it works.