How to Add Custom Bubble Menu Actions for Specific Node Types in Tiptap

You can add custom bubble menu actions by extending the BubbleOptions configuration in your node extension, defining a unique token in the list array, and implementing a corresponding button view factory that returns the menu item component and action handler.

The reactjs-tiptap-editor repository implements a data-driven bubble menu system that maps node types to menu items through a declarative configuration API. By leveraging the BubbleOptions interface exported from src/components/Bubble/formatBubble.ts, you can inject custom actions into the floating toolbar that appears when users select specific nodes like images or videos.

Understanding the Bubble Menu Architecture

The bubble menu system in this editor is purely functional and configuration-based rather than hard-coded. It relies on a type-safe mapping between node types and their corresponding toolbar items.

Core Types and Configuration Files

The architecture centers on three key definitions in src/components/Bubble/formatBubble.ts:

  • BubbleAllType (lines 30-36): A union type representing every possible bubble menu item identifier, including built-in actions like image-size-small and extension names.
  • NodeTypeMenu (lines 42-45): A mapped type Partial<Record<NodeTypeKey, BubbleAllType[]>> that associates specific node types with arrays of menu item tokens.
  • BubbleOptions (lines 66-75): The public API consumed by extensions, containing the list (a NodeTypeMenu), defaultBubbleList, and a button factory function that creates concrete menu item objects.

Runtime Rendering Pipeline

When a user selects content, src/components/Bubble/RichTextBubbleMedia.tsx (lines 54-86) detects the active node type and calls the appropriate helper function—such as getBubbleImage or getBubbleVideo—defined in formatBubble.ts. These helpers iterate over the tokens specified in options.list for that node type, invoke the button factory to resolve each token into a BubbleMenuItem object, and pass the resulting configuration to the UI renderer.

Step-by-Step Implementation

Adding a custom action requires extending your node extension's options to declare the new menu item and provide its implementation.

1. Define a Unique Token Constant

Create a type-safe constant to identify your custom action. This token must be assignable to BubbleAllType.

// src/constants/customBubble.ts
export const IMAGE_WATERMARK = 'image-add-watermark' as const;

2. Extend the Node Extension Configuration

In your node extension file (for example, src/extensions/Image/Image.ts), modify the addOptions method to register the token in the list mapping and provide a button factory implementation.

// src/extensions/Image/Image.ts
import { IMAGE_WATERMARK } from '@/constants/customBubble';
import { ActionButton } from '@/components';
import { BubbleOptions } from '@/components/Bubble/formatBubble';

export const Image = TiptapImage.extend({
  addOptions(): BubbleOptions<any> {
    return {
      // ... existing options
      list: {
        image: [
          'image-size-small',
          'image-align-center',
          IMAGE_WATERMARK, // ← Your custom token appended to the array
        ],
      },
      defaultBubbleList: [],
      button: ({ editor, t }) => ({
        // Return a map keyed by your custom token
        [IMAGE_WATERMARK]: {
          type: IMAGE_WATERMARK,
          component: ActionButton,
          componentProps: {
            tooltip: t('editor.watermark.tooltip'),
            icon: 'Watermark',
            action: () => {
              const attrs = editor.getAttributes('image');
              editor
                .chain()
                .focus()
                .updateImage({
                  ...attrs,
                  src: `${attrs.src}?watermark=1`,
                })
                .run();
            },
          },
        },
      }),
    };
  },
});

3. Implement the Action Logic

The action property inside componentProps receives the Tiptap editor instance. Use standard Tiptap commands to manipulate the node. The example above appends a query parameter to the image source, but you can implement complex logic such as opening modals, triggering API calls, or chaining multiple editor commands.

4. Create a Custom UI Component (Optional)

If the default ActionButton component does not meet your design requirements, create a custom React component and reference it in the component property.

// src/components/WatermarkButton.tsx
import { FC } from 'react';
import { IconComponent } from '@/components/icons';

export const WatermarkButton: FC<{ action: () => void; tooltip: string }> = ({
  action,
  tooltip,
}) => (
  <button
    onClick={action}
    className="richtext-bubble-menu-btn"
    title={tooltip}
    type="button"
  >
    <IconComponent name="Watermark" className="richtext-size-4" />
  </button>
);

Then update the extension configuration:

import { WatermarkButton } from '@/components/WatermarkButton';

// Inside button factory:
[IMAGE_WATERMARK]: {
  type: IMAGE_WATERMARK,
  component: WatermarkButton,
  componentProps: {
    action: () => { /* ... */ },
    tooltip: 'Add Watermark',
  },
}

Complete Working Example

Here is the full configuration for adding a watermark action to the Image extension:

// src/extensions/Image/Image.ts
import TiptapImage from '@tiptap/extension-image';
import { IMAGE_WATERMARK } from '@/constants/customBubble';
import { ActionButton } from '@/components';
import { BubbleOptions } from '@/components/Bubble/formatBubble';

export const Image = TiptapImage.extend({
  addOptions(): BubbleOptions<any> {
    return {
      list: {
        image: [
          'image-size-small',
          'image-align-center',
          IMAGE_WATERMARK,
        ],
      },
      defaultBubbleList: [],
      button: ({ editor, t }) => ({
        [IMAGE_WATERMARK]: {
          type: IMAGE_WATERMARK,
          component: ActionButton,
          componentProps: {
            tooltip: t('editor.image.watermark'),
            icon: 'Watermark',
            action: () => {
              const attrs = editor.getAttributes('image');
              if (!attrs.src) return;
              
              editor
                .chain()
                .focus()
                .updateImage({ 
                  src: `${attrs.src}?watermark=1` 
                })
                .run();
            },
          },
        },
      }),
    };
  },
});

Ensure the extension is included in your editor configuration:

import { useEditor } from '@tiptap/react';
import { Image } from '@/extensions/Image';

const editor = useEditor({
  extensions: [
    Image,
    // ... other extensions
  ],
});

Summary

  • The bubble menu system uses a declarative configuration where node extensions declare their menu items via BubbleOptions.
  • NodeTypeMenu maps node types to arrays of string tokens defined in src/components/Bubble/formatBubble.ts.
  • The button factory in your extension's addOptions translates tokens into concrete UI components by returning objects with component, componentProps, and action properties.
  • RichTextBubbleMedia.tsx automatically picks up custom items when they are listed in the list configuration for the active node type.
  • You can reuse the built-in ActionButton component or provide custom React components for specialized UI requirements.

Frequently Asked Questions

Can I add bubble menu items to multiple node types at once?

Yes. The list property in BubbleOptions accepts a NodeTypeMenu object where you can map the same token to multiple node keys. For example, you could add a custom-download token to both image and video arrays. However, you must ensure the extension providing the button factory is responsible for both node types, or configure the button factory in a shared extension that handles the token resolution for all relevant nodes.

How do I remove or override default bubble menu items?

To override defaults, omit the built-in tokens from the list array in your extension's BubbleOptions and replace them with your custom implementations. The defaultBubbleList property can also be used to filter out unwanted items globally. If you need to modify built-in behavior, extend the original extension and override the addOptions method to return a modified list array containing only your desired tokens.

Can I use a custom React component instead of ActionButton?

Absolutely. The component property in the object returned by the button factory accepts any React component. Your custom component receives the componentProps object as props, which includes action, tooltip, icon, and any other properties you define. Create your component in src/components/, import it into your extension file, and reference it instead of ActionButton in the configuration.

Why isn't my custom bubble menu item appearing?

First, verify that your token string in the list array exactly matches the key returned by the button factory (including case sensitivity). Second, ensure your extension is properly registered in the editor's extensions array. Finally, check that the node type under the cursor matches the key in your NodeTypeMenu (e.g., image vs tiptapImage). The RichTextBubbleMedia component only renders items when the current selection matches a configured node type in src/components/Bubble/formatBubble.ts.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →