183 lines
6.2 KiB
TypeScript
183 lines
6.2 KiB
TypeScript
"use client";
|
|||
|
|
|
||
|
|
import { useEffect, useState } from "react";
|
||
|
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||
|
|
import StarterKit from "@tiptap/starter-kit";
|
||
|
|
import Placeholder from "@tiptap/extension-placeholder";
|
||
|
|
import { Markdown } from "tiptap-markdown";
|
||
|
|
import { Bold, Code, Heading2, Heading3, Italic, List, ListOrdered } from "lucide-react";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
type Props = {
|
||
|
|
value: string;
|
||
|
|
onChange: (v: string) => void;
|
||
|
|
placeholder?: string;
|
||
|
|
className?: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
export function RichEditor({ value, onChange, placeholder, className }: Props) {
|
||
|
|
const [tab, setTab] = useState<"edit" | "preview">("edit");
|
||
|
|
const [preview, setPreview] = useState("");
|
||
|
|
|
||
|
|
const editor = useEditor({
|
||
|
|
extensions: [
|
||
|
|
StarterKit.configure({ codeBlock: false }),
|
||
|
|
Placeholder.configure({ placeholder: placeholder ?? "توضیحات را اینجا بنویسید…" }),
|
||
|
|
Markdown.configure({ transformPastedText: true }),
|
||
|
|
],
|
||
|
|
content: value,
|
||
|
|
immediatelyRender: false,
|
||
|
|
onUpdate({ editor: e }) {
|
||
|
|
const md = e.storage.markdown?.getMarkdown?.() ?? e.getText();
|
||
|
|
onChange(md);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// Sync external value changes (e.g. when a new lesson is loaded).
|
||
|
|
useEffect(() => {
|
||
|
|
if (!editor) return;
|
||
|
|
const current = editor.storage.markdown?.getMarkdown?.() ?? editor.getText();
|
||
|
|
if (current !== value) {
|
||
|
|
editor.commands.setContent(value, false);
|
||
|
|
}
|
||
|
|
}, [value, editor]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (tab !== "preview") return;
|
||
|
|
import("marked").then(async ({ marked }) => {
|
||
|
|
const DOMPurify = (await import("dompurify")).default;
|
||
|
|
const html = await marked(value);
|
||
|
|
setPreview(DOMPurify.sanitize(html));
|
||
|
|
});
|
||
|
|
}, [tab, value]);
|
||
|
|
|
||
|
|
const ToolBtn = ({
|
||
|
|
onClick,
|
||
|
|
active,
|
||
|
|
title,
|
||
|
|
children,
|
||
|
|
}: {
|
||
|
|
onClick: () => void;
|
||
|
|
active?: boolean;
|
||
|
|
title: string;
|
||
|
|
children: React.ReactNode;
|
||
|
|
}) => (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onMouseDown={(e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
onClick();
|
||
|
|
}}
|
||
|
|
title={title}
|
||
|
|
className={cn(
|
||
|
|
"grid h-7 w-7 place-items-center rounded transition-colors",
|
||
|
|
active
|
||
|
|
? "bg-primary/15 text-primary"
|
||
|
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={cn("rounded-md border border-input bg-background", className)}>
|
||
|
|
{/* Tab bar */}
|
||
|
|
<div className="flex items-center justify-between gap-1 border-b border-input px-2 py-1">
|
||
|
|
<div className="flex gap-0.5">
|
||
|
|
{editor && (
|
||
|
|
<>
|
||
|
|
<ToolBtn
|
||
|
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||
|
|
active={editor.isActive("bold")}
|
||
|
|
title="توپر"
|
||
|
|
>
|
||
|
|
<Bold className="h-3.5 w-3.5" />
|
||
|
|
</ToolBtn>
|
||
|
|
<ToolBtn
|
||
|
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||
|
|
active={editor.isActive("italic")}
|
||
|
|
title="مورب"
|
||
|
|
>
|
||
|
|
<Italic className="h-3.5 w-3.5" />
|
||
|
|
</ToolBtn>
|
||
|
|
<ToolBtn
|
||
|
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
||
|
|
active={editor.isActive("code")}
|
||
|
|
title="کد درونخطی"
|
||
|
|
>
|
||
|
|
<Code className="h-3.5 w-3.5" />
|
||
|
|
</ToolBtn>
|
||
|
|
<ToolBtn
|
||
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||
|
|
active={editor.isActive("heading", { level: 2 })}
|
||
|
|
title="سرتیتر ۲"
|
||
|
|
>
|
||
|
|
<Heading2 className="h-3.5 w-3.5" />
|
||
|
|
</ToolBtn>
|
||
|
|
<ToolBtn
|
||
|
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||
|
|
active={editor.isActive("heading", { level: 3 })}
|
||
|
|
title="سرتیتر ۳"
|
||
|
|
>
|
||
|
|
<Heading3 className="h-3.5 w-3.5" />
|
||
|
|
</ToolBtn>
|
||
|
|
<ToolBtn
|
||
|
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||
|
|
active={editor.isActive("bulletList")}
|
||
|
|
title="فهرست"
|
||
|
|
>
|
||
|
|
<List className="h-3.5 w-3.5" />
|
||
|
|
</ToolBtn>
|
||
|
|
<ToolBtn
|
||
|
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||
|
|
active={editor.isActive("orderedList")}
|
||
|
|
title="فهرست عددی"
|
||
|
|
>
|
||
|
|
<ListOrdered className="h-3.5 w-3.5" />
|
||
|
|
</ToolBtn>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="flex gap-1 text-xs">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setTab("edit")}
|
||
|
|
className={cn(
|
||
|
|
"rounded px-2 py-0.5 transition-colors",
|
||
|
|
tab === "edit" ? "bg-primary/15 text-primary font-medium" : "text-muted-foreground hover:text-foreground",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
ویرایش
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setTab("preview")}
|
||
|
|
className={cn(
|
||
|
|
"rounded px-2 py-0.5 transition-colors",
|
||
|
|
tab === "preview" ? "bg-primary/15 text-primary font-medium" : "text-muted-foreground hover:text-foreground",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
پیشنمایش
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Editor / Preview */}
|
||
|
|
{tab === "edit" ? (
|
||
|
|
<EditorContent
|
||
|
|
editor={editor}
|
||
|
|
dir="auto"
|
||
|
|
className="prose prose-sm max-w-none px-3 py-2 text-sm focus-within:outline-none [&_.ProseMirror]:min-h-[80px] [&_.ProseMirror]:outline-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground/50 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-right [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none"
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<div
|
||
|
|
dir="auto"
|
||
|
|
className="prose prose-sm max-w-none px-3 py-2 text-sm"
|
||
|
|
dangerouslySetInnerHTML={{ __html: preview || "<p class='text-muted-foreground text-sm'>پیشنمایشی نیست.</p>" }}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|