remirror/remirror

View on GitHub
packages/remirror__react-hooks/src/use-mention-atom.ts

Summary

Maintainability
A
30 mins
Test Coverage
A
90%
import { useCallback, useMemo, useState } from 'react';
import {
  MentionAtomChangeHandler,
  MentionAtomExtension,
  MentionAtomNodeAttributes,
} from '@remirror/extension-mention-atom';
import { ChangeReason, SuggestChangeHandlerProps } from '@remirror/pm/suggest';
import { useExtensionEvent, useHelpers } from '@remirror/react-core';

import {
  MenuNavigationOptions,
  useMenuNavigation,
  UseMenuNavigationReturn,
} from './use-menu-navigation';

export interface MentionAtomState<
  Data extends MentionAtomNodeAttributes = MentionAtomNodeAttributes,
> extends Pick<SuggestChangeHandlerProps, 'name' | 'query' | 'text' | 'range'> {
  /**
   * The reason for the change.
   */
  reason: ChangeReason;

  /**
   * A command that will update the current matching region with the provided
   * attributes. To see what can be accomplished please inspect the type of the attrs which should be passed through.
   */
  command: (attrs: Data) => void;
}

export interface UseMentionAtomReturn<
  Data extends MentionAtomNodeAttributes = MentionAtomNodeAttributes,
> extends UseMenuNavigationReturn<Data> {
  state: MentionAtomState<Data> | null;
}

/**
 * A hook that provides the state for social mention atoms that responds to
 * keybindings and key-presses from the user.
 *
 * The difference between this and the `useMention` is that `useMention` creates
 * editable mentions that can be changed over an over again. This creates atom
 * mention which are inserted into the editor as non editable nodes. Backspacing
 * into this node will delete the whole mention.
 *
 * In order to properly support keybindings you will need to provide a list of
 * data that is to be shown to the user. This allows for the user to press the
 * arrow up and arrow down key.
 *
 * You can also add other supported attributes which will be added to the
 * mention node, like `href` and whatever you decide upon.
 *
 * @param props - the props that can be passed through to the mention atom.
 */
export function useMentionAtom<Data extends MentionAtomNodeAttributes = MentionAtomNodeAttributes>(
  props: UseMentionAtomProps<Data>,
): UseMentionAtomReturn<Data> {
  const {
    ignoreMatchesOnDismiss = true,
    items,
    direction,
    dismissKeys,
    focusOnClick,
    submitKeys,
    replacementType,
  } = props;
  const [state, setState] = useState<MentionAtomState<Data> | null>(null);
  const helpers = useHelpers();
  const isOpen = !!state;

  const onDismiss = useCallback(() => {
    if (!state) {
      return false;
    }

    const { range, name } = state;

    if (ignoreMatchesOnDismiss) {
      // Ignore the current mention so that it doesn't show again for this
      // matching area
      helpers.getSuggestMethods().addIgnored({ from: range.from, name, specific: true });
    }

    // Remove the matches.
    setState(null);

    return true;
  }, [helpers, ignoreMatchesOnDismiss, state]);

  const onSubmit = useCallback(
    (item: Data) => {
      if (!state) {
        // When there is no state, defer to the next keybinding.
        return false;
      }

      // TODO add option to override the submission here. Return true to
      // completely override.

      // Call the command with the item (including all the provided attributes
      // which it includes).
      state.command({ replacementType, ...item });

      return true;
    },
    [state, replacementType],
  );

  const menu = useMenuNavigation<Data>({
    items,
    isOpen,
    onDismiss,
    onSubmit,
    direction,
    dismissKeys,
    focusOnClick,
    submitKeys,
  });
  const { setIndex } = menu;

  /**
   * The is the callback for when a suggestion is changed.
   */
  const onChange: MentionAtomChangeHandler = useCallback(
    (props, command) => {
      const { query, range, name, exitReason, changeReason, text } = props;

      // By default the mention atom will not automatically create the mention
      // for you.
      if (exitReason) {
        setState(null);

        return;
      }

      // This really should never be the case.
      if (!changeReason) {
        return;
      }

      if (changeReason !== ChangeReason.Move) {
        setIndex(0);
      }

      // Reset the active index so that the dropdown is back to the top.
      setState({
        query,
        range,
        name,
        reason: changeReason,
        text,
        command: (attrs) => {
          command(attrs);
          setState(null);
        },
      });
    },
    [setIndex],
  );

  // Add the handlers to the `MentionExtension`
  useExtensionEvent(MentionAtomExtension, 'onChange', onChange);

  return useMemo(() => ({ ...menu, state }), [menu, state]);
}

export interface UseMentionAtomProps<
  Data extends MentionAtomNodeAttributes = MentionAtomNodeAttributes,
> extends MenuNavigationOptions,
    Pick<MentionAtomNodeAttributes, 'replacementType'> {
  /**
   * The list of data from which an index can be calculated. Must include at
   * least an `id` and a `label`.
   */
  items: Data[];

  /**
   * Whether matches should be permanently ignored when the user dismisses the
   * mention suggestion.
   *
   * @defaultValue true
   */
  ignoreMatchesOnDismiss?: boolean;
}

export type { MentionAtomNodeAttributes };