feat(blog): add codemirror markdown editor
This commit is contained in:
683
src/components/MarkdownEditor.tsx
Normal file
683
src/components/MarkdownEditor.tsx
Normal 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: `` },
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user