Files
pargar/frontend/src/components/ui/rich-editor.tsx
T
ahpandClaude Sonnet 4.6 40fa0b8581 fix: quality audit — memory leaks, dead prose classes, stale state scope
- safe-markdown: add `cancelled` flag to async useEffect; no more
  setState after unmount when component is destroyed mid-import
- rich-editor: same cleanup for preview tab effect; also remove dead
  `prose prose-sm max-w-none` classes (Tailwind Typography not installed)
  and replace with explicit arbitrary-selector markdown styling that
  actually renders using existing design tokens
- profile-certificate-slot: drop dead `ORDER_LABEL`, `order` variable,
  and `physicalOrder` from `MineCertificateResponse`; physical orders
  are disabled — these were leftover references causing lint noise
- learning-panel: change `issuingCert: boolean` → `issuingCertFor:
  number | null` so only the targeted user's button shows a spinner;
  a boolean shared across all users caused the wrong row to animate
  when the admin switched selected users mid-request

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-18 10:29:58 +03:30

197 lines
6.9 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;
};
const mdClasses =
"[&_strong]:font-bold [&_em]:italic [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:text-[0.85em] [&_h2]:text-base [&_h2]:font-bold [&_h2]:mt-3 [&_h3]:text-sm [&_h3]:font-bold [&_h3]:mt-2 [&_ul]:list-disc [&_ul]:pr-5 [&_ol]:list-decimal [&_ol]:pr-5 [&_li]:mt-0.5 [&_p]:leading-relaxed [&_p+p]:mt-2 [&_a]:underline [&_a]:text-primary";
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 }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const md = (e.storage as any).markdown?.getMarkdown?.() ?? e.getText();
onChange(md);
},
});
useEffect(() => {
if (!editor) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const current = (editor.storage as any).markdown?.getMarkdown?.() ?? editor.getText();
if (current !== value) {
editor.commands.setContent(value, { emitUpdate: false });
}
}, [value, editor]);
useEffect(() => {
if (tab !== "preview") return;
let cancelled = false;
void (async () => {
const { marked } = await import("marked");
const DOMPurify = (await import("dompurify")).default;
const html = await marked(value);
if (!cancelled) setPreview(DOMPurify.sanitize(html));
})();
return () => { cancelled = true; };
}, [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={cn(
"px-3 py-2 text-sm focus-within:outline-none",
mdClasses,
"[&_.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={cn("px-3 py-2 text-sm", mdClasses)}
dangerouslySetInnerHTML={{
__html: preview || "<p class='text-muted-foreground text-sm'>پیش‌نمایشی نیست.</p>",
}}
/>
)}
</div>
);
}