feat(blog): add codemirror markdown editor
This commit is contained in:
239
package-lock.json
generated
239
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
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