How to build an interactive image for your WYSIWYG editor
Tags: webdev, tutorial, javascript, frontend
Published: 2024-01-27 10:43:57

## Who will find this post useful

If you are building a WYSIWYG editor and are using [Prosemirror](https://prosemirror.net/) libary or [Tiptap](https://tiptap.dev/docs/editor/introduction)(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.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qtackr3aaoycoe7ccnsp.jpg)
_Fig. Atlassian's Confluence offers more complicated controls on the images._

Fast forward to now, I'm building a [Markdown-based WYSIWYG editor](https://zuunote.com) 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](https://discuss.prosemirror.net/t/form-input-inside-an-image-node-view/6113/) 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:

{% embed https://vimeo.com/906962472 %}

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:

{% embed https://stackblitz.com/edit/vitejs-vite-8qokr8?embed=1&file=style.css&view=preview %}

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](https://prosemirror.net/docs/ref/#model.NodeSpec) which don't concern us here.

You can find the schema in `Image.js` file:

```js
    {
      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,
        ];
      },
    }
```

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 &mdash; `attrs.src.default`
2. Is to flow _inline_, which is the default behavior not only in HTML but also in Markdown spec &mdash; `inline: true`
3. Can only contain text nodes which themselves are inline. Can't be empty here &mdash; `content: text*`
4. Don't allow any _marks_ such as bold &mdash; `marks: ""`
5. Belongs to the inline group &mdash; `group: inline`
6. Is a leaf node. Apparently, we can't do without this &mdash; `atom: true`
7. Is selectable &mdash; `selectable: true`
8. Tag is `img` when parsing, for example during pasting &mdash; `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 &mdash; `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:

```js
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() {}
}
```

This class conforms to the [interface](https://prosemirror.net/docs/ref/#view.NodeView) 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`](https://prosemirror.net/docs/ref/#view.NodeView.dom) which is your top-most container element.

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

this.dom.appendChild(this.img);
```

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

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

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

    // ...
}
```

Another thing I learned the hard way is: **Don't use [`contentDOM`](https://prosemirror.net/docs/ref/#view.NodeView.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.

```js
ignoreMutation() {
    return true;
}
```

### 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.

```js
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;
}
```

### 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.

```js
 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);
  }
```

```js
 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');
  }
```

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`.

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

    setTimeout(() => {
      this.isEditing = false;
    }, 0);
}
```

### 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.

```js
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;
}
```

### 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.

```js
 destroy() {
    document.removeEventListener('click', this.outsideClickHandler);
    this.dom = null;
}
```

## 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:

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

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:

```js
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,
    }),
  );
});
```

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`](https://prosemirror.net/docs/ref/#view.EditorProps.nodeViews) property:

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

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