Resize images on Tiptap Editor with Vue
Tiptap WYSIWYG Editor
This week I was in need of a WYSIWYG editor, so I found Tiptap and so far so good. Just one little problem, it does not have a built-in way to resize the images inside its editor.
The image resizing is a needed feature so I had to find a way to do it. Luckily, Tiptap lets its extensions to be extended, so we are going to start from there.
The project
We are going to use Vue 2 and its CLI for the example project. The relevant commands are: npm i -g @vue/cli
to install the CLI and vue create <name-here>
to create the project.
Now to install the necessary Tiptap dependencies:
npm install @tiptap/vue-2 @tiptap/starter-kit @tiptap/extension-image
Code
You can get the full code here, but the important bits are next.
Extend the extension
First, we have to extend the Image extension:
import Image from '@tiptap/extension-image';
// ...
export default Image.extend({
name: 'ResizableImage',
// ...
});
Next, we have to add the attributes that we need (width and height):
// ...
addAttributes() {
return {
// Inherit all the attrs of the Image extension
...this.parent?.(),
// New attrs
width: {
default: '100%',
// tell them to render on the img tag
renderHTML: (attributes) => {
return {
width: attributes.width
};
}
},
height: {
default: 'auto',
renderHTML: (attributes) => {
return {
height: attributes.height
};
}
},
// We'll use this to tell if we are going to drag the image
// through the editor or if we are resizing it
isDraggable: {
default: true,
// We don't want it to render on the img tag
renderHTML: (attributes) => {
return {};
}
}
};
},
// ...
Now, the commands to so we can add images and tell them to be resizable
// ...
addCommands() {
return {
// Inherit all the commands of the Image extension.
// This way we can add images as always:
// this.editor.chain().focus()
// .setImage({
// src: 'https://source.unsplash.com/8xznAGy4HcY/800x400',
// width: '80',
// height: '40'
// })
// .run();
...this.parent?.(),
// New command that is going to be called like:
// this.editor.chain().focus().toggleResizable().run();
toggleResizable:
() =>
({ tr }) => {
const { node } = tr?.selection;
if (node?.type?.name === 'ResizableImage') {
node.attrs.isDraggable = !node.attrs.isDraggable;
}
}
};
},
// ...
The last piece of the resizable-image.js
file is to call the Vue component that we are going to use as template or renderer for our extended extension:
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import ResizableImageTemplate from './ResizableImageTemplate.vue';
// ...
// ...
addNodeView() {
return VueNodeViewRenderer(ResizableImageTemplate);
}
// ...
The template/renderer component
This component is the one that is going to create the <img src ... />
tag and will add the resizing functionality.
For the html part:
<template>
<node-view-wrapper as="span">
<img
v-bind="node.attrs"
:draggable="isDraggable"
:data-drag-handle="isDraggable"
ref="resizableImg" />
</node-view-wrapper>
</template>
We are binding the attributes that we defined in resizable-image.js
and letting Tiptap now when the image is draggable or resizable (our own custom functionality).
The boilerplate code:
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-2';
export default {
components: {
NodeViewWrapper
},
props: nodeViewProps,
// ...
}
The data used in this component:
// ...
data() {
return {
// When is resizing
isResizing: false,
// We keep last movement calculation so we can
// determine if the image is going to get larger or smaller
lastMovement: 0,
// Original aspect ratio of the img
aspectRatio: 0
};
},
computed: {
// The isDraggable attr from 'resizable-image.js'
isDraggable() {
return this.node?.attrs?.isDraggable;
}
}
// ...
Now, when the component has mounted we are going to get the aspect ratio of the image and add the events that will trigger the resizing. Original code by MDN Web Docs.
// ...
mounted() {
// When the image has loaded
this.$refs.resizableImg.onload = () => {
// Aspect Ratio from its original size
this.aspectRatio = this.$refs.resizableImg.naturalWidth / this.$refs.resizableImg.naturalHeight;
};
// On mouse down, start resizing
this.$refs.resizableImg.addEventListener('mousedown', (e) => {
// We are not resizing if the img is draggable
if (this.isDraggable) {
return;
}
this.isResizing = true;
});
// On mouse move, resize
this.$refs.resizableImg.addEventListener('mousemove', (e) => {
if (!this.isResizing) {
return;
}
// TL;DR: Current movement is larger, img is larger.
// Current movement is smaller, img is smaller.
//
// Using the Pythagorean theorem we are getting the magnitude
// of the vector/position of the mouse. If it is larger than the
// previous one, then we make the image larger, and viceversa.
// This makes the img larger when the mouse is moving to the bottom right of it.
let movement = Math.sqrt(Math.pow(e.offsetY, 2) + Math.pow(e.offsetX, 2));
if (this.lastMovement > 0) {
if (movement > this.lastMovement) {
this.resizeAspectRatio(true);
} else if (movement < this.lastMovement) {
this.resizeAspectRatio(false);
}
}
this.lastMovement = movement;
});
// We stop resizing when releasing the click button.
// Caveat: it only works when the mouse is over the img.
this.$refs.resizableImg.addEventListener('mouseup', (e) => {
this.isResizing = false;
this.lastMovement = 0;
});
},
// ...
The final piece is the resizing itself:
// ...
methods: {
resizeAspectRatio(grow) {
let calcW;
let calcH;
// We just add or subtract 1 to the height
if (grow) {
calcH = this.$refs.resizableImg.height + 1;
} else {
calcH = this.$refs.resizableImg.height - 1;
}
// And calculate the width with the Aspect Ratio
calcW = calcH * this.aspectRatio;
// Tell Tiptap to update width and height
this.updateAttributes({ width: calcW, height: calcH });
}
}
// ...
The editor
For the editor we don't have to do much, just the basics and a couple of lines more.
import { Editor, EditorContent } from '@tiptap/vue-2';
import StarterKit from '@tiptap/starter-kit';
// We are importing our custom extension
import ResizableImage from './resizable-image.js';
// ...
// When creating the Editor object, we use our ResizableImage extension
mounted() {
this.editor = new Editor({
extensions: [
StarterKit,
ResizableImage.configure({
inline: true
})
],
});
},
// ...
// The function that is going to toggle the 'isDraggable' attr
methods: {
toggleResize() {
this.editor.chain().focus().toggleResizable().run();
}
},
// ...
// For the button that will trigger 'toggleResize'
computed: {
// We show that button only if the node is of type 'ResizableImage'
showButton() {
return this.editor?.state?.selection?.node?.type?.name === 'ResizableImage';
},
// And let the button know what is going on with the img right now
isDraggable() {
return this.editor?.state?.selection?.node?.attrs?.isDraggable;
}
},
Conclusion
This is just the basics of what we need and we can improve it in so many ways, but for now it is enough.