remirror/remirror

View on GitHub
packages/@remirror/extension-link/src/link-extension.ts

Summary

Maintainability
A
35 mins
Test Coverage
A
91%
import {
  ApplySchemaAttributes,
  CommandFunction,
  CreatePluginReturn,
  EditorState,
  extensionDecorator,
  ExtensionPriority,
  ExtensionTag,
  FromToParameter,
  GetMarkRange,
  getMarkRange,
  getMatchString,
  getSelectedWord,
  Handler,
  isAllSelection,
  isElementDomNode,
  isMarkActive,
  isSelectionEmpty,
  isTextSelection,
  KeyBindings,
  LEAF_NODE_REPLACING_CHARACTER,
  MarkAttributes,
  MarkExtension,
  MarkExtensionSpec,
  markPasteRule,
  omitExtraAttributes,
  OnSetOptionsParameter,
  preserveSelection,
  ProsemirrorNode,
  ProsemirrorPlugin,
  range as numberRange,
  removeMark,
  Static,
  updateMark,
} from '@remirror/core';
import type { CreateEventHandlers } from '@remirror/extension-events';
import { TextSelection } from '@remirror/pm/state';
import { isInvalidSplitReason, isRemovedReason, Suggester } from '@remirror/pm/suggest';

const UPDATE_LINK = 'updateLink';

/**
 * Can be an empty string which sets url's to '//google.com'.
 */
export type DefaultProtocol = 'http:' | 'https:' | '';

interface EventMeta {
  selection: TextSelection;
  range: FromToParameter | undefined;
  doc: ProsemirrorNode;
  attrs: LinkAttributes;
}

export interface LinkOptions {
  /**
   * Called when the user activates the keyboard shortcut.
   */
  onActivateLink?: Handler<(selectedText: string) => void>;

  /**
   * Called after the `commands.updateLink` has been called.
   */
  onUpdateLink?: Handler<(selectedText: string, meta: EventMeta) => void>;

  /**
   * Whether whether to select the text of the full active link when clicked.
   */
  selectTextOnClick?: boolean;

  /**
   * Listen to click events for links.
   */
  onClick?: Handler<(event: MouseEvent, data: LinkClickData) => boolean>;

  /**
   * Whether the link is opened when being clicked.
   *
   * @deprecated use `onClick` handler instead.
   */
  openLinkOnClick?: boolean;

  /**
   * Whether automatic links should be created.
   *
   * @default false
   */
  autoLink?: boolean;

  /**
   * The regex matcher for matching against the RegExp. The matcher must capture
   * the URL part of the string as it's first match. Take a look at the default
   * value.
   *
   * @default
   * /((http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[\da-z]+([.-][\da-z]+)*\.[a-z]{2,5}(:\d{1,5})?(\/.*)?)/gi
   */
  autoLinkRegex?: Static<RegExp>;

  /**
   * The default protocol to use when it can't be inferred
   */
  defaultProtocol?: DefaultProtocol;
}

export interface LinkClickData extends GetMarkRange, LinkAttributes {}

export type LinkAttributes = MarkAttributes<{
  /**
   * The link which is required property for the link mark.
   */
  href: string;

  /**
   * True when this was an automatically generated link. False when the link was
   * added specifically by the user.
   *
   * @default false
   */
  auto?: boolean;
}>;

