remirror/remirror

View on GitHub
packages/remirror__extension-emoji/src/emoji-extension.ts

Summary

Maintainability
A
0 mins
Test Coverage
B
85%
import EMOJI_REGEX from 'emojibase-regex/emoji.js';
import EMOTICON_REGEX from 'emojibase-regex/emoticon.js';
import SHORTCODE_REGEX from 'emojibase-regex/shortcode.js';
import escapeStringRegex from 'escape-string-regexp';
import type { Moji } from 'svgmoji';
import {
  ApplySchemaAttributes,
  command,
  CommandFunction,
  extension,
  ExtensionTag,
  FromToProps,
  GetAttributes,
  getMatchString,
  getTextSelection,
  InputRule,
  isElementDomNode,
  isString,
  keyBinding,
  KeyBindingProps,
  LEAF_NODE_REPLACING_CHARACTER,
  NodeExtension,
  NodeExtensionSpec,
  nodeInputRule,
  NodeSpecOverride,
  omitExtraAttributes,
  plainInputRule,
  PrimitiveSelection,
  ShouldSkipFunction,
} from '@remirror/core';
import { DEFAULT_SUGGESTER, Suggester } from '@remirror/pm/suggest';
import { ExtensionEmojiTheme } from '@remirror/theme';

import {
  AddEmojiCommandOptions,
  DefaultMoji,
  EMOJI_DATA_ATTRIBUTE,
  EmojiAttributes,
  EmojiOptions,
} from './emoji-utils';

@extension<EmojiOptions>({
  defaultOptions: {
    plainText: false,
    data: [],
    identifier: 'emoji',
    fallback: ':red_question_mark:',
    moji: 'noto',
    suggestionCharacter: ':',
    supportedCharacters: DEFAULT_SUGGESTER.supportedCharacters,
  },
  staticKeys: ['plainText'],
  handlerKeys: ['suggestEmoji'],
})
export class EmojiExtension extends NodeExtension<EmojiOptions> {
  /**
   * The name is dynamically generated based on the passed in type.
   */
  get name() {
    return 'emoji' as const;
  }

  private _moji?: Moji;

  get moji(): Moji {
    return (this._moji ??= isString(this.options.moji)
      ? new DefaultMoji[this.options.moji]({
          data: this.options.data,
          type: 'all',
          fallback: this.options.fallback,
        })
      : this.options.moji);
  }

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

  createNodeSpec(extra: ApplySchemaAttributes, override: NodeSpecOverride): NodeExtensionSpec {
    return {
      selectable: true,
      draggable: false,
      ...override,
      inline: true,

      atom: true,
      attrs: { ...extra.defaults(), code: {} },
      parseDOM: [
        {
          tag: `span[${EMOJI_DATA_ATTRIBUTE}`,
          getAttrs: (node) => {
            if (!isElementDomNode(node)) {
              return null;
            }

            const code = node.getAttribute(EMOJI_DATA_ATTRIBUTE);
            return { ...extra.parse(node), code };
          },
        },
        ...(override.parseDOM ?? []),
      ],

      toDOM: (node) => {
        const { code } = omitExtraAttributes(node.attrs, extra) as EmojiAttributes;
        const emoji = this.moji.find(code) ?? this.moji.fallback;

        return [
          'span',
          {
            class: ExtensionEmojiTheme.EMOJI_WRAPPER,
            [EMOJI_DATA_ATTRIBUTE]: emoji[this.options.identifier],
          },
          [
            'img',
            {
              role: 'presentation',
              class: ExtensionEmojiTheme.EMOJI_IMAGE,
              'aria-label': emoji.annotation,
              alt: emoji.annotation,
              // TODO use the emoji rather than the code once `svgmoji` supports it.
              src: this.moji.url(code),
            },
          ],
          // ['span', { style: 'display: inline-block; text-indent: -99999px' }, emoji.emoji],
        ];
      },
    };
  }

