import {Plugin, TextSelection} from "prosemirror-state";
import {autoJoin, toggleMark, wrapIn} from "prosemirror-commands";
import {liftTarget, Transform} from "prosemirror-transform";
import ProseMirrorPlugin from "./plugin.mjs";
import {wrapInList} from "prosemirror-schema-list";
import ProseMirrorDropDown from "./dropdown.mjs";
import {
  addColumnAfter, addColumnBefore, deleteColumn, addRowAfter, addRowBefore, deleteRow, mergeCells, splitCell,
  deleteTable
} from "@massifrg/prosemirror-tables-sections";

/**
 * @import {Schema} from "prosemirror-model"
 * @import {EditorView} from "prosemirror-view"
 * @import {MenuToggleBlockWrapCommand, ProseMirrorContentLinkOptions, ProseMirrorDropDownConfig,
 *   ProseMirrorMenuItem, ProseMirrorMenuOptions, ProseMirrorMenuResizeState} from "./_types.mjs"
 */

/**
 * A class responsible for building a menu for a ProseMirror instance.
 * @extends {ProseMirrorPlugin}
 */
export default class ProseMirrorMenu extends ProseMirrorPlugin {

  /**
   * @param {Schema} schema                     The ProseMirror schema to build a menu for.
   * @param {EditorView} view                   The editor view.
   * @param {ProseMirrorMenuOptions} [options]  Additional options to configure the plugin's behaviour.
   */
  constructor(schema, view, options={}) {
    super(schema);
    this.options = options;
    this.#renderTarget = view.dom.ownerDocument.createElement("template");

    /**
     * The editor view.
     * @type {EditorView}
     */
    Object.defineProperty(this, "view", {value: view});

    /**
     * The items configured for this menu.
     * @type {ProseMirrorMenuItem[]}
     */
    Object.defineProperty(this, "items", {value: this._getMenuItems()});

    /**
     * The ID of the menu element in the DOM.
     * @type {string}
     */
    Object.defineProperty(this, "id", {value: `prosemirror-menu-${foundry.utils.randomID()}`, writable: false});

    this._createDropDowns();
    this._wrapEditor();
  }

  /* -------------------------------------------- */

  /**
   * An enumeration of editor scopes in which a menu item can appear
   * @enum {string}
   * @protected
   */
  static _MENU_ITEM_SCOPES = {
    BOTH: "",
    TEXT: "text",
    HTML: "html"
  };

  /* -------------------------------------------- */

  /**
   * Additional options to configure the plugin's behaviour.
   * @type {ProseMirrorMenuOptions}
   */
  options;

  /* -------------------------------------------- */

  /**
   * An HTML element that we write HTML to before injecting it into the DOM.
   * @type {HTMLTemplateElement}
   */
  #renderTarget;

  /* -------------------------------------------- */

