Search code examples
javascriptreactjsfloating-ui

How to disable `transform` animation while scrolling with Floating UI?


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

https://codesandbox.io/p/sandbox/controlled-bubble-menu-framer-latest-on-off-forked-3kfydk?file=%2Fsrc%2Fbubble-menu.tsx%3A44%2C51


Solution

  • 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.

    Here it is in codeasandbox.

    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>
          )}
        </>
      );
    };