'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) => ( ); 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 ( {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 (
setUrl(event.target.value)} placeholder="Paste a link" ref={inputReference} type="text" value={url} /> {editor.getAttributes('link').href ? ( ) : ( )}
); }; 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()}
); }, };