Node.fromJSON and custom marks with attributes

Hi guys, I am new to ProseMirror and we are switching over from Quill.js.

I have created a custom mark named background which looks like this.

background: {
    parseDOM: [
      {
        tag: 'span[background]',
        getAttrs(node: any): { [key: string]: any } {
          return { color: node.attrs.color };
        },
      },
    ],
    toDOM(mark: Mark): DOMOutputSpec {
      return ['span', { style: `background: ${mark.attrs.color};`, background: mark.attrs.color }, 0];
    },
  },

I use this mark to set a background color with a custom menu which looks like this.

Screenshot 2020-07-08 at 16.07.06

To achieve my goal, I had to create a custom toggle handler for this mark, because I have to recreate the mark with the given color attribute if I want to set it :slight_smile:

The function I use to set the marks looks like this

const { from, to, empty } = this._editorView.state.selection;
    if (!empty) {
      const colorAttrs = { color };
      const tr = this._editorView.state.tr;
      // Adding attrs on create call is not working for some reasons
      const mark = this._markType.create(colorAttrs);
      mark.attrs = colorAttrs;
      if (color === '__remove' && this.isActive.value) {
        tr.removeMark(from, to, this._markType);
      } else {
        if (!this.isActive.value) {
          tr.addMark(from, to, mark);
        } else {
          tr.removeMark(from, to, this._markType);
          tr.addMark(from, to, mark);
        }
      }
      this._editorView.dispatch(tr);
    }

All of this works perfectly as long as I use the editor, but if I extract the JSON from the editor and try to reload it in another session with Node.fromJSON(myDoc) the attrs of the background mark are not set properly.

Here is what the JSON after editing looks like (not matching the screenshot):

{
      type: "paragraph",
      content: [
        {
          type: "text",
          marks: [{ type: "background", attrs: { color: "red" } }],
          text: "this is a test",
        },
      ],
    }

and here is what the JSON looks like if I loaded it with Node.fromJSON:

{
      type: "paragraph",
      content: [
        {
          type: "text",
          marks: [{ type: "background"}],
          text: "this is a test",
        },
      ],
    }

As you can see, the attrs are missing from the mark.

Has anyone an Idea what I am doing wrong in this case?

Thank you very much :slight_smile:

For anyone with the same issue as I had, I just found the solution.

I analyzed the code of prosemirror-model and i just realised that the function computeAttrs requires a default value to be set on the mark to work properly.

attrs: {
      color: { default: '' },
    },

My final mark looks like this:

background: {
    attrs: {
      color: { default: '' },
    },
    parseDOM: [
      {
        tag: 'span[background]',
        getAttrs(node: any): { [key: string]: any } {
          console.log('arsch', node);
          return { color: node.attrs.color };
        },
      },
    ],
    toDOM(mark: Mark): DOMOutputSpec {
      console.log('arsch to', mark);
      return ['span', { style: `background: ${mark.attrs.color};`, background: mark.attrs.color }, 0];
    },
  },

This also fixed the issue in my menu item code, the one that the create function of the markType will not properly bind the attrs… This one looks like this now:

const { from, to, empty } = this._editorView.state.selection;
    if (!empty) {
      const colorAttrs = { color };
      const tr = this._editorView.state.tr;
      const mark = this._markType.create(colorAttrs);
      if (color === '__remove' && this.isActive.value) {
        tr.removeMark(from, to, this._markType);
      } else {
        if (!this.isActive.value) {
          tr.addMark(from, to, mark);
        } else {
          tr.removeMark(from, to, this._markType);
          tr.addMark(from, to, mark);
        }
      }
      this._editorView.dispatch(tr);
    }
  }

Maybe this will help someone :slight_smile:

There may of course be a bug, but in principle this isn’t correct—you can create attributes without default values just fine (but you’ll have to provide a value for the attribute when you create a mark of that type). toJSON just puts the mark’s attribute object into the JSON structure, so I can’t really imagine that losing any attributes.

I did not dig into into it too deeply to be honest, I just made a few test with computeAttrs and in that case the objects were not merged.

If you look at the code this makes sense I guess.

function computeAttrs(attrs, value) {
  let built = Object.create(null)
  for (let name in attrs) {
    let given = value && value[name]
    if (given === undefined) {
      let attr = attrs[name]
      if (attr.hasDefault) given = attr.default
      else throw new RangeError("No value supplied for attribute " + name)
    }
    built[name] = given
  }
  return built
}

As you can see, the for loop goes over the attrs not the value, and the call is done with computeAttrs(this.attrs, theAdditionalyProvidedAttrs) at least as soon as I can tell. But I don’t know if there is a reason for this. This will result in the fact that attrs which are not defined on the mark itself will be ignored.

Oh, right, you weren’t specifying the attribute at all in the mark spec before? Yes, that won’t work. You’ll want at least attrs: {color: {}}.

Yes exactly, but now it works perfectly :+1:

Now I just need to migrate the quill data to ProseMirror which is kind of a pain, but it will be worth it :slight_smile: