remirror/remirror

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

Summary

Maintainability
A
0 mins
Test Coverage
B
89%
import extractDomain from 'extract-domain';
import {
  ApplySchemaAttributes,
  command,
  CommandFunction,
  composeTransactionSteps,
  CreateExtensionPlugin,
  EditorState,
  extension,
  ExtensionPriority,
  ExtensionTag,
  findMatches,
  FromToProps,
  getChangedRanges,
  GetMarkRange,
  getMarkRange,
  getMatchString,
  getSelectedWord,
  Handler,
  includes,
  isAllSelection,
  isElementDomNode,
  isMarkActive,
  isSelectionEmpty,
  isTextSelection,
  keyBinding,
  KeyBindingProps,
  last,
  LiteralUnion,
  MarkExtension,
  MarkExtensionSpec,
  MarkSpecOverride,
  NamedShortcut,
  NodeWithPosition,
  omitExtraAttributes,
  ProsemirrorAttributes,
  ProsemirrorNode,
  removeMark,
  Static,
  updateMark,
} from '@remirror/core';
import type { CreateEventHandlers } from '@remirror/extension-events';
import { undoDepth } from '@remirror/pm/history';
import { MarkPasteRule } from '@remirror/pm/paste-rules';
import { Selection } from '@remirror/pm/state';
import { ReplaceAroundStep, ReplaceStep } from '@remirror/pm/transform';

import { TOP_50_TLDS } from './link-extension-utils';

const UPDATE_LINK = 'updateLink';

