I have a floating menu component which I made using floating-ui
library. I animate the transform
property to nicely adjust the position of menu. However, this is a problem while scrolling, wherein I do not want the animation to take effect.
Is it possible to add a class to a floating element only when scrolling to not animate this property in such a case (in React)?
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
useClick,
FloatingFocusManager,
useTransitionStyles,
FloatingPortal,
FloatingArrow,
useTransitionStatus,
hide,
inline,
useDismiss,
useInteractions,
} from "@floating-ui/react";
import { arrow } from "@floating-ui/react";
import { Editor, isNodeSelection, posToDOMRect } from "@tiptap/core";
import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
import { AnimatePresence, easeIn, motion } from "framer-motion";
import * as Portal from "@radix-ui/react-portal";
const ARROW_WIDTH = 16;
const ARROW_HEIGHT = 8;
const GAP = 8;
type Props = {
editor: Editor;
open: boolean;
setOpen?: any;
children: ReactNode;
};
// Adapted from https://github.com/ueberdosis/tiptap/issues/2305#issuecomment-1020665146
export const BubbleMenu = ({ editor, children }: Props) => {
const [open, setOpen] = useState(false);
const [scrolling, setScrolling] = useState(true);
useEffect(() => {
const handleScroll = () => {
setScrolling((prev) => true);
console.log("Calling handle scroll", scrolling);
};
const handleScrollEnd = () => {
setScrolling((prev) => false);
console.log("Calling handle scroll end", scrolling);
};
window.addEventListener("scroll", handleScroll);
window.addEventListener("scrollend", handleScrollEnd);
return () => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("scrollend", handleScrollEnd);
};
});
// This function determines whether menu should be shown or not
const shouldShow = (floating) => {
// selection not empty?
let notEmpty = !editor.state.selection.empty;
if (notEmpty && !editor.isFocused) {
// We want to hide menu when editor loses focus. The only exception is when
// Floating element has this focus
const menuFocused = floating?.contains(document.activeElement);
if (menuFocused) return true;
return false;
}
return notEmpty;
};
const arrowRef = useRef(null);
const {
floatingStyles,
refs: { reference, setReference, setFloating },
elements: { floating },
middlewareData,
context,
placement,
} = useFloating({
open,
onOpenChange: (open, event, reason) => {
setOpen(open);
},
strategy: "fixed",
whileElementsMounted: autoUpdate,
placement: "bottom",
middleware: [
offset(ARROW_HEIGHT + GAP),
flip({
padding: 8,
boundary: editor.view.dom,
}),
shift({
padding: 5,
}),
arrow({
element: arrowRef,
padding: 8,
}),
hide({
strategy: "referenceHidden",
padding: 0,
}),
],
});
const { isMounted, status } = useTransitionStatus(context, {
duration: 250,
});
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
const updateReference = () => {
if (!shouldShow(floating)) return;
setReference({
contextElement: editor.view.dom.firstChild as HTMLElement,
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);
},
});
};
let finalFloatingStyles = {
...floatingStyles,
};
useEffect(() => {
const onChange = () => setOpen(shouldShow(floating));
editor.on("selectionUpdate", onChange);
editor.on("blur", onChange);
editor.on("selectionUpdate", updateReference);
return () => {
editor.off("selectionUpdate", onChange);
editor.off("blur", onChange);
editor.off("selectionUpdate", updateReference);
};
});
console.log(status);
return (
<>
{isMounted && (
<FloatingPortal>
<div
id="floating-wrapper"
data-scrolling={scrolling}
ref={setFloating}
style={finalFloatingStyles}
data-status={status}
{...getFloatingProps()}
>
<div id="floating" data-status={status} data-placement={placement}>
{children}
<FloatingArrow
ref={arrowRef}
context={context}
width={ARROW_WIDTH}
height={ARROW_HEIGHT}
></FloatingArrow>
</div>
</div>
</FloatingPortal>
)}
</>
);
};
#floating {
transition-property: opacity, transform;
}
#floating[data-status="open"],
#floating[data-status="close"] {
transition-property: opacity, transform;
transition-duration: 250ms;
}
#floating[data-status="initial"],
#floating[data-status="close"] {
opacity: 0;
}
#floating[data-status="initial"][data-placement^="top"],
#floating[data-status="close"][data-placement^="top"] {
transform: translateY(-5px);
}
#floating[data-status="initial"][data-placement^="bottom"],
#floating[data-status="close"][data-placement^="bottom"] {
transform: translateY(5px);
}
#floating[data-status="initial"][data-placement^="left"],
#floating[data-status="close"][data-placement^="left"] {
transform: translateX(5px);
}
#floating[data-status="initial"][data-placement^="right"],
#floating[data-status="close"][data-placement^="right"] {
transform: translateX(-5px);
}
#floating-wrapper[data-status="open"][data-scrolling="false"] {
transition-property: transform;
transition-duration: 250ms;
}
#floating-wrapper[data-status="close"][data-scrolling="false"] {
transition-property: transform;
transition-duration: 250ms;
}
.Editor {
max-height: 300px;
overflow: auto;
}
Here is the working example (select some text and scroll down):
This is playing a little with internal APIs (that aren't typed correctly as a result, so there's some fudge there), but you could modify the autoUpdate
behaviour passed to whileElementsMounted
by calling it with a modified (wrapped) update()
function.
That function will detect scroll events via the undocumented first argument that is passed to it that indicates the event that triggers the auto-update. If the position is being updated in response to a scroll it will set the attribute that disables the transition. Otherwise, it will remove the attribute.
This is experimental at best. You can remove the previous scroll detection. scrollend
does not do what you think it does, this is related to when the scroll reaches the end of a container. However, keep the CSS targeting data-scrolling
.
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
useClick,
FloatingFocusManager,
useTransitionStyles,
FloatingPortal,
FloatingArrow,
useTransitionStatus,
hide,
inline,
useDismiss,
useInteractions,
} from "@floating-ui/react";
import { arrow } from "@floating-ui/react";
import { Editor, isNodeSelection, posToDOMRect } from "@tiptap/core";
import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
import { AnimatePresence, easeIn, motion } from "framer-motion";
import * as Portal from "@radix-ui/react-portal";
import { useCallback } from "react";
const ARROW_WIDTH = 16;
const ARROW_HEIGHT = 8;
const GAP = 8;
type Props = {
editor: Editor;
open: boolean;
setOpen?: any;
children: ReactNode;
};
// Adapted from https://github.com/ueberdosis/tiptap/issues/2305#issuecomment-1020665146
export const BubbleMenu = ({ editor, children }: Props) => {
const [open, setOpen] = useState(false);
// This function determines whether menu should be shown or not
const shouldShow = (floating) => {
// selection not empty?
let notEmpty = !editor.state.selection.empty;
if (notEmpty && !editor.isFocused) {
// We want to hide menu when editor loses focus. The only exception is when
// Floating element has this focus
const menuFocused = floating?.contains(document.activeElement);
if (menuFocused) return true;
return false;
}
return notEmpty;
};
const arrowRef = useRef(null);
const {
floatingStyles,
refs: { floating: floatingRef, setReference, setFloating },
elements: { floating },
context,
placement,
} = useFloating({
open,
onOpenChange: (open, event, reason) => {
setOpen(open);
},
strategy: "fixed",
whileElementsMounted: useCallback((ref, floating, update) => {
return autoUpdate(ref, floating, ((e: Event | undefined | string) => {
if ((e as Event)?.type === "scroll") {
floatingRef.current?.setAttribute("data-scrolling", "true");
update(e);
return;
}
floatingRef.current?.setAttribute("data-scrolling", "false");
update(e);
}) as () => void);
}, []),
placement: "bottom",
middleware: [
offset(ARROW_HEIGHT + GAP),
flip({
padding: 8,
boundary: editor.view.dom,
}),
shift({
padding: 5,
}),
arrow({
element: arrowRef,
padding: 8,
}),
hide({
strategy: "referenceHidden",
padding: 0,
}),
],
});
const { isMounted, status } = useTransitionStatus(context, {
duration: 250,
});
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
const updateReference = () => {
if (!shouldShow(floating)) return;
setReference({
contextElement: editor.view.dom.firstChild as HTMLElement,
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);
},
});
};
let finalFloatingStyles = {
...floatingStyles,
};
useEffect(() => {
const onChange = () => setOpen(shouldShow(floating));
editor.on("selectionUpdate", onChange);
editor.on("blur", onChange);
editor.on("selectionUpdate", updateReference);
return () => {
editor.off("selectionUpdate", onChange);
editor.off("blur", onChange);
editor.off("selectionUpdate", updateReference);
};
});
console.log(status);
return (
<>
{isMounted && (
<FloatingPortal>
<div
id="floating-wrapper"
data-scrolling={false}
ref={setFloating}
style={finalFloatingStyles}
data-status={status}
{...getFloatingProps()}
>
<div id="floating" data-status={status} data-placement={placement}>
{children}
<FloatingArrow
ref={arrowRef}
context={context}
width={ARROW_WIDTH}
height={ARROW_HEIGHT}
></FloatingArrow>
</div>
</div>
</FloatingPortal>
)}
</>
);
};