feat(blog): add codemirror markdown editor

This commit is contained in:
2026-06-12 23:43:55 +03:30
parent cb8eeadba9
commit 25cbf53179
4 changed files with 954 additions and 18 deletions

View File

@@ -0,0 +1,683 @@
"use client";
import { useEffect, useRef, useState, type ComponentType } from "react";
import { defaultKeymap, history, historyKeymap, redo, undo } from "@codemirror/commands";
import { markdown } from "@codemirror/lang-markdown";
import { bracketMatching, defaultHighlightStyle, HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { search, searchKeymap } from "@codemirror/search";
import { Compartment, EditorState, RangeSetBuilder, type Extension } from "@codemirror/state";
import { tags } from "@lezer/highlight";
import {
Decoration,
drawSelection,
dropCursor,
EditorView,
highlightActiveLine,
highlightSpecialChars,
keymap,
rectangularSelection,
} from "@codemirror/view";
import {
Bold,
Code2,
Heading1,
Heading2,
Heading3,
HelpCircle,
Image as ImageIcon,
IndentDecrease,
IndentIncrease,
Italic,
Link as LinkIcon,
List,
ListChecks,
ListOrdered,
Minus,
Quote,
Redo2,
Strikethrough,
Table2,
TextCursorInput,
Undo2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type MarkdownDirectionMode = "auto" | "rtl" | "ltr";
type MarkdownEditorProps = {
value: string;
onChange: (value: string) => void;
minHeight?: string;
directionMode?: MarkdownDirectionMode;
onDirectionModeChange?: (mode: MarkdownDirectionMode) => void;
onSave?: () => unknown | Promise<unknown>;
className?: string;
};
type ToolbarAction = {
key: string;
label: string;
icon: ComponentType<{ className?: string }>;
run: () => void;
};
type InsertDialogState =
| { type: "link"; from: number; to: number; selectedText: string }
| { type: "image"; from: number; to: number; selectedText: string }
| null;
const rtlStrongPattern = /[\u0590-\u08FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
const ltrStrongPattern = /[A-Za-z0-9]/;
const codeFontFamily =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
const codeFontHighlightStyle = HighlightStyle.define([
{
tag: [tags.monospace, tags.processingInstruction],
fontFamily: codeFontFamily,
},
]);
function detectLineDirection(text: string): "rtl" | "ltr" {
for (const char of text.trimStart()) {
if (rtlStrongPattern.test(char)) return "rtl";
if (ltrStrongPattern.test(char)) return "ltr";
}
return "ltr";
}
function directionExtension(mode: MarkdownDirectionMode): Extension {
if (mode !== "auto") {
return [
EditorView.editorAttributes.of({ dir: mode }),
EditorView.contentAttributes.of({ dir: mode }),
EditorView.perLineTextDirection.of(false),
];
}
return [
EditorView.editorAttributes.of({ dir: "rtl" }),
EditorView.perLineTextDirection.of(true),
EditorView.decorations.compute(["doc"], (state) => {
const builder = new RangeSetBuilder<Decoration>();
for (let lineNumber = 1; lineNumber <= state.doc.lines; lineNumber += 1) {
const line = state.doc.line(lineNumber);
builder.add(
line.from,
line.from,
Decoration.line({ attributes: { dir: detectLineDirection(line.text) } }),
);
}
return builder.finish();
}),
];
}
function editorTheme(minHeight: string): Extension {
return EditorView.theme({
"&": {
minHeight,
backgroundColor: "hsl(var(--muted) / 0.28)",
color: "hsl(var(--foreground))",
fontSize: "14px",
direction: "rtl",
},
".cm-scroller": {
minHeight,
fontFamily: "inherit",
lineHeight: "1.9",
},
".cm-content": {
caretColor: "hsl(var(--primary))",
padding: "1rem",
backgroundColor: "hsl(var(--muted) / 0.16)",
},
".cm-line": {
padding: "0 0.25rem",
unicodeBidi: "plaintext",
},
".cm-monospace": {
fontFamily: codeFontFamily,
},
".cm-line[dir='rtl']": {
textAlign: "right",
},
".cm-line[dir='ltr']": {
textAlign: "left",
},
".cm-activeLine": {
backgroundColor: "hsl(var(--background) / 0.82)",
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "hsl(var(--primary) / 0.2)",
},
"&.cm-focused": {
outline: "none",
},
".cm-cursor": {
borderLeftColor: "hsl(var(--primary))",
},
".cm-tooltip": {
borderRadius: "0.75rem",
borderColor: "hsl(var(--border))",
backgroundColor: "hsl(var(--popover))",
color: "hsl(var(--popover-foreground))",
},
".cm-gutters": {
display: "none",
},
});
}
function insertAtSelection(view: EditorView, text: string, cursorOffset = text.length) {
const selection = view.state.selection.main;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: text },
selection: { anchor: selection.from + cursorOffset },
scrollIntoView: true,
});
view.focus();
}
function wrapSelection(view: EditorView, before: string, after = before, placeholder = "متن") {
const selection = view.state.selection.main;
const selected = view.state.doc.sliceString(selection.from, selection.to) || placeholder;
const insert = `${before}${selected}${after}`;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert },
selection: {
anchor: selection.from + before.length,
head: selection.from + before.length + selected.length,
},
scrollIntoView: true,
});
view.focus();
}
function selectedLines(view: EditorView) {
const selection = view.state.selection.main;
const startLine = view.state.doc.lineAt(selection.from);
const endLine = view.state.doc.lineAt(selection.to);
return { startLine, endLine };
}
function prefixLines(view: EditorView, makePrefix: (index: number) => string) {
const { startLine, endLine } = selectedLines(view);
const changes: Array<{ from: number; insert: string }> = [];
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
const line = view.state.doc.line(lineNumber);
changes.push({ from: line.from, insert: makePrefix(lineNumber - startLine.number + 1) });
}
view.dispatch({ changes, scrollIntoView: true });
view.focus();
}
function indentListLines(view: EditorView) {
const { startLine, endLine } = selectedLines(view);
const changes: Array<{ from: number; insert: string }> = [];
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
const line = view.state.doc.line(lineNumber);
if (/^\s*(?:[-*+]|\d+\.)(?:\s+\[[ xX]\])?\s+/.test(line.text)) {
changes.push({ from: line.from, insert: " " });
}
}
if (!changes.length) return false;
view.dispatch({ changes, scrollIntoView: true });
view.focus();
return true;
}
function outdentListLines(view: EditorView) {
const { startLine, endLine } = selectedLines(view);
const changes: Array<{ from: number; to: number; insert: string }> = [];
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
const line = view.state.doc.line(lineNumber);
const match = line.text.match(/^(\t| {1,2})/);
if (match && /^\s*(?:[-*+]|\d+\.)(?:\s+\[[ xX]\])?\s+/.test(line.text)) {
changes.push({ from: line.from, to: line.from + match[1].length, insert: "" });
}
}
if (!changes.length) return false;
view.dispatch({ changes, scrollIntoView: true });
view.focus();
return true;
}
function setHeading(view: EditorView, level: 1 | 2 | 3) {
const { startLine, endLine } = selectedLines(view);
const changes: Array<{ from: number; to?: number; insert: string }> = [];
const marker = `${"#".repeat(level)} `;
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
const line = view.state.doc.line(lineNumber);
const match = line.text.match(/^(\s{0,3})(#{1,6}\s+)/);
if (match) {
const prefixStart = line.from + match[1].length;
changes.push({ from: prefixStart, to: prefixStart + match[2].length, insert: marker });
} else {
changes.push({ from: line.from, insert: marker });
}
}
view.dispatch({ changes, scrollIntoView: true });
view.focus();
}
function wrapBlock(view: EditorView, before: string, after: string, placeholder: string) {
const selection = view.state.selection.main;
const selected = view.state.doc.sliceString(selection.from, selection.to) || placeholder;
const insert = `${before}${selected}${after}`;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert },
selection: {
anchor: selection.from + before.length,
head: selection.from + before.length + selected.length,
},
scrollIntoView: true,
});
view.focus();
}
function getSelectedText(view: EditorView) {
const selection = view.state.selection.main;
return view.state.doc.sliceString(selection.from, selection.to);
}
function insertLinkAtSelection(view: EditorView, from: number, to: number, selectedText: string, text: string, url: string) {
const label = text.trim() || selectedText || "متن لینک";
view.dispatch({
changes: { from, to, insert: `[${label}](${url})` },
selection: { anchor: from + 1, head: from + 1 + label.length },
scrollIntoView: true,
});
view.focus();
}
function insertImageAtSelection(view: EditorView, from: number, to: number, selectedText: string, altText: string, url: string) {
const alt = altText.trim() || selectedText || "تصویر";
view.dispatch({
changes: { from, to, insert: `![${alt}](${url})` },
selection: { anchor: from + 2, head: from + 2 + alt.length },
scrollIntoView: true,
});
view.focus();
}
function runEditorCommand(view: EditorView | null, command: (view: EditorView) => void) {
if (!view) return;
command(view);
}
export default function MarkdownEditor({
value,
onChange,
minHeight = "520px",
directionMode = "auto",
onDirectionModeChange,
onSave,
className,
}: MarkdownEditorProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
const onSaveRef = useRef(onSave);
const initialValueRef = useRef(value);
const initialDirectionModeRef = useRef(directionMode);
const directionCompartment = useRef(new Compartment());
const [guideOpen, setGuideOpen] = useState(false);
const [insertDialog, setInsertDialog] = useState<InsertDialogState>(null);
const [insertUrl, setInsertUrl] = useState("");
const [insertText, setInsertText] = useState("");
onChangeRef.current = onChange;
onSaveRef.current = onSave;
const openInsertDialog = (view: EditorView, type: "link" | "image") => {
const selection = view.state.selection.main;
const selectedText = getSelectedText(view);
setInsertDialog({ type, from: selection.from, to: selection.to, selectedText });
setInsertText(selectedText);
setInsertUrl("");
};
const closeInsertDialog = () => {
setInsertDialog(null);
setInsertText("");
setInsertUrl("");
viewRef.current?.focus();
};
const submitInsertDialog = () => {
const view = viewRef.current;
const dialog = insertDialog;
const url = insertUrl.trim();
if (!view || !dialog || !url) return;
if (dialog.type === "link") {
insertLinkAtSelection(view, dialog.from, dialog.to, dialog.selectedText, insertText, url);
} else {
insertImageAtSelection(view, dialog.from, dialog.to, dialog.selectedText, insertText, url);
}
closeInsertDialog();
};
useEffect(() => {
if (!hostRef.current) return undefined;
const extensions: Extension[] = [
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
rectangularSelection(),
bracketMatching(),
highlightActiveLine(),
markdown(),
search({ top: true }),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
syntaxHighlighting(codeFontHighlightStyle),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
}),
keymap.of([
{ key: "Mod-s", run: () => (void onSaveRef.current?.(), true) },
{ key: "Mod-b", run: (view) => (wrapSelection(view, "**"), true) },
{ key: "Mod-i", run: (view) => (wrapSelection(view, "_"), true) },
{ key: "Mod-k", run: (view) => (openInsertDialog(view, "link"), true) },
{ key: "Mod-/", run: () => (setGuideOpen(true), true) },
{ key: "Mod-Shift-7", run: (view) => (prefixLines(view, (index) => `${index}. `), true) },
{ key: "Mod-Shift-8", run: (view) => (prefixLines(view, () => "- "), true) },
{ key: "Mod-e", run: (view) => (wrapSelection(view, "`"), true) },
{ key: "Mod-Alt-c", run: (view) => (wrapBlock(view, "\n```text\n", "\n```\n", "code"), true) },
{ key: "Mod-Alt-i", run: (view) => (openInsertDialog(view, "image"), true) },
{ key: "Mod-Alt-1", run: (view) => (setHeading(view, 1), true) },
{ key: "Mod-Alt-2", run: (view) => (setHeading(view, 2), true) },
{ key: "Mod-Alt-3", run: (view) => (setHeading(view, 3), true) },
{
key: "Mod-Alt-t",
run: (view) => (
insertAtSelection(view, "\n| ستون اول | ستون دوم |\n| --- | --- |\n| مقدار | مقدار |\n"),
true
),
},
{ key: "Mod-Alt-x", run: (view) => (prefixLines(view, () => "- [ ] "), true) },
{ key: "Mod-Shift-x", run: (view) => (wrapSelection(view, "~~"), true) },
{ key: "Tab", run: indentListLines },
{ key: "Shift-Tab", run: outdentListLines },
...searchKeymap,
...defaultKeymap,
...historyKeymap,
]),
editorTheme(minHeight),
directionCompartment.current.of(directionExtension(initialDirectionModeRef.current)),
];
const state = EditorState.create({
doc: initialValueRef.current,
extensions,
});
const view = new EditorView({ state, parent: hostRef.current });
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
}, [minHeight]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current === value) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
}, [value]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: directionCompartment.current.reconfigure(directionExtension(directionMode)),
});
}, [directionMode]);
const setDirectionMode = (mode: MarkdownDirectionMode) => {
onDirectionModeChange?.(mode);
};
const actions: ToolbarAction[] = [
{ key: "bold", label: "درشت (Ctrl+B)", icon: Bold, run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "**")) },
{ key: "italic", label: "کج (Ctrl+I)", icon: Italic, run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "_")) },
{
key: "strike",
label: "خط‌خورده (Ctrl+Shift+X)",
icon: Strikethrough,
run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "~~")),
},
{ key: "h1", label: "تیتر ۱ (Ctrl+Alt+1)", icon: Heading1, run: () => runEditorCommand(viewRef.current, (view) => setHeading(view, 1)) },
{ key: "h2", label: "تیتر ۲ (Ctrl+Alt+2)", icon: Heading2, run: () => runEditorCommand(viewRef.current, (view) => setHeading(view, 2)) },
{ key: "h3", label: "تیتر ۳ (Ctrl+Alt+3)", icon: Heading3, run: () => runEditorCommand(viewRef.current, (view) => setHeading(view, 3)) },
{ key: "quote", label: "نقل قول", icon: Quote, run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, () => "> ")) },
{ key: "ul", label: "فهرست نقطه‌ای (Ctrl+Shift+8)", icon: List, run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, () => "- ")) },
{
key: "ol",
label: "فهرست شماره‌ای (Ctrl+Shift+7)",
icon: ListOrdered,
run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, (index) => `${index}. `)),
},
{
key: "task-list",
label: "فهرست کارها (Ctrl+Alt+X)",
icon: ListChecks,
run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, () => "- [ ] ")),
},
{ key: "link", label: "لینک (Ctrl+K)", icon: LinkIcon, run: () => runEditorCommand(viewRef.current, (view) => openInsertDialog(view, "link")) },
{ key: "image", label: "تصویر (Ctrl+Alt+I)", icon: ImageIcon, run: () => runEditorCommand(viewRef.current, (view) => openInsertDialog(view, "image")) },
{ key: "inline-code", label: "کد کوتاه (Ctrl+E)", icon: TextCursorInput, run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "`", "`", "code")) },
{
key: "code",
label: "بلوک کد (Ctrl+Alt+C)",
icon: Code2,
run: () => runEditorCommand(viewRef.current, (view) => wrapBlock(view, "\n```text\n", "\n```\n", "code")),
},
{
key: "table",
label: "جدول (Ctrl+Alt+T)",
icon: Table2,
run: () =>
runEditorCommand(viewRef.current, (view) =>
insertAtSelection(view, "\n| ستون اول | ستون دوم |\n| --- | --- |\n| مقدار | مقدار |\n"),
),
},
{ key: "hr", label: "خط جداکننده", icon: Minus, run: () => runEditorCommand(viewRef.current, (view) => insertAtSelection(view, "\n---\n")) },
{ key: "undo", label: "بازگردانی (Ctrl+Z)", icon: Undo2, run: () => runEditorCommand(viewRef.current, (view) => undo(view)) },
{ key: "redo", label: "انجام دوباره (Ctrl+Shift+Z)", icon: Redo2, run: () => runEditorCommand(viewRef.current, (view) => redo(view)) },
];
const shortcutSections = [
{
title: "قالب‌بندی متن",
items: [
["Ctrl/Cmd + B", "درشت کردن متن"],
["Ctrl/Cmd + I", "کج کردن متن"],
["Ctrl/Cmd + Shift + X", "خط‌خورده"],
["Ctrl/Cmd + E", "کد کوتاه درون‌خطی"],
["Ctrl/Cmd + Alt + 1/2/3", "تیترهای سطح ۱، ۲ و ۳"],
],
},
{
title: "بلوک‌ها و ساختار",
items: [
["Ctrl/Cmd + Shift + 8", "فهرست نقطه‌ای"],
["Ctrl/Cmd + Shift + 7", "فهرست شماره‌ای"],
["Ctrl/Cmd + Alt + X", "فهرست کارها"],
["Ctrl/Cmd + Alt + C", "بلوک کد"],
["Ctrl/Cmd + Alt + T", "جدول"],
["Tab / Shift + Tab", "تورفتگی یا خروج از تورفتگی برای آیتم‌های فهرست"],
],
},
{
title: "درج محتوا",
items: [
["Ctrl/Cmd + K", "لینک"],
["Ctrl/Cmd + Alt + I", "تصویر"],
],
},
{
title: "عملیات و راهنما",
items: [
["Ctrl/Cmd + S", "ذخیره پیش‌نویس"],
["Ctrl/Cmd + F", "جست‌وجو در متن ویرایشگر"],
["Ctrl/Cmd + Z", "بازگردانی"],
["Ctrl/Cmd + Shift + Z", "انجام دوباره"],
["Ctrl/Cmd + /", "باز کردن همین راهنما"],
],
},
];
return (
<div className={cn("overflow-hidden rounded-2xl border bg-muted/30 shadow-inner", className)} dir="rtl">
<TooltipProvider delayDuration={150}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b bg-background/80 p-2">
<div className="flex flex-wrap items-center justify-start gap-1">
{actions.map((action) => {
const Icon = action.icon;
return (
<Tooltip key={action.key}>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" onClick={action.run}>
<Icon className="h-4 w-4" />
<span className="sr-only">{action.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{action.label}</TooltipContent>
</Tooltip>
);
})}
</div>
<div className="flex items-center gap-1 rounded-full border bg-background/80 p-1">
{(["auto", "rtl", "ltr"] as const).map((mode) => (
<Button
key={mode}
type="button"
variant={directionMode === mode ? "default" : "ghost"}
size="sm"
className="h-7 rounded-full px-3 text-xs"
onClick={() => setDirectionMode(mode)}
>
{mode === "auto" ? "Auto" : mode.toUpperCase()}
</Button>
))}
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 rounded-full" onClick={() => setGuideOpen(true)}>
<HelpCircle className="h-3.5 w-3.5" />
<span className="sr-only">راهنمای میانبرها</span>
</Button>
</TooltipTrigger>
<TooltipContent>راهنمای میانبرها</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>
<div ref={hostRef} className="text-left" />
<Dialog open={guideOpen} onOpenChange={setGuideOpen}>
<DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto rounded-3xl" dir="rtl">
<DialogHeader className="text-right md:text-right mt-6 mb-2">
<DialogTitle>راهنمای میانبرهای ویرایشگر مارکداون</DialogTitle>
<DialogDescription>
در ویندوز و لینوکس از Ctrl و در مک از Cmd استفاده کنید. میانبرها فقط وقتی ویرایشگر فعال است اجرا میشوند.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
{shortcutSections.map((section) => (
<section key={section.title} className="rounded-2xl border bg-muted/20 p-4">
<h3 className="mb-3 text-right font-bold">{section.title}</h3>
<div className="space-y-2">
{section.items.map(([shortcut, description]) => (
<div key={`${section.title}-${shortcut}`} className="flex items-center justify-between gap-3 rounded-xl bg-background/80 px-3 py-2 text-sm">
<span className="text-right text-muted-foreground">{description}</span>
<kbd className="shrink-0 rounded-lg border bg-muted px-2 py-1 font-mono text-[11px] leading-none text-foreground">
{shortcut}
</kbd>
</div>
))}
</div>
</section>
))}
</div>
</DialogContent>
</Dialog>
<Dialog open={Boolean(insertDialog)} onOpenChange={(open) => (open ? undefined : closeInsertDialog())}>
<DialogContent className="max-w-lg rounded-3xl" dir="rtl">
<DialogHeader className="mt-6 text-right md:text-right">
<DialogTitle>{insertDialog?.type === "image" ? "درج تصویر" : "درج لینک"}</DialogTitle>
<DialogDescription>
{insertDialog?.type === "image"
? "آدرس تصویر و متن جایگزین را وارد کنید. برای فایل‌های آپلودشده می‌توانید لینک را از مرکز آپلود کپی کنید."
: "آدرس مقصد و متن لینک را وارد کنید. اگر متن انتخاب کرده باشید، به عنوان متن لینک استفاده می‌شود."}
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
submitInsertDialog();
}}
>
<div className="space-y-2">
<Label htmlFor="markdown-insert-url" className="block text-right">
آدرس
</Label>
<Input
id="markdown-insert-url"
value={insertUrl}
onChange={(event) => setInsertUrl(event.target.value)}
placeholder={insertDialog?.type === "image" ? "https://example.com/image.png" : "https://example.com"}
dir="ltr"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="markdown-insert-text" className="block text-right">
{insertDialog?.type === "image" ? "متن جایگزین" : "متن لینک"}
</Label>
<Input
id="markdown-insert-text"
value={insertText}
onChange={(event) => setInsertText(event.target.value)}
placeholder={insertDialog?.type === "image" ? "توضیح کوتاه تصویر" : "متن قابل کلیک"}
className="text-right"
/>
</div>
<div className="flex flex-wrap justify-start gap-2 pt-2">
<Button type="submit" disabled={!insertUrl.trim()}>
{insertDialog?.type === "image" ? "درج تصویر" : "درج لینک"}
</Button>
<Button type="button" variant="outline" onClick={closeInsertDialog}>
انصراف
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowLeft, ArrowRight, FolderUp, ImageUp, Loader2, Save, Send, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import Markdown from "@/components/Markdown";
import MarkdownEditor, { type MarkdownDirectionMode } from "@/components/MarkdownEditor";
import { useAuth } from "@/contexts/AuthContext";
import { Link } from "@/lib/router";
import { Badge } from "@/components/ui/badge";
@@ -55,6 +56,7 @@ export default function AdminBlogEditor({ postId }: Props) {
const [loading, setLoading] = useState(Boolean(postId));
const [saving, setSaving] = useState(false);
const [uploadingFeatured, setUploadingFeatured] = useState(false);
const [editorDirection, setEditorDirection] = useState<MarkdownDirectionMode>("auto");
const isNew = postId == null;
const featuredImage = post?.absolute_featured_image_preview_url || post?.absolute_featured_image_url || post?.featured_image;
@@ -399,25 +401,28 @@ export default function AdminBlogEditor({ postId }: Props) {
<Card>
<CardHeader className="text-right">
<CardTitle>متن نوشته</CardTitle>
<CardDescription>ویرایشگر مارکداون در کنار پیشنمایش زنده، مشابه جریان نوشتن Quera.</CardDescription>
<CardDescription>ویرایشگر مارکداون در کنار پیشنمایش زنده</CardDescription>
</CardHeader>
<CardContent>
<div className="hidden grid-cols-2 gap-0 overflow-hidden rounded-3xl border bg-muted/20 md:grid">
<div className="border-l bg-background">
<div className="border-b px-4 py-3 text-right text-sm font-medium">پیشنمایش</div>
<div className="hidden grid-cols-2 gap-0 overflow-hidden bg-muted/20 md:grid">
<div className="bg-background ">
{/* <div className="border-b px-4 py-3 text-right text-sm font-medium">متن مارک‌داون</div> */}
<MarkdownEditor
value={form.content}
onChange={(value) => updateForm("content", value)}
minHeight="620px"
directionMode={editorDirection}
onDirectionModeChange={setEditorDirection}
onSave={savePost}
className="rounded-none border-0"
/>
</div>
<div className="bg-background">
{/* <div className="border-b px-4 py-3 text-right text-sm font-medium">پیش‌نمایش</div> */}
<div className="min-h-[560px] p-5">
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify={false} size="base" />
</div>
</div>
<div className="bg-background">
<div className="border-b px-4 py-3 text-right text-sm font-medium">متن مارکداون</div>
<Textarea
value={form.content}
onChange={(event) => updateForm("content", event.target.value)}
className="min-h-[620px] resize-y rounded-none border-0 font-mono text-left shadow-none focus-visible:ring-0"
dir="ltr"
/>
</div>
</div>
<Tabs defaultValue="editor" className="md:hidden" dir="rtl">
@@ -426,11 +431,13 @@ export default function AdminBlogEditor({ postId }: Props) {
<TabsTrigger value="preview">پیشنمایش</TabsTrigger>
</TabsList>
<TabsContent value="editor">
<Textarea
<MarkdownEditor
value={form.content}
onChange={(event) => updateForm("content", event.target.value)}
className="min-h-[520px] font-mono text-left"
dir="ltr"
onChange={(value) => updateForm("content", value)}
minHeight="520px"
directionMode={editorDirection}
onDirectionModeChange={setEditorDirection}
onSave={savePost}
/>
</TabsContent>
<TabsContent value="preview">
@@ -445,7 +452,7 @@ export default function AdminBlogEditor({ postId }: Props) {
<Card>
<CardHeader className="text-right">
<CardTitle>تنظیمات سئو</CardTitle>
<CardDescription>این بخش جدا از متن اصلی است تا جریان نوشتن ساده و متمرکز بماند.</CardDescription>
{/* <CardDescription>این بخش جدا از متن اصلی است تا جریان نوشتن ساده و متمرکز بماند.</CardDescription> */}
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">