Using Tailwind
Bubble Menu
Using Tailwind
Bubble Menu
Showcase of the Bubble Menu component in various configurations.
![Hero Dark](https://mintlify.s3-us-west-1.amazonaws.com/novel/images/bubble-light.png)
![Hero Dark](https://mintlify.s3-us-west-1.amazonaws.com/novel/images/bubble-dark.png)
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.
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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>;
...