remirror/remirror

View on GitHub
packages/remirror__core/src/builtins/helpers-extension.ts

Summary

Maintainability
A
0 mins
Test Coverage
B
80%
import { ErrorConstant, NULL_CHARACTER } from '@remirror/core-constants';
import { entries, isEmptyObject, object } from '@remirror/core-helpers';
import type {
  AnyFunction,
  CommandFunction,
  EditorState,
  EditorStateProps,
  EmptyShape,
  Fragment,
  LiteralUnion,
  ProsemirrorAttributes,
  ProsemirrorNode,
  RemirrorJSON,
  Shape,
  StateJSON,
} from '@remirror/core-types';
import {
  containsAttributes,
  FragmentStringHandlerOptions,
  getActiveNode,
  getMarkRange,
  htmlToProsemirrorNode,
  isMarkActive,
  isNodeActive,
  isSelectionEmpty,
  NodeStringHandlerOptions,
  prosemirrorNodeToHtml,
  StringHandlerOptions,
} from '@remirror/core-utils';

import {
  ActiveFromExtensions,
  AnyExtension,
  AttrsFromExtensions,
  extension,
  Helper,
  HelperNames,
  HelpersFromExtensions,
  isMarkExtension,
  isNodeExtension,
  PlainExtension,
} from '../extension';
import { throwIfNameNotUnique } from '../helpers';
import type { ExtensionHelperReturn } from '../types';
import { command, helper, HelperDecoratorOptions } from './builtin-decorators';
import { InsertNodeOptions } from './commands-extension';

/**
 * Helpers are custom methods that can provide extra functionality to the
 * editor.
 *
 * @remarks
 *
 * They can be used for pulling information from the editor or performing custom
 * async commands.
 *
 * Also provides the default helpers used within the extension.
 *
 * @category Builtin Extension
 */
@extension({})
export class HelpersExtension extends PlainExtension {
  get name() {
    return 'helpers' as const;
  }

  /**
   * Add the `html` and `text` string handlers to the editor.
   */
  onCreate(): void {
    this.store.setStringHandler('text', this.textToProsemirrorNode.bind(this));
    this.store.setStringHandler('html', htmlToProsemirrorNode);

    const helpers: Record<string, AnyFunction> = object();
    const active: Record<string, AnyFunction> = object();
    const attrs: Record<string, AnyFunction> = object();
    const names = new Set<string>();

    for (const extension of this.store.extensions) {
      if (isNodeExtension(extension)) {
        active[extension.name] = (attrs?: ProsemirrorAttributes) =>
          isNodeActive({ state: this.store.getState(), type: extension.type, attrs });

        attrs[extension.name] = (attrs?: ProsemirrorAttributes) =>
          getActiveNode({ state: this.store.getState(), type: extension.type, attrs })?.node.attrs;
      }

      if (isMarkExtension(extension)) {
        active[extension.name] = (attrs?: ProsemirrorAttributes) =>
          isMarkActive({ trState: this.store.getState(), type: extension.type, attrs });

        attrs[extension.name] = (attrs?: ProsemirrorAttributes) => {
          const markRange = getMarkRange(this.store.getState().selection.$from, extension.type);

          if (!markRange || !attrs) {
            return markRange?.mark.attrs;
          }

          if (containsAttributes(markRange.mark, attrs)) {
            return markRange.mark.attrs;
          }

          return;
        };
      }

      const extensionHelpers = extension.createHelpers?.() ?? {};

      for (const helperName of Object.keys(extension.decoratedHelpers ?? {})) {
        extensionHelpers[helperName] = (extension as Shape)[helperName].bind(extension);
      }

      if (isEmptyObject(extensionHelpers)) {
        continue;
      }

      for (const [name, helper] of entries(extensionHelpers)) {
        throwIfNameNotUnique({ name, set: names, code: ErrorConstant.DUPLICATE_HELPER_NAMES });
        helpers[name] = helper;
      }
    }

    this.store.setStoreKey('attrs', attrs);
    this.store.setStoreKey('active', active);
    this.store.setStoreKey('helpers', helpers as any);
    this.store.setExtensionStore('attrs', attrs);
    this.store.setExtensionStore('active', active);
    this.store.setExtensionStore('helpers', helpers as any);
  }

  /**
   * Check whether the selection is empty.
   */
  @helper()
  isSelectionEmpty(state: EditorState = this.store.getState()): Helper<boolean> {
    return isSelectionEmpty(state);
  }

  /*
   * Check if the document view is currently editable.
   */
  @helper()
  isViewEditable(state: EditorState = this.store.getState()): Helper<boolean> {
    return this.store.view.props.editable?.(state) ?? false;
  }

  /**
   * Get the full JSON output for the ProseMirror editor state object.
   */
  @helper()
  getStateJSON(state: EditorState = this.store.getState()): Helper<StateJSON> {
    return state.toJSON() as StateJSON;
  }

  /**
   * Get the JSON output for the main ProseMirror `doc` node.
   *
   * This can be used to persist data between sessions and can be passed as
   * content to the `initialContent` prop.
   */
  @helper()
  getJSON(state: EditorState = this.store.getState()): Helper<RemirrorJSON> {
    return state.doc.toJSON() as RemirrorJSON;
  }

  /**
   * @deprecated use `getJSON` instead.
   */
  @helper()
  getRemirrorJSON(state: EditorState = this.store.getState()): Helper<RemirrorJSON> {
    return this.getJSON(state);
  }

  /**
   * Insert a html string as a ProseMirror Node.
   *
   * @category Builtin Command
   */
  @command()
  insertHtml(html: string, options?: InsertNodeOptions): CommandFunction {
    return (props) => {
      const { state } = props;
      const fragment = htmlToProsemirrorNode({
        content: html,
        schema: state.schema,
        fragment: true,
      });

      return this.store.commands.insertNode.original(fragment, options)(props);
    };
  }

  /**
   * A method to get all the content in the editor as text. Depending on the
   * content in your editor, it is not guaranteed to preserve it 100%, so it's
   * best to test that it meets your needs before consuming.
   */
  @helper()
  getText({
    lineBreakDivider = '\n\n',
    state = this.store.getState(),
  }: GetTextHelperOptions = {}): Helper<string> {
    return state.doc.textBetween(0, state.doc.content.size, lineBreakDivider, NULL_CHARACTER);
  }

  @helper()
  getTextBetween(
    from: number,
    to: number,
    doc: ProsemirrorNode = this.store.getState().doc,
  ): Helper<string> {
    return doc.textBetween(from, to, '\n\n', NULL_CHARACTER);
  }

  /**
   * Get the html from the current state, or provide a custom state.
   */
  @helper()
  getHTML(state: EditorState = this.store.getState()): Helper<string> {
    return prosemirrorNodeToHtml(state.doc, this.store.document);
  }

  /**
   * Wrap the content in a pre tag to preserve whitespace and see what the
   * editor does with it.
   */
  private textToProsemirrorNode(options: FragmentStringHandlerOptions): Fragment;
  private textToProsemirrorNode(options: NodeStringHandlerOptions): ProsemirrorNode;
  private textToProsemirrorNode(options: StringHandlerOptions): ProsemirrorNode | Fragment {
    const content = `<pre>${options.content}</pre>`;

    return this.store.stringHandlers.html({ ...(options as NodeStringHandlerOptions), content });
  }
}

interface GetTextHelperOptions extends Partial<EditorStateProps> {
  /**
   * The divider used to separate text blocks.
   *
   * @defaultValue '\n\n'
   */
  lineBreakDivider?: string;
}

