Show/hide plugin view from onclick of widget decoration

Hello! I am new to writing ProseMirror plugins and looking for help writing a plugin that does two fold:

  1. Creates widget decorations (in this case a button) for every header node in the document.
  2. Creates a view (a menu) whose visibility is controlled by the onClick of the above button and is positioned in relation to the button itself.

I’m struggling to find best approach to link these two together as I can’t figure out how to show/hide the view based on the click of a decoration.

I did take a look at the suggested code from the collab example shown here but that use case seemed a little different and more complicated than what I was trying to accomplish. I also have a fairly strict way of enabling plugins in my project which doesn’t allow me to easily pass dispatch into the plugin itself.

Here’s my code so far.

import { Plugin, EditorState } from "prosemirror-state";
import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
import {
  createSectionMenu,
  createSectionMenuButton,
} from "prosemirror/Commands/Heading/SectionMenu";

export class SectionMenu {
  menu: HTMLElement;

  constructor(view: EditorView, parentNode: Node) {
    this.menu = createSectionMenu();
    parentNode.appendChild(this.menu);
  }

  show(): void {
    this.menu.classList.add("section-menu--show");
  }

  hide(): void {
    this.menu.classList.remove("section-menu--show");
  }

  setMenuPosition(view: EditorView): void {
    // TODO: set position relative to pos of decoration btn
  }
}

export default (): Plugin =>
  new Plugin({
    props: {
      decorations: (state: EditorState): DecorationSet => {
        const { doc } = state;
        const decos: Decoration[] = [];

        doc.descendants((node, pos) => {
          if (node.type !== state.schema.nodes.heading) {
            return false;
          }

          decos.push(
            Decoration.widget(
              pos + node.content.size + 1,
              createSectionMenuButton(node.attrs.blockID),
              {
                side: 1,
                key: node.attrs.blockID,
              }
            )
          );

          return false;
        });

        return DecorationSet.create(doc, decos);
      },
    },
    view(editorView) {
      if (!editorView.dom.parentNode) {
        throw new Error("View has no parentNode");
      }

      return new SectionMenu(editorView, editorView.dom.parentNode);
    },
  });
export const createSectionMenuButton = (id: string): Node => {
  const menuButton = document.createElement("button");
  menuButton.classList.add("section-menu__btn");

  menuButton.onclick = () => {
    console.log("menu button clicked, id: ", id);
    // ???
  };

  return menuButton;
};

Can these exist in the same plugin?

Would appreciate any tips or nudges in the right direction.

You’ll want to pass a function, not a DOM node, to Decoration.widget. Preferably the same function value every time, for a given ID (by caching them in a WeakMap), since that will avoid redraws. But that function will be passed the view object, and can call dispatch on that in its event handler.

I also recommend keeping this decoration set in plugin state, and only updating it when necessary, for performance.

Thanks for the suggestion, @marijn! This helped get me most of the way there but I’m still stuck on two remaining issues.

First, I occasionally running into a RangeError: Applying a mismatched transaction error when clicking my decoration widget button. My assumption is that is because I use view.state.tr within the button itself, it may be stale if another unrelated transaction has occurs within the document but I’m not sure how to resolve this. I did attempt to put decorations in plugin state, as you suggested, but it seems like at that point, they would update on every transaction to get around my assumed issue.

Second, I only use createSectionMenu once, upon creating it in the PluginView, thus getting plugin state with sectionMenuKey.getState(state) within that function only gives me the state at initialization. Any suggestions on the best way to handle getting updated plugin state within the PluginView? Whether it be to be to create/destroy the view itself each time my decoration button is clicked or otherwise? I tried a few things, but I fear I might run into the same scenario as above with getting mismatched transactions.

Updated code:

import {
  Plugin,
  EditorState as PMState,
  PluginKey,
} from "prosemirror-state";
import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
import {
  createSectionMenu,
  createSectionMenuButton,
} from "prosemirror/Commands/Heading/SectionMenu";

import { PluginInteraction } from "./interactions";

type SectionMenuState = Readonly<{
  sectionID?: string;
  showMenu: boolean;
}>;

export const sectionMenuKey = new PluginKey<SectionMenuState>(
  "section-menu"
);

export const ActiveSectionMenuState =
  new PluginInteraction<SectionMenuState | null>("active-section-menu");

export class SectionMenu {
  menu: HTMLElement;

  constructor(view: EditorView, parentNode: Node) {
    this.menu = createSectionMenu(view);
    parentNode.appendChild(this.menu);
  }

  show(): void {
    this.menu.classList.add("section-menu--show");
  }

  hide(): void {
    this.menu.classList.remove("section-menu--show");
  }

  update({ state }: EditorView): void {
    const pluginState = sectionMenuKey.getState(state);

    if (pluginState?.showMenu) {
      this.show();
    } else {
      this.hide();
    }
  }
}

export default (): Plugin<SectionMenuState> =>
  new Plugin({
    key: sectionMenuKey,
    state: {
      init(state: PMState) {
        return {
          sectionID: undefined,
          showMenu: false,
        };
      },
      apply(tr, prevPluginState) {
        // helper for getMeta
        const activeSection = ActiveSectionMenuState.getFromTr(
          tr,
          prevPluginState
        );

        return {
          sectionID: activeSection?.sectionID,
          showMenu: activeSection?.showMenu ?? false,
        };
      },
    },
    props: {
      /**
       * Creates the section menu widget that appears to the right of each heading
       */
      decorations: (state: PMState): DecorationSet => {
        const { doc } = state;
        const decos: Decoration[] = [];

        doc.descendants((node, pos) => {
          if (node.type !== state.schema.nodes.heading) {
            return false;
          }

          decos.push(
            Decoration.widget(
              pos + node.content.size + 1,
              (view) =>
                createSectionMenuButton(node.attrs.blockID, view),
              {
                side: 1,
                key: node.attrs.blockID,
              }
            )
          );

          return false;
        });

        return DecorationSet.create(doc, decos);
      },
      handleDOMEvents: {
        // hide the menu when clicking outside of menu
        click: (
          { state: { tr }, dispatch }: EditorView,
          event: Event
        ): void => {
          event.preventDefault();
          const targetEl = event.target as HTMLElement;
          if (
            !(
              targetEl?.classList.contains("section-menu__btn") ||
              // it is possible to click the image within the button rather than the button itself
              targetEl.parentElement?.classList.contains(
                "section-menu__btn"
              )
            )
          ) {
            // helper for tr.setMeta
            ActiveSectionMenuState.setOnTr(tr, {
              showMenu: false,
            });

            if (dispatch) dispatch(tr);
          }
        },
      },
    },

    view(editorView) {
      if (!editorView.dom.parentNode) {
        throw new Error("View has no parentNode");
      }

      return new SectionMenu(editorView, editorView.dom.parentNode);
    },
  });
export const createSectionMenuButton = (
  id: string,
  { state: { tr }, dispatch }: EditorView
): Node => {
  const menuButton = document.createElement("button");
  menuButton.classList.add("section-menu__btn");

  menuButton.onclick = () => {
    // helper for tr.setMeta
    ActiveSectionMenuState.setOnTr(tr, {
      sectionID: id,
      showMenu: true,
    });
    if (dispatch) dispatch(tr);
  };

  return menuButton;
};

view.state is always the current state. Unless something else is dispatched between the point where you read state.tr and where you call dispatch, the transaction cannot be mismatched.