ueberdosis / tiptap

The headless rich text editor framework for web artisans.
https://tiptap.dev
MIT License
27.68k stars 2.3k forks source link

How to toggle FloatingMenu with keyboard shortcut? #1727

Closed sabetAI closed 3 years ago

sabetAI commented 3 years ago

The problem I am facing In the example from the docs, the FloatingMenu displays when the cursor is set at a new line. I would like to toggle the floating menu (or the BubbleMenu) through a keyboard shortcut. How could I implement this? Do I use an addKeyboardShortcuts() {} plugin extension?

The solution I would like Toggle a floating menu through a keyboard shortcut.

Alternatives I have considered

  1. addKeyboardShortcuts() {}
  2. pass a custom shouldShow callback to FLoatingMenu.
BrianHung commented 3 years ago

If you want to toggle a floating menu, without inputting a character like the experimental commands extension, I would suggest doing either of two things (which are pretty similar):

  1. Make a plugin that has a showFloatingMenu as part of its state.
    • Change that state using addKeyboardShortcuts to tr.setMeta(pluginKey, true | false}).
    • Within the plugin apply(tr) method, use tr.getMeta(pluginKey) to update the plugin state.
    • Within shouldShow, show the floating menu if pluginKey.getState(view.state) == true.
  2. Fork the FloatingMenu plugin to have a show state, update plugin state using setMeta and getMeta as mentioned beforee, use that state within update to show or hide.
sabetAI commented 3 years ago

Great suggestions! Implementing 1, it appears shouldShow triggers before the addKeyboardShortcuts callback, so tr.setMeta doesn't have any effect. Oddly, apply(tr) gets called twice after keyboard press, where tr.getMeta(pluginKey) returns true the first time (because addKeyboardShortcuts sets it to true), then undefined second time. Here are my snippets:

export const FloatingMenuPlugin = (options: FloatingMenuPluginProps) => {
  return new Plugin({
    key:
      typeof options.pluginKey === "string"
        ? new PluginKey(options.pluginKey)
        : options.pluginKey,
    view: (view) => new FloatingMenuView({ view, ...options }),
    state: {
      init: () => {
        return { showFloatingMenu: false };
      },
      apply: (tr, value) => {
        return { showFloatingMenu: tr.getMeta(floatingMenuKey) };
      },
    },
  });
};
export const floatingMenuKey = new PluginKey("floatingMenu");

extension:

FloatingMenuBase.extend({
        addKeyboardShortcuts() {
          return {
            "Mod-e": () =>
              this.editor.commands.command(({ tr, state }) => {
                tr.setMeta(floatingMenuKey, true);
                console.log(`keyboard: ${tr.getMeta(floatingMenuKey)}`);
                return true;
              }),
          };
        },
      }),

and component:

<FloatingMenu
          editor={editor}
          pluginKey={floatingMenuKey}
          shouldShow={({ editor, view, state, oldState }) => {
            const menuState = floatingMenuKey.getState(view.state);
            return menuState.showFloatingMenu;
          }}
/>
BrianHung commented 3 years ago

@sabetAI Ah, I found the issue.

Within FloatingMenuView, shouldShow isn't called even though the plugin state updates because of these two lines: change in plugin state doesn't touch the document nor the selection so this always shortcircuits.

    const isSame = oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection);
    if (composing || isSame) {
      return;
    }

Workaround would be to remove isSame, which requires either a change in tiptap and or you can fork FloatingMenuView.

Here's a working demo with isSame removed. Another note from the code sandbox is that you will have to copy the FloatingMenu React or Vue component because it uses the default FloatingMenuPlugin instead of the one you modified to have plugin state.

sabetAI commented 3 years ago

@BrianHung thanks for the correction and demo code 🙏, works beautifully. When I try to add some menu items to the FloatingMenu, the onClick for none of them seem to get triggered, however. Here's a demo showing this. This wasn't this case with the original FloatingMenu or BubbleMenu. What might be causing this?

BrianHung commented 3 years ago

@sabetAI

The menu is closing before the click can register because apply is being called beforehand, and getMeta for that transaction is undefined. To ignore transactions that don't have to do with floatingMenuKey, we check whether it is undefined first.

If it is undefined, we just return the same plugin state as before (second argument to apply); otherwise, we update the plugin state with the value we put into setMeta.

      apply: (tr, value) => {
        let meta = tr.getMeta(floatingMenuKey);
        if (meta !== undefined) {
          return { showFloatingMenu: meta };
        } else {
          return value;
        }
      }

To close the menu, in the same transaction, call setMeta(floatingMenuKey, false).

sabetAI commented 3 years ago

@BrianHung ah thanks, makes a lot of sense 👍. I also added toggling the menu off with

Escape: () =>
        this.editor.commands.command(({ tr, state, dispatch }) => {
          dispatch(tr.setMeta(floatingMenuKey, false))
          console.log("setfloatingOff");
          return true;
        }),

everything works smoothly now 🙏.