🎶 Sym - 一款用 Java 实现的现代化社区(论坛/BBS/社交网络/博客)平台

📕 思源笔记 - 一款桌面端笔记应用,支持 Windows、Mac 和 Linux

🎸 Solo - B3log 分布式社区的博客端节点,欢迎加入下一代社区网络

♏ Vditor - 一款浏览器端的 Markdown 编辑器

Vditor 一款浏览器端的 Markdown 编辑器,支持所见即所得、即时渲染(类似 Typora)和分屏预览模式

Vditor
下一代的 Markdown 编辑器,为未来而构建

npm bundle size



💡 简介

Vditor 是一款浏览器端的 Markdown 编辑器,支持所见即所得、即时渲染(类似 Typora)和分屏预览模式。它使用 TypeScript 实现,支持原生 JavaScript、Vue、React、Angular,提供桌面版

欢迎到 Vditor 官方讨论区了解更多。同时也欢迎关注 B3log 开源社区微信公众号 B3log开源

b3logos.png

📽️ 背景

我们在开发 Sym 的初期是直接使用 WYSIWYG 富文本编辑器的。那时候基于 HTML 的编辑器非常流行,项目中引用起来也很方便,也符合用户当时的使用习惯。

后来,Markdown 的崛起逐步改变了大家的排版方式。再加上我们其他几个项目都是面向程序员用户的,所以迁移到 md 上也是大势所趋。我们选择了 CodeMirror,这是一款优秀的编辑器,它对开发者提供了丰富的编程接口,对各种浏览器的兼容性也比较好。

再后来,随着我们项目业务需求方面的沉淀,使用 CodeMirror 有时候会感到比较“笨重”。比如要实现 @自动完成用户名列表、插入 Emoji、上传文件等就需要比较深入的二次开发,而这些业务需求恰恰是很多项目场景共有且必备的。

终于,我们决定开始在 Sym 中自己实现编辑器。随着几个版本的迭代,Sym 的编辑器也日趋成熟。在我们运营的社区黑客派上陆续有人问我们是否能将编辑器单独抽离出来提供给大家使用。与此同时,我们的前端主程 V 同学对于维护分散在各个项目中的编辑器也感到有点力不从心,外加对 TypeScript 的好感,所以就决定使用 ts 来实现一个全新的浏览器端 md 编辑器。

于是,Vditor 就这样诞生了。

✨ 特性

  • 支持三种编辑模式:所见即所得(wysiwyg)、即时渲染(ir)、分屏预览(sv)
  • 支持大纲、数学公式、脑图、图表、流程图、甘特图、时序图、五线谱、多媒体、语音阅读、标题锚点、代码高亮及复制、graphviz 渲染
  • 内置安全过滤、导出、图片懒加载、任务列表、at、多平台预览、多主题切换、复制到微信公众号功能
  • 实现 CommonMark 和 GFM 规范,可对 Markdown 进行格式化和语法树查看,并支持10+项配置
  • 工具栏包含 36+ 项操作,除支持扩展外还可对每一项中的快捷键、提示、提示位置、图标、点击事件、类名、子工具栏进行自定义
  • 表情自动补全,设置常用表情,支持表情自定义
  • 可使用拖拽、剪切板粘贴上传,显示实时上传进度,支持 CORS 跨域上传
  • 实时保存内容,防止意外丢失
  • 录音支持,用户可直接发布语音
  • 粘贴 HTML 自动转换为 Markdown,如粘贴中包含外链图片可通过指定接口上传到服务器
  • 支持主窗口大小拖拽、字符计数
  • 多主题支持,内置黑白绿三套主题
  • 多语言支持,内置中、英、韩文本地化
  • 支持主流浏览器,对移动端友好

editor.png

preview.png

🔮 编辑模式

所见即所得(WYSIWYG)

所见即所得模式对不熟悉 Markdown 的用户较为友好,熟悉 Markdown 的话也可以无缝使用。

