remirror/remirror

View on GitHub
packages/remirror__core-utils/src/core-utils.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
90%
import { cssifyObject } from 'css-in-js-utils';
import type { StyleObject } from 'css-in-js-utils/es/cssifyObject';
import { getDomDocument } from 'get-dom-document';
import {
  __INTERNAL_REMIRROR_IDENTIFIER_KEY__,
  ErrorConstant,
  LEAF_NODE_REPLACING_CHARACTER,
  RemirrorIdentifier,
} from '@remirror/core-constants';
import {
  assert,
  assertGet,
  clamp,
  includes,
  invariant,
  isArray,
  isNullOrUndefined,
  isNumber,
  isObject,
  isString,
  keys,
  omit,
  sort,
  uniqueBy,
  unset,
} from '@remirror/core-helpers';
import type {
  AnchorHeadProps,
  AnyConstructor,
  ApplySchemaAttributes,
  AttributesProps,
  DOMCompatibleAttributes,
  EditorSchema,
  EditorState,
  FromToProps,
  MarkTypeProps,
  PosProps,
  PrimitiveSelection,
  ProsemirrorAttributes,
  ProsemirrorNode,
  RemirrorContentType,
  RemirrorIdentifierShape,
  RemirrorJSON,
  ResolvedPos,
  SchemaProps,
  Selection,
  TextProps,
  Transaction,
  TrStateProps,
} from '@remirror/core-types';
import {
  DOMParser as PMDomParser,
  DOMSerializer,
  Fragment,
  Mark,
  MarkType,
  Node as PMNode,
  NodeRange,
  NodeType,
  ParseOptions,
  ResolvedPos as PMResolvedPos,
  Schema,
  Slice,
} from '@remirror/pm/model';
import {
  AllSelection,
  EditorState as PMEditorState,
  NodeSelection,
  Selection as PMSelection,
  TextSelection,
  Transaction as PMTransaction,
} from '@remirror/pm/state';
import type { Step } from '@remirror/pm/transform';
import {
  AddMarkStep,
  RemoveMarkStep,
  ReplaceAroundStep,
  ReplaceStep,
} from '@remirror/pm/transform';

import { environment } from './environment';
import { containsAttributes } from './prosemirror-utils';

function isRangeStep(
  step: Step,
): step is AddMarkStep | ReplaceAroundStep | ReplaceStep | RemoveMarkStep {
  return isValidStep(step, [AddMarkStep, ReplaceAroundStep, ReplaceStep, RemoveMarkStep]);
}

/**
 * Identifies the value as having a remirror identifier. This is the core
 * predicate check for the remirror library.
 *
 * @param value - the value to be checked
 *
 * @internal
 */
export function isRemirrorType(value: unknown): value is RemirrorIdentifierShape {
  return isObject<RemirrorIdentifierShape>(value);
}

/**
 * Checks that the provided remirror shape is of a given type.
 *
 * @param value - any remirror shape
 * @param type - the remirror identifier type to check for
 *
 * @internal
 */
export function isIdentifierOfType(
  value: RemirrorIdentifierShape,
  type: RemirrorIdentifier | RemirrorIdentifier[],
): boolean {
  return isArray(type)
    ? includes(type, value[__INTERNAL_REMIRROR_IDENTIFIER_KEY__])
    : type === value[__INTERNAL_REMIRROR_IDENTIFIER_KEY__];
}

/**
 * Check to see if the passed value is a NodeType.
 *
 * @param value - the value to check
 */
export function isNodeType(value: unknown): value is NodeType {
  return isObject(value) && value instanceof NodeType;
}

/**
 * Get the node type from a potential string value.
 */
export function getNodeType(type: string | NodeType, schema: EditorSchema): NodeType {
  return isString(type) ? assertGet(schema.nodes, type) : type;
}

/**
 * Check to see if the passed value is a MarkType.
 *
 * @param value - the value to check
 */
export function isMarkType(value: unknown): value is MarkType {
  return isObject(value) && value instanceof MarkType;
}

/**
 * Get the mark type from a potential string value.
 */
export function getMarkType(type: string | MarkType, schema: EditorSchema): MarkType {
  return isString(type) ? assertGet(schema.marks, type) : type;
}

/**
 * Checks to see if the passed value is a ProsemirrorNode
 *
 * @param value - the value to check
 */
export function isProsemirrorNode(value: unknown): value is ProsemirrorNode {
  return isObject(value) && value instanceof PMNode;
}

/**
 * Checks to see if the passed value is a ProsemirrorNode
 *
 * @param value - the value to check
 */
export function isProsemirrorFragment(value: unknown): value is Fragment {
  return isObject(value) && value instanceof Fragment;
}

/**
 * Checks to see if the passed value is a ProsemirrorMark
 *
 * @param value - the value to check
 */
export function isProsemirrorMark(value: unknown): value is Mark {
  return isObject(value) && value instanceof Mark;
}

/**
 * Checks to see if the passed value is a Prosemirror Editor State
 *
 * @param value - the value to check
 */
export function isEditorState(value: unknown): value is PMEditorState | Readonly<PMEditorState> {
  return isObject(value) && value instanceof PMEditorState;
}

/**
 * Checks to see if the passed value is a Prosemirror Transaction
 *
 * @param value - the value to check
 */
export function isTransaction(value: unknown): value is PMTransaction {
  return isObject(value) && value instanceof PMTransaction;
}

/**
 * Checks to see if the passed value is an instance of the editor schema
 *
 * @param value - the value to check
 */
export function isEditorSchema(value: unknown): value is EditorSchema {
  return isObject(value) && value instanceof Schema;
}

/**
 * Predicate checking whether the selection is a `TextSelection`.
 *
 * @param value - the value to check
 */
export function isTextSelection(value: unknown): value is TextSelection {
  return isObject(value) && value instanceof TextSelection;
}

/**
 * Predicate checking whether the selection is an `AllSelection`.
 *
 * @param value - the value to check
 */
export function isAllSelection(value: unknown): value is AllSelection {
  return isObject(value) && value instanceof AllSelection;
}

