remirror/remirror

View on GitHub
packages/remirror__core/src/extension/base-class.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
91%
/* eslint-disable @typescript-eslint/member-ordering */

import {
  __INTERNAL_REMIRROR_IDENTIFIER_KEY__,
  ErrorConstant,
  ExtensionPriority,
  RemirrorIdentifier,
} from '@remirror/core-constants';
import {
  deepMerge,
  invariant,
  isEmptyArray,
  isFunction,
  keys,
  noop,
  object,
  omit,
  sort,
} from '@remirror/core-helpers';
import type {
  AnyFunction,
  Dispose,
  EmptyShape,
  GetAcceptUndefined,
  GetConstructorProps,
  GetCustomHandler,
  GetFixed,
  GetFixedDynamic,
  GetHandler,
  GetMappedHandler,
  GetPartialDynamic,
  GetStatic,
  IfNoRequiredProperties,
  LiteralUnion,
  MakeUndefined,
  Primitive,
  RemoveAnnotations,
  Replace,
  Shape,
  StringKey,
  UndefinedFlipPartialAndRequired,
  ValidOptions,
} from '@remirror/core-types';
import { environment } from '@remirror/core-utils';

import { getChangedOptions } from '../helpers';
import type { OnSetOptionsProps } from '../types';

const IGNORE = '__IGNORE__';
const GENERAL_OPTIONS = '__ALL__' as const;

export abstract class BaseClass<
  Options extends ValidOptions = EmptyShape,
  DefaultStaticOptions extends Shape = EmptyShape,
