重构编辑器,进度50%

This commit is contained in:
Liu 宇阳
2024-11-02 21:04:13 +08:00
parent 8e49ff2ede
commit f24dcbecd8
8 changed files with 1604 additions and 349 deletions

1335
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,8 @@
"url": "https://blog.liuyuyang.net" "url": "https://blog.liuyuyang.net"
}, },
"dependencies": { "dependencies": {
"@bytemd/plugin-highlight": "^1.21.0",
"@bytemd/react": "^1.21.0",
"antd": "^5.19.3", "antd": "^5.19.3",
"apexcharts": "^3.41.0", "apexcharts": "^3.41.0",
"axios": "^1.7.2", "axios": "^1.7.2",
@@ -32,7 +34,6 @@
"react-toastify": "^9.1.3", "react-toastify": "^9.1.3",
"sass": "^1.77.8", "sass": "^1.77.8",
"sort-by": "^0.0.2", "sort-by": "^0.0.2",
"vditor": "^3.10.4",
"vite-plugin-sass-dts": "^1.3.25", "vite-plugin-sass-dts": "^1.3.25",
"zustand": "^4.5.4" "zustand": "^4.5.4"
}, },

View File

@@ -1,13 +1,25 @@
"use client" "use client"
import { Card } from "antd" import { Card } from "antd"
import Breadcrumb from "../Breadcrumbs"
import { titleSty } from '@/styles/sty' import { titleSty } from '@/styles/sty'
import { ReactNode } from "react"
export default ({ value, className }: { value: string, className?: string }) => { interface Props {
value: string,
children?: ReactNode,
className?: string
}
export default ({ value, children, className }: Props) => {
return ( return (
<> <>
<Card title={<Breadcrumb pageName={value} />} className={`${titleSty} ${className}`} /> <Card className={`${titleSty} p-4 mb-2 ${className}`}>
<div className="flex justify-between items-center">
<h2 className="font-semibold text-black dark:text-white text-xl">{value}</h2>
{children}
</div>
</Card>
</> </>
) )
} }

View File