/**
 * Predicate checking whether the value is a Selection
 *
 * @param value - the value to check
 */
export function isSelection(value: unknown): value is Selection {
  return isObject(value) && value instanceof PMSelection;
}

/**
 * Predicate checking whether the value is a ResolvedPosition.
 *
 * @param value - the value to check
 */
export function isResolvedPos(value: unknown): value is PMResolvedPos {
  return isObject(value) && value instanceof PMResolvedPos;
}

interface RangeHasMarkProps
  extends TrStateProps,
    FromToProps,
    MarkTypeProps,
    Partial<AttributesProps> {}

/**
 * A wrapper for ProsemirrorNode.rangeHasMark that can also compare mark attributes (if supplied)
 *
 * @param props - see [[`RangeHasMarkProps`]] for options
 */
export function rangeHasMark(props: RangeHasMarkProps): boolean {
  const { trState, from, to, type, attrs = {} } = props;
  const { doc } = trState;
  const markType = getMarkType(type, doc.type.schema);

  if (Object.keys(attrs).length === 0) {
    return doc.rangeHasMark(from, to, markType);
  }

  let found = false;

  if (to > from) {
    doc.nodesBetween(from, to, (node) => {
      if (found) {
        return false;
      }

      const marks = node.marks ?? [];
      found = marks.some((mark) => {
        if (mark.type !== markType) {
          return false;
        }

        return containsAttributes(mark, attrs);
      });
      // Don't descend if found
      return !found;
    });
  }

  return found;
}

/**
 * Predicate checking whether the selection is a NodeSelection
 *
 * @param value - the value to check
 */
export function isNodeSelection(value: unknown): value is NodeSelection {
  return isObject(value) && value instanceof NodeSelection;
}

interface IsMarkActiveProps
  extends MarkTypeProps,
    Partial<AttributesProps>,
    Partial<FromToProps>,
    TrStateProps {}

/**
 * Checks that a mark is active within the selected region, or the current
 * selection point is within a region with the mark active. Used by extensions
 * to implement their active methods.
 *
 * @param props - see [[`IsMarkActiveProps`]] for options
 */
export function isMarkActive(props: IsMarkActiveProps): boolean {
  const { trState, type, attrs = {}, from, to } = props;
  const { selection, doc, storedMarks } = trState;
  const markType = isString(type) ? doc.type.schema.marks[type] : type;

  invariant(markType, {
    code: ErrorConstant.SCHEMA,
    message: `Mark type: ${type} does not exist on the current schema.`,
  });

  if (from && to) {
    try {
      return Math.max(from, to) < doc.nodeSize && rangeHasMark({ ...props, from, to });
    } catch {
      return false;
    }
  }

  if (selection.empty) {
    const marks = storedMarks ?? selection.$from.marks();
    return marks.some((mark) => {
      if (mark.type !== type) {
        return false;
      }

      return containsAttributes(mark, attrs ?? {});
    });
  }

  return rangeHasMark({ ...props, from: selection.from, to: selection.to });
}

/**
 * Check if the specified type (NodeType) can be inserted at the current
 * selection point.
 *
 * @param state - the editor state
 * @param type - the node type
 */
export function canInsertNode(state: EditorState, type: NodeType): boolean {
  const { $from } = state.selection;

  for (let depth = $from.depth; depth >= 0; depth--) {
    const index = $from.index(depth);
    try {
      if ($from.node(depth).canReplaceWith(index, index, type)) {
        return true;
      }
    } catch {
      return false;
    }
  }

  return false;
}

/**
 * Checks if a node looks like an empty document.
 *
 * @param node - the prosemirror node
 */
export function isDocNodeEmpty(node: ProsemirrorNode): boolean {
  const nodeChild = node.content.firstChild;

  if (node.childCount !== 1 || !nodeChild) {
    return false;
  }

  return (
    nodeChild.type.isBlock &&
    !nodeChild.childCount &&
    nodeChild.nodeSize === 2 &&
    (isNullOrUndefined(nodeChild.marks) || nodeChild.marks.length === 0)
  );
}

export interface DefaultDocNodeOptions {
  /**
   * When true will not check any of the attributes for any of the nodes.
   */
  ignoreAttributes?: boolean;

  /**
   * Set this to true to only test whether the content is identical to the
   * default and not the parent node.
   */
  ignoreDocAttributes?: boolean;
}

/**
 * Check whether the provided doc node has the same value as the default empty
 * node for the document. Basically checks that the document is untouched.
 *
 * This is useful for extensions like the placeholder which only should be shown
 * when the document matches the default empty state.
 */
export function isDefaultDocNode(
  doc: ProsemirrorNode,
  options: DefaultDocNodeOptions = {},
): boolean {
  const defaultDoc = getDefaultDocNode(doc.type.schema);

  // Make sure the `doc` was created.
  if (!defaultDoc) {
    // No default doc exists for the current schema.
    return false;
  }

  const { ignoreAttributes, ignoreDocAttributes } = options;

  if (ignoreAttributes) {
    return prosemirrorNodeEquals(defaultDoc, doc);
  }

  if (ignoreDocAttributes) {
    return defaultDoc.content.eq(doc.content);
  }

  return defaultDoc.eq(doc);
}

/**
 * Check that two nodes are equal while ignoring all attributes.
 *
 * This is an alternative to the `node.eq()` method.
 */
export function prosemirrorNodeEquals(node: ProsemirrorNode, other: ProsemirrorNode): boolean {
  // The values are equivalent so return `true` early.
  if (node === other) {
    return true;
  }

  // Check if the markup is the same (ignoring attributes)
  const identicalMarkup = node.type === other.type && Mark.sameSet(node.marks, other.marks);

  function contentEquals(): boolean {
    if (node.content === other.content) {
      return true;
    }

    if (node.content.size !== other.content.size) {
      return false;
    }

    const nodeChildren: ProsemirrorNode[] = [];
    const otherChildren: ProsemirrorNode[] = [];
    node.content.forEach((node) => nodeChildren.push(node));
    other.content.forEach((node) => otherChildren.push(node));

    for (const [index, nodeChild] of nodeChildren.entries()) {
      const otherChild = otherChildren[index];

      if (!otherChild) {
        return false;
      }

      if (!prosemirrorNodeEquals(nodeChild, otherChild)) {
        return false;
      }
    }

    return true;
  }

  return identicalMarkup && contentEquals();
}

