remirror/remirror

View on GitHub
packages/remirror__extension-mention-atom/src/mention-atom-extension.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
90%
import {
  ApplySchemaAttributes,
  command,
  CommandFunction,
  ErrorConstant,
  extension,
  ExtensionTag,
  Handler,
  invariant,
  isElementDomNode,
  isString,
  kebabCase,
  NodeExtension,
  NodeExtensionSpec,
  NodeSpecOverride,
  NodeWithPosition,
  omitExtraAttributes,
  pick,
  ProsemirrorAttributes,
  replaceText,
  Static,
} from '@remirror/core';
import type { CreateEventHandlers } from '@remirror/extension-events';
import {
  DEFAULT_SUGGESTER,
  MatchValue,
  RangeWithCursor,
  SuggestChangeHandlerProps,
  Suggester,
} from '@remirror/pm/suggest';
import { ExtensionMentionAtomTheme as Theme } from '@remirror/theme';

/**
 * Options available to the [[`MentionAtomExtension`]].
 */
export interface MentionAtomOptions
  extends Pick<
    Suggester,
    'invalidNodes' | 'validNodes' | 'invalidMarks' | 'validMarks' | 'isValidPosition'
  > {
  /**
   * When `true` the atom node which wraps the mention will be selectable.
   *
   * @defaultValue true
   */
  selectable?: Static<boolean>;

  /**
   * Whether mentions should be draggable.
   *
   * @defaultValue false
   */
  draggable?: Static<boolean>;

  /**
   * Provide a custom tag for the mention
   */
  mentionTag?: Static<string>;

  /**
   * Provide the custom matchers that will be used to match mention text in the
   * editor.
   *
   * TODO - add customized tags here.
   */
  matchers: Static<MentionAtomExtensionMatcher[]>;

  /**
   * Text to append after the mention has been added.
   *
   * **NOTE**: If it seems that your editor is swallowing  up empty whitespace,
   * make sure you've imported the core css from the `@remirror/styles` library.
   *
   * @defaultValue ' '
   */
  appendText?: string;

  /**
   * Tag for the prosemirror decoration which wraps an active match.
   *
   * @defaultValue 'span'
   */
  suggestTag?: string;

  /**
   * When true, decorations are not created when this mention is being edited.
   */
  disableDecorations?: boolean;

  /**
   * Called whenever a suggestion becomes active or changes in any way.
   *
   * @remarks
   *
   * It receives a parameters object with the `reason` for the change for more
   * granular control.
   */
  onChange?: Handler<MentionAtomChangeHandler>;

  /**
   * Listen for click events to the mention atom extension.
   */
  onClick?: Handler<
    (event: MouseEvent, nodeWithPosition: NodeWithPosition) => boolean | undefined | void
  >;
}

/**
 * This is the atom version of the `MentionExtension`
 * `@remirror/extension-mention`.
 *
 * It provides mentions as atom nodes which don't support editing once being
 * inserted into the document.
 */
@extension<MentionAtomOptions>({
  defaultOptions: {
    selectable: true,
    draggable: false,
    mentionTag: 'span' as const,
    matchers: [],
    appendText: ' ',
    suggestTag: 'span' as const,
    disableDecorations: false,
    invalidMarks: [],
    invalidNodes: [],
    isValidPosition: () => true,
    validMarks: null,
    validNodes: null,
  },
  handlerKeyOptions: { onClick: { earlyReturnValue: true } },
  handlerKeys: ['onChange', 'onClick'],
  staticKeys: ['selectable', 'draggable', 'mentionTag', 'matchers'],
})
export class MentionAtomExtension extends NodeExtension<MentionAtomOptions> {
  get name() {
    return 'mentionAtom' as const;
  }

  createTags() {
    return [ExtensionTag.InlineNode, ExtensionTag.Behavior];
  }

  createNodeSpec(extra: ApplySchemaAttributes, override: NodeSpecOverride): NodeExtensionSpec {
    const dataAttributeId = 'data-mention-atom-id';
    const dataAttributeName = 'data-mention-atom-name';

    return {
      inline: true,
      marks: '',
      selectable: this.options.selectable,
      draggable: this.options.draggable,
      atom: true,
      ...override,
      attrs: {
        ...extra.defaults(),
        id: {},
        label: {},
        name: {},
      },
      parseDOM: [
        ...this.options.matchers.map((matcher) => ({
          tag: `${matcher.mentionTag ?? this.options.mentionTag}[${dataAttributeId}]`,
          getAttrs: (node: string | Node) => {
            if (!isElementDomNode(node)) {
              return false;
            }

            const id = node.getAttribute(dataAttributeId);
            const name = node.getAttribute(dataAttributeName);
            const label = node.textContent;
            return { ...extra.parse(node), id, label, name };
          },
        })),
        ...(override.parseDOM ?? []),
      ],
      toDOM: (node) => {
        const { label, id, name } = omitExtraAttributes(
          node.attrs,
          extra,
        ) as NamedMentionAtomNodeAttributes;
        const matcher = this.options.matchers.find((matcher) => matcher.name === name);

        const mentionClassName = matcher
          ? matcher.mentionClassName ?? DEFAULT_MATCHER.mentionClassName
          : DEFAULT_MATCHER.mentionClassName;

        const attrs = {
          ...extra.dom(node),
          class: name
            ? `${mentionClassName} ${mentionClassName}-${kebabCase(name)}`
            : mentionClassName,
          [dataAttributeId]: id,
          [dataAttributeName]: name,
        };

        return [matcher?.mentionTag ?? this.options.mentionTag, attrs, label];
      },
    };
  }

