'use client';
import type { Editor, Range } from '@tiptap/core';
import { mergeAttributes, Node } from '@tiptap/core';
import { CharacterCount } from '@tiptap/extension-character-count';
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { Placeholder } from '@tiptap/extension-placeholder';
import { Subscript } from '@tiptap/extension-subscript';
import { Superscript } from '@tiptap/extension-superscript';
import { Table } from '@tiptap/extension-table';
import { TableCell } from '@tiptap/extension-table-cell';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableRow } from '@tiptap/extension-table-row';
import { TaskItem } from '@tiptap/extension-task-item';
import { TaskList } from '@tiptap/extension-task-list';
import { TextStyle } from '@tiptap/extension-text-style';
import { Typography } from '@tiptap/extension-typography';
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';
// BubbleMenu and FloatingMenu were removed in TipTap v3
// Creating stub types for backwards compatibility
type BubbleMenuProps = {
className?: string;
editor: any;
tippyOptions?: any;
children?: React.ReactNode;
[key: string]: any;
};
type FloatingMenuProps = {
className?: string;
editor: any;
tippyOptions?: any;
children?: React.ReactNode;
[key: string]: any;
};
// Stub implementations - these features are not available in TipTap v3
const BubbleMenu = ({ className, children, ...props }: BubbleMenuProps) => {
// Render as a simple div - the bubble menu functionality is not available in v3
return
{children}
;
};
const FloatingMenu = ({ className, children, ...props }: FloatingMenuProps) => {
// Render as a simple div - the floating menu functionality is not available in v3
return {children}
;
};
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,
MoreHorizontal as EllipsisIcon,
MoreVertical as 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';
interface SlashNodeAttrs {
id: string | null;
label?: string | null;
}
type SlashOptions<
SlashOptionSuggestionItem = unknown,
Attrs = SlashNodeAttrs,
> = {
HTMLAttributes: Record;
renderText: (props: {
options: SlashOptions;
node: ProseMirrorNode;
}) => string;
renderHTML: (props: {
options: SlashOptions;
node: ProseMirrorNode;
}) => DOMOutputSpec;
deleteTriggerWithBackspace: boolean;
suggestion: Omit<
SuggestionOptions,
'editor'
>;
};
const SlashPluginKey = new PluginKey('slash');
export interface SuggestionItem {
title: string;
description: string;
icon: LucideIcon;
searchTerms: string[];
command: (props: { editor: Editor; range: Range }) => void;
}
export const defaultSlashSuggestions: SuggestionOptions['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).toggleTaskList().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({
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) => (
{
e.stopPropagation();
}}
>
No results
{items.map((item) => (
item.command({ editor, range })}
>
{item.title}
{item.description}
))}
);
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;
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',
},
nested: true,
}),
TextStyle.configure({ mergeNestedSpanStyles: true }),
];
return (
{
handleCommandNavigation(event);
},
}}
extensions={[...defaultExtensions, ...(extensions ?? [])]}
{...props}
/>
);
};
export type EditorFloatingMenuProps = Omit;
export const EditorFloatingMenu = ({
className,
...props
}: EditorFloatingMenuProps) => (
);
export type EditorBubbleMenuProps = Omit;
export const EditorBubbleMenu = ({
className,
children,
...props
}: EditorBubbleMenuProps) => (
*:first-child]:rounded-l-[9px]',
'[&>*:last-child]:rounded-r-[9px]',
className
)}
editor={null}
tippyOptions={{
maxWidth: 'none',
}}
{...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( );
acc.push(child);
return acc;
}, [])
: children}
);
type EditorButtonProps = {
name: string;
isActive: () => boolean;
command: () => void;
icon: LucideIcon | ((props: LucideProps) => ReactNode);
hideName?: boolean;
};
const BubbleMenuButton = ({
name,
isActive,
command,
icon: Icon,
hideName,
}: EditorButtonProps) => (
command()}
size="sm"
variant="ghost"
>
{!hideName && {name} }
{isActive() ? (
) : null}
);
export type EditorClearFormattingProps = Pick;
export const EditorClearFormatting = ({
hideName = true,
}: EditorClearFormattingProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().clearNodes().unsetAllMarks().run()}
hideName={hideName}
icon={RemoveFormattingIcon}
isActive={() => false}
name="Clear Formatting"
/>
);
};
export type EditorNodeTextProps = Pick;
export const EditorNodeText = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
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;
export const EditorNodeHeading1 = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
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;
export const EditorNodeHeading2 = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
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;
export const EditorNodeHeading3 = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
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;
export const EditorNodeBulletList = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleBulletList().run()}
hideName={hideName}
icon={ListIcon}
isActive={() => editor.isActive('bulletList') ?? false}
name="Bullet List"
/>
);
};
export type EditorNodeOrderedListProps = Pick;
export const EditorNodeOrderedList = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleOrderedList().run()}
hideName={hideName}
icon={ListOrderedIcon}
isActive={() => editor.isActive('orderedList') ?? false}
name="Numbered List"
/>
);
};
export type EditorNodeTaskListProps = Pick;
export const EditorNodeTaskList = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleTaskList().run()}
hideName={hideName}
icon={CheckSquareIcon}
isActive={() => editor.isActive('taskItem') ?? false}
name="To-do List"
/>
);
};
export type EditorNodeQuoteProps = Pick;
export const EditorNodeQuote = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor
.chain()
.focus()
.toggleNode('paragraph', 'paragraph')
.toggleBlockquote()
.run()
}
hideName={hideName}
icon={TextQuoteIcon}
isActive={() => editor.isActive('blockquote') ?? false}
name="Quote"
/>
);
};
export type EditorNodeCodeProps = Pick;
export const EditorNodeCode = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleCodeBlock().run()}
hideName={hideName}
icon={CodeIcon}
isActive={() => editor.isActive('codeBlock') ?? false}
name="Code"
/>
);
};
export type EditorNodeTableProps = Pick;
export const EditorNodeTable = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
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 & {
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 (
{title}
{children}
);
};
export type EditorFormatBoldProps = Pick;
export const EditorFormatBold = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleBold().run()}
hideName={hideName}
icon={BoldIcon}
isActive={() => editor.isActive('bold') ?? false}
name="Bold"
/>
);
};
export type EditorFormatItalicProps = Pick;
export const EditorFormatItalic = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleItalic().run()}
hideName={hideName}
icon={ItalicIcon}
isActive={() => editor.isActive('italic') ?? false}
name="Italic"
/>
);
};
export type EditorFormatStrikeProps = Pick;
export const EditorFormatStrike = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleStrike().run()}
hideName={hideName}
icon={StrikethroughIcon}
isActive={() => editor.isActive('strike') ?? false}
name="Strikethrough"
/>
);
};
export type EditorFormatCodeProps = Pick;
export const EditorFormatCode = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleCode().run()}
hideName={hideName}
icon={CodeIcon}
isActive={() => editor.isActive('code') ?? false}
name="Code"
/>
);
};
export type EditorFormatSubscriptProps = Pick;
export const EditorFormatSubscript = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleSubscript().run()}
hideName={hideName}
icon={SubscriptIcon}
isActive={() => editor.isActive('subscript') ?? false}
name="Subscript"
/>
);
};
export type EditorFormatSuperscriptProps = Pick;
export const EditorFormatSuperscript = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleSuperscript().run()}
hideName={hideName}
icon={SuperscriptIcon}
isActive={() => editor.isActive('superscript') ?? false}
name="Superscript"
/>
);
};
export type EditorFormatUnderlineProps = Pick;
export const EditorFormatUnderline = ({
hideName = false,
}: Pick) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
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('');
const inputReference = useRef(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 = (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 (
Link
);
};
export type EditorTableMenuProps = {
children: ReactNode;
};
export const EditorTableMenu = ({ children }: EditorTableMenuProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
const isActive = editor.isActive('table');
return (
{children}
);
};
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 (
{children}
);
};
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 (
{children}
);
};
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 (
{children}
);
};
export const EditorTableColumnBefore = () => {
const { editor } = useCurrentEditor();
const handleClick = useCallback(() => {
if (editor) {
editor.chain().focus().addColumnBefore().run();
}
}, [editor]);
if (!editor) {
return null;
}
return (
Add column before
);
};
export const EditorTableColumnAfter = () => {
const { editor } = useCurrentEditor();
const handleClick = useCallback(() => {
if (editor) {
editor.chain().focus().addColumnAfter().run();
}
}, [editor]);
if (!editor) {
return null;
}
return (
Add column after
);
};
export const EditorTableRowBefore = () => {
const { editor } = useCurrentEditor();
const handleClick = useCallback(() => {
if (editor) {
editor.chain().focus().addRowBefore().run();
}
}, [editor]);
if (!editor) {
return null;
}
return (
Add row before
);
};
export const EditorTableRowAfter = () => {
const { editor } = useCurrentEditor();
const handleClick = useCallback(() => {
if (editor) {
editor.chain().focus().addRowAfter().run();
}
}, [editor]);
if (!editor) {
return null;
}
return (
Add row after
);
};
export const EditorTableColumnDelete = () => {
const { editor } = useCurrentEditor();
const handleClick = useCallback(() => {
if (editor) {
editor.chain().focus().deleteColumn().run();
}
}, [editor]);
if (!editor) {
return null;
}
return (
Delete column
);
};
export const EditorTableRowDelete = () => {
const { editor } = useCurrentEditor();
const handleClick = useCallback(() => {
if (editor) {
editor.chain().focus().deleteRow().run();
}
}, [editor]);
if (!editor) {
return null;
}
return (
Delete row
);
};
export const EditorTableHeaderColumnToggle = () => {
const { editor } = useCurrentEditor();
const handleClick = useCallback(() => {
if (editor) {
editor.chain().focus().toggleHeaderColumn().run();
}
}, [editor]);
if (!editor) {
return null;
}
return (
Toggle header column
);
};
export const EditorTableHeaderRowToggle = () => {
const { editor } = useCurrentEditor();
const handleClick = useCallback(() => {
if (editor) {
editor.chain().focus().toggleHeaderRow().run();
}
}, [editor]);
if (!editor) {
return null;
}
return (
Toggle header row
);
};
export const EditorTableDelete = () => {
const { editor } = useCurrentEditor();
const handleClick = useCallback(() => {
if (editor) {
editor.chain().focus().deleteTable().run();
}
}, [editor]);
if (!editor) {
return null;
}
return (
Delete table
);
};
export const EditorTableMergeCells = () => {
const { editor } = useCurrentEditor();
const handleClick = useCallback(() => {
if (editor) {
editor.chain().focus().mergeCells().run();
}
}, [editor]);
if (!editor) {
return null;
}
return (
Merge cells
);
};
export const EditorTableSplitCell = () => {
const { editor } = useCurrentEditor();
const handleClick = useCallback(() => {
if (editor) {
editor.chain().focus().splitCell().run();
}
}, [editor]);
if (!editor) {
return null;
}
return (
Split cell
);
};
export const EditorTableFix = () => {
const { editor } = useCurrentEditor();
const handleClick = useCallback(() => {
if (editor) {
editor.chain().focus().fixTables().run();
}
}, [editor]);
if (!editor) {
return null;
}
return (
Fix table
);
};
export type EditorCharacterCountProps = {
children: ReactNode;
className?: string;
};
export const EditorCharacterCount = {
Characters({ children, className }: EditorCharacterCountProps) {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
{children}
{editor.storage.characterCount.characters()}
);
},
Words({ children, className }: EditorCharacterCountProps) {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
{children}
{editor.storage.characterCount.words()}
);
},
};