vditor-wysiwyg

即时渲染(IR)

即时渲染模式对熟悉 Typora 的用户应该不会感到陌生,理论上这是最优雅的 Markdown 编辑方式。

vditor-ir

分屏预览(SV)

传统的分屏预览模式适合大屏下的 Markdown 编辑。

vditor-sv

🗃 案例

  • 🎶 Sym 一款用 Java 实现的现代化社区(论坛/BBS/社交网络/博客)平台
  • 🎸 Solo & 🎷 Pipe B3log 分布式社区的博客端节点,欢迎加入下一代社区网络
  • 📕 链滴笔记 一款桌面端笔记应用,支持 Windows、Mac 和 Linux
  • 🌟 Starfire 一个分布式的内容分享讨论社区,星星之火可以燎原
  • 📝 Arya 基于 Vue、Vditor,所构建的在线 Markdown 编辑器

🛠️ 使用文档

CommonJS

  • 安装依赖
npm install vditor --save
  • 在代码中引入并初始化对象,可参考 index.js
import Vditor from 'vditor'
import "~vditor/src/assets/scss/index"

const vditor = new Vditor(id, {options...})

HTML script

  • 在 HTML 中插入 CSS 和 JavaScript,可参考 demo
<!-- ⚠️生产环境请指定版本号,如 https://cdn.jsdelivr.net/npm/[email protected]/dist... -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vditor/dist/index.css" />
<script src="https://cdn.jsdelivr.net/npm/vditor/dist/index.min.js" defer></script>

示例代码

主题

  • 支持黑白两套主题:classic/dark
  • 参考现有样式后使用自己开发的 scss/css 进行样式的完全自定制
  • 可通过修改 index.scss 中的变量对主题颜色进行定制
  • 在内容显示元素上添加 class="vditor-reset" (经典主题) 或 class="vditor-reset vditor-reset--dark"(黑色主题) 属性可对内容进行更为友好的展示

API

id

可填入元素 id 或元素自身 HTMLElement

⚠️:当填入元素自身的 HTMLElement 时需设置 options.cache.id 或将 options.cache.enable 设置为 false

options

说明 默认值
after 编辑器异步渲染完成后的回调方法 -
height 编辑器总高度 'auto'
minHeight 编辑区域最小高度 -
width 编辑器总宽度,支持 % 'auto'
placeholder 输入区域为空时的提示 ''
lang 多语言:en_US, ko_KR, zh_CN 'zh_CN'
input 输入后触发 (value: string, previewElement?: HTMLElement): void -
focus 聚焦后触发 (value: string): void -
blur 失焦后触发 (value: string): void -
esc esc 按下后触发 (value: string): void -
ctrlEnter ⌘/ctrl+enter 按下后触发 (value: string): void -
select 编辑器中选中文字后触发 (value: string): void -
tab tab 键操作字符串,支持 \t 及任意字符串 -
typewriterMode 是否启用打字机模式 false
cdn 配置自建 CDN 地址 https://cdn.jsdelivr.net/npm/vditor@${VDITOR_VERSION}
mode 可选模式:sv, ir, wysiwyg 'wysiwyg'
debugger 是否显示日志 false
value 编辑器初始化值 ''
theme 主题:classic, dark 'classic'
outline 是否展现大纲 false