> {
  /**
   * The default options for this extension.
   *
   * TODO see if this can be cast to something other than any and allow
   * composition.
   */
  static readonly defaultOptions: any = {};

  /**
   * The static keys for this class.
   */
  static readonly staticKeys: string[] = [];

  /**
   * The event handler keys.
   */
  static readonly handlerKeys: string[] = [];

  /**
   * Customize the way the handler should behave.
   */
  static handlerKeyOptions: Partial<
    Record<string, HandlerKeyOptions> & { [GENERAL_OPTIONS]?: HandlerKeyOptions }
  > = {};

  /**
   * The custom keys.
   */
  static readonly customHandlerKeys: string[] = [];

  /**
   * This is not for external use. It is purely here for TypeScript inference of
   * the generic `Options` type parameter.
   *
   * @internal
   */
  ['~O']: Options & DefaultStaticOptions = {} as Options & DefaultStaticOptions;

  /**
   * This identifies this as a `Remirror` object. .
   * @internal
   */
  abstract readonly [__INTERNAL_REMIRROR_IDENTIFIER_KEY__]: RemirrorIdentifier;

  /**
   * The unique name of this extension.
   *
   * @remarks
   *
   * Every extension **must** have a name. The name should have a distinct type
   * to allow for better type inference for end users. By convention the name
   * should be `camelCased` and unique within your editor instance.
   *
   * ```ts
   * class SimpleExtension extends Extension {
   *   get name() {
   *     return 'simple' as const;
   *   }
   * }
   * ```
   */
  abstract get name(): string;

  /**
   * The options for this extension.
   *
   * @remarks
   *
   * Options are composed of Static, Dynamic, Handlers and ObjectHandlers.
   *
   * - `Static` - set at instantiation by the constructor.
   * - `Dynamic` - optionally set at instantiation by the constructor and also
   *   set during the runtime.
   * - `Handlers` - can only be set during the runtime.
   * - `ObjectHandlers` - Can only be set during the runtime of the extension.
   */
  get options(): RemoveAnnotations<GetFixed<Options> & DefaultStaticOptions> {
    return this._options;
  }

  /**
   * Get the dynamic keys for this extension.
   */
  get dynamicKeys(): string[] {
    return this._dynamicKeys;
  }

  /**
   * The options that this instance was created with, merged with all the
   * default options.
   */
  get initialOptions(): RemoveAnnotations<GetFixed<Options> & DefaultStaticOptions> {
    return this._initialOptions;
  }

  /**
   * The initial options at creation (used to reset).
   */
  private readonly _initialOptions: RemoveAnnotations<GetFixed<Options> & DefaultStaticOptions>;

  /**
   * All the dynamic keys supported by this extension.
   */
  private readonly _dynamicKeys: string[];

  /**
   * Private instance of the extension options.
   */
  private _options: RemoveAnnotations<GetFixed<Options> & DefaultStaticOptions>;

  /**
   * The mapped function handlers.
   */
  private _mappedHandlers: GetMappedHandler<Options>;

  constructor(
    defaultOptions: DefaultStaticOptions,
    ...[options]: ConstructorProps<Options, DefaultStaticOptions>
  ) {
    this._mappedHandlers = object();
    this.populateMappedHandlers();

    this._options = this._initialOptions = deepMerge(
      defaultOptions,
      this.constructor.defaultOptions,
      options ?? object(),
      this.createDefaultHandlerOptions(),
    );

    this._dynamicKeys = this.getDynamicKeys();

    // Triggers the `init` options update for this extension.
    this.init();
  }

  /**
   * This method is called by the extension constructor. It is not strictly a
   * lifecycle method since at this point the manager has not yet been
   * instantiated.
   *
   * @remarks
   *
   * It should be used instead of overriding the constructor which is strongly
   * advised against.
   *
   * There are some limitations when using this method.
   *
   * - Accessing `this.store` will throw an error since the manager hasn't been
   *   created and it hasn't yet been attached to the extensions.
   * - `this.type` in `NodeExtension` and `MarkExtension` will also throw an
   *   error since the schema hasn't been created yet.
   *
   * You should use this to setup any instance properties with the options
   * provided to the extension.
   */
  protected init(): void {}

  /**
   * Clone the current instance with the provided options. If nothing is
   * provided it uses the same initial options as the current instance.
   */
  abstract clone(
    ...parameters: ConstructorProps<Options, DefaultStaticOptions>
  ): BaseClass<Options, DefaultStaticOptions>;

  /**
   * Get the dynamic keys for this extension.
   */
  private getDynamicKeys(): string[] {
    const dynamicKeys: string[] = [];
    const { customHandlerKeys, handlerKeys, staticKeys } = this.constructor;

    for (const key of keys(this._options)) {
      if (
        staticKeys.includes(key) ||
        handlerKeys.includes(key) ||
        customHandlerKeys.includes(key)
      ) {
        continue;
      }

      dynamicKeys.push(key);
    }

    return dynamicKeys;
  }

  /**
   * Throw an error if non dynamic keys are updated.
   */
  private ensureAllKeysAreDynamic(update: GetPartialDynamic<Options>) {
    if (environment.isProduction) {
      return;
    }

    const invalid: string[] = [];

    for (const key of keys(update)) {
      if (this._dynamicKeys.includes(key)) {
        continue;
      }

      invalid.push(key);
    }

    invariant(isEmptyArray(invalid), {
      code: ErrorConstant.INVALID_SET_EXTENSION_OPTIONS,
      message: `Invalid properties passed into the 'setOptions()' method: ${JSON.stringify(
        invalid,
      )}.`,
    });
  }

  /**
   * Update the properties with the provided partial value when changed.
   */
  setOptions(update: GetPartialDynamic<Options>): void {
    const previousOptions = this.getDynamicOptions();

    this.ensureAllKeysAreDynamic(update);

    const { changes, options, pickChanged } = getChangedOptions({
      previousOptions,
      update,
    });

    this.updateDynamicOptions(options);

    // Trigger the update handler so the extension can respond to any relevant
    // property updates.
    this.onSetOptions?.({
      reason: 'set',
      changes,
      options,
      pickChanged,
      initialOptions: this._initialOptions,
    });
  }

  /**
   * Reset the extension properties to their default values.
   *
   * @nonVirtual
   */
  resetOptions(): void {
    const previousOptions = this.getDynamicOptions();
    const { changes, options, pickChanged } = getChangedOptions<Options>({
      previousOptions,
      update: this._initialOptions,
    });

    this.updateDynamicOptions(options);

    // Trigger the update handler so that child extension properties can also be
    // updated.
    this.onSetOptions?.({
      reason: 'reset',
      options,
      changes,
      pickChanged,
      initialOptions: this._initialOptions,
    });
  }

  /**
   * Override this to receive updates whenever the options have been updated on
   * this instance. This method is called after the updates have already been
   * applied to the instance. If you need more control over exactly how the
   * option should be applied you should set the option to be `Custom`.
   *
   * **Please Note**:
   *
   * This must be defined as a instance method and not a property since it is
   * called in the constructor.
   *
   * ```ts
   * class ThisPreset extends Preset {
   *   // GOOD ✅
   *   onSetOptions(props: OnSetOptionsProps<Options>) {}
   *
   *    // BAD ❌
   *   onSetOptions = (props: OnSetOptionsProps<Options>) => {}
   * }
   * ```
   *
   * @abstract
   */
  protected onSetOptions?(props: OnSetOptionsProps<Options>): void;

  /**
   * Update the private options.
   */
  private getDynamicOptions(): GetFixedDynamic<Options> {
    return omit(this._options, [
      ...this.constructor.customHandlerKeys,
      ...this.constructor.handlerKeys,
    ]) as any;
  }

  /**
   * Update the dynamic options.
   */
  private updateDynamicOptions(options: GetFixedDynamic<Options>) {
    this._options = { ...this._options, ...options };
  }

  /**
   * Set up the mapped handlers object with default values (an empty array);
   */
  private populateMappedHandlers() {
    for (const key of this.constructor.handlerKeys) {
      this._mappedHandlers[key as keyof GetMappedHandler<Options>] = [];
    }
  }

  /**
   * This is currently fudged together, I'm not sure it will work.
   */
  private createDefaultHandlerOptions() {
    const methods = object<any>();

    for (const key of this.constructor.handlerKeys) {
      methods[key] = (...args: any[]) => {
        const { handlerKeyOptions } = this.constructor;
        const reducer = handlerKeyOptions[key]?.reducer;
        let returnValue: unknown = reducer?.getDefault(...args);

        for (const [, handler] of this._mappedHandlers[key as keyof GetMappedHandler<Options>]) {
          const value = (handler as unknown as AnyFunction)(...args);
          returnValue = reducer ? reducer.accumulator(returnValue, value, ...args) : value;

          // Check if the method should cause an early return, based on the
          // return value.
          if (shouldReturnEarly(handlerKeyOptions, returnValue, key)) {
            return returnValue;
          }
        }

        return returnValue;
      };
    }

    return methods;
  }

  /**
   * Add a handler to the event handlers so that it is called along with all the
   * other handler methods.
   *
   * This is helpful for integrating react hooks which can be used in multiple
   * places. The original problem with fixed properties is that you can only
   * assign to a method once and it overwrites any other methods. This pattern
   * for adding handlers allows for multiple usages of the same handler in the
   * most relevant part of the code.
   *
   * More to come on this pattern.
   *
   * @nonVirtual
   */
  addHandler<Key extends keyof GetHandler<Options>>(
    key: Key,
    method: GetHandler<Options>[Key],
    priority = ExtensionPriority.Default,
  ): Dispose {
    this._mappedHandlers[key].push([priority, method]);
    this.sortHandlers(key);

    // Return a method for disposing of the handler.
    return () =>
      (this._mappedHandlers[key] = this._mappedHandlers[key].filter(
        ([, handler]) => handler !== method,
      ));
  }

  /**
   * Determines if handlers exist for the given key.
   *
   * Checking the existence of a handler property directly gives wrong results.
   * `this.options.onHandlerName` is always truthy because it is a reference to
   * the wrapper function that calls each handler.
   *
   * ```ts
   *
   * // GOOD ✅
   * if (!this.hasHandlers('onHandlerName')) {
   *   return;
   * }
   *
   * // BAD ❌
   * if (!this.options.onHandlerName) {
   *   return;
   * }
   * ```
   *
   * @param key The handler to test
   */
  hasHandlers<Key extends keyof GetHandler<Options>>(key: Key): boolean {
    return (this._mappedHandlers[key] ?? []).length > 0;
  }

  private sortHandlers<Key extends keyof GetHandler<Options>>(key: Key) {
    this._mappedHandlers[key] = sort(
      this._mappedHandlers[key],
      // Sort from highest binding to the lowest.
      ([a], [z]) => z - a,
    );
  }

  /**
   * A method that can be used to add a custom handler. It is up to the
   * extension creator to manage the handlers and dispose methods.
   */
  addCustomHandler<Key extends keyof GetCustomHandler<Options>>(
    key: Key,
    value: Required<GetCustomHandler<Options>>[Key],
  ): Dispose {
    return this.onAddCustomHandler?.({ [key]: value } as any) ?? noop;
  }

  /**
   * Override this method if you want to set custom handlers on your extension.
   *
   * This must return a dispose function.
   */
  protected onAddCustomHandler?: AddCustomHandler<Options>;
}

