ueberdosis / tiptap

The headless rich text editor framework for web artisans.
https://tiptap.dev
MIT License
27.72k stars 2.3k forks source link

Show & Hide BubbleMenu programmatically #2305

Closed thefinnomenon closed 2 years ago

thefinnomenon commented 2 years ago

What problem are you facing?

I am trying to make a prompt for link input and want to reuse the BubbleMenu plugin. I am using BubbleMenu to open a toolbar on selection, which contains a button for creating a link. I want to open a second BubbleMenu positioned at the selection when the user clicks the link toolbar button but I haven't been able to come up with a way to do this. I know BubbleMenu has the shouldShow option but I can't figure out a way to use this to be toggled by a button press.

What’s the solution you would like to see?

It would be nice to expose the tooltip's show & hide methods as editor commands so I could do something like this, editor.commands.showBubbleMenu(id).

What alternatives did you consider?

I am trying to get it to work with shouldShow but haven't gotten anywhere. I am considering taking BubbleMenu and making a similar extension but with the ability to programmatically trigger the tooltip but I wanted to open an issue first.

Anything to add? (optional)

No response

Are you sponsoring us?

marclave commented 2 years ago

Hey 👋 ! Are you using vuejs? If so i got what you need here:

https://codesandbox.io/s/priceless-mayer-gyb7s?file=/src/App.vue

You can totally make it more generic and dynamic than how I did it here, but you get the idea :) Let me know if this works

thefinnomenon commented 2 years ago

@marclave Thanks for the example but I am struggling to recreate it in React. For some reason, the shouldShow function is not rerunning when the force state is changed. I feel like it's getting into some React nuances and I'll have to dig deeper.

thefinnomenon commented 2 years ago

Phew, finally figured it out 😅 I wasn't able to do it with React state because the callback always referenced the initial force value. I ended up switching to using useRef instead to store the menu show property and it looks like it should work. It is still far from ideal though -- being able to pass in a value to a show option on the BubbleMenu or exposed editor commands would allow for better menu management.

sjdemartini commented 2 years ago

I am in a similar situation and would also benefit from the programmatic show/hide support. I'm also looking to add Link-editing, ideally with a UI similar to Slack's link-adding/editing/removing popover (or CKEditor5's, TinyMCE's, etc). Ideally it would be possible to only show the BubbleMenu upon clicking a link, as that is common for these text-editing interfaces, but I have yet to figure out a way to accomplish this, for the reasons mentioned in this issue's original post. I have tried adding a separate click handler and useRef, but this doesn't work, since shouldShow doesn't re-run after a generic click-handler, as described in this related issue https://github.com/ueberdosis/tiptap/issues/2171. Programmatic show/hide of the BubbleMenu would seemingly resolve that issue and allow for these sorts of commonly-desired interfaces.

thefinnomenon commented 2 years ago

@sjdemartini I think you can accomplish what you are trying by checking if the current selection has a type of link in the shouldShow method. Then whenever the cursor is inside of a link, the link toolbar would show.

sjdemartini commented 2 years ago

Thanks for suggesting @thefinnomenon. That works if menu visibility is based entirely on cursor position, but I actually want the BubbleMenu to show only when the link is clicked (not just if the cursor is moved), and then I also want to allow the user to close the BubbleMenu as well (just like with Slack's link edit UI, or with CKEditor5's Link feature as you can see here). So the programmatic show/hide would make both of those possible.

thefinnomenon commented 2 years ago

@sjdemartini ahh yeah without programmatic show/hide it won't be possible. My solution is working well enough for me now so I can't devote time to this but it would be nice if this was added to TipTap at some point.

sjdemartini commented 2 years ago

I ended up implementing my own simple "Bubble Menu" to get it to follow React paradigms more closely (so that changes to "should show" are always rendered/reflected). If anyone is seeing this and happens to be using MaterialUI (or would want to use https://github.com/atomiks/tippyjs-react/, since that could easily be swapped for the Popper here), I've pasted my example below in case it helps.

The implementation below has two main advantages:

  1. The BubbleMenu visibility can be controlled programmatically using the open prop. Any updates to the prop will re-render as you'd intend. (Resolves this issue.)
  2. The popper is rendered via React Portal under the hood under the document body, so it can't get visually clipped by the editor boundaries. The Tiptap BubbleMenuPlugin places its tippy DOM element within the editor DOM structure, so it will get clipped/hidden 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 or appendTo: () => document.body there as a workaround due to the way in which the element is dynamically created/destroyed via tippy inside Tiptap, thereby preventing interactivity (see https://github.com/ueberdosis/tiptap/issues/2292).

Minimal version of the component code:

import { Popper } from "@mui/material";
import { Editor, isNodeSelection, posToDOMRect } from "@tiptap/core";

type Props = {
  editor: Editor;
  open: boolean;
  children: React.ReactNode;
};

const ControlledBubbleMenu: React.FC<Props> = ({
  editor,
  open,
  children,
}: Props) => (
  <Popper
    open={open}
    placement="top"
    modifiers={[
      {
        name: "offset",
        options: {
          // Add a slight vertical offset for the popper from the current selection
          offset: [0, 4],
        },
      },
      {
        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: [
            "bottom",
            "top-start",
            "bottom-start",
            "top-end",
            "bottom-end",
          ],
          padding: 8,
        },
      },
    ]}
    anchorEl={() => {
      // 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) as HTMLElement;

            if (node) {
              return node.getBoundingClientRect();
            }
          }

          return posToDOMRect(editor.view, from, to);
        },
      };
    }}
  >
    {children}
  </Popper>
);

