-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Resize images on editor #333
Comments
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 ( |
I'm also interested in this feature |
Hi guys, |
@scrumpy In the plan?I'm also interested in this feature |
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:
and rendered it in my CustomImage Extension
After that, just use this CustomImage instead of the original Image. EDITED: Return src attribute instead of style |
@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. 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 |
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! |
@wwayne Replace |
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>
)
} |
Instead of using 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 😂 |
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.
The text was updated successfully, but these errors were encountered: