remirror/remirror

View on GitHub
packages/remirror__core/src/commands.ts

Summary

Maintainability
A
0 mins
Test Coverage
C
73%
import { ErrorConstant } from '@remirror/core-constants';
import {
  assertGet,
  entries,
  invariant,
  isFunction,
  isPromise,
  isString,
} from '@remirror/core-helpers';
import type {
  AttributesProps,
  CommandFunction,
  CommandFunctionProps,
  FromToProps,
  MarkType,
  MarkTypeProps,
  PrimitiveSelection,
  ProsemirrorAttributes,
  ProsemirrorNode,
} from '@remirror/core-types';
import { convertCommand, getCursor, getTextSelection, isMarkActive } from '@remirror/core-utils';
import { toggleMark as originalToggleMark } from '@remirror/pm/commands';
import type { SelectionRange } from '@remirror/pm/state';

/**
 * The parameter that is passed into `DelayedCommand`s.
 */
interface DelayedCommandProps<Value> {
  /**
   * Runs as soon as the command is triggered. For most delayed commands within
   * the `remirror` codebase this is used to add a position tracker to the
   * document.
   */
  immediate?: CommandFunction;

  /**
   * The promise that provides the value that the `onDone` callback uses to
   * complete the delayed command.
   */
  promise: DelayedValue<Value>;

  /**
   * Called when the provided promise resolves.
   */
  onDone: CommandFunction<{ value: Value }>;

  /**
   * Called when the promise fails. This could be used to cleanup the active
   * position trackers when the delayed command fails.
   */
  onFail?: CommandFunction;
}

export type DelayedValue<Type> = Promise<Type> | (() => Promise<Type>);

/**
 * Returns `true` when the provided value is a delayed value.
 */
export function isDelayedValue<Type>(value: unknown): value is DelayedValue<Type> {
  return isFunction(value) || isPromise(value);
}

/**
 * Add tentative support for delayed commands in the editor.
 *
 * Delayed commands are commands that run an immediate action, like adding a
 * tracker to a position in the document. Once the promise that is provided is
 * returned the `onDone` parameter is run with the document in the current
 * state. The tracker that was added can now be used to insert content, delete
 * content or replace content.
 *
 * @experimental This is still being worked on and the API is subject to changes
 * in structure going forward.
 *
 * @deprecated use [[`DelayedCommand`]] instead.
 *
 */
export function delayedCommand<Value>({
  immediate,
  promise,
  onDone,
  onFail,
}: DelayedCommandProps<Value>): CommandFunction {
  return (props) => {
    const { view } = props;

    if (immediate?.(props) === false) {
      return false;
    }

    if (!view) {
      return true;
    }

    const deferred = isFunction(promise) ? promise() : promise;

    deferred
      .then((value) => {
        // Run the command
        onDone({ state: view.state, tr: view.state.tr, dispatch: view.dispatch, view, value });
      })
      .catch(() => {
        // Run the failure command if it exists.
        onFail?.({ state: view.state, tr: view.state.tr, dispatch: view.dispatch, view });
      });

    return true;
  };
}

export type DelayedPromiseCreator<Value> = (props: CommandFunctionProps) => Promise<Value>;

export class DelayedCommand<Value> {
  private readonly failureHandlers: Array<CommandFunction<{ error: any }>> = [];
  private readonly successHandlers: Array<CommandFunction<{ value: Value }>> = [];
  private readonly validateHandlers: CommandFunction[] = [];

  constructor(private readonly promiseCreator: DelayedPromiseCreator<Value>) {}

  /**
   * The commands that will immediately be run and used to evaluate whether to
   * proceed.
   */
  validate(handler: CommandFunction, method: 'push' | 'unshift' = 'push'): this {
    this.validateHandlers[method](handler);
    return this;
  }

  /**
   * Add a success callback to the handler.
   */
  success(handler: CommandFunction<{ value: Value }>, method: 'push' | 'unshift' = 'push'): this {
    this.successHandlers[method](handler);
    return this;
  }

  /**
   * Add a failure callback to the handler.
   */
  failure(handler: CommandFunction<{ error: any }>, method: 'push' | 'unshift' = 'push'): this {
    this.failureHandlers[method](handler);
    return this;
  }

  private runHandlers<Param extends CommandFunctionProps>(
    handlers: Array<(params: Param) => boolean>,
    param: Param,
  ): void {
    for (const handler of handlers) {
      if (!handler({ ...param, dispatch: () => {} })) {
        break;
      }
    }

    param.dispatch?.(param.tr);
  }

  /**
   * Generate the `remirror` command.
   */
  readonly generateCommand = (): CommandFunction => (props) => {
    let isValid = true;
    const { view, tr, dispatch } = props;

    if (!view) {
      return false;
    }

    for (const handler of this.validateHandlers) {
      if (!handler({ ...props, dispatch: () => {} })) {
        isValid = false;
        break;
      }
    }

    if (!dispatch || !isValid) {
      return isValid;
    }

    // Start the promise.
    const deferred = this.promiseCreator(props);

    deferred
      .then((value) => {
        this.runHandlers(this.successHandlers, {
          value,
          state: view.state,
          tr: view.state.tr,
          dispatch: view.dispatch,
          view,
        });
      })
      .catch((error) => {
        this.runHandlers(this.failureHandlers, {
          error,
          state: view.state,
          tr: view.state.tr,
          dispatch: view.dispatch,
          view,
        });
      });

    dispatch(tr);
    return true;
  };
}

