remirror/remirror

View on GitHub
packages/remirror__react-components/src/floating-menu.tsx

Summary

Maintainability
A
0 mins
Test Coverage
import {
  Alignment,
  autoPlacement,
  autoUpdate,
  flip,
  FloatingPortal,
  Middleware,
  offset,
  Placement as FloatingUIPlacement,
  Strategy,
  useFloating,
} from '@floating-ui/react';
import React, {
  FC,
  MouseEventHandler,
  PropsWithChildren,
  ReactNode,
  Ref,
  useCallback,
  useMemo,
} from 'react';
import { createPortal } from 'react-dom';
import { cx, isObject } from '@remirror/core';
import type { PositionerParam } from '@remirror/extension-positioner';
import { getPositioner } from '@remirror/extension-positioner';
import { useHelpers } from '@remirror/react-core';
import { useEditorFocus, UseEditorFocusProps, usePositioner } from '@remirror/react-hooks';
import { ComponentsTheme, ExtensionPositionerTheme } from '@remirror/theme';

import { composeRefs } from './commonjs-packages/seznam-compose-react-refs';

interface BaseFloatingPositioner extends UseEditorFocusProps {
  /**
   * The positioner used to determine the position of the relevant part of the
   * editor state.
   */
  positioner: PositionerParam;

  /**
   * When `true` will hide the popover element whenever the positioner is no
   * longer visible in the DOM.
   */
  hideWhenInvisible?: boolean;

  /**
   * Set animated as detailed [here](https://reakit.io/docs/popover/#animating).
   *
   * Currently this is turned off due to problems with an infinite loop.
   */
  animated?: boolean | number;

  /**
   * Set to false to make the positioner inactive.
   */
  enabled?: boolean;

  /**
   * Where to place the popover relative to the positioner.
   * @remarks
   * The floating-ui library has removed the auto- prefixed placement attribute types.
   * The type declaration you see here is for compatibility with Popper.js.
   *
   * https://floating-ui.com/docs/autoPlacement#conflict-with-flip
   */
  placement?: FloatingUIPlacement | 'auto' | `auto-${Alignment}`;

  /**
   * When `true` the child component is rendered outside the `ProseMirror`
   * editor. Set this to `false` when you need to render special components
   * (like inputs) which capture events and conflict with the default
   * prosemirror editor.
   *
   * For toolbars which rely on clicks you can leave this as the default.
   *
   * Setting to true will also make scrolling less smooth since it will be using
   * JavaScript to keep track of the position of the element.
   *
   * @defaultValue false
   */
  renderOutsideEditor?: boolean;

  /**
   * Array of middleware objects to modify the positioning or provide data for
   * rendering.
   */
  middleware?: Array<Middleware | null | undefined | false>;

  /**
   * The strategy to use when positioning the floating element.
   */
  strategy?: Strategy;

  /**
   * Portals the floating element into a given container element — by default,
   * outside of the app root and into the body.
   * @see https://floating-ui.com/docs/FloatingPortal
   * @defaultValue false
   * @remarks This is conflict to renderOutsideEditor, and renderOutsideEditor has high priority.
   * And this property will cause the loss of the css variable if you use remirror's internal style
   */
  useFloatingPortal?: boolean | Parameters<typeof FloatingPortal>[0];
}

interface FloatingWrapperProps extends BaseFloatingPositioner {
  /**
   * When true the arrow will be displayed.
   *
   * @defaultValue false
   */
  displayArrow?: boolean;

  animatedClass?: string;
  containerClass?: string;
  floatingLabel?: string;
}

interface UseMemoizedPositionProps {
  height: number;
  left: number;
  top: number;
  width: number;
}

function useMemoizedPosition(props: UseMemoizedPositionProps) {
  const { height, left, top, width } = props;
  return useMemo(() => ({ height, left, top, width }), [height, left, top, width]);
}