/**
 * Get the default `doc` node for a given schema.
 */
export function getDefaultDocNode(schema: EditorSchema): ProsemirrorNode | undefined {
  return schema.nodes.doc?.createAndFill() ?? undefined;
}

/**
 * Get the default block node from the schema.
 */
export function getDefaultBlockNode(schema: EditorSchema): NodeType {
  // Set the default block node from the schema.
  for (const type of Object.values(schema.nodes)) {
    if (type.name === 'doc') {
      continue;
    }

    // Break as soon as the first non 'doc' block node is encountered.
    if (type.isBlock || type.isTextblock) {
      return type;
    }
  }

  invariant(false, {
    code: ErrorConstant.SCHEMA,
    message: 'No default block node found for the provided schema.',
  });
}

/**
 * Check if the provided node is a default block node.
 */
export function isDefaultBlockNode(node: ProsemirrorNode): boolean {
  return node.type === getDefaultBlockNode(node.type.schema);
}

/**
 * Checks if the current node is a block node and empty.
 *
 * @param node - the prosemirror node
 */
export function isEmptyBlockNode(node: ProsemirrorNode | null | undefined): boolean {
  return !!node && node.type.isBlock && !node.textContent && !node.childCount;
}

/**
 * Retrieve the attributes for a mark.
 *
 * @param trState - the editor state or a transaction
 * @param type - the mark type
 */
export function getMarkAttributes(
  trState: EditorState | Transaction,
  type: MarkType,
): ProsemirrorAttributes | false {
  // Get the current range of the cursor selection.
  const { from, to } = trState.selection;

  // The container which will be used to store the marks.
  const marks: Mark[] = [];

  // Find the nodes and add all the marks contained to the above mark container.
  trState.doc.nodesBetween(from, to, (node) => {
    marks.push(...node.marks);
  });

  // Search for the first mark with the same type as requested
  const mark = marks.find((markItem) => markItem.type.name === type.name);

  // Return the mark attrs when found.
  if (mark) {
    return mark.attrs;
  }

  // Return false to denote the mark could not be found.
  return false;
}

export interface GetMarkRange extends FromToProps {
  /**
   * The mark that was found within the active range.
   */
  mark: Mark;

  /**
   * The text contained by this mark.
   */
  text: string;
}

/**
 * Retrieve the `start` and `end` position of a mark. The `$pos` value should be
 * calculated via `tr.doc.resolve(number)`.
 *
 * @remarks
 *
 * @param $pos - the resolved ProseMirror position
 * @param type - the mark type
 * @param $end - the end position to search until. When this is provided the
 * mark will be checked for all point up until the `$end`. The first mark within
 * the range will be returned.
 *
 * To find all marks within a selection use [[`getMarkRanges`]].
 */
export function getMarkRange(
  $pos: ResolvedPos,
  type: string | MarkType,
  $end?: ResolvedPos,
): GetMarkRange | undefined {
  // Get the start position of the current node that the `$pos` value was
  // calculated for.
  const start = $pos.parent.childAfter($pos.parentOffset);

  // If the position provided was incorrect and no node exists for this start
  // position exit early.
  if (!start.node) {
    return;
  }

  const typeName = isString(type) ? type : type.name;

  // Find the mark if it exists.
  const mark = start.node.marks.find(({ type: markType }) => markType.name === typeName);

  let startIndex = $pos.index();
  let startPos = $pos.start() + start.offset;
  let endIndex = startIndex + 1;
  let endPos = startPos + start.node.nodeSize;

  // If the mark wasn't found then no range can be calculated. Exit early.
  if (!mark) {
    if ($end && endPos < $end.pos) {
      return getMarkRange($pos.doc.resolve(endPos + 1), type, $end);
    }

    return;
  }

  while (startIndex > 0 && mark.isInSet($pos.parent.child(startIndex - 1).marks)) {
    startIndex -= 1;
    startPos -= $pos.parent.child(startIndex).nodeSize;
  }

  while (endIndex < $pos.parent.childCount && mark.isInSet($pos.parent.child(endIndex).marks)) {
    endPos += $pos.parent.child(endIndex).nodeSize;
    endIndex += 1;
  }

  const text = $pos.doc.textBetween(startPos, endPos, LEAF_NODE_REPLACING_CHARACTER, '\n\n');

  return { from: startPos, to: endPos, text, mark };
}

/**
 * Get all the ranges which contain marks for the provided selection.
 */
export function getMarkRanges(selection: Selection, type: string | MarkType): GetMarkRange[] {
  const markRanges: GetMarkRange[] = [];
  const { $from, $to } = selection;
  let $pos = $from;

  while (true) {
    const range = getMarkRange($pos, type, $to);

    if (!range) {
      return markRanges;
    }

    markRanges.push(range);

    if (range.to < $to.pos) {
      $pos = $from.doc.resolve(range.to + 1);
      continue;
    }

    return markRanges;
  }
}

/**
 * Return true if the step provided an instance of any of the provided step
 * constructors.
 *
 * @param step - the step to check
 * @param StepTypes - the valid Step Constructors. Set to an empty array to
 * accept all Steps.
 */
function isValidStep(step: Step, StepTypes: Array<AnyConstructor<Step>>) {
  return StepTypes.length === 0 || StepTypes.some((Constructor) => step instanceof Constructor);
}

export interface ChangedRange extends FromToProps {
  /**
   * The previous starting position in the document.
   */
  prevFrom: number;
  /**
   * The previous ending position in the document.
   */
  prevTo: number;
}

/**
 * Deduplicate changes ranges and removes ranges that spanned by other ranges
 */