  /**
   * Stored resize state.
   * @type {ProseMirrorMenuResizeState}
   */
  #resizeState = {
    hiddenWidth: 0,
    menus: {},
    observer: new ResizeObserver(this._onResize.bind(this)),
    sizes: []
  };

  /* -------------------------------------------- */

  /**
   * Track whether we are currently in a state of editing the HTML source.
   * @type {boolean}
   */
  get editingSource() {
    return this.#editingSource;
  }

  #editingSource = false;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static build(schema, options={}) {
    return new Plugin({
      key: this.key,
      view: editorView => {
        return new this(schema, editorView, options).render();
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Render the menu's HTML.
   * @returns {ProseMirrorMenu}
   */
  render() {
    const document = this.view.dom.ownerDocument;
    const scopes = this.constructor._MENU_ITEM_SCOPES;
    const scopeKey = this.editingSource ? "HTML" : "TEXT";

    // Dropdown Menus
    const dropdowns = this.dropdowns.map(d => {
      const { menu, weight } = d;
      const dataset = [menu ? `data-menu="${menu}"` : "", weight ? `data-weight="${weight}"` : ""].filterJoin(" ");
      return `<li class="text" ${dataset}>${d.render()}</li>`;
    });

    // Button items
    const buttons = this.items.reduce((buttons, item) => {
      if ( ![scopes.BOTH, scopes[scopeKey]].includes(item.scope) ) return buttons;
      const li = document.createElement("li");
      if ( item.active ) li.classList.add("active");
      if ( item.cssClass ) li.classList.add(...item.cssClass.split(/ +/));
      if ( item.scope ) li.classList.add(item.scope);
      const button = document.createElement("button");
      button.type = "button";
      if ( item.active ) button.classList.add("active");
      Object.assign(button.dataset, { action: item.action, tooltip: item.title });
      if ( item.weight ) button.dataset.weight = item.weight;
      if ( item.menu ) button.dataset.menu = item.menu;
      button.innerHTML = item.icon;
      li.append(button);
      buttons.push(li.outerHTML);
      return buttons;
    }, []);

    // Add collaboration indicator.
    const collaborating = document.getElementById(this.id)?.querySelector(".concurrent-users");
    const tooltip = collaborating?.dataset.tooltipText || _loc("EDITOR.CollaboratingUsers");
    buttons.push(`
      <li class="concurrent-users" data-tooltip-text="${foundry.utils.escapeHTML(tooltip)}">
        ${collaborating?.innerHTML || ""}
      </li>
    `);

    // Replace Menu HTML
    this.#renderTarget.innerHTML = `
      <menu class="editor-menu" id="${this.id}">
        ${dropdowns.join("")}
        ${buttons.join("")}
      </menu>
    `;
    const menu = this.#renderTarget.content.getElementById(this.id);
    this.#resizeState.hiddenWidth = 0;
    this.#resizeState.observer.disconnect();
    document.getElementById(this.id).replaceWith(menu);
    this.#recordSizes(menu);
    if ( !this.view.editable ) menu.querySelectorAll("button").forEach(b => b.disabled = true);

    // Toggle source editing state for the parent
    const editor = this.view.dom.closest(".editor");
    editor.classList.toggle("editing-source", this.editingSource);

    // Menu interactivity
    this.activateListeners(menu);
    this.#resizeState.observer.observe(menu);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Attach event listeners.
   * @param {HTMLMenuElement} html  The root menu element.
   */
  activateListeners(html) {
    html.querySelectorAll("button[data-action]").forEach(button => button.onclick = evt => this._onAction(evt));
    this.dropdowns.map(d => d.activateListeners(html));
  }

  /* -------------------------------------------- */

  /**
   * Called whenever the view's state is updated.
   * @param {EditorView} view       The current editor state.
   * @param {EditorView} prevState  The previous editor state.
   */
  update(view, prevState) {
    this.dropdowns.forEach(d => d.forEachItem(item => {
      item.active = this._isItemActive(item);
    }));
    this.items.forEach(item => item.active = this._isItemActive(item));
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Instantiate the ProseMirrorDropDown instances and configure them with the defined menu items.
   * @protected
   */
  _createDropDowns() {
    const dropdowns = Object.values(this._getDropDownMenus()).map(({title, entries, ...options}) => {
      return new ProseMirrorDropDown(title, entries, { onAction: this._onAction.bind(this), ...options });
    });

    /**
     * The dropdowns configured for this menu.
     * @type {ProseMirrorDropDown[]}
     */
    Object.defineProperty(this, "dropdowns", {value: dropdowns});
  }

  /* -------------------------------------------- */

  /**
   * Configure dropdowns for this menu. Each entry in the top-level array corresponds to a separate drop-down.
   * @returns {Record<string, ProseMirrorDropDownConfig>}
   * @protected
   */
  _getDropDownMenus() {
    // Formatting.
    const menus = {
      format: {
        title: "EDITOR.Format",
        cssClass: "format",
        entries: [
          {
            action: "block",
            title: "EDITOR.Block",
            children: [{
              action: "paragraph",
              title: "EDITOR.Paragraph",
              priority: 3,
              node: this.schema.nodes.paragraph,
              cmd: () => this._toggleTextBlock(this.schema.nodes.paragraph)
            }, {
              action: "blockquote",
              title: "EDITOR.Blockquote",
              priority: 1,
              node: this.schema.nodes.blockquote,
              cmd: () => this._toggleBlock(this.schema.nodes.blockquote, wrapIn)
            }, {
              action: "code-block",
              title: "EDITOR.CodeBlock",
              priority: 1,
              node: this.schema.nodes.code_block,
              cmd: () => this._toggleTextBlock(this.schema.nodes.code_block)
            }, {
              action: "secret",
              title: "EDITOR.Secret",
              priority: 1,
              node: this.schema.nodes.secret,
              cmd: () => {
                this._toggleBlock(this.schema.nodes.secret, wrapIn, {
                  attrs: {
                    id: `secret-${foundry.utils.randomID()}`
                  }
                });
              }
            }]
          }, {
            action: "inline",
            title: "EDITOR.Inline",
            children: [{
              action: "bold",
              title: "EDITOR.Bold",
              priority: 2,
              style: "font-weight: bold;",
              mark: this.schema.marks.strong,
              cmd: toggleMark(this.schema.marks.strong)
            }, {
              action: "italic",
              title: "EDITOR.Italic",
              priority: 2,
              style: "font-style: italic;",
              mark: this.schema.marks.em,
              cmd: toggleMark(this.schema.marks.em)
            }, {
              action: "code",
              title: "EDITOR.Code",
              priority: 2,
              style: "font-family: monospace;",
              mark: this.schema.marks.code,
              cmd: toggleMark(this.schema.marks.code)
            }, {
              action: "underline",
              title: "EDITOR.Underline",
              priority: 2,
              style: "text-decoration: underline;",
              mark: this.schema.marks.underline,
              cmd: toggleMark(this.schema.marks.underline)
            }, {
              action: "strikethrough",
              title: "EDITOR.Strikethrough",
              priority: 2,
              style: "text-decoration: line-through;",
              mark: this.schema.marks.strikethrough,
              cmd: toggleMark(this.schema.marks.strikethrough)
            }, {
              action: "superscript",
              title: "EDITOR.Superscript",
              priority: 2,
              mark: this.schema.marks.superscript,
              cmd: toggleMark(this.schema.marks.superscript)
            }, {
              action: "subscript",
              title: "EDITOR.Subscript",
              priority: 2,
              mark: this.schema.marks.subscript,
              cmd: toggleMark(this.schema.marks.subscript)
            }]
          }, {
            action: "alignment",
            title: "EDITOR.Alignment",
            children: [{
              action: "align-left",
              title: "EDITOR.AlignmentLeft",
              priority: 4,
              node: this.schema.nodes.paragraph,
              attrs: {alignment: "left"},
              cmd: () => this.#toggleAlignment("left")
            }, {
              action: "align-center",
              title: "EDITOR.AlignmentCenter",
              priority: 4,
              node: this.schema.nodes.paragraph,
              attrs: {alignment: "center"},
              cmd: () => this.#toggleAlignment("center")
            }, {
              action: "align-justify",
              title: "EDITOR.AlignmentJustify",
              priority: 4,
              node: this.schema.nodes.paragraph,
              attrs: {alignment: "justify"},
              cmd: () => this.#toggleAlignment("justify")
            }, {
              action: "align-right",
              title: "EDITOR.AlignmentRight",
              priority: 4,
              node: this.schema.nodes.paragraph,
              attrs: {alignment: "right"},
              cmd: () => this.#toggleAlignment("right")
            }]
          }
        ]
      }
    };

    // Headings.
    const headings = Array.fromRange(6, 1).map(level => ({
      action: `h${level}`,
      title: _loc("EDITOR.Heading", {level}),
      priority: 1,
      class: `level${level}`,
      node: this.schema.nodes.heading,
      attrs: {level},
      cmd: () => this._toggleTextBlock(this.schema.nodes.heading, {attrs: {level}})
    }));

    menus.format.entries.unshift({
      action: "headings",
      title: "EDITOR.Headings",
      children: headings
    });

    // Inserts.
    if ( CONFIG.TextEditor.inserts.length ) {
      const cmd = this._placeInsert;
      menus.format.entries.push({
        action: "custom-inserts",
        title: "EDITOR.Custom",
        children: CONFIG.TextEditor.inserts.map(function transformConfig({ action, title, inline, html, children }) {
          if ( children?.length ) return { action, title, children: children.map(transformConfig) };
          return {
            action, title,
            cmd: (state, dispatch, view) => cmd(state, dispatch, view, html, { inline })
          };
        })
      });
    }

    // Tables.
    const tables = [{
      action: "insert-table",
      title: "EDITOR.TableInsert",
      group: 1,
      cmd: this._insertTablePrompt.bind(this)
    }, {
      action: "delete-table",
      title: "EDITOR.TableDelete",
      group: 1,
      cmd: deleteTable
    }, {
      action: "add-col-before",
      title: "EDITOR.TableAddColumnBefore",
      group: 2,
      cmd: addColumnBefore
    }, {
      action: "add-col-after",
      title: "EDITOR.TableAddColumnAfter",
      group: 2,
      cmd: addColumnAfter
    }, {
      action: "delete-col",
      title: "EDITOR.TableDeleteColumn",
      group: 2,
      cmd: deleteColumn
    }, {
      action: "add-row-before",
      title: "EDITOR.TableAddRowBefore",
      group: 3,
      cmd: addRowBefore
    }, {
      action: "add-row-after",
      title: "EDITOR.TableAddRowAfter",
      group: 3,
      cmd: addRowAfter
    }, {
      action: "delete-row",
      title: "EDITOR.TableDeleteRow",
      group: 3,
      cmd: deleteRow
    }, {
      action: "merge-cells",
      title: "EDITOR.TableMergeCells",
      group: 4,
      cmd: mergeCells
    }, {
      action: "split-cell",
      title: "EDITOR.TableSplitCell",
      group: 4,
      cmd: splitCell
    }];
    menus.format.entries.push({
      action: "tables",
      title: "EDITOR.Table",
      children: tables
    });
    menus.table = {
      title: "EDITOR.Table",
      cssClass: "tables",
      icon: '<i class="fa-solid fa-table fa-fw"></i>',
      weight: 100,
      menu: "tables",
      entries: tables
    };

    // Fonts.
    const fonts = foundry.applications.settings.menus.FontConfig.getAvailableFonts().sort().map(family => ({
      action: `font-family-${family.slugify()}`,
      title: family,
      priority: 2,
      style: `font-family: '${family}';`,
      mark: this.schema.marks.font,
      attrs: {family},
      cmd: () => this._toggleMark(this.schema.marks.font, {family})
    }));
    menus.format.entries.push({
      action: "fonts",
      title: "EDITOR.Font.Title",
      children: fonts
    });
    menus.fonts = {
      title: "EDITOR.Font.Title",
      cssClass: "fonts",
      icon: '<i class="fa-solid fa-font fa-fw"></i>',
      weight: 200,
      menu: "fonts",
      entries: fonts
    };

    // Sizes.
    const sizes = [{
      action: "font-size-small",
      title: "EDITOR.Font.Size.Small",
      group: 1,
      cmd: () => this._toggleMark(this.schema.marks.size, { size: "small" })
    }, {
      action: "font-size-normal",
      title: "EDITOR.Font.Size.Normal",
      group: 1,
      cmd: () => this._clearMark(this.schema.marks.size)
    }, {
      action: "font-size-large",
      title: "EDITOR.Font.Size.Large",
      group: 1,
      cmd: () => this._toggleMark(this.schema.marks.size, { size: "large" })
    }, {
      action: "font-size-huge",
      title: "EDITOR.Font.Size.Huge",
      group: 1,
      cmd: () => this._toggleMark(this.schema.marks.size, { size: "x-large" })
    }, {
      action: "font-size-custom",
      title: "EDITOR.Custom",
      group: 2,
      cmd: this._fontSizePrompt.bind(this)
    }];
    menus.format.entries.push({
      action: "font-size",
      title: "EDITOR.Font.Size.Title",
      children: sizes
    });
    menus.sizes = {
      title: "EDITOR.Font.Size.Title",
      cssClass: "sizes",
      icon: '<i class="fa-solid fa-text-size fa-fw"></i>',
      weight: 200,
      menu: "font-size",
      entries: sizes
    };

    // Menu items.
    const items = this._getMenuItems();
    const byMenu = Object.groupBy(items, ({ menu }) => menu || "");
    menus.format.entries.push({
      action: "font-format",
      title: "EDITOR.Format",
      children: byMenu["font-format"]
    });
    menus.format.entries.push({
      action: "lists",
      title: "EDITOR.Lists",
      children: byMenu.lists
    });
    menus.format.entries.push({
      action: "insert",
      title: "EDITOR.Insert",
      children: byMenu.insert
    });

    Hooks.callAll("getProseMirrorMenuDropDowns", this, menus);
    return menus;
  }

  /* -------------------------------------------- */

  /**
   * Configure the items for this menu.
   * @returns {ProseMirrorMenuItem[]}
   * @protected
   */
  _getMenuItems() {
    const scopes = this.constructor._MENU_ITEM_SCOPES;
    const items = [
      {
        action: "font-color",
        title: "EDITOR.Font.Color.Title",
        icon: '<i class="fa-solid fa-palette fa-fw"></i>',
        weight: 200,
        menu: "font-format",
        scope: scopes.TEXT,
        cmd: this._fontColorPrompt.bind(this)
      },
      {
        action: "bullet-list",
        title: "EDITOR.BulletList",
        icon: '<i class="fa-solid fa-list-ul fa-fw"></i>',
        weight: 300,
        menu: "lists",
        node: this.schema.nodes.bullet_list,
        scope: scopes.TEXT,
        cmd: () => this._toggleBlock(this.schema.nodes.bullet_list, wrapInList)
      },
      {
        action: "number-list",
        title: "EDITOR.NumberList",
        icon: '<i class="fa-solid fa-list-ol fa-fw"></i>',
        weight: 300,
        menu: "lists",
        node: this.schema.nodes.ordered_list,
        scope: scopes.TEXT,
        cmd: () => this._toggleBlock(this.schema.nodes.ordered_list, wrapInList)
      },
      {
        action: "horizontal-rule",
        title: "EDITOR.HorizontalRule",
        icon: '<i class="fa-solid fa-horizontal-rule fa-fw"></i>',
        weight: 400,
        menu: "insert",
        scope: scopes.TEXT,
        cmd: this.#insertHorizontalRule.bind(this)
      },
      {
        action: "image",
        title: "EDITOR.InsertImage",
        icon: '<i class="fa-solid fa-image fa-fw"></i>',
        weight: 400,
        menu: "insert",
        scope: scopes.TEXT,
        node: this.schema.nodes.image,
        cmd: this._insertImagePrompt.bind(this)
      },
      {
        action: "link",
        title: "EDITOR.Link",
        icon: '<i class="fa-solid fa-link fa-fw"></i>',
        weight: 400,
        menu: "insert",
        scope: scopes.TEXT,
        mark: this.schema.marks.link,
        cmd: this._insertLinkPrompt.bind(this)
      },
      {
        action: "clear-formatting",
        title: "EDITOR.ClearFormatting",
        icon: '<i class="fa-solid fa-text-slash fa-fw"></i>',
        scope: scopes.TEXT,
        cmd: this._clearFormatting.bind(this)
      },
      {
        action: "cancel-html",
        title: "EDITOR.DiscardHTML",
        icon: '<i class="fa-solid fa-xmark fa-fw"></i>',
        scope: scopes.HTML,
        cmd: this.#clearSourceEditor.bind(this)
      }
    ];

    if ( this.view.state.plugins.some(p => p.spec.isHighlightMatchesPlugin) ) {
      items.push({
        action: "toggle-matches",
        title: "EDITOR.EnableHighlightDocumentMatches",
        icon: '<i class="fa-solid fa-wand-magic-sparkles fa-fw"></i>',
        scope: scopes.TEXT,
        cssClass: "toggle-matches",
        cmd: this._toggleMatches.bind(this),
        active: game.settings.get("core", "pmHighlightDocumentMatches")
      });
    }

    if ( this.options.onSave ) {
      items.push({
        action: "save",
        title: `EDITOR.${this.options.destroyOnSave ? "SaveAndClose" : "Save"}`,
        icon: `<i class="fa-solid fa-${this.options.destroyOnSave ? "floppy-disk-circle-arrow-right" : "save"} fa-fw"></i>`,
        scope: scopes.BOTH,
        cssClass: "right",
        cmd: this._handleSave.bind(this)
      });
    }

    items.push({
      action: "source-code",
      title: "EDITOR.SourceHTML",
      icon: '<i class="fa-solid fa-code fa-fw"></i>',
      scope: scopes.BOTH,
      cssClass: "source-code-edit right",
      cmd: this.#toggleSource.bind(this)
    });

    Hooks.callAll("getProseMirrorMenuItems", this, items);
    return items;
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the given menu item is currently active or not.
   * @param {ProseMirrorMenuItem} item  The menu item.
   * @returns {boolean}                 Whether the cursor or selection is in a state represented by the given menu
   *                                    item.
   * @protected
   */
  _isItemActive(item) {
    if ( item.action === "source-code" ) return !!this.#editingSource;
    if ( item.action === "toggle-matches" ) return game.settings.get("core", "pmHighlightDocumentMatches");
    if ( item.mark ) return this._isMarkActive(item);
    if ( item.node ) return this._isNodeActive(item);
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the given menu item representing a mark is active or not.
   * @param {ProseMirrorMenuItem} item  The menu item representing a MarkType.
   * @returns {boolean}                 Whether the cursor or selection is in a state represented by the given mark.
   * @protected
   */
  _isMarkActive(item) {
    const state = this.view.state;
    const {from, $from, to, empty} = state.selection;
    const markCompare = mark => {
      if ( mark.type !== item.mark ) return false;
      const attrs = foundry.utils.deepClone(mark.attrs);
      delete attrs._preserve;
      if ( item.attrs ) return foundry.utils.equals(attrs, item.attrs);
      return true;
    };
    if ( empty ) return $from.marks().some(markCompare);
    let active = false;
    state.doc.nodesBetween(from, to, node => {
      if ( node.marks.some(markCompare) ) active = true;
      return !active;
    });
    return active;
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the given menu item representing a node is active or not.
   * @param {ProseMirrorMenuItem} item  The menu item representing a NodeType.
   * @returns {boolean}                 Whether the cursor or selection is currently within a block of this menu item's
   *                                    node type.
   * @protected
   */
  _isNodeActive(item) {
    const state = this.view.state;
    const {$from, $to, empty} = state.selection;
    const sameParent = empty || $from.sameParent($to);
    // If the selection spans multiple nodes, give up on detecting whether we're in a given block.
    // TODO: Add more complex logic for detecting if all selected nodes belong to the same parent.
    if ( !sameParent ) return false;
    return (state.doc.nodeAt($from.pos)?.type === item.node) || $from.hasAncestor(item.node, item.attrs);
  }

  /* -------------------------------------------- */

  /**
   * Handle a button press.
   * @param {MouseEvent} event  The click event.
   * @protected
   */
  _onAction(event) {
    event.preventDefault();
    const action = event.currentTarget.dataset.action;
    let item;

    // Check dropdowns first
    this.dropdowns.forEach(d => d.forEachItem(i => {
      if ( i.action !== action ) return;
      item = i;
      return false;
    }));

    // Menu items
    if ( !item ) item = this.items.find(i => i.action === action);
    item?.cmd?.(this.view.state, this.view.dispatch, this.view);

    // Destroy the dropdown, if present, & refocus the editor.
    foundry.applications.detached.querySelector("#prosemirror-dropdown")?.remove();
    this.view.focus();
  }

  /* -------------------------------------------- */

  /**
   * Handle collapsing various menu items to accommodate the menu's available width.
   * @param {ResizeObserverEntry[]} entries
   * @protected
   */
  _onResize([entry]) {
    // TODO: We could eliminate all getBoundingClientRect calls here by caching them on render and then invalidating
    //       them whenever the parent window moves.
    const { x } = entry.target.getBoundingClientRect();
    const { children } = entry.target;
    let last;
    let i = 1;
    do { last = children[children.length - i++]; } while ( last && (getComputedStyle(last).display === "none") );
    if ( !last ) return;
    const contentWidth = last.getBoundingClientRect().right - x;
    const [size] = entry.borderBoxSize;
    const { inlineSize } = size;
    const totalWidth = contentWidth + this.#resizeState.hiddenWidth;
    let hiddenWidth = 0;
    for ( const { elements, menu: menuKey, width } of this.#resizeState.sizes ) {
      const menu = this.#resizeState.menus[menuKey];
      const fits = (totalWidth - hiddenWidth) <= inlineSize;
      if ( !fits ) hiddenWidth += width;
      menu.hidden = fits;
      for ( const el of elements ) el.hidden = !fits;
    }
    this.#resizeState.hiddenWidth = hiddenWidth;
  }

  /* -------------------------------------------- */

  /**
   * Cache sizing information after rendering for fast lookup during resize handling.
   * @param {HTMLElement} menu  The rendered menu.
   */
  #recordSizes(menu) {
    this.#resizeState.menus = Array.from(menu.querySelectorAll("li[data-action]")).reduce((obj, li) => {
      obj[li.dataset.action] = li;
      return obj;
    }, {});
    this.#resizeState.sizes = Array.from(menu.querySelectorAll("[data-weight]")).map(element => {
      const { menu, weight } = element.dataset;
      return { element, menu, size: element.getBoundingClientRect(), weight: Number(weight) };
    }).sort((a, b) => a.weight - b.weight).reduce((acc, entry, i, arr) => {
      const { element, menu, size, weight } = entry;
      if ( !i || (arr[i-1].menu !== menu) ) acc.push({ menu, weight, elements: [], width: 0 });
      const obj = acc.at(-1);
      obj.elements.push(element);
      obj.width += size.width;
      return acc;
    }, []);
  }

  /* -------------------------------------------- */

  /**
   * Wrap the editor view element and inject our template ready to be rendered into.
   * @protected
   */
  _wrapEditor() {
    const document = this.view.dom.ownerDocument;
    const menuWrapper = document.createElement("div");
    menuWrapper.classList.add("menu-container");
    const editorWrapper = document.createElement("div");
    editorWrapper.classList.add("editor-container");
    const template = document.createElement("template");
    template.setAttribute("id", this.id);
    menuWrapper.append(template);
    this.view.dom.before(menuWrapper);
    this.view.dom.replaceWith(editorWrapper);
    editorWrapper.append(this.view.dom);
  }

  /* -------------------------------------------- */

  /**
   * Handle requests to save the editor contents
   * @protected
   */
  _handleSave() {
    if ( this.#editingSource ) this.#commitSourceEdits();
    return this.options.onSave?.();
  }

  /* -------------------------------------------- */

  /**
   * Global listeners for the drop-down menu.
   * @param {Document} document  The document to bind to.
   */
  static activateListeners(document, { _deprecated=false }={}) {
    if ( !_deprecated && (foundry.utils.getDefiningClass(this, "eventListeners") !== ProseMirrorMenu) ) {
      foundry.utils.logCompatibilityWarning("ProseMirrorMenu.eventListeners is deprecated. "
        + "Please use ProseMirrorMenu.activateListeners instead.", { since: 14, until: 16 });
      this.eventListeners();
      return;
    }
    document ??= window.document;
    document.addEventListener("pointerdown", event => {
      if ( !event.target.closest("#prosemirror-dropdown") ) {
        foundry.applications.detached.querySelector("#prosemirror-dropdown")?.remove();
      }
    }, { passive: true, capture: true });
  }

  /* -------------------------------------------- */
  /*  Source Code Textarea Management             */
  /* -------------------------------------------- */

  /**
   * Handle a request to edit the source HTML directly.
   */
  #toggleSource() {
    if ( this.editingSource ) return this.#commitSourceEdits();
    this.#activateSourceEditor();
  }

  /* -------------------------------------------- */

  /**
   * Conclude editing the HTML source. Remove the editor and return its contents.
   * @returns {string} The HTML text contained within the CodeMirror editor before it was cleared
   */
  #clearSourceEditor() {
    const editor = this.view.dom.closest(".editor");
    const htmlEditor = editor.querySelector(":scope > .source-editor");
    const html = htmlEditor.value;
    htmlEditor.remove();
    this.#editingSource = false;
    this.items.find(i => i.action === "source-code").active = false;
    this.render();
    return html;
  }

  /* -------------------------------------------- */

  /**
   * Create and activate the HTML source editor.
   */
  #activateSourceEditor() {
    const editor = this.view.dom.closest(".editor");
    const htmlEditor = document.createElement("code-mirror");
    Object.assign(htmlEditor, { language: "html", indent: 4, className: "source-editor", managed: true });
    htmlEditor.value = ProseMirror.dom.serializeString(this.view.state.doc.content, {spaces: 4});
    editor.appendChild(htmlEditor);
    htmlEditor.focus();
    htmlEditor
      .querySelector("[contenteditable]")
      .addEventListener("keydown", event => this.#handleSourceKeydown(event));
    this.#editingSource = true;
    this.items.find(i => i.action === "source-code").active = true;
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Commit changes from the source textarea to the view.
   */
  #commitSourceEdits() {
    const html = this.#clearSourceEditor();
    const newDoc = ProseMirror.dom.parseString(html);
    const selection = new ProseMirror.AllSelection(this.view.state.doc);
    this.view.dispatch(this.view.state.tr.setSelection(selection).replaceSelectionWith(newDoc));
  }

  /* -------------------------------------------- */

  /**
   * Handle keypresses while editing editor source.
   * @param {KeyboardEvent} event  The keyboard event.
   */
  #handleSourceKeydown(event) {
    if ( game.keyboard.isModifierActive("CONTROL") && (event.key === "s") ) this._handleSave();
  }

  /* -------------------------------------------- */
  /*  Editor Functions                            */
  /* -------------------------------------------- */

  /**
   * Clear a specific mark from the selection.
   * @param {MarkType} markType  The mark to remove.
   * @protected
   */
  _clearMark(markType) {
    const { state } = this.view;
    const { from, to } = state.selection;
    this.view.dispatch(state.tr.removeMark(from, to, markType));
  }

  /* -------------------------------------------- */

  /**
   * Display a prompt for font color.
   * @protected
   */
  async _fontColorPrompt() {
    const { state } = this.view;
    const { empty, from, to, $cursor, $from } = state.selection;
    const colorMark = this.schema.marks.color;
    const marks = empty ? $cursor.marks() : $from.marks();
    let value = "";
    for ( const mark of marks ) {
      if ( mark.type === colorMark ) {
        value = mark.attrs.color;
        break;
      }
    }
    if ( value.startsWith("rgb") ) {
      const [, r, g, b] = value.match(/rgb\((\d+), (\d+), (\d+)\)/);
      value = foundry.utils.Color.fromRGBvalues(r / 255, g / 255, b / 255).css;
    }
    const field = new foundry.data.fields.ColorField({ label: "EDITOR.Font.Color.Label" }, { name: "color" });
    const dialog = await this._showDialog("font-color", "templates/journal/font-color.hbs", {
      data: { field, value }
    });
    const form = dialog.querySelector("form");
    form.elements.save.addEventListener("click", () => {
      const color = form.elements.color.value;
      if ( color ) {
        this.view.dispatch(state.tr.removeMark(from, to, colorMark).addMark(from, to, colorMark.create({ color })));
      }
      else this._clearMark(colorMark);
    });
  }

  /* -------------------------------------------- */

  /**
   * Display a prompt for a custom font size.
   * @protected
   */
  async _fontSizePrompt() {
    const field = new foundry.data.fields.StringField({ label: "EDITOR.Font.Size.Label" }, { name: "size" });
    const dialog = await this._showDialog("font-size-custom", "templates/journal/font-size-custom.hbs", {
      data: { field }
    });
    const form = dialog.querySelector("form");
    const span = dialog.querySelector("[hidden]");
    const onSubmit = event => {
      event.preventDefault();
      const input = form.elements.size;
      let size = input.value;
      // Assume unit-less values are specified in points.
      if ( Number.isNumeric(size) ) size = `${size}pt`;
      // Validate font size.
      span.style.fontSize = size;
      if ( span.style.fontSize !== size ) {
        event.stopImmediatePropagation();
        input.setCustomValidity(_loc("EDITOR.Font.Size.Invalid"));
        input.addEventListener("input", () => input.setCustomValidity(""), { once: true });
        form.reportValidity();
        return;
      }
      if ( size ) this._toggleMark(this.schema.marks.size, { size });
      else this._clearMark(this.schema.marks.size);
    };
    form.addEventListener("submit", onSubmit, { capture: true });
    form.elements.save.addEventListener("click", onSubmit, { capture: true });
  }

  /* -------------------------------------------- */

  /**
   * Display the insert image prompt.
   * @protected
   */
  async _insertImagePrompt() {
    const state = this.view.state;
    const { $from, empty } = state.selection;
    const { figcaption, figure, image } = this.schema.nodes;
    const data = { src: "", alt: "", width: "", height: "" };
    let selected;
    if ( !empty ) {
      selected = state.doc.nodeAt($from.pos);
      Object.assign(data, selected?.attrs ?? {});
    }
    const { FilePathField, NumberField, StringField } = foundry.data.fields;
    const context = {
      alt: {
        field: new StringField({ label: "EDITOR.ImageAlt" }, { name: "alt" }),
        value: data.alt
      },
      height: {
        field: new NumberField({ min: 0, integer: true }, { name: "height" }),
        value: data.height
      },
      src: {
        field: new FilePathField({
          required: true, categories: ["IMAGE"], base64: true, label: "EDITOR.ImageSource"
        }, { name: "src" }),
        value: data.src
      },
      width: {
        field: new NumberField({ min: 0, integer: true }, { name: "width" }),
        value: data.width
      }
    };
    if ( selected?.type !== this.schema.nodes.image ) context.caption = {
      field: new StringField({ label: "EDITOR.ImageCaption" }, { name: "caption" }),
      value: data.caption
    };
    const dialog = await this._showDialog("image", "templates/journal/insert-image.hbs", { data: context });
    const form = dialog.querySelector("form");
    const src = form.elements.src;
    form.elements.save.addEventListener("click", () => {
      if ( !src.value ) return;
      let node;
      const imageNode = node = image.create({
        src: src.value,
        alt: form.elements.alt.value,
        width: form.elements.width.value,
        height: form.elements.height.value
      });
      if ( form.elements.caption?.value ) {
        node = figure.create(null, [
          imageNode,
          figcaption.create(null, this.schema.text(form.elements.caption.value))
        ]);
      }
      this.view.dispatch(this.view.state.tr.replaceSelectionWith(node).scrollIntoView());
    });
  }

  /* -------------------------------------------- */

  /**
   * Display the insert link prompt.
   * @protected
   */
  async _insertLinkPrompt() {
    const state = this.view.state;
    const {$from, $to, $cursor} = state.selection;

    // Capture the selected text.
    const selection = state.selection.content().content;
    const data = {text: selection.textBetween(0, selection.size), href: "", title: ""};

    // Check if the user has placed the cursor within a single link, or has selected a single link.
    const links = [];
    state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
      if ( node.marks.some(m => m.type === this.schema.marks.link) ) links.push([node, pos]);
    });
    const existing = links.length === 1 && links[0];
    if ( existing ) {
      const [node] = existing;
      if ( $cursor ) data.text = node.text;
      // Pre-fill the dialog with the existing link's attributes.
      const link = node.marks.find(m => m.type === this.schema.marks.link);
      data.href = link.attrs.href;
      data.title = link.attrs.title;
    }

    const dialog = await this._showDialog("link", "templates/journal/insert-link.html", {data});
    const form = dialog.querySelector("form");
    form.elements.save.addEventListener("click", () => {
      const href = form.elements.href.value;
      const text = form.elements.text.value || href;
      if ( !href ) return;
      const link = this.schema.marks.link.create({href, title: form.elements.title.value});
      const tr = state.tr;

      // The user has placed the cursor within a link they wish to edit.
      if ( existing && $cursor ) {
        const [node, pos] = existing;
        const selection = TextSelection.create(state.doc, pos, pos + node.nodeSize);
        tr.setSelection(selection);
      }

      tr.addStoredMark(link).replaceSelectionWith(this.schema.text(text)).scrollIntoView();
      this.view.dispatch(tr);
    });
  }

  /* -------------------------------------------- */

  /**
   * Display the insert table prompt.
   * @protected
   */
  async _insertTablePrompt() {
    const dialog = await this._showDialog("insert-table", "templates/journal/insert-table.html");
    const form = dialog.querySelector("form");
    form.elements.save.addEventListener("click", () => {
      const rows = Number(form.elements.rows.value) || 1;
      const cols = Number(form.elements.cols.value) || 1;
      const html = `
        <table>
          ${Array.fromRange(rows).reduce(row => row + `
            <tr>${Array.fromRange(cols).reduce(col => col + "<td></td>", "")}</tr>
          `, "")}
        </table>
      `;
      const table = ProseMirror.dom.parseString(html, this.schema);
      this.view.dispatch(this.view.state.tr.replaceSelectionWith(table).scrollIntoView());
    });
  }

  /* -------------------------------------------- */

  /**
   * Create a dialog for a menu button.
   * @param {string} action                      The unique menu button action.
   * @param {string} template                    The dialog's template.
   * @param {object} [options]                   Additional options to configure the dialog's behaviour.
   * @param {object} [options.data={}]           Data to pass to the template.
   * @returns {HTMLDialogElement}
   * @protected
   */
  async _showDialog(action, template, {data={}}={}) {
    const document = this.view.dom.ownerDocument;
    const button = this.getActionElement(action);
    button.classList.add("active");
    let { top, bottom, left } = button.getBoundingClientRect();
    const dialog = document.createElement("dialog");
    dialog.classList.add("application", "menu-dialog", "prosemirror");
    const nearestThemed = this.view.dom.closest(".themed") ?? document.body;
    const [, theme] = nearestThemed.className.match(/(?:^|\s)(theme-\w+)/);
    if ( theme ) dialog.classList.add("themed", theme);
    dialog.innerHTML = await foundry.applications.handlebars.renderTemplate(template, data);
    document.body.appendChild(dialog);
    dialog.addEventListener("click", event => {
      if ( event.target.closest("form") ) return;
      button.classList.remove("active");
      dialog.remove();
    });
    const form = dialog.querySelector("form");
    form.classList.add("standard-form");
    const size = form.getBoundingClientRect();
    const { clientWidth, clientHeight } = document.documentElement;
    left = Math.min(left + 15 - (size.width / 2), clientWidth - size.width);
    top = bottom + size.height > clientHeight ? top - size.height : bottom;
    left = Math.clamp(left, 0, clientWidth);
    top = Math.clamp(top, 0, clientHeight);
    Object.assign(form.style, { top: `${top}px`, left: `${left}px` });
    dialog.style.zIndex = ++foundry.applications.api.ApplicationV2._maxZ;
    const onSubmit = event => {
      event.preventDefault();
      button.classList.remove("active");
      dialog.remove();
      this.view.focus();
    };
    form.addEventListener("submit", onSubmit);
    form.elements.save?.addEventListener("click", onSubmit);
    dialog.open = true;
    return dialog;
  }

  /* -------------------------------------------- */

  /**
   * Clear any marks from the current selection.
   * @protected
   */
  _clearFormatting() {
    const state = this.view.state;
    const {empty, $from, $to} = state.selection;
    if ( empty ) return;
    const tr = this.view.state.tr;
    for ( const markType of Object.values(this.schema.marks) ) {
      if ( state.doc.rangeHasMark($from.pos, $to.pos, markType) ) tr.removeMark($from.pos, $to.pos, markType);
    }
    const range = $from.blockRange($to);
    const nodePositions = [];
    // Capture any nodes that are completely encompassed by the selection, or ones that begin and end exactly at the
    // selection boundaries (i.e., the user has selected all text inside the node).
    tr.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
      if ( node.isText ) return false;
      // Node is entirely contained within the selection.
      if ( (pos >= range.start) && (pos + node.nodeSize <= range.end) ) nodePositions.push(pos);
    });
    // Clear marks and attributes from all eligible nodes.
    nodePositions.forEach(pos => {
      const node = state.doc.nodeAt(pos);
      const attrs = {...node.attrs};
      for ( const [attr, spec] of Object.entries(node.type.spec.attrs) ) {
        if ( spec.formatting ) delete attrs[attr];
      }
      tr.setNodeMarkup(pos, null, attrs);
    });
    this.view.dispatch(tr);
  }

  /* -------------------------------------------- */

  /**
   * Place the given insert into the ProseMirror Document.
   * @param {EditorState} state  The editor state.
   * @param {Function} dispatch  Dispatch changes to the editor state.
   * @param {EditorView} view    The editor view.
   * @param {string} html        The insert's markup.
   * @param {object} [options]
   * @param {boolean} [options.inline]  Whether the insert contains inline content, otherwise block content is assumed.
   * @protected
   */
  _placeInsert(state, dispatch, view, html, { inline }={}) {
    const { $cursor, $from, $to } = state.selection;
    let selection = "";
    if ( $from && $to ) selection = state.doc.textBetween($from.pos, $to.pos);

    // For inline inserts, eagerly replace the selection as <selection> cannot contain inline content.
    // TODO: Preserve marks in the selection.
    if ( inline ) html = html.replace(/<selection>(.*?)<\/selection>/, function(match, alt) {
      return selection || alt || "";
    });

    // Parse the markup as a ProseMirror Document.
    const parsed = ProseMirror.dom.parseString(html);
    let doc = parsed?.content?.content?.[0] ?? parsed;

    // Find the slot where the current selection should be placed, if any.
    let slot;
    doc.nodesBetween(0, doc.nodeSize - 2, (node, pos) => {
      if ( node.type === state.schema.nodes.selection ) slot = { node, pos };
    });

    if ( Number.isFinite(slot?.pos) ) {
      const slice = $cursor
        // If there is no selection, place the contents of the selection slot at the cursor position.
        ? slot.node.slice(0, slot.node.nodeSize - 2)
        // Otherwise replace the selection slot with the current selection contents.
        : state.doc.slice($from.start($from.depth), $to.end($to.depth));
      doc = new Transform(doc).replaceRange(slot.pos, slot.node.nodeSize + slot.pos, slice).doc;
    }

    // Place the insert into the editor.
    let tr = state.tr;
    if ( $cursor ) tr = tr.insert($cursor.pos, inline ? doc.content.content : doc);
    else if ( inline ) tr = tr.replaceSelection(doc.slice(0));
    else tr = tr.replaceSelectionWith(doc);

    // Place the cursor at the end of the newly-inserted content.
    let lastText;
    const suffix = html.includes(">") ? html.length - html.lastIndexOf(">") - 1 : 0;
    tr.doc.nodesBetween($from.pos, doc.nodeSize + $from.pos - suffix - 2, (node, pos) => {
      if ( node.type === state.schema.nodes.text ) lastText = { node, pos };
    });
    tr = tr.setSelection(TextSelection.create(tr.doc, lastText.pos + lastText.node.nodeSize));

    // Dispatch the transaction.
    dispatch(tr);
  }

  /* -------------------------------------------- */

  /**
   * Toggle link recommendations
   * @protected
   */
  async _toggleMatches() {
    const enabled = game.settings.get("core", "pmHighlightDocumentMatches");
    await game.settings.set("core", "pmHighlightDocumentMatches", !enabled);
    this.items.find(i => i.action === "toggle-matches").active = !enabled;
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Inserts a horizontal rule at the cursor.
   */
  #insertHorizontalRule() {
    const hr = this.schema.nodes.horizontal_rule;
    this.view.dispatch(this.view.state.tr.replaceSelectionWith(hr.create()).scrollIntoView());
  }

  /* -------------------------------------------- */

  /**
   * Toggle a particular alignment for the given selection.
   * @param {string} alignment  The text alignment to toggle.
   */
  #toggleAlignment(alignment) {
    const state = this.view.state;
    const {$from, $to} = state.selection;
    const range = $from.blockRange($to);
    if ( !range ) return;
    const {paragraph, image} = this.schema.nodes;
    const positions = [];
    // The range positions are absolute, so we need to convert them to be relative to the parent node.
    const blockStart = range.parent.eq(state.doc) ? 0 : range.start;
    // Calculate the positions of all the paragraph nodes that are direct descendents of the blockRange parent node.
    range.parent.nodesBetween(range.start - blockStart, range.end - blockStart, (node, pos) => {
      if ( ![paragraph, image].includes(node.type) ) return false;
      positions.push({pos: blockStart + pos, attrs: node.attrs});
    });
    const tr = state.tr;
    positions.forEach(({pos, attrs}) => {
      const node = state.doc.nodeAt(pos);
      tr.setNodeMarkup(pos, null, {
        ...attrs, alignment: attrs.alignment === alignment ? node.type.attrs.alignment.default : alignment
      });
    });
    this.view.dispatch(tr);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the given selection by wrapping it in a given block or lifting it out of one.
   * @param {NodeType} node                    The type of node being interacted with.
   * @param {MenuToggleBlockWrapCommand} wrap  The wrap command specific to the given node.
   * @param {object} [options]                 Additional options to configure behaviour.
   * @param {object} [options.attrs]           Attributes for the node.
   * @protected
   */
  _toggleBlock(node, wrap, {attrs=null}={}) {
    const state = this.view.state;
    const {$from, $to} = state.selection;
    const range = $from.blockRange($to);
    if ( !range ) return;
    const inBlock = $from.hasAncestor(node);
    if ( inBlock ) {
      // FIXME: This will lift out of the closest block rather than only the given one, and doesn't work on multiple
      // list elements.
      const target = liftTarget(range);
      if ( target != null ) this.view.dispatch(state.tr.lift(range, target));
    } else autoJoin(wrap(node, attrs), [node.name])(state, this.view.dispatch);
  }

  /* -------------------------------------------- */

  /**
   * Toggle a mark in the selection. Unlike the built-in toggleMark command, this will check for exact attribute
   * matches to determine toggling behavior.
   * @param {MarkType} markType  The mark to apply.
   * @param {Attrs|null} attrs   The mark's attributes.
   * @protected
   */
  _toggleMark(markType, attrs=null) {
    const { state } = this.view;
    const { from, to } = state.selection;
    const mark = markType.create(attrs);
    const hasMark = state.doc.rangeHasMark(from, to, mark);
    let tr = state.tr.removeMark(from, to, markType);
    if ( !hasMark ) tr = tr.addMark(from, to, mark);
    this.view.dispatch(tr);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the given selection by wrapping it in a given text block, or reverting to a paragraph block.
   * @param {NodeType} node           The type of node being interacted with.
   * @param {object} [options]        Additional options to configure behaviour.
   * @param {object} [options.attrs]  Attributes for the node.
   * @protected
   */
  _toggleTextBlock(node, {attrs=null}={}) {
    const state = this.view.state;
    const {$from, $to} = state.selection;
    const range = $from.blockRange($to);
    if ( !range ) return;
    const inBlock = $from.hasAncestor(node, attrs);
    if ( inBlock ) node = this.schema.nodes.paragraph;
    this.view.dispatch(state.tr.setBlockType(range.start, range.end, node, attrs));
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Get the visible element from its action.
   * @param {string} action  The action.
   * @returns {HTMLElement}
   * @protected
   */
  getActionElement(action) {
    const document = this.view.dom.ownerDocument;
    const query = `[data-action="${action}"]`;

    // Case 1 - The button is inside an active drop-down.
    let button = document.getElementById("prosemirror-dropdown")?.querySelector(query);
    if ( button ) return button;

    const editor = this.view.dom.closest(".editor");
    // Case 2 - The button is inside an inactive drop-down.
    button ??= editor.querySelector(`li:not([hidden]) > ul > li${query}`);

    // Case 3 - The button is on the menu bar.
    button ??= editor.querySelector(`button${query}`);
    return button;
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated since v14 until v16
   * @ignore
   */
  static eventListeners() {
    foundry.utils.logCompatibilityWarning("ProseMirrorMenu.eventListeners is deprecated. "
      + "Please use ProseMirrorMenu.activateListeners instead.", { since: 14, until: 16 });
    this.activateListeners(document, { _deprecated: true });
  }
}