declare global {
  namespace Remirror {
    interface ManagerStore<Extension extends AnyExtension> {
      /**
       * The helpers provided by the extensions used.
       */
      helpers: HelpersFromExtensions<Extension>;

      /**
       * Check which nodes and marks are active under the current user
       * selection.
       *
       * ```ts
       * const { active } = manager.store;
       *
       * return active.bold() ? 'bold' : 'regular';
       * ```
       */
      active: ActiveFromExtensions<Extension>;

      /**
       * Get the attributes for the named node or mark from the current user
       * selection.
       *
       * ```ts
       * const { attrs } = manager.store;
       *
       * attrs.heading(); // => { id: 'i1238ha', level: 1 }
       * ```
       */
      attrs: AttrsFromExtensions<Extension>;
    }

    interface BaseExtension {
      /**
       * `ExtensionHelpers`
       *
       * This pseudo property makes it easier to infer Generic types of this
       * class.
       *
       * @internal
       */
      ['~H']: this['createHelpers'] extends AnyFunction
        ? ReturnType<this['createHelpers']>
        : EmptyShape;

      /**
       * @experimental
       *
       * Stores all the helpers that have been added via decorators to the
       * extension instance. This is used by the `HelpersExtension` to pick the
       * helpers.
       *
       * @internal
       */
      decoratedHelpers?: Record<string, HelperDecoratorOptions>;

      /**
       * A helper method is a function that takes in arguments and returns a
       * value depicting the state of the editor specific to this extension.
       *
       * @remarks
       *
       * Unlike commands they can return anything and may not effect the
       * behavior of the editor.
       *
       * Below is an example which should provide some idea on how to add
       * helpers to the app.
       *
       * ```tsx
       * // extension.ts
       * import { ExtensionFactory } from '@remirror/core';
       *
       * const MyBeautifulExtension = ExtensionFactory.plain({
       *   name: 'beautiful',
       *   createHelpers: () => ({
       *     checkBeautyLevel: () => 100
       *   }),
       * })
       * ```
       *
       * ```
       * // app.tsx
       * import { useRemirrorContext } from '@remirror/react';
       *
       * const MyEditor = () => {
       *   const { helpers } = useRemirrorContext({ autoUpdate: true });
       *
       *   return helpers.beautiful.checkBeautyLevel() > 50
       *     ? (<span>😍</span>)
       *     : (<span>😢</span>);
       * };
       * ```
       */
      createHelpers?(): ExtensionHelperReturn;
    }

    interface StringHandlers {
      /**
       * Register the plain `text` string handler which renders a text string
       * inside a `<pre />`.
       */
      text: HelpersExtension;

      /**
       * Register the html string handler, which converts a html string to a
       * prosemirror node.
       */
      html: HelpersExtension;
    }

    interface ExtensionStore {
      /**
       * Helper method to provide information about the content of the editor.
       * Each extension can register its own helpers.
       *
       * This should only be accessed after the `onView` lifecycle method
       * otherwise it will throw an error.
       */
      helpers: HelpersFromExtensions<Extensions>;

      /**
       * Check which nodes and marks are active under the current user
       * selection.
       *
       * ```ts
       * const { active } = manager.store;
       *
       * return active.bold() ? 'bold' : 'regular';
       * ```
       */
      active: ActiveFromExtensions<Extensions>;

      /**
       * Get the attributes for the named node or mark from the current user
       * selection.
       *
       * ```ts
       * const { attrs } = manager.store;
       *
       * attrs.heading(); // => { id: 'i1238ha', level: 1 }
       * ```
       */
      attrs: AttrsFromExtensions<Extensions>;
    }

    interface ListenerProperties<Extension extends AnyExtension> {
      helpers: HelpersFromExtensions<Extension>;
    }

    interface AllExtensions {
      helpers: HelpersExtension;
    }
  }

  /**
   * The helpers name for all extension defined in the current project.
   */
  type AllHelperNames = LiteralUnion<HelperNames<Remirror.Extensions>, string>;
}