Closed thefinnomenon closed 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
@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 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.
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.
@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.
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 Popper
here), I've pasted my example below in case it helps.
The implementation below has two main advantages:
open
prop. Any updates to the prop will re-render as you'd intend. (Resolves this issue.)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)
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 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>
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
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 ashow
option on the BubbleMenu or exposed editor commands would allow for better menu management.
can I get an example code?
@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 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
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 reference is not a function
. I'm not sure if it's due to a floating-ui update or a TipTap update.
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 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.
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.
@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
@piszczu4 You'll probably want to add editor.view
to your dependency array but otherwise it LGTM!
@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:
translateY
not. Here I want to obtain the same effect as with Tiptap's Bubble Menu and shift-toward-subtle
animation.open
is false), translates 5x
as per css file and erase because of the 0
opacity.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 :)
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?
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
@bennett1412 yeah I manage to fix that by adding contextElement
to setReference
method. The only remaining issue is disabling transform animation on scroll
@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. 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>
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
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?