type HandlerKeyOptionsMap = Partial<
  Record<string, HandlerKeyOptions> & { [GENERAL_OPTIONS]?: HandlerKeyOptions }
>;

/**
 * A function used to determine whether the value provided by the handler
 * warrants an early return.
 */
function shouldReturnEarly(
  handlerKeyOptions: HandlerKeyOptionsMap,
  returnValue: unknown,
  handlerKey: string,
): boolean {
  const { [GENERAL_OPTIONS]: generalOptions } = handlerKeyOptions;
  const handlerOptions = handlerKeyOptions[handlerKey];

  if (!generalOptions && !handlerOptions) {
    return false;
  }

  // First check if there are options set for the provided handlerKey
  if (
    handlerOptions &&
    // Only proceed if the value should not be ignored.
    handlerOptions.earlyReturnValue !== IGNORE &&
    (isFunction(handlerOptions.earlyReturnValue)
      ? handlerOptions.earlyReturnValue(returnValue) === true
      : returnValue === handlerOptions.earlyReturnValue)
  ) {
    return true;
  }

  if (
    generalOptions &&
    // Only proceed if they are not ignored.
    generalOptions.earlyReturnValue !== IGNORE &&
    // Check whether the `earlyReturnValue` is a predicate check.
    (isFunction(generalOptions.earlyReturnValue)
      ? // If it is a predicate and when called with the current
        // `returnValue` the value is `true` then we should return
        // early.
        generalOptions.earlyReturnValue(returnValue) === true
      : // Check the actual return value.
        returnValue === generalOptions.earlyReturnValue)
  ) {
    return true;
  }

  return false;
}

