Skip to content
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

Closed
MatheusRich opened this issue May 30, 2019 · 28 comments
Closed

Resize images on editor #333

MatheusRich opened this issue May 30, 2019 · 28 comments
Labels
Type: Feature The issue or pullrequest is a new feature

Comments

@MatheusRich
Copy link
Contributor

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.

@MatheusRich MatheusRich added the Type: Feature The issue or pullrequest is a new feature label May 30, 2019
@dev7ch
Copy link

dev7ch commented Jun 3, 2019

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)

@korkies22
Copy link

I'm also interested in this feature

@nijat13
Copy link

nijat13 commented Jul 15, 2019

Hi guys,
Any updates?

@owen-gxz
Copy link

@scrumpy In the plan?I'm also interested in this feature

@philippkuehn
Copy link
Contributor

for now I don't have plans to implement this in the core package

@littlecxm
Copy link

any updates?

@iamdhrooov
Copy link

:( need this

@mikelwellsmore
Copy link

Definitely need this :O

@RyonMB
Copy link

RyonMB commented Nov 22, 2021

I really need this too

@theodorenguyen45
Copy link

theodorenguyen45 commented Mar 2, 2022

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

@naffarn
Copy link

naffarn commented Mar 24, 2022

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!

@theodorenguyen45
Copy link

@naffarn Glad it helps, I just updated the CustomImage component code a little bit. For src attribute, it should render src instead of style.

@naffarn
Copy link

naffarn commented Mar 24, 2022

@theodorenguyen45 Yep, I already tweaked that, it was just the resize function that I was having issues with - it works great now!

@ZarkhanNaro
Copy link

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

@theonmt
Copy link

theonmt commented May 10, 2022

@ZarkhanNaro you will need to add and config sass-loader into your project. Or you can just convert those scss into raw css.

@ZarkhanNaro
Copy link

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

@ericmatte
Copy link

ericmatte commented Oct 13, 2023

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 = {
  /** ... */
};

@kkaehler
Copy link

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

@kkaehler
Copy link

kkaehler commented Oct 25, 2023

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

@wwayne
Copy link

wwayne commented Dec 20, 2023

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

@alexbennycodes
Copy link

@kkaehler can we disable this if the editor is not editable right now its allowing to resize even when editable is false

@ghost
Copy link

ghost commented Jan 2, 2024

我目前对此的解决方法:

我创建了 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?

@ghost
Copy link

ghost commented Feb 5, 2024

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!

@jaycho46
Copy link

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

@ajayvignesh01
Copy link

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
}

@ajayvignesh01
Copy link

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

@LeoDoldan7
Copy link

LeoDoldan7 commented Aug 20, 2024

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

@adeyemialameen04
Copy link

adeyemialameen04 commented Oct 13, 2024

This issue thread is crazyyyy 😂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Type: Feature The issue or pullrequest is a new feature
Projects
None yet
Development

No branches or pull requests