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

239
package-lock.json generated
View File

@@ -8,7 +8,14 @@
"name": "guilan-ace-frontend",
"version": "0.0.0",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"@hookform/resolvers": "^3.10.0",
"@lezer/highlight": "^1.2.3",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
@@ -106,6 +113,147 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/search/-/search-6.7.0.tgz",
"integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/view/-/view-6.43.1.tgz",
"integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/@emnapi/runtime/-/runtime-1.10.0.tgz",
@@ -914,6 +1062,79 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/css/-/css-1.3.3.tgz",
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.6.4",
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/markdown/-/markdown-1.6.4.tgz",
"integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@next/env": {
"version": "15.5.18",
"resolved": "https://package-mirror.liara.ir/repository/npm/@next/env/-/env-15.5.18.tgz",
@@ -3494,6 +3715,12 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://package-mirror.liara.ir/repository/npm/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7135,6 +7362,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/style-to-js": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.18.tgz",
@@ -7696,6 +7929,12 @@
"d3-timer": "^3.0.1"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://package-mirror.liara.ir/repository/npm/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",

View File

@@ -10,7 +10,14 @@
"start": "next start --port 3000"
},
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"@hookform/resolvers": "^3.10.0",
"@lezer/highlight": "^1.2.3",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",

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">