UNPKG

mui-tiptap

Version:

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

137 lines (136 loc) 7.42 kB
import { jsx as _jsx } from "react/jsx-runtime"; import Fade from "@mui/material/Fade"; import Paper from "@mui/material/Paper"; import Popper from "@mui/material/Popper"; import { styled, useTheme, useThemeProps, } from "@mui/material/styles"; import { isNodeSelection, posToDOMRect } from "@tiptap/core"; import { clsx } from "clsx"; import { useCallback } from "react"; import { controlledBubbleMenuClasses, } from "./ControlledBubbleMenu.classes"; import { getUtilityComponentName } from "./styles"; const componentName = getUtilityComponentName("ControlledBubbleMenu"); const ControlledBubbleMenuRoot = styled(Popper, { name: componentName, slot: "root", overridesResolver: (props, styles) => styles.root, })(({ theme }) => ({ // Ensure the bubble menu is above modals, in case the editor is rendered in // a modal, consistent with recommendations here // https://github.com/mui/material-ui/issues/14216. See // https://github.com/sjdemartini/mui-tiptap/issues/265. zIndex: theme.zIndex.tooltip, })); const ControlledBubbleMenuPaper = styled(Paper, { name: componentName, slot: "paper", overridesResolver: (props, styles) => styles.paper, })(({ theme }) => ({ backgroundColor: theme.palette.background.default, })); // The `BubbleMenu` React component provided by Tiptap in @tiptap/react and the // underlying BubbleMenuPlugin don't work very well in practice. There are two // primary problems: // 1) BubbleMenu places its tippy DOM element *within* the editor DOM structure, // so it can get clipped by the edges of the editor, especially noticeable // when there is no content in the editor yet (so it'll get sliced off at the // top of the editor). It's not possible to use a React Portal there as a // workaround due to the way in which the element is dynamically // created/destroyed via tippy inside Tiptap, preventing interactivity (see // https://github.com/ueberdosis/tiptap/issues/2292). // 2) The BubbleMenu visibility cannot be controlled programmatically. Its // `shouldShow` callback only runs when editor internal state changes, so we // can't control it beyond that without wacky hacks. See the issue here // https://github.com/ueberdosis/tiptap/issues/2305. // // This alternative component has a simpler API, with just an `open` flag, which // properly responds to all changes in React props, and it uses MUI's Popper // rather than relying on tippy, so we inherently get "Portal" behavior and // don't have to worry about visual clipping. export default function ControlledBubbleMenu(inProps) { const props = useThemeProps({ props: inProps, name: componentName }); const { editor, open, className, classes = {}, sx, children, anchorEl, container, disablePortal, placement = "top", fallbackPlacements = [ "top", "bottom", "top-start", "bottom-start", "top-end", "bottom-end", ], flipPadding = 8, PaperProps, ...popperProps } = props; const theme = useTheme(); const defaultAnchorEl = useCallback(() => { // The logic here is taken from the positioning implementation in Tiptap's BubbleMenuPlugin // https://github.com/ueberdosis/tiptap/blob/16bec4e9d0c99feded855b261edb6e0d3f0bad21/packages/extension-bubble-menu/src/bubble-menu-plugin.ts#L183-L193 const { ranges } = editor.state.selection; const from = Math.min(...ranges.map((range) => range.$from.pos)); const to = Math.max(...ranges.map((range) => range.$to.pos)); return { getBoundingClientRect: () => { if (isNodeSelection(editor.state.selection)) { const node = editor.view.nodeDOM(from); if (node instanceof HTMLElement) { return node.getBoundingClientRect(); } } return posToDOMRect(editor.view, from, to); }, }; }, [editor]); return (_jsx(ControlledBubbleMenuRoot, { open: open, placement: placement, modifiers: [ { name: "offset", options: { // Add a slight vertical offset for the popper from the current selection offset: [0, 6], }, }, { name: "flip", enabled: true, options: { // We'll reposition (to one of the below fallback placements) whenever our Popper goes // outside of the editor. (This is necessary since our children aren't actually rendered // here, but instead with a portal, so the editor DOM node isn't a parent.) boundary: editor.options.element, fallbackPlacements: fallbackPlacements, padding: flipPadding, }, }, { // Don't allow the bubble menu to overflow outside of the its clipping parents // or viewport name: "preventOverflow", enabled: true, options: { // Check for overflow in the y-axis direction instead of x-axis direction // (the default for top and bottom placements), since that's likely to be // the more problematic direction when scrolling. (Theoretically it would be // nice to have it check all axes which seemingly could be done with // `mainAxis: false`, but for an element that is wide and tall, this ends up // not placing the Popper in a visible location, so the behavior of // `altAxis: true` seems preferable.) altAxis: true, boundary: "clippingParents", padding: 8, }, }, // If we want to add an arrow to the Popper, we'll seemingly need to implement a lot // of custom styling and whatnot, like in // https://github.com/mui-org/material-ui/blob/84671ab1d6db4f6901d60206f2375bd51862c66e/docs/src/pages/components/popper/ScrollPlayground.js#L19-L103, // which is probably not worth it ], anchorEl: anchorEl !== null && anchorEl !== void 0 ? anchorEl : defaultAnchorEl, className: clsx([ controlledBubbleMenuClasses.root, className, classes.root, ]), sx: sx, container: container, disablePortal: disablePortal, transition: true, ...popperProps, children: ({ TransitionProps }) => (_jsx(Fade, { ...TransitionProps, timeout: { enter: theme.transitions.duration.enteringScreen, // Exit immediately rather than using a transition, since the // content of the bubble menu will usually be updating as the editor // content and thus `open` state changes, and we don't want it to // "flash" with incorrect content during the transition exit: 0, }, children: _jsx(ControlledBubbleMenuPaper, { elevation: 7, ...PaperProps, className: clsx([ controlledBubbleMenuClasses.paper, classes.paper, PaperProps === null || PaperProps === void 0 ? void 0 : PaperProps.className, ]), children: children }) })) })); }