export default ControlledBubbleMenu;

which can be used nearly identically to the BubbleMenu from @tiptap/react, like:

<div>
  {editor && (
    <ControlledBubbleMenu editor={editor} open={shouldShow}>
      <button
        onClick={() => editor.chain().focus().toggleBold().run()}
        className={editor.isActive('bold') ? 'is-active' : ''}
      >
        bold
      </button>
    </ControlledBubbleMenu>
  )}
</div>

where shouldShow is whatever you want it to be (e.g. based on some React state variable and/or editor state)

stale[bot] commented 2 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

ehynds commented 2 years ago

Here's a floating-ui implementation of @sjdemartini's ControlledBubbleMenu:

import { useFloating, autoUpdate, offset, flip } from '@floating-ui/react-dom';
import { Editor, isNodeSelection, posToDOMRect } from '@tiptap/core';
import { ReactNode, useLayoutEffect } from 'react';

type Props = {
  editor: Editor;
  open: boolean;
  children: ReactNode;
};

// Adapted from https://github.com/ueberdosis/tiptap/issues/2305#issuecomment-1020665146
export const ControlledBubbleMenu = ({ editor, open, children }: Props) => {
  const { floatingStyles, refs } = useFloating({
    strategy: 'fixed',
    whileElementsMounted: autoUpdate,
    placement: 'top',
    middleware: [
      offset({ mainAxis: 8 }),
      flip({
        padding: 8,
        boundary: editor.options.element,
        fallbackPlacements: [
          'bottom',
          'top-start',
          'bottom-start',
          'top-end',
          'bottom-end',
        ],
      }),
    ],
  });

  useLayoutEffect(() => {
    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));

    refs.setReference({
      getBoundingClientRect() {
        if (isNodeSelection(editor.state.selection)) {
          const node = editor.view.nodeDOM(from) as HTMLElement | null;

          if (node) {
            return node.getBoundingClientRect();
          }
        }

        return posToDOMRect(editor.view, from, to);
      },
    });
  }, [refs, editor.view, editor.state.selection]);

  if (!open) {
    return null;
  }

  return (
    <div ref={refs.setFloating} style={floatingStyles}>
      {children}
    </div>
  );
};

Usage:

<ControlledBubbleMenu open={!editor.view.state.selection.empty} editor={editor}>
  // your custom toolbar
</ControlledBubbleMenu>
bennett1412 commented 2 years ago

Is there a way to anchor the menu to the selection, so that it follows the selected part on scroll? Edit: Making the parent scrollable seems to interfere with floating-ui positioning, soln is to remove it

yadprab commented 1 year ago

Phew, finally figured it out 😅 I wasn't able to do it with React state because the callback always referenced the initial force value. I ended up switching to using useRef instead to store the menu show property and it looks like it should work. It is still far from ideal though -- being able to pass in a value to a show option on the BubbleMenu or exposed editor commands would allow for better menu management.

can I get an example code?

thefinnomenon commented 1 year ago

@yadprab I ended up open sourcing my project if you want to take a look, https://github.com/thefinnomenon/creatorkitchen/blob/main/components/TipTap.tsx

yadprab commented 1 year ago

@yadprab I ended up open sourcing my project if you want to take a look, https://github.com/thefinnomenon/creatorkitchen/blob/main/components/TipTap.tsx

Thank you so much

thefinnomenon commented 1 year ago

No problem. Let me know if you have any questions. I still don't think it's the best way to handle it because it requires too much coupling but it was working for me for the time being.

Nantris commented 1 year ago

Using @ehynds's implementation in TipTap2 it errors with reference is not a function. I'm not sure if it's due to a floating-ui update or a TipTap update.

hugh-sun-everlaw commented 1 year ago

Hello guys, thanks for the code examples you provided. However, all the code examples are in react, and I am in a non-React project. I wonder if you guys have any ideas for implementing the same thing, but in typescript? Thanks! @ehynds @sjdemartini