// Based on https://gist.github.com/dperini/729294
const DEFAULT_AUTO_LINK_REGEX =
  /(?:(?:(?:https?|ftp):)?\/\/)?(?:\S+(?::\S*)?@)?(?:(?:[\da-z\u00A1-\uFFFF][\w\u00A1-\uFFFF-]{0,62})?[\da-z\u00A1-\uFFFF]\.)*(?:(?:\d(?!\.)|[a-z\u00A1-\uFFFF])(?:[\da-z\u00A1-\uFFFF][\w\u00A1-\uFFFF-]{0,62})?[\da-z\u00A1-\uFFFF]\.)+[a-z\u00A1-\uFFFF]{2,}(?::\d{2,5})?(?:[#/?]\S*)?/gi;

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

export interface FoundAutoLink {
  /** link href */
  href: string;
  /** link text */
  text: string;
  /** offset of matched text */
  start: number;
  /** index of next char after match end */
  end: number;
}

interface LinkWithProperties extends Omit<FoundAutoLink, 'href'> {
  range: FromToProps;
  attrs: LinkAttributes;
}

interface EventMeta {
  selection: Selection;
  range: FromToProps | undefined;
  doc: ProsemirrorNode;
  attrs: LinkAttributes;
}

interface ShortcutHandlerActiveLink extends FromToProps {
  attrs: LinkAttributes;
}

export interface ShortcutHandlerProps extends FromToProps {
  selectedText: string;
  activeLink: ShortcutHandlerActiveLink | undefined;
}

type LinkTarget = LiteralUnion<'_blank' | '_self' | '_parent' | '_top', string> | null;

export interface LinkOptions {
  /**
   * @deprecated use `onShortcut` instead
   */
  onActivateLink?: Handler<(selectedText: string) => void>;

  /**
   * Called when the user activates the keyboard shortcut.
   *
   * It is called with the active link in the selected range, if it exists.
   *
   * If multiple links exist within the range, only the first is returned. I'm
   * open to PR's if you feel it's important to capture all contained links.
   *
   * @defaultValue undefined
   */
  onShortcut?: Handler<(props: ShortcutHandlerProps) => void>;

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

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

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

  /**
   * Extract the `href` attribute from the provided `url` text.
   *
   * @remarks
   *
   * By default this will return the `url` text with a `${defaultProtocol}//` or
   * `mailto:` prefix if needed.
   */
  extractHref?: Static<(props: { url: string; defaultProtocol: DefaultProtocol }) => string>;

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

  /**
   * Whether automatic links should be created.
   *
   * @defaultValue 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
   * /(?:(?:(?:https?|ftp):)?\/\/)?(?:\S+(?::\S*)?@)?(?:(?:[\da-z\u00A1-\uFFFF][\w\u00A1-\uFFFF-]{0,62})?[\da-z\u00A1-\uFFFF]\.)*(?:(?:\d(?!\.)|[a-z\u00A1-\uFFFF])(?:[\da-z\u00A1-\uFFFF][\w\u00A1-\uFFFF-]{0,62})?[\da-z\u00A1-\uFFFF]\.)+[a-z\u00A1-\uFFFF]{2,}(?::\d{2,5})?(?:[#/?]\S*)?/gi
   */
  autoLinkRegex?: Static<RegExp>;

  /**
   * An array of valid Top Level Domains (TLDs) to limit the scope of auto linking.
   *
   * @remarks
   *
   * The default autoLinkRegex does not limit the TLD of a URL for performance and maintenance reasons.
   * This can lead to the auto link behaviour being overly aggressive.
   *
   * Defaults to the top 50 TLDs (as of May 2022).
   *
   * If you find this too permissive, you can override this with an array of your own TLDs.  i.e. you could use the top 10 TLDs.
   *
   * ['com', 'de', 'net', 'org', 'uk', 'cn', 'ga', 'nl', 'cf', 'ml']
   *
   * However, this would prevent auto linking to domains like remirror.io!
   *
   * For a complete list of TLDs, you could use an external package like "tlds" or "global-tld-list"
   *
   * Or to extend the default list you could
   *
   * ```ts
   * import { LinkExtension, TOP_50_TLDS } from 'remirror/extensions';
   * const extensions = () => [
   *   new LinkExtension({ autoLinkAllowedTLDs: [...TOP_50_TLDS, 'london', 'tech'] })
   * ];
   * ```
   *
   * @defaultValue the top 50 TLDs by usage (May 2022)
   */
  autoLinkAllowedTLDs?: Static<string[]>;

  /**
   * Returns a list of links found in string where each element is a hash
   * with properties { href: string; text: string; start: number; end: number; }
   *
   * @remarks
   *
   * This function is used instead of matching links with the autoLinkRegex option.
   *
   * @default null
   *
   * @param {string} input
   * @param {string} defaultProtocol
   * @returns {array} FoundAutoLink[]
   */
  findAutoLinks?: Static<(input: string, defaultProtocol: string) => FoundAutoLink[]>;

  /**
   * Check if the given string is a link
   *
   * @remarks
   *
   * Used instead of validating a link with the autoLinkRegex and autoLinkAllowedTLDs option.
   *
   * @default null
   *
   * @param {string} input
   * @param {string} defaultProtocol
   * @returns {boolean}
   */
  isValidUrl?: Static<(input: string, defaultProtocol: string) => boolean>;

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

  /**
   * The default target to use for links.
   *
   * @defaultValue null
   */
  defaultTarget?: LinkTarget;

  /**
   * The supported targets which can be parsed from the DOM or added with
   * `insertLink`.
   *
   * @defaultValue []
   */
  supportedTargets?: LinkTarget[];
}

export interface LinkClickData extends GetMarkRange, LinkAttributes {}

export type LinkAttributes = ProsemirrorAttributes<{
  /**
   * The link which is a 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.
   *
   * @defaultValue false
   */
  auto?: boolean;

  /**
   * The target for the link..
   */
  target?: LinkTarget;
}>;

@extension<LinkOptions>({
  defaultOptions: {
    autoLink: false,
    defaultProtocol: '',
    selectTextOnClick: false,
    openLinkOnClick: false,
    autoLinkRegex: DEFAULT_AUTO_LINK_REGEX,
    autoLinkAllowedTLDs: TOP_50_TLDS,
    findAutoLinks: undefined,
    isValidUrl: undefined,
    defaultTarget: null,
    supportedTargets: [],
    extractHref,
  },
  staticKeys: ['autoLinkRegex'],
  handlerKeyOptions: { onClick: { earlyReturnValue: true } },
  handlerKeys: ['onActivateLink', 'onShortcut', 'onUpdateLink', 'onClick'],
  defaultPriority: ExtensionPriority.Medium,
})
export class LinkExtension extends MarkExtension<LinkOptions> {
  get name() {
    return 'link' as const;
  }

  /**
   * The autoLinkRegex option with the global flag removed, ensure no "lastIndex" state is maintained over multiple matches
   * @private
   */
  private _autoLinkRegexNonGlobal: RegExp | undefined = undefined;

  createTags() {
    return [ExtensionTag.Link, ExtensionTag.ExcludeInputRules];
  }

  createMarkSpec(extra: ApplySchemaAttributes, override: MarkSpecOverride): MarkExtensionSpec {
    const AUTO_ATTRIBUTE = 'data-link-auto';

    const getTargetObject = (target: string | null | undefined): { target: string } | undefined => {
      const { defaultTarget, supportedTargets } = this.options;
      const targets = defaultTarget ? [...supportedTargets, defaultTarget] : supportedTargets;
      return target && includes(targets, target) ? { target } : undefined;
    };

    return {
      inclusive: false,
      excludes: '_',
      ...override,
      attrs: {
        ...extra.defaults(),
        href: {},
        target: { default: this.options.defaultTarget },
        auto: { default: false },
      },
      parseDOM: [
        {
          tag: 'a[href]',
          getAttrs: (node) => {
            if (!isElementDomNode(node)) {
              return false;
            }

            const href = node.getAttribute('href');
            const text = node.textContent;

            // If link text content equals href value we "auto link"
            // e.g [test](//test.com) - not "auto link"
            // e.g [test.com](//test.com) - "auto link"
            const auto =
              this.options.autoLink &&
              (node.hasAttribute(AUTO_ATTRIBUTE) ||
                href === text ||
                href?.replace(`${this.options.defaultProtocol}//`, '') === text);

            return {
              ...extra.parse(node),
              href,
              auto,
              ...getTargetObject(node.getAttribute('target')),
            };
          },
        },
        ...(override.parseDOM ?? []),
      ],
      toDOM: (node) => {
        const { auto: _, target: __, ...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,
          ...getTargetObject(node.attrs.target),
        };

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

  onCreate(): void {
    const { autoLinkRegex } = this.options;
    // Remove the global flag from autoLinkRegex, and wrap in start (^) and end ($) terminator to test for exact match
    this._autoLinkRegexNonGlobal = new RegExp(
      `^${autoLinkRegex.source}$`,
      autoLinkRegex.flags.replace('g', ''),
    );
  }

  /**
   * Add a handler to the `onActivateLink` to capture when .
   */
  @keyBinding({ shortcut: NamedShortcut.InsertLink })
  shortcut({ tr }: KeyBindingProps): boolean {
    let selectedText = '';
    let { from, to, empty, $from } = tr.selection;
    let expandedSelection = false;
    const mark = getMarkRange($from, this.type);

    // When the selection is empty, expand it to the active mark
    if (empty) {
      const selectedWord = mark ?? getSelectedWord(tr);

      if (!selectedWord) {
        return false;
      }

      ({ text: selectedText, from, to } = selectedWord);
      expandedSelection = true;
    }

    if (from === to) {
      return false;
    }

    if (!expandedSelection) {
      selectedText = tr.doc.textBetween(from, to);
    }

    this.options.onActivateLink(selectedText);
    this.options.onShortcut({
      activeLink: mark
        ? { attrs: mark.mark.attrs as LinkAttributes, from: mark.from, to: mark.to }
        : undefined,
      selectedText,
      from,
      to,
    });

    return true;
  }

  /**
   * Create or update the link if it doesn't currently exist at the current
   * selection or provided range.
   */
  @command()
  updateLink(attrs: LinkAttributes, range?: FromToProps): CommandFunction {
    return (props) => {
      const { tr } = props;
      const selectionIsValid =
        (isTextSelection(tr.selection) && !isSelectionEmpty(tr.selection)) ||
        isAllSelection(tr.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 })(props);
    };
  }

  /**
   * Select the link at the current location.
   */
  @command()
  selectLink(): CommandFunction {
    return this.store.commands.selectMark.original(this.type);
  }

  /**
   * Remove the link at the current selection
   */
  @command()
  removeLink(range?: FromToProps): CommandFunction {
    return (props) => {
      const { tr } = props;

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

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

  /**
   * Create the paste rules that can transform a pasted link in the document.
   */
  createPasteRules(): MarkPasteRule[] {
    return [
      {
        type: 'mark',
        regexp: this.options.autoLinkRegex,
        markType: this.type,
        getAttributes: (url, isReplacement) => ({
          href: this.buildHref(getMatchString(url)),
          auto: !isReplacement,
        }),
        transformMatch: (match) => {
          const url = getMatchString(match);

          if (!url) {
            return false;
          }

          if (!this.isValidUrl(url)) {
            return false;
          }

          return url;
        },
      },
    ];
  }

  /**
   * 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;
        }

        // If editable is false, the openLinkOnClick handler or the selectTextOnClick handler should
        // not be triggered or it will conflict with the default browser event
        if (!this.store.view.editable) {
          return;
        }

        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;
      },
    };
  }

  createPlugin(): CreateExtensionPlugin {
    return {
      appendTransaction: (transactions, prevState, state: EditorState) => {
        const transactionsWithLinkMeta = transactions.filter((tr) => !!tr.getMeta(this.name));

        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);
          }
        });

        if (!this.options.autoLink) {
          return;
        }

        const isUndo = undoDepth(prevState) - undoDepth(state) === 1;

        if (isUndo) {
          return; // Don't execute auto link logic if an undo was performed.
        }

        const docChanged = transactions.some((tr) => tr.docChanged);

        if (!docChanged) {
          return; // Don't execute auto link logic if nothing has changed.
        }

        // Create a single transaction, by combining all transactions
        const composedTransaction = composeTransactionSteps(transactions, prevState);

        const changes = getChangedRanges(composedTransaction, [ReplaceAroundStep, ReplaceStep]);
        const { mapping } = composedTransaction;
        const { tr, doc } = state;

        const { updateLink, removeLink } = this.store.chain(tr);

        changes.forEach(({ prevFrom, prevTo, from, to }) => {
          // Store all the callbacks we need to make
          const onUpdateCallbacks: Array<Pick<EventMeta, 'range' | 'attrs'> & { text: string }> =
            [];

          // Check if node was split into two by `Enter` key press
          const isNodeSeparated = to - from === 2;

          // Get previous links
          const prevMarks = this.getLinkMarksInRange(prevState.doc, prevFrom, prevTo, true)
            .filter((item) => item.mark.type === this.type)
            .map(({ from, to, text }) => ({
              mappedFrom: mapping.map(from),
              mappedTo: mapping.map(to),
              text,
              from,
              to,
            }));

          // Check if links need to be removed or updated.
          prevMarks.forEach(
            ({ mappedFrom: newFrom, mappedTo: newTo, from: prevMarkFrom, to: prevMarkTo }, i) =>
              this.getLinkMarksInRange(doc, newFrom, newTo, true)
                .filter((item) => item.mark.type === this.type)
                .forEach((newMark) => {
                  const prevLinkText = prevState.doc.textBetween(
                    prevMarkFrom,
                    prevMarkTo,
                    undefined,
                    ' ',
                  );

                  const newLinkText = doc
                    .textBetween(newMark.from, newMark.to + 1, undefined, ' ')
                    .trim();

                  const wasLink = this.isValidUrl(prevLinkText);
                  const isLink = this.isValidUrl(newLinkText);

                  if (isLink) {
                    return;
                  }

                  if (wasLink) {
                    removeLink({ from: newMark.from, to: newMark.to }).tr();

                    prevMarks.splice(i, 1);
                  }

                  if (isNodeSeparated) {
                    return;
                  }

                  // If link characters have been deleted
                  from === to &&
                    // Check newLinkText for a remaining valid link
                    this.findAutoLinks(newLinkText)
                      .map((link) =>
                        this.addLinkProperties({
                          ...link,
                          from: newFrom + link.start,
                          to: newFrom + link.end,
                        }),
                      )
                      .forEach(({ attrs, range, text }) => {
                        updateLink(attrs, range).tr();

                        onUpdateCallbacks.push({ attrs, range, text });
                      });
                }),
          );

          // Find text that can be auto linked
          this.findTextBlocksInRange(doc, { from, to }).forEach(({ text, positionStart }) => {
            // Match links in text node
            this.findAutoLinks(text)
              .map((link) =>
                this.addLinkProperties({
                  ...link,
                  // Calculate link position.
                  from: positionStart + link.start + 1,
                  to: positionStart + link.end + 1,
                }),
              )
              // Check if link is within the changed range.
              .filter(({ range }) => {
                const fromIsInRange = from >= range.from && from <= range.to;
                const toIsInRange = to >= range.from && to <= range.to;

                return fromIsInRange || toIsInRange || isNodeSeparated;
              })
              // Avoid overwriting manually created links.
              .filter(
                ({ range }) =>
                  this.getLinkMarksInRange(tr.doc, range.from, range.to, false).length === 0,
              )
              // Prevent updating existing auto links
              .filter(
                ({ range: { from }, text }) =>
                  !prevMarks.some(
                    ({ text: prevMarkText, mappedFrom }) =>
                      mappedFrom === from && prevMarkText === text,
                  ),
              )
              .forEach(({ attrs, text, range }) => {
                updateLink(attrs, range).tr();

                onUpdateCallbacks.push({ attrs, range, text });
              });
          });

          window.requestAnimationFrame(() => {
            onUpdateCallbacks.forEach(({ attrs, range, text }) => {
              const { doc, selection } = tr;
              this.options.onUpdateLink(text, { attrs, doc, range, selection });
            });
          });
        });

        if (tr.steps.length === 0) {
          return;
        }

        return tr;
      },
    };
  }

  private buildHref(url: string): string {
    return this.options.extractHref({
      url,
      defaultProtocol: this.options.defaultProtocol,
    });
  }

  private getLinkMarksInRange(
    doc: ProsemirrorNode,
    from: number,
    to: number,
    isAutoLink: boolean,
  ): GetMarkRange[] {
    const linkMarks: GetMarkRange[] = [];

    if (from === to) {
      const resolveFrom = Math.max(from - 1, 0);

      const $pos = doc.resolve(resolveFrom);
      const range = getMarkRange($pos, this.type);

      if (range?.mark.attrs.auto === isAutoLink) {
        linkMarks.push(range);
      }
    } else {
      doc.nodesBetween(from, to, (node, pos) => {
        const marks = node.marks ?? [];
        const linkMark = marks.find(
          ({ type, attrs }) => type === this.type && attrs.auto === isAutoLink,
        );

        if (linkMark) {
          linkMarks.push({
            from: pos,
            to: pos + node.nodeSize,
            mark: linkMark,
            text: node.textContent,
          });
        }
      });
    }

    return linkMarks;
  }

  private findTextBlocksInRange(
    node: ProsemirrorNode,
    range: FromToProps,
  ): Array<{ text: string; positionStart: number }> {
    const nodesWithPos: NodeWithPosition[] = [];

    // define a placeholder for leaf nodes to calculate link position
    node.nodesBetween(range.from, range.to, (node, pos) => {
      if (!node.isTextblock || !node.type.allowsMarkType(this.type)) {
        return;
      }

      nodesWithPos.push({
        node,
        pos,
      });
    });

    return nodesWithPos.map((textBlock) => ({
      text: node.textBetween(
        textBlock.pos,
        textBlock.pos + textBlock.node.nodeSize,
        undefined,
        ' ',
      ),
      positionStart: textBlock.pos,
    }));
  }

  private addLinkProperties({
    from,
    to,
    href,
    ...link
  }: FoundAutoLink & FromToProps): LinkWithProperties {
    return {
      ...link,
      range: { from, to },
      attrs: { href, auto: true },
    };
  }

  private findAutoLinks(str: string): FoundAutoLink[] {
    if (this.options.findAutoLinks) {
      return this.options.findAutoLinks(str, this.options.defaultProtocol);
    }

    const toAutoLink: FoundAutoLink[] = [];

    for (const match of findMatches(str, this.options.autoLinkRegex)) {
      const text = getMatchString(match);

      if (!text) {
        continue;
      }

      const href = this.buildHref(text);

      if (!this.isValidTLD(href) && !href.startsWith('tel:')) {
        continue;
      }

      toAutoLink.push({
        text,
        href,
        start: match.index,
        end: match.index + text.length,
      });
    }

    return toAutoLink;
  }

  private isValidUrl(text: string): boolean {
    if (this.options.isValidUrl) {
      return this.options.isValidUrl(text, this.options.defaultProtocol);
    }

    return this.isValidTLD(this.buildHref(text)) && !!this._autoLinkRegexNonGlobal?.test(text);
  }

  private isValidTLD(str: string): boolean {
    const { autoLinkAllowedTLDs } = this.options;

    if (autoLinkAllowedTLDs.length === 0) {
      return true;
    }

    const domain = extractDomain(str);

    if (domain === '') {
      // Not a domain
      return true;
    }

    const tld = last<string>(domain.split('.'));

    return autoLinkAllowedTLDs.includes(tld);
  }
}

/**
 * Extract the `href` from the provided text.
 *
 * @remarks
 *
 * This will return the `url` text with a `${defaultProtocol}//` or `mailto:` prefix if needed.
 */
export function extractHref({
  url,
  defaultProtocol,
}: {
  url: string;
  defaultProtocol: DefaultProtocol;
}): string {
  const startsWithProtocol = /^((?:https?|ftp)?:)\/\//.test(url);

  // This isn't 100% precise because we allowed URLs without protocol
  // For example, userid@example.com could be email address or link http://userid@example.com
  const isEmail = !startsWithProtocol && url.includes('@');

  if (isEmail) {
    return `mailto:${url}`;
  }

  return startsWithProtocol ? url : `${defaultProtocol}//${url}`;
}

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