remirror/remirror

View on GitHub
packages/remirror__extension-positioner/src/positioner.ts

Summary

Maintainability
A
0 mins
Test Coverage
import { createNanoEvents, Unsubscribe } from 'nanoevents';
import {
  AnyFunction,
  EditorState,
  EditorViewProps,
  ErrorConstant,
  Except,
  invariant,
  isFunction,
  StateUpdateLifecycleProps,
} from '@remirror/core';
import type { HoverEventHandlerState, MouseEventHandlerState } from '@remirror/extension-events';

/**
 * The events that can trigger a positioner update.
 */
export type PositionerUpdateEvent = 'scroll' | 'state' | 'hover' | 'contextmenu';

export interface Rect {
  /**
   * Pixel distance from left of the reference frame.
   * Alias of `left`.
   */
  x: number;

  /**
   * Pixel distance from top of the reference frame.
   * Alias of `top` for css.
   */
  y: number;

  /**
   * The height of the captured position.
   */
  height: number;

  /**
   * The width of the captured position.
   */
  width: number;
}

/**
 * The absolutely positioned coordinates relative to the editor element. With
 * these coordinates you can perfectly simulate a position within the text
 * editor and render it as you decide.
 */
export interface PositionerPosition extends Rect {
  /**
   * The position relative to the document viewport. This can be used with
   * `position: fixed` when that is a better fit for your application.
   */
  rect: DOMRect;

  /**
   * True when any part of the captured position is visible within the dom view.
   */
  visible: boolean;
}

export interface GetPositionProps<Data> extends EditorViewProps, BasePositionerProps {
  /**
   * The data that can be transformed into a position.
   */
  data: Data;

  /**
   * The reference element being used by the positioner to determine
   * positioning.
   */
  element: HTMLElement;
}

export interface GetActiveProps extends EditorViewProps, BasePositionerProps {}

export interface BasePositioner<Data> {
  /**
   * Determines whether anything has changed and whether to continue with a
   * recalculation. By default this is only true when the document has or
   * selection has changed.
   *
   * @remarks
   *
   * Sometimes it is useful to recalculate the positioner on every state update.
   * In this case you can set this method to always return true.
   *
   * ```ts
   * const positioner: Positioner = {
   *   hasStateChanged: () => true
   * };
   * ```
   */
  hasChanged: (props: BasePositionerProps) => boolean;

  /**
   * Get a unique id for the data returned from `getActive`.
   *
   * If left undefined, it defaults to use the index.
   */
  getID?: (data: Data, index: number) => string;

  /**
   * Get the active items that will be passed into the `getPosition` method.
   */
  getActive: (props: GetActiveProps) => Data[];

  /**
   * Calculate and return an array of `VirtualPosition`'s which represent the
   * virtual element the positioner represents.
   */
  getPosition: (props: GetPositionProps<Data>) => PositionerPosition;

  /**
   * An array of update listeners to determines when the positioner will update it's position.
   *
   * - `state` - updates when the prosemirror state is updated - default.
   * - `scroll` - updates when the editor is scrolled (debounced)
   *
   * @defaultValue ['state']
   */
  events?: PositionerUpdateEvent[];
}

export interface SetActiveElement<Data = any> {
  /**
   * Set the html element for the active position.
   */
  setElement: (element: HTMLElement) => void;

  /**
   * The unique ide for the active element.
   */
  id: string;

  data: Data;
}

export interface BasePositionerProps extends Omit<StateUpdateLifecycleProps, 'previousState'> {
  helpers: Record<string, AnyFunction>;

  previousState: undefined | EditorState;

  /**
   * The event that triggered this update.
   */
  event: PositionerUpdateEvent;

  /**
   * The scroll event information.
   */
  scroll?: {
    scrollTop: number;
  };

  /**
   * The hover event information. This is only present when the update was
   * triggered by a hover event.
   */
  hover?: Except<HoverEventHandlerState, 'view'>;

  /**
   * The contextmenu event information. This is only present when the update was
   * triggered by a contextmenu event.
   */
  contextmenu?: Except<MouseEventHandlerState, 'view'>;
}

export interface ElementsAddedProps {
  position: PositionerPosition;
  element: HTMLElement;
  id: string;
}

interface PositionerEvents<Data = any> {
  /**
   * Called when the dom elements have all been received. In some frameworks
   * like `React` this may be called asynchronously.
   */
  done: (props: ElementsAddedProps[]) => void;

  /**
   * Called when the active values have been updated.
   */
  update: (elementSetters: Array<SetActiveElement<Data>>) => void;
}

/**
 * This is the positioner. It exists to report the position of things in the
 * editor. Typically you will use it to get the position of the cursor.
 *
 * But you can be more ambitious and get the position all the active nodes of a
 * certain type. Or all visible nodes of a certain type in the editor, updated
 * as it scrolls.
 *
 * The positions returned have a rect which is the viewport position.
 *
 * There are also the `top`, `left`, `right`, `bottom` which represent the
 * absolute positioned rectangle of the position in questions. For a cursor
 * position `left` and `right` are probably the same.
 */
export class Positioner<Data = any> {
  /**
   * An empty return value for the positioner.
   */
  static EMPTY: never[] = [];

  /**
   * Create a positioner.
   */
  static create<Data>(props: BasePositioner<Data>): Positioner<Data> {
    return new Positioner(props);
  }

  /**
   * Create a positioner from an existing positioner.
   *
   * This is useful when you want to modify parts of the positioner.
   */
  static fromPositioner<Data>(
    positioner: Positioner,
    base: Partial<BasePositioner<Data>>,
  ): Positioner<Data> {
    return Positioner.create({ ...positioner.basePositioner, ...base });
  }

  readonly events: PositionerUpdateEvent[];

  #handler = createNanoEvents<PositionerEvents<Data>>();
  #active: Data[] = [];
  #props: Map<number, GetPositionProps<Data>> = new Map();
  #ids: string[] = [];
  #updated = false;

  /**
   * Store the props for the most recent update. This is used by `React` to
   * reapply the most recent props to the new positioner when the positioner is
   * recreated within a component.
   */
  recentUpdate?: GetActiveProps;

  readonly #constructorProps: BasePositioner<Data>;
  readonly #getActive: BasePositioner<Data>['getActive'];
  readonly #getID?: (data: Data, index: number) => string;
  readonly #getPosition: BasePositioner<Data>['getPosition'];
  readonly hasChanged: (props: BasePositionerProps) => boolean;

  get basePositioner(): BasePositioner<Data> {
    return {
      getActive: this.#getActive,
      getPosition: this.#getPosition,
      hasChanged: this.hasChanged,
      events: this.events,
      getID: this.#getID,
    };
  }

  private constructor(props: BasePositioner<Data>) {
    this.#constructorProps = props;
    this.#getActive = props.getActive;
    this.#getPosition = props.getPosition;
    this.#getID = props.getID;
    this.hasChanged = props.hasChanged;
    this.events = props.events ?? ['state', 'scroll'];
  }

  /**
   * Get the active element setters.
   */
  onActiveChanged(props: GetActiveProps): void {
    this.recentUpdate = props;
    const active = this.#getActive(props);
    this.#active = active;
    this.#props = new Map();
    this.#updated = false;
    this.#ids = [];

    const elementSetters: Array<SetActiveElement<Data>> = [];

    for (const [index, data] of active.entries()) {
      const id = this.getID(data, index);
      this.#ids.push(id);

      elementSetters.push({
        setElement: (element: HTMLElement) => this.addProps({ ...props, data, element }, index),
        id,
        data,
      });
    }

    this.#handler.emit('update', elementSetters);
  }

  /**
   * Get the id for the active data. Defaults to the index of the data item.
   */
  getID(data: Data, index: number): string {
    return this.#getID?.(data, index) ?? index.toString();
  }

  /**
   * Add a listener to the positioner events.
   */
  readonly addListener = <Key extends keyof PositionerEvents<Data>>(
    event: Key,
    cb: PositionerEvents<Data>[Key],
  ): Unsubscribe => this.#handler.on(event, cb);

  private addProps(props: GetPositionProps<Data>, index: number) {
    if (this.#updated) {
      return;
    }

    this.#props.set(index, props);

    if (this.#props.size < this.#active.length) {
      return;
    }

    const doneProps: ElementsAddedProps[] = [];

    for (const index of this.#active.keys()) {
      const item = this.#props.get(index);

      invariant(item, {
        code: ErrorConstant.INTERNAL,
        message: 'Something went wrong when retrieving the parameters',
      });

      const id = this.#ids[index];

      if (!id) {
        return;
      }

      doneProps.push({
        position: this.#getPosition(item),
        element: item.element,
        id,
      });
    }

    this.#handler.emit('done', doneProps);
  }

  /**
   * Create a new Positioner with the provided props.
   */
  clone(props?: PositionerCloneProps<Data>): Positioner<Data> {
    return Positioner.create({
      ...this.#constructorProps,
      ...(isFunction(props) ? props(this.#constructorProps) : props),
    });
  }

  /**
   * Clones the positioner while updating the `active` value. This is designed
   * for usage in frameworks like `react`.
   */
  active(isActive: boolean | ((data: Data) => boolean)): Positioner<Data> {
    const filterFunction = isFunction(isActive) ? isActive : () => isActive;

    return this.clone((original) => ({
      getActive: (props) => original.getActive(props).filter(filterFunction),
    }));
  }
}

type PositionerCloneProps<Data> =
  | Partial<BasePositioner<Data>>
  | ((original: BasePositioner<Data>) => Partial<BasePositioner<Data>>);