options.toolbar

  • 工具栏,可使用 name 进行简写: toolbar: ['emoji', 'br', 'bold', '|', 'line'] 。默认值参见 src/ts/util/Options.ts
  • name 可枚举为: emoji , headings , bold , italic , strike , | , line , quote , list , ordered-list , check ,outdent ,indent , code , inline-code , insert-after , insert-before ,undo , redo , upload , link , table , record , edit-mode , both , preview , format , fullscreen , outline , code-theme , content-theme , export, devtools , info , help , br
  • name 不在枚举中时,可以添加自定义按钮,格式如下:
{  
 hotkey: '⌘-⇧-f',  
 name: 'format',  
 tipPosition: 'ne',  
 tip: 'format',  
 className: '',
 icon: '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768"><path d="M342 426v-84h426v84h-426zM342 256v-86h426v86h-426zM0 0h768v86h-768v-86zM342 598v-86h426v86h-426zM0 214l170 170-170 170v-340zM0 768v-86h768v86h-768z"></path></svg>',  
 click: () => {  
   alert('custom toolbar')  
 },  
}
说明 默认值
name 唯一标示 -
icon svg 图标 -
tip 提示 -
tipPosition 提示位置:ne, nw -
hotkey 快捷键,格式为⌘/ctrl-key⌘/ctrl-⇧/shift-key -
suffix 插入编辑器中的后缀 -
prefix 插入编辑器中的前缀 -
click 自定义按钮点击时触发的事件 (): void -
className 样式名 ''
toolbar?: Array<options.toolbar> 子菜单 -

options.toolbarConfig

说明 默认值
hide 是否隐藏工具栏 false
pin 是否固定工具栏 false

options.counter

说明 默认值
enable 是否启用计数器 false
max 允许输入的最大值 -
type 统计类型:md,text 'md'

options.cache

说明 默认值
enable 是否使用 localStorage 进行缓存 true
id 缓存 key,第一个参数为元素且启用缓存时必填 -

options.preview

说明 默认值
delay 预览 debounce 毫秒间隔 1000
maxWidth 预览区域最大宽度 800
mode 显示模式:both, editor 'both'
url md 解析请求 -
parse 预览回调 (element: HTMLElement): void -
transform 渲染之前回调 (html: string): string -

options.preview.hljs

说明 默认值
enable 是否启用代码高亮 true
style 可选值参见Chroma github
lineNumber 是否启用行号 false

options.preview.markdown

说明 默认值
autoSpace 自动空格 false
fixTermTypo 自动矫正术语 false
chinesePunct 自动矫正标点 false
toc 插入目录 false
footnotes 脚注 true
codeBlockPreview wysiwyg 和 ir 模式下是否对代码块进行渲染 true
theme 内容主题:light, dark, wechat 'light'
setext 是否解析 setext 标题 true
paragraphBeginningSpace 段落开头空两个 false
sanitize 是否启用过滤 XSS true
listMarker 为列表添加 data-marker 属性 false

options.preview.math

说明 默认值
inlineDigit 内联数学公式起始 $ 后是否允许数字 false
macros 使用 MathJax 渲染时传入的宏定义 {}
engine 数学公式渲染引擎:KaTeX, MathJax 'KaTeX'

options.hint

说明 默认值
delay 提示 debounce 毫秒间隔 200
emoji 默认表情,可从lute/emoji_map 中选取,也可自定义 { '+1': '👍', '-1': '👎', 'heart': '❤️', 'cold_sweat': '😰' }
emojiTail 常用表情提示 -
emojiPath 表情图片地址 https://cdn.jsdelivr.net/npm/vditor@${VDITOR_VERSION}/dist/images/emoji
at @用户回调 (value: string): Array<any>,需同步返回数组 [{value: '', html: ''}] -

options.upload

  • 文件上传的数据结构如下。后端返回的数据结构不一致时,可使用 format 进行转换。
// POST data  
xhr.send(formData);  // formData = FormData.append("file[]", File)  
// return data  
{  
 "msg": "",  
 "code": 0,  
 "data": {  
 "errFiles": ['filename', 'filename2'],  
 "succMap": {  
   "filename3": "filepath3",  
   "filename3": "filepath3"  
   }  
 }  
}
  • 为了防止站外图片失效, linkToImgUrl 可将剪贴板中的站外图片地址传到服务器端进行保存处理,其数据结构如下:
// POST data  
xhr.send(JSON.stringify({url: src})); // src 为站外图片地址  
// return data  
{  
 msg: '',  
 code: 0,  
 data : {  
   originalURL: '',  
   url: ''  
 }  
}
  • successformaterror 不会同时触发,具体调用情况如下:
if (xhr.status === 200) {
    if (vditor.options.upload.success) {
        vditor.options.upload.success(editorElement, xhr.responseText);
    } else {
        let responseText = xhr.responseText;
        if (vditor.options.upload.format) {
            responseText = vditor.options.upload.format(files as File [], xhr.responseText);
        }
        genUploadedLabel(responseText, vditor);
    }
} else {
    if (vditor.options.upload.error) {
        vditor.options.upload.error(xhr.responseText);
    } else {
        vditor.tip.show(xhr.responseText);
    }
}
说明 默认值
url 上传 url ''
max 上传文件最大 Byte 10 * 1024 * 1024
linkToImgUrl 剪切板中包含图片地址时,使用此 url 重新上传 ''
success 上传成功回调 (editor: HTMLPreElement, msg: string): void -
error 上传失败回调 (msg: string): void -
token CORS 上传验证,头为 X-Upload-Token -
withCredentials 跨站点访问控制 false
headers 请求头设置 -
filename 文件名安全处理 (name: string): string| name => name.replace(/\W/g, '')
accept 文件上传类型,同input accept -
validate 校验,成功时返回 true 否则返回错误信息 (files: File[]) => string| boolean -
handler 自定义上传,当发生错误时返回错误信息 (files: File[]) => string| null -
format 对服务端返回的数据进行转换,以满足内置的数据结构 (files: File[], responseText: string): string -
file 将上传的文件处理后再返回 (files: File[]): File[] -
setHeaders 上传前使用返回值设置头 (): { [key: string]: string } -
extraData 为 FormData 添加额外的参数 { [key: string]: string Blob }

options.resize

说明 默认值
enable 是否支持大小拖拽 false
position 拖拽栏位置:top, bottom 'bottom'
after 拖拽结束的回调 (height: number): void -

options.classes

说明 默认值
preview 预览元素上的 className ''

options.keymap

说明 默认值
deleteLine 删除光标所在行或选中的行 '⌘-Backspace'
duplicate 复制当前行或选中的内容 '⌘-D'

methods

说明
getValue() 获取编辑器内容
getHTML() 获取预览区内容
insertValue(value: string, render = true) 在焦点处插入内容,并默认进行 Markdown 渲染
focus() 聚焦到编辑器
blur() 让编辑器失焦
disabled() 禁用编辑器
enable() 解除编辑器禁用
setSelection(start: number, end: number) 选中从 start 开始到 end 结束的字符串,不支持 wysiwyg 模式
getSelection(): string 返回选中的字符串
setValue(markdown: string) 设置编辑器内容
renderPreview(value?: string) 设置预览区域内容
getCursorPosition():{top: number, left: number} 获取焦点位置
deleteValue() 删除选中内容
updateValue(value: string) 更新选中内容
isUploading() 上传是否还在进行中
clearCache() 清除缓存
disabledCache() 禁用缓存
enableCache() 启用缓存
html2md(value: string) HTML 转 md
tip(text: string, time: number) 消息提示。time 为 0 将一直显示
setPreviewMode(mode: "both"| "editor") 设置预览模式
setTheme(theme: "dark"| "classic", contentTheme?: string, codeTheme?: string) 设置主题、内容主题及代码块风格
getCurrentMode(): string 获取编辑器当前编辑模式

static methods

  • 不需要进行编辑操作时,仅需引入 method.min.js 后如下直接调用
Vditor.mermaidRender(document)
import VditorPreview from 'vditor/dist/method.min'  
VditorPreview.mermaidRender(document)
  • 需要对页面中的 Markdown 进行渲染时可直接调用 preview 方法,参数如下:
previewElement: HTMLDivElement,   // 使用该元素进行渲染
markdown: string,  // 需要渲染的 markdown 原文
options?: IPreviewOptions {  
 anchor?: number;  // 为标题添加锚点 0:不渲染;1:渲染于标题前;2:渲染于标题后,默认 0
 customEmoji?: { [key: string]: string };    // 自定义 emoji,默认为 {}  
 lang?: (keyof II18nLang);    // 语言,默认为 'zh_CN'  
 emojiPath?: string;    // 表情图片路径 
 hljs?: IHljs; // 参见 options.preview.hljs 
 speech?: {  // 对选中后的内容进行阅读
  enable?: boolean,
 };
 math?: IMath; // 数学公式渲染配置
 cdn?: string; // 自建 CDN 地址
 transform?(html: string): string; // 在渲染前进行的回调方法
 after?(): void; // 渲染完成后的回调
 lazyLoadImage?: string; // 设置为 Loading 图片地址后将启用图片的懒加载
 markdown?: options.preview.markdown;
 renderers?: ILuteRender; // 自定义渲染 https://hacpai.com/article/1588412297062
}
  • ⚠️ method.min.jsindex.min.js 不可同时引入
说明
mermaidRender(element: HTMLElement, className = ".language-mermaid", cdn = options.cdn) 转换 element 中 class 为 className 的元素为流程图/时序图/甘特图
codeRender(element: HTMLElement, lang: (keyof II18nLang) = "zh_CN") 为 element 中的代码块添加复制按钮
chartRender(element: (HTMLElement| Document) = document, cdn = options.cdn) 图表渲染
mindmapRender(element: (HTMLElement| Document) = document, cdn = options.cdn) 脑图渲染
abcRender(element: (HTMLElement| Document) = document, cdn = options.cdn) 五线谱渲染
md2html(mdText: string, options?: IPreviewOptions): Promise<string> Markdown 文本转换为 HTML,该方法需使用异步编程
preview(previewElement: HTMLDivElement, markdown: string, options?: IPreviewOptions) 页面 Markdown 文章渲染
highlightRender(hljsOption?: IHljs, element?: HTMLElement| Document, cdn = options.cdn) 为 element 中的代码块进行高亮渲染
mediaRender(element: HTMLElement) 特定链接分别渲染为视频、音频、嵌入的 iframe
mathRender(element: HTMLElement, options?: {cdn?: string, math?: IMath}) 对数学公式进行渲染
speechRender(element: HTMLElement, lang?: (keyof II18nLang)) 对选中的文字进行阅读
graphvizRender(element: HTMLElement, cdn?: string) 对 graphviz 进行渲染
outlineRender(contentElement: HTMLElement, targetElement: Element) 对大纲进行渲染
lazyLoadImageRender(element: (HTMLElement| Document) = document) 对启用懒加载的图片进行渲染
setCodeTheme (codeTheme: string, cdn = options.cdn) 设置代码主题,codeTheme 参见 options.preview.hljs.style
setContentTheme (contentTheme: string, cdn = options.cdn) 设置内容主题,contentTheme 参见 options.preview.markdown.theme

🏗 开发文档

原理相关

环境

  1. 安装 node LTS 版本
  2. 下载最新代码并解压
  3. 根目录运行 npm install
  4. npm run start 启动本地服务器,打开 http://localhost:9000
  5. 修改代码
  6. npm run build 打包代码到 dist 目录

CDN 切换

由于使用了按需加载的机制,默认 CDN 为 https://cdn.jsdelivr.net/npm/vditor@版本号

如果代码有修改或需要使用自建 CDN 的话,可按以下步骤进行操作:

  • 初始化的 optionsIPreviewOptions 中需添加 cdn 配置
  • highlightRender , mathRender , abcRender , chartRender , mermaidRender 方法中需添加 cdn 参数
  • 将 build 成功的 dist 目录或 jsDelivr 中的 dist 目录拷贝至正确的位置

