DEV Community

Cover image for How to build an interactive image for your WYSIWYG editor
kyw
kyw

Posted on • Originally published at zuunote.com

How to build an interactive image for your WYSIWYG editor

Who will find this post useful

If you are building a WYSIWYG editor and are using Prosemirror libary or Tiptap(which is built on top of Prosemirror), and you would like to offer custom editing experiences for your users' images.

Introduction

In some WYSIWYG editors, after you have inserted an image and if you focus on it, a toolbar will show up with a few action buttons that can do things like change the alignment of the image and its alt text.

Atlassian's Confluence offers more complicated controls on the images.
Fig. Atlassian's Confluence offers more complicated controls on the images.

Fast forward to now, I'm building a Markdown-based WYSIWYG editor and I found myself wanting to offer users some capabilities to edit their images too. But I had no idea how to begin building it. Google returned no answers and no one could shine me some lights when I was at my wit’s end. I got anxious from being stuck in a rut for days.

And that’s why I’m writing this guide — a guide I wish had existed.

After countless of trial-and-error and console.log, my vision became a reality:

In this article, I will share all the critical knowledge I'd learned the hard way. We will focus on building a similar toolbar specifically for users to replace their images. At the end of this post, you will be able to apply what you have learned to build your own interactions such as resizing the image.

Here is the source code and demo of what we will be building:

In the demo, you can click the View editor data button to view the editor's JSON data to confirm your changes.

Okay let's do this.

Define schema for your image

First things first, our images need a "schema". Schema is where we declare about our images what attributes are allowed, whether they flow inline or not, are they selectable, and more which don't concern us here.

You can find the schema in Image.js file:

    {
      attrs: {
        src: {
          default: '',
        },
      },
      inline: true,
      content: 'text*',
      marks: '',
      group: 'inline',
      atom: true,
      selectable: true,
      parseDOM: [
        {
          tag: 'img',
          getAttrs: (dom) => {
            return {
              src: dom?.getAttribute('src'),
            };
          },
        },
      ],
      toDOM: (node) => {
        return [
          'img',
          {
            class: 'image',
            ...node.attrs,
            src: node.attrs.src,
          },
          0,
        ];
      },
    }
Enter fullscreen mode Exit fullscreen mode

What we are saying there is that our image:

  1. Has a src attribute with default value as empty string if it wasn't provided — attrs.src.default
  2. Is to flow inline, which is the default behavior not only in HTML but also in Markdown spec — inline: true
  3. Can only contain text nodes which themselves are inline. Can't be empty here — content: text*
  4. Don't allow any marks such as bold — marks: ""
  5. Belongs to the inline group — group: inline
  6. Is a leaf node. Apparently, we can't do without this — atom: true
  7. Is selectable — selectable: true
  8. Tag is img when parsing, for example during pasting — parseDom
  9. DOM is structured in a certain way when we create it. Not important here since we will construct the structure in the "node view" which I will show below — toDom

Here is one of the things I found out the hard way: Don't set draggable: true in the schema! Otherwise, for some reasons I still don't know, the input field wouldn't be as responsive. However, it would still be draggable. You can verify this in the demo by first selecting the image, then drag and drop it somewhere else in the editor, then click the "View editor data" button.

Node view

"Node view" is what you need if you want a node with custom interfaces to have behaviors that are orchestrated by you. That sounds like what we are trying to do here.

You can find our node view in image-nodeview.js file. Here it is at the highest level:

class ImageView {
  constructor(node, view, getPos) {
    this.getPos = getPos;
    this.view = view;
    this.node = node;

    // this.dom is your parent container. Create and append your interactive elements to it here or
    // anywhere in this class by accessing this.dom again
  }

  // return true to tell editor to ignore mutation within the node view
  ignoreMutation() {
    return true;
  }

  // return true to stop browser events from being handled by editor
  stopEvent(event) {
    return true;
  }

  // this is called when user has selected the node view
  selectNode() {}

  // this is called when user selected any node other than the node view
  deselectNode(e) {}

  // update the DOM here
  update(node) {}

  // clean up when image is removed from editor
  destroy() {}
}
Enter fullscreen mode Exit fullscreen mode

This class conforms to the interface of a node view object. I use class here to, not only return the node view object when we instantiate it later, but also to store some useful values(e.g. getPos, view, node) to properties that we can access in any method in the class.

Let's see what each of the methods in the class can do for us in achieving our goal here.

constructor

Here is where I construct the custom interfaces of my image with good 'ol document.createElement and appendChild. Just make sure that all of them is housed under dom which is your top-most container element.

this.dom = document.createElement("div");
this.img = document.createElement("img");

this.dom.appendChild(this.img);
Enter fullscreen mode Exit fullscreen mode

The initial values of a particular image's attributes such as src can be found in node.attrs.