export const FloatingWrapper: FC<PropsWithChildren<FloatingWrapperProps>> = (
  props,
): JSX.Element => {
  const {
    containerClass,
    placement = 'right-end',
    positioner,
    children,
    blurOnInactive = false,
    ignoredElements = [],
    enabled = true,
    floatingLabel,
    hideWhenInvisible = true,
    renderOutsideEditor = false,
    middleware: propsMiddleware,
    strategy,
    useFloatingPortal,
  } = props;

  const [isFocused] = useEditorFocus({ blurOnInactive, ignoredElements });
  const {
    ref,
    active,
    height,
    x: left,
    y: top,
    width,
    visible,
  } = usePositioner(() => {
    const active = isFocused && enabled;
    const refinedPositioner = getPositioner(positioner);
    return refinedPositioner.active(active);
  }, [isFocused, enabled, renderOutsideEditor]);

  const shouldShow = (hideWhenInvisible ? visible : true) && active;
  const position = useMemoizedPosition({ height, left, top, width });

  const _placement = isFloatingUIPlacement(placement) ? placement : undefined;

  const middleware = useMemo(() => {
    if (propsMiddleware) {
      return propsMiddleware;
    }

    return [
      _placement ? flip({ padding: 8 }) : autoPlacement({ padding: 8 }),
      offset({ mainAxis: 12 }),
    ];
  }, [_placement, propsMiddleware]);

  const { refs, floatingStyles } = useFloating({
    placement: _placement,
    open: visible,
    whileElementsMounted: autoUpdate,
    strategy,
    middleware,
  });

  const handleMouseDown: MouseEventHandler<HTMLDivElement> = useCallback(
    (e) => {
      if (renderOutsideEditor || useFloatingPortal) {
        // Prevent blur events from being triggered
        e.preventDefault();
      }
    },
    [renderOutsideEditor, useFloatingPortal],
  );

  let floatingElement = (
    <div
      aria-label={floatingLabel}
      ref={refs.setFloating}
      style={floatingStyles}
      className={cx(ComponentsTheme.FLOATING_POPOVER, containerClass)}
      onMouseDown={handleMouseDown}
    >
      {shouldShow && children}
    </div>
  );

  if (!renderOutsideEditor && !useFloatingPortal) {
    floatingElement = <PositionerPortal>{floatingElement}</PositionerPortal>;
  } else if (useFloatingPortal) {
    const props = isObject(useFloatingPortal) ? useFloatingPortal : {};
    floatingElement = <FloatingPortal {...props}>{floatingElement}</FloatingPortal>;
  }

  return (
    <>
      <PositionerPortal>
        <span
          className={ExtensionPositionerTheme.POSITIONER}
          style={{
            top: position.top,
            left: position.left,
            width: position.width,
            height: position.height,
          }}
          ref={composeRefs(ref, refs.setReference) as Ref<any>}
        />
      </PositionerPortal>
      {floatingElement}
    </>
  );
};

export interface PositionerComponentProps {
  children: ReactNode;
}

/**
 * Render a component into the editors positioner widget using `createPortal`
 * from `react-dom`.
 */
export const PositionerPortal: FC<PositionerComponentProps> = (props) => {
  const container = useHelpers().getPositionerWidget();

  return createPortal(<>{props.children}</>, container);
};

function isFloatingUIPlacement(
  placement: BaseFloatingPositioner['placement'],
): placement is FloatingUIPlacement {
  // Compare to previous PopperJS, FloatingUI doesn't support "auto" placement anymore.
  return !placement?.startsWith('auto');
}

// interface FloatingActionsMenuProps extends Partial<FloatingWrapperProps> {
//   actions: MenuActionItemUnion[];
// }

/**
 * Respond to user queries in the editor.
 */
// export const FloatingActionsMenu = (props: FloatingActionsMenuProps): JSX.Element => {
//   const {
//     actions,
//     animated = false,
//     placement = 'right-end',
//     positioner = 'nearestWord',
//     blurOnInactive,
//     ignoredElements,
//     enabled = true,
//     ...floatingWrapperProps
//   } = props;
//   const { change } = useSuggest({ char: '/', name: 'actions-dropdown', matchOffset: 0 });
//   const query = change?.query.full;
//   const menuState = useMenuState({ unstable_virtual: true, wrap: true, loop: true });
//
//   const items = (
//     query
//       ? matchSorter(actions, query, {
//           keys: ['tags', 'description', (item) => item.description?.replace(/\W/g, '') ?? ''],
//           threshold: matchSorter.rankings.CONTAINS,
//         })
//       : actions
//   ).map<MenuPaneItem | MenuCommandPaneItem>((item) =>
//     item.type === ComponentItem.MenuAction
//       ? { ...item, type: ComponentItem.MenuPane }
//       : { ...item, type: ComponentItem.MenuCommandPane },
//   );
//
//   return (
//     <FloatingWrapper
//       enabled={!!query}
//       positioner={positioner}
//       placement={placement}
//       animated={animated}
//       blurOnInactive={blurOnInactive}
//       ignoredElements={ignoredElements}
//       {...floatingWrapperProps}
//     >
//       <div style={{ width: 50, height: 50, background: 'red' }} />
//       <MenuComponent open={!!query && enabled} items={items} menuState={menuState} />
//     </FloatingWrapper>
//   );
// };