sjdemartini commented 1 year ago

I'm sorry @hugh-sun-everlaw, I've only worked with Tiptap in a React context. It looks like floating-ui has vanilla JS support (besides its React-specific and Vue-specific hooks) https://floating-ui.com/, so I imagine you could use much of the logic from the implementation in @ehynds's example above https://github.com/ueberdosis/tiptap/issues/2305#issuecomment-1201373762.

Side note: I've released a package mui-tiptap https://github.com/sjdemartini/mui-tiptap recently, which includes the ControlledBubbleMenu I showed above—it's since been improved slightly and made more customizable. There's also a lot of separate functionality in mui-tiptap, namely built-in support for Material UI styles, additional extensions (ResizableImage, HeadingWithAnchor, FontSize, TableImproved), and several components, including LinkBubbleMenu and TableBubbleMenu that utilize that ControlledBubbleMenu under the hood.

siminino commented 1 year ago

Hey guys, thanks for the suggestions. Currently, I am facing the same issue. Are there any plans to add this functionality to BubbleMenu Plugin & Component? I believe many users will struggle with this limitation.

Also, currently the BubbleMenuPlugin is not extensible, because the BubbleMenu react component has the TipTap Plugin hardcoded.. I wish we could at least extend BubbleMenuPlugin and set a custom one to be used by BubbleMenu component, without having to copy a lot of source code from TipTap libraries.

piszczu4 commented 10 months ago

@ehynds I tried to rewrite it to the latest version of floating-ui as below:

 const {
    x,
    y,
    strategy,
    refs: { reference, setReference, setFloating },
  } = useFloating({
    strategy: 'fixed',
    whileElementsMounted: autoUpdate,
    placement: 'bottom',
    middleware: [
      offset({ mainAxis: 8 }),
      flip({
        padding: 8,
        boundary: editor.options.element,
        fallbackPlacements: [
          'bottom',
          'top-start',
          'bottom-start',
          'top-end',
          'bottom-end',
        ],
      }),
    ],
  });

  useLayoutEffect(() => {
    setReference({
      getBoundingClientRect() {
        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));

        if (isNodeSelection(editor.state.selection)) {
          const node = editor.view.nodeDOM(from) as HTMLElement;

          if (node) {
            return node.getBoundingClientRect();
          }
        }

        return posToDOMRect(editor.view, from, to);
      },
    });
  }, [reference, editor.state.selection]);

It works and changes its position when the selection expands. Is that written correct? Note that if I replace editor.state.selection by editor in dependency array it stops to rerender. Now I just need to add an animation to show and hide BubbleMenu and to shift it smoothly when selection changes

ehynds commented 10 months ago

@piszczu4 You'll probably want to add editor.view to your dependency array but otherwise it LGTM!

piszczu4 commented 10 months ago

@piszczu4 You'll probably want to add editor.view to your dependency array but otherwise it LGTM!

