remirror/remirror

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

Summary

Maintainability
A
0 mins
Test Coverage
A
91%
import refractor, { RefractorNode } from 'refractor/core.js';
import {
  ApplySchemaAttributes,
  CommandFunction,
  cx,
  DOMOutputSpec,
  findParentNodeOfType,
  flattenArray,
  FromToProps,
  isEqual,
  isObject,
  isString,
  joinStyles,
  NodeType,
  NodeTypeProps,
  NodeWithPosition,
  object,
  omitExtraAttributes,
  PosProps,
  ProsemirrorAttributes,
  ProsemirrorNode,
  range,
  TextProps,
} from '@remirror/core';
import { ExtensionCodeBlockMessages } from '@remirror/messages';
import { TextSelection } from '@remirror/pm/state';
import { Decoration } from '@remirror/pm/view';

import type { CodeBlockAttributes, CodeBlockOptions, FormattedContent } from './code-block-types';

export const LANGUAGE_ATTRIBUTE = 'data-code-block-language';
export const WRAP_ATTRIBUTE = 'data-code-block-wrap';

interface ParsedRefractorNode extends TextProps {
  /**
   * The classes that will wrap the node
   */
  classes: string[];
}

interface PositionedRefractorNode extends FromToProps, ParsedRefractorNode {}

/**
 * Maps the refractor nodes into text and classes which will be used to create
 * our decoration.
 */
function parseRefractorNodes(
  refractorNodes: RefractorNode[],
  plainTextClassName: string | undefined,
  className: string[] = [],
): ParsedRefractorNode[][] {
  return refractorNodes.map((node) => {
    const classes: string[] = [...className];

    if (node.type === 'element' && node.properties.className) {
      classes.push(...node.properties.className);
    } else if (node.type === 'text' && classes.length === 0 && plainTextClassName) {
      classes.push(plainTextClassName);
    }

    if (node.type === 'element') {
      return parseRefractorNodes(node.children, plainTextClassName, classes) as any;
    }

    return {
      text: node.value,
      classes,
    };
  });
}

interface CreateDecorationsProps {
  defaultLanguage: string;

  /**
   * The list of codeBlocks and their positions which we would like to update.
   */
  blocks: NodeWithPosition[];

  /**
   * When a delete happens within the last valid decoration in a block it causes
   * the editor to jump. This skipLast should be set to true immediately after a
   * delete which then allows for createDecorations to skip updating the
   * decoration for the last refactor node, and hence preventing the jumpy bug.
   */
  skipLast: boolean;

  plainTextClassName: string | undefined;
}

/**
 * Retrieves positioned refractor nodes from the positionedNode
 *
 * @param nodeWithPos - a node and position
 * @param plainTextClassName - a class to assign to text nodes on the top-level
 * @returns the positioned refractor nodes which are text, classes and a FromTo
 * interface
 */
function getPositionedRefractorNodes(
  nodeWithPos: NodeWithPosition,
  plainTextClassName: string | undefined,
) {
  const { node, pos } = nodeWithPos;
  const language = getLanguage({
    language: node.attrs.language?.replace('language-', ''),
    fallback: 'markup',
  });
  const refractorNodes = refractor.highlight(node.textContent ?? '', language);
  const parsedRefractorNodes = parseRefractorNodes(refractorNodes, plainTextClassName);

  let startPos = pos + 1;

  function mapper(refractorNode: ParsedRefractorNode): PositionedRefractorNode {
    const from = startPos;
    const to = from + refractorNode.text.length;
    startPos = to;
    return {
      ...refractorNode,
      from,
      to,
    };
  }

  return flattenArray<ParsedRefractorNode>(parsedRefractorNodes).map(mapper);
}

/**
 * Creates a decoration set for the provided blocks
 */
export function createDecorations(props: CreateDecorationsProps): Decoration[] {
  const { blocks, skipLast, plainTextClassName } = props;
  const decorations: Decoration[] = [];

  for (const block of blocks) {
    const positionedRefractorNodes = getPositionedRefractorNodes(block, plainTextClassName);
    const lastBlockLength = skipLast
      ? positionedRefractorNodes.length - 1
      : positionedRefractorNodes.length;

    for (const index of range(lastBlockLength)) {
      const positionedRefractorNode = positionedRefractorNodes[index];
      const classes = positionedRefractorNode?.classes;

      if (!positionedRefractorNode || !classes?.length) {
        // Do not create a decoration if we cannot assign at least one class
        continue;
      }

      const decoration = Decoration.inline(
        positionedRefractorNode.from,
        positionedRefractorNode.to,
        {
          class: classes.join(' '),
        },
      );

      decorations.push(decoration);
    }
  }

  return decorations;
}

/**
 * Check that the attributes exist and are valid for the codeBlock
 * updateAttributes.
 */
export function isValidCodeBlockAttributes(
  attributes: ProsemirrorAttributes,
): attributes is CodeBlockAttributes {
  return !!(
    attributes &&
    isObject(attributes) &&
    isString(attributes.language) &&
    attributes.language.length > 0
  );
}

