mui-tiptap
Version:
A Material-UI (MUI) styled WYSIWYG rich text editor, using Tiptap
169 lines (168 loc) • 9.79 kB
JavaScript
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;