> ## Documentation Index
> Fetch the complete documentation index at: https://novel.mintlify.site/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Bubble Menu

> Showcase of the Bubble Menu component in various configurations.

<img className="block dark:hidden" src="https://mintlify.s3-us-west-1.amazonaws.com/novel/images/bubble-light.png" alt="Hero Dark" />

<img className="hidden dark:block" src="https://mintlify.s3-us-west-1.amazonaws.com/novel/images/bubble-dark.png" alt="Hero Dark" />

We first have to create the selectors for the different types of nodes and links. We can then use these selectors to create the bubble menu.

<AccordionGroup>
  <Accordion title="Node Selector" icon="share-nodes">
    ```tsx node-selector.tsx
    import {
      Check,
      ChevronDown,
      Heading1,
      Heading2,
      Heading3,
      TextQuote,
      ListOrdered,
      TextIcon,
      Code,
      CheckSquare,
      type LucideIcon,
    } from "lucide-react";
    import { EditorBubbleItem, useEditor } from "novel";

    import { Popover } from "@radix-ui/react-popover";
    import { PopoverContent, PopoverTrigger } from "@/components/ui/popover";
    import { Button } from "@/components/ui/button";

    export type SelectorItem = {
      name: string;
      icon: LucideIcon;
      command: (editor: ReturnType<typeof useEditor>["editor"]) => void;
      isActive: (editor: ReturnType<typeof useEditor>["editor"]) => boolean;
    };

    const items: SelectorItem[] = [
      {
        name: "Text",
        icon: TextIcon,
        command: (editor) => editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
        // I feel like there has to be a more efficient way to do this – feel free to PR if you know how!
        isActive: (editor) =>
          editor.isActive("paragraph") &&
          !editor.isActive("bulletList") &&
          !editor.isActive("orderedList"),
      },
      {
        name: "Heading 1",
        icon: Heading1,
        command: (editor) => editor.chain().focus().toggleHeading({ level: 1 }).run(),
        isActive: (editor) => editor.isActive("heading", { level: 1 }),
      },
      {
        name: "Heading 2",
        icon: Heading2,
        command: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(),
        isActive: (editor) => editor.isActive("heading", { level: 2 }),
      },
      {
        name: "Heading 3",
        icon: Heading3,
        command: (editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(),
        isActive: (editor) => editor.isActive("heading", { level: 3 }),
      },
      {
        name: "To-do List",
        icon: CheckSquare,
        command: (editor) => editor.chain().focus().toggleTaskList().run(),
        isActive: (editor) => editor.isActive("taskItem"),
      },
      {
        name: "Bullet List",
        icon: ListOrdered,
        command: (editor) => editor.chain().focus().toggleBulletList().run(),
        isActive: (editor) => editor.isActive("bulletList"),
      },
      {
        name: "Numbered List",
        icon: ListOrdered,
        command: (editor) => editor.chain().focus().toggleOrderedList().run(),
        isActive: (editor) => editor.isActive("orderedList"),
      },
      {
        name: "Quote",
        icon: TextQuote,
        command: (editor) =>
          editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
        isActive: (editor) => editor.isActive("blockquote"),
      },
      {
        name: "Code",
        icon: Code,
        command: (editor) => editor.chain().focus().toggleCodeBlock().run(),
        isActive: (editor) => editor.isActive("codeBlock"),
      },
    ];
    interface NodeSelectorProps {
      open: boolean;
      onOpenChange: (open: boolean) => void;
    }

    export const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => {
      const { editor } = useEditor();
      if (!editor) return null;
      const activeItem = items.filter((item) => item.isActive(editor)).pop() ?? {
        name: "Multiple",
      };

      return (
        <Popover modal={true} open={open} onOpenChange={onOpenChange}>
          <PopoverTrigger
            asChild
            className='gap-2 rounded-none border-none hover:bg-accent focus:ring-0'>
            <Button variant='ghost' className='gap-2'>
              <span className='whitespace-nowrap text-sm'>{activeItem.name}</span>
              <ChevronDown className='h-4 w-4' />
            </Button>
          </PopoverTrigger>
          <PopoverContent sideOffset={5} align='start' className='w-48 p-1'>
            {items.map((item, index) => (
              <EditorBubbleItem
                key={index}
                onSelect={(editor) => {
                  item.command(editor);
                  onOpenChange(false);
                }}
                className='flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent'>
                <div className='flex items-center space-x-2'>
                  <div className='rounded-sm border p-1'>
                    <item.icon className='h-3 w-3' />
                  </div>
                  <span>{item.name}</span>
                </div>
                {activeItem.name === item.name && <Check className='h-4 w-4' />}
              </EditorBubbleItem>
            ))}
          </PopoverContent>
        </Popover>
      );
    };
    ```
  </Accordion>

  <Accordion title="Link Selector" icon="link">
    ```tsx link-selector.tsx
    import { cn } from "@/lib/utils";
    import { useEditor } from "novel";
    import { Check, Trash } from "lucide-react";
    import { type Dispatch, type FC, type SetStateAction, useEffect, useRef } from "react";
    import { Popover, PopoverTrigger } from "@radix-ui/react-popover";
    import { Button } from "@/components/tailwind/ui/button";
    import { PopoverContent } from "@/components/tailwind/ui/popover";

    export function isValidUrl(url: string) {
      try {
        new URL(url);
        return true;
      } catch (e) {
        return false;
      }
    }
    export function getUrlFromString(str: string) {
      if (isValidUrl(str)) return str;
      try {
        if (str.includes(".") && !str.includes(" ")) {
          return new URL(`https://${str}`).toString();
        }
      } catch (e) {
        return null;
      }
    }
    interface LinkSelectorProps {
      open: boolean;
      onOpenChange: (open: boolean) => void;
    }

    export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => {
      const inputRef = useRef<HTMLInputElement>(null);
      const { editor } = useEditor();

      // Autofocus on input by default
      useEffect(() => {
        inputRef.current && inputRef.current?.focus();
      });
      if (!editor) return null;

      return (
        <Popover modal={true} open={open} onOpenChange={onOpenChange}>
          <PopoverTrigger asChild>
            <Button variant='ghost' className='gap-2 rounded-none border-none'>
              <p className='text-base'>↗</p>
              <p
                className={cn("underline decoration-stone-400 underline-offset-4", {
                  "text-blue-500": editor.isActive("link"),
                })}>
                Link
              </p>
            </Button>
          </PopoverTrigger>
          <PopoverContent align='start' className='w-60 p-0' sideOffset={10}>
            <form
              onSubmit={(e) => {
                const target = e.currentTarget as HTMLFormElement;
                e.preventDefault();
                const input = target[0] as HTMLInputElement;
                const url = getUrlFromString(input.value);
                url && editor.chain().focus().setLink({ href: url }).run();
              }}
              className='flex  p-1 '>
              <input
                ref={inputRef}
                type='text'
                placeholder='Paste a link'
                className='flex-1 bg-background p-1 text-sm outline-none'
                defaultValue={editor.getAttributes("link").href || ""}
              />
              {editor.getAttributes("link").href ? (
                <Button
                  size='icon'
                  variant='outline'
                  type='button'
                  className='flex h-8 items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800'
                  onClick={() => {
                    editor.chain().focus().unsetLink().run();
                  }}>
                  <Trash className='h-4 w-4' />
                </Button>
              ) : (
                <Button size='icon' className='h-8'>
                  <Check className='h-4 w-4' />
                </Button>
              )}
            </form>
          </PopoverContent>
        </Popover>
      );
    };
    ```
  </Accordion>

  <Accordion title="Text Buttons" icon="bold">
    ```tsx text-buttons.tsx
    import { cn } from "@/lib/utils";
    import { EditorBubbleItem, useEditor } from "novel";
    import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
    import type { SelectorItem } from "./node-selector";
    import { Button } from "@/components/tailwind/ui/button";

    export const TextButtons = () => {
      const { editor } = useEditor();
      if (!editor) return null;
      const items: SelectorItem[] = [
        {
          name: "bold",
          isActive: (editor) => editor.isActive("bold"),
          command: (editor) => editor.chain().focus().toggleBold().run(),
          icon: BoldIcon,
        },
        {
          name: "italic",
          isActive: (editor) => editor.isActive("italic"),
          command: (editor) => editor.chain().focus().toggleItalic().run(),
          icon: ItalicIcon,
        },
        {
          name: "underline",
          isActive: (editor) => editor.isActive("underline"),
          command: (editor) => editor.chain().focus().toggleUnderline().run(),
          icon: UnderlineIcon,
        },
        {
          name: "strike",
          isActive: (editor) => editor.isActive("strike"),
          command: (editor) => editor.chain().focus().toggleStrike().run(),
          icon: StrikethroughIcon,
        },
        {
          name: "code",
          isActive: (editor) => editor.isActive("code"),
          command: (editor) => editor.chain().focus().toggleCode().run(),
          icon: CodeIcon,
        },
      ];
      return (
        <div className='flex'>
          {items.map((item, index) => (
            <EditorBubbleItem
              key={index}
              onSelect={(editor) => {
                item.command(editor);
              }}>
              <Button size='icon' className='rounded-none' variant='ghost'>
                <item.icon
                  className={cn("h-4 w-4", {
                    "text-blue-500": item.isActive(editor),
                  })}
                />
              </Button>
            </EditorBubbleItem>
          ))}
        </div>
      );
    };
    ```
  </Accordion>

  <Accordion title="Color Selector" icon="palette">
    ```tsx color-selector.tsx
    import { Check, ChevronDown } from "lucide-react";
    import type { Dispatch, SetStateAction } from "react";
    import { EditorBubbleItem, useEditor } from "novel";

    import { PopoverTrigger, Popover, PopoverContent } from "@/components/tailwind/ui/popover";
    import { Button } from "@/components/tailwind/ui/button";
    export interface BubbleColorMenuItem {
      name: string;
      color: string;
    }

    interface ColorSelectorProps {
      isOpen: boolean;
      setIsOpen: Dispatch<SetStateAction<boolean>>;
    }

    const TEXT_COLORS: BubbleColorMenuItem[] = [
      {
        name: "Default",
        color: "var(--novel-black)",
      },
      {
        name: "Purple",
        color: "#9333EA",
      },
      {
        name: "Red",
        color: "#E00000",
      },
      {
        name: "Yellow",
        color: "#EAB308",
      },
      {
        name: "Blue",
        color: "#2563EB",
      },
      {
        name: "Green",
        color: "#008A00",
      },
      {
        name: "Orange",
        color: "#FFA500",
      },
      {
        name: "Pink",
        color: "#BA4081",
      },
      {
        name: "Gray",
        color: "#A8A29E",
      },
    ];

    const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
      {
        name: "Default",
        color: "var(--novel-highlight-default)",
      },
      {
        name: "Purple",
        color: "var(--novel-highlight-purple)",
      },
      {
        name: "Red",
        color: "var(--novel-highlight-red)",
      },
      {
        name: "Yellow",
        color: "var(--novel-highlight-yellow)",
      },
      {
        name: "Blue",
        color: "var(--novel-highlight-blue)",
      },
      {
        name: "Green",
        color: "var(--novel-highlight-green)",
      },
      {
        name: "Orange",
        color: "var(--novel-highlight-orange)",
      },
      {
        name: "Pink",
        color: "var(--novel-highlight-pink)",
      },
      {
        name: "Gray",
        color: "var(--novel-highlight-gray)",
      },
    ];

    interface ColorSelectorProps {
      open: boolean;
      onOpenChange: (open: boolean) => void;
    }

    export const ColorSelector = ({ open, onOpenChange }) => {
      const { editor } = useEditor();

      if (!editor) return null;
      const activeColorItem = TEXT_COLORS.find(({ color }) => editor.isActive("textStyle", { color }));

      const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
        editor.isActive("highlight", { color })
      );

      return (
        <Popover modal={true} open={open} onOpenChange={onOpenChange}>
          <PopoverTrigger asChild>
            <Button className='gap-2 rounded-none' variant='ghost'>
              <span
                className='rounded-sm px-1'
                style={{
                  color: activeColorItem?.color,
                  backgroundColor: activeHighlightItem?.color,
                }}>
                A
              </span>
              <ChevronDown className='h-4 w-4' />
            </Button>
          </PopoverTrigger>

          <PopoverContent
            sideOffset={5}
            className='my-1 flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl '
            align='start'>
            <div className='flex flex-col'>
              <div className='my-1 px-2 text-sm font-semibold text-muted-foreground'>Color</div>
              {TEXT_COLORS.map(({ name, color }, index) => (
                <EditorBubbleItem
                  key={index}
                  onSelect={() => {
                    editor.commands.unsetColor();
                    name !== "Default" &&
                      editor
                        .chain()
                        .focus()
                        .setColor(color || "")
                        .run();
                  }}
                  className='flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent'>
                  <div className='flex items-center gap-2'>
                    <div className='rounded-sm border px-2 py-px font-medium' style={{ color }}>
                      A
                    </div>
                    <span>{name}</span>
                  </div>
                </EditorBubbleItem>
              ))}
            </div>
            <div>
              <div className='my-1 px-2 text-sm font-semibold text-muted-foreground'>Background</div>
              {HIGHLIGHT_COLORS.map(({ name, color }, index) => (
                <EditorBubbleItem
                  key={index}
                  onSelect={() => {
                    editor.commands.unsetHighlight();
                    name !== "Default" && editor.commands.setHighlight({ color });
                  }}
                  className='flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent'>
                  <div className='flex items-center gap-2'>
                    <div
                      className='rounded-sm border px-2 py-px font-medium'
                      style={{ backgroundColor: color }}>
                      A
                    </div>
                    <span>{name}</span>
                  </div>
                  {editor.isActive("highlight", { color }) && <Check className='h-4 w-4' />}
                </EditorBubbleItem>
              ))}
            </div>
          </PopoverContent>
        </Popover>
      );
    };
    ```
  </Accordion>
</AccordionGroup>

```tsx editor.tsx
import { NodeSelector } from "./selectors/node-selector";
import { LinkSelector } from "./selectors/link-selector";
import { ColorSelector } from "./selectors/color-selector";
import { TextButtons } from "./selectors/text-buttons";


...
<EditorContent>
  <EditorBubble
    tippyOptions={{
      placement: openAI ? "bottom-start" : "top",
    }}
    className='flex w-fit max-w-[90vw] overflow-hidden rounded border border-muted bg-background shadow-xl'>
      <NodeSelector open={openNode} onOpenChange={setOpenNode} />
      <LinkSelector open={openLink} onOpenChange={setOpenLink} />
      <TextButtons />
      <ColorSelector open={openColor} onOpenChange={setOpenColor} />
  </EditorBubble>
</EditorContent>;
...
```