/**
 * Updates the node attrs.
 *
 * This is used to update the language for the codeBlock.
 */
export function updateNodeAttributes(type: NodeType) {
  return (attributes: CodeBlockAttributes): CommandFunction =>
    ({ state: { tr, selection }, dispatch }) => {
      if (!isValidCodeBlockAttributes(attributes)) {
        throw new Error('Invalid attrs passed to the updateAttributes method');
      }

      const parent = findParentNodeOfType({ types: type, selection });

      if (!parent || isEqual(attributes, parent.node.attrs)) {
        // Do nothing since the attrs are the same
        return false;
      }

      tr.setNodeMarkup(parent.pos, type, { ...parent.node.attrs, ...attributes });

      if (dispatch) {
        dispatch(tr);
      }

      return true;
    };
}

interface GetLanguageProps {
  /**
   * The language input from the user;
   */
  language: string | undefined;

  /**
   * The default language to use if none found.
   */
  fallback: string;
}

/**
 * Get the language from user input.
 */
export function getLanguage(props: GetLanguageProps): string {
  const { language, fallback } = props;

  if (!language) {
    return fallback;
  }

  const supportedLanguages = refractor.listLanguages();

  for (const name of supportedLanguages) {
    if (name.toLowerCase() === language.toLowerCase()) {
      return name;
    }
  }

  return fallback;
}

/**
 * Used to provide a `toDom` function for the code block. Currently this only
 * support the browser runtime.
 */
export function codeBlockToDOM(node: ProsemirrorNode, extra: ApplySchemaAttributes): DOMOutputSpec {
  const { language, wrap } = omitExtraAttributes(node.attrs, extra);
  const { style: _, ...extraAttrs } = extra.dom(node);
  let style = extraAttrs.style;

  if (wrap) {
    style = joinStyles({ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }, style);
  }

  const attributes = {
    spellcheck: 'false',
    ...extraAttrs,
    class: cx(extraAttrs.class, `language-${language}`),
  };

  return ['pre', attributes, ['code', { [LANGUAGE_ATTRIBUTE]: language, style }, 0]];
}

interface FormatCodeBlockFactoryProps
  extends NodeTypeProps,
    Required<Pick<CodeBlockOptions, 'formatter' | 'defaultLanguage'>> {}

/**
 * A factory for creating a command which can format a selected codeBlock (or
 * one located at the provided position).
 */
export function formatCodeBlockFactory(props: FormatCodeBlockFactoryProps) {
  return ({ pos }: Partial<PosProps> = object()): CommandFunction =>
    ({ tr, dispatch }) => {
      const { type, formatter, defaultLanguage: fallback } = props;

      const { from, to } = pos ? { from: pos, to: pos } : tr.selection;

      // Find the current codeBlock the cursor is positioned in.
      const codeBlock = findParentNodeOfType({ types: type, selection: tr.selection });

      if (!codeBlock) {
        return false;
      }

      // Get the `language`, `source` and `cursorOffset` for the block and run the
      // formatter
      const {
        node: { attrs, textContent },
        start,
      } = codeBlock;

      const offsetStart = from - start;
      const offsetEnd = to - start;
      const language = getLanguage({ language: attrs.language, fallback });
      const formatStart = formatter({ source: textContent, language, cursorOffset: offsetStart });
      let formatEnd: FormattedContent | undefined;

      // When the user has a selection
      if (offsetStart !== offsetEnd) {
        formatEnd = formatter({ source: textContent, language, cursorOffset: offsetEnd });
      }

      if (!formatStart) {
        return false;
      }

      const { cursorOffset, formatted } = formatStart;

      // Do nothing if nothing has changed
      if (formatted === textContent) {
        return false;
      }

      const end = start + textContent.length;

      // Replace the codeBlock content with the transformed text.
      tr.insertText(formatted, start, end);

      // Set the new selection
      const anchor = start + cursorOffset;
      const head = formatEnd ? start + formatEnd.cursorOffset : undefined;

      tr.setSelection(
        TextSelection.between(tr.doc.resolve(anchor), tr.doc.resolve(head ?? anchor)),
      );

      if (dispatch) {
        dispatch(tr);
      }

      return true;
    };
}

/**
 * Get the language from the provided `code` element. This is used as the
 * default implementation in the `CodeExtension` but it can be overridden.
 */
export function getLanguageFromDom(codeElement: HTMLElement): string | undefined {
  return (codeElement.getAttribute(LANGUAGE_ATTRIBUTE) ?? codeElement.classList[0])?.replace(
    'language-',
    '',
  );
}

const { DESCRIPTION, LABEL } = ExtensionCodeBlockMessages;
export const toggleCodeBlockOptions: Remirror.CommandDecoratorOptions = {
  icon: 'bracesLine',
  description: ({ t }) => t(DESCRIPTION),
  label: ({ t }) => t(LABEL),
};