290 lines
11 KiB
JavaScript
Executable File
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;
|