"use client"; import { EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import Link from "@tiptap/extension-link"; import { useEffect } from "react"; import TurndownService from "turndown"; import { marked } from "marked"; import { Bold, Code2, Heading1, Heading2, Heading3, Italic, Link2, List, ListOrdered, Minus, Quote, Redo2, Strikethrough, Undo2, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import "./tiptap-markdown-editor.css"; interface TiptapMarkdownEditorProps { content: string; onChange: (content: string) => void; readOnly?: boolean; placeholder?: string; } // Configure marked to parse markdown marked.setOptions({ gfm: true, breaks: true, }); // Configure turndown to convert HTML back to markdown const turndownService = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", }); type ToolbarButtonProps = { icon: LucideIcon; label: string; active?: boolean; disabled?: boolean; onClick: () => void; }; function ToolbarButton({ icon: Icon, label, active, disabled, onClick }: ToolbarButtonProps) { return ( ); } export function TiptapMarkdownEditor({ content, onChange, readOnly = false, placeholder = "Start typing...", }: TiptapMarkdownEditorProps) { const editor = useEditor({ immediatelyRender: false, content: content ? (marked.parse(content) as string) : "", extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3], }, codeBlock: { HTMLAttributes: { class: "code-block", }, }, }), Placeholder.configure({ placeholder, emptyEditorClass: "is-editor-empty", }), Link.configure({ openOnClick: false, linkOnPaste: true, autolink: true, }), ], editorProps: { attributes: { class: "tiptap-markdown-editor-content", }, }, editable: !readOnly, onUpdate: ({ editor }) => { const html = editor.getHTML(); const markdown = turndownService.turndown(html); onChange(markdown); }, }); // Keep editor content in sync when a new artifact is selected useEffect(() => { if (!editor) return; const currentMarkdown = turndownService.turndown(editor.getHTML()); if ((currentMarkdown || "").trim() === (content || "").trim()) return; editor.commands.setContent(content ? (marked.parse(content) as string) : ""); }, [editor, content]); if (!editor) { return null; } const handleLink = () => { const previousUrl = editor.getAttributes("link").href as string | undefined; const url = window.prompt("Paste or type a link", previousUrl ?? ""); if (url === null) return; if (url === "") { editor.chain().focus().unsetLink().run(); return; } editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run(); }; return (
{!readOnly && (
editor.chain().focus().undo().run()} disabled={!editor.can().undo()} /> editor.chain().focus().redo().run()} disabled={!editor.can().redo()} />
editor.chain().focus().toggleBold().run()} /> editor.chain().focus().toggleItalic().run()} /> editor.chain().focus().toggleStrike().run()} /> editor.chain().focus().toggleCode().run()} />
editor.chain().focus().toggleHeading({ level: 1 }).run()} /> editor.chain().focus().toggleHeading({ level: 2 }).run()} /> editor.chain().focus().toggleHeading({ level: 3 }).run()} />
editor.chain().focus().toggleBulletList().run()} /> editor.chain().focus().toggleOrderedList().run()} /> editor.chain().focus().toggleBlockquote().run()} /> editor.chain().focus().toggleCodeBlock().run()} /> editor.chain().focus().setHorizontalRule().run()} />
Markdown
)}
Editor Markdown + shortcuts
); }