editor

PreviousNext

The Editor component is a powerful and flexible text editor that allows you to create and edit rich text content.

Docs
kibo-uiui

Preview

Loading preview…
index.tsx
"use client";

import type { Editor, Range } from "@tiptap/core";
import { mergeAttributes, Node } from "@tiptap/core";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import Subscript from "@tiptap/extension-subscript";
import Superscript from "@tiptap/extension-superscript";
import {
  Table,
  TableCell,
  TableHeader,
  TableRow,
} from "@tiptap/extension-table";
import { TextStyleKit } from "@tiptap/extension-text-style";
import Typography from "@tiptap/extension-typography";
import { CharacterCount, Placeholder } from "@tiptap/extensions";
import type { DOMOutputSpec, Node as ProseMirrorNode } from "@tiptap/pm/model";
import { PluginKey } from "@tiptap/pm/state";
import {
  ReactRenderer,
  EditorProvider as TiptapEditorProvider,
  type EditorProviderProps as TiptapEditorProviderProps,
  useCurrentEditor,
} from "@tiptap/react";
import {
  BubbleMenu,
  type BubbleMenuProps,
  FloatingMenu,
  type FloatingMenuProps,
} from "@tiptap/react/menus";
import { Button } from "@/components/ui/button";
import {
  Command,
  CommandEmpty,
  CommandItem,
  CommandList,
} from "@/components/ui/command";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";

export type { Editor, JSONContent } from "@tiptap/react";

import StarterKit from "@tiptap/starter-kit";
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
import Fuse from "fuse.js";
import { all, createLowlight } from "lowlight";
import {
  ArrowDownIcon,
  ArrowLeftIcon,
  ArrowRightIcon,
  ArrowUpIcon,
  BoldIcon,
  BoltIcon,
  CheckIcon,
  CheckSquareIcon,
  ChevronDownIcon,
  CodeIcon,
  ColumnsIcon,
  EllipsisIcon,
  EllipsisVerticalIcon,
  ExternalLinkIcon,
  Heading1Icon,
  Heading2Icon,
  Heading3Icon,
  ItalicIcon,
  ListIcon,
  ListOrderedIcon,
  type LucideIcon,
  type LucideProps,
  RemoveFormattingIcon,
  RowsIcon,
  StrikethroughIcon,
  SubscriptIcon,
  SuperscriptIcon,
  TableCellsMergeIcon,
  TableColumnsSplitIcon,
  TableIcon,
  TextIcon,
  TextQuoteIcon,
  TrashIcon,
  UnderlineIcon,
} from "lucide-react";
import type { FormEventHandler, HTMLAttributes, ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import tippy, { type Instance as TippyInstance } from "tippy.js";

type SlashNodeAttrs = {
  id: string | null;
  label?: string | null;
};

type SlashOptions<
  SlashOptionSuggestionItem = unknown,
  Attrs = SlashNodeAttrs,
> = {
  HTMLAttributes: Record<string, unknown>;
  renderText: (props: {
    options: SlashOptions<SlashOptionSuggestionItem, Attrs>;
    node: ProseMirrorNode;
  }) => string;
  renderHTML: (props: {
    options: SlashOptions<SlashOptionSuggestionItem, Attrs>;
    node: ProseMirrorNode;
  }) => DOMOutputSpec;
  deleteTriggerWithBackspace: boolean;
  suggestion: Omit<
    SuggestionOptions<SlashOptionSuggestionItem, Attrs>,
    "editor"
  >;
};

const SlashPluginKey = new PluginKey("slash");

export type SuggestionItem = {
  title: string;
  description: string;
  icon: LucideIcon;
  searchTerms: string[];
  command: (props: { editor: Editor; range: Range }) => void;
};

export const defaultSlashSuggestions: SuggestionOptions<SuggestionItem>["items"] =
  () => [
    {
      title: "Text",
      description: "Just start typing with plain text.",
      searchTerms: ["p", "paragraph"],
      icon: TextIcon,
      command: ({ editor, range }) => {
        editor
          .chain()
          .focus()
          .deleteRange(range)
          .toggleNode("paragraph", "paragraph")
          .run();
      },
    },
    {
      title: "To-do List",
      description: "Track tasks with a to-do list.",
      searchTerms: ["todo", "task", "list", "check", "checkbox"],
      icon: CheckSquareIcon,
      command: ({ editor, range }) => {
        editor
          .chain()
          .focus()
          .deleteRange(range)
          .toggleList("taskList", "taskItem")
          .run();
      },
    },
    {
      title: "Heading 1",
      description: "Big section heading.",
      searchTerms: ["title", "big", "large"],
      icon: Heading1Icon,
      command: ({ editor, range }) => {
        editor
          .chain()
          .focus()
          .deleteRange(range)
          .setNode("heading", { level: 1 })
          .run();
      },
    },
    {
      title: "Heading 2",
      description: "Medium section heading.",
      searchTerms: ["subtitle", "medium"],
      icon: Heading2Icon,
      command: ({ editor, range }) => {
        editor
          .chain()
          .focus()
          .deleteRange(range)
          .setNode("heading", { level: 2 })
          .run();
      },
    },
    {
      title: "Heading 3",
      description: "Small section heading.",
      searchTerms: ["subtitle", "small"],
      icon: Heading3Icon,
      command: ({ editor, range }) => {
        editor
          .chain()
          .focus()
          .deleteRange(range)
          .setNode("heading", { level: 3 })
          .run();
      },
    },
    {
      title: "Bullet List",
      description: "Create a simple bullet list.",
      searchTerms: ["unordered", "point"],
      icon: ListIcon,
      command: ({ editor, range }) => {
        editor.chain().focus().deleteRange(range).toggleBulletList().run();
      },
    },
    {
      title: "Numbered List",
      description: "Create a list with numbering.",
      searchTerms: ["ordered"],
      icon: ListOrderedIcon,
      command: ({ editor, range }) => {
        editor.chain().focus().deleteRange(range).toggleOrderedList().run();
      },
    },
    {
      title: "Quote",
      description: "Capture a quote.",
      searchTerms: ["blockquote"],
      icon: TextQuoteIcon,
      command: ({ editor, range }) =>
        editor
          .chain()
          .focus()
          .deleteRange(range)
          .toggleNode("paragraph", "paragraph")
          .toggleBlockquote()
          .run(),
    },
    {
      title: "Code",
      description: "Capture a code snippet.",
      searchTerms: ["codeblock"],
      icon: CodeIcon,
      command: ({ editor, range }) =>
        editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
    },
    {
      title: "Table",
      description: "Add a table view to organize data.",
      searchTerms: ["table"],
      icon: TableIcon,
      command: ({ editor, range }) =>
        editor
          .chain()
          .focus()
          .deleteRange(range)
          .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
          .run(),
    },
  ];

const Slash = Node.create<SlashOptions>({
  name: "slash",
  priority: 101,
  addOptions() {
    return {
      HTMLAttributes: {},
      renderText({ options, node }) {
        return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
      },
      deleteTriggerWithBackspace: false,
      renderHTML({ options, node }) {
        return [
          "span",
          mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
          `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`,
        ];
      },
      suggestion: {
        char: "/",
        pluginKey: SlashPluginKey,
        command: ({ editor, range, props }) => {
          // increase range.to by one when the next node is of type "text"
          // and starts with a space character
          const nodeAfter = editor.view.state.selection.$to.nodeAfter;
          const overrideSpace = nodeAfter?.text?.startsWith(" ");

          if (overrideSpace) {
            range.to += 1;
          }

          editor
            .chain()
            .focus()
            .insertContentAt(range, [
              {
                type: this.name,
                attrs: props,
              },
              {
                type: "text",
                text: " ",
              },
            ])
            .run();

          // get reference to `window` object from editor element, to support cross-frame JS usage
          editor.view.dom.ownerDocument.defaultView
            ?.getSelection()
            ?.collapseToEnd();
        },
        allow: ({ state, range }) => {
          const $from = state.doc.resolve(range.from);
          const type = state.schema.nodes[this.name];
          const allow = !!$from.parent.type.contentMatch.matchType(type);

          return allow;
        },
      },
    };
  },

  group: "inline",

  inline: true,

  selectable: false,

  atom: true,

  addAttributes() {
    return {
      id: {
        default: null,
        parseHTML: (element) => element.getAttribute("data-id"),
        renderHTML: (attributes) => {
          if (!attributes.id) {
            return {};
          }

          return {
            "data-id": attributes.id,
          };
        },
      },

      label: {
        default: null,
        parseHTML: (element) => element.getAttribute("data-label"),
        renderHTML: (attributes) => {
          if (!attributes.label) {
            return {};
          }

          return {
            "data-label": attributes.label,
          };
        },
      },
    };
  },

  parseHTML() {
    return [
      {
        tag: `span[data-type="${this.name}"]`,
      },
    ];
  },

  renderHTML({ node, HTMLAttributes }) {
    const mergedOptions = { ...this.options };

    mergedOptions.HTMLAttributes = mergeAttributes(
      { "data-type": this.name },
      this.options.HTMLAttributes,
      HTMLAttributes
    );
    const html = this.options.renderHTML({
      options: mergedOptions,
      node,
    });

    if (typeof html === "string") {
      return [
        "span",
        mergeAttributes(
          { "data-type": this.name },
          this.options.HTMLAttributes,
          HTMLAttributes
        ),
        html,
      ];
    }
    return html;
  },

  renderText({ node }) {
    return this.options.renderText({
      options: this.options,
      node,
    });
  },

  addKeyboardShortcuts() {
    return {
      Backspace: () =>
        this.editor.commands.command(({ tr, state }) => {
          let isMention = false;
          const { selection } = state;
          const { empty, anchor } = selection;

          if (!empty) {
            return false;
          }

          state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
            if (node.type.name === this.name) {
              isMention = true;
              tr.insertText(
                this.options.deleteTriggerWithBackspace
                  ? ""
                  : this.options.suggestion.char || "",
                pos,
                pos + node.nodeSize
              );

              return false;
            }
          });

          return isMention;
        }),
    };
  },

  addProseMirrorPlugins() {
    return [
      Suggestion({
        editor: this.editor,
        ...this.options.suggestion,
      }),
    ];
  },
});

// Create a lowlight instance with all languages loaded
const lowlight = createLowlight(all);

type EditorSlashMenuProps = {
  items: SuggestionItem[];
  command: (item: SuggestionItem) => void;
  editor: Editor;
  range: Range;
};

const EditorSlashMenu = ({ items, editor, range }: EditorSlashMenuProps) => (
  <Command
    className="border shadow"
    id="slash-command"
    onKeyDown={(e) => {
      e.stopPropagation();
    }}
  >
    <CommandEmpty className="flex w-full items-center justify-center p-4 text-muted-foreground text-sm">
      <p>No results</p>
    </CommandEmpty>
    <CommandList>
      {items.map((item) => (
        <CommandItem
          className="flex items-center gap-3 pr-3"
          key={item.title}
          onSelect={() => item.command({ editor, range })}
        >
          <div className="flex size-9 shrink-0 items-center justify-center rounded border bg-secondary">
            <item.icon className="text-muted-foreground" size={16} />
          </div>
          <div className="flex flex-col">
            <span className="font-medium text-sm">{item.title}</span>
            <span className="text-muted-foreground text-xs">
              {item.description}
            </span>
          </div>
        </CommandItem>
      ))}
    </CommandList>
  </Command>
);

const handleCommandNavigation = (event: KeyboardEvent) => {
  if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
    const slashCommand = document.querySelector("#slash-command");

    if (slashCommand) {
      event.preventDefault();

      slashCommand.dispatchEvent(
        new KeyboardEvent("keydown", {
          key: event.key,
          cancelable: true,
          bubbles: true,
        })
      );

      return true;
    }
  }
};

export type EditorProviderProps = TiptapEditorProviderProps & {
  className?: string;
  limit?: number;
  placeholder?: string;
};

export const EditorProvider = ({
  className,
  extensions,
  limit,
  placeholder,
  ...props
}: EditorProviderProps) => {
  const defaultExtensions = [
    StarterKit.configure({
      codeBlock: false,
      bulletList: {
        HTMLAttributes: {
          class: cn("list-outside list-disc pl-4"),
        },
      },
      orderedList: {
        HTMLAttributes: {
          class: cn("list-outside list-decimal pl-4"),
        },
      },
      listItem: {
        HTMLAttributes: {
          class: cn("leading-normal"),
        },
      },
      blockquote: {
        HTMLAttributes: {
          class: cn("border-l border-l-2 pl-2"),
        },
      },
      code: {
        HTMLAttributes: {
          class: cn("rounded-md bg-muted px-1.5 py-1 font-medium font-mono"),
          spellcheck: "false",
        },
      },
      horizontalRule: {
        HTMLAttributes: {
          class: cn("mt-4 mb-6 border-muted-foreground border-t"),
        },
      },
      dropcursor: {
        color: "var(--border)",
        width: 4,
      },
    }),
    Typography,
    Placeholder.configure({
      placeholder,
      emptyEditorClass:
        "before:text-muted-foreground before:content-[attr(data-placeholder)] before:float-left before:h-0 before:pointer-events-none",
    }),
    CharacterCount.configure({
      limit,
    }),
    CodeBlockLowlight.configure({
      lowlight,
      HTMLAttributes: {
        class: cn(
          "rounded-md border p-4 text-sm",
          "bg-background text-foreground",
          "[&_.hljs-doctag]:text-[#d73a49] [&_.hljs-keyword]:text-[#d73a49] [&_.hljs-meta_.hljs-keyword]:text-[#d73a49] [&_.hljs-template-tag]:text-[#d73a49] [&_.hljs-template-variable]:text-[#d73a49] [&_.hljs-type]:text-[#d73a49] [&_.hljs-variable.language_]:text-[#d73a49]",
          "[&_.hljs-title.class_.inherited__]:text-[#6f42c1] [&_.hljs-title.class_]:text-[#6f42c1] [&_.hljs-title.function_]:text-[#6f42c1] [&_.hljs-title]:text-[#6f42c1]",
          "[&_.hljs-attr]:text-[#005cc5] [&_.hljs-attribute]:text-[#005cc5] [&_.hljs-literal]:text-[#005cc5] [&_.hljs-meta]:text-[#005cc5] [&_.hljs-number]:text-[#005cc5] [&_.hljs-operator]:text-[#005cc5] [&_.hljs-selector-attr]:text-[#005cc5] [&_.hljs-selector-class]:text-[#005cc5] [&_.hljs-selector-id]:text-[#005cc5] [&_.hljs-variable]:text-[#005cc5]",
          "[&_.hljs-meta_.hljs-string]:text-[#032f62] [&_.hljs-regexp]:text-[#032f62] [&_.hljs-string]:text-[#032f62]",
          "[&_.hljs-built_in]:text-[#e36209] [&_.hljs-symbol]:text-[#e36209]",
          "[&_.hljs-code]:text-[#6a737d] [&_.hljs-comment]:text-[#6a737d] [&_.hljs-formula]:text-[#6a737d]",
          "[&_.hljs-name]:text-[#22863a] [&_.hljs-quote]:text-[#22863a] [&_.hljs-selector-pseudo]:text-[#22863a] [&_.hljs-selector-tag]:text-[#22863a]",
          "[&_.hljs-subst]:text-[#24292e]",
          "[&_.hljs-section]:font-bold [&_.hljs-section]:text-[#005cc5]",
          "[&_.hljs-bullet]:text-[#735c0f]",
          "[&_.hljs-emphasis]:text-[#24292e] [&_.hljs-emphasis]:italic",
          "[&_.hljs-strong]:font-bold [&_.hljs-strong]:text-[#24292e]",
          "[&_.hljs-addition]:bg-[#f0fff4] [&_.hljs-addition]:text-[#22863a]",
          "[&_.hljs-deletion]:bg-[#ffeef0] [&_.hljs-deletion]:text-[#b31d28]"
        ),
      },
    }),
    Superscript,
    Subscript,
    Slash.configure({
      suggestion: {
        items: async ({ editor, query }) => {
          const items = await defaultSlashSuggestions({ editor, query });

          if (!query) {
            return items;
          }

          const slashFuse = new Fuse(items, {
            keys: ["title", "description", "searchTerms"],
            threshold: 0.2,
            minMatchCharLength: 1,
          });

          const results = slashFuse.search(query);

          return results.map((result) => result.item);
        },
        char: "/",
        render: () => {
          let component: ReactRenderer<EditorSlashMenuProps>;
          let popup: TippyInstance;

          return {
            onStart: (onStartProps) => {
              component = new ReactRenderer(EditorSlashMenu, {
                props: onStartProps,
                editor: onStartProps.editor,
              });

              popup = tippy(document.body, {
                getReferenceClientRect: () =>
                  onStartProps.clientRect?.() || new DOMRect(),
                appendTo: () => document.body,
                content: component.element,
                showOnCreate: true,
                interactive: true,
                trigger: "manual",
                placement: "bottom-start",
              });
            },

            onUpdate(onUpdateProps) {
              component.updateProps(onUpdateProps);

              popup.setProps({
                getReferenceClientRect: () =>
                  onUpdateProps.clientRect?.() || new DOMRect(),
              });
            },

            onKeyDown(onKeyDownProps) {
              if (onKeyDownProps.event.key === "Escape") {
                popup.hide();
                component.destroy();

                return true;
              }

              return handleCommandNavigation(onKeyDownProps.event) ?? false;
            },

            onExit() {
              popup.destroy();
              component.destroy();
            },
          };
        },
      },
    }),
    Table.configure({
      HTMLAttributes: {
        class: cn(
          "relative m-0 mx-auto my-3 w-full table-fixed border-collapse overflow-hidden rounded-none text-sm"
        ),
      },
      allowTableNodeSelection: true,
    }),
    TableRow.configure({
      HTMLAttributes: {
        class: cn(
          "relative box-border min-w-[1em] border p-1 text-start align-top"
        ),
      },
    }),
    TableCell.configure({
      HTMLAttributes: {
        class: cn(
          "relative box-border min-w-[1em] border p-1 text-start align-top"
        ),
      },
    }),
    TableHeader.configure({
      HTMLAttributes: {
        class: cn(
          "relative box-border min-w-[1em] border bg-secondary p-1 text-start align-top font-medium font-semibold text-muted-foreground"
        ),
      },
    }),
    TaskList.configure({
      HTMLAttributes: {
        // 17px = the width of the checkbox + the gap between the checkbox and the text
        class: "before:translate-x-[17px]",
      },
    }),
    TaskItem.configure({
      HTMLAttributes: {
        class: "flex items-start gap-1",
      },
    }),
  ];

  return (
    <TooltipProvider>
      <div className={cn(className, "[&_.ProseMirror-focused]:outline-none")}>
        <TiptapEditorProvider
          editorProps={{
            handleKeyDown: (_view, event) => {
              handleCommandNavigation(event);
            },
          }}
          extensions={[
            ...defaultExtensions,
            TextStyleKit,
            ...(extensions ?? []),
          ]}
          immediatelyRender={false}
          {...props}
        />
      </div>
    </TooltipProvider>
  );
};

export type EditorFloatingMenuProps = Omit<FloatingMenuProps, "editor">;

export const EditorFloatingMenu = ({
  className,
  ...props
}: EditorFloatingMenuProps) => {
  const { editor } = useCurrentEditor();
  return (
    <FloatingMenu
      className={cn("flex items-center bg-secondary", className)}
      editor={editor ?? null}
      {...props}
    />
  );
};

export type EditorBubbleMenuProps = Omit<BubbleMenuProps, "editor">;

export const EditorBubbleMenu = ({
  className,
  children,
  ...props
}: EditorBubbleMenuProps) => {
  const { editor } = useCurrentEditor();
  return (
    <BubbleMenu
      className={cn(
        "flex rounded-xl border bg-background p-0.5 shadow",
        "[&>*:first-child]:rounded-l-[9px]",
        "[&>*:last-child]:rounded-r-[9px]",
        className
      )}
      editor={editor ?? undefined}
      {...props}
    >
      {children && Array.isArray(children)
        ? children.reduce((acc: ReactNode[], child, index) => {
            if (index === 0) {
              return [child];
            }

            // biome-ignore lint/suspicious/noArrayIndexKey: "only iterator we have"
            acc.push(<Separator key={index} orientation="vertical" />);
            acc.push(child);
            return acc;
          }, [])
        : children}
    </BubbleMenu>
  );
};

type EditorButtonProps = {
  name: string;
  isActive: () => boolean;
  command: () => void;
  icon: LucideIcon | ((props: LucideProps) => ReactNode);
  hideName?: boolean;
};

const BubbleMenuButton = ({
  name,
  isActive,
  command,
  icon: Icon,
  hideName,
}: EditorButtonProps) => (
  <Button
    className={`flex gap-4 ${hideName ? "" : "w-full"}`}
    onClick={() => command()}
    size="sm"
    variant="ghost"
  >
    <Icon className="shrink-0 text-muted-foreground" size={12} />
    {!hideName && <span className="flex-1 text-left">{name}</span>}
    {isActive() ? (
      <CheckIcon className="shrink-0 text-muted-foreground" size={12} />
    ) : null}
  </Button>
);

export type EditorClearFormattingProps = Pick<EditorButtonProps, "hideName">;

export const EditorClearFormatting = ({
  hideName = true,
}: EditorClearFormattingProps) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
      hideName={hideName}
      icon={RemoveFormattingIcon}
      isActive={() => false}
      name="Clear Formatting"
    />
  );
};

export type EditorNodeTextProps = Pick<EditorButtonProps, "hideName">;

export const EditorNodeText = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() =>
        editor.chain().focus().toggleNode("paragraph", "paragraph").run()
      }
      hideName={hideName}
      // I feel like there has to be a more efficient way to do this – feel free to PR if you know how!
      icon={TextIcon}
      isActive={() =>
        (editor &&
          !editor.isActive("paragraph") &&
          !editor.isActive("bulletList") &&
          !editor.isActive("orderedList")) ??
        false
      }
      name="Text"
    />
  );
};

export type EditorNodeHeading1Props = Pick<EditorButtonProps, "hideName">;

export const EditorNodeHeading1 = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
      hideName={hideName}
      icon={Heading1Icon}
      isActive={() => editor.isActive("heading", { level: 1 }) ?? false}
      name="Heading 1"
    />
  );
};

export type EditorNodeHeading2Props = Pick<EditorButtonProps, "hideName">;

export const EditorNodeHeading2 = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
      hideName={hideName}
      icon={Heading2Icon}
      isActive={() => editor.isActive("heading", { level: 2 }) ?? false}
      name="Heading 2"
    />
  );
};

export type EditorNodeHeading3Props = Pick<EditorButtonProps, "hideName">;

export const EditorNodeHeading3 = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
      hideName={hideName}
      icon={Heading3Icon}
      isActive={() => editor.isActive("heading", { level: 3 }) ?? false}
      name="Heading 3"
    />
  );
};

export type EditorNodeBulletListProps = Pick<EditorButtonProps, "hideName">;

export const EditorNodeBulletList = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() => editor.chain().focus().toggleBulletList().run()}
      hideName={hideName}
      icon={ListIcon}
      isActive={() => editor.isActive("bulletList") ?? false}
      name="Bullet List"
    />
  );
};

export type EditorNodeOrderedListProps = Pick<EditorButtonProps, "hideName">;

export const EditorNodeOrderedList = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() => editor.chain().focus().toggleOrderedList().run()}
      hideName={hideName}
      icon={ListOrderedIcon}
      isActive={() => editor.isActive("orderedList") ?? false}
      name="Numbered List"
    />
  );
};

export type EditorNodeTaskListProps = Pick<EditorButtonProps, "hideName">;

export const EditorNodeTaskList = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() =>
        editor.chain().focus().toggleList("taskList", "taskItem").run()
      }
      hideName={hideName}
      icon={CheckSquareIcon}
      isActive={() => editor.isActive("taskItem") ?? false}
      name="To-do List"
    />
  );
};

export type EditorNodeQuoteProps = Pick<EditorButtonProps, "hideName">;

export const EditorNodeQuote = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() =>
        editor
          .chain()
          .focus()
          .toggleNode("paragraph", "paragraph")
          .toggleBlockquote()
          .run()
      }
      hideName={hideName}
      icon={TextQuoteIcon}
      isActive={() => editor.isActive("blockquote") ?? false}
      name="Quote"
    />
  );
};

export type EditorNodeCodeProps = Pick<EditorButtonProps, "hideName">;

export const EditorNodeCode = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() => editor.chain().focus().toggleCodeBlock().run()}
      hideName={hideName}
      icon={CodeIcon}
      isActive={() => editor.isActive("codeBlock") ?? false}
      name="Code"
    />
  );
};

export type EditorNodeTableProps = Pick<EditorButtonProps, "hideName">;

export const EditorNodeTable = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() =>
        editor
          .chain()
          .focus()
          .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
          .run()
      }
      hideName={hideName}
      icon={TableIcon}
      isActive={() => editor.isActive("table") ?? false}
      name="Table"
    />
  );
};

export type EditorSelectorProps = HTMLAttributes<HTMLDivElement> & {
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  title: string;
};

export const EditorSelector = ({
  open,
  onOpenChange,
  title,
  className,
  children,
  ...props
}: EditorSelectorProps) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <Popover onOpenChange={onOpenChange} open={open}>
      <PopoverTrigger asChild>
        <Button
          className="gap-2 rounded-none border-none"
          size="sm"
          variant="ghost"
        >
          <span className="whitespace-nowrap text-xs">{title}</span>
          <ChevronDownIcon size={12} />
        </Button>
      </PopoverTrigger>
      <PopoverContent
        align="start"
        className={cn("w-48 p-1", className)}
        sideOffset={5}
        {...props}
      >
        {children}
      </PopoverContent>
    </Popover>
  );
};

export type EditorFormatBoldProps = Pick<EditorButtonProps, "hideName">;

export const EditorFormatBold = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() => editor.chain().focus().toggleBold().run()}
      hideName={hideName}
      icon={BoldIcon}
      isActive={() => editor.isActive("bold") ?? false}
      name="Bold"
    />
  );
};

export type EditorFormatItalicProps = Pick<EditorButtonProps, "hideName">;

export const EditorFormatItalic = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() => editor.chain().focus().toggleItalic().run()}
      hideName={hideName}
      icon={ItalicIcon}
      isActive={() => editor.isActive("italic") ?? false}
      name="Italic"
    />
  );
};

export type EditorFormatStrikeProps = Pick<EditorButtonProps, "hideName">;

export const EditorFormatStrike = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() => editor.chain().focus().toggleStrike().run()}
      hideName={hideName}
      icon={StrikethroughIcon}
      isActive={() => editor.isActive("strike") ?? false}
      name="Strikethrough"
    />
  );
};

export type EditorFormatCodeProps = Pick<EditorButtonProps, "hideName">;

export const EditorFormatCode = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() => editor.chain().focus().toggleCode().run()}
      hideName={hideName}
      icon={CodeIcon}
      isActive={() => editor.isActive("code") ?? false}
      name="Code"
    />
  );
};

export type EditorFormatSubscriptProps = Pick<EditorButtonProps, "hideName">;

export const EditorFormatSubscript = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() => editor.chain().focus().toggleSubscript().run()}
      hideName={hideName}
      icon={SubscriptIcon}
      isActive={() => editor.isActive("subscript") ?? false}
      name="Subscript"
    />
  );
};

export type EditorFormatSuperscriptProps = Pick<EditorButtonProps, "hideName">;

export const EditorFormatSuperscript = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() => editor.chain().focus().toggleSuperscript().run()}
      hideName={hideName}
      icon={SuperscriptIcon}
      isActive={() => editor.isActive("superscript") ?? false}
      name="Superscript"
    />
  );
};

export type EditorFormatUnderlineProps = Pick<EditorButtonProps, "hideName">;

export const EditorFormatUnderline = ({
  hideName = false,
}: Pick<EditorButtonProps, "hideName">) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  return (
    <BubbleMenuButton
      command={() => editor.chain().focus().toggleUnderline().run()}
      hideName={hideName}
      icon={UnderlineIcon}
      isActive={() => editor.isActive("underline") ?? false}
      name="Underline"
    />
  );
};

export type EditorLinkSelectorProps = {
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
};

export const EditorLinkSelector = ({
  open,
  onOpenChange,
}: EditorLinkSelectorProps) => {
  const [url, setUrl] = useState<string>("");
  const inputReference = useRef<HTMLInputElement>(null);
  const { editor } = useCurrentEditor();

  const isValidUrl = (text: string): boolean => {
    try {
      new URL(text);
      return true;
    } catch {
      return false;
    }
  };

  const getUrlFromString = (text: string): string | null => {
    if (isValidUrl(text)) {
      return text;
    }
    try {
      if (text.includes(".") && !text.includes(" ")) {
        return new URL(`https://${text}`).toString();
      }

      return null;
    } catch {
      return null;
    }
  };

  useEffect(() => {
    inputReference.current?.focus();
  }, []);

  if (!editor) {
    return null;
  }

  const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
    event.preventDefault();

    const href = getUrlFromString(url);

    if (href) {
      editor.chain().focus().setLink({ href }).run();
      onOpenChange?.(false);
    }
  };

  const defaultValue = (editor.getAttributes("link") as { href?: string }).href;

  return (
    <Popover modal onOpenChange={onOpenChange} open={open}>
      <PopoverTrigger asChild>
        <Button
          className="gap-2 rounded-none border-none"
          size="sm"
          variant="ghost"
        >
          <ExternalLinkIcon size={12} />
          <p
            className={cn(
              "text-xs underline decoration-text-muted underline-offset-4",
              {
                "text-primary": editor.isActive("link"),
              }
            )}
          >
            Link
          </p>
        </Button>
      </PopoverTrigger>
      <PopoverContent align="start" className="w-60 p-0" sideOffset={10}>
        <form className="flex p-1" onSubmit={handleSubmit}>
          <input
            aria-label="Link URL"
            className="flex-1 bg-background p-1 text-sm outline-none"
            defaultValue={defaultValue ?? ""}
            onChange={(event) => setUrl(event.target.value)}
            placeholder="Paste a link"
            ref={inputReference}
            type="text"
            value={url}
          />
          {editor.getAttributes("link").href ? (
            <Button
              className="flex h-8 items-center rounded-sm p-1 text-destructive transition-all hover:bg-destructive-foreground dark:hover:bg-destructive"
              onClick={() => {
                editor.chain().focus().unsetLink().run();
                onOpenChange?.(false);
              }}
              size="icon"
              type="button"
              variant="outline"
            >
              <TrashIcon size={12} />
            </Button>
          ) : (
            <Button className="h-8" size="icon" variant="secondary">
              <CheckIcon size={12} />
            </Button>
          )}
        </form>
      </PopoverContent>
    </Popover>
  );
};

export type EditorTableMenuProps = {
  children: ReactNode;
};

export const EditorTableMenu = ({ children }: EditorTableMenuProps) => {
  const { editor } = useCurrentEditor();

  if (!editor) {
    return null;
  }

  const isActive = editor.isActive("table");

  return (
    <div
      className={cn({
        hidden: !isActive,
      })}
    >
      {children}
    </div>
  );
};

export type EditorTableGlobalMenuProps = {
  children: ReactNode;
};

export const EditorTableGlobalMenu = ({
  children,
}: EditorTableGlobalMenuProps) => {
  const { editor } = useCurrentEditor();
  const [top, setTop] = useState(0);
  const [left, setLeft] = useState(0);

  useEffect(() => {
    if (!editor) {
      return;
    }

    editor.on("selectionUpdate", () => {
      const selection = window.getSelection();

      if (!selection) {
        return;
      }

      const range = selection.getRangeAt(0);
      let startContainer = range.startContainer as HTMLElement | string;

      if (!(startContainer instanceof HTMLElement)) {
        startContainer = range.startContainer.parentElement as HTMLElement;
      }

      const tableNode = startContainer.closest("table");

      if (!tableNode) {
        return;
      }

      const tableRect = tableNode.getBoundingClientRect();

      setTop(tableRect.top + tableRect.height);
      setLeft(tableRect.left + tableRect.width / 2);
    });

    return () => {
      editor.off("selectionUpdate");
    };
  }, [editor]);

  return (
    <div
      className={cn(
        "-translate-x-1/2 absolute flex translate-y-1/2 items-center rounded-full border bg-background shadow-xl",
        {
          hidden: !(left || top),
        }
      )}
      style={{ top, left }}
    >
      {children}
    </div>
  );
};

export type EditorTableColumnMenuProps = {
  children: ReactNode;
};

export const EditorTableColumnMenu = ({
  children,
}: EditorTableColumnMenuProps) => {
  const { editor } = useCurrentEditor();
  const [top, setTop] = useState(0);
  const [left, setLeft] = useState(0);

  useEffect(() => {
    if (!editor) {
      return;
    }

    editor.on("selectionUpdate", () => {
      const selection = window.getSelection();

      if (!selection) {
        return;
      }

      const range = selection.getRangeAt(0);
      let startContainer = range.startContainer as HTMLElement | string;

      if (!(startContainer instanceof HTMLElement)) {
        startContainer = range.startContainer.parentElement as HTMLElement;
      }

      // Get the closest table cell (td or th)
      const tableCell = startContainer.closest("td, th");

      if (!tableCell) {
        return;
      }

      const cellRect = tableCell.getBoundingClientRect();

      setTop(cellRect.top);
      setLeft(cellRect.left + cellRect.width / 2);
    });

    return () => {
      editor.off("selectionUpdate");
    };
  }, [editor]);

  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        asChild
        className={cn(
          "-translate-x-1/2 -translate-y-1/2 absolute flex h-4 w-7 overflow-hidden rounded-md border bg-background shadow-xl",
          {
            hidden: !(left || top),
          }
        )}
        style={{ top, left }}
      >
        <Button size="icon" variant="ghost">
          <EllipsisIcon className="text-muted-foreground" size={16} />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent>{children}</DropdownMenuContent>
    </DropdownMenu>
  );
};

export type EditorTableRowMenuProps = {
  children: ReactNode;
};

export const EditorTableRowMenu = ({ children }: EditorTableRowMenuProps) => {
  const { editor } = useCurrentEditor();
  const [top, setTop] = useState(0);
  const [left, setLeft] = useState(0);

  useEffect(() => {
    if (!editor) {
      return;
    }

    editor.on("selectionUpdate", () => {
      const selection = window.getSelection();

      if (!selection) {
        return;
      }

      const range = selection.getRangeAt(0);
      let startContainer = range.startContainer as HTMLElement | string;

      if (!(startContainer instanceof HTMLElement)) {
        startContainer = range.startContainer.parentElement as HTMLElement;
      }

      const tableRow = startContainer.closest("tr");

      if (!tableRow) {
        return;
      }

      const rowRect = tableRow.getBoundingClientRect();

      setTop(rowRect.top + rowRect.height / 2);
      setLeft(rowRect.left);
    });

    return () => {
      editor.off("selectionUpdate");
    };
  }, [editor]);

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button
          className={cn(
            "-translate-x-1/2 -translate-y-1/2 absolute flex h-7 w-4 overflow-hidden rounded-md border bg-background shadow-xl",
            {
              hidden: !(left || top),
            }
          )}
          size="icon"
          style={{ top, left }}
          variant="ghost"
        >
          <EllipsisVerticalIcon className="text-muted-foreground" size={12} />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent>{children}</DropdownMenuContent>
    </DropdownMenu>
  );
};

export const EditorTableColumnBefore = () => {
  const { editor } = useCurrentEditor();

  const handleClick = useCallback(() => {
    if (editor) {
      editor.chain().focus().addColumnBefore().run();
    }
  }, [editor]);

  if (!editor) {
    return null;
  }

  return (
    <DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
      <ArrowLeftIcon className="text-muted-foreground" size={16} />
      <span>Add column before</span>
    </DropdownMenuItem>
  );
};

export const EditorTableColumnAfter = () => {
  const { editor } = useCurrentEditor();

  const handleClick = useCallback(() => {
    if (editor) {
      editor.chain().focus().addColumnAfter().run();
    }
  }, [editor]);

  if (!editor) {
    return null;
  }

  return (
    <DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
      <ArrowRightIcon className="text-muted-foreground" size={16} />
      <span>Add column after</span>
    </DropdownMenuItem>
  );
};

export const EditorTableRowBefore = () => {
  const { editor } = useCurrentEditor();

  const handleClick = useCallback(() => {
    if (editor) {
      editor.chain().focus().addRowBefore().run();
    }
  }, [editor]);

  if (!editor) {
    return null;
  }

  return (
    <DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
      <ArrowUpIcon className="text-muted-foreground" size={16} />
      <span>Add row before</span>
    </DropdownMenuItem>
  );
};

export const EditorTableRowAfter = () => {
  const { editor } = useCurrentEditor();

  const handleClick = useCallback(() => {
    if (editor) {
      editor.chain().focus().addRowAfter().run();
    }
  }, [editor]);

  if (!editor) {
    return null;
  }

  return (
    <DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
      <ArrowDownIcon className="text-muted-foreground" size={16} />
      <span>Add row after</span>
    </DropdownMenuItem>
  );
};

export const EditorTableColumnDelete = () => {
  const { editor } = useCurrentEditor();

  const handleClick = useCallback(() => {
    if (editor) {
      editor.chain().focus().deleteColumn().run();
    }
  }, [editor]);

  if (!editor) {
    return null;
  }

  return (
    <DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
      <TrashIcon className="text-destructive" size={16} />
      <span>Delete column</span>
    </DropdownMenuItem>
  );
};

export const EditorTableRowDelete = () => {
  const { editor } = useCurrentEditor();

  const handleClick = useCallback(() => {
    if (editor) {
      editor.chain().focus().deleteRow().run();
    }
  }, [editor]);

  if (!editor) {
    return null;
  }

  return (
    <DropdownMenuItem className="flex items-center gap-2" onClick={handleClick}>
      <TrashIcon className="text-destructive" size={16} />
      <span>Delete row</span>
    </DropdownMenuItem>
  );
};

export const EditorTableHeaderColumnToggle = () => {
  const { editor } = useCurrentEditor();

  const handleClick = useCallback(() => {
    if (editor) {
      editor.chain().focus().toggleHeaderColumn().run();
    }
  }, [editor]);

  if (!editor) {
    return null;
  }

  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <Button
          className="flex items-center gap-2 rounded-full"
          onClick={handleClick}
          size="icon"
          variant="ghost"
        >
          <ColumnsIcon className="text-muted-foreground" size={16} />
        </Button>
      </TooltipTrigger>
      <TooltipContent>
        <span>Toggle header column</span>
      </TooltipContent>
    </Tooltip>
  );
};

export const EditorTableHeaderRowToggle = () => {
  const { editor } = useCurrentEditor();

  const handleClick = useCallback(() => {
    if (editor) {
      editor.chain().focus().toggleHeaderRow().run();
    }
  }, [editor]);

  if (!editor) {
    return null;
  }

  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <Button
          className="flex items-center gap-2 rounded-full"
          onClick={handleClick}
          size="icon"
          variant="ghost"
        >
          <RowsIcon className="text-muted-foreground" size={16} />
        </Button>
      </TooltipTrigger>
      <TooltipContent>
        <span>Toggle header row</span>
      </TooltipContent>
    </Tooltip>
  );
};

export const EditorTableDelete = () => {
  const { editor } = useCurrentEditor();

  const handleClick = useCallback(() => {
    if (editor) {
      editor.chain().focus().deleteTable().run();
    }
  }, [editor]);

  if (!editor) {
    return null;
  }

  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <Button
          className="flex items-center gap-2 rounded-full"
          onClick={handleClick}
          size="icon"
          variant="ghost"
        >
          <TrashIcon className="text-destructive" size={16} />
        </Button>
      </TooltipTrigger>
      <TooltipContent>
        <span>Delete table</span>
      </TooltipContent>
    </Tooltip>
  );
};

export const EditorTableMergeCells = () => {
  const { editor } = useCurrentEditor();

  const handleClick = useCallback(() => {
    if (editor) {
      editor.chain().focus().mergeCells().run();
    }
  }, [editor]);

  if (!editor) {
    return null;
  }

  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <Button
          className="flex items-center gap-2 rounded-full"
          onClick={handleClick}
          size="icon"
          variant="ghost"
        >
          <TableCellsMergeIcon className="text-muted-foreground" size={16} />
        </Button>
      </TooltipTrigger>
      <TooltipContent>
        <span>Merge cells</span>
      </TooltipContent>
    </Tooltip>
  );
};

export const EditorTableSplitCell = () => {
  const { editor } = useCurrentEditor();

  const handleClick = useCallback(() => {
    if (editor) {
      editor.chain().focus().splitCell().run();
    }
  }, [editor]);

  if (!editor) {
    return null;
  }

  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <Button
          className="flex items-center gap-2 rounded-full"
          onClick={handleClick}
          size="icon"
          variant="ghost"
        >
          <TableColumnsSplitIcon className="text-muted-foreground" size={16} />
        </Button>
      </TooltipTrigger>
      <TooltipContent>
        <span>Split cell</span>
      </TooltipContent>
    </Tooltip>
  );
};

export const EditorTableFix = () => {
  const { editor } = useCurrentEditor();

  const handleClick = useCallback(() => {
    if (editor) {
      editor.chain().focus().fixTables().run();
    }
  }, [editor]);

  if (!editor) {
    return null;
  }

  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <Button
          className="flex items-center gap-2 rounded-full"
          onClick={handleClick}
          size="icon"
          variant="ghost"
        >
          <BoltIcon className="text-muted-foreground" size={16} />
        </Button>
      </TooltipTrigger>
      <TooltipContent>
        <span>Fix table</span>
      </TooltipContent>
    </Tooltip>
  );
};

export type EditorCharacterCountProps = {
  children: ReactNode;
  className?: string;
};

export const EditorCharacterCount = {
  Characters({ children, className }: EditorCharacterCountProps) {
    const { editor } = useCurrentEditor();

    if (!editor) {
      return null;
    }

    return (
      <div
        className={cn(
          "absolute right-4 bottom-4 rounded-md border bg-background p-2 text-muted-foreground text-sm shadow",
          className
        )}
      >
        {children}
        {editor.storage.characterCount.characters()}
      </div>
    );
  },

  Words({ children, className }: EditorCharacterCountProps) {
    const { editor } = useCurrentEditor();

    if (!editor) {
      return null;
    }

    return (
      <div
        className={cn(
          "absolute right-4 bottom-4 rounded-md border bg-background p-2 text-muted-foreground text-sm shadow",
          className
        )}
      >
        {children}
        {editor.storage.characterCount.words()}
      </div>
    );
  },
};

Installation

npx shadcn@latest add @kibo-ui/editor

Usage

import { Editor } from "@/components/ui/editor"
<Editor />