function removeOverlappingChangedRanges(ranges: ChangedRange[]): ChangedRange[] {
  const uniqueRanges = uniqueBy(
    ranges,
    ({ from, to, prevFrom, prevTo }) => `${from}_${to}_${prevFrom}_${prevTo}`,
  );

  return uniqueRanges.filter(
    (range, i, arr) =>
      !arr.some((otherRange, j) => {
        if (i === j) {
          return false;
        }

        return (
          range.prevFrom >= otherRange.prevFrom &&
          range.prevTo <= otherRange.prevTo &&
          range.from >= otherRange.from &&
          range.to <= otherRange.to
        );
      }),
  );
}

/**
 * Get all the ranges of changes for the provided transaction.
 *
 * This can be used to gather specific parts of the document which require
 * decorations to be recalculated or where nodes should be updated.
 *
 * This is adapted from the answer
 * [here](https://discuss.prosemirror.net/t/find-new-node-instances-and-track-them/96/7)
 *
 * @param tr - the transaction received with updates applied.
 * @param StepTypes - the valid Step Constructors. Set to an empty array to
 * accept all Steps.
 */
export function getChangedRanges(
  tr: Transaction,
  StepTypes: Array<AnyConstructor<Step>> = [],
): ChangedRange[] {
  const ranges: ChangedRange[] = [];
  const { steps, mapping } = tr;
  const inverseMapping = mapping.invert();

  steps.forEach((step, i) => {
    if (!isValidStep(step, StepTypes)) {
      return;
    }

    const rawRanges: FromToProps[] = [];
    const stepMap = step.getMap();
    const mappingSlice = mapping.slice(i);

    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore @see https://github.com/ProseMirror/prosemirror/issues/1075
    if (stepMap.ranges.length === 0 && isRangeStep(step)) {
      const { from, to } = step;
      rawRanges.push({ from, to });
    } else {
      stepMap.forEach((from, to) => {
        rawRanges.push({ from, to });
      });
    }

    rawRanges.forEach((range) => {
      const from = mappingSlice.map(range.from, -1);
      const to = mappingSlice.map(range.to);

      ranges.push({
        from,
        to,
        prevFrom: inverseMapping.map(from, -1),
        prevTo: inverseMapping.map(to),
      });
    });
  });

  // Sort the ranges.
  const sortedRanges = sort(ranges, (a, z) => a.from - z.from);

  return removeOverlappingChangedRanges(sortedRanges);
}

/**
 * Get all the changed node ranges for a provided transaction.
 *
 * @param tr - the transaction received with updates applied.
 * @param StepTypes - the valid Step Constructors. Set to an empty array to
 * accept all Steps.
 */
export function getChangedNodeRanges(
  tr: Transaction,
  StepTypes?: Array<AnyConstructor<Step>>,
): NodeRange[] {
  // The container of the ranges to be returned from this function.
  const nodeRanges: NodeRange[] = [];
  const ranges = getChangedRanges(tr, StepTypes);

  for (const range of ranges) {
    try {
      const $from = tr.doc.resolve(range.from);
      const $to = tr.doc.resolve(range.to);

      // Find the node range for this provided range.
      const nodeRange = $from.blockRange($to);

      // Make sure a valid node is available.
      if (nodeRange) {
        nodeRanges.push(nodeRange);
      }
    } catch {
      // Changed ranged outside the document
    }
  }

  return nodeRanges;
}

/**
 * Retrieves the text content from a slice
 *
 * @remarks
 * A utility that's useful for pulling text content from a slice which is
 * usually created via `selection.content()`
 *
 * @param slice - the prosemirror slice
 */
export function getTextContentFromSlice(slice: Slice): string {
  return slice.content.firstChild?.textContent ?? '';
}

export interface GetSelectedGroup extends FromToProps {
  /**
   * The capture text within the group.
   */
  text: string;
}

/**
 * Takes an empty selection and expands it out to the nearest group not matching
 * the excluded characters.
 *
 * @remarks
 *
 * Can be used to find the nearest selected word. See {@link getSelectedWord}
 *
 * @param state - the editor state or a transaction
 * @param exclude - the regex pattern to exclude
 * @returns false if not a text selection or if no expansion available
 */
export function getSelectedGroup(
  state: EditorState | Transaction,
  exclude: RegExp,
): GetSelectedGroup | undefined {
  if (!isTextSelection(state.selection)) {
    return;
  }

  let { from, to } = state.selection;

  const getChar = (start: number, end: number) =>
    getTextContentFromSlice(
      TextSelection.between(state.doc.resolve(start), state.doc.resolve(end)).content(),
    );

  for (
    let char = getChar(from - 1, from);
    char && !exclude.test(char);
    from--, char = getChar(from - 1, from)
  ) {
    // Step backwards until reaching first excluded character or empty text
    // content.
  }

  for (
    let char = getChar(to, to + 1);
    char && !exclude.test(char);
    to++, char = getChar(to, to + 1)
  ) {
    // Step forwards until reaching the first excluded character or empty text
    // content
  }

  if (from === to) {
    return;
  }

  const text = state.doc.textBetween(from, to, LEAF_NODE_REPLACING_CHARACTER, '\n\n');
  return { from, to, text };
}

/**
 * Retrieves the nearest space separated word from the current selection.
 *
 * @remarks
 *
 * This always expands outward so that given: `The tw<start>o words<end>` The
 * selection would become `The <start>two words<end>`
 *
 * In other words it expands until it meets an invalid character.
 *
 * @param state - the editor state or transaction.
 */
export function getSelectedWord(state: EditorState | Transaction): GetSelectedGroup | undefined {
  return getSelectedGroup(state, /\W/);
}

/**
 * Get matching string from a list or single value
 *
 * @remarks
 * Get attrs can be called with a direct match string or array of string
 * matches. This method should be used to retrieve the required string.
 *
 * The index of the matched array used defaults to 0 but can be updated via the
 * second parameter.
 *
 * @param match - the match(es)
 * @param index - the zero-index point from which to start
 */
export function getMatchString(match: string | string[], index = 0): string {
  const value = isArray(match) ? match[index] : match;

  // Throw an error if value is not defined for the index.
  assert(isString(value), `No match string found for match ${match}`);

  return value ?? '';
}

