ueberdosis / tiptap

The headless rich text editor framework for web artisans.

Home Page:https://tiptap.dev

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Resize images on editor

MatheusRich opened this issue · 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

commented

Hi guys,
Any updates?

@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

commented

any updates?

:( need this

commented

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:

image

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 ?

commented

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

image

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:

example

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

example

// 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:
20231220220621

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

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

commented

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
}
commented

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>
  )
}