"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>
);
},
};