/**
 * Checks whether the cursor is at the end of the state.doc
 *
 * @param state - the editor state
 */
export function atDocEnd(state: EditorState): boolean {
  return state.doc.nodeSize - state.selection.$to.pos - 2 === state.selection.$to.depth;
}

/**
 * Checks whether the cursor is at the beginning of the state.doc
 *
 * @param state - the editor state
 */
export function atDocStart(state: EditorState): boolean {
  return state.selection.$from.pos === state.selection.$from.depth;
}

/**
 * Get the start position of the parent of the current resolve position
 *
 * @param $pos - the resolved `ProseMirror` position
 */
export function startPositionOfParent($pos: ResolvedPos): number {
  return $pos.start($pos.depth);
}

/**
 * Get the end position of the parent of the current resolve position
 *
 * @param $pos - the resolved `ProseMirror` position
 */
export function endPositionOfParent($pos: ResolvedPos): number {
  return $pos.end($pos.depth) + 1;
}

/**
 * Retrieve the current position of the cursor
 *
 * @param selection - the editor selection
 * @returns a resolved position only when the selection is a text selection
 */
export function getCursor(selection: Selection): ResolvedPos | null | undefined {
  return isTextSelection(selection) ? selection.$cursor : undefined;
}

/**
 * Checks whether a Prosemirror node is the top level `doc` node
 *
 * @param node - the prosemirror node
 * @param schema - the prosemirror schema to check against
 */
export function isDocNode(
  node: ProsemirrorNode | null | undefined,
  schema?: EditorSchema,
): node is ProsemirrorNode {
  if (!isProsemirrorNode(node)) {
    return false;
  }

  if (schema) {
    return node.type === schema.nodes.doc;
  }

  return node.type.name === 'doc';
}

/**
 * Checks whether the passed in JSON is a valid object node
 *
 * @param value - the value to check
 */
export function isRemirrorJSON(value: unknown): value is RemirrorJSON {
  return isObject(value) && value.type === 'doc' && Array.isArray(value.content);
}

/**
 * This type is the combination of all the registered string handlers for the
 * extension. This is used rather than the `StringHandlers` in order to enforce
 * the type signature of the handler method, which isn't possible with the
 * interface.
 */
export type NamedStringHandlers = { [K in keyof Remirror.StringHandlers]: StringHandler };

export interface HandlersProps {
  /**
   * All the available string handlers which have been made available for this
   * editor. Using this allows for composition of [[`StringHandler`]]'s.
   *
   * For example, the markdown string handler first converts the markdown string
   * to html and then uses the html handler to convert the html output to a
   * prosemirror step.
   *
   * Composition for the win.
   */
  handlers: NamedStringHandlers;
}

export interface CreateDocumentNodeProps
  extends SchemaProps,
    Partial<CustomDocumentProps>,
    StringHandlerProps {
  /**
   * The content to render
   */
  content: RemirrorContentType;

  /**
   * The error handler which is called when the JSON passed is invalid.
   */
  onError?: InvalidContentHandler;

  /**
   * The selection that the user should have in the created node.
   *
   * TODO add `'start' | 'end' | number` for a better developer experience.
   */
  selection?: PrimitiveSelection;

  /**
   * When an error is thrown the onError handler is called which can return new
   * content. The new content is recursively checked to see if it is valid. This
   * number is tracks the call depth of the recursive function to prevent it
   * exceeding the maximum.
   *
   * @defaultValue 0
   *
   * @internal
   */
  attempts?: number;
}

/**
 * Return true when the provided value is an anchor / head selection property
 */
export function isAnchorHeadObject(value: unknown): value is AnchorHeadProps {
  return isObject(value) && isNumber(value.anchor) && isNumber(value.head);
}

/**
 * Get the nearest valid selection to the provided selection parameter.
 */
export function getTextSelection(selection: PrimitiveSelection, doc: ProsemirrorNode): Selection {
  const max = doc.nodeSize - 2;
  const min = 0;
  let pos: number | FromToProps | AnchorHeadProps;

  /** Ensure the selection is within the current document range */
  const clampToDocument = (value: number) => clamp({ min, max, value });

  if (isSelection(selection)) {
    return selection;
  }

  if (selection === 'all') {
    return new AllSelection(doc);
  }

  if (selection === 'start') {
    pos = min;
  } else if (selection === 'end') {
    pos = max;
  } else if (isResolvedPos(selection)) {
    pos = selection.pos;
  } else {
    pos = selection;
  }

  if (isNumber(pos)) {
    pos = clampToDocument(pos);

    return TextSelection.near(doc.resolve(pos));
  }

  if (isAnchorHeadObject(pos)) {
    const anchor = clampToDocument(pos.anchor);
    const head = clampToDocument(pos.head);

    return TextSelection.between(doc.resolve(anchor), doc.resolve(head));
  }

  // In this case assume that `from` is the fixed anchor and `to` is the movable
  // head.
  const anchor = clampToDocument(pos.from);
  const head = clampToDocument(pos.to);

  return TextSelection.between(doc.resolve(anchor), doc.resolve(head));
}

/**
 * A function that converts a string into a `ProsemirrorNode`.
 */
export interface StringHandler {
  (params: NodeStringHandlerOptions): ProsemirrorNode;
  (params: FragmentStringHandlerOptions): Fragment;
}

export interface StringHandlerProps {
  /**
   * A function which transforms a string into a prosemirror node.
   *
   * @remarks
   * Can be used to transform markdown / html or any other string format into a
   * prosemirror node.
   *
   * See [[`fromHTML`]] for an example of how this could work.
   */
  stringHandler?: StringHandler;
}

// The maximum attempts to check invalid content before throwing an an error.
const MAX_ATTEMPTS = 3;