@ehynds I made a working example of my current implemention of ControlledBubbleMenu (https://codesandbox.io/p/sandbox/controlled-bubble-menu-6prxj2?file=%2Fsrc%2Fstyles.scss%3A99%2C15) and would really appreciate your or anyone else feedback how solve the remaining issues which are:

I tried to do some things with framer-motion but ideally I'd like to use only floating-ui to adress all above issues. I would really aprpeciate help here :)

piszczu4 commented 10 months ago

Is there a way to anchor the menu to the selection, so that it follows the selected part on scroll? Edit: Making the parent scrollable seems to interfere with floating-ui positioning, soln is to remove it

@bennett1412 I encountered the same issue here (https://codesandbox.io/p/sandbox/controlled-bubble-menu-framer-latest-on-off-forked-3kfydk?file=%2Fsrc%2Fbubble-menu.tsx%3A107%2C19), i.e. when selecting text and then scrolling, the menu does not follow the selection. Do you know what is the solution here?

bennett1412 commented 10 months ago

Is there a way to anchor the menu to the selection, so that it follows the selected part on scroll? Edit: Making the parent scrollable seems to interfere with floating-ui positioning, soln is to remove it

@bennett1412 I encountered the same issue here (https://codesandbox.io/p/sandbox/controlled-bubble-menu-framer-latest-on-off-forked-3kfydk?file=%2Fsrc%2Fbubble-menu.tsx%3A107%2C19), i.e. when selecting text and then scrolling, the menu does not follow the selection. Do you know what is the solution here?

hey, i checked the sandbox, and it seems to be working for me

piszczu4 commented 10 months ago

@bennett1412 yeah I manage to fix that by adding contextElement to setReference method. The only remaining issue is disabling transform animation on scroll

ngocphuongnb commented 8 months ago

@sjdemartini @ehynds @piszczu4 Thanks for your bubble code snippet. I have updated the code to keep the bubble at the center of the selected mark.

import { ReactNode, useLayoutEffect } from 'react';
import { useFloating, autoUpdate, offset, flip } from '@floating-ui/react-dom';
import { Editor, isNodeSelection, posToDOMRect } from '@tiptap/core';
import { getMarkRange } from '@tiptap/react';
import { TextSelection } from '@tiptap/pm/state';

type Props = {
  editor: Editor;
  open: boolean;
  children: ReactNode;
};

// Extended from:
// https://github.com/ueberdosis/tiptap/issues/2305#issuecomment-1020665146
// https://github.com/ueberdosis/tiptap/issues/2305#issuecomment-1894184891
export const ControlledBubbleMenu = ({ editor, open, children }: Props) => {
  const { view } = editor;
  const { x, y, strategy: position, refs } = useFloating({
    strategy: 'fixed',
    whileElementsMounted: autoUpdate,
    placement: 'bottom',
    middleware: [
      offset({ mainAxis: 8 }),
      flip({
        padding: 8,
        boundary: editor.options.element,
        fallbackPlacements: [
          'bottom',
          'top-start',
          'bottom-start',
          'top-end',
          'bottom-end',
        ],
      }),
    ],
  });

  useLayoutEffect(() => {
    refs.setReference({
      getBoundingClientRect() {
        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));

        // If the selection is a node selection, return the node's bounding rect
        if (isNodeSelection(editor.state.selection)) {
          const node = editor.view.nodeDOM(from) as HTMLElement;

          if (node) {
            return node.getBoundingClientRect();
          }
        }

        // If the clicked position a mark, create a selection from the mark range
        // When the selection is not empy, the bubble menu will be shown
        const range = getMarkRange(view.state.doc.resolve(from), view.state.schema.marks.link);
        if (range) {
          const $start = view.state.doc.resolve(range.from);
          const $end = view.state.doc.resolve(range.to);
          const transaction = view.state.tr.setSelection(new TextSelection($start, $end));
          view.dispatch(transaction);
          return posToDOMRect(editor.view, range.from, range.to);
        }

        // Otherwise,
        return posToDOMRect(editor.view, from, to);
      },
    });
  }, [refs.reference, editor.state.selection, view]);

  if (!open) {
    return null;
  }
  const style = { position, top: y ?? 0, left: x ?? 0 };

  return <div ref={refs.setFloating} style={style}>
    {children}
  </div>;
};
DhenPadilla commented 7 months ago

If I may!

We had a similar problem where we needed to programatically show/hide the bubble menu. I think the approach I took was super simple and did the job for us but throwing it out there too as this issue helped me rubber-duck a little.

Our requirement was a little different. Essentially; we wanted to only show the bubble menu if and only if the mouse has finished their selection of text (i.e, on 'Mouse-up').

The approach I ended up taking was tracking this in a state var and then rendering the child of the bubble menu conditionally with some animation.

const { editor , ... } = useEditor()
const [isMouseDown, setIsMouseDown] = useState()
<BubbleMenu
  editor={editor}
  tippyOptions={}
  shouldShow={({ editor, from, to }) => {
      if (isMouseDown) return false
      const isEmptySelection = from === to
      return !isEmptySelection
  }}
>
  {!isMouseDown && (
      <motion.div
          className={'absolute'}
          animate={{
              opacity: 1,
              transition: {
                  duration: 0.1,
              },
          }}
          initial={{
              opacity: 0,
          }}
          exit={{
              opacity: 0,
          }}
      >
          <BubbleMenuBody />
      </motion.div>
  )}
</BubbleMenu>
chrisp018 commented 7 months ago

I have managed to control opening Bubble via a button using useRef with useState, here is the example code I have used:

interface GenerativeMenuCustomizeProps {
  open: boolean
  onOpenChange: (open: boolean) => void
}
const GenerativeMenuCustomize = ({
  open,
  onOpenChange,
}: GenerativeMenuCustomizeProps) => {
  const { editor } = useEditor()
  if (!editor) return <></>

  const openref = useRef<boolean>()
  openref.current = open
  return (
    <EditorBubble
      tippyOptions={{
        placement: open ? "bottom-start" : "bottom",
        onHidden: () => {
          onOpenChange(false)
          editor.chain().unsetHighlight().run()
        },
      }}
      shouldShow={() => {
        return openref.current || false
      }}
      className="flex flex-col items-start min-w-[250px] max-w-[90vw] overflow-hidden rounded-xl border shadow-xl bg-[#e6486a]"
    >
      ExampleCode
    </EditorBubble>
  )
}
export default GenerativeMenuCustomize