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

Show & Hide BubbleMenu programmatically #2305

Closed
1 task
thefinnomenon opened this issue Dec 22, 2021 · 28 comments
Closed
1 task

Show & Hide BubbleMenu programmatically #2305

thefinnomenon opened this issue Dec 22, 2021 · 28 comments
Labels
Type: Feature The issue or pullrequest is a new feature

Comments

@thefinnomenon
Copy link

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?

  • Yes, I’m a sponsor. 💖
@thefinnomenon thefinnomenon added the Type: Feature The issue or pullrequest is a new feature label Dec 22, 2021
@marclave
Copy link

marclave commented Dec 22, 2021

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
Copy link
Author

@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
Copy link
Author

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
Copy link
Contributor

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 #2171. Programmatic show/hide of the BubbleMenu would seemingly resolve that issue and allow for these sorts of commonly-desired interfaces.

@thefinnomenon
Copy link
Author

@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
Copy link
Contributor

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
Copy link
Author

@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
Copy link
Contributor

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 appendTo to document.body makes BubbleMenu noninteractive #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
Copy link

stale bot commented Jul 6, 2022

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.

@stale stale bot added the stale label Jul 6, 2022
@stale stale bot closed this as completed Jul 14, 2022
@ehynds
Copy link
Contributor

ehynds commented Aug 1, 2022

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
Copy link

bennett1412 commented Oct 31, 2022

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
Copy link

yadprab commented Dec 11, 2022

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
Copy link
Author

@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
Copy link

yadprab commented Dec 12, 2022

@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
Copy link
Author

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
Copy link
Contributor

Nantris commented May 5, 2023

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
Copy link

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 sjdemartini mentioned this issue Jul 2, 2023
6 tasks
@sjdemartini
Copy link
Contributor

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 #2305 (comment).

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
Copy link

siminino commented Sep 27, 2023

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
Copy link

piszczu4 commented Jan 16, 2024

@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
Copy link
Contributor

ehynds commented Jan 16, 2024

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

@piszczu4
Copy link

piszczu4 commented Jan 16, 2024

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

  • If you double click on "Sandbox", it will be selected and Bubble Menu appear. Unfortunately, for some reason only opacity is nicely animated, but translateY not. Here I want to obtain the same effect as with Tiptap's Bubble Menu and shift-toward-subtle animation.
  • when you expand the selection, bubble menu nicely change its position. However, when you click outside the selection, the bubble menu change its position to the place where you clicked and erase. The expectation would be to: not change the position (as open is false), translates 5x as per css file and erase because of the 0 opacity.
  • bubble menu content is not interactive - when you click on it, bubble menu is closed.

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
Copy link

piszczu4 commented Jan 19, 2024

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
Copy link

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
Copy link

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

@ngocphuongnb
Copy link

ngocphuongnb commented Mar 26, 2024

@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
Copy link

DhenPadilla commented Apr 17, 2024

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
Copy link

chrisp018 commented Apr 25, 2024

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

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