/**
 * Creates a document node from the passed in content and schema.
 *
 * @remirror
 *
 * This supports a primitive form of error handling. When an error occurs, the
 * `onError` handler will be called along with the error produced by the Schema
 * and it is up to you as a developer to decide how to transform the invalid
 * content into valid content.
 *
 * Please note that the `onError` is only called when the content is a JSON
 * object. It is not called for a `string`, the `ProsemirrorNode` or the
 * `EditorState`. The reason for this is that the `string` requires a `stringHandler`
 * which is defined by the developer and transforms the content. That is the
 * point that error's should be handled. The editor state and the
 * `ProsemirrorNode` are similar. They need to be created by the developer and
 * as a result, the errors should be handled at the point of creation rather
 * than when the document is being applied to the editor.
 */
export function createDocumentNode(props: CreateDocumentNodeProps): ProsemirrorNode {
  const { content, schema, document, stringHandler, onError, attempts = 0 } = props;

  // If there is an `onError` handler then check the attempts does not exceed
  // the maximum, otherwise only allow one attempt.
  const attemptsRemaining = (onError && attempts <= MAX_ATTEMPTS) || attempts === 0;

  invariant(attemptsRemaining, {
    code: ErrorConstant.INVALID_CONTENT,
    message:
      'The invalid content has been called recursively more than ${MAX_ATTEMPTS} times. The content is invalid and the error handler has not been able to recover properly.',
  });

  if (isString(content)) {
    invariant(stringHandler, {
      code: ErrorConstant.INVALID_CONTENT,
      message: `The string '${content}' was added to the editor, but no \`stringHandler\` was added. Please provide a valid string handler which transforms your content to a \`ProsemirrorNode\` to prevent this error.`,
    });

    const options = { document, content, schema };

    // With string content it is up to you the developer to ensure there are no
    // errors in the produced content.
    return stringHandler(options);
  }

  // If passing in an editor state, it is left to the developer to make sure the
  // state they created is valid.
  if (isEditorState(content)) {
    return content.doc;
  }

  // When passing the prosemirror no error checking is done. Before creating the
  // node you should manually ensure that it is valid.
  if (isProsemirrorNode(content)) {
    return content;
  }

  // At this point the only possible solution is that the content is a json
  // object so we try to convert the json to a valid object.

  try {
    // This will throw an error for invalid content.
    return schema.nodeFromJSON(content);
  } catch (error: any) {
    const details = getInvalidContent({ schema, error, json: content });
    const transformedContent = onError?.(details);

    invariant(transformedContent, {
      code: ErrorConstant.INVALID_CONTENT,
      message: `An error occurred when processing the content. Please provide an \`onError\` handler to process the invalid content: ${JSON.stringify(
        details.invalidContent,
        null,
        2,
      )}`,
    });

    return createDocumentNode({
      ...props,
      content: transformedContent,
      attempts: attempts + 1,
    });
  }
}

/**
 * Checks which environment should be used. Returns true when we are in the dom
 * environment.
 */
export function shouldUseDomEnvironment(): boolean {
  return environment.isBrowser;
}

/**
 * Retrieves the document from global scope and throws an error in a non-browser
 * environment.
 *
 * @internal
 */
export function getDocument(): Document {
  const document = getDomDocument();

  if (document) {
    return document;
  }

  throw new Error(
    'Unable to retrieve the document from the global scope. \n' +
      'It seems that you are running Remirror in a non-browser environment. ' +
      'Remirror need browser APIs to work. \n' +
      'If you are using Jest (or other testing frameworks), make sure that ' +
      'you are using the JSDOM environment (https://jestjs.io/docs/29.0/configuration#testenvironment-string). \n' +
      'If you are using Next.js (or other server-side rendering frameworks), ' +
      'please use dynamic import with `ssr: false` to load the editor component ' +
      'without rendering it on the server (https://nextjs.org/docs/advanced-features/dynamic-import#with-no-ssr). \n' +
      'If you are using Node.js, you can install JSDOM and Remirror will try to use it automatically, ' +
      'or you can create a fake document and pass it to Remirror',
  );
}

/**
 * @internal
 */
export function maybeGetWindowFromDocument(
  document?: Document | null,
): (Window & typeof globalThis) | null | undefined {
  return (
    document?.defaultView ??
    (typeof window !== 'undefined' ? window : undefined) ??
    getDomDocument()?.defaultView
  );
}

/**
 * @internal
 */
export function maybeGetWindowFromElement(
  element?: Element | HTMLElement | null,
): (Window & typeof globalThis) | null | undefined {
  return maybeGetWindowFromDocument(element?.ownerDocument);
}

/**
 * @internal
 */
export function getWindowFromDocument(document?: Document | null): Window & typeof globalThis {
  const view = maybeGetWindowFromDocument(document) ?? getDocument().defaultView;

  if (view) {
    return view;
  }

  throw new Error('Unable to retrieve the window from the global scope');
}

/**
 * @internal
 */
export function getWindowFromElement(
  element?: Element | HTMLElement | null,
): Window & typeof globalThis {
  return getWindowFromDocument(element?.ownerDocument);
}

export interface CustomDocumentProps {
  /**
   * The root or custom document to use when referencing the dom.
   *
   * This can be used to support SSR.
   */
  document: Document;
}

/**
 * Convert a node into its DOM representative
 *
 * @param node - the node to extract html from.
 * @param document - the document to use for the DOM
 */
export function prosemirrorNodeToDom(
  node: ProsemirrorNode,
  document = getDocument(),
): DocumentFragment | HTMLElement {
  const fragment = isDocNode(node, node.type.schema) ? node.content : Fragment.from(node);
  return DOMSerializer.fromSchema(node.type.schema).serializeFragment(fragment, { document });
}

function elementFromString(html: string, document?: Document): HTMLElement {
  const parser = new (getWindowFromDocument(document).DOMParser)();
  return parser.parseFromString(`<body>${html}</body>`, 'text/html').body;
}

/**
 * Convert the provided `node` to a html string.
 *
 * @param node - the node to extract html from.
 * @param document - the document to use for the DOM
 *
 * ```ts
 * import { EditorState, prosemirrorNodeToHtml } from 'remirror';
 *
 * function convertStateToHtml(state: EditorState): string {
 *   return prosemirrorNodeToHtml(state.doc);
 * }
 * ```
 */