/**
 * @internal
 */
export type CustomHandlerMethod<Options extends ValidOptions> = <
  Key extends keyof GetCustomHandler<Options>,
>(
  key: Key,
  value: Required<GetCustomHandler<Options>>[Key],
) => Dispose;

export type AddCustomHandler<Options extends ValidOptions> = (
  props: Partial<GetCustomHandler<Options>>,
) => Dispose | undefined;

export type AddHandler<Options extends ValidOptions> = <Key extends keyof GetHandler<Options>>(
  key: Key,
  method: GetHandler<Options>[Key],
) => Dispose;

/**
 * TODO see if this is needed or remove.
 */
export type AddHandlers<Options extends ValidOptions> = (
  props: Partial<GetHandler<Options>>,
) => Dispose;

export interface HandlerKeyOptions<ReturnType = any, Args extends any[] = any[]> {
  /**
   * When this value is encountered the handler will exit early.
   *
   * Set the value to `'__IGNORE__'` to ignore the early return value.
   */
  earlyReturnValue?: LiteralUnion<typeof IGNORE, Primitive> | ((value: unknown) => boolean);

  /**
   * Allows combining the values from the handlers together to produce a single
   * reduced output value.
   */
  reducer?: {
    /**
     * Combine the value with the the previous value
     */
    accumulator: (accumulated: ReturnType, latestValue: ReturnType, ...args: Args) => ReturnType;

    /**
     * The a function that returns the default value for combined handler
     * values. This is required for setting up a default value.
     */
    getDefault: (...args: Args) => ReturnType;
  };
}

