Resize images on editor
MatheusRich opened this issue ยท 28 comments
Is your feature request related to a problem? Please describe.
I would like to be able to resize images inside the editor. That way, content would be more flexible. I didn't find a way to do this through tiptap
.
Describe the solution you'd like
Adding a resize option like this (gif bellow) would help so much!
Describe alternatives you've considered
I really don't know how to implement this. Should this be on the Image plugin or a totally separated plugin? If you guys give some ideas I could make a PR.
you could do this currently by manipulating the size manually via an input e.g. in a modal where detached from the editor but your feature request would be a great improvement for image handling ๐
ร think you can achieve that by creating a custom plugin, you could use the image component as starting point and just adding a custom, separated vue view (getView()
), where you could handle your custom dragging/resizing event (or implement a custom lib)
I'm also interested in this feature
Hi guys,
Any updates?
for now I don't have plans to implement this in the core package
any updates?
:( need this
Definitely need this :O
I really need this too
My workaround for this at the moment:
I created CustomResizableImage component:
<template>
<node-view-wrapper as="span" class="image-container">
<img
v-bind="node.attrs"
ref="resizableImg"
:draggable="isDraggable"
:data-drag-handle="isDraggable"
/>
<v-icon
class="ml-n3 resize-icon hidden"
ref="icon"
@mousedown="onMouseDown"
>
mdi-arrow-top-left-bottom-right-bold
</v-icon>
</node-view-wrapper>
</template>
<script>
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-2';
export default {
components: {
NodeViewWrapper,
},
props: nodeViewProps,
data: () => ({
isResizing: false,
lastMovement: {},
count: 0,
}),
computed: {
isDraggable() {
return this.node?.attrs?.isDraggable;
},
},
watch: {},
mounted() {},
methods: {
onMouseDown(e) {
e.preventDefault();
this.isResizing = true;
window.addEventListener('mousemove', this.throttle(this.onMouseMove));
window.addEventListener('mouseup', this.onMouseUp);
},
onMouseUp(e) {
// e.preventDefault();
this.isResizing = false;
this.lastMovement = {};
window.removeEventListener('mousemove', this.throttle(this.onMouseMove));
window.removeEventListener('mouseup', this.onMouseUp);
},
throttle(fn, wait = 60, leading = true) {
let prev, timeout, lastargs;
return (...args) => {
lastargs = args;
if (timeout) return;
timeout = setTimeout(
() => {
timeout = null;
prev = [Date.now](http://date.now/)();
// let's do this ... we'll release the stored args as we pass them through
fn.apply(this, lastargs.splice(0, lastargs.length));
// some fancy timing logic to allow leading / sub-offset waiting periods
},
leading ? (prev && Math.max(0, wait - [Date.now](http://date.now/)() + prev)) || 0 : wait
);
};
},
onMouseMove(e) {
e.preventDefault();
if (!this.isResizing) {
return;
}
if (!Object.keys(this.lastMovement).length) {
this.lastMovement = { x: e.x, y: e.y };
return;
}
if (e.x === this.lastMovement.x && e.y === this.lastMovement.y) {
return;
}
let nextX = e.x - this.lastMovement.x;
let nextY = e.y - this.lastMovement.y;
const width = this.$refs.resizableImg.width + nextX;
const height = this.$refs.resizableImg.height + nextY;
this.lastMovement = { x: e.x, y: e.y };
this.updateAttributes({ width, height });
},
},
};
</script>
<style lang="scss" scoped>
.image-container:hover {
.hidden {
visibility: visible !important;
}
}
.image-container {
overflow: hidden;
position: relative;
}
.resize-icon {
position: absolute;
bottom: 0;
}
::v-deep.resize-icon {
cursor: se-resize !important;
}
</style>
and rendered it in my CustomImage Extension
import Image from '@tiptap/extension-image';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import ResizableImageTemplate from './ResizableImageTemplate.vue';
const CustomImage = Image.extend({
name: 'CustomImage',
addAttributes() {
// Return an object with attribute configuration
return {
...this.parent?.(),
src: {
default: '',
renderHTML: attributes => {
// โฆ and return an object with HTML attributes.
return {
src: attributes.src,
};
},
},
width: {
default: 750,
renderHTML: ({ width }) => ({ width }),
},
height: {
default: 500,
renderHTML: ({ height }) => ({ height }),
},
isDraggable: {
default: true,
// We don't want it to render on the img tag
renderHTML: attributes => {
return {};
},
},
};
},
addNodeView() {
return VueNodeViewRenderer(ResizableImageTemplate);
},
});
export { CustomImage };
export default CustomImage;
After that, just use this CustomImage instead of the original Image.
EDITED: Return src attribute instead of style
My workaround for this at the moment:
I created CustomResizableImage component
@theodorenguyen45 You're a life saver, I've been having real issues trying to write an extension for this and yours is great, thanks so much!
@naffarn Glad it helps, I just updated the CustomImage component code a little bit. For src attribute, it should render src instead of style.
@theodorenguyen45 Yep, I already tweaked that, it was just the resize function that I was having issues with - it works great now!
@theodorenguyen45 Thank you so much for the extension, It's exactly what I was looking for and the only thing tiptap really lacks.
I just had one issue, the scss doesn't wanna work and shows this:
From what I saw online it might be my compiler options but I'm a bit new to scss so I don't really understand where those options are in vue. Can someone help ?
@ZarkhanNaro you will need to add and config sass-loader into your project. Or you can just convert those scss into raw css.
@theonmt Thank you for the answer, I also figured I could just convert it. The real problem tho is that I use the tiptap-vuetify component and it seems that there's more functions to implement as I'm getting this:
I didn't find where I should implement this function yet, so If anyone knows let me know !
Following #333 (comment) from @theodorenguyen45, here is a React version:
import { NodeViewWrapper, type NodeViewProps, ReactNodeViewRenderer } from "@tiptap/react";
import { type CSSProperties, useRef, useState } from "react";
import TipTapImage from "@tiptap/extension-image";
import DragIcon from "$icons/Resize.svg";
import { useEvent } from "$utils/hooks";
const MIN_WIDTH = 60;
const ResizableImageTemplate = ({ node, updateAttributes }: NodeViewProps) => {
const imgRef = useRef<HTMLImageElement>(null);
const [resizingStyle, setResizingStyle] = useState<Pick<CSSProperties, "width"> | undefined>();
const handleMouseDown = useEvent((event: React.MouseEvent) => {
if (!imgRef.current) return;
event.preventDefault();
const initialXPosition = event.clientX;
const currentWidth = imgRef.current.width;
let newWidth = currentWidth;
const removeListeners = () => {
window.removeEventListener("mousemove", mouseMoveHandler);
window.removeEventListener("mouseup", removeListeners);
updateAttributes({ width: newWidth });
setResizingStyle(undefined);
};
const mouseMoveHandler = (event: MouseEvent) => {
newWidth = Math.max(currentWidth + (event.clientX - initialXPosition), MIN_WIDTH);
setResizingStyle({ width: newWidth });
// If mouse is up, remove event listeners
if (!event.buttons) removeListeners();
};
window.addEventListener("mousemove", mouseMoveHandler);
window.addEventListener("mouseup", removeListeners);
});
return (
<NodeViewWrapper as="span" className={styles.container} draggable data-drag-handle>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img {...node.attrs} ref={imgRef} style={resizingStyle} className={styles.img(node.attrs.className)} />
<div
role="button"
tabIndex={0}
onMouseDown={handleMouseDown}
className={styles.dragIcon.container(!!resizingStyle)}
>
<DragIcon className={styles.dragIcon.icon} />
</div>
</NodeViewWrapper>
);
};
const ResizableImageExtension = TipTapImage.extend({
addAttributes() {
return {
...this.parent?.(),
width: { renderHTML: ({ width }) => ({ width }) },
height: { renderHTML: ({ height }) => ({ height }) },
};
},
addNodeView() {
return ReactNodeViewRenderer(ResizableImageTemplate);
},
}).configure({ inline: true });
export default ResizableImageExtension;
const styles = {
/** ... */
};
@ericmatte Thanks for the great code! I needed to modify it a bit for me needs (using inline styles) and also made all four corners actionable. Figured I'd share in case it helped anyone:
// Inspired/plagiarized from
// https://github.com/ueberdosis/tiptap/issues/333#issuecomment-1056434177
import { NodeViewWrapper, type NodeViewProps, ReactNodeViewRenderer } from "@tiptap/react";
import {type CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState} from "react";
import TipTapImage from "@tiptap/extension-image";
const useEvent = <T extends (...args: any[]) => any>(handler: T): T => {
const handlerRef = useRef<T | null>(null);
useLayoutEffect(() => {
handlerRef.current = handler;
}, [handler]);
return useCallback((...args: Parameters<T>): ReturnType<T> => {
if (handlerRef.current === null) {
throw new Error('Handler is not assigned');
}
return handlerRef.current(...args);
}, []) as T;
};
const MIN_WIDTH = 60;
const BORDER_COLOR = '#0096fd';
const ResizableImageTemplate = ({ node, updateAttributes }: NodeViewProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const [editing, setEditing] = useState(false);
const [resizingStyle, setResizingStyle] = useState<Pick<CSSProperties, "width"> | undefined>();
// Lots of work to handle "not" div click events.
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setEditing(false);
}
};
// Add click event listener and remove on cleanup
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, [editing]);
const handleMouseDown = useEvent((event: React.MouseEvent<HTMLDivElement>) => {
if (!imgRef.current) return;
event.preventDefault();
const direction = event.currentTarget.dataset.direction || "--";
const initialXPosition = event.clientX;
const currentWidth = imgRef.current.width;
let newWidth = currentWidth;
const transform = direction[1] === "w" ? -1 : 1;
const removeListeners = () => {
window.removeEventListener("mousemove", mouseMoveHandler);
window.removeEventListener("mouseup", removeListeners);
updateAttributes({ width: newWidth });
setResizingStyle(undefined);
};
const mouseMoveHandler = (event: MouseEvent) => {
newWidth = Math.max(currentWidth + (transform * (event.clientX - initialXPosition)), MIN_WIDTH);
setResizingStyle({ width: newWidth });
// If mouse is up, remove event listeners
if (!event.buttons) removeListeners();
};
window.addEventListener("mousemove", mouseMoveHandler);
window.addEventListener("mouseup", removeListeners);
});
const dragCornerButton = (direction: string) => (
<div
role="button"
tabIndex={0}
onMouseDown={handleMouseDown}
data-direction={direction}
style={{
position: 'absolute',
height: '10px',
width: '10px',
backgroundColor: BORDER_COLOR,
...({ n: { top: 0 }, s: { bottom: 0 } }[direction[0]]),
...({ w: { left: 0 }, e: { right: 0 } }[direction[1]]),
cursor: `${direction}-resize`,
}}
>
</div>
);
return (
<NodeViewWrapper
ref={containerRef}
as="div" draggable data-drag-handle
onClick={() => setEditing(true)}
onBlur={() => setEditing(false)}
style={{
overflow: 'hidden',
position: 'relative',
display: 'inline-block',
// Weird! Basically tiptap/prose wraps this in a span and the line height causes an annoying buffer.
lineHeight: '0px',
}}
>
<img
{...node.attrs} ref={imgRef}
style={{
...resizingStyle,
cursor: 'default',
}}
/>
{editing && (
<>
{/* Don't use a simple border as it pushes other content around. */}
{[
{left: 0, top: 0, height: '100%', width: '1px'}, {right: 0, top: 0, height: '100%', width: '1px'},
{top: 0, left: 0, width: '100%', height: '1px'}, {bottom: 0, left: 0, width: '100%', height: '1px'}
].map((style, i) => (
<div key={i} style={{ position: 'absolute', backgroundColor: BORDER_COLOR, ...style }}></div>
))}
{dragCornerButton("nw")}
{dragCornerButton("ne")}
{dragCornerButton("sw")}
{dragCornerButton("se")}
</>
)}
</NodeViewWrapper>
);
};
const ResizableImageExtension = TipTapImage.extend({
addAttributes() {
return {
...this.parent?.(),
width: { renderHTML: ({ width }) => ({ width }) },
height: { renderHTML: ({ height }) => ({ height }) },
};
},
addNodeView() {
return ReactNodeViewRenderer(ResizableImageTemplate);
},
}).configure({ inline: true });
export default ResizableImageExtension;
@ericmatte Thanks for the great code! I needed to modify it a bit for my needs and also made all four corners actionable. Figured I'd share in case it helped anyone:
// Inspired/plagiarized from
// https://github.com/ueberdosis/tiptap/issues/333#issuecomment-1056434177
import { NodeViewWrapper, type NodeViewProps, ReactNodeViewRenderer } from "@tiptap/react";
import {type CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState} from "react";
import TipTapImage from "@tiptap/extension-image";
const useEvent = <T extends (...args: any[]) => any>(handler: T): T => {
const handlerRef = useRef<T | null>(null);
useLayoutEffect(() => {
handlerRef.current = handler;
}, [handler]);
return useCallback((...args: Parameters<T>): ReturnType<T> => {
if (handlerRef.current === null) {
throw new Error('Handler is not assigned');
}
return handlerRef.current(...args);
}, []) as T;
};
const MIN_WIDTH = 60;
const BORDER_COLOR = '#0096fd';
const ResizableImageTemplate = ({ node, updateAttributes }: NodeViewProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const [editing, setEditing] = useState(false);
const [resizingStyle, setResizingStyle] = useState<Pick<CSSProperties, "width"> | undefined>();
// Lots of work to handle "not" div click events.
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setEditing(false);
}
};
// Add click event listener and remove on cleanup
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, [editing]);
const handleMouseDown = useEvent((event: React.MouseEvent<HTMLDivElement>) => {
if (!imgRef.current) return;
event.preventDefault();
const direction = event.currentTarget.dataset.direction || "--";
const initialXPosition = event.clientX;
const currentWidth = imgRef.current.width;
let newWidth = currentWidth;
const transform = direction[1] === "w" ? -1 : 1;
const removeListeners = () => {
window.removeEventListener("mousemove", mouseMoveHandler);
window.removeEventListener("mouseup", removeListeners);
updateAttributes({ width: newWidth });
setResizingStyle(undefined);
};
const mouseMoveHandler = (event: MouseEvent) => {
newWidth = Math.max(currentWidth + (transform * (event.clientX - initialXPosition)), MIN_WIDTH);
setResizingStyle({ width: newWidth });
// If mouse is up, remove event listeners
if (!event.buttons) removeListeners();
};
window.addEventListener("mousemove", mouseMoveHandler);
window.addEventListener("mouseup", removeListeners);
});
const dragCornerButton = (direction: string) => (
<div
role="button"
tabIndex={0}
onMouseDown={handleMouseDown}
data-direction={direction}
style={{
position: 'absolute',
height: '10px',
width: '10px',
backgroundColor: BORDER_COLOR,
...({ n: { top: 0 }, s: { bottom: 0 } }[direction[0]]),
...({ w: { left: 0 }, e: { right: 0 } }[direction[1]]),
cursor: `${direction}-resize`,
}}
>
</div>
);
return (
<NodeViewWrapper
ref={containerRef}
as="div" draggable data-drag-handle
onClick={() => setEditing(true)}
onBlur={() => setEditing(false)}
style={{
overflow: 'hidden',
position: 'relative',
display: 'inline-block',
// Weird! Basically tiptap/prose wraps this in a span and the line height causes an annoying buffer.
lineHeight: '0px',
}}
>
<img
{...node.attrs} ref={imgRef}
style={{
...resizingStyle,
cursor: 'default',
}}
/>
{editing && (
<>
{/* Don't use a simple border as it pushes other content around. */}
{[
{left: 0, top: 0, height: '100%', width: '1px'}, {right: 0, top: 0, height: '100%', width: '1px'},
{top: 0, left: 0, width: '100%', height: '1px'}, {bottom: 0, left: 0, width: '100%', height: '1px'}
].map((style, i) => (
<div key={i} style={{ position: 'absolute', backgroundColor: BORDER_COLOR, ...style }}></div>
))}
{dragCornerButton("nw")}
{dragCornerButton("ne")}
{dragCornerButton("sw")}
{dragCornerButton("se")}
</>
)}
</NodeViewWrapper>
);
};
const ResizableImageExtension = TipTapImage.extend({
addAttributes() {
return {
...this.parent?.(),
width: { renderHTML: ({ width }) => ({ width }) },
height: { renderHTML: ({ height }) => ({ height }) },
};
},
addNodeView() {
return ReactNodeViewRenderer(ResizableImageTemplate);
},
}).configure({ inline: true });
export default ResizableImageExtension;
Thanks @kkaehler for the solution, I just copy/paste and it works in my next.js project.
But I noticed after adding this extension, the placeholder image is in the incorrect place when dragging the image, for example:
So I add another div inside and looks it solved the issue, the completed code is:
// Inspired/plagiarized from
// https://github.com/ueberdosis/tiptap/issues/333#issuecomment-1056434177
import { NodeViewWrapper, type NodeViewProps, ReactNodeViewRenderer } from "@tiptap/react";
import {type CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState} from "react";
import TipTapImage from "@tiptap/extension-image";
const useEvent = <T extends (...args: any[]) => any>(handler: T): T => {
const handlerRef = useRef<T | null>(null);
useLayoutEffect(() => {
handlerRef.current = handler;
}, [handler]);
return useCallback((...args: Parameters<T>): ReturnType<T> => {
if (handlerRef.current === null) {
throw new Error('Handler is not assigned');
}
return handlerRef.current(...args);
}, []) as T;
};
const MIN_WIDTH = 60;
const BORDER_COLOR = '#0096fd';
const ResizableImageTemplate = ({ node, updateAttributes }: NodeViewProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const [editing, setEditing] = useState(false);
const [resizingStyle, setResizingStyle] = useState<Pick<CSSProperties, "width"> | undefined>();
// Lots of work to handle "not" div click events.
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setEditing(false);
}
};
// Add click event listener and remove on cleanup
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, [editing]);
const handleMouseDown = useEvent((event: React.MouseEvent<HTMLDivElement>) => {
if (!imgRef.current) return;
event.preventDefault();
const direction = event.currentTarget.dataset.direction || "--";
const initialXPosition = event.clientX;
const currentWidth = imgRef.current.width;
let newWidth = currentWidth;
const transform = direction[1] === "w" ? -1 : 1;
const removeListeners = () => {
window.removeEventListener("mousemove", mouseMoveHandler);
window.removeEventListener("mouseup", removeListeners);
updateAttributes({ width: newWidth });
setResizingStyle(undefined);
};
const mouseMoveHandler = (event: MouseEvent) => {
newWidth = Math.max(currentWidth + (transform * (event.clientX - initialXPosition)), MIN_WIDTH);
setResizingStyle({ width: newWidth });
// If mouse is up, remove event listeners
if (!event.buttons) removeListeners();
};
window.addEventListener("mousemove", mouseMoveHandler);
window.addEventListener("mouseup", removeListeners);
});
const dragCornerButton = (direction: string) => (
<div
role="button"
tabIndex={0}
onMouseDown={handleMouseDown}
data-direction={direction}
style={{
position: 'absolute',
height: '10px',
width: '10px',
backgroundColor: BORDER_COLOR,
...({ n: { top: 0 }, s: { bottom: 0 } }[direction[0]]),
...({ w: { left: 0 }, e: { right: 0 } }[direction[1]]),
cursor: `${direction}-resize`,
}}
>
</div>
);
return (
<NodeViewWrapper
ref={containerRef}
as="div" draggable data-drag-handle
onClick={() => setEditing(true)}
onBlur={() => setEditing(false)}
>
<div
style={{
overflow: 'hidden',
position: 'relative',
display: 'inline-block',
// Weird! Basically tiptap/prose wraps this in a span and the line height causes an annoying buffer.
lineHeight: '0px',
}}
>
<img
{...node.attrs} ref={imgRef}
style={{
...resizingStyle,
cursor: 'default',
}}
/>
{editing && (
<>
{/* Don't use a simple border as it pushes other content around. */}
{[
{left: 0, top: 0, height: '100%', width: '1px'}, {right: 0, top: 0, height: '100%', width: '1px'},
{top: 0, left: 0, width: '100%', height: '1px'}, {bottom: 0, left: 0, width: '100%', height: '1px'}
].map((style, i) => (
<div key={i} style={{ position: 'absolute', backgroundColor: BORDER_COLOR, ...style }}></div>
))}
{dragCornerButton("nw")}
{dragCornerButton("ne")}
{dragCornerButton("sw")}
{dragCornerButton("se")}
</>
)}
</div>
</NodeViewWrapper>
);
};
export default TipTapImage.extend({
addAttributes() {
return {
...this.parent?.(),
width: { renderHTML: ({ width }) => ({ width }) },
height: { renderHTML: ({ height }) => ({ height }) },
};
},
addNodeView() {
return ReactNodeViewRenderer(ResizableImageTemplate);
},
}).configure({ inline: true });
@kkaehler can we disable this if the editor is not editable right now its allowing to resize even when editable is false
ๆ็ฎๅๅฏนๆญค็่งฃๅณๆนๆณ๏ผ
ๆๅๅปบไบ CustomResizableImage ็ปไปถ๏ผ
<template> <node-view-wrapper as="span" class="image-container"> <img v-bind="node.attrs" ref="resizableImg" :draggable="isDraggable" :data-drag-handle="isDraggable" /> <v-icon class="ml-n3 resize-icon hidden" ref="icon" @mousedown="onMouseDown" > mdi-arrow-top-left-bottom-right-bold </v-icon> </node-view-wrapper> </template> <script> import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-2'; export default { components: { NodeViewWrapper, }, props: nodeViewProps, data: () => ({ isResizing: false, lastMovement: {}, count: 0, }), computed: { isDraggable() { return this.node?.attrs?.isDraggable; }, }, watch: {}, mounted() {}, methods: { onMouseDown(e) { e.preventDefault(); this.isResizing = true; window.addEventListener('mousemove', this.throttle(this.onMouseMove)); window.addEventListener('mouseup', this.onMouseUp); }, onMouseUp(e) { // e.preventDefault(); this.isResizing = false; this.lastMovement = {}; window.removeEventListener('mousemove', this.throttle(this.onMouseMove)); window.removeEventListener('mouseup', this.onMouseUp); }, throttle(fn, wait = 60, leading = true) { let prev, timeout, lastargs; return (...args) => { lastargs = args; if (timeout) return; timeout = setTimeout( () => { timeout = null; prev = [Date.now](http://date.now/)(); // let's do this ... we'll release the stored args as we pass them through fn.apply(this, lastargs.splice(0, lastargs.length)); // some fancy timing logic to allow leading / sub-offset waiting periods }, leading ? (prev && Math.max(0, wait - [Date.now](http://date.now/)() + prev)) || 0 : wait ); }; }, onMouseMove(e) { e.preventDefault(); if (!this.isResizing) { return; } if (!Object.keys(this.lastMovement).length) { this.lastMovement = { x: e.x, y: e.y }; return; } if (e.x === this.lastMovement.x && e.y === this.lastMovement.y) { return; } let nextX = e.x - this.lastMovement.x; let nextY = e.y - this.lastMovement.y; const width = this.$refs.resizableImg.width + nextX; const height = this.$refs.resizableImg.height + nextY; this.lastMovement = { x: e.x, y: e.y }; this.updateAttributes({ width, height }); }, }, }; </script> <style lang="scss" scoped> .image-container:hover { .hidden { visibility: visible !important; } } .image-container { overflow: hidden; position: relative; } .resize-icon { position: absolute; bottom: 0; } ::v-deep.resize-icon { cursor: se-resize !important; } </style>
ๅนถๅฐๅ ถๅ็ฐๅจๆ็ CustomImage ๆฉๅฑไธญ
import Image from '@tiptap/extension-image'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; import ResizableImageTemplate from './ResizableImageTemplate.vue'; const CustomImage = Image.extend({ name: 'CustomImage', addAttributes() { // Return an object with attribute configuration return { ...this.parent?.(), src: { default: '', renderHTML: attributes => { // โฆ and return an object with HTML attributes. return { src: attributes.src, }; }, }, width: { default: 750, renderHTML: ({ width }) => ({ width }), }, height: { default: 500, renderHTML: ({ height }) => ({ height }), }, isDraggable: { default: true, // We don't want it to render on the img tag renderHTML: attributes => { return {}; }, }, }; }, addNodeView() { return VueNodeViewRenderer(ResizableImageTemplate); }, }); export { CustomImage }; export default CustomImage;
ไนๅ๏ผๅช้ไฝฟ็จๆญค CustomImage ่ไธๆฏๅๅง Imageใ
EDITED๏ผ่ฟๅ src ๅฑๆง่ไธๆฏๆ ทๅผ
Is this version suitable for vue3?
Hello everyone,
I recently integrated the Tiptap editor into my project, and everything was working well until I encountered an issue while attempting to resize images. Unfortunately, I'm unsure about the appropriate solution for this situation. Could someone please offer guidance or a solution? I'm using Vue 3 for my project. Thank you!
Thanks @kkaehler for the solution, I just copy/paste and it works in my next.js project. But I noticed after adding this extension, the placeholder image is in the incorrect place when dragging the image, for example:
So I add another div inside and looks it solved the issue, the completed code is:
// Inspired/plagiarized from // https://github.com/ueberdosis/tiptap/issues/333#issuecomment-1056434177 import { NodeViewWrapper, type NodeViewProps, ReactNodeViewRenderer } from "@tiptap/react"; import {type CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState} from "react"; import TipTapImage from "@tiptap/extension-image"; const useEvent = <T extends (...args: any[]) => any>(handler: T): T => { const handlerRef = useRef<T | null>(null); useLayoutEffect(() => { handlerRef.current = handler; }, [handler]); return useCallback((...args: Parameters<T>): ReturnType<T> => { if (handlerRef.current === null) { throw new Error('Handler is not assigned'); } return handlerRef.current(...args); }, []) as T; }; const MIN_WIDTH = 60; const BORDER_COLOR = '#0096fd'; const ResizableImageTemplate = ({ node, updateAttributes }: NodeViewProps) => { const containerRef = useRef<HTMLDivElement>(null); const imgRef = useRef<HTMLImageElement>(null); const [editing, setEditing] = useState(false); const [resizingStyle, setResizingStyle] = useState<Pick<CSSProperties, "width"> | undefined>(); // Lots of work to handle "not" div click events. useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setEditing(false); } }; // Add click event listener and remove on cleanup document.addEventListener('click', handleClickOutside); return () => { document.removeEventListener('click', handleClickOutside); }; }, [editing]); const handleMouseDown = useEvent((event: React.MouseEvent<HTMLDivElement>) => { if (!imgRef.current) return; event.preventDefault(); const direction = event.currentTarget.dataset.direction || "--"; const initialXPosition = event.clientX; const currentWidth = imgRef.current.width; let newWidth = currentWidth; const transform = direction[1] === "w" ? -1 : 1; const removeListeners = () => { window.removeEventListener("mousemove", mouseMoveHandler); window.removeEventListener("mouseup", removeListeners); updateAttributes({ width: newWidth }); setResizingStyle(undefined); }; const mouseMoveHandler = (event: MouseEvent) => { newWidth = Math.max(currentWidth + (transform * (event.clientX - initialXPosition)), MIN_WIDTH); setResizingStyle({ width: newWidth }); // If mouse is up, remove event listeners if (!event.buttons) removeListeners(); }; window.addEventListener("mousemove", mouseMoveHandler); window.addEventListener("mouseup", removeListeners); }); const dragCornerButton = (direction: string) => ( <div role="button" tabIndex={0} onMouseDown={handleMouseDown} data-direction={direction} style={{ position: 'absolute', height: '10px', width: '10px', backgroundColor: BORDER_COLOR, ...({ n: { top: 0 }, s: { bottom: 0 } }[direction[0]]), ...({ w: { left: 0 }, e: { right: 0 } }[direction[1]]), cursor: `${direction}-resize`, }} > </div> ); return ( <NodeViewWrapper ref={containerRef} as="div" draggable data-drag-handle onClick={() => setEditing(true)} onBlur={() => setEditing(false)} > <div style={{ overflow: 'hidden', position: 'relative', display: 'inline-block', // Weird! Basically tiptap/prose wraps this in a span and the line height causes an annoying buffer. lineHeight: '0px', }} > <img {...node.attrs} ref={imgRef} style={{ ...resizingStyle, cursor: 'default', }} /> {editing && ( <> {/* Don't use a simple border as it pushes other content around. */} {[ {left: 0, top: 0, height: '100%', width: '1px'}, {right: 0, top: 0, height: '100%', width: '1px'}, {top: 0, left: 0, width: '100%', height: '1px'}, {bottom: 0, left: 0, width: '100%', height: '1px'} ].map((style, i) => ( <div key={i} style={{ position: 'absolute', backgroundColor: BORDER_COLOR, ...style }}></div> ))} {dragCornerButton("nw")} {dragCornerButton("ne")} {dragCornerButton("sw")} {dragCornerButton("se")} </> )} </div> </NodeViewWrapper> ); }; export default TipTapImage.extend({ addAttributes() { return { ...this.parent?.(), width: { renderHTML: ({ width }) => ({ width }) }, height: { renderHTML: ({ height }) => ({ height }) }, }; }, addNodeView() { return ReactNodeViewRenderer(ResizableImageTemplate); }, }).configure({ inline: true });
@wwayne Replace display: 'inline-block',
in NodeViewWrapper to display: "table",
it will fix the position issue
Thanks @kkaehler, was able to use that as a base to extend the Youtube extension with resizability
Screen.Recording.2024-02-23.at.5.44.27.AM.mov
// YoutubeResize.tsx
import { ResizableYoutubeTemplate } from '@/app/(testing)/tiptap/components/YoutubeResize/ResizableYoutubeTemplate'
import { Youtube } from '@tiptap/extension-youtube'
import { ReactNodeViewRenderer } from '@tiptap/react'
export const YoutubeResize = Youtube.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: this.options.width,
renderHTML: ({ width }) => ({ width })
},
height: {
default: 'auto',
renderHTML: ({ height }) => ({ height })
},
align: {
default: 'mx-auto'
}
}
},
addNodeView() {
return ReactNodeViewRenderer(ResizableYoutubeTemplate)
}
}).configure({
modestBranding: true,
ivLoadPolicy: 3
})
// ResizableYoutubeTemplate.tsx
import { getEmbedUrlFromYoutubeUrl } from '@/app/(testing)/tiptap/components/YoutubeResize/utils'
import { cn } from '@/lib/utils'
import { mergeAttributes } from '@tiptap/core'
import { YoutubeOptions } from '@tiptap/extension-youtube'
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react'
import React, {
CSSProperties,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react'
const useEvent = <T extends (...args: any[]) => any>(handler: T): T => {
const handlerRef = useRef<T | null>(null)
useLayoutEffect(() => {
handlerRef.current = handler
}, [handler])
return useCallback((...args: Parameters<T>): ReturnType<T> => {
if (handlerRef.current === null) {
throw new Error('Handler is not assigned')
}
return handlerRef.current(...args)
}, []) as T
}
export const ResizableYoutubeTemplate = ({
editor,
node,
updateAttributes,
extension
}: NodeViewProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const iFrameRef = useRef<HTMLIFrameElement>(null)
const [editing, setEditing] = useState(false)
const [resizingStyle, setResizingStyle] = useState<Pick<CSSProperties, 'width'> | undefined>()
// Lots of work to handle "not" div click events.
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setEditing(false)
}
}
// Add click event listener and remove on cleanup
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickOutside)
}
}, [editing])
const handleMouseDown = useEvent((event: React.MouseEvent<HTMLDivElement>) => {
if (!iFrameRef.current) return
event.preventDefault()
setEditing(true)
const direction = event.currentTarget.dataset.direction || '-'
console.log('direction', direction)
const initialXPosition = event.clientX
const currentWidth = iFrameRef.current.clientWidth
let newWidth = currentWidth
const transform = direction === 'w' ? -1 : 1
const mouseMoveHandler = (event: MouseEvent) => {
newWidth = currentWidth + transform * (event.clientX - initialXPosition)
setResizingStyle({ width: newWidth })
// If mouse is up, remove event listeners
if (!event.buttons) removeListeners()
}
const removeListeners = () => {
window.removeEventListener('mousemove', mouseMoveHandler)
window.removeEventListener('mouseup', removeListeners)
setEditing(false)
updateAttributes({ width: newWidth })
setResizingStyle(undefined)
}
window.addEventListener('mousemove', mouseMoveHandler)
window.addEventListener('mouseup', removeListeners)
})
const dragCornerButton = (direction: string, className?: string) => (
<div
role='button'
tabIndex={0}
onMouseDown={handleMouseDown}
data-direction={direction}
className={cn(
`absolute top-1/2 h-16 w-2 -translate-y-1/2 transform rounded-md bg-secondary hover:bg-muted-foreground`,
className,
editing && 'bg-muted-foreground'
)}
></div>
)
const options = extension.options as YoutubeOptions
const embedUrl = getEmbedUrlFromYoutubeUrl({
url: node.attrs.src,
...options,
startAt: options.HTMLAttributes.start || node.attrs.start || 0
})
const iFrameOptions = mergeAttributes(options.HTMLAttributes, {
// width: options.width,
// height: options.height,
allowFullScreen: options.allowFullscreen,
autoPlay: options.autoplay,
cclanguage: options.ccLanguage,
ccloadpolicy: options.ccLoadPolicy,
controls: options.controls,
disablekbcontrols: options.disableKBcontrols.toString(),
enableiframeapi: options.enableIFrameApi.toString(),
endtime: options.endTime,
interfacelanguage: options.interfaceLanguage,
ivloadpolicy: options.ivLoadPolicy,
loop: options.loop,
modestbranding: options.modestBranding.toString(),
nocookie: options.nocookie.toString(),
origin: options.origin,
playlist: options.playlist,
progressbarcolor: options.progressBarColor
})
return (
<NodeViewWrapper
ref={containerRef}
as='div'
draggable
data-youtube-video
style={{
display: 'table',
// Weird! Basically tiptap/prose wraps this in a span and the line height causes an annoying buffer.
lineHeight: '0px'
}}
className={`relative my-6 overflow-visible rounded-md sm:my-8 ${node.attrs.align}`}
>
<iframe
{...node.attrs}
{...iFrameOptions}
ref={iFrameRef}
style={{
...resizingStyle
}}
src={embedUrl || undefined}
className={cn(
editing && `pointer-events-none cursor-default ring-1 ring-foreground`,
'aspect-video min-w-[200px] max-w-full rounded-md'
)}
></iframe>
<>
{dragCornerButton('w', '-left-4 cursor-w-resize')}
{dragCornerButton('e', '-right-4 cursor-e-resize')}
</>
</NodeViewWrapper>
)
}
// utils.tsx
// copied from https://github.com/ueberdosis/tiptap/blob/a863e1c49a0531ddfe06c4e73a427c109a4757db/packages/extension-youtube/src/utils.ts
export const YOUTUBE_REGEX =
/^(https?:\/\/)?(www\.|music\.)?(youtube\.com|youtu\.be|youtube-nocookie\.com)\/(?!channel\/)(?!@)(.+)?$/
export const YOUTUBE_REGEX_GLOBAL =
/^(https?:\/\/)?(www\.|music\.)?(youtube\.com|youtu\.be)\/(?!channel\/)(?!@)(.+)?$/g
export const isValidYoutubeUrl = (url: string) => {
return url.match(YOUTUBE_REGEX)
}
export interface GetEmbedUrlOptions {
url: string
allowFullscreen?: boolean
autoplay?: boolean
ccLanguage?: string
ccLoadPolicy?: boolean
controls?: boolean
disableKBcontrols?: boolean
enableIFrameApi?: boolean
endTime?: number
interfaceLanguage?: string
ivLoadPolicy?: number
loop?: boolean
modestBranding?: boolean
nocookie?: boolean
origin?: string
playlist?: string
progressBarColor?: string
startAt?: number
}
export const getYoutubeEmbedUrl = (nocookie?: boolean) => {
return nocookie ? 'https://www.youtube-nocookie.com/embed/' : 'https://www.youtube.com/embed/'
}
export const getEmbedUrlFromYoutubeUrl = (options: GetEmbedUrlOptions) => {
const {
url,
allowFullscreen,
autoplay,
ccLanguage,
ccLoadPolicy,
controls,
disableKBcontrols,
enableIFrameApi,
endTime,
interfaceLanguage,
ivLoadPolicy,
loop,
modestBranding,
nocookie,
origin,
playlist,
progressBarColor,
startAt
} = options
if (!isValidYoutubeUrl(url)) {
return null
}
// if is already an embed url, return it
if (url.includes('/embed/')) {
return url
}
// if is a youtu.be url, get the id after the /
if (url.includes('youtu.be')) {
const id = url.split('/').pop()
if (!id) {
return null
}
return `${getYoutubeEmbedUrl(nocookie)}${id}`
}
const videoIdRegex = /(?:v=|shorts\/)([-\w]+)/gm
const matches = videoIdRegex.exec(url)
if (!matches || !matches[1]) {
return null
}
let outputUrl = `${getYoutubeEmbedUrl(nocookie)}${matches[1]}`
const params = []
if (allowFullscreen === false) {
params.push('fs=0')
}
if (autoplay) {
params.push('autoplay=1')
}
if (ccLanguage) {
params.push(`cc_lang_pref=${ccLanguage}`)
}
if (ccLoadPolicy) {
params.push('cc_load_policy=1')
}
if (!controls) {
params.push('controls=0')
}
if (disableKBcontrols) {
params.push('disablekb=1')
}
if (enableIFrameApi) {
params.push('enablejsapi=1')
}
if (endTime) {
params.push(`end=${endTime}`)
}
if (interfaceLanguage) {
params.push(`hl=${interfaceLanguage}`)
}
if (ivLoadPolicy) {
params.push(`iv_load_policy=${ivLoadPolicy}`)
}
if (loop) {
params.push('loop=1')
}
if (modestBranding) {
params.push('modestbranding=1')
}
if (origin) {
params.push(`origin=${origin}`)
}
if (playlist) {
params.push(`playlist=${playlist}`)
}
if (startAt) {
params.push(`start=${startAt}`)
}
if (progressBarColor) {
params.push(`color=${progressBarColor}`)
}
if (params.length) {
outputUrl += `?${params.join('&')}`
}
return outputUrl
}
Was also able to add touch support for mobile:
RPReplay_Final1708733310.mov
// ImageResize.tsx
import { ResizableImageTemplate } from '@/app/(testing)/tiptap/components/ImageResize/ResizableImageTemplate'
import TipTapImage from '@tiptap/extension-image'
import { ReactNodeViewRenderer } from '@tiptap/react'
export const ImageResize = TipTapImage.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: 640,
renderHTML: ({ width }) => ({ width })
},
height: {
default: 'auto',
renderHTML: ({ height }) => ({ height })
},
align: {
default: 'mx-auto'
}
}
},
addNodeView() {
return ReactNodeViewRenderer(ResizableImageTemplate)
}
}).configure({ inline: false })
// ResizableImageTemplate
import { cn } from '@/lib/utils'
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react'
import React, {
CSSProperties,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react'
const useEvent = <T extends (...args: any[]) => any>(handler: T): T => {
const handlerRef = useRef<T | null>(null)
useLayoutEffect(() => {
handlerRef.current = handler
}, [handler])
return useCallback((...args: Parameters<T>): ReturnType<T> => {
if (handlerRef.current === null) {
throw new Error('Handler is not assigned')
}
return handlerRef.current(...args)
}, []) as T
}
export const ResizableImageTemplate = ({ node, updateAttributes }: NodeViewProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const imgRef = useRef<HTMLImageElement>(null)
const [editing, setEditing] = useState(false)
const [resizingStyle, setResizingStyle] = useState<Pick<CSSProperties, 'width'> | undefined>()
// Lots of work to handle "not" div click events.
useEffect(() => {
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setEditing(false)
}
}
// Add click event listener and remove on cleanup
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickOutside)
}
}, [editing])
const handleMouseDown = useEvent(
(event: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
if (!imgRef.current) return
setEditing(true)
const direction = event.currentTarget.dataset.direction || '--'
const initialXPosition = event.type.includes('mouse')
? (event as React.MouseEvent<HTMLDivElement>).clientX
: (event as React.TouchEvent<HTMLDivElement>).touches[0].clientX
const currentWidth = imgRef.current.clientWidth
let newWidth = currentWidth
const transform = direction === 'w' ? -1 : 1
const mouseMoveHandler = (event: MouseEvent | TouchEvent) => {
event.cancelable && event.preventDefault()
const currentPosition =
event instanceof MouseEvent ? event.clientX : event.touches[0].clientX
newWidth = currentWidth + transform * (currentPosition - initialXPosition)
setResizingStyle({ width: newWidth })
// If mouse is up, remove event listeners
// TODO: what about if touch is up?
if ('buttons' in event && !event.buttons) removeListeners()
}
const removeListeners = () => {
window.removeEventListener('mousemove', mouseMoveHandler)
window.removeEventListener('mouseup', removeListeners)
window.removeEventListener('touchmove', mouseMoveHandler)
window.removeEventListener('touchend', removeListeners)
setEditing(false)
updateAttributes({ width: newWidth })
setResizingStyle(undefined)
}
window.addEventListener('mousemove', mouseMoveHandler)
window.addEventListener('mouseup', removeListeners)
// passive false to prevent scroll on mobile while resizing
window.addEventListener('touchmove', mouseMoveHandler, { passive: false })
window.addEventListener('touchend', removeListeners, { passive: false })
}
)
const dragCornerButton = (direction: string, className?: string) => (
<div
role='button'
tabIndex={0}
data-direction={direction}
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
onClick={() => setEditing(true)}
onBlur={() => setEditing(false)}
className={cn(
`absolute top-1/2 h-16 w-2 -translate-y-1/2 transform rounded-md bg-secondary group-hover:bg-muted-foreground`,
className,
editing && 'bg-muted-foreground'
)}
></div>
)
return (
<NodeViewWrapper
ref={containerRef}
as='div'
draggable
data-drag-handle
onMouseDown={() => setEditing(true)}
onTouchStart={() => setEditing(true)}
onBlur={() => setEditing(false)}
style={{
display: 'table',
// Weird! Basically tiptap/prose wraps this in a span and the line height causes an annoying buffer.
lineHeight: '0px'
}}
className={`relative my-6 overflow-visible sm:my-8 ${node.attrs.align}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
{...node.attrs}
ref={imgRef}
style={{
...resizingStyle
}}
alt='img'
className={cn(
editing && `cursor-default ring-1 ring-foreground`,
'min-w-[200px] max-w-full rounded-md'
)}
/>
<div className='group'>
{dragCornerButton('w', '-left-3.5 cursor-w-resize')}
{dragCornerButton('e', '-right-3.5 cursor-e-resize')}
</div>
</NodeViewWrapper>
)
}
Following #333 (comment) from @theodorenguyen45, here is a React version:
import { NodeViewWrapper, type NodeViewProps, ReactNodeViewRenderer } from "@tiptap/react"; import { type CSSProperties, useRef, useState } from "react"; import TipTapImage from "@tiptap/extension-image"; import DragIcon from "$icons/Resize.svg"; import { useEvent } from "$utils/hooks"; const MIN_WIDTH = 60; const ResizableImageTemplate = ({ node, updateAttributes }: NodeViewProps) => { const imgRef = useRef<HTMLImageElement>(null); const [resizingStyle, setResizingStyle] = useState<Pick<CSSProperties, "width"> | undefined>(); const handleMouseDown = useEvent((event: React.MouseEvent) => { if (!imgRef.current) return; event.preventDefault(); const initialXPosition = event.clientX; const currentWidth = imgRef.current.width; let newWidth = currentWidth; const removeListeners = () => { window.removeEventListener("mousemove", mouseMoveHandler); window.removeEventListener("mouseup", removeListeners); updateAttributes({ width: newWidth }); setResizingStyle(undefined); }; const mouseMoveHandler = (event: MouseEvent) => { newWidth = Math.max(currentWidth + (event.clientX - initialXPosition), MIN_WIDTH); setResizingStyle({ width: newWidth }); // If mouse is up, remove event listeners if (!event.buttons) removeListeners(); }; window.addEventListener("mousemove", mouseMoveHandler); window.addEventListener("mouseup", removeListeners); }); return ( <NodeViewWrapper as="span" className={styles.container} draggable data-drag-handle> {/* eslint-disable-next-line jsx-a11y/alt-text */} <img {...node.attrs} ref={imgRef} style={resizingStyle} className={styles.img(node.attrs.className)} /> <div role="button" tabIndex={0} onMouseDown={handleMouseDown} className={styles.dragIcon.container(!!resizingStyle)} > <DragIcon className={styles.dragIcon.icon} /> </div> </NodeViewWrapper> ); }; const ResizableImageExtension = TipTapImage.extend({ addAttributes() { return { ...this.parent?.(), width: { renderHTML: ({ width }) => ({ width }) }, height: { renderHTML: ({ height }) => ({ height }) }, }; }, addNodeView() { return ReactNodeViewRenderer(ResizableImageTemplate); }, }).configure({ inline: true }); export default ResizableImageExtension; const styles = { /** ... */ };
Instead of using useState
, you can track the selected
state of the component.
Adapted version:
const ResizableImageTemplate = ({ node, updateAttributes, selected }: NodeViewProps) => {
const imgRef = useRef<HTMLImageElement>(null)
const [resizingStyle, setResizingStyle] = useState<Pick<CSSProperties, 'width'> | undefined>()
return (
<NodeViewWrapper>
<div
className={cn('image-container overflow-hidden relative inline-block', selected && 'outline')}
style={{
// Weird! Basically tiptap/prose wraps this in a span and the line height causes an annoying buffer.
// No Tailwind class is available to remove this.
lineHeight: '0px',
outlineColor: Colors.primary600,
}}
>
<img
{...node.attrs}
alt={node.attrs.src}
ref={imgRef}
// Weird! Basically tiptap/prose wraps this in a span and the line height causes an annoying buffer.
// No Tailwind class is available to remove this.
style={{
lineHeight: '0px',
width: resizingStyle?.width,
}}
/>
<div className="after-overlay" />
{selected &&
[DIRECTIONS.NW, DIRECTIONS.NE, DIRECTIONS.SW, DIRECTIONS.SE].map(direction => (
<DragCornerButton
key={direction}
direction={direction}
imgRef={imgRef}
onResizeStart={width => {
setResizingStyle({ width })
}}
onResizeEnd={width => {
updateAttributes({ width })
setResizingStyle(undefined)
}}
/>
))}
</div>
</NodeViewWrapper>
)
}
This issue thread is crazyyyy ๐