export function prosemirrorNodeToHtml(node: ProsemirrorNode, document = getDocument()): string {
  const element = document.createElement('div');
  element.append(prosemirrorNodeToDom(node, document));

  return element.innerHTML;
}

export interface BaseStringHandlerOptions
  extends Partial<CustomDocumentProps>,
    SchemaProps,
    ParseOptions {
  /**
   * The string content provided to the editor.
   */
  content: string;
}

export interface FragmentStringHandlerOptions extends BaseStringHandlerOptions {
  /**
   * When true will create a fragment from the provided string.
   */
  fragment: true;
}

export interface NodeStringHandlerOptions extends BaseStringHandlerOptions {
  fragment?: false;
}

export type StringHandlerOptions = NodeStringHandlerOptions | FragmentStringHandlerOptions;

/**
 * Convert a HTML string into a ProseMirror node. This can be used for the
 * `stringHandler` property in your editor when you want to support html.
 *
 * ```tsx
 * import { htmlToProsemirrorNode } from 'remirror';
 * import { Remirror, useManager } from '@remirror/react';
 *
 * const Editor = () => {
 *   const manager = useManager([]);
 *
 *   return (
 *     <Remirror
 *       stringHandler={htmlToProsemirrorNode}
 *       initialContent='<p>A wise person once told me to relax</p>'
 *     >
 *       <div />
 *     </Remirror>
 *   );
 * }
 * ```
 */
export function htmlToProsemirrorNode(props: FragmentStringHandlerOptions): Fragment;
export function htmlToProsemirrorNode(props: NodeStringHandlerOptions): ProsemirrorNode;
export function htmlToProsemirrorNode(props: StringHandlerOptions): ProsemirrorNode | Fragment {
  const { content, schema, document, fragment = false, ...parseOptions } = props;
  const element = elementFromString(content, document);
  const parser = PMDomParser.fromSchema(schema);

  return fragment
    ? parser.parseSlice(element, { ...defaultParseOptions, ...parseOptions }).content
    : parser.parse(element, { ...defaultParseOptions, ...parseOptions });
}

const defaultParseOptions = { preserveWhitespace: false } as const;

/**
 * A wrapper around `state.doc.toJSON` which returns the state as a
 * `RemirrorJSON` object.
 */
export function getRemirrorJSON(content: EditorState | ProsemirrorNode): RemirrorJSON {
  return isProsemirrorNode(content)
    ? (content.toJSON() as RemirrorJSON)
    : (content.doc.toJSON() as RemirrorJSON);
}

interface IsStateEqualOptions {
  /**
   * Whether to compare the selection of the two states.
   *
   * @defaultValue false
   */
  checkSelection?: boolean;
}

/**
 * Check if two states are equal.
 */
export function areStatesEqual(
  stateA: EditorState,
  stateB: EditorState,
  options: IsStateEqualOptions = {},
): boolean {
  // The states are identical, so they're equal.
  if (stateA === stateB) {
    return true;
  }

  // If the content is different then, no, not equal.
  if (!stateA.doc.eq(stateB.doc)) {
    return false;
  }

  // If we care about selection and selection is not the same, then not equal.
  if (options.checkSelection && !stateA.selection.eq(stateB.selection)) {
    return false;
  }

  // If the schema are not compatible then no, not equal.
  if (!areSchemasCompatible(stateA.schema, stateB.schema)) {
    return false;
  }

  return true;
}

/**
 * Check that the nodes and marks present on `schemaA` are also present on
 * `schemaB`.
 */
export function areSchemasCompatible(schemaA: EditorSchema, schemaB: EditorSchema): boolean {
  if (schemaA === schemaB) {
    return true;
  }

  const marksA = keys(schemaA.marks);
  const marksB = keys(schemaB.marks);
  const nodesA = keys(schemaA.nodes);
  const nodesB = keys(schemaB.nodes);

  if (marksA.length !== marksB.length || nodesA.length !== nodesB.length) {
    return false;
  }

  for (const mark of marksA) {
    // No reverse check needed since we know the keys are unique and the lengths
    // are identical.
    if (!marksB.includes(mark)) {
      return false;
    }
  }

  for (const node of nodesA) {
    // No reverse check needed since we know the keys are unique and the lengths
    // are identical.
    if (!nodesB.includes(node)) {
      return false;
    }
  }

  return true;
}

/**
 * Return attributes for a node excluding those that were provided as extra
 * attributes.
 *
 * @param attrs - The source attributes
 * @param extra - The extra attribute schema for this node
 */
export function omitExtraAttributes<Output extends object = DOMCompatibleAttributes>(
  attrs: ProsemirrorAttributes,
  extra: ApplySchemaAttributes,
): Omit<Output, keyof Remirror.Attributes> {
  const extraAttributeNames = keys(extra.defaults());
  return omit({ ...attrs }, extraAttributeNames) as Output;
}

/**
 * Take the `style` string attribute and combine it with the provided style
 * object.
 */
export function joinStyles(styleObject: object, initialStyles?: string): string {
  let start = '';

  if (initialStyles) {
    start = `${initialStyles.trim()}`;
  }

  const end = cssifyObject(styleObject as StyleObject);

  if (!end) {
    return start;
  }

  const separator = start.endsWith(';') ? ' ' : ' ';
  return `${start}${separator}${end}`;
}

interface TextBetweenProps extends FromToProps {
  /**
   * The prosemirror `doc` node.
   */
  doc: ProsemirrorNode;
}

interface TextBetween extends PosProps, TextProps {}

/**
 * Find the different ranges of text between a provided range with support for
 * traversing multiple nodes.
 */
export function textBetween(props: TextBetweenProps): TextBetween[] {
  const { from, to, doc } = props;
  const positions: TextBetween[] = [];

  doc.nodesBetween(from, to, (node, pos) => {
    if (!node.isText || !node.text) {
      return;
    }

    const offset = Math.max(from, pos) - pos;
    positions.push({
      pos: pos + offset,
      text: node.text.slice(offset, to - pos),
    });
  });

  return positions;
}

/**
 * Get the full range of the selectable content in the ProseMirror `doc`.
 */