  /**
   * Creates a mention atom at the  the provided range.
   *
   * A variant of this method is provided to the `onChange` handler for this
   * extension.
   *
   * @param details - the range and name of the mention to be created.
   * @param attrs - the attributes that should be passed through. Required
   * values are `id` and `label`.
   */
  @command()
  createMentionAtom(details: CreateMentionAtom, attrs: MentionAtomNodeAttributes): CommandFunction {
    const { name, range } = details;
    const validNameExists = this.options.matchers.some((matcher) => name === matcher.name);

    // Check that the name is valid.
    invariant(validNameExists, {
      code: ErrorConstant.EXTENSION,
      message: `Invalid name '${name}' provided when creating a mention. Please ensure you only use names that were configured on the matchers when creating the \`MentionAtomExtension\`.`,
    });

    const { appendText, replacementType, ...rest } = attrs;

    const { from, to } = {
      from: range.from,
      to: replacementType === 'partial' ? range.cursor : range.to,
    };

    return replaceText({
      type: this.type,
      appendText: getAppendText(appendText, this.options.appendText),
      attrs: { name, ...rest },
      selection: { from, to },
    });
  }

  /**
   * Track click events passed through to the editor.
   */
  createEventHandlers(): CreateEventHandlers {
    return {
      click: (event, clickState) => {
        // Check if this is a direct click which must be the case for atom
        // nodes.
        if (!clickState.direct) {
          return;
        }

        const nodeWithPosition = clickState.getNode(this.type);

        if (!nodeWithPosition) {
          return;
        }

        return this.options.onClick(event, nodeWithPosition);
      },
    };
  }

  createSuggesters(): Suggester[] {
    const options = pick(this.options, [
      'invalidMarks',
      'invalidNodes',
      'isValidPosition',
      'validMarks',
      'validNodes',
      'suggestTag',
      'disableDecorations',
      'appendText',
    ]);

    return this.options.matchers.map<Suggester>((matcher) => ({
      ...DEFAULT_MATCHER,
      ...options,
      ...matcher,
      onChange: (props) => {
        const { name, range } = props;
        const { createMentionAtom } = this.store.commands;

        function command(attrs: MentionAtomNodeAttributes) {
          createMentionAtom({ name, range }, attrs);
        }

        this.options.onChange(props, command);
      },
    }));
  }
}

/**
 * The default matcher to use when none is provided in options
 */
const DEFAULT_MATCHER = {
  ...pick(DEFAULT_SUGGESTER, [
    'startOfLine',
    'supportedCharacters',
    'validPrefixCharacters',
    'invalidPrefixCharacters',
  ]),
  appendText: '',
  matchOffset: 1,
  suggestClassName: Theme.SUGGEST_ATOM,
  mentionClassName: Theme.MENTION_ATOM,
};

export interface OptionalMentionAtomExtensionProps {
  /**
   * The text to append to the replacement.
   *
   * @defaultValue ''
   */
  appendText?: string;

  /**
   * The type of replacement to use. By default, the command will replace the entire match.
   *
   * To replace the match up only to where the cursor is placed set this to
   * `partial`.
   *
   * @defaultValue 'full'
   */
  replacementType?: keyof MatchValue;
}

export interface CreateMentionAtom {
  /**
   * The name of the matcher used to create this mention.
   */
  name: string;

  /**
   * The range of the current selection
   */
  range: RangeWithCursor;
}

/**
 * The attrs that will be added to the node.
 * ID and label are plucked and used while attributes like href and role can be assigned as desired.
 */
export type MentionAtomNodeAttributes = ProsemirrorAttributes<
  OptionalMentionAtomExtensionProps & {
    /**
     * A unique identifier for the suggesters node
     */
    id: string;

    /**
     * The text to be placed within the suggesters node
     */
    label: string;
  }
>;

export type NamedMentionAtomNodeAttributes = MentionAtomNodeAttributes & {
  /**
   * The name of the matcher used to create this mention.
   */
  name: string;
};

/**
 * This change handler is called whenever there is an update in the matching
 * suggester. The second parameter `command` is available to automatically
 * create the mention with the required attributes.
 */
export type MentionAtomChangeHandler = (
  handlerState: SuggestChangeHandlerProps,
  command: (attrs: MentionAtomNodeAttributes) => void,
) => void;

/**
 * The options for the matchers which can be created by this extension.
 */
export interface MentionAtomExtensionMatcher
  extends Pick<
    Suggester,
    | 'char'
    | 'name'
    | 'startOfLine'
    | 'supportedCharacters'
    | 'validPrefixCharacters'
    | 'invalidPrefixCharacters'
    | 'suggestClassName'
  > {
  /**
   * See [[``Suggester.matchOffset`]] for more details.
   *
   * @defaultValue 1
   */
  matchOffset?: number;

  /**
   * Provide customs class names for the completed mention.
   */
  mentionClassName?: string;

  /**
   * An override for the default mention tag. This allows different mentions to
   * use different tags.
   */
  mentionTag?: string;
}

/**
 * Get the append text value which needs to be handled carefully since it can
 * also be an empty string.
 */
function getAppendText(preferred: string | undefined, fallback: string | undefined) {
  if (isString(preferred)) {
    return preferred;
  }

  if (isString(fallback)) {
    return fallback;
  }

  return DEFAULT_MATCHER.appendText;
}

declare global {
  namespace Remirror {
    interface AllExtensions {
      mentionAtom: MentionAtomExtension;
    }
  }
}