In-progress "Slash Command" Feedback

Hello all. I’m trying to implement a “Slash Command” popup similar to notion’s. Right now I’m just working on getting a Decoration wrapper set up in the document around the actual “/” and the following text. The existence of the Decoration will both toggle and be an anchor for the popup. So if it exists, I’ll show the popup anchored to the decoration.

My current approach/goal is as follows:

  1. When a user types “/” add a decoration around the “/” content and following text
  2. If the cursor ends up anywhere outside the Decoration, remove the decoration
  3. If the text following the “/” exceeds a length of 30, remove the decoration

The code for this is below:

import { Extension } from "@tiptap/core";
import { EditorState, Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";

const createDecorationSet = ({
  doc,
  from,
  to,
  search = "",
}: Pick<EditorState, "doc"> & { from: number; to: number; search?: string }) => {
  return DecorationSet.create(doc, [
    Decoration.inline(
      from,
      to,
      {
        class: "slash-popup",
        "data-search": search,
      },
      {
        inclusiveEnd: true,
      }
    ),
  ]);
};

type Storage = {
  decoration: {
    element: null | DecorationSet;
    from: null | number;
    to: null | number;
  };
  isMenuOpen: boolean;
  search: string;
};

const getPopupPlugin = () => {
  const plugin: Plugin = new Plugin({
    props: {
      decorations: state => plugin.getState(state)?.decoration?.element,
    },
    state: {
      init() {
        return {
          node: null,
          decoration: {
            element: null,
            from: null,
            to: null,
          },
          isMenuOpen: false,
          search: "",
        } as Storage;
      },
      apply(transaction, value, _, state) {
        const { selection } = state;
        const { doc } = transaction;

        const from = selection.$from.pos;
        const to = selection.$to.pos;
        if (from !== to) return value;

        const text = doc.textBetween(from - 1, from);
        if (!value.isMenuOpen && text === "\\") {
          return {
            decoration: {
              element: createDecorationSet({ doc, from: from - 1, to: from }),
              from: from - 1,
              to: from,
            },
            isMenuOpen: true,
            search: "",
          };
        }

        if (value.isMenuOpen) {
          // If there's no node, we're probably on a new node, so remove the decoration
          const node = selection.$anchor.nodeBefore;
          if (!node) {
            return {
              decoration: {
                element: null,
                from: null,
                to: null,
              },
              isMenuOpen: false,
              search: "",
            };
          }

          // If the current anchor is equal to the initial, remove the decoration
          if (from === value.decoration.from) {
            return {
              decoration: {
                element: null,
                from: null,
                to: null,
              },
              isMenuOpen: false,
              search: "",
            };
          }

          // If the anchor is ever before or after 30 of the decoration, remove the decoration
          if (from < value.decoration.from! || from - value.decoration.from! > 30) {
            return {
              decoration: {
                element: null,
                from: null,
                to: null,
              },
              isMenuOpen: false,
              search: "",
            };
          }

          // Update the decoration with the search value as we type
          if (value.decoration.from && from > value.decoration.from) {
            const search = doc.textBetween(value.decoration.from, from, "%block%");
            return {
              decoration: {
                element: createDecorationSet({
                  doc,
                  from: value.decoration.from,
                  to: from,
                  search,
                }),
                from: value.decoration.from,
                to,
              },
              isMenuOpen: true,
              search: search,
            };
          }
        }

        return value;
      },
    },
  });
  return plugin;
};

export const SlashCommandPopup = Extension.create({
  name: "slash-command-popup",
  addProseMirrorPlugins() {
    return [getPopupPlugin()];
  },
});

As you can see I’m using TipTap, but the whole extension is just a ProseMirror Plugin. What I have above works for a simple editor, but I haven’t stress tested it. It just feels kind of hacky, so was wanting to get some feedback.

I’d use the transaction to check whether single “/” was inserted. Dont know what you mean about stress testing, if it works it should be fine with any load. You’ll might find some edge cases though but that’s kinda the norm when you keep adding things.