-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
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
Comments
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 |
@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. |
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 |
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 |
@sjdemartini I think you can accomplish what you are trying by checking if the current selection has a type of link in the |
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. |
@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. |
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 The implementation below has two main advantages:
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 <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 |
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. |
Here's a 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> |
Is there a way to anchor the menu to the selection, so that it follows the selected part on scroll? |
can I get an example code? |
@yadprab |
Thank you so much |
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. |
Using @ehynds's implementation in TipTap2 it errors with |
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 |
I'm sorry @hugh-sun-everlaw, I've only worked with Tiptap in a React context. It looks like Side note: I've released a package |
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 |
@ehynds I tried to rewrite it to the latest version of
It works and changes its position when the selection expands. Is that written correct? Note that if I replace |
@piszczu4 You'll probably want to add |
@ehynds I made a working example of my current implemention of
I tried to do some things with |
@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 |
@bennett1412 yeah I manage to fix that by adding |
@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>;
}; |
If I may! We had a similar problem where we needed to programatically show/hide the bubble menu. 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.
|
I have managed to control opening Bubble via a button using useRef with useState, here is the example code I have used:
|
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?
The text was updated successfully, but these errors were encountered: