remirror/remirror

View on GitHub
packages/remirror__extension-node-formatting/src/node-formatting-extension.ts

Summary

Maintainability
A
0 mins
Test Coverage
D
66%
import {
  clamp,
  command,
  CommandFunction,
  extension,
  ExtensionPriority,
  ExtensionTag,
  IdentifierSchemaAttributes,
  isEmptyArray,
  joinStyles,
  keyBinding,
  KeyBindingProps,
  NamedShortcut,
  NodeWithPosition,
  PlainExtension,
  ProsemirrorAttributes,
  SchemaAttributesObject,
} from '@remirror/core';

import {
  centerAlignOptions,
  decreaseIndentOptions,
  extractIndent,
  extractLineHeight,
  gatherNodes,
  increaseIndentOptions,
  justifyAlignOptions,
  leftAlignOptions,
  NODE_INDENT_ATTRIBUTE,
  NODE_LINE_HEIGHT_ATTRIBUTE,
  NODE_TEXT_ALIGNMENT_ATTRIBUTE,
  NodeFormattingOptions,
  NodeTextAlignment,
  rightAlignOptions,
} from './node-formatting-utils';

/**
 * Support consistent formatting of nodes within your editor.
 */
@extension<NodeFormattingOptions>({
  defaultOptions: {
    indents: [
      '0',
      '20px',
      '40px',
      '60px',
      '80px',
      '100px',
      '120px',
      '140px',
      '160px',
      '180px',
      '200px',
    ],
    excludeNodes: [],
  },
  staticKeys: ['indents'],
})
export class NodeFormattingExtension extends PlainExtension<NodeFormattingOptions> {
  get name() {
    return 'nodeFormatting' as const;
  }

  /**
   * Set up the extra attributes which are applied to the formattable node
   * blocks.
   */
  createSchemaAttributes(): IdentifierSchemaAttributes[] {
    return [
      {
        identifiers: {
          type: 'node',
          tags: [ExtensionTag.FormattingNode],
          excludeNames: this.options.excludeNodes,
        },
        attributes: {
          nodeIndent: this.nodeIndent(),
          nodeTextAlignment: this.nodeTextAlignment(),
          nodeLineHeight: this.nodeLineHeight(),
          style: {
            default: '',
            parseDOM: () => '',
            toDOM: ({ nodeIndent, nodeTextAlignment, nodeLineHeight, style }) => {
              const marginLeft = nodeIndent ? this.options.indents[nodeIndent] : undefined;
              const textAlign =
                nodeTextAlignment && nodeTextAlignment !== 'none' ? nodeTextAlignment : undefined;
              const lineHeight = nodeLineHeight ? nodeLineHeight : undefined;

              return {
                // Compose the style string together with the currently set style.
                style: joinStyles({ marginLeft, textAlign, lineHeight }, style as string),
              };
            },
          },
        },
      },
    ];
  }

  @command()
  setLineHeight(lineHeight: number): CommandFunction {
    return this.setNodeAttribute(({ node }) => {
      if (lineHeight === node.attrs.nodeLineHeight) {
        return;
      }

      return { nodeLineHeight: lineHeight };
    });
  }

  /**
   * Set the text alignment for the selected nodes.
   */
  @command()
  setTextAlignment(alignment: NodeTextAlignment): CommandFunction {
    return this.setNodeAttribute(({ node }) => {
      if (alignment === node.attrs.nodeTextAlignment) {
        return;
      }

      return { nodeTextAlignment: alignment };
    });
  }

  /**
   * Set the indent level for the selected nodes.
   */
  @command()
  setIndent(level: number | '+1' | '-1'): CommandFunction {
    return this.setNodeAttribute(({ node }) => {
      const currentIndent: number = node.attrs.nodeIndent ?? 0;
      const value = level === '-1' ? currentIndent - 1 : level === '+1' ? currentIndent + 1 : level;
      const indent = clamp({ min: 0, max: this.options.indents.length - 1, value });

      if (indent === currentIndent) {
        return;
      }

      return { nodeIndent: indent };
    });
  }

  /**
   * Center the text within current block node.
   */
  @command(centerAlignOptions)
  centerAlign(): CommandFunction {
    return this.setTextAlignment('center');
  }

  /**
   * Justify the text within the current block node.
   */
  @command(justifyAlignOptions)
  justifyAlign(): CommandFunction {
    return this.setTextAlignment('justify');
  }

  /**
   * Left align the text within the current block node.
   */
  @command(leftAlignOptions)
  leftAlign(): CommandFunction {
    return this.setTextAlignment('left');
  }

  /**
   * Right align the text within the current block node.
   */
  @command(rightAlignOptions)
  rightAlign(): CommandFunction {
    return this.setTextAlignment('right');
  }

  /**
   * Increase the indentation level of the current block node, if applicable.
   */
  @command(increaseIndentOptions)
  increaseIndent(): CommandFunction {
    return (props) => {
      return this.setIndent('+1')(props);
    };
  }

  /**
   * Decrease the indentation of the current block node.
   */
  @command(decreaseIndentOptions)
  decreaseIndent(): CommandFunction {
    return (props) => {
      return this.setIndent('-1')(props);
    };
  }

  @keyBinding({ shortcut: NamedShortcut.CenterAlignment, command: 'centerAlign' })
  centerAlignShortcut(props: KeyBindingProps): boolean {
    return this.centerAlign()(props);
  }

  @keyBinding({ shortcut: NamedShortcut.JustifyAlignment, command: 'justifyAlign' })
  justifyAlignShortcut(props: KeyBindingProps): boolean {
    return this.justifyAlign()(props);
  }

  @keyBinding({ shortcut: NamedShortcut.LeftAlignment, command: 'leftAlign' })
  leftAlignShortcut(props: KeyBindingProps): boolean {
    return this.leftAlign()(props);
  }

  @keyBinding({ shortcut: NamedShortcut.RightAlignment, command: 'rightAlign' })
  rightAlignShortcut(props: KeyBindingProps): boolean {
    return this.rightAlign()(props);
  }

  @keyBinding({
    shortcut: NamedShortcut.IncreaseIndent,
    command: 'increaseIndent',
    // Ensure this has lower priority than the indent keybinding in @remirror/extension-list
    priority: ExtensionPriority.Low,
  })
  increaseIndentShortcut(props: KeyBindingProps): boolean {
    return this.increaseIndent()(props);
  }

  @keyBinding({
    shortcut: NamedShortcut.DecreaseIndent,
    command: 'decreaseIndent',
    // Ensure this has higher priority than the dedent keybinding in @remirror/extension-list
    priority: ExtensionPriority.Medium,
  })
  decreaseIndentShortcut(props: KeyBindingProps): boolean {
    return this.decreaseIndent()(props);
  }

  /**
   * Add an indentation attribute to the formattable node blocks.
   */
  private nodeIndent(): SchemaAttributesObject {
    return {
      default: null,
      parseDOM: (element) => {
        return (
          element.getAttribute(NODE_INDENT_ATTRIBUTE) ??
          extractIndent(this.options.indents, element.style.marginLeft)
        );
      },
      toDOM: (attrs) => {
        // Ignoring the `0` value is intentional here.
        if (!attrs.nodeIndent) {
          return;
        }

        const indentIndex = `${attrs.nodeIndent}`;
        const marginLeft = this.options.indents[attrs.nodeIndent];

        if (!marginLeft) {
          return;
        }

        return {
          [NODE_INDENT_ATTRIBUTE]: indentIndex,
        };
      },
    };
  }

  /**
   * Add the `nodeTextAlignment` attribute to the formattable block nodes.
   */
  private nodeTextAlignment(): SchemaAttributesObject {
    return {
      default: null,
      parseDOM: (element) => {
        return element.getAttribute(NODE_TEXT_ALIGNMENT_ATTRIBUTE) ?? element.style.textAlign;
      },
      toDOM: (attrs) => {
        const textAlign = attrs.nodeTextAlignment;

        if (!textAlign || textAlign === 'none') {
          return;
        }

        return {
          [NODE_TEXT_ALIGNMENT_ATTRIBUTE]: textAlign,
        };
      },
    };
  }

  /**
   * Add a `line height` attribute to all the formattable block nodes selected.
   */
  private nodeLineHeight(): SchemaAttributesObject {
    return {
      default: null,
      parseDOM: (element) => {
        const val = element.getAttribute(NODE_LINE_HEIGHT_ATTRIBUTE);
        return extractLineHeight(val) ?? extractLineHeight(element.style.lineHeight);
      },
      toDOM: (attrs) => {
        const lineHeight = attrs.nodeLineHeight;

        if (!lineHeight) {
          return;
        }

        return {
          [NODE_LINE_HEIGHT_ATTRIBUTE]: lineHeight.toString(),
        };
      },
    };
  }

  private setNodeAttribute(
    getAttributes: (nodeWithPos: NodeWithPosition) => ProsemirrorAttributes | undefined,
  ): CommandFunction {
    return (props) => {
      const { tr, dispatch } = props;
      const gatheredNodes = gatherNodes(
        tr,
        this.store.nodeTags.formattingNode,
        this.options.excludeNodes,
      );

      if (isEmptyArray(gatheredNodes)) {
        return false;
      }

      if (!dispatch) {
        return true;
      }

      const updates: Array<[pos: number, attrs: ProsemirrorAttributes]> = [];

      for (const nodeWithPos of gatheredNodes) {
        const { node, pos } = nodeWithPos;
        const attrs = getAttributes(nodeWithPos);

        if (!attrs) {
          continue;
        }

        updates.push([pos, { ...node.attrs, ...attrs }]);
      }

      if (isEmptyArray(updates)) {
        return false;
      }

      if (!dispatch) {
        return true;
      }

      for (const [pos, attrs] of updates) {
        tr.setNodeMarkup(pos, undefined, attrs);
      }

      dispatch(tr);
      return true;
    };
  }
}

declare global {
  namespace Remirror {
    interface Attributes {
      /**
       * The indentation level for the formattable node.
       */
      nodeIndent?: number;

      /**
       * Set the text alignment fpr the formattable node.
       */
      nodeTextAlignment?: NodeTextAlignment;

      /**
       * A ratio with a minimum value of `1` (100%) for the line height of a
       * formattable node.
       */
      nodeLineHeight?: number;
    }

    interface AllExtensions {
      nodeFormatting: NodeFormattingExtension;
    }
  }
}