"use client"; import { Button } from "@mdxui/primitives/button"; import { Card } from "@mdxui/primitives/card"; import { cn } from "@mdxui/primitives/lib/utils"; import Placeholder from "@tiptap/extension-placeholder"; import { type Editor, EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { Code, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Type, } from "lucide-react"; import type React from "react"; import { useState } from "react"; export interface BlockEditorProps { /** Initial JSON content */ initialValue?: any; /** Callback when content changes */ onChange?: (json: any) => void; /** Placeholder text */ placeholder?: string; /** Height of the editor */ height?: string | number; /** Additional CSS class */ className?: string; /** Whether the editor is read-only */ readOnly?: boolean; /** Available block types */ availableBlocks?: BlockType[]; } export type BlockType = | "paragraph" | "heading1" | "heading2" | "heading3" | "bulletList" | "orderedList" | "quote" | "code" | "image"; interface BlockMenuItem { type: BlockType; label: string; icon: React.ReactNode; action: (editor: Editor) => void; } const getBlockMenuItems = (_editor: Editor): BlockMenuItem[] => [ { type: "paragraph", label: "Text", icon: , action: (e) => e.chain().focus().setParagraph().run(), }, { type: "heading1", label: "Heading 1", icon: , action: (e) => e.chain().focus().setHeading({ level: 1 }).run(), }, { type: "heading2", label: "Heading 2", icon: , action: (e) => e.chain().focus().setHeading({ level: 2 }).run(), }, { type: "heading3", label: "Heading 3", icon: , action: (e) => e.chain().focus().setHeading({ level: 3 }).run(), }, { type: "bulletList", label: "Bullet List", icon: , action: (e) => e.chain().focus().toggleBulletList().run(), }, { type: "orderedList", label: "Numbered List", icon: , action: (e) => e.chain().focus().toggleOrderedList().run(), }, { type: "quote", label: "Quote", icon: , action: (e) => e.chain().focus().toggleBlockquote().run(), }, { type: "code", label: "Code", icon: , action: (e) => e.chain().focus().toggleCodeBlock().run(), }, ]; const BlockMenu: React.FC<{ editor: Editor; onClose: () => void; availableBlocks?: BlockType[]; }> = ({ editor, onClose, availableBlocks }) => { const items = getBlockMenuItems(editor).filter( (item) => !availableBlocks || availableBlocks.includes(item.type), ); return (
Turn into
{items.map((item) => ( ))}
); }; export const BlockEditor: React.FC = ({ initialValue, onChange, placeholder = "Type '/' for commands...", height = 600, className, readOnly = false, availableBlocks, }) => { const [showMenu, setShowMenu] = useState(false); const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3], }, }), Placeholder.configure({ placeholder: ({ node }) => { if (node.type.name === "heading") { return `Heading ${node.attrs.level}`; } return placeholder; }, showOnlyWhenEditable: true, }), ], content: initialValue, editable: !readOnly, onUpdate: ({ editor }) => { const json = editor.getJSON(); onChange?.(json); }, editorProps: { attributes: { class: cn( "prose prose-sm max-w-none focus:outline-none dark:prose-invert", "[&_*]:relative [&_*]:group", ), }, handleDOMEvents: { keydown: (_view, event) => { // Show menu on '/' if (event.key === "/" && !showMenu) { setShowMenu(true); return true; } // Hide menu on escape if (event.key === "Escape" && showMenu) { setShowMenu(false); return true; } return false; }, }, }, }); if (!editor) { return null; } return (
Press /{" "} for commands
{showMenu && ( setShowMenu(false)} availableBlocks={availableBlocks} /> )}
); };