export interface BaseClass<
  Options extends ValidOptions,
  DefaultStaticOptions extends Shape = EmptyShape,
> {
  constructor: BaseClassConstructor<Options, DefaultStaticOptions>;
}

export interface BaseClassConstructor<
  Options extends ValidOptions = EmptyShape,
  DefaultStaticOptions extends Shape = EmptyShape,
> extends Function {
  new (...args: ConstructorProps<Options, DefaultStaticOptions>): any;

  /**
   * The identifier for the constructor which can determine whether it is a node
   * constructor, mark constructor or plain constructor.
   * @internal
   */
  readonly [__INTERNAL_REMIRROR_IDENTIFIER_KEY__]: RemirrorIdentifier;

  /**
   * Defines the `defaultOptions` for all extension instances.
   *
   * @remarks
   *
   * Once set it can't be updated during run time. Some of the settings are
   * optional and some are not. Any non-required settings must be specified in
   * the `defaultOptions`.
   *
   * **Please note**: There is a slight downside when setting up
   * `defaultOptions`. `undefined` is not supported for partial settings at this
   * point in time. As a workaround use `null` as the type and pass it as the
   * value in the default settings.
   *
   * @defaultValue {}
   *
   * @internal
   */
  readonly defaultOptions: DefaultOptions<Options, DefaultStaticOptions>;

  /**
   * An array of the keys that are static for this extension.
   *
   * This is actually currently unused, but might become useful in the future.
   * An auto-fix lint rule will be added should that be the case.
   */
  readonly staticKeys: string[];

  /**
   * An array of all the keys which correspond to the the event handler options.
   *
   * This **MUST** be present if you want to use event handlers in your
   * extension.
   *
   * Every key here is automatically removed from the `setOptions` method and is
   * added to the `addHandler` method for adding new handlers. The
   * `this.options[key]` is automatically replaced with a method that combines
   * all the handlers into one method that can be called effortlessly. All this
   * work is done for you.
   */
  readonly handlerKeys: string[];

  /**
   * Customize the way the handler should behave.
   */
  readonly handlerKeyOptions: Partial<
    Record<string, HandlerKeyOptions> & { __ALL__?: HandlerKeyOptions }
  >;

  /**
   * A list of the custom keys in the extension or preset options.
   */
  readonly customHandlerKeys: string[];
}

export type AnyBaseClassConstructor = Replace<
  BaseClassConstructor<any, any>,
  // eslint-disable-next-line @typescript-eslint/prefer-function-type
  { new (...args: any[]): AnyFunction }
>;

/* eslint-enable @typescript-eslint/member-ordering */

/**
 * Auto infers the parameter for the constructor. If there is a required static
 * option then the TypeScript compiler will error if nothing is passed in.
 */
export type ConstructorProps<
  Options extends ValidOptions,
  DefaultStaticOptions extends Shape,
> = IfNoRequiredProperties<
  GetStatic<Options>,
  [options?: GetConstructorProps<Options> & DefaultStaticOptions],
  [options: GetConstructorProps<Options> & DefaultStaticOptions]
>;

/**
 * Get the expected type signature for the `defaultOptions`. Requires that every
 * optional setting key (except for keys which are defined on the
 * `BaseExtensionOptions`) has a value assigned.
 */
export type DefaultOptions<
  Options extends ValidOptions,
  DefaultStaticOptions extends Shape,
> = MakeUndefined<
  UndefinedFlipPartialAndRequired<GetStatic<Options>> &
    Partial<DefaultStaticOptions> &
    GetFixedDynamic<Options>,
  StringKey<GetAcceptUndefined<Options>>
>;

export interface AnyBaseClassOverrides {
  addCustomHandler: AnyFunction;
  addHandler: AnyFunction;
  clone: AnyFunction;
}