BookStackApp/BookStack

View on GitHub
resources/js/wysiwyg/lexical/selection/__tests__/utils/index.ts

Summary

Maintainability
F
2 wks
Test Coverage
/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 */

import {
  $createTextNode,
  $getSelection,
  $isNodeSelection,
  $isRangeSelection,
  $isTextNode,
  LexicalEditor,
  PointType,
} from 'lexical';

Object.defineProperty(HTMLElement.prototype, 'contentEditable', {
  get() {
    return this.getAttribute('contenteditable');
  },

  set(value) {
    this.setAttribute('contenteditable', value);
  },
});

type Segment = {
  index: number;
  isWordLike: boolean;
  segment: string;
};

if (!Selection.prototype.modify) {
  const wordBreakPolyfillRegex =
    /[\s.,\\/#!$%^&*;:{}=\-`~()\uD800-\uDBFF\uDC00-\uDFFF\u3000-\u303F]/u;

  const pushSegment = function (
    segments: Array<Segment>,
    index: number,
    str: string,
    isWordLike: boolean,
  ): void {
    segments.push({
      index: index - str.length,
      isWordLike,
      segment: str,
    });
  };

  const getWordsFromString = function (string: string): Array<Segment> {
    const segments: Segment[] = [];
    let wordString = '';
    let nonWordString = '';
    let i;

    for (i = 0; i < string.length; i++) {
      const char = string[i];

      if (wordBreakPolyfillRegex.test(char)) {
        if (wordString !== '') {
          pushSegment(segments, i, wordString, true);
          wordString = '';
        }

        nonWordString += char;
      } else {
        if (nonWordString !== '') {
          pushSegment(segments, i, nonWordString, false);
          nonWordString = '';
        }

        wordString += char;
      }
    }

    if (wordString !== '') {
      pushSegment(segments, i, wordString, true);
    }

    if (nonWordString !== '') {
      pushSegment(segments, i, nonWordString, false);
    }

    return segments;
  };

  Selection.prototype.modify = function (alter, direction, granularity) {
    // This is not a thorough implementation, it was more to get tests working
    // given the refactor to use this selection method.
    const symbol = Object.getOwnPropertySymbols(this)[0];
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const impl = (this as any)[symbol];
    const focus = impl._focus;
    const anchor = impl._anchor;

    if (granularity === 'character') {
      let anchorNode = anchor.node;
      let anchorOffset = anchor.offset;
      let _$isTextNode = false;

      if (anchorNode.nodeType === 3) {
        _$isTextNode = true;
        anchorNode = anchorNode.parentElement;
      } else if (anchorNode.nodeName === 'BR') {
        const parentNode = anchorNode.parentElement;
        const childNodes = Array.from(parentNode.childNodes);
        anchorOffset = childNodes.indexOf(anchorNode);
        anchorNode = parentNode;
      }

      if (direction === 'backward') {
        if (anchorOffset === 0) {
          let prevSibling = anchorNode.previousSibling;

          if (prevSibling === null) {
            prevSibling = anchorNode.parentElement.previousSibling.lastChild;
          }

          if (prevSibling.nodeName === 'P') {
            prevSibling = prevSibling.firstChild;
          }

          if (prevSibling.nodeName === 'BR') {
            anchor.node = prevSibling;
            anchor.offset = 0;
          } else {
            anchor.node = prevSibling.firstChild;
            anchor.offset = anchor.node.nodeValue.length - 1;
          }
        } else if (!_$isTextNode) {
          anchor.node = anchorNode.childNodes[anchorOffset - 1];
          anchor.offset = anchor.node.nodeValue.length - 1;
        } else {
          anchor.offset--;
        }
      } else {
        if (
          (_$isTextNode && anchorOffset === anchorNode.textContent.length) ||
          (!_$isTextNode &&
            (anchorNode.childNodes.length === anchorOffset ||
              (anchorNode.childNodes.length === 1 &&
                anchorNode.firstChild.nodeName === 'BR')))
        ) {
          let nextSibling = anchorNode.nextSibling;

          if (nextSibling === null) {
            nextSibling = anchorNode.parentElement.nextSibling.lastChild;
          }

          if (nextSibling.nodeName === 'P') {
            nextSibling = nextSibling.lastChild;
          }

          if (nextSibling.nodeName === 'BR') {
            anchor.node = nextSibling;
            anchor.offset = 0;
          } else {
            anchor.node = nextSibling.firstChild;
            anchor.offset = 0;
          }
        } else {
          anchor.offset++;
        }
      }
    } else if (granularity === 'word') {
      const anchorNode = this.anchorNode!;
      const targetTextContent =
        direction === 'backward'
          ? anchorNode.textContent!.slice(0, this.anchorOffset)
          : anchorNode.textContent!.slice(this.anchorOffset);
      const segments = getWordsFromString(targetTextContent);
      const segmentsLength = segments.length;
      let index = anchor.offset;
      let foundWordNode = false;

      if (direction === 'backward') {
        for (let i = segmentsLength - 1; i >= 0; i--) {
          const segment = segments[i];
          const nextIndex = segment.index;

          if (segment.isWordLike) {
            index = nextIndex;
            foundWordNode = true;
          } else if (foundWordNode) {
            break;
          } else {
            index = nextIndex;
          }
        }
      } else {
        for (let i = 0; i < segmentsLength; i++) {
          const segment = segments[i];
          const nextIndex = segment.index + segment.segment.length;

          if (segment.isWordLike) {
            index = nextIndex;
            foundWordNode = true;
          } else if (foundWordNode) {
            break;
          } else {
            index = nextIndex;
          }
        }
      }

      if (direction === 'forward') {
        index += anchor.offset;
      }

      anchor.offset = index;
    }

    if (alter === 'move') {
      focus.offset = anchor.offset;
      focus.node = anchor.node;
    }
  };
}

export function printWhitespace(whitespaceCharacter: string) {
  return whitespaceCharacter.charCodeAt(0) === 160
    ? '&nbsp;'
    : whitespaceCharacter;
}

export function insertText(text: string) {
  return {
    text,
    type: 'insert_text',
  };
}

export function insertTokenNode(text: string) {
  return {
    text,
    type: 'insert_token_node',
  };
}

export function insertSegmentedNode(text: string) {
  return {
    text,
    type: 'insert_segmented_node',
  };
}

export function convertToTokenNode() {
  return {
    text: null,
    type: 'convert_to_token_node',
  };
}

export function convertToSegmentedNode() {
  return {
    text: null,
    type: 'convert_to_segmented_node',
  };
}

export function insertParagraph() {
  return {
    type: 'insert_paragraph',
  };
}

export function deleteWordBackward(n: number | null | undefined) {
  return {
    text: null,
    times: n,
    type: 'delete_word_backward',
  };
}

export function deleteWordForward(n: number | null | undefined) {
  return {
    text: null,
    times: n,
    type: 'delete_word_forward',
  };
}

export function moveBackward(n: number | null | undefined) {
  return {
    text: null,
    times: n,
    type: 'move_backward',
  };
}

export function moveForward(n: number | null | undefined) {
  return {
    text: null,
    times: n,
    type: 'move_forward',
  };
}

export function moveEnd() {
  return {
    type: 'move_end',
  };
}

export function deleteBackward(n: number | null | undefined) {
  return {
    text: null,
    times: n,
    type: 'delete_backward',
  };
}

export function deleteForward(n: number | null | undefined) {
  return {
    text: null,
    times: n,
    type: 'delete_forward',
  };
}

export function formatBold() {
  return {
    format: 'bold',
    type: 'format_text',
  };
}

export function formatItalic() {
  return {
    format: 'italic',
    type: 'format_text',
  };
}

export function formatStrikeThrough() {
  return {
    format: 'strikethrough',
    type: 'format_text',
  };
}

export function formatUnderline() {
  return {
    format: 'underline',
    type: 'format_text',
  };
}

export function redo(n: number | null | undefined) {
  return {
    text: null,
    times: n,
    type: 'redo',
  };
}

export function undo(n: number | null | undefined) {
  return {
    text: null,
    times: n,
    type: 'undo',
  };
}

export function pastePlain(text: string) {
  return {
    text: text,
    type: 'paste_plain',
  };
}

export function pasteLexical(text: string) {
  return {
    text: text,
    type: 'paste_lexical',
  };
}

export function pasteHTML(text: string) {
  return {
    text: text,
    type: 'paste_html',
  };
}

export function moveNativeSelection(
  anchorPath: number[],
  anchorOffset: number,
  focusPath: number[],
  focusOffset: number,
) {
  return {
    anchorOffset,
    anchorPath,
    focusOffset,
    focusPath,
    type: 'move_native_selection',
  };
}

export function getNodeFromPath(path: number[], rootElement: Node) {
  let node = rootElement;

  for (let i = 0; i < path.length; i++) {
    node = node.childNodes[path[i]];
  }

  return node;
}

export function setNativeSelection(
  anchorNode: Node,
  anchorOffset: number,
  focusNode: Node,
  focusOffset: number,
) {
  const domSelection = window.getSelection()!;
  const range = document.createRange();
  range.setStart(anchorNode, anchorOffset);
  range.setEnd(focusNode, focusOffset);
  domSelection.removeAllRanges();
  domSelection.addRange(range);
  Promise.resolve().then(() => {
    document.dispatchEvent(new Event('selectionchange'));
  });
}

export function setNativeSelectionWithPaths(
  rootElement: Node,
  anchorPath: number[],
  anchorOffset: number,
  focusPath: number[],
  focusOffset: number,
) {
  const anchorNode = getNodeFromPath(anchorPath, rootElement);
  const focusNode = getNodeFromPath(focusPath, rootElement);
  setNativeSelection(anchorNode, anchorOffset, focusNode, focusOffset);
}

function getLastTextNode(startingNode: Node) {
  let node = startingNode;

  mainLoop: while (node !== null) {
    if (node !== startingNode && node.nodeType === 3) {
      return node;
    }

    const child = node.lastChild;

    if (child !== null) {
      node = child;
      continue;
    }

    const previousSibling = node.previousSibling;

    if (previousSibling !== null) {
      node = previousSibling;
      continue;
    }

    let parent = node.parentNode;

    while (parent !== null) {
      const parentSibling = parent.previousSibling;

      if (parentSibling !== null) {
        node = parentSibling;
        continue mainLoop;
      }

      parent = parent.parentNode;
    }
  }

  return null;
}

function getNextTextNode(startingNode: Node) {
  let node = startingNode;

  mainLoop: while (node !== null) {
    if (node !== startingNode && node.nodeType === 3) {
      return node;
    }

    const child = node.firstChild;

    if (child !== null) {
      node = child;
      continue;
    }

    const nextSibling = node.nextSibling;

    if (nextSibling !== null) {
      node = nextSibling;
      continue;
    }

    let parent = node.parentNode;

    while (parent !== null) {
      const parentSibling = parent.nextSibling;

      if (parentSibling !== null) {
        node = parentSibling;
        continue mainLoop;
      }

      parent = parent.parentNode;
    }
  }

  return null;
}

function moveNativeSelectionBackward() {
  const domSelection = window.getSelection()!;
  let anchorNode = domSelection.anchorNode!;
  let anchorOffset = domSelection.anchorOffset!;

  if (domSelection.isCollapsed) {
    const target = (
      anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode
    )!;
    const keyDownEvent = new KeyboardEvent('keydown', {
      bubbles: true,
      cancelable: true,
      key: 'ArrowLeft',
      keyCode: 37,
    });
    target.dispatchEvent(keyDownEvent);

    if (!keyDownEvent.defaultPrevented) {
      if (anchorNode.nodeType === 3) {
        if (anchorOffset === 0) {
          const lastTextNode = getLastTextNode(anchorNode);

          if (lastTextNode === null) {
            throw new Error('moveNativeSelectionBackward: TODO');
          } else {
            const textLength = lastTextNode.nodeValue!.length;
            setNativeSelection(
              lastTextNode,
              textLength,
              lastTextNode,
              textLength,
            );
          }
        } else {
          setNativeSelection(
            anchorNode,
            anchorOffset - 1,
            anchorNode,
            anchorOffset - 1,
          );
        }
      } else if (anchorNode.nodeType === 1) {
        if (anchorNode.nodeName === 'BR') {
          const parentNode = anchorNode.parentNode!;
          const childNodes = Array.from(parentNode.childNodes);
          anchorOffset = childNodes.indexOf(anchorNode as ChildNode);
          anchorNode = parentNode;
        } else {
          anchorOffset--;
        }

        setNativeSelection(anchorNode, anchorOffset, anchorNode, anchorOffset);
      } else {
        throw new Error('moveNativeSelectionBackward: TODO');
      }
    }

    const keyUpEvent = new KeyboardEvent('keyup', {
      bubbles: true,
      cancelable: true,
      key: 'ArrowLeft',
      keyCode: 37,
    });
    target.dispatchEvent(keyUpEvent);
  } else {
    throw new Error('moveNativeSelectionBackward: TODO');
  }
}

function moveNativeSelectionForward() {
  const domSelection = window.getSelection()!;
  const anchorNode = domSelection.anchorNode!;
  const anchorOffset = domSelection.anchorOffset!;

  if (domSelection.isCollapsed) {
    const target = (
      anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode
    )!;
    const keyDownEvent = new KeyboardEvent('keydown', {
      bubbles: true,
      cancelable: true,
      key: 'ArrowRight',
      keyCode: 39,
    });
    target.dispatchEvent(keyDownEvent);

    if (!keyDownEvent.defaultPrevented) {
      if (anchorNode.nodeType === 3) {
        const text = anchorNode.nodeValue!;

        if (text.length === anchorOffset) {
          const nextTextNode = getNextTextNode(anchorNode);

          if (nextTextNode === null) {
            throw new Error('moveNativeSelectionForward: TODO');
          } else {
            setNativeSelection(nextTextNode, 0, nextTextNode, 0);
          }
        } else {
          setNativeSelection(
            anchorNode,
            anchorOffset + 1,
            anchorNode,
            anchorOffset + 1,
          );
        }
      } else {
        throw new Error('moveNativeSelectionForward: TODO');
      }
    }

    const keyUpEvent = new KeyboardEvent('keyup', {
      bubbles: true,
      cancelable: true,
      key: 'ArrowRight',
      keyCode: 39,
    });
    target.dispatchEvent(keyUpEvent);
  } else {
    throw new Error('moveNativeSelectionForward: TODO');
  }
}

export async function applySelectionInputs(
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  inputs: Record<string, any>[],
  update: (fn: () => void) => Promise<void>,
  editor: LexicalEditor,
) {
  const rootElement = editor.getRootElement()!;

  for (let i = 0; i < inputs.length; i++) {
    const input = inputs[i];
    const times = input?.times ?? 1;

    for (let j = 0; j < times; j++) {
      await update(() => {
        const selection = $getSelection()!;

        switch (input.type) {
          case 'insert_text': {
            selection.insertText(input.text);
            break;
          }

          case 'insert_paragraph': {
            if ($isRangeSelection(selection)) {
              selection.insertParagraph();
            }
            break;
          }

          case 'move_backward': {
            moveNativeSelectionBackward();
            break;
          }

          case 'move_forward': {
            moveNativeSelectionForward();
            break;
          }

          case 'move_end': {
            if ($isRangeSelection(selection)) {
              const anchorNode = selection.anchor.getNode();
              if ($isTextNode(anchorNode)) {
                anchorNode.select();
              }
            }
            break;
          }

          case 'delete_backward': {
            if ($isRangeSelection(selection)) {
              selection.deleteCharacter(true);
            }
            break;
          }

          case 'delete_forward': {
            if ($isRangeSelection(selection)) {
              selection.deleteCharacter(false);
            }
            break;
          }

          case 'delete_word_backward': {
            if ($isRangeSelection(selection)) {
              selection.deleteWord(true);
            }
            break;
          }

          case 'delete_word_forward': {
            if ($isRangeSelection(selection)) {
              selection.deleteWord(false);
            }
            break;
          }

          case 'format_text': {
            if ($isRangeSelection(selection)) {
              selection.formatText(input.format);
            }
            break;
          }

          case 'move_native_selection': {
            setNativeSelectionWithPaths(
              rootElement,
              input.anchorPath,
              input.anchorOffset,
              input.focusPath,
              input.focusOffset,
            );
            break;
          }

          case 'insert_token_node': {
            const text = $createTextNode(input.text);
            text.setMode('token');
            if ($isRangeSelection(selection)) {
              selection.insertNodes([text]);
            }
            break;
          }

          case 'insert_segmented_node': {
            const text = $createTextNode(input.text);
            text.setMode('segmented');
            if ($isRangeSelection(selection)) {
              selection.insertNodes([text]);
            }
            text.selectNext();
            break;
          }

          case 'convert_to_token_node': {
            const text = $createTextNode(selection.getTextContent());
            text.setMode('token');
            if ($isRangeSelection(selection)) {
              selection.insertNodes([text]);
            }
            text.selectNext();
            break;
          }

          case 'convert_to_segmented_node': {
            const text = $createTextNode(selection.getTextContent());
            text.setMode('segmented');
            if ($isRangeSelection(selection)) {
              selection.insertNodes([text]);
            }
            text.selectNext();
            break;
          }

          case 'undo': {
            rootElement.dispatchEvent(
              new KeyboardEvent('keydown', {
                bubbles: true,
                cancelable: true,
                ctrlKey: true,
                key: 'z',
                keyCode: 90,
              }),
            );
            break;
          }

          case 'redo': {
            rootElement.dispatchEvent(
              new KeyboardEvent('keydown', {
                bubbles: true,
                cancelable: true,
                ctrlKey: true,
                key: 'z',
                keyCode: 90,
                shiftKey: true,
              }),
            );
            break;
          }

          case 'paste_plain': {
            rootElement.dispatchEvent(
              Object.assign(
                new Event('paste', {
                  bubbles: true,
                  cancelable: true,
                }),
                {
                  clipboardData: {
                    getData: (type: string) => {
                      if (type === 'text/plain') {
                        return input.text;
                      }

                      return '';
                    },
                  },
                },
              ),
            );
            break;
          }

          case 'paste_lexical': {
            rootElement.dispatchEvent(
              Object.assign(
                new Event('paste', {
                  bubbles: true,
                  cancelable: true,
                }),
                {
                  clipboardData: {
                    getData: (type: string) => {
                      if (type === 'application/x-lexical-editor') {
                        return input.text;
                      }

                      return '';
                    },
                  },
                },
              ),
            );
            break;
          }

          case 'paste_html': {
            rootElement.dispatchEvent(
              Object.assign(
                new Event('paste', {
                  bubbles: true,
                  cancelable: true,
                }),
                {
                  clipboardData: {
                    getData: (type: string) => {
                      if (type === 'text/html') {
                        return input.text;
                      }

                      return '';
                    },
                  },
                },
              ),
            );
            break;
          }
        }
      });
    }
  }
}

export function $setAnchorPoint(
  point: Pick<PointType, 'type' | 'offset' | 'key'>,
) {
  const selection = $getSelection();

  if (!$isRangeSelection(selection)) {
    const dummyTextNode = $createTextNode();
    dummyTextNode.select();
    return $setAnchorPoint(point);
  }

  if ($isNodeSelection(selection)) {
    return;
  }

  const anchor = selection.anchor;
  anchor.type = point.type;
  anchor.offset = point.offset;
  anchor.key = point.key;
}

export function $setFocusPoint(
  point: Pick<PointType, 'type' | 'offset' | 'key'>,
) {
  const selection = $getSelection();

  if (!$isRangeSelection(selection)) {
    const dummyTextNode = $createTextNode();
    dummyTextNode.select();
    return $setFocusPoint(point);
  }

  if ($isNodeSelection(selection)) {
    return;
  }

  const focus = selection.focus;
  focus.type = point.type;
  focus.offset = point.offset;
  focus.key = point.key;
}