升级

版本升级时请仔细阅读 CHANGELOG 中的升级部分

Ⓜ️ Markdown 使用指南

🏘️ 社区

📄 授权

Vditor 使用 MIT 开源协议。

🙏 鸣谢

  • Lute:🎼 一款结构化的 Markdown 引擎,支持 Go 和 JavaScript
  • highlight.js:JavaScript syntax highlighter
  • mermaid:Generation of diagram and flowchart from text in a similar manner as Markdown
  • incubator-echarts:A powerful, interactive charting and visualization library for browser
  • abcjs:JavaScript library for rendering standard music notation in a browser

欢迎注册黑客派社区,开启你的博客之旅。让学习和分享成为一种习惯!

169 评论
zhaoyangkun • 2019-12-11
回复 删除

V 姐,在 HTML 面中指定版本号

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/index.min.js"></script>

会报错,markdown编辑器加载不了。
image.png

hellotomoro • 2019-12-11
回复 删除

换到1.10.8吧,路径打包没改过来吧

zhaoyangkun • 2019-12-12
回复 删除

Vanessa • 2019-12-12
回复 删除

@hellotomoro

为了更加方便的修改 cdn,做了修改,产生了 bug 现已修复,请更新到 1.10.10

haaid • 2019-12-13
回复 删除

上传图片按钮还是会触发 submit 事件

547176052 • 2019-12-19
回复 删除

怎么导出 md语法渲染完成的html

xhaoxiong • 2019-12-29
回复 删除

@Vanessa V姐这个有统计字数的接口嘛😋

xiaoyaoFreedom • 2019-12-30
回复 删除

你好,为什么我没改代码的情况下,编辑器突然不提示语法了呢?预览模式也无法默认生效,只能点击编辑器按钮才生效,而且界面和以前不一样了😭
image.png
image.png
image.png

XShellv • 2020-01-05
回复 删除

你好,react中父组件给子组件传递默认的markdown值时,vditor没有提供接受该默认值的属性么?文档中有个setValue方法,但是在子组件接受父组件传过来的值时调用该方法产生堆栈溢出的错误。请问我该怎么办?

XShellv • 2020-01-05
回复 删除

我的代码是这样的:

class MdEditor extends React.Component<Props, State> {
    vditor: any
    componentWillReceiveProps(nextProps: Props) {
        if (nextProps.value) {
            this.vditor.setValue(nextProps.value)
        }
    }
    componentDidMount() {
        this.vditor = new Vditor('vditor', this.vditorConfig.vditorOptions)
        this.vditor.setValue(this.props.value)
    }
    shouldComponentUpdate(nextProps: Props, nextState: State) {
        return false
    }
    render() {
        return (
            <div id="vditor"></div>
        )
    }
    triggerChange = (changedValue: any) => {
        const { onChange } = this.props;
        if (onChange) {
            onChange(changedValue);
        }
    };
}

buexplain • 2020-01-13
回复 删除

发现一个错误,请楼主帮忙看看。
版本:Vditor v2.0.15
报错:image.pngimage.png

q2484877 • 2020-01-16
回复 删除

在外面能否拿到上传文件的事件啊? 我想先拿到文件对象先进行压缩后再上传到七牛云中! 看了半天代码,貌似上传的事件不能在初始化的时候取到!

zhaoyangkun • 2020-02-02
回复 删除

@Vanessa V 姐,我在单页面 vue 中创建了两个 elementui 的弹窗组件,在两个弹窗中都载入了 vditor 组件,当先点击添加文章弹窗,然后再点击修改文章弹窗,修改文章弹窗中 vditor 组件没有渲染,该怎么破啊 😂 ?

zhicheng • 2020-02-11
回复 删除

一直不清楚 右侧的那个锚点列表是怎么生成的呀 @Vanessa

paulirish00 • 2020-02-26
回复 删除