constructor(node, view, getPos) {
    // ...

    this.img = document.createElement("img");
    this.img.src = node.attrs.src;

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Another thing I learned the hard way is: Don't use contentDOM to house your custom interfaces because Prosemirror will jump in and manage for you which would produce weird results that I had to spend hours to resolve to make everyone happy. So just put everything in this.dom and you will get full control and things will work as you expect.

ignoreMutation

We will return true here to tell editor to ignore any DOM changes we make in our node view, otherwise things will get willy willy wonky.

ignoreMutation() {
    return true;
}
Enter fullscreen mode Exit fullscreen mode

stopEvent

If we return true here, means that we are stopping any browser events originated from our node view from being handled by the editor. And this is what I do to, again, avoid wonky response from the editor. The only time to return false here is when I want to inform editor that user has selected the node view. This works in tandem with the following section.

stopEvent(event) {
    // let this event through to editor if it's coming from img itself
    if (event.target.tagName === 'IMG') return false;

    // otherwise, block all events at the node view level
    return true;
}
Enter fullscreen mode Exit fullscreen mode

selectNode

Since we are passing through events from img tag to editor, this function will run when editor has detected our node view has been selected.

This is where I add a CSS class for styling and bind a event handler to handle some stuff.

 selectNode() {
    // add a class on the container for conditional styling on its childs
    this.dom.classList.add('ProseMirror-selectednode');

    // bind a click event handler to the document object to detect when user has clicked outside of the node view
    document.addEventListener('click', this.outsideClickHandler);
  }
Enter fullscreen mode Exit fullscreen mode
 outsideClickHandler(event) {
    if (this.isEditing) {
      this.isEditing = false;
      return;
    }
    // exit if still inside image
    if (this.dom.contains(event.target)) return;

    document.removeEventListener('click', this.outsideClickHandler);

    this.dom.classList.remove('ProseMirror-selectednode');
  }
Enter fullscreen mode Exit fullscreen mode

This outside-click detector does 3 things:

  1. If user is still editing, reset isEditing property to false and exit early.
  2. Exit early as well if the click wasn't outside of the node view.
  3. If the click was outside of node view, clean up the event handler on document and remove the CSS class added in selectNode.

deselectNode

This function runs when editor has detected user has selected a node other than our node view. This place is useful to reverse the effects applied in the selectNode.

But seems to me it would run whenever I interact with the custom interface of my image node view. To work around that, I introduced a isEditing boolean check here to exit this function if its value is true. And once all event handlers have run after each interaction(stopEvent -> deselectNode -> outsideClickHandler()), I set it back to false automatically via setTimeout.

deselectNode() {
    if (!this.isEditing) {
      this.dom.classList.remove('ProseMirror-selectednode');
    }

    setTimeout(() => {
      this.isEditing = false;
    }, 0);
}
Enter fullscreen mode Exit fullscreen mode

update

This is where I update the actual DOM after I have dispatched(there is a section about this below) a specific change to the editor.

update(node) {
    if (node.type.name !== 'image') return false;

    this.node = node;

    for (const [name, value] of Object.entries(node.attrs).filter(Boolean)) {
      this.img.setAttribute(name, value);
    }

    return true;
}
Enter fullscreen mode Exit fullscreen mode

destroy

Do clean-up here when the node view is removed from the editor. For example, remove any event listeners on document or window, and set this.dom to null to free up the memory.

 destroy() {
    document.removeEventListener('click', this.outsideClickHandler);
    this.dom = null;
}
Enter fullscreen mode Exit fullscreen mode

How to dispatch changes to editor

Every change during editing needs to be dispatched to the editor so that it's reflected when we output for our editor's data.

In this case, when user has replaced an image, we will dispatch the new src value to the editor. The way we do that in a node view is with this snippet:

this.view.dispatch(
  this.view.state.tr.setNodeMarkup(this.getPos(), null, {
    ...this.node.attrs,
    src: /* value of new src here */,
  }),
);
Enter fullscreen mode Exit fullscreen mode

You can do this in any event handler anywhere in the class on any interactive elements of your node view. In this case, I do it when user submit the form with a new image URL:

toolbarForm.addEventListener("submit", (event) => {
  event.preventDefault();

  const formData = new FormData(toolbarForm);

  const attrs = {};

  for (let [name, value] of formData) {
    attrs[name] = value;
  }

  this.view.dispatch(
    this.view.state.tr.setNodeMarkup(this.getPos(), null, {
      ...this.node.attrs,
      ...attrs,
    }),
  );
});
Enter fullscreen mode Exit fullscreen mode

Click the View editor data button to verify the latest src value.

Wiring up the node view

Finally, now that we have created a node view for our image, we need to tell our editor to use it when it's rendering images.

In Editor.js file, you can see we do just that with the nodeViews property:

new EditorView(/* document.querySelector("#app") */, {
  // state: this.state,
  // attributes: { spellcheck: false },
  nodeViews: {
    image(node, view, getPos) {
      return new ImageView(node, view, getPos);
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

And that's all I got! Please leave a comment if you know there's a better way to all this :)

Top comments (0)