export interface ToggleMarkProps extends MarkTypeProps, Partial<AttributesProps> {
  /**
   * @deprecated use `selection` property instead.
   */
  range?: FromToProps;

  /**
   * The selection point for toggling the chosen mark.
   */
  selection?: PrimitiveSelection;
}

/**
 * A custom `toggleMark` function that works for the `remirror` codebase.
 *
 * Create a command function that toggles the given mark with the given
 * attributes. Will return `false` when the current selection doesn't support
 * that mark. This will remove the mark if any marks of that type exist in the
 * selection, or add it otherwise. If the selection is empty, this applies to
 * the [stored marks](#state.EditorState.storedMarks) instead of a range of the
 * document.
 *
 * The differences from the `prosemirror-commands` version.
 * - Acts on the transaction rather than the state to allow for commands to be
 *   chained together.
 * - Uses the ONE parameter function signature for compatibility with remirror.
 * - Supports passing a custom range.
 */
export function toggleMark(props: ToggleMarkProps): CommandFunction {
  const { type, attrs, range, selection } = props;

  return (props) => {
    const { dispatch, tr, state } = props;
    const markType = isString(type) ? state.schema.marks[type] : type;

    invariant(markType, {
      code: ErrorConstant.SCHEMA,
      message: `Mark type: ${type} does not exist on the current schema.`,
    });

    if (range || selection) {
      const { from, to } = getTextSelection(selection ?? range ?? tr.selection, tr.doc);
      isMarkActive({ trState: tr, type, ...range })
        ? dispatch?.(tr.removeMark(from, to, markType))
        : dispatch?.(tr.addMark(from, to, markType.create(attrs)));

      return true;
    }

    return convertCommand(originalToggleMark(markType, attrs))(props);
  };
}

/**
 * Verifies that the mark type can be applied to the current document.
 */
function markApplies(type: MarkType, doc: ProsemirrorNode, ranges: readonly SelectionRange[]) {
  for (const { $from, $to } of ranges) {
    let markIsAllowed = $from.depth === 0 ? doc.type.allowsMarkType(type) : false;

    doc.nodesBetween($from.pos, $to.pos, (node) => {
      if (markIsAllowed) {
        // This prevents diving deeper into child nodes.
        return false;
      }

      markIsAllowed = node.inlineContent && node.type.allowsMarkType(type);
      return;
    });

    if (markIsAllowed) {
      return true;
    }
  }

  return false;
}

/**
 * Apply the provided mark type and attributes.
 *
 * @param markType - the mark to apply.
 * @param attrs - the attributes to set on the applied mark.
 * @param selectionPoint - optionally specify where the mark should be applied.
 * Defaults to the current selection.
 */
export function applyMark(
  type: string | MarkType,
  attrs?: ProsemirrorAttributes,
  selectionPoint?: PrimitiveSelection,
): CommandFunction {
  return ({ tr, dispatch, state }) => {
    const selection = getTextSelection(selectionPoint ?? tr.selection, tr.doc);
    const $cursor = getCursor(selection);

    const markType = isString(type) ? state.schema.marks[type] : type;

    invariant(markType, {
      code: ErrorConstant.SCHEMA,
      message: `Mark type: ${type} does not exist on the current schema.`,
    });

    if ((selection.empty && !$cursor) || !markApplies(markType, tr.doc, selection.ranges)) {
      return false;
    }

    if (!dispatch) {
      return true;
    }

    if ($cursor) {
      tr.removeStoredMark(markType);

      if (attrs) {
        tr.addStoredMark(markType.create(attrs));
      }

      dispatch(tr);
      return true;
    }

    let containsMark = false;

    for (const { $from, $to } of selection.ranges) {
      if (containsMark) {
        break;
      }

      containsMark = tr.doc.rangeHasMark($from.pos, $to.pos, markType);
    }

    for (const { $from, $to } of selection.ranges) {
      if (containsMark) {
        tr.removeMark($from.pos, $to.pos, markType);
      }

      if (attrs) {
        tr.addMark($from.pos, $to.pos, markType.create(attrs));
      }
    }

    dispatch(tr);

    return true;
  };
}

export interface InsertTextOptions extends Partial<FromToProps> {
  /**
   * Marks can be added to the inserted text.
   */
  marks?: Record<string, ProsemirrorAttributes>;
}

/**
 * Insert text into the dom at the current location by default. If a promise is
 * provided then the text will be inserted at the tracked position when the
 * promise is resolved.
 */
export function insertText(text: string, options: InsertTextOptions = {}): CommandFunction {
  return ({ tr, dispatch, state }) => {
    const schema = state.schema;
    const selection = tr.selection;
    const { from = selection.from, to = from ?? selection.to, marks = {} } = options;

    if (!dispatch) {
      return true;
    }

    // Insert the text
    tr.insertText(text, from, to);

    // Map the end position after inserting the text to understand what needs to
    // be wrapped with a mark.
    const end = assertGet(tr.steps, tr.steps.length - 1)
      .getMap()
      .map(to);

    // Loop through the provided marks to add the mark to the selection. This
    // uses the order of the map you created. If any marks are exclusive, they
    // will override the previous.
    for (const [markName, attributes] of entries(marks)) {
      tr.addMark(from, end, assertGet(schema.marks, markName).create(attributes));
    }

    dispatch(tr);

    return true;
  };
}