Focus Editor

source

FocusEditorCore.mjs

import * as md2html from "./md2html.mjs";
import * as helper from "./helper.mjs";
import BrowserFixes from "./BrowserFixes.mjs";
import Cursor from "./Cursor.mjs";
import UndoText from "./UndoText.mjs";

/** Focus Editor Core class creates the editable content element and manages all its' changes on the text */
class FocusEditorCore {
  #readonly = false;
  #tabSize = 0;
  #caretPosition = [];
  #editorCaretPosition = 0;
  #textLengthOnKeyDown = 0;
  #placeholder = "";
  #maxUndoSteps = 200;
  #textUndo = new UndoText();
  #scrollIntoViewOptions = { block: "center" };
  #target = null;

  #keyboardShortcuts = {
    refresh: {
      accessKey: "KeyR",
      handler: () => {
        this.refresh();
      },
    },
    zen: {
      accessKey: "KeyZ",
      handler: () => {
        this.toggleZenMode();
      },
    },
    focus: {
      handler: () => {
        if (this.target.parentElement.hasAttribute("focus")) {
          this.target.parentElement.removeAttribute("focus");
        } else {
          this.target.parentElement.setAttribute("focus", "paragraph");
        }
      },
      accessKey: "KeyX",
    },
    images: {
      handler: () => {
        if (this.target.parentElement.hasAttribute("image-preview")) {
          this.target.parentElement.removeAttribute("image-preview");
        } else {
          this.target.parentElement.setAttribute("image-preview", "*");
        }
      },
      accessKey: "KeyI",
    },
  };

  HIDE_CARET_ON_CHANGE_FOR_MILLISECONDS = false;

  POSSIBLE_BLOCK_CLASSES = [
    "h1",
    "h2",
    "h3",
    "h4",
    "h5",
    "h6",
    "blockquote",
    "code-block",
    "code-block-start",
    "code-block-end",
    "hr",
  ];

  /**
   *
   * @param {HTMLElement} target
   * @param {string} initialText
   */
  constructor(targetHTMLElement, initialText = "") {
    if (!targetHTMLElement?.tagName) {
      throw new Error("A target HTML element is required");
    }
    this.target = targetHTMLElement;

    this.__addUndoStepDebounced = helper.debounce(this.#addUndoStep, 200);

    BrowserFixes.noDivInsideContentEditable(this.target);

    this.target.innerHTML = md2html.innerTextToHtml(
      helper.removeFirstLineBreak(initialText),
      document,
    );
    this.#updateChildrenElementsWithMarkdownClasses();
  }

  /**
   * Replaces the current text with new text
   * @param {string} text
   * @param {Object} options
   * @param {boolean} options.clearHistory
   * @param {boolean} options.dontAddToHistory
   */
  replaceText(text, { clearHistory = false, dontAddToHistory = false } = {}) {
    // TODO: not sure that this rule is a good idea? But often empty text is set as \n…
    if (text === "\n") {
      text = "";
    }
    this.target.innerHTML = md2html.innerTextToHtml(text || "", document);
    this.#updateChildrenElementsWithMarkdownClasses();
    this.#addCssClassToBlockWithCaret();
    this.target.parentElement.scroll({ top: 0 });
    this.target.focus();
    this.target.click();
    if (clearHistory) {
      this.#textUndo.clear();
    }
    if (!dontAddToHistory) {
      this.#textUndo.add(this.getMarkdown());
    }
  }

  /**
   * @returns {NodeList} All children of the target element
   */
  allChildren() {
    return this.target.querySelectorAll(":scope > *");
  }

  #visibleChildren() {
    return [...this.allChildren()].filter((el) =>
      helper.elementIsVisible(el, {
        offsetTop: -1000,
        offsetBottom: -1000,
        offsetLeft: 0,
        offsetRight: 0,
      }),
    );
  }

  /**
   * (Re)renders markdown.
   * Can be helpfull if not all elements are updated correctly.
   * Triggering refresh may change the caret position as well.
   */
  refresh() {
    let cursor = Cursor.getCurrentCursorPosition(this.target);
    const lengthBefore = this.target.textContent.length;

    this.replaceText(this.getMarkdown());

    const diffCursorPosition = lengthBefore - this.target.textContent.length;

    if (cursor === 0) {
      // Firefox issue
      Cursor.setCurrentCursorPosition(
        cursor,
        this.target.querySelector(".block:first-child") || this.target,
      );
    } else {
      Cursor.setCurrentCursorPosition(cursor + diffCursorPosition, this.target);
    }
    this.#addCssClassToBlockWithCaret();
  }

  /**
   * Returns the plain text.
   * @returns {string} plain text
   */
  getMarkdown() {
    let text = [];
    if (!this.target.querySelector(".block") && this.target.textContent) {
      console.warn("No .block element found");
      return this.target.textContent;
    }
    this.target
      .querySelectorAll(".block")
      .forEach((el) => text.push(String(el.textContent).replace(/\n+$/, "")));

    text = text.join("\n");

    // sometimes a browser (firefox) screws up blocks. user inner text instead
    return text.trim() === "" && this.target.textContent
      ? this.target.textContent
      : text;
  }

  #hasManyElements() {
    return this.allChildren().length > 700;
  }

  #updateChildrenElementsWithMarkdownClasses() {
    let children = this.allChildren();
    this._warnedAboutTooManyChildren = false;
    md2html.addCodeBlockClasses(children, document);
    md2html.addParagraphClasses(children, document);

    this.#updateAllVisibleElements();
  }

  #storeEditorCaretPosition() {
    this.#editorCaretPosition = Cursor.getCurrentCursorPosition(this.target);
  }

  #restoreEditorCaretPosition({ offset = 0 } = {}) {
    let position = this.#editorCaretPosition + offset;
    if (position > this.target.innerText.length) {
      position = this.target.innerText.length;
    }
    Cursor.setCurrentCursorPosition(position, this.target);
  }

  #storeLastCaretPosition(
    paragraph = helper.currentBlockWithCaret(),
    offset = 0,
  ) {
    if (!paragraph) {
      console.debug?.("no element with current caret");
      return;
    }
    const caretPosition = Cursor.getCurrentCursorPosition(paragraph);
    this.#caretPosition.push(caretPosition + offset);
    return caretPosition;
  }

  #restoreLastCaretPosition(
    paragraph = helper.currentBlockWithCaret(),
    { offset = 0 } = {},
  ) {
    if (!paragraph) {
      console.debug?.("no element with current caret");
      return;
    }
    const caretPosition = this.#caretPosition.pop();
    Cursor.setCurrentCursorPosition(caretPosition + offset, paragraph);
    return caretPosition;
  }

  #updateAllVisibleElements() {
    const visibleElements = this.#visibleChildren();
    md2html.addCodeBlockClasses(this.allChildren(), document);
    md2html.addParagraphClasses(visibleElements, document);
  }

  #addCssClassToBlockWithCaret() {
    let current = null;
    try {
      current = helper.currentBlockWithCaret();
      if (!current) return;
    } catch (e) {
      if (helper.isFirefox()) {
        console.info(e);
      } else {
        console.warn(e);
      }
      return;
    }

    this.target
      .querySelectorAll(".with-caret")
      .forEach((el) => el.classList.remove("with-caret"));

    if (current.classList.contains("with-caret")) {
      return;
    }
    current.classList.add("with-caret");

    /* FIX FOR FIREFOX */
    if (
      current.innerText.trim() === "" &&
      md2html.EMPTY_LINE_HTML_PLACEHOLDER &&
      helper.isFirefox()
    ) {
      current.innerHTML = md2html.EMPTY_LINE_HTML_PLACEHOLDER;
    }
  }

  set placeholder(placeholder) {
    this.#placeholder = placeholder;
    this.#checkPlaceholder();
  }

  set tabSize(value) {
    if (value === "\\t") {
      /* Bug: Safari can not handle the custom \t tab behaviour, use 4 spaces instead */
      this.#tabSize = helper.isSafari() ? 4 : "\t";
      return;
    }
    if (Number(value) !== this.#tabSize) {
      this.#tabSize = Number(value);
    }
    if (!value) {
      this.#tabSize = false;
    }
  }

  set readonly(value) {
    this.#readonly = !!value;
    this.target.contentEditable = !this.#readonly;
  }

  set focus(value) {
    if (!value) {
      return;
    }
    this.target.blur();
    this.target.focus();
    // this.target.click();
    this.#addCssClassToBlockWithCaret();
  }

  set target(value) {
    this.#target = value;
    this.#target.contentEditable = !this.#readonly;
    this.#target.classList.add("focus-editor");
    this.#target.contentEditable = true;
    this.#target.setAttribute("role", "textbox");
    this.#target.setAttribute("aria-multiline", "true");
    this.#target.addEventListener("keyup", (ev) => this.#onKeyUp(ev));
    this.#target.addEventListener("keydown", (ev) => this.#onKeyDown(ev));
    this.#target.addEventListener("click", (ev) => this.#onClick(ev));
    this.#target.addEventListener("paste", (ev) => this.#onPaste(ev));
    this.#target.addEventListener("copy", (ev) => this.#onCopy(ev));
    this.#target.addEventListener("blur", (ev) => this.#onBlur(ev));
    this.#target.addEventListener("input", (ev) => this.#onInput(ev));
    this.#target.parentElement.addEventListener("scroll", (ev) =>
      this.#onScroll(ev, this),
    );
  }

  get target() {
    return this.#target;
  }

  #customTabBehaviour(event) {
    if (!this.#tabSize > 0 && this.#tabSize !== "\t") return;

    event.preventDefault();
    const current = helper.currentBlockWithCaret();
    this.#storeLastCaretPosition();

    const caretPosition = Cursor.getCurrentCursorPosition(
      helper.currentBlockWithCaret(),
    );

    if (this.#tabSize === "\t") {
      if (event.shiftKey) {
        if (current.textContent.substring(0, caretPosition).trim() === "") {
          current.innerHTML = current.innerHTML.replace(/^(\t){1}/, "");
          this.#restoreLastCaretPosition(helper.currentBlockWithCaret(), {
            offset: -1,
          });
        }
        return;
      } else {
        if (caretPosition === 0) {
          current.innerHTML = "\t" + current.innerHTML;
        } else {
          current.textContent =
            current.textContent.substring(0, caretPosition) +
            "\t" +
            current.textContent.substring(caretPosition);
        }
        this.#restoreLastCaretPosition(helper.currentBlockWithCaret(), {
          offset: 1,
        });
      }
      return;
    }

    if (event.shiftKey) {
      current.innerHTML = current.innerHTML.replace(
        new RegExp(`^( | ){1,${this.#tabSize}}`),
        "",
      );
      this.#restoreLastCaretPosition(helper.currentBlockWithCaret(), {
        offset: -1 * this.#tabSize,
      });
    } else {
      current.innerHTML =
        [...new Array(this.#tabSize + 1)].join(" ") + current.innerHTML;
      this.#restoreLastCaretPosition(helper.currentBlockWithCaret(), {
        offset: this.#tabSize,
      });
    }
  }

  #checkPlaceholder() {
    if (!this.target.querySelector(".block")) {
      let div = document.createElement("div");
      div.classList.add("block");
      div.textContent = this.target.textContent;
      this.target.textContent = "";
      this.target.appendChild(div);
      this.#updateAllVisibleElements();
    }

    if (!this.#placeholder) {
      return;
    }

    // for aesthetic reasons: add a small delay to ensure the placeholder is removed before checking if the editor is empty (sometimes the editor is not empty yet during check)
    setTimeout(() => {
      this.target
        .querySelectorAll(".block[data-placeholder]")
        .forEach((el) => delete el.dataset.placeholder);
      if (
        this.target.textContent === "" &&
        this.target.querySelectorAll(".block").length === 1
      ) {
        this.target.querySelector(".block").dataset.placeholder =
          this.#placeholder;
      }
    }, 1);
  }

  #onCopy(event) {
    event.preventDefault();
    const selection = document.getSelection();
    const copiedText = selection.toString().replace(/\xA0/g, " ");
    event.clipboardData.setData("text/plain", copiedText);
  }

  #onPaste(event) {
    let paste = (event.clipboardData || window.clipboardData).getData("text");

    const selection = window.getSelection();
    if (!selection.rangeCount) return false;
    selection.deleteFromDocument();
    selection.getRangeAt(0).insertNode(document.createTextNode(paste));
    this.#textUndo.add(this.getMarkdown());
    this.#dispatchInputEvent();

    event.preventDefault();
    setTimeout(async () => {
      this.refresh();
      let offset = this.target.innerText.length - this.#textLengthOnKeyDown + 2;

      this.#restoreEditorCaretPosition({
        offset,
      });
    }, 1);
  }

  #onBlur() {
    this.#checkPlaceholder();
  }

  #onInput() {
    this.#checkPlaceholder();
  }

  #onClick(event) {
    this.#addCssClassToBlockWithCaret();
    if (event.isTrusted) {
      this.#checkPlaceholder();
    }
  }

  #onScroll() {
    this.#updateAllVisibleElements();
  }

  #isUndoEnabled() {
    return this.#maxUndoSteps && this.#maxUndoSteps > 0;
  }

  #onKeyDown(event) {
    this.#checkPlaceholder();
    this.#addCssClassToBlockWithCaret();

    const currentParagraph = helper.currentBlockWithCaret();

    if (event.key === "Enter" && !event.shiftKey && currentParagraph) {
      if (this.#onHittingEnter) {
        event.preventDefault();
        this.#onHittingEnter(event, currentParagraph);
      }
      return;
    }

    if (helper.isSafari() && this.target.textContent === "") {
      if (
        event.key === "Backspace" ||
        (event.key.metaKey && event.key === "x")
      ) {
        // Prevents safaris' incorrect behaviour:
        // Removing all text causes setting caret out of blocks after
        event.preventDefault();
        setTimeout(() => {
          let block = this.target.querySelector(".block");
          if (!block) return;
          FocusEditorCore.#activateElementWithClickFocusAndCaret(block);
        }, 10);
        return;
      }
    }

    if (
      event.key === "Backspace" &&
      !event.shiftKey &&
      !event.metaKey &&
      !event.ctrlKey
    ) {
      if (this.#onHittingBackspace(event, currentParagraph)) {
        this.#onHittingBackspace(event, currentParagraph);
        return;
      }
    }

    if (
      this.#isUndoEnabled() &&
      (event.metaKey || event.ctrlKey) &&
      event.key === "z"
    ) {
      if (event.shiftKey) {
        this.#redoStep(event);
      } else {
        this.#undoStep(event);
      }
      return;
    }

    this.#storeEditorCaretPosition();

    this.#textLengthOnKeyDown = this.target.innerText.length;

    if ((event.ctrlKey || event.metaKey) && event.key === "x") {
      if (
        currentParagraph?.nextElementSibling &&
        !window.getSelection().toString()
      ) {
        // copy text and remove it
        if (currentParagraph.textContent.trim() !== "") {
          navigator.clipboard.writeText(currentParagraph.textContent);
        }
        Cursor.setCurrentCursorPosition(0, currentParagraph.nextElementSibling);
        currentParagraph.remove();
      }
    }

    if ((event.ctrlKey && event.altKey) || (event.altKey && event.shiftKey)) {
      for (let name in this.#keyboardShortcuts) {
        if (this.#keyboardShortcuts[name].accessKey === event.code) {
          this.#keyboardShortcuts[name].handler(event);
          event.preventDefault();
          return;
        }
      }
    }

    this.#checkPlaceholder();

    if (event.key === "Tab") {
      if (this.#customTabBehaviour) {
        this.#customTabBehaviour(event);
        return;
      }
    }
  }

  #onKeyUp(event) {
    if (event.isComposing) {
      return;
    }

    this.#checkPlaceholder();

    if (!document.fullscreenElement) {
      this.target.parentElement.classList.remove("zen-mode");
    }

    const currentParagraph = helper.currentBlockWithCaret();

    if (this.#maxUndoSteps && this.#maxUndoSteps > 0) {
      this.__addUndoStepDebounced(currentParagraph);
    }

    const selectionRange = Math.abs(
      window.getSelection().extentOffset - window.getSelection().baseOffset,
    );

    const textIsSelectedInBlock =
      selectionRange > 0 &&
      (window
        .getSelection()
        .baseNode?.parentNode?.classList?.contains("block") ||
        window
          .getSelection()
          .baseNode?.parentNode?.closest(".focus-editor[contenteditable]"))
        ? true
        : false;

    if (textIsSelectedInBlock) {
      return;
    }

    if (helper.isFirefox() && !this.target.querySelector(".block")) {
      /* Firefox Bug (1): When selecting all text and clean it, not div is there anymore */
      /* eslint-disable no-unused-vars */
      try {
        Cursor.setCurrentCursorPosition(0, this.target.querySelector(".block"));
      } catch (_) {
        this.refresh();
        return;
      }
    }

    if (
      helper.isFirefox() &&
      this.target.innerText.trim() !== "" &&
      this.target.querySelectorAll(".block").length === 1 &&
      this.target.querySelector(".block").innerText.trim() === ""
    ) {
      /* Firefox Bug (2): When selecting all text and clean it, not div is there anymore */
      this.refresh();
      Cursor.setCurrentCursorPosition(
        this.target.textContent.length,
        this.target.querySelector(".block"),
      );
    }

    this.#addCssClassToBlockWithCaret();

    if (!currentParagraph) {
      /**
       * Firefox Bug (3): When selecting text and clean it, the text might be outside of any div
       */
      if (helper.isFirefox()) {
        let divs = this.target.querySelectorAll("div:not(.block)");
        if (divs.length > 0) {
          divs.forEach((el) => el.classList.add("block"));
          divs[0].click();
          divs[0].focus();
        } else {
          // find text which is outside from any div (happens on firefox)
          let elements = [...this.target.childNodes]
            .filter((el) => el.nodeType === Node.TEXT_NODE)
            .filter((v) => !!v.data.trim());
          elements.forEach((el) => {
            let div = document.createElement("div");
            div.textContent = el.textContent || "";
            div.classList.add("block");
            if (el.nextElementSibling) {
              el.nextElementSibling.after(div);
            } else {
              this.target.appendChild(div);
            }
            FocusEditorCore.#activateElementWithClickFocusAndCaret(div);

            el.remove();
          });
          console.warn("restored text");
        }
      }
      console.warn("… no element with current caret…");
      return;
    }

    this.#storeLastCaretPosition(helper.currentBlockWithCaret());

    if (currentParagraph.innerText.trim() === "") {
      /* BUG: browsers have problems with cursor position on empty paragraphs */
      return;
    }

    if (event.key !== "Enter") {
      if (this.#hasManyElements()) {
        this.__renderMarkdownToHtmlDebounced ||= helper.debounce(() => {
          this.#storeLastCaretPosition();
          this.#updateAllVisibleElements();
          this.#restoreLastCaretPosition();
        }, 200);
        this.__renderMarkdownToHtmlDebounced();
        return;
      }

      md2html.addParagraphClasses([currentParagraph], document);
      md2html.addCodeBlockClasses(this.allChildren(), document);

      this.#updateAllVisibleElements();
      this.#restoreLastCaretPosition();
    }
  }

  static #activateElementWithClickFocusAndCaret(el) {
    el.click();
    el.focus();
    Cursor.setCurrentCursorPosition(el.innerText.length, el);
  }

  #onHittingBackspace(event, current) {
    /* fixes caret jumping on backspace on blocks which where created outside view scope */
    let cursorPosition = Cursor.getCurrentCursorPosition(current);

    if (cursorPosition === 0 && current.previousElementSibling) {
      let prev = current.previousElementSibling;
      let pos = prev.textContent.length;
      setTimeout(
        () => {
          Cursor.setCurrentCursorPosition(pos, prev);
        },
        helper.isTouchDevice() ? 20 : 5,
      );
    }
  }

  #onHittingEnter(event, current) {
    const div = document.createElement("div");
    div.innerHTML = md2html.EMPTY_LINE_HTML_PLACEHOLDER;
    div.setAttribute("class", "block");
    const cursorPosition = Cursor.getCurrentCursorPosition(current);

    let previousElement = current.previousElementSibling;
    let textIsSplitAt = 0;
    const textSplits = [];

    const setCursorToNewPositionAndUpdate = () => {
      if (!current) current = helper.currentBlockWithCaret();
      this.#updateAllVisibleElements();
      // timeout because: if the block is not fully visible yet the cursor may not be in the correct position
      setTimeout(() => {
        Cursor.setCurrentCursorPosition(
          textIsSplitAt > 0
            ? current.dataset.autocompletePattern?.length
            : current.textContent.length,
          current,
        );
      }, 1);
    };

    if (cursorPosition === 0) {
      current.before(div);
      Cursor.setCurrentCursorPosition(0, current);
      if (current.classList.contains("code-block")) {
        this.#updateAllVisibleElements();
      }
      if (
        this.#scrollIntoViewOptions &&
        (!helper.isElementVisible(current, this.target) ||
          !helper.elementIsVisible(current))
      ) {
        current.scrollIntoView(this.#scrollIntoViewOptions);
      }
      return;
    }

    if (cursorPosition < current.innerText.length) {
      // split text
      let text = current.innerText;
      textSplits[0] = text.substr(0, cursorPosition);
      textSplits[1] = text.substr(cursorPosition);
      current.innerText = textSplits[0];
      div.innerText = textSplits[1];
      textIsSplitAt = cursorPosition;
    }
    current.after(div);
    if (
      current.classList.contains("code-block") &&
      !current.classList.contains("code-block-end")
    ) {
      div.classList.add("code-block");
    }
    previousElement = current;
    Cursor.setCurrentCursorPosition(0, div);
    current = div;

    if (!current) current = helper.currentBlockWithCaret();
    if (!current) return;

    if (
      this.#scrollIntoViewOptions &&
      (!helper.isElementVisible(current, this.target) ||
        !helper.elementIsVisible(current))
    ) {
      current.scrollIntoView(this.#scrollIntoViewOptions);
    }

    if (current.classList.contains("code-block")) {
      this.#updateAllVisibleElements();
      return;
    }

    if (this.target.parentElement.getAttribute("autocomplete") === "off") {
      return;
    }

    /* AUTOCOMPLETE (list items) */

    const previousAutocompletePattern =
      previousElement.dataset?.autocompletePattern || "";
    const insertedElementText = current.textContent;
    const previousText = textSplits[0] || previousElement.textContent;

    const lineBeginsWithUnorderedList =
      /^(\s*-\s+|\s*\*\s+|\s*•\s+|\s*\*\s+|\s*\+\s+|>+\s*)(.*)$/;
    const lineBeginsWithOrderedList = /^(\s*)(\d+)(\.|\.\)|\))\s.+/;

    let matches = previousText.match(lineBeginsWithUnorderedList);

    if (matches && matches[1]) {
      let previousTextTrimmed = insertedElementText
        .replace(lineBeginsWithUnorderedList, "")
        .trim();
      current.textContent = matches[1] + previousTextTrimmed;

      if (
        // previousAutocompletePattern &&
        //new RegExp(previousAutocompletePattern.slice(1, -1)).test(previousText) &&
        previousText === matches[1]
      ) {
        current.textContent = previousTextTrimmed || "";
        previousElement.textContent = "";
        this.#updateAllVisibleElements();
        return;
      }
      current.dataset.autocompletePattern = lineBeginsWithUnorderedList;
      setCursorToNewPositionAndUpdate();
    } else {
      matches = (textSplits[0] || previousText).match(
        lineBeginsWithOrderedList,
      );
      if (matches && matches[2] && matches[3]) {
        let autocompleteText =
          (matches[1] || "") +
          (Number(matches[2].trim()) + 1) +
          matches[3] +
          " ";
        let previousTextTrimmed = insertedElementText
          .replace(lineBeginsWithOrderedList, "")
          .trim();
        current.textContent = autocompleteText + previousTextTrimmed;
        if (
          previousAutocompletePattern &&
          previousElement.textContent === current.textContent
        ) {
          current.textContent = previousTextTrimmed || "";
          previousElement.textContent = "";
          return;
        }
        current.dataset.autocompletePattern = lineBeginsWithOrderedList;
      } else if (
        previousElement.textContent &&
        previousElement.dataset.autocompletePattern &&
        !current.textContent.match(
          new RegExp(previousElement.dataset.autocompletePattern.slice(1, -1)),
        )
      ) {
        previousElement.innerText = "";
        delete previousElement.dataset.autocompletePattern;
        delete current.dataset.autocompletePattern;
        return;
      }
      setCursorToNewPositionAndUpdate();
    }
    delete previousElement.dataset.autocompletePattern;
  }

  #dispatchInputEvent() {
    this.target.parentElement.dispatchEvent(new InputEvent("input"));
  }

  #undoStep(event) {
    event.preventDefault();
    const { text } = this.#textUndo.undo();
    if (text === undefined) {
      return;
    }

    let caretPosition =
      this.#textUndo.previous()?.additionalData?.caretPosition;

    this.replaceText(text, { dontAddToHistory: true });
    this.#dispatchInputEvent();
    setTimeout(() => {
      // restore caret
      if (caretPosition === undefined) {
        return;
      }
      Cursor.setCurrentCursorPosition(caretPosition, this.target);
      this.#scrollCurrentParagraphIntoView();
    }, 10);
  }

  #addUndoStep(currentParagraph) {
    this.#textUndo.maxSteps = this.#maxUndoSteps;
    this.#textUndo.add(
      this.getMarkdown(),
      currentParagraph?.parentNode
        ? {
            caretPosition: Cursor.getCurrentCursorPosition(this.target),
          }
        : {},
    );
  }

  #redoStep(event) {
    event.preventDefault();
    const { text, additionalData } = this.#textUndo.redo();
    if (text === undefined) {
      return;
    }
    this.replaceText(text, { dontAddToHistory: true });
    this.#dispatchInputEvent();
    setTimeout(() => {
      // restore caret
      Cursor.setCurrentCursorPosition(
        additionalData.caretPosition,
        this.target,
      );
      this.#scrollCurrentParagraphIntoView();
    }, 10);
  }

  #scrollCurrentParagraphIntoView() {
    if (!this.#scrollIntoViewOptions) {
      return;
    }
    let current = this.currentBlockWithCaret();
    if (!current) {
      return;
    }
    current.scrollIntoView(this.#scrollIntoViewOptions);
  }

  /**
   * Toggles Zen Mode (means setting the focus editor to full screen)
   */
  toggleZenMode() {
    if (document.fullscreenElement) {
      document.exitFullscreen();
      this.target.parentElement.classList.remove("zen-mode");
      setTimeout(() => {
        this.target.parentElement.classList.remove("zen-mode");
      }, 10);
    } else {
      this.target.parentElement.requestFullscreen();
      this.target.parentElement.classList.add("zen-mode");
    }
  }

  /**
   * Appends character(s) without rerendering the whole editor
   * @param {string} char
   */
  appendCharacter(char) {
    if (char === "\n") {
      let div = document.createElement("div");
      div.classList.add("block");
      this.target.appendChild(div);
      if (div.previousElementSibling) {
        md2html.addParagraphClasses([div.previousElementSibling], document);
      }
      this.#updateAllVisibleElements();
      return;
    }

    let last = this.target.querySelector(".block:last-child");
    last.innerText += char;
    md2html.addParagraphClasses([last], document);
  }
}

export default FocusEditorCore;