@@ -0,0 +1,207 @@
.markdown-body {
color: #595959;
font-size: 15px;
font-family: -apple-system, system-ui, BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
background-image: linear-gradient(90deg, rgba(60, 10, 30, 0.04) 3%, rgba(0, 0, 0, 0) 3%), linear-gradient(360deg, rgba(60, 10, 30, 0.04) 3%, rgba(0, 0, 0, 0) 3%);
background-size: 20px 20px;
background-position: center center;
}
/* 段落 */
.markdown-body p {
color: #595959;
font-size: 15px;
line-height: 2;
font-weight: 400;
}
/* 段落间距控制 */
.markdown-body p+p {
margin-top: 16px;
}
/* 标题的通用设置 */
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
padding: 30px 0;
margin: 0;
color: #135ce0;
}
/* 一级标题 */
.markdown-body h1 {
position: relative;
text-align: center;
font-size: 22px;
margin: 50px 0;
}
/* 一级标题前缀,用来放背景图,支持透明度控制 */
.markdown-body h1:before {
position: absolute;
content: "";
top: -10px;
left: 50%;
width: 32px;
height: 32px;
transform: translateX(-50%);
background-size: 100% 100%;
opacity: .36;
background-repeat: no-repeat;
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAABfVBMVEX///8Ad/8AgP8AgP8AgP8Aff8AgP8Af/8AgP8AVf8Af/8Af/8AgP8AgP8Af/8Afv8AAP8Afv8Afv8Aef8AgP8AdP8Afv8AgP8AgP8Acf8Ae/8AgP8Af/8AgP8Af/8Af/8AfP8Afv8AgP8Af/8Af/8Afv8Afv8AgP8Afv8AgP8Af/8Af/8AgP8AgP8Afv8AgP8Af/8AgP8AgP8AgP8Ae/8Afv8Af/8AgP8Af/8AgP8Af/8Af/8Aff8Af/8Abf8AgP8Af/8AgP8Af/8Af/8Afv8AgP8AgP8Afv8Afv8AgP8Af/8Aff8AgP8Afv8AgP8Aff8AgP8AfP8AgP8Ae/8AgP8Af/8AgP8AgP8AgP8Afv8AgP8AgP8AgP8Afv8AgP8AgP8AgP8AgP8AgP8Af/8AgP8Af/8Af/8Aev8Af/8AgP8Aff8Afv8AgP8AgP8AgP8Af/8AgP8Af/8Af/8AgP8Afv8AgP8AgP8AgP8AgP8Af/8AeP8Af/8Af/8Af//////rzEHnAAAAfXRSTlMAD7CCAivatxIDx5EMrP19AXdLEwgLR+6iCR/M0yLRzyFF7JupSXn8cw6v60Q0QeqzKtgeG237HMne850/6Qeq7QaZ+WdydHtj+OM3qENCMRYl1B3K2U7wnlWE/mhlirjkODa9FN/BF7/iNV/2kASNZpX1Wlf03C4stRGxgUPclqoAAAABYktHRACIBR1IAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEaBzgZ4yeM3AAAAT9JREFUOMvNUldbwkAQvCAqsSBoABE7asSOBRUVVBQNNuy9996789+9cMFAMHnVebmdm+/bmdtbQv4dOFOW2UjPzgFyLfo6nweKfIMOBYWwFtmMPGz2Yj2pJI0JDq3udJW6VVbmKa9I192VQFV1ktXUAl5NB0cd4KpnORqsEO2ZIRpF9gJfE9Dckqq0KuZt7UAH5+8EPF3spjsRpCeQNO/tA/qDwIDA+OCQbBoKA8NOdjMySgcZGVM6jwcgRuUiSs0nlPFNSrEpJfU0jTLD6llqbvKxei7OzvkFNQohi0vAsj81+MoqsCaoPOQFgus/1LyxichW+hS2JWCHZ7VlF9jb187pIAYcHiViHAMnp5mTjJ8B5xeEXF4B1ze/fTh/C0h398DDI9HB07O8ci+vRBdvdGnfP4gBuM8vw7X/G3wDmFhFZEdxzjMAAAAldEVYdGRhdGU6Y3JlYXRlADIwMTgtMDEtMjZUMDc6NTY6MjUrMDE6MDA67pVWAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE4LTAxLTI2VDA3OjU2OjI1KzAxOjAwS7Mt6gAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAWdEVYdFRpdGxlAGp1ZWppbl9sb2dvIGNvcHlxapmKAAAAV3pUWHRSYXcgcHJvZmlsZSB0eXBlIGlwdGMAAHic4/IMCHFWKCjKT8vMSeVSAAMjCy5jCxMjE0uTFAMTIESANMNkAyOzVCDL2NTIxMzEHMQHy4BIoEouAOoXEXTyQjWVAAAAAElFTkSuQmCC);
}
/* 二级标题 */
.markdown-body h2 {
position: relative;
font-size: 20px;
border-left: 4px solid;
padding: 0 0 0 10px;
margin: 30px 0;
}
/* 三级标题 */
.markdown-body h3 {
font-size: 16px;
}
/* 无序列表 */
.markdown-body ul {
list-style: disc outside;
margin-left: 2em;
margin-top: 1em;
}
/* 无序列表内容 */
.markdown-body li {
line-height: 2;
color: #595959;
margin-bottom: 0;
list-style: inherit;
}
.markdown-body img {
max-width: 100%;
}
/* 已加载图片 */
.markdown-body img.loaded {
margin: 0 auto;
display: block;
}
/* 引用 */
.markdown-body blockquote {
background: #fff9f9;
margin: 2em 0;
padding: 2px 20px;
border-left: 4px solid #b2aec5;
}
/* 引用文字 */
.markdown-body blockquote p {
color: #666;
line-height: 2;
}
/* 链接 */
.markdown-body a {
color: #036aca;
border-bottom: 1px solid rgba(3, 106, 202, .8);
font-weight: 400;
text-decoration: none;
}
/* 加粗 */
.markdown-body strong {
color: #036aca;
}
/* 加粗斜体 */
.markdown-body em strong {
color: #036aca;
}
/* 分隔线 */
.markdown-body hr {
border-top: 1px solid #135ce0;
}
/* 代码 */
.markdown-body pre {
overflow: auto;
}
.markdown-body pre,
.markdown-body code {
overflow: auto;
position: relative;
line-height: 1.75;
font-family: Menlo, Monaco, Consolas, Courier New, monospace;
}
.markdown-body pre>code {
font-size: 12px;
padding: 15px 12px;
margin: 0;
word-break: normal;
display: block;
overflow-x: auto;
color: #DCDCDC;
background: #1E1E1E;
border-radius: 5px;
}
.markdown-body code {
word-break: break-word;
border-radius: 2px;
overflow-x: auto;
background-color: #fff5f5;
color: #ff502c;
font-size: .87em;
padding: .065em .4em;
}
/* 表格 */
.markdown-body table {
border-collapse: collapse;
margin: 1rem 0;
overflow-x: auto;
}
.markdown-body table th,
.markdown-body table td {
border: 1px solid #dfe2e5;
padding: .6em 1em;
}
.markdown-body table tr {
border-top: 1px solid #dfe2e5;
}
.markdown-body table tr:nth-child(2n) {
background-color: #f6f8fa;
}
/*
From: icewolf-sec (御坂19008号)
Description: 新增HTML5 键盘文字标签样式
*/
.markdown-body kbd {
color: white;
background-color: #135ce0;
border: 1px solid #135ce0;
border-radius: 5px;
padding: 2px;
font-weight: bold;
margin: auto 5px auto;
font-size: small;
}
.bytemd {
height: 600px;
}

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react'
import { Editor, Viewer } from '@bytemd/react'
import 'bytemd/dist/index.css'
import highlight from '@bytemd/plugin-highlight'
import 'highlight.js/styles/vs2015.css';
import "./index.scss"
const plugins = [
highlight()
]
// import FileUpload from "@/components/FileUpload";
interface Props {
value?: string,
getValue: (value: string) => void
}
const EditorMD = ({ value, getValue }: Props) => {
const [content, setContent] = useState('')
return (
<>
<Editor
value={content}
plugins={plugins}
onChange={(v) => {
setContent(v)
}}
/>
{/* 文件上传 */}
{/* <FileUpload
dir="article"
open={openUploadModalOpen}
onSuccess={(urls: string[]) => {
urls.forEach((path: string) => {
vd?.insertValue(`![${path}](${path})`);
});
}}
onCancel={() => setOpenUploadModalOpen(false)}
/> */}
</>
);
};
export default EditorMD;

