remirror/remirror

View on GitHub
packages/remirror__extension-code-block/src/code-block-plugin.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
import {
  EditorState,
  findChildrenByNode,
  getChangedNodes,
  NodeExtension,
  NodeType,
  NodeWithPosition,
  ProsemirrorNode,
  Transaction,
} from '@remirror/core';
import { DecorationSet } from '@remirror/pm/view';

import type { CodeBlockOptions } from './code-block-types';
import { createDecorations } from './code-block-utils';

export class CodeBlockState {
  #extension: NodeExtension<CodeBlockOptions>;

  /**
   * Keep track of the node type of the `codeBlock`
   */
  readonly #type: NodeType;

  /**
   * Tracks whether or not a deletion has occurred.
   */
  #deleted = false;

  /**
   * The set of cached decorations to minimize dom updates
   */
  decorationSet!: DecorationSet;

  constructor(type: NodeType, extension: NodeExtension<CodeBlockOptions>) {
    this.#type = type;
    this.#extension = extension;
  }

  /**
   * Creates the initial set of decorations
   */
  init(state: EditorState): this {
    // Find all the `codeBlocks` in the editor.
    const blocks = findChildrenByNode({ node: state.doc, type: this.#type });

    // Update the `codeBlocks` with the relevant syntax highlighting.
    this.refreshDecorationSet(state.doc, blocks);

    return this;
  }

  /**
   * Recreate all the decorations again for all the provided blocks.
   */
  private refreshDecorationSet(doc: ProsemirrorNode, blocks: NodeWithPosition[]) {
    const decorations = createDecorations({
      blocks,
      skipLast: this.#deleted,
      defaultLanguage: this.#extension.options.defaultLanguage,
      plainTextClassName: this.#extension.options.plainTextClassName ?? undefined,
    });

    // Store the decoration set to be applied to the editor by the plugin.
    this.decorationSet = DecorationSet.create(doc, decorations);
  }

  /**
   * Apply the state and update decorations when a change has happened in the
   * editor.
   */
  apply(tr: Transaction, _: EditorState): this {
    if (!tr.docChanged) {
      return this;
    }

    this.decorationSet = this.decorationSet.map(tr.mapping, tr.doc);

    // I was thinking I would need to check for deletions, but, since the
    // `decorationSet` is mapped over the previous change all decorations from a
    // deleted node will automatically be deleted. All that is needed is to find
    // the changed nodes and update their decorations.
    const changedNodes = getChangedNodes(tr, {
      descend: true,
      predicate: (node) => node.type === this.#type,
      StepTypes: [],
    });
    this.updateDecorationSet(tr, changedNodes);

    return this;
  }

  /**
   * Removes all decorations which relate to the changed block node before creating new decorations
   * and adding them to the decorationSet.
   */
  private updateDecorationSet(tr: Transaction, blocks: NodeWithPosition[]) {
    if (blocks.length === 0) {
      return;
    }

    let decorationSet = this.decorationSet;

    for (const { node, pos } of blocks) {
      decorationSet = this.decorationSet.remove(this.decorationSet.find(pos, pos + node.nodeSize));
    }

    this.decorationSet = decorationSet.add(
      tr.doc,
      createDecorations({
        blocks,
        skipLast: this.#deleted,
        defaultLanguage: this.#extension.options.defaultLanguage,
        plainTextClassName: this.#extension.options.plainTextClassName ?? undefined,
      }),
    );
  }

  /**
   * Flags that a deletion has just occurred.
   */
  setDeleted(deleted: boolean): void {
    this.#deleted = deleted;
  }
}