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:
- When a user types “/” add a decoration around the “/” content and following text
- If the cursor ends up anywhere outside the Decoration, remove the decoration
- 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.