View File

@@ -1,33 +0,0 @@
#vditor {
border: none;
.vditor-toolbar {
padding-left: 0 !important;
background-color: transparent;
border-bottom: none;
.vditor-tooltipped__ai {
padding: 0;
svg {
width: 25px;
height: 25px;
margin-left: 20px;
}
}
}
.vditor-reset {
background-color: transparent !important;
.vditor-ir__marker--heading {
color: #727cf5;
}
}
// 针对表单元素的光标样式
pre {
// 设置光标样式
caret-color: #727cf5; // 光标颜色
}
}

View File

@@ -1,278 +0,0 @@
import { useEffect, useState } from "react";
import Vditor from "vditor";
import "vditor/dist/index.css";
import "./index.scss"
import FileUpload from "@/components/FileUpload";
const toolbar = [
{
hotkey: "⌘H",
icon:
'<svg><use xlink:href="#vditor-icon-headings"></use></svg>',
name: "headings",
tipPosition: "ne",
},
{
hotkey: "⌘B",
icon: '<svg><use xlink:href="#vditor-icon-bold"></use></svg>',
name: "bold",
prefix: "**",
suffix: "**",
tipPosition: "ne",
},
{
hotkey: "⌘I",
icon: '<svg><use xlink:href="#vditor-icon-italic"></use></svg>',
name: "italic",
prefix: "*",
suffix: "*",
tipPosition: "ne",
},
{
hotkey: "⌘D",
icon: '<svg><use xlink:href="#vditor-icon-strike"></use></svg>',
name: "strike",
prefix: "~~",
suffix: "~~",
tipPosition: "ne",
},
{
hotkey: "⌘K",
icon: '<svg><use xlink:href="#vditor-icon-link"></use></svg>',
name: "link",
prefix: "[",
suffix: "](https://)",
tipPosition: "n",
},
{
name: "|",
},
{
hotkey: "⌘L",
icon: '<svg><use xlink:href="#vditor-icon-list"></use></svg>',
name: "list",
prefix: "* ",
tipPosition: "n",
},
{
hotkey: "⌘O",
icon:
'<svg><use xlink:href="#vditor-icon-ordered-list"></use></svg>',
name: "ordered-list",
prefix: "1. ",
tipPosition: "n",
},
{
hotkey: "⌘J",
icon: '<svg><use xlink:href="#vditor-icon-check"></use></svg>',
name: "check",
prefix: "* [ ] ",
tipPosition: "n",
},
{
hotkey: "⇧⌘I",
icon:
'<svg><use xlink:href="#vditor-icon-outdent"></use></svg>',
name: "outdent",
tipPosition: "n",
},
{
hotkey: "⇧⌘O",
icon: '<svg><use xlink:href="#vditor-icon-indent"></use></svg>',
name: "indent",
tipPosition: "n",
},
{
name: "|",
},
{
hotkey: "⌘;",
icon: '<svg><use xlink:href="#vditor-icon-quote"></use></svg>',
name: "quote",
prefix: "> ",
tipPosition: "n",
},
{
hotkey: "⇧⌘H",
icon: '<svg><use xlink:href="#vditor-icon-line"></use></svg>',
name: "line",
prefix: "---",
tipPosition: "n",
},
{
hotkey: "⌘U",
icon: '<svg><use xlink:href="#vditor-icon-code"></use></svg>',
name: "code",
prefix: "```",
suffix: "\n```",
tipPosition: "n",
},
{
hotkey: "⌘G",
icon:
'<svg><use xlink:href="#vditor-icon-inline-code"></use></svg>',
name: "inline-code",
prefix: "`",
suffix: "`",
tipPosition: "n",
},
{
name: "|",
},
{
icon: '<svg><use xlink:href="#vditor-icon-upload"></use></svg>',
name: "upload",
tipPosition: "n",
},
{
hotkey: "⌘M",
icon: '<svg><use xlink:href="#vditor-icon-table"></use></svg>',
name: "table",
prefix: "| col1",
suffix:
" | col2 | col3 |\n| --- | --- | --- |\n| | | |\n| | | |",
tipPosition: "n",
},
{
name: "|",
},
{
hotkey: "⌘Z",
icon: '<svg><use xlink:href="#vditor-icon-undo"></use></svg>',
name: "undo",
tipPosition: "nw",
},
{
hotkey: "⌘Y",
icon: '<svg><use xlink:href="#vditor-icon-redo"></use></svg>',
name: "redo",
tipPosition: "nw",
},
{
name: "|",
},
{
icon:
'<svg><use xlink:href="#vditor-icon-align-center"></use></svg>',
name: "outline",
tipPosition: "nw",
},
{
icon: '<svg><use xlink:href="#vditor-icon-theme"></use></svg>',
name: "content-theme",
tipPosition: "nw",
},
{
icon: '<svg><use xlink:href="#vditor-icon-code-theme"></use></svg>',
name: "code-theme",
tipPosition: "nw",
},
{
name: "br",
}
]
interface VditorProps {
value?: string,
getValue: (value: string) => void
}
const VditorEditor = ({ value, getValue }: VditorProps) => {
const [openUploadModalOpen, setOpenUploadModalOpen] = useState(false);
const [vd, setVd] = useState<Vditor>();
useEffect(() => {
const vditor = new Vditor("vditor", {
minHeight: 550,
// 禁止缓存数据
cache: {
enable: false
},
preview: {
// 限制防抖时间
delay: 500
},
toolbar,
// upload: {
// handler: async (files) => {
// console.log(files, 333);
// const formData = new FormData();
// files.forEach(file => {
// formData.append('files', file);
// });
// // 添加额外参数
// formData.append('dir', 'article');
// const res = await fetch(`${baseURL}/file`, {
// method: "POST",
// body: formData,
// headers: {
// "Authorization": `Bearer ${store.token}`
// }
// });
// const { code, message, data } = await res.json();
// if (code !== 200) return message.error("文件上传失败:" + message);
// // 插入到编辑器中
// data.forEach((path: string) => {
// vditor.insertValue(`![${path}](${path})`);
// });
// },
// },
input: (value) => {
// 把数据传给父组件
getValue(value)
},
after: () => {
// 获取文件上传按钮
const uploadButton = document.querySelector('.vditor-toolbar [data-type="upload"]')!;
// 添加点击事件监听器
uploadButton.addEventListener('click', (e) => {
e.preventDefault()
console.log('文件上传图标被点击');
// 在这里添加你的自定义逻辑
setOpenUploadModalOpen(true)
});
setVd(vditor);
}
})
return () => {
vd?.destroy();
setVd(undefined);
};
}, []);
// 监听 value 变化并更新编辑器内容
useEffect(() => {
if (vd && value !== undefined && value !== vd.getValue()) {
vd.setValue(value);
}
}, [value, vd]);
return (
<>
<div id="vditor" className="vditor" />
{/* 文件上传 */}
<FileUpload
dir="article"
open={openUploadModalOpen}
onSuccess={(urls: string[]) => {
urls.forEach((path: string) => {
vd?.insertValue(`![${path}](${path})`);
});
}}
onCancel={() => setOpenUploadModalOpen(false)}
/>
</>
);
};
export default VditorEditor;

