remirror/remirror

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

Summary

Maintainability
A
0 mins
Test Coverage
D
61%
import {
  command,
  CommandFunction,
  CreateExtensionPlugin,
  EditorState,
  extension,
  ExtensionTag,
  FragmentStringHandlerOptions,
  Helper,
  helper,
  InsertNodeOptions,
  NodeStringHandlerOptions,
  PlainExtension,
  ProsemirrorNode,
  Slice,
  Static,
  StringHandlerOptions,
} from '@remirror/core';
import { DOMSerializer, Fragment } from '@remirror/pm/model';

import { htmlToMarkdown } from './html-to-markdown';
import { markdownToHtml } from './markdown-to-html';

export interface MarkdownOptions {
  /**
   * Converts the provided html to a markdown string.
   *
   * By default this uses
   */
  htmlToMarkdown?: Static<(html: string) => string>;

  /**
   * Takes a markdown string and outputs html. It is up to you to make sure the
   * markdown is sanitized during this function call by providing the
   * `sanitizeHtml` method.
   */
  markdownToHtml?: Static<(markdown: string, sanitizer?: (html: string) => string) => string>;

  /**
   * Provide a sanitizer to prevent XSS attacks. Remirror does not provide any
   * sanitization by default.
   */
  htmlSanitizer?: Static<(html: string) => string>;

  /**
   * The parent nodes (or tags) where the markdown extension shortcuts are
   * active.
   *
   * @defaultValue ['code']
   *
   * TODO implement keyboard shortcuts when within an activeNode for the
   * markdown extension.
   */
  activeNodes?: string[];

  /**
   * Set this to `true` to copy the values from the text editor as markdown.
   * This means that when pasting into a plain text editor (vscode) for example,
   * the markdown will be preserved.
   *
   * @defaultValue false
   */
  copyAsMarkdown?: boolean;
}

/**
 * This extension adds support for markdown editors using remirror.
 *
 * TODO - when presets are removed automatically include all the supported
 * extensions.
 *
 * This extension adds the following to the `ManagerStore`.
 *
 * - `getMarkdown()` - extract the markdown representation from the editor.
 *
 * Future features
 *
 * - [ ] Add markdown specific commands which add the markdown syntax to the
 *   text content.
 */
@extension<MarkdownOptions>({
  defaultOptions: {
    htmlToMarkdown,
    markdownToHtml,
    htmlSanitizer: undefined,
    activeNodes: [ExtensionTag.Code],
    copyAsMarkdown: false,
  },
  staticKeys: ['htmlToMarkdown', 'markdownToHtml', 'htmlSanitizer'],
})
export class MarkdownExtension extends PlainExtension<MarkdownOptions> {
  get name() {
    return 'markdown' as const;
  }

  /**
   * Add the `markdown` string handler and `getMarkdown` state helper method.
   */
  onCreate(): void {
    this.store.setStringHandler('markdown', this.markdownToProsemirrorNode.bind(this));
  }

  createPlugin(): CreateExtensionPlugin {
    const clipboardTextSerializer = this.options.copyAsMarkdown
      ? (slice: Slice) => {
          const wrapper = document.createElement('div');
          const serializer = DOMSerializer.fromSchema(this.store.schema);
          wrapper.append(serializer.serializeFragment(slice.content));

          // Here we take the sliced text and transform it into markdown.
          return this.options.htmlToMarkdown(wrapper.innerHTML);
        }
      : undefined;

    return {
      props: {
        clipboardTextSerializer,
      },
    };
  }

  /**
   * Convert the markdown to a prosemirror node.
   */
  private markdownToProsemirrorNode(options: FragmentStringHandlerOptions): Fragment;
  private markdownToProsemirrorNode(options: NodeStringHandlerOptions): ProsemirrorNode;
  private markdownToProsemirrorNode(options: StringHandlerOptions): ProsemirrorNode | Fragment {
    return this.store.stringHandlers.html({
      ...(options as NodeStringHandlerOptions),
      content: this.options.markdownToHtml(options.content, this.options.htmlSanitizer),
    });
  }

  /**
   * Insert a markdown string as a ProseMirror Node.
   *
   * ```ts
   * commands.insertMarkdown('# Heading\nAnd content');
   * // => <h1 id="heading">Heading</h1><p>And content</p>
   * ```
   *
   * The content will be inlined by default if not a block node.
   *
   * ```ts
   * commands.insertMarkdown('**is bold.**')
   * // => <strong>is bold.</strong>
   * ```
   *
   * To always wrap the content in a block you can pass the following option.
   *
   * ```ts
   * commands.insertMarkdown('**is bold.**', { alwaysWrapInBlock: true });
   * // => <p><strong>is bold.</strong></p>
   * ```
   */
  @command()
  insertMarkdown(
    markdown: string,
    options?: InsertNodeOptions & { alwaysWrapInBlock?: boolean },
  ): CommandFunction {
    return (props) => {
      const { state } = props;
      let html = this.options.markdownToHtml(markdown, this.options.htmlSanitizer);

      html =
        !options?.alwaysWrapInBlock && html.startsWith('<p><') && html.endsWith('</p>\n')
          ? html.slice(3, -5)
          : `<div>${html}</div>`;

      const fragment = this.store.stringHandlers.html({
        content: html,
        schema: state.schema,
        fragment: true,
      });

      return this.store.commands.insertNode.original(fragment, {
        ...options,
        replaceEmptyParentBlock: true,
      })(props);
    };
  }

  /**
   * Get the markdown content from the current document.
   *
   * @param state - the state provided to the `getMarkdown` method.
   */
  @helper()
  getMarkdown(state?: EditorState): Helper<string> {
    return this.options.htmlToMarkdown(this.store.helpers.getHTML(state));
  }

  /**
   * TODO add commands for plain text markdown
   * @notimplemented
   *
   * @internal
   */
  @command()
  toggleBoldMarkdown(): CommandFunction {
    return (_) => false;
  }
}

declare global {
  namespace Remirror {
    interface StringHandlers {
      /**
       * Register the markdown string handler..
       */
      markdown: MarkdownExtension;
    }

    interface AllExtensions {
      markdown: MarkdownExtension;
    }
  }
}