UNPKG

mui-tiptap

Version:

A Material-UI (MUI) styled WYSIWYG rich text editor, using Tiptap

169 lines (168 loc) 9.79 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { styled, useThemeProps } from "@mui/material/styles"; import { NodeViewWrapper } from "@tiptap/react"; import { clsx } from "clsx"; import throttle from "es-toolkit/compat/throttle"; import { useMemo, useRef, useState } from "react"; import { getUtilityComponentName } from "../styles"; import { resizableImageComponentClasses, } from "./ResizableImageComponent.classes"; import ResizableImageResizer from "./ResizableImageResizer"; const IMAGE_MINIMUM_WIDTH_PIXELS = 15; const componentName = getUtilityComponentName("ResizableImageComponent"); const ResizableImageComponentImageContainer = styled("div", { name: componentName, slot: "imageContainer", overridesResolver: (props, styles) => styles.imageContainer, })(() => ({ // Use inline-flex so that the container is only as big as the inner img display: "inline-flex", // Use relative position so that the resizer is positioned relative to // the img dimensions (via their common container) position: "relative", })); const ResizableImageComponentImage = styled("img", { name: componentName, slot: "image", overridesResolver: (props, styles) => [ styles.image, props.ownerState.selectedOrResizing && styles.imageSelected, ], })(({ theme, ownerState }) => ({ // We need display:block in order for the container element to be // sized properly (no extra space below the image) display: "block", ...(ownerState.selectedOrResizing && { // This "selected" state outline style is copied from our standard editor // styles (which are kept there as well so they appear even if not using our // custom resizable image). outline: `3px solid ${theme.palette.primary.main}`, }), })); const ResizableImageComponentResizer = styled(ResizableImageResizer, { name: componentName, slot: "resizer", overridesResolver: (props, styles) => styles.resizer, })(() => ({ // As described here https://github.com/ueberdosis/tiptap/issues/3775, // updates to editor isEditable do not trigger re-rendering of node views. // Even editor state changes external to a given ReactNodeView component // will not trigger re-render (which is probably a good thing most of the // time, in terms of performance). As such, we always render the resizer // component with React (and so in the DOM), but hide it with CSS when the // editor is not editable. This also means its mouse event listeners will // also not fire, as intended. '.ProseMirror[contenteditable="false"] &': { display: "none", }, })); function ResizableImageComponent(inProps) { var _a, _b, _c; const props = useThemeProps({ props: inProps, name: componentName }); const { node, selected, updateAttributes, extension, classes = {}, sx, } = props; const { attrs } = node; const imageRef = useRef(null); // We store the mouse-down state of the ResizableImageResizer here to properly // control the resizer visibility when `inline` option is enabled. Tiptap // seems to change the selected state of the node to `false` as soon as the // user drags on the resize handle if the extension has `inline: true`, so we // need to consider the resizing state here in order to ensure it's still // shown during a resize. See // https://github.com/sjdemartini/mui-tiptap/issues/211 const [resizerMouseDown, setResizerMouseDown] = useState(false); const selectedOrResizing = selected || resizerMouseDown; const ownerState = { selected, selectedOrResizing, }; const handleResize = useMemo(() => // Throttle our "on resize" handler, since the event fires very rapidly during // dragging, so rendering would end up stuttering a bit without a throttle throttle( // eslint-disable-next-line react-hooks/refs -- ref is used in a memoized throttle callback (event handler), not during render (event) => { if (!imageRef.current) { return; } const originalBoundingRect = imageRef.current.getBoundingClientRect(); // Get the "width" and "height" of the resized image based on the user's // cursor position after movement, if we were to imagine a box drawn from // the top left corner of the image to their cursor. (clientX/Y and // getBoundingClientRect both reference positions relative to the viewport, // allowing us to use them to calculate the new "resized" image dimensions.) const resizedWidth = event.clientX - originalBoundingRect.x; const resizedHeight = event.clientY - originalBoundingRect.y; // We always preserve the original image aspect ratio, setting only the // `width` to a specific number upon resize (and leaving the `height` of the // `img` as "auto"). So to determine the new width, we'll take the larger of // (a) the new resized width after the user's latest drag resize movement, // (b) the width proportional to the new resized height given the image // aspect ratio, or (c) a minimum width to prevent mistakes. This is similar // to what Google Docs image resizing appears to be doing, which feels // intuitive. const resultantWidth = Math.max(resizedWidth, (originalBoundingRect.width / originalBoundingRect.height) * resizedHeight, // Set a minimum width, since any smaller is probably a mistake, and we // don't want images to get mistakenly shrunken below a size which makes // it hard to later select/resize the image IMAGE_MINIMUM_WIDTH_PIXELS); updateAttributes({ width: Math.round(resultantWidth), }); }, 50, { trailing: true }), [updateAttributes]); const ChildComponent = extension.options.ChildComponent; return (_jsx(NodeViewWrapper, { style: { // Handle @tiptap/extension-text-align. Ideally we'd be able to inherit // this style from TextAlign's GlobalAttributes directly, but those are // only applied via `renderHTML` and not the `NodeView` renderer // (https://github.com/ueberdosis/tiptap/blob/6c34dec33ac39c9f037a0a72e4525f3fc6d422bf/packages/extension-text-align/src/text-align.ts#L43-L49), // so we have to do this manually/redundantly here. textAlign: attrs.textAlign, width: "100%", }, // Change the outer component's component to a "span" if the `inline` // extension option is enabled, to ensure it can appear alongside other // inline elements like text. as: extension.options.inline ? "span" : "div", sx: sx, children: _jsxs(ResizableImageComponentImageContainer, { className: clsx([ resizableImageComponentClasses.imageContainer, classes.imageContainer, ]), children: [_jsx(ResizableImageComponentImage, { ref: imageRef, src: attrs.src, height: "auto", width: attrs.width ? attrs.width : undefined, alt: (_a = attrs.alt) !== null && _a !== void 0 ? _a : undefined, title: (_b = attrs.title) !== null && _b !== void 0 ? _b : undefined, className: clsx([ resizableImageComponentClasses.image, classes.image, // For consistency with the standard Image extension selection // class/UI: selectedOrResizing && "ProseMirror-selectednode", // We'll only show the outline when the editor content is selected selectedOrResizing && [ resizableImageComponentClasses.imageSelected, classes.imageSelected, ], ]), style: { // If no width has been specified, we use auto max-width maxWidth: attrs.width ? undefined : "auto", // Always specify the aspect-ratio if it's been defined, to improve // initial render (so auto-height works before the image loads) aspectRatio: (_c = attrs.aspectRatio) !== null && _c !== void 0 ? _c : undefined, }, ownerState: ownerState, "data-drag-handle": true, // When the image loads, we'll update our width and aspect-ratio based // on the image's natural size, if they're not set. That way, all future // renders will know the image width/height prior to load/render, // preventing flashing onLoad: (event) => { const newAttributes = {}; if (!attrs.width) { newAttributes.width = event.currentTarget.naturalWidth; } if (!attrs.aspectRatio) { newAttributes.aspectRatio = String(event.currentTarget.naturalWidth / event.currentTarget.naturalHeight); } if (newAttributes.width || newAttributes.aspectRatio) { updateAttributes(newAttributes); } } }), selectedOrResizing && (_jsx(ResizableImageComponentResizer, { onResize: handleResize, className: clsx([ resizableImageComponentClasses.resizer, classes.resizer, ]), mouseDown: resizerMouseDown, setMouseDown: setResizerMouseDown })), ChildComponent && _jsx(ChildComponent, { ...props })] }) })); } export default ResizableImageComponent;