View File

@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom';
import { Button, Card, Drawer, Dropdown, MenuProps, message } from 'antd'; import { Button, Card, Drawer, Dropdown, MenuProps, message } from 'antd';
import Title from '@/components/Title'; import Title from '@/components/Title';
import VditorEditor from './components/VditorMD'; import Editor from './components/Editor';
import PublishForm from './components/PublishForm'; import PublishForm from './components/PublishForm';
import { Article } from '@/types/app/article'; import { Article } from '@/types/app/article';
@@ -151,11 +151,8 @@ const CreatePage = () => {
return ( return (
<> <>
<Title value="创作" /> <Title value="创作">
<div className='flex space-x-4'>
<Card className='relative mt-2'>
<div className='flex justify-end w-full'>
<div className='relative z-50 flex w-[24%] space-x-4'>
<Dropdown.Button menu={{ items }}></Dropdown.Button> <Dropdown.Button menu={{ items }}></Dropdown.Button>
<Button className='w-full flex justify-between' onClick={saveBtn} > <Button className='w-full flex justify-between' onClick={saveBtn} >
@@ -166,11 +163,10 @@ const CreatePage = () => {
<GrFormNext className='text-2xl' /> <GrFormNext className='text-2xl' />
</Button> </Button>
</div> </div>
</div> </Title>
<div className='relative -top-[40px]'> <Card className='[&>.ant-card-body]:!p-0 overflow-hidden rounded-xl'>
<VditorEditor value={content} getValue={getVditorData} /> <Editor value={content} getValue={getVditorData} />
</div>
<Drawer <Drawer
title={id ? "编辑文章" : "发布文章"} title={id ? "编辑文章" : "发布文章"}