@extensionDecorator<LinkOptions>({
  defaultOptions: {
    autoLink: false,
    defaultProtocol: '',
    selectTextOnClick: false,
    openLinkOnClick: false,
    autoLinkRegex: /(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[\da-z]+([.-][\da-z]+)*\.[a-z]{2,5}(:\d{1,5})?(\/\S*)?/,
  },
  staticKeys: ['autoLinkRegex'],
  handlerKeyOptions: { onClick: { earlyReturnValue: true } },
  handlerKeys: ['onActivateLink', 'onUpdateLink', 'onClick'],
})
export class LinkExtension extends MarkExtension<LinkOptions> {
  get name() {
    return 'link' as const;
  }

  readonly tags = [ExtensionTag.Link];

  createMarkSpec(extra: ApplySchemaAttributes): MarkExtensionSpec {
    const AUTO_ATTRIBUTE = 'data-link-auto';
    return {
      attrs: {
        ...extra.defaults(),
        href: {},
        auto: { default: false },
      },
      inclusive: false,
      parseDOM: [
        {
          tag: 'a[href]',
          getAttrs: (node) => {
            if (!isElementDomNode(node)) {
              return false;
            }

            const href = node.getAttribute('href');
            const auto =
              node.hasAttribute(AUTO_ATTRIBUTE) ||
              this.options.autoLinkRegex.test(node.textContent ?? '');
            return { ...extra.parse(node), href, auto };
          },
        },
      ],
      toDOM: (node) => {
        const { auto: _, ...rest } = omitExtraAttributes(node.attrs, extra);
        const auto = node.attrs.auto ? { [AUTO_ATTRIBUTE]: '' } : {};
        const rel = 'noopener noreferrer nofollow';
        const attrs = { ...extra.dom(node), ...rest, rel, ...auto };

        return ['a', attrs, 0];
      },
    };
  }

  onSetOptions(options: OnSetOptionsParameter<LinkOptions>): void {
    if (options.changes.autoLink.changed) {
      if (options.changes.autoLink.value === true) {
        this.store.addSuggester(this.createSuggesters()[0]);
      }

      if (options.changes.autoLink.value === false) {
        this.store.removeSuggester(this.name);
      }
    }
  }

  createKeymap(): KeyBindings {
    return {
      'Mod-k': ({ state, dispatch }) => {
        // if the selection is empty, expand it
        const range = state.selection.empty ? getSelectedWord(state) : state.selection;

        if (!range) {
          return false;
        }

        const { from, to } = range;
        const tr = state.tr.setSelection(TextSelection.create(state.doc, from, to));

        if (dispatch) {
          dispatch(tr);
        }

        this.options.onActivateLink(tr.doc.textBetween(from, to));

        return true;
      },
    };
  }

  createCommands() {
    return {
      /**
       * Create or update the link if it doesn't currently exist at the current
       * selection or provided range.
       */
      updateLink: (attrs: LinkAttributes, range?: FromToParameter): CommandFunction => {
        return (parameter) => {
          const { tr } = parameter;
          const { selection } = tr;
          const selectionIsValid =
            (isTextSelection(selection) && !isSelectionEmpty(tr.selection)) ||
            isAllSelection(selection) ||
            isMarkActive({ trState: tr, type: this.type });

          if (!selectionIsValid && !range) {
            return false;
          }

          tr.setMeta(this.name, { command: UPDATE_LINK, attrs, range });

          return updateMark({ type: this.type, attrs, range })(parameter);
        };
      },

      /**
       * Remove the link at the current selection
       */
      removeLink: (range?: FromToParameter): CommandFunction => {
        return (parameter) => {
          const { tr } = parameter;

          if (!isMarkActive({ trState: tr, type: this.type, ...range })) {
            return false;
          }

          return removeMark({ type: this.type, expand: true, range })(parameter);
        };
      },
    };
  }

  /**
   * Create the paste rules that can transform a pasted link in the document.
   */
  createPasteRules(): ProsemirrorPlugin[] {
    if (this.options.autoLink) {
      return [];
    }

    return [
      markPasteRule({
        regexp: /https?:\/\/(www\.)?[\w#%+.:=@~-]{2,256}\.[a-z]{2,6}\b([\w#%&+./:=?@~-]*)/gi,
        type: this.type,
        getAttributes: (url) => ({ href: getMatchString(url), auto: true }),
      }),
    ];
  }

  createSuggesters(): Suggester[] {
    if (!this.options.autoLink) {
      return [];
    }

    // Keep track of this to prevent multiple updates which prevent history from
    // working
    let cachedRange: FromToParameter | undefined;

    const suggester: Suggester = {
      name: this.name,
      matchOffset: 0,
      supportedCharacters: /$/,
      char: this.options.autoLinkRegex,
      priority: ExtensionPriority.Lowest,
      caseInsensitive: true,
      disableDecorations: true,
      appendTransaction: true,

      checkNextValidSelection: ($pos, tr) => {
        const range = getMarkRange($pos, this.type);

        if (!range || (cachedRange?.from === $pos.pos && cachedRange.to === range.to)) {
          return;
        }

        if (!range.mark.attrs.auto) {
          return;
        }

        const { mark, from, to } = range;
        const text = $pos.doc.textBetween(from, to, LEAF_NODE_REPLACING_CHARACTER, ' ');
        const href = extractHref(text, this.options.defaultProtocol);

        if (from === range.from && to === range.to && mark.attrs.href === href) {
          return;
        }

        // The helpers to use here.
        const { getSuggestMethods } = this.store.helpers;

        // Using the chainable commands so that the selection can be preserved
        // for the update.
        const { updateLink, removeLink, custom, restore } = this.store.chain;
        const { findMatchAtPosition } = getSuggestMethods();
        const selection = tr.selection;

        // Keep track of the last removal.
        cachedRange = { from: $pos.pos, to };

        // Set the transaction to update for the chainable commands.
        custom(tr);

        // Remove the link
        removeLink(cachedRange);

        // Make sure the selection gets preserved otherwise the cursor jumps
        // around.
        preserveSelection(selection, tr);

        // Check for active matches from the provided $pos
        const match = findMatchAtPosition($pos, this.name);

        if (match) {
          const { range, text } = match;
          updateLink(
            { href: extractHref(text.full, this.options.defaultProtocol), auto: true },
            range,
          );
        }

        // Make sure to restore the shared transaction to it's default value.
        restore();
      },

      onChange: (details, tr) => {
        const selection = tr.selection;
        const { text, range, exitReason, setMarkRemoved } = details;
        const href = extractHref(text.full, this.options.defaultProtocol);

        // Using the chainable commands so that the selection can be preserved
        // for the update.
        const { updateLink, removeLink, custom, restore } = this.store.chain;
        const { getSuggestMethods } = this.store.helpers;
        const { findMatchAtPosition } = getSuggestMethods();

        /** Remove the link at the provided range. */
        const remove = () => {
          // Call the command to use a custom transaction rather than the current.
          custom(tr);

          let markRange: ReturnType<typeof getMarkRange> | undefined;

          for (const pos of numberRange(range.from, range.to)) {
            markRange = getMarkRange(tr.doc.resolve(pos), this.type);

            if (markRange) {
              break;
            }
          }

          removeLink(markRange ?? range);

          // The mark range for the position after the matched text. If this
          // exists it should be removed to handle cleanup properly.
          let afterMarkRange: ReturnType<typeof getMarkRange> | undefined;

          for (const pos of numberRange(
            tr.selection.to,
            Math.min(tr.selection.$to.end(), tr.doc.nodeSize - 2),
          )) {
            afterMarkRange = getMarkRange(tr.doc.resolve(pos), this.type);

            if (afterMarkRange) {
              break;
            }
          }

          if (afterMarkRange) {
            removeLink(afterMarkRange);
          }

          preserveSelection(selection, tr);
          const match = findMatchAtPosition(
            tr.doc.resolve(Math.min(range.from + 1, tr.doc.nodeSize - 2)),
            this.name,
          );

          if (match) {
            updateLink(
              { href: extractHref(match.text.full, this.options.defaultProtocol), auto: true },
              match.range,
            );
          } else {
            setMarkRemoved();
          }

          restore();
        };

        // Respond when there is an exit.
        if (exitReason) {
          const isInvalid = isInvalidSplitReason(exitReason);
          const isRemoved = isRemovedReason(exitReason);

          if (isInvalid || isRemoved) {
            try {
              remove();
            } catch {
              // Errors here can be ignored since they can be caused by deleting
              // the whole document.
              return;
            }
          }

          return;
        }

        let value: ReturnType<typeof getMarkRange> | undefined;

        for (const pos of numberRange(range.from, range.to)) {
          value = getMarkRange(tr.doc.resolve(pos), this.type);

          if (value?.mark.attrs.auto === false) {
            return;
          }

          if (value) {
            break;
          }
        }

        /** Update the current range with the new link */
        function update() {
          custom(tr);
          updateLink({ href, auto: true }, range);
          preserveSelection(selection, tr);
          restore();
        }

        // Update when there is a value
        if (!value) {
          update();

          return;
        }

        // Do nothing when the current mark is identical to the mark that would be created.
        if (
          value.from === range.from &&
          value.to === range.to &&
          value.mark.attrs.auto &&
          value.mark.attrs.href === href
        ) {
          return;
        }

        update();
      },
    };

    return [suggester];
  }

  /**
   * Track click events passed through to the editor.
   */
  createEventHandlers(): CreateEventHandlers {
    return {
      clickMark: (event, clickState) => {
        const markRange = clickState.getMark(this.type);

        if (!markRange) {
          return;
        }

        const attrs = markRange.mark.attrs as LinkAttributes;
        const data: LinkClickData = { ...attrs, ...markRange };

        // If one of the handlers returns `true` then return early.
        if (this.options.onClick(event, data)) {
          return true;
        }

        let handled = false;

        if (this.options.openLinkOnClick) {
          handled = true;
          const href = attrs.href;
          window.open(href, '_blank');
        }

        if (this.options.selectTextOnClick) {
          handled = true;
          this.store.commands.selectText(markRange);
        }

        return handled;
      },
    };
  }

  /**
   * The plugin for handling click events in the editor.
   *
   * TODO extract this into the events extension and move that extension into
   * core.
   */
  createPlugin(): CreatePluginReturn {
    return {
      props: {
        handleClick: (view, pos) => {
          if (!this.options.selectTextOnClick && !this.options.openLinkOnClick) {
            return false;
          }

          const { doc, tr } = view.state;
          const range = getMarkRange(doc.resolve(pos), this.type);

          if (!range) {
            return false;
          }

          if (this.options.openLinkOnClick) {
            const href = range.mark.attrs.href;
            window.open(href, '_blank');
          }

          if (this.options.selectTextOnClick) {
            const $start = doc.resolve(range.from);
            const $end = doc.resolve(range.to);
            const transaction = tr.setSelection(new TextSelection($start, $end));

            view.dispatch(transaction);
          }

          return true;
        },
      },
      appendTransaction: (transactions, _oldState, state: EditorState) => {
        const transactionsWithLinkMeta = transactions.filter((tr) => !!tr.getMeta(this.name));

        if (transactionsWithLinkMeta.length === 0) {
          return;
        }

        transactionsWithLinkMeta.forEach((tr) => {
          const trMeta = tr.getMeta(this.name);

          if (trMeta.command === UPDATE_LINK) {
            const { range, attrs } = trMeta;
            const { selection, doc } = state;
            const meta = { range, selection, doc, attrs };

            const { from, to } = range ?? selection;
            this.options.onUpdateLink(doc.textBetween(from, to), meta);
          }
        });
      },
    };
  }
}

/**
 * Extract the `href` from the provided text.
 */
function extractHref(url: string, defaultProtocol: DefaultProtocol) {
  return url.startsWith('http') || url.startsWith('//') ? url : `${defaultProtocol}//${url}`;
}

declare global {
  namespace Remirror {
    interface AllExtensions {
      link: LinkExtension;
    }
  }
}