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.
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,
];
},
}
What we are saying there is that our image:
- Has a
src
attribute with default value as empty string if it wasn't provided —attrs.src.default
- Is to flow inline, which is the default behavior not only in HTML but also in Markdown spec —
inline: true
- Can only contain text nodes which themselves are inline. Can't be empty here —
content: text*
- Don't allow any marks such as bold —
marks: ""
- Belongs to the inline group —
group: inline
- Is a leaf node. Apparently, we can't do without this —
atom: true
- Is selectable —
selectable: true
- Tag is
img
when parsing, for example during pasting —parseDom
- 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() {}
}
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);
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;
// ...
}
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;
}
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;
}
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);
}
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:
- If user is still editing, reset
isEditing
property tofalse
and exit early. - Exit early as well if the click wasn't outside of the node view.
- If the click was outside of node view, clean up the event handler on
document
and remove the CSS class added inselectNode
.
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);
}
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;
}
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;
}
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 */,
}),
);
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,
}),
);
});
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);
},
},
});
And that's all I got! Please leave a comment if you know there's a better way to all this :)
Top comments (0)