  /**
   * Manage input rules for emoticons.
   */
  createInputRules(): InputRule[] {
    // Use plain text when this option is set.
    if (this.options.plainText) {
      return [
        // Replace emoticons
        plainInputRule({
          regexp: new RegExp(`(${EMOTICON_REGEX.source})[\\s]$`),
          transformMatch: ([full, partial]) => {
            if (!full || !partial) {
              return null;
            }

            const emoji = this.moji.find(partial);
            return emoji ? full.replace(partial, emoji.emoji) : null;
          },
        }),

        // Replace matching shortcodes
        plainInputRule({
          regexp: new RegExp(`(${SHORTCODE_REGEX.source})$`),
          transformMatch: ([, match]) => {
            if (!match) {
              return null;
            }

            const emoji = this.moji.find(match);
            return emoji ? emoji.emoji : null;
          },
        }),
      ];
    }

    // Return true when the input rule should be skipped.
    const shouldSkip: ShouldSkipFunction = ({ captureGroup }) =>
      // eslint-disable-next-line unicorn/prefer-array-some
      !captureGroup || !this.moji.find(captureGroup);
    // Capture the attributes for the emoji
    const getAttributes: GetAttributes = ([, match]) => {
      if (!match) {
        return;
      }

      const emoji = this.moji.find(match);

      return emoji ? { code: emoji[this.options.identifier] } : undefined;
    };

    // The current emoji node type.
    const type = this.type;

    return [
      // Replace emoticons
      nodeInputRule({
        type,
        shouldSkip,
        getAttributes,
        regexp: new RegExp(`(${EMOTICON_REGEX.source})[\\s]$`),
        beforeDispatch: ({ tr }) => {
          tr.insertText(' ');
        },
      }),

      // Replace matching shortcodes
      nodeInputRule({
        type,
        shouldSkip,
        getAttributes,
        regexp: new RegExp(`(${SHORTCODE_REGEX.source})$`),
      }),

      // Replace matching shortcodes
      nodeInputRule({
        type,
        shouldSkip,
        getAttributes,
        regexp: new RegExp(`(${EMOJI_REGEX.source})`),
      }),
    ];
  }

  /**
   * Insert an emoji into the document at the requested location by name
   *
   * The range is optional and if not specified the emoji will be inserted
   * at the current selection.
   *
   * @param identifier - the hexcode | unicode | shortcode | emoticon of the emoji to insert.
   * @param [options] - the options when inserting the emoji.
   */
  @command()
  addEmoji(identifier: string, options: AddEmojiCommandOptions = {}): CommandFunction {
    return (props) => {
      const { dispatch, tr } = props;
      const emoji = this.moji.find(identifier);

      if (!emoji) {
        // Nothing to do here since no emoji found.
        return false;
      }

      if (!this.options.plainText) {
        return this.store.commands.replaceText.original({
          type: this.type,
          attrs: { code: emoji[this.options.identifier] },
          selection: options.selection,
        })(props);
      }

      const { from, to } = getTextSelection(options.selection ?? tr.selection, tr.doc);

      dispatch?.(tr.insertText(emoji.emoji, from, to));

      return true;
    };
  }

  /**
   * Inserts the suggestion character into the current position in the
   * editor in order to activate the suggestion popup.
   */
  @command()
  suggestEmoji(selection?: PrimitiveSelection): CommandFunction {
    return ({ tr, dispatch }) => {
      const { from, to } = getTextSelection(selection ?? tr.selection, tr.doc);
      const text = this.store.helpers.getTextBetween(from - 1, to, tr.doc);

      if (text.includes(this.options.suggestionCharacter)) {
        return false;
      }

      dispatch?.(tr.insertText(this.options.suggestionCharacter, from, to));

      return true;
    };
  }

  @keyBinding({ shortcut: 'Enter' })
  handleEnterKey({ tr, next }: KeyBindingProps): boolean {
    const { $from, empty } = tr.selection;

    if (!empty) {
      return next();
    }

    // Try and find an emoticon in the last 5 characters
    const textBeforeCursor = $from.parent.textBetween(
      Math.max(0, $from.parentOffset - 5),
      $from.parentOffset,
      undefined,
      LEAF_NODE_REPLACING_CHARACTER,
    );
    const match = textBeforeCursor.match(EMOTICON_REGEX);

    if (match) {
      const emoticon = getMatchString(match);
      const selection: FromToProps = {
        from: $from.pos - emoticon.length,
        to: $from.pos,
      };
      // Replace the matching text with the emoticon
      this.store.chain(tr).addEmoji(emoticon, { selection }).tr();
    }

    return next();
  }

  /**
   * Emojis can be selected via `:` the colon key (by default). This sets the
   * configuration using `prosemirror-suggest`
   */
  createSuggesters(): Suggester {
    return {
      disableDecorations: true,
      invalidPrefixCharacters: `${escapeStringRegex(this.options.suggestionCharacter)}|\\w`,
      supportedCharacters: this.options.supportedCharacters,
      char: this.options.suggestionCharacter,
      name: this.name,
      suggestTag: 'span',
      onChange: (props) => {
        // When the change handler is called call the extension handler
        // `suggestEmoji` with props that can be used to trigger the emoji.
        this.options.suggestEmoji({
          moji: this.moji,
          query: props.query.full,
          text: props.text.full,
          range: props.range,
          exit: !!props.exitReason,
          change: !!props.changeReason,
          apply: (code: string) => {
            this.store.commands.addEmoji(code, { selection: props.range });
          },
        });
      },
    };
  }
}

declare global {
  namespace Remirror {
    interface AllExtensions {
      emoji: EmojiExtension;
    }
  }
}