export function getDocRange(doc: ProsemirrorNode): FromToProps {
  const { from, to } = new AllSelection(doc);
  return { from, to };
}

/**
 * A description of an invalid content block (representing a node or a mark).
 */
export interface InvalidContentBlock {
  /**
   * The type of content that is invalid.
   */
  type: 'mark' | 'node';

  /**
   * The name of the node or mark that is invalid.
   */
  name: string;

  /**
   * The json path to the invalid part of the `RemirrorJSON` object.
   */
  path: Array<string | number>;

  /**
   * Whether this block already has an invalid parent node. Invalid blocks are
   * displayed from the deepest content outward. By checking whether a parent
   * has already been identified as invalid you can choose to only transform the
   * root invalid node.
   */
  invalidParentNode: boolean;

  /**
   * Whether this block has any invalid wrapping marks.
   */
  invalidParentMark: boolean;
}

/**
 * This interface is used when there is an attempt to add content to a schema
 */
export interface InvalidContentHandlerProps {
  /**
   * The JSON representation of the content that caused the error.
   */
  json: RemirrorJSON;

  /**
   * The list of invalid nodes and marks.
   */
  invalidContent: InvalidContentBlock[];

  /**
   * The error that was thrown.
   */
  error: Error;

  /**
   * Transformers can be used to apply certain strategies for dealing with
   * invalid content.
   */
  transformers: typeof transformers;
}

/**
 * The error handler function which should return a valid content type to
 * prevent further errors.
 */
export type InvalidContentHandler = (props: InvalidContentHandlerProps) => RemirrorContentType;

const transformers = {
  /**
   * Remove every invalid block from the editor. This is a destructive action
   * and should only be applied if you're sure it's the best strategy.
   *
   * @param json - the content as a json object.
   * @param invalidContent - the list of invalid items as passed to the error
   * handler.
   */
  remove(json: RemirrorJSON, invalidContent: InvalidContentBlock[]): RemirrorJSON {
    let newJSON = json;

    for (const block of invalidContent) {
      if (block.invalidParentNode) {
        continue;
      }

      newJSON = unset(block.path, newJSON) as RemirrorJSON;
    }

    return newJSON;
  },
};

type GetInvalidContentProps<Extra extends object> = SchemaProps & {
  /**
   * The RemirrorJSON representation of the invalid content.
   */
  json: RemirrorJSON;
} & Extra;

type GetInvalidContentReturn<Extra extends object> = Omit<InvalidContentHandlerProps, 'error'> &
  Extra;

/**
 * Get the invalid parameter which is passed to the `onError` handler.
 */
export function getInvalidContent<Extra extends object>({
  json,
  schema,
  ...extra
}: GetInvalidContentProps<Extra>): GetInvalidContentReturn<Extra> {
  const validMarks = new Set(keys(schema.marks));
  const validNodes = new Set(keys(schema.nodes));
  const invalidContent = checkForInvalidContent({ json, path: [], validNodes, validMarks });

  return { json, invalidContent, transformers, ...extra } as GetInvalidContentReturn<Extra>;
}

interface CheckForInvalidContentProps {
  json: RemirrorJSON;
  validMarks: Set<string>;
  validNodes: Set<string>;
  path?: string[];
  invalidParentNode?: boolean;
  invalidParentMark?: boolean;
}

/**
 * Get the invalid content from the `RemirrorJSON`.
 */
function checkForInvalidContent(props: CheckForInvalidContentProps): InvalidContentBlock[] {
  const { json, validMarks, validNodes, path = [] } = props;
  const valid = { validMarks, validNodes };
  const invalidNodes: InvalidContentBlock[] = [];
  const { type, marks, content } = json;
  let { invalidParentMark = false, invalidParentNode = false } = props;

  if (marks) {
    const invalidMarks: InvalidContentBlock[] = [];

    for (const [index, mark] of marks.entries()) {
      const name = isString(mark) ? mark : mark.type;

      if (validMarks.has(name)) {
        continue;
      }

      invalidMarks.unshift({
        name,
        path: [...path, 'marks', `${index}`],
        type: 'mark',
        invalidParentMark,
        invalidParentNode,
      });

      invalidParentMark = true;
    }

    invalidNodes.push(...invalidMarks);
  }

  if (!validNodes.has(type)) {
    invalidNodes.push({
      name: type,
      type: 'node',
      path,
      invalidParentMark,
      invalidParentNode,
    });

    invalidParentNode = true;
  }

  if (content) {
    const invalidContent: InvalidContentBlock[] = [];

    for (const [index, value] of content.entries()) {
      invalidContent.unshift(
        ...checkForInvalidContent({
          ...valid,
          json: value,
          path: [...path, 'content', `${index}`],
          invalidParentMark,
          invalidParentNode,
        }),
      );
    }

    invalidNodes.unshift(...invalidContent);
  }

  return invalidNodes;
}

/**
 * Checks that the selection is an empty text selection at the end of its parent
 * node.
 */
export function isEndOfTextBlock(selection: Selection): selection is TextSelection {
  return !!(
    isTextSelection(selection) &&
    selection.$cursor &&
    selection.$cursor.parentOffset >= selection.$cursor.parent.content.size
  );
}

/**
 * Checks that the selection is an empty text selection at the start of its
 * parent node.
 */
export function isStartOfTextBlock(selection: Selection): selection is TextSelection {
  return !!(isTextSelection(selection) && selection.$cursor && selection.$cursor.parentOffset <= 0);
}

/**
 * Returns true when the selection is a text selection at the start of the
 * document.
 */
export function isStartOfDoc(selection: Selection): boolean {
  const selectionAtStart = PMSelection.atStart(selection.$anchor.doc);
  return !!(isStartOfTextBlock(selection) && selectionAtStart.anchor === selection.anchor);
}

declare global {
  namespace Remirror {
    /**
     * This interface provides all the named string handlers. The key is the
     * only part that's used meaning the value isn't important. However, it's
     * conventional to use the Extension for the value.
     */
    interface StringHandlers {}
  }
}