markdown(md-editor-v3)简单使用
# md-editor-v3
vue3 项目下的 Markdown 编辑器,,使用 jsx 语法开发,支持在 tsx 项目使用。采用了替换 class 名称的方式实现暗黑主题切换。
文档与在线预览:传送门 (opens new window)
在线尝试示例:传送门 (opens new window)
同系列react
版本:md-editor-rt (opens new window)
# 功能一览
- 快捷插入内容工具栏、编辑器浏览器全屏、页面内全屏等;
- 内置的白色主题和暗黑主题,支持绑定切换;
- 支持快捷键插入内容;
- 支持使用 prettier 格式化内容(使用 CDN 方式引入,只支持格式化 md 内容,可在代码内设置关闭);
- 支持多语言,支持自行扩展语言;
- 支持复制粘贴上传图片,图片裁剪上传;
- 支持仅预览模式(不显示编辑器,只显示 md 预览内容,无额外监听)
- ...
更多功能待后续更新,若有想要的功能未开发,请留言~
# 预览图
默认模式下:
暗黑模式下:
# apis
# props
名称 | 类型 | 默认值 | 响应式 | 说明 |
---|---|---|---|---|
modelValue | String | '' | √ | md 编辑内容,vue 模板支持双向绑定(v-model="value") |
theme | 'light' | 'dark' | 'light' | √ | 主题切换 |
editorClass | String | '' | √ | 编辑器最外层样式 |
hljs | Object | null | x | 项目中使用到了 highlight,可将实例直接传递,生产环境则不会请求 CDN,需要手动导入支持的高亮代码样式 |
highlightJs | String | highlight.js@11.2.0 (opens new window) | x | highlightJs CDN |
highlightCss | String | atom-one-dark@11.2.0 (opens new window) | x | 预览高亮代码样式 |
historyLength | Number | 10 | x | 最大记录操作数(太大会占用内存) |
pageFullScreen | Boolean | false | x | 浏览器内全屏 |
preview | Boolean | true | x | 预览模式 |
htmlPreview | Boolean | false | x | html 预览 |
previewOnlyv1.3.0 | Boolean | false | x | 仅预览模式,不显示 bar 和编辑框,不支持响应式,仅能初始设置一次 |
language | String | 'zh-CN' | √ | 内置中英文('zh-CN','en-US'),可自行扩展其他语言,同时可覆盖内置的中英文 |
languageUserDefined | Array | [{key: StaticTextDefaultValue}] | √ | 通过这里扩展语言,修改 language 值为扩展 key 即可,类型申明可手动导入 |
toolbars | Array | [all] | √ | 选择性展示工具栏,可选内容如下[toolbars] |
toolbarsExcludev1.1.4 | Array | [] | √ | 选择性不展示工具栏,内容同toolbars |
prettier | Boolean | true | x | 是否启用 prettier 优化 md 内容 |
prettierCDN | String | standalone@2.4.0 (opens new window) | x | |
prettierMDCDN | String | parser-markdown@2.4.0 (opens new window) | x | |
editorNamev1.3.2delete | String | 'editor' | x | 当在同一页面放置了多个编辑器,最好提供该属性以区别某些带有 ID 的内容,v1.3.2 后版本编辑器自动生成唯一 ID,不再需要手动设置 |
cropperCssv1.2.0 | String | cropper.min.css@1.5.12 (opens new window) | x | cropper css url |
cropperJsv1.2.0 | String | cropper.min.js@1.5.12 (opens new window) | x | cropper js url |
iconfontJsv1.3.2 | String | iconfont (opens new window) | x | 矢量图标链接,无外网时,下载 js 到内网,提供链接 |
editorIdv1.4.0 | String | random | x | 编辑器唯一标识,非必须项,用于后续支持ssr 时,防止产生服务端与客户端渲染内容不一致错误提示 |
tabWidthv1.4.0 | Number | 2 | x | 编辑器 TAB 键位等于空格数 |
showCodeRowNumberv1.4.3 (opens new window) | Boolean | false | x | 代码块显示行号 |
screenfullv1.4.3 (opens new window) | Object | null | x | 全屏插件实例,项目中有使用可以将其传入,这样编辑器不再会使用 cdn 引入 |
screenfullJsv1.4.3 (opens new window) | String | 5.1.0 (opens new window) | x | cdn 链接 |
previewThemev1.4.3 (opens new window) | 'default' | 'github' | 'vuepress' | 'default' | √ | 预览内容主题 |
响应式=x,该属性只支持设置,不支持响应式更新~
[toolbars]
[
'bold',
'underline',
'italic',
'strikeThrough',
'title',
'sub',
'sup',
'quote',
'unorderedList',
'orderedList',
'codeRow',
'code',
'link',
'image',
'table',
'revoke',
'next',
'save',
'pageFullscreen',
'fullscreen',
'preview',
'htmlPreview',
'github'
];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
自定义语言,需要替换的内容如下(某些字段若不主动提供,可能会造成页面不美观):
[StaticTextDefaultValue]
export interface StaticTextDefaultValue {
// 工具栏hover title提示
toolbarTips?: ToolbarTips;
// 标题下拉框内容
titleItem?: {
h1?: string;
h2?: string;
h3?: string;
h4?: string;
h5?: string;
h6?: string;
};
// 添加链接或图片时弹窗提示
linkModalTips?: {
title?: string;
descLable?: string;
descLablePlaceHolder?: string;
urlLable?: string;
UrlLablePlaceHolder?: string;
buttonOK?: string;
buttonUpload?: string;
};
// 裁剪图片弹窗提示,v1.2.0
clipModalTips?: {
title?: string;
buttonUpload?: string;
};
// 预览代码中复制代码提示,v1.1.4
copyCode?: {
text?: string;
tips?: string;
};
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 事件绑定
名称 | 入参 | 说明 |
---|---|---|
onChange | v:String | 内容变化事件(当前与textare 的oninput 事件绑定,每输入一个单字即会触发) |
onSave | v:String | 保存事件,快捷键与保存按钮均会触发 |
onUploadImg | files:Array | 上传图片事件,弹窗会等待上传结果,务必将上传后的 urls 作为 callback 入参回传 |
onHtmlChanged | h:String | html 变化回调事件,用于获取预览 html 代码 |
onGetCatalogv1.4.0 | list: HeadList[] | 动态获取markdown 目录 |
# 快捷键
主要以CTRL
搭配对应功能英文单词首字母,冲突项添加SHIFT
,再冲突替换为ALT
。
键位 | 功能 | 说明 | 版本标记 |
---|---|---|---|
TAB | 空格 | 通过tabWidth 属性预设 TAB 键位新增空格长度,默认 2,支持多行 | v1.4.0 |
SHIFT + TAB | 取消空格 | 同上,一次取消两个空格,支持多行 | v1.4.0 |
CTRL + C | 复制 | 选中时复制选中内容,未选中时复制当前行内容 | v1.4.0 |
CTRL + X | 剪切 | 选中时剪切选中内容,未选中时剪切当前行 | v1.4.0 |
CTRL + D | 删除 | 选中时删除选中内容,未选中时删除当前行 | v1.4.0 |
CTRL + S | 保存 | 触发编辑器的onSave 回调 | v1.0.0 |
CTRL + B | 加粗 | **加粗** | v1.0.0 |
CTRL + U | 下划线 | <u>下划线</u> | v1.0.0 |
CTRL + I | 斜体 | *斜体* | v1.0.0 |
CTRL + 1-6 | 1-6 级标题 | # 标题 | v1.0.0 |
CTRL + ↑ | 上角标 | <sup>上角标</sup> | v1.0.0 |
CTRL + ↓ | 下角标 | <sub>下角标</sub> | v1.0.0 |
CTRL + Q | 引用 | > 引用 | v1.0.0 |
CTRL + O | 有序列表 | 1. 有序列表 | v1.0.0 |
CTRL + L | 链接 | [链接](https://imzbf.cc) | v1.0.0 |
CTRL + Z | 撤回 | 触发编辑器内内容撤回,与系统无关 | v1.0.0 |
CTRL + SHIFT + S | 删除线 | ~删除线~ | v1.0.0 |
CTRL + SHIFT + U | 无序列表 | - 无序列表 | v1.0.0 |
CTRL + SHIFT + C | 块级代码 | 多行代码块 | v1.0.0 |
CTRL + SHIFT + I | 图片链接 | ![图片](https://imzbf.cc) | v1.0.0 |
CTRL + SHIFT + Z | 前进一步 | 触发编辑器内内容前进,与系统无关 | v1.0.0 |
CTRL + SHIFT + F | 美化内容 | v1.0.0 | |
CTRL + ALT + C | 行内代码 | 行内代码块 | v1.0.0 |
CTRL + SHIFT + ALT + T | 表格 | \|表格\| | v1.4.0 |
# 演示
# jsx 语法项目
import { defineComponent, reactive } from 'vue';
import Editor from 'md-editor-v3';
import 'md-editor-v3/lib/style.css';
import hljs from 'highlight.js';
import 'highlight.js/styles/atom-one-dark.css';
export default defineComponent({
setup() {
const md = reactive({
text: 'default markdown content'
});
return () => (
<Editor hljs={hljs} modelValue={md.text} onChange={(value) => (md.text = value)} />
);
}
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# vue 模板项目
<template>
<editor v-model="text" pageFullScreen></editor>
</template>
<script>
import { defineComponent } from 'vue';
import Editor from 'md-editor-v3';
import 'md-editor-v3/lib/style.css';
export default defineComponent({
name: 'VueTemplateDemo',
components: { Editor },
data() {
return {
text: '默认值'
};
}
});
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 上传图片
默认可以选择多张图片,支持粘贴板上传图片。
注意:粘贴板上传时,如果是网页上的 gif 图,无法正确上传为 gif 格式!
async onUploadImg(files: Array<File>, callback: (urls: string[]) => void) {
const res = await Promise.all(
files.map((file) => {
return new Promise((rev, rej) => {
const form = new FormData();
form.append('file', file);
axios
.post('/api/img/upload', form, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then((res) => rev(res))
.catch((error) => rej(error));
});
})
);
callback(res.map((item: any) => item.data.url));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 示例模板代码
<template>
<div class="notez-markdown" ref="editorDom" tabindex="1">
<MdEditorV3
@onUploadImg="handleOnUploadImg"
v-model="content"
:editor-id="editorId"
:preview-theme="previewTheme"
:toolbars="store.state.toolbars"
:previewOnly="previewOnly"
placeholder="请输入内容..."
:preview="true"
>
<template #defToolbars>
<MdEditorV3.DropdownToolbar
title="emoji"
:visible="data.emojiVisible"
:onChange="emojiVisibleChanged"
>
<template #overlay>
<div class="emoji-container">
<ol class="emojis-box">
<li
v-for="(item, i) in store.state.emojis"
:key="i"
@click="handlerEmoji(item)"
>
{{ item }}
</li>
</ol>
</div>
</template>
<template #trigger>
<svg class="md-icon biao-qing" viewBox="0 0 1024 1024">
<path
d="M512 1024C230.4 1024 0 793.6 0 512S230.4 0 512 0s512 230.4 512 512-230.4 512-512 512z m0-102.4c226.742857 0 409.6-182.857143 409.6-409.6S738.742857 102.4 512 102.4 102.4 285.257143 102.4 512s182.857143 409.6 409.6 409.6z m-204.8-358.4h409.6c0 113.371429-91.428571 204.8-204.8 204.8s-204.8-91.428571-204.8-204.8z m0-102.4c-43.885714 0-76.8-32.914286-76.8-76.8s32.914286-76.8 76.8-76.8 76.8 32.914286 76.8 76.8-32.914286 76.8-76.8 76.8z m409.6 0c-43.885714 0-76.8-32.914286-76.8-76.8s32.914286-76.8 76.8-76.8c43.885714 0 76.8 32.914286 76.8 76.8s-32.914286 76.8-76.8 76.8z"
></path>
</svg>
</template>
</MdEditorV3.DropdownToolbar>
<MdEditorV3.NormalToolbar title="标记" @on-click="handlerOnMark">
<template #trigger>
<svg
class="md-icon mark"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M256 128v698.88l196.032-156.864a96 96 0 0 1 119.936 0L768 826.816V128H256zm-32-64h576a32 32 0 0 1 32 32v797.44a32 32 0 0 1-51.968 24.96L531.968 720a32 32 0 0 0-39.936 0L243.968 918.4A32 32 0 0 1 192 893.44V96a32 32 0 0 1 32-32z"
></path>
</svg>
</template>
</MdEditorV3.NormalToolbar>
</template>
</MdEditorV3>
</div>
</template>
<script setup>
import {
// computed,
// onBeforeUnmount,
// onMounted,
ref,
nextTick,
watch,
// defineEmits,
reactive,
} from "vue";
import axios from "@/axios";
import { useStore } from "vuex";
import MdEditorV3 from "md-editor-v3";
import "md-editor-v3/lib/style.css";
import "@/assets/iconfont.js";
import screenfull from "screenfull";
import katex from "katex";
import "katex/dist/katex.min.css";
import Cropper from "cropperjs";
import "cropperjs/dist/cropper.css";
import mermaid from "mermaid";
import highlight from "highlight.js";
// import 'highlight.js/styles/atom-one-dark-reasonable.css';
import prettier from "prettier";
import parserMarkdown from "prettier/parser-markdown";
MdEditorV3.config({
editorExtensions: {
prettier: {
prettierInstance: prettier,
parserMarkdownInstance: parserMarkdown,
},
highlight: {
instance: highlight,
},
screenfull: {
instance: screenfull,
},
katex: {
instance: katex,
},
cropper: {
instance: Cropper,
},
mermaid: {
instance: mermaid,
},
},
});
//动态添加css样式文件
function setAddCodeStyle(cssName) {
let dom = document.querySelector("#code-style");
if (dom) {
dom.remove();
}
let link = document.createElement("link");
link.setAttribute("href", `/code/styles/${cssName}.css`);
link.setAttribute("rel", "stylesheet");
link.setAttribute("id", "code-style");
document.querySelector("head").appendChild(link);
}
// import { getItem } from "@/utils/tools";
// import cloneDeep from "lodash.clonedeep";
// import { Loading, CircleCheck } from "@element-plus/icons-vue";
// vuex
const store = useStore();
// const fileData = JSON.parse(JSON.stringify(store.state.fileData));
const editorId = ref("md-editor-id");
const previewTheme = ref("smart-blue"); //default、github、vuepress、mk-cute、smart-blue、cyanosis
// const codeTheme = ref("atom"); //atom、a11y、github、gradient、kimbie、paraiso、qtcreator、stackoverflow
setAddCodeStyle("atom-one-dark"); // a11y-dark atom-one-dark monokai monokai-sublime vs vs2015 xcode paraiso-dark gradient-dark a11y-dark paraiso-dark
const data = reactive({
emojiVisible: false,
});
const props = defineProps({
content: {
type: String,
},
save: {
type: Function,
},
previewOnly: {
default: false,
},
});
const content = ref(props.content);
const handlerEmoji = (emoji) => {
// 获取输入框
const textarea = document.querySelector(`#${editorId.value}-textarea`);
// 获取鼠标位置
const endPoint = textarea.selectionStart;
const prefixStr = textarea.value.substring(0, endPoint);
const suffixStr = textarea.value.substring(endPoint, textarea.value.length);
content.value = `${prefixStr}${emoji}${suffixStr}`;
nextTick(() => {
textarea.setSelectionRange(1 + endPoint, 1 + endPoint);
textarea.focus();
});
};
const handlerOnMark = () => {
// 获取输入框
const textarea = document.querySelector(`#${editorId.value}-textarea`);
// 获取选中的内容
const selection = window.getSelection()?.toString();
// 获取鼠标位置
const endPoint = textarea.selectionStart;
// 生成标记文本
const markStr = `@${selection}@`;
// 根据鼠标位置分割旧文本
// 前半部分
const prefixStr = textarea.value.substring(0, endPoint);
// 后半部分
const suffixStr = textarea.value.substring(
endPoint + (selection?.length || 0)
);
// console.log( );
// emit("onChange", `${prefixStr}${markStr}${suffixStr}`);
content.value = `${prefixStr}${markStr}${suffixStr}`;
nextTick(() => {
textarea.setSelectionRange(endPoint, markStr.length + endPoint);
textarea.focus();
});
};
const emojiVisibleChanged = (visible) => {
data.emojiVisible = visible;
};
const handleOnUploadImg = async (files, callback) => {
// const user = getItem("user") || {};
const res = await Promise.all(
Array.from(files).map((file) => {
return new Promise((rev, rej) => {
const form = new FormData();
form.append("file", file);
axios
.post("/notez/images/post", form)
.then((res) => rev(res))
.catch((error) => rej(error));
});
})
);
callback(res.map((item) => item.data.data.url));
};
// const emit = defineEmits(["on-share-click"]);
// const loading = ref(true);
// const fileData = computed(() => store.state.fileData);
//防抖
let timer = null;
const anti_shake = () => {
return function () {
clearInterval(timer);
timer = setTimeout(() => {
handleSave(false);
}, 800);
};
};
const ctrl = anti_shake();
let isUpdate = false;
setTimeout(() => {
isUpdate = true;
}, 3000);
watch(content, (val) => {
// if (isUpdate && store.state.isAutoSave) ctrl(val);
});
const handleSave = (mark) => {
if (content.value || props.save) props.save(mark);
};
// 编辑器事件监听
const editorDom = ref(null);
nextTick(() => {
editorDom.value.onkeydown = (e) => {
e = e || window.event;
if (e.ctrlKey && e.keyCode == 83) {
// ctr+S
handleSave(true);
return false;
}
};
});
// 分享
// const handleShare = () => {
// emit("on-share-click", store.state.fileData);
// };
// // 加载数据
// const setContentData = (val) => {
// nextTick(() => {
// content.value = val;
// });
// };
const getContent = () => {
return content.value;
};
defineExpose({ getContent });
// // 组件销毁时,也及时销毁编辑器
// onBeforeUnmount(() => {
// if (content.value || props.save)
// console.log(fileData.name);
// props.save({ data: content.value, file: fileData });
// // const editor = getEditor(editorId);
// // // sessionContent("set", store.state.fileData.bid, editor.children);
// // if (editor == null) return;
// // editor.destroy();
// // removeEditor(editorId);
// });
</script>
<style lang="less" scoped>
.notez-markdown {
:deep(.md-divider) {
height: 45px;
}
.md {
border: 0;
height: calc(100vh - 65px);
}
:deep(.md-fullscreen){
height:initial;
}
:deep(.mk-cute-theme) {
color: initial;
}
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
上次更新: 2024/01/30, 00:35:17
- 02
- Node与GLIBC_2.27不兼容解决方案08-19
- 03
- Git清空本地文件跟踪缓存08-13