Files
dewedev/src/components/RichMarkdownEditor.js

290 lines
11 KiB
JavaScript
Executable File

import React, { useEffect, useCallback } from "react";
import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import { TaskList } from "@tiptap/extension-task-list";
import { TaskItem } from "@tiptap/extension-task-item";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from "lowlight";
import CodeBlockComponent from "./CodeBlockComponent";
import { Markdown } from "tiptap-markdown";
import {
Bold,
Italic,
Strikethrough,
Code,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
CheckSquare,
Quote,
Link2,
Image as ImageIcon,
Table as TableIcon,
Minus,
} from "lucide-react";
// Set up lowlight for syntax highlighting in Tiptap
const lowlight = createLowlight(common);
const MenuBar = ({ editor }) => {
if (!editor) return null;
return (
<div className="flex flex-wrap justify-center items-center gap-1 p-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10 w-full">
<div className="flex flex-wrap items-center gap-1 w-full max-w-[85ch]">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("bold") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Bold"
>
<Bold className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("italic") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Italic"
>
<Italic className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("strike") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Strikethrough"
>
<Strikethrough className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("code") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Inline Code"
>
<Code className="h-4 w-4" />
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
<button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("heading", { level: 1 }) ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Heading 1"
>
<Heading1 className="h-4 w-4" />
</button>
<button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("heading", { level: 2 }) ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Heading 2"
>
<Heading2 className="h-4 w-4" />
</button>
<button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("heading", { level: 3 }) ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Heading 3"
>
<Heading3 className="h-4 w-4" />
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("bulletList") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Bullet List"
>
<List className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("orderedList") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Ordered List"
>
<ListOrdered className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleTaskList().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("taskList") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Task List"
>
<CheckSquare className="h-4 w-4" />
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("blockquote") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Blockquote"
>
<Quote className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("codeBlock") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Code Block"
>
<Code className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().setHorizontalRule().run()}
className="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300"
title="Horizontal Rule"
>
<Minus className="h-4 w-4" />
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1" />
<button
onClick={() => {
const url = window.prompt("URL");
if (url) {
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.run();
}
}}
className={`p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors ${editor.isActive("link") ? "bg-gray-200 dark:bg-gray-700 text-blue-600 dark:text-blue-400" : "text-gray-600 dark:text-gray-300"}`}
title="Add Link"
>
<Link2 className="h-4 w-4" />
</button>
<button
onClick={() => {
const url = window.prompt("Image URL");
if (url) {
editor.chain().focus().setImage({ src: url }).run();
}
}}
className="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300"
title="Add Image"
>
<ImageIcon className="h-4 w-4" />
</button>
<button
onClick={() =>
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run()
}
className="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300"
title="Insert Table"
>
<TableIcon className="h-4 w-4" />
</button>
</div>
</div>
);
};
const RichMarkdownEditor = ({
initialContent,
onChange,
className = "",
height = "600px",
isFullscreen = false,
}) => {
const editor = useEditor({
extensions: [
StarterKit.configure({
codeBlock: false, // We'll use our own codeblock extension
}),
CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockComponent);
},
}).configure({
lowlight,
}),
Link.configure({
openOnClick: false,
}),
Image,
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
TaskList,
TaskItem.configure({
nested: true,
}),
Markdown.configure({
html: true,
tightLists: true,
tightListClass: "tight",
bulletListMarker: "-",
linkify: true,
breaks: false,
}),
],
content: initialContent,
onUpdate: ({ editor }) => {
// Serialize back to markdown and send to parent
const markdownOutput = editor.storage.markdown.getMarkdown();
const htmlOutput = editor.getHTML();
onChange(markdownOutput, htmlOutput);
},
editorProps: {
attributes: {
class:
"prose prose-sm sm:prose dark:prose-invert prose-blue focus:outline-none w-full max-w-none",
},
},
});
// Update editor content when initialContent prop completely changes from outside (e.g. loading a template)
useEffect(() => {
if (editor && initialContent !== undefined) {
const currentMarkdown = editor.storage.markdown.getMarkdown();
if (initialContent !== currentMarkdown) {
editor.commands.setContent(initialContent);
}
}
}, [editor, initialContent]);
return (
<div
className={`flex flex-col bg-white dark:bg-gray-900 overflow-hidden ${className}`}
>
<MenuBar editor={editor} />
<div
className={`overflow-y-auto w-full custom-scrollbar flex justify-center p-6`}
style={{ height }}
>
<div
className={`w-full max-w-[85ch] markdown-content-wrapper ${isFullscreen ? "is-fullscreen" : "is-normal"} is-edit-mode`}
>
<EditorContent editor={editor} />
</div>
</div>
</div>
);
};
export default RichMarkdownEditor;