remirror/remirror

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

Summary

Maintainability
A
0 mins
Test Coverage
import {
  command,
  CommandFunction,
  debounce,
  DebouncedFunction,
  EditorState,
  extension,
  Handler,
  invariant,
  isArray,
  isNumber,
  PlainExtension,
  ProsemirrorAttributes,
  ProsemirrorPlugin,
  Shape,
  StateUpdateLifecycleProps,
  Static,
  Transaction,
  uniqueId,
} from '@remirror/core';
import { collab, getVersion, receiveTransaction, sendableSteps } from '@remirror/pm/collab';
import { Step } from '@remirror/pm/transform';

/**
 * The collaboration extension adds collaborative functionality to your editor.
 *
 * Once a central server is created the collaboration extension is good.
 */
@extension<CollaborationOptions>({
  defaultOptions: {
    version: 0,
    clientID: uniqueId(),
    debounceMs: 250,
  },
  staticKeys: ['version', 'clientID', 'debounceMs'],
  handlerKeys: ['onSendableReceived'],
})
export class CollaborationExtension extends PlainExtension<CollaborationOptions> {
  get name() {
    return 'collaboration' as const;
  }

  private _debounceGetSendableSteps?: DebouncedFunction<(state: EditorState) => void>;

  private get debounceGetSendableSteps() {
    if (!this._debounceGetSendableSteps) {
      this._debounceGetSendableSteps = debounce(
        this.options.debounceMs,
        this.getSendableSteps.bind(this),
      );
    }

    return this._debounceGetSendableSteps;
  }

  /**
   * Send a collaboration update.
   */
  @command()
  sendCollaborationUpdate(attributes: CollaborationAttributes): CommandFunction {
    return ({ state, dispatch }) => {
      invariant(isValidCollaborationAttributes(attributes), {
        message: 'Invalid attributes passed to the collaboration command.',
      });

      const { version, steps } = attributes;

      if (getVersion(state) > version) {
        return false;
      }

      if (dispatch) {
        dispatch(
          receiveTransaction(
            state,
            steps.map((item) => Step.fromJSON(this.store.schema, item)),
            steps.map((item) => item.clientID),
          ),
        );
      }

      return true;
    };
  }

  @command()
  cancelSendableSteps(): CommandFunction {
    return () => {
      this.debounceGetSendableSteps?.cancel();
      return true;
    };
  }

  @command()
  flushSendableSteps(): CommandFunction {
    return ({ state }) => {
      this.debounceGetSendableSteps?.cancel();
      this.getSendableSteps(state);
      return true;
    };
  }

  createExternalPlugins(): ProsemirrorPlugin[] {
    const { version, clientID } = this.options;

    const plugin = collab({
      version,
      clientID,
    });

    return [plugin];
  }

  onStateUpdate(props: StateUpdateLifecycleProps): void {
    this.debounceGetSendableSteps?.(props.state);
  }

  onDestroy(): void {
    this.store.commands.flushSendableSteps();
  }

  /**
   * This passes the sendable steps into the `onSendableReceived` handler defined in the
   * options when there is something to send.
   */
  private getSendableSteps(state: EditorState) {
    const sendable = sendableSteps(state);

    if (sendable) {
      const jsonSendable = {
        version: sendable.version,
        steps: sendable.steps.map((step) => step.toJSON()),
        clientID: sendable.clientID,
      };
      this.options.onSendableReceived({ sendable, jsonSendable });
    }
  }
}

export interface Sendable {
  version: number;
  steps: readonly Step[];
  clientID: number | string;
  origins: readonly Transaction[];
}

export interface JSONSendable extends Omit<Sendable, 'steps' | 'origins'> {
  steps: Shape[];
}

export interface OnSendableReceivedProps {
  /**
   * The raw sendable generated by the prosemirror-collab library.
   */
  sendable: Sendable;

  /**
   * A sendable which can be sent to a server
   */
  jsonSendable: JSONSendable;
}

export interface CollaborationOptions {
  /**
   * The document version.
   *
   * @defaultValue 0
   */
  version?: Static<number>;

  /**
   * The unique ID of the client connecting to the server.
   */
  clientID: Static<number | string>;

  /**
   * The debounce time in milliseconds
   *
   * @defaultValue 250
   */
  debounceMs?: Static<number>;

  /**
   * Called when an an editor transaction occurs and there are changes ready to
   * be sent to the server.
   *
   * @remarks
   *
   * The callback will receive the `jsonSendable` which can be sent to the
   * server as it is. If you need more control then the `sendable` property can
   * be used to shape the data the way you require.
   *
   * Since this method is called for everyTransaction that updates the
   * jsonSendable value it is automatically debounced for you.
   *
   * @param props - the sendable and jsonSendable properties which can be sent
   * to your backend
   */
  onSendableReceived: Handler<(props: OnSendableReceivedProps) => void>;
}

export interface StepWithClientId extends Step {
  clientID: number | string;
}

export type CollaborationAttributes = ProsemirrorAttributes<{
  /**
   * The steps to confirm, combined with the clientID of the user who created the change
   */
  steps: StepWithClientId[];

  /**
   * The version of the document that these steps were added to.
   */
  version: number;
}>;

/**
 * Check that the attributes exist and are valid for the collaboration update
 * command method.
 */
const isValidCollaborationAttributes = (
  attributes: ProsemirrorAttributes,
): attributes is CollaborationAttributes =>
  !(!attributes || !isArray(attributes.steps) || !isNumber(attributes.version));

declare global {
  namespace Remirror {
    interface AllExtensions {
      collaboration: CollaborationExtension;
    }
  }
}