From 25cbf531792c0ca8e22ebe8b830d7a5204a7ddce Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 12 Jun 2026 23:43:55 +0330 Subject: [PATCH] feat(blog): add codemirror markdown editor --- package-lock.json | 239 +++++++++++ package.json | 7 + src/components/MarkdownEditor.tsx | 683 ++++++++++++++++++++++++++++++ src/views/AdminBlogEditor.tsx | 43 +- 4 files changed, 954 insertions(+), 18 deletions(-) create mode 100644 src/components/MarkdownEditor.tsx diff --git a/package-lock.json b/package-lock.json index d1020d2..9df4029 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ad737f8..f659067 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/MarkdownEditor.tsx b/src/components/MarkdownEditor.tsx new file mode 100644 index 0000000..09e3ef5 --- /dev/null +++ b/src/components/MarkdownEditor.tsx @@ -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; + 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(); + 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(null); + const viewRef = useRef(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(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 ( +
+ +
+
+ {actions.map((action) => { + const Icon = action.icon; + return ( + + + + + {action.label} + + ); + })} +
+ +
+ {(["auto", "rtl", "ltr"] as const).map((mode) => ( + + ))} + + + + + راهنمای میانبرها + +
+
+
+
+ + + + راهنمای میانبرهای ویرایشگر مارک‌داون + + در ویندوز و لینوکس از Ctrl و در مک از Cmd استفاده کنید. میانبرها فقط وقتی ویرایشگر فعال است اجرا می‌شوند. + + +
+ {shortcutSections.map((section) => ( +
+

{section.title}

+
+ {section.items.map(([shortcut, description]) => ( +
+ {description} + + {shortcut} + +
+ ))} +
+
+ ))} +
+
+
+ (open ? undefined : closeInsertDialog())}> + + + {insertDialog?.type === "image" ? "درج تصویر" : "درج لینک"} + + {insertDialog?.type === "image" + ? "آدرس تصویر و متن جایگزین را وارد کنید. برای فایل‌های آپلودشده می‌توانید لینک را از مرکز آپلود کپی کنید." + : "آدرس مقصد و متن لینک را وارد کنید. اگر متن انتخاب کرده باشید، به عنوان متن لینک استفاده می‌شود."} + + +
{ + event.preventDefault(); + submitInsertDialog(); + }} + > +
+ + setInsertUrl(event.target.value)} + placeholder={insertDialog?.type === "image" ? "https://example.com/image.png" : "https://example.com"} + dir="ltr" + autoFocus + /> +
+
+ + setInsertText(event.target.value)} + placeholder={insertDialog?.type === "image" ? "توضیح کوتاه تصویر" : "متن قابل کلیک"} + className="text-right" + /> +
+
+ + +
+
+
+
+
+ ); +} diff --git a/src/views/AdminBlogEditor.tsx b/src/views/AdminBlogEditor.tsx index 1e8425c..2a11165 100644 --- a/src/views/AdminBlogEditor.tsx +++ b/src/views/AdminBlogEditor.tsx @@ -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("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) { متن نوشته - ویرایشگر مارک‌داون در کنار پیش‌نمایش زنده، مشابه جریان نوشتن Quera. + ویرایشگر مارک‌داون در کنار پیش‌نمایش زنده -
-
-
پیش‌نمایش
+
+
+ {/*
متن مارک‌داون
*/} + updateForm("content", value)} + minHeight="620px" + directionMode={editorDirection} + onDirectionModeChange={setEditorDirection} + onSave={savePost} + className="rounded-none border-0" + /> +
+
+ {/*
پیش‌نمایش
*/}
-
-
متن مارک‌داون
-