它是什么
KEditor 是一个纯前端的微信公众号风格富文本编辑器。它不依赖任何后端,也不依赖任何运行时库:图片与视频走上传(整包 + 分片),代码块可选 20 种语言并语法高亮,Markdown 文件/文本可一键导入转成可编辑 HTML,随时能切到 HTML 源码模式查看或改写产物。
零构建可用
一个 <script> 标签即可,全局变量 KEditor。
上传两条链路
普通上传与分片上传各自独立配置,只配一条也不会被 mock 顶上。
Markdown 导入
选文件、拖文件或粘文本,实时预览后三种落点导入。
样式随 JS 注入,不需要额外引入 CSS。Vue 是可选 peerDependency —— 只用核心 API 的项目不会被强制安装 Vue。
安装
用包管理器,或者直接用 CDN。两条路都不需要构建步骤。
npm install kyle-editor
# 使用 Vue 组件的项目,再把 Vue 显式装上
npm install kyle-editor vue@^3
<!-- unpkg / jsdelivr 的默认入口就是压缩版,下面两种写法等价 -->
<script src="https://unpkg.com/kyle-editor"></script>
<script src="https://unpkg.com/kyle-editor/dist/keditor.umd.min.js"></script>
| 产物 | 用途 | 体积 |
|---|---|---|
keditor.umd.min.js | CDN / <script> 默认入口 | 192 KB / gzip 61 KB |
keditor.umd.js | 同上但未压缩,便于断点调试 | 300 KB / gzip 78 KB |
keditor.es.js | ESM,打包器走这个 | 288 KB |
keditor.cjs | CommonJS | 288 KB |
keditor-vue.*.js | Vue 组件 / 插件,外部化 vue 与本包 | 各约 4 KB |
两份 UMD 产物由同一入口分别构建,全局变量都是 window.KEditor,API 完全一致 —— 调试时换未压缩版不会改变行为。
快速开始
准备一个挂载点,然后创建实例。下面三种方式任选其一。
方式一:<script> 引入(零构建)
<div id="editor"></div>
<script src="https://unpkg.com/kyle-editor/dist/keditor.umd.min.js"></script>
<script>
const editor = KEditor.create('#editor', {
placeholder: '开始撰写内容,支持拖拽 / 粘贴图片与视频…',
// 高度默认随内容自动撑开;想要固定高度 + 内部滚动就写上 height: 460
})
editor.on('change', ({ html }) => console.log(html))
</script>
本地开发时把 src 换成相对路径 dist/keditor.umd.min.js 即可。
方式二:ESM / 打包器
import KEditor from 'kyle-editor'
const editor = KEditor.create('#editor') // 高度默认自适应内容
方式三:Vue 3 安装集成
import { createApp } from 'vue'
import KEditorVue from 'kyle-editor/vue'
createApp(App).use(KEditorVue).mount('#app')
<template>
<k-editor v-model="form.content" :options="options" :disabled="saving" @upload="onUpload" />
</template>
<script setup>
import { ref } from 'vue'
const form = ref({ content: '' })
const saving = ref(false)
const options = {
// 不写 height 就是自适应内容高度;写了则固定高度、内容超出时编辑区内部滚动
height: 420,
// 无后端时保持默认 'mock';上线时换成下面两行即可
uploader: '/api/upload', // 普通上传:整个文件一次请求
chunkUploader: '/api/chunk-upload', // 分片上传:大文件自动切片(≥ 5MB)
upload: { mode: 'auto', chunkSize: 5 * 1024 * 1024, concurrency: 4 },
}
const onUpload = (payload) => console.log(payload.stage, payload.percent)
</script>
kyle-editor/vue 的默认导出就是组件本身(自带 install),所以 app.use(KEditorVue) 后模板里用全局标签 <k-editor>,与直接 import KEditorVue 当组件用 <KEditorVue v-model="html" /> 两种写法等价。只想显式拿插件时用 import { KEditorPlugin }。
常用配置
下面这些是 90% 的场景会用到的。完整清单见 API 参考 · 配置项。
innerHTML'开始撰写内容…'0 / 'auto' = 随内容自动撑开;填 px 才固定高度并内部滚动'#07c160'(CSS 变量 --ke-primary)false 隐藏;分隔符 '|',右对齐标记 ':tail'editor.setReadOnly(bool)true60const editor = KEditor.create('#editor', {
value: '<p>初始内容</p>',
placeholder: '开始撰写内容…',
primaryColor: '#07c160',
height: 0, // 自适应内容高度
paperWidth: 677, // 公众号正文版心
wordCount: true,
uploader: '/api/upload',
chunkUploader: '/api/chunk-upload',
code: { defaultLanguage: 'javascript' },
markdown: { insertMode: 'replace' },
toolbar: [
'undo', 'redo', '|',
'heading', 'fontsize', '|',
'bold', 'italic', 'underline', 'strike', '|',
'foreColor', 'backColor', '|',
'alignLeft', 'alignCenter', 'alignRight', '|',
'ul', 'ol', 'quote', '|',
'link', 'image', 'video', 'table', 'code', 'hr', 'clear',
':tail', 'markdown', 'source', 'fullscreen',
],
onUpload: ({ stage, percent }) => console.log(stage, percent),
})
preview 不在默认工具栏里(默认尾栏是「导入MD / 源码 / 全屏」),但预览能力没变:用 editor.openPreview(),或者把 'preview' 写回 toolbar。
上传:整包与分片
上传分两条链路,互不影响,可以只配一条,也可以都配。
| 链路 | 适用 | 行为 |
|---|---|---|
普通上传uploader |
小文件(图片为主) | 整个文件一次请求发完,返回 url 即成功 |
分片上传chunkUploader |
大文件(视频为主) | 切片 → 并发 → 重试,只需一个接口,响应里 url 非空即成功 |
KEditor.create('#editor', {
uploader: '/api/upload', // 普通上传(整个文件一次请求)
chunkUploader: '/api/chunk-upload', // 分片上传(切片并发)
upload: {
mode: 'auto', // 'auto' 按体积自动选(默认)| 'normal' | 'chunk'
chunkThreshold: 5 * 1024 * 1024, // auto:达到该体积才走分片
chunkSize: 1024 * 1024, // 分片大小
concurrency: 4, // 并发分片数
retries: 2, // 单片失败重试次数
},
})
你配了什么,就是什么行为
| 你配了什么 | 实际行为 |
|---|---|
| 两条都配 | mode: 'auto':小于 chunkThreshold 走普通,否则走分片 |
只配 uploader | 全部走普通上传(分片不启用,不会静默用 mock) |
只配 chunkUploader | 全部走分片上传(mode 默认变 'chunk') |
| 两条都不配 | 默认两条都是 'mock':小文件普通、大文件分片(开箱演示) |
只配一条链路时,另一条不会被默认 mock 顶上 —— 否则真实后端只接了一半,另一半会静默产生 blob: 假地址。
接口约定
普通上传:整包 POST(multipart/form-data),请求字段 file(可用 fieldName 改)、filename、fileSize;响应 { "url": "https://cdn/a.png" },也支持 { code: 0, data: { url } }(用 parse 解开)。没返回 url 视为失败,占位块转错误态并提供「重试 / 移除」。
分片上传:每个分片都 POST 到 chunkUploader 这一个地址。请求字段 file(分片二进制)、identifier(同一文件恒定的指纹)、index(从 0 起)、total、filename、fileSize、chunkSize。只要某个响应里返回非空 url,就代表这个文件上传成功,编辑器立刻用它替换占位块,剩余分片不再上传 —— 服务端秒传、或收齐最后一片时返回都可以。
KEditor.create('#editor', {
chunkUploader: {
type: 'http',
url: '/api/chunk-upload',
fieldName: 'chunk', // 默认 'file'
headers: () => ({ Authorization: `Bearer ${getToken()}` }), // 传函数则每次请求实时取值
withCredentials: true,
parse: (json) => json.data, // 默认 json.data ?? json
pickUrl: (json) => json?.payload?.filePath || '', // 默认认 url / fileUrl / src
mode: 'formdata', // 默认 formdata;可换 'raw'
},
})
其他写法(两条链路通用)
uploader: '/api/upload' // 字符串地址
uploader: { type: 'http', url: '/api/upload' } // 显式写法
uploader: KEditor.httpAdapter({ url: '/api/upload' }) // 自己造适配器
uploader: KEditor.simpleAdapter(async (file) => myUpload(file)) // 函数包装
uploader: async (file) => (await myUpload(file)).url // 直接给函数也可以
uploader: 'mock' // 零后端,生成 blob: 地址
uploader: KEditor.mockAdapter({ speed: 8 }) // mock 可调速度 / 延迟 / 失败率
分片上传现在是单接口:httpAdapter 不再接受 initUrl / chunkUrl / completeUrl / abortUrl,配了会直接报错提示迁移。需要自定义 init / complete 流程时,自己实现适配器传给 chunkUploader。
进度、速度、取消、重试的完整适配器协议与后端实现建议,见仓库 docs/upload-adapter.md。
导入 Markdown
尾栏的 导入MD 按钮打开导入面板:选文件、拖文件,或直接粘文本,实时预览转换结果与结构统计,再选落点导入。把 .md / .markdown / .txt 文件拖进正文也能直接转换。
KEditor.create('#editor', {
markdown: {
insertMode: 'replace', // 默认落点:replace 替换全文 / append 追加文末 / cursor 插入光标
maxSize: 5 * 1024 * 1024, // 单文件上限(字节),0 = 不限制
gfm: true, // 表格 / 删除线 / 任务列表 / 裸链接
breaks: false, // true = 段落内单换行也当硬换行
linkTarget: false, // true 或 '_blank' = 外链自动加 target + rel
frontMatter: true, // 丢弃开头的 YAML front matter
},
})
await editor.importMarkdown(mdText) // 按 options.markdown.insertMode 落点
await editor.importMarkdown(file, { mode: 'append' }) // 临时指定落点
await editor.setMarkdown(mdText) // 替换全文
await editor.insertMarkdown(mdText) // 插入光标处
await editor.appendMarkdown(mdText) // 追加到文末
editor.markdownOptions() // { insertMode: 'replace', maxSize: 5242880, auto: true }
editor.on('markdownimport', ({ mode, name, summary }) => {
console.log(mode, name, summary)
})
导入返回值是 { ok, html, summary, mode, chars, name };只读模式返回 { ok: false, reason: 'readonly' },超限或读文件失败返回 { ok: false, reason: 'error' }。
支持的语法
| 类别 | 说明 |
|---|---|
| 标题 | # ~ ###### 与 setext(=== / ---) |
| 列表 | 有序 / 无序 / 嵌套 / 松散段落 / 任务列表(- [x] → ☑) |
| 表格 | GFM 表格,列对齐写入 style="text-align:…" |
| 代码块 | 围栅与缩进,语言保留并高亮(data-k-lang + class="language-x"),未知语言退化纯文本 |
| 行内 | 粗体 / 斜体 / 删除线 / 行内代码 / 转义 / 硬换行 / 引用式与自动链接 / 图片 |
| 其他 | 引用(含嵌套与懒续行)、分隔线、YAML front matter 丢弃、内联 HTML 与 HTML 块 |
导入是 GFM 子集:不支持脚注、目录(TOC)、数学公式与自定义容器(callout),这些语法会按普通段落渲染。导入是单次快照,不会与源 .md 保持双向同步。
文件编码先按严格 UTF-8 解码,失败回退 gb18030 / gbk / big5。相对路径图片()只保留路径文本,不会自动上传。
代码块与高亮
点工具栏「代码块」就是一个语言面板,20 种内置语言,支持按名称或别名搜索(js / mysql / yml)。光标已在某个代码块里时,同一个面板变成「代码块语言」,选一项即改写当前块并重新着色。
KEditor.create('#editor', {
code: {
languages: ['js', 'python', 'go', 'sql'], // 只留几种(主名或别名都认);不写 = 全部 20 种
defaultLanguage: 'python', // 新建代码块默认语言
highlight: true, // false = 只保留纯文本,语言标记照写
},
})
editor.insertCode('const a = 1', 'js') // 插入代码块(语言可省,用 defaultLanguage)
editor.setCodeLanguage('sql') // 改写光标所在块的语言
editor.getCodeLanguage() // 不在块内返回 ''
editor.codeLanguages() // [{ value, label, hint }, …]
editor.getState().inCode // 光标是否在代码块内
语言同时写在 <pre data-k-lang="javascript"> 与 <code class="language-javascript"> 上;外部 HTML 只带 class="language-xxx" 也能识别并补上标记。
高亮只是编辑/预览时的显示效果:getHTML()、源码模式、复制出去的内容都是不带 token <span> 的干净代码,再次导入会自动重新高亮。单块超过 60 KB 退化为纯文本,避免长文卡顿。
版心与高度
高度:默认自适应
不配置 height 时,内容区随内容自动撑开(整页滚动,工具栏尽量吸附视口顶部);配置了才固定高度,内容超出时在编辑区内部滚动。
KEditor.create('#editor', {}) // 自适应:默认行为
KEditor.create('#editor', { height: 460 }) // 固定 460px,内部滚动
KEditor.create('#editor', { minHeight: 200 }) // 只控最小高度
// 运行时切换
editor.setHeight(600) // 固定 600px
editor.setHeight(0) // 回到自适应(也接受 'auto')
editor.heightOptions() // { height: 0, auto: true }
版心:宽屏不浪费
宽屏下如果版心固定 677px,编辑器两侧会出现大片空白。默认采用 fluid 版心:版心不小于 paperWidth,宽屏时随容器生长,两侧留白恒定为 paperGap。
KEditor.create('#editor', {
paperWidth: 677, // 版心基准宽(公众号);fluid 模式下它是最小宽度
paperMode: 'fluid', // 'fluid'(默认)| 'fixed'(恒定 paperWidth + 64)
paperGap: 24, // fluid 模式下单侧留白
paperMaxWidth: 0, // fluid 模式下的版心上限,0 = 不限
})
// 运行时调整(侧栏折叠、布局变化时)
editor.setPaper({ mode: 'fixed' })
editor.setPaper({ gap: 40, maxWidth: 1000 })
editor.paperOptions() // { width, mode, gap, maxWidth }
| 需求 | 配置 |
|---|---|
| 永远像公众号那样居中一栏 | paperMode: 'fixed' |
| 宽屏也想多写点字 | 默认 fluid(不限上限) |
| 宽屏但可读性优先 | fluid + paperMaxWidth: 1000 |
自定义按钮
不想改源码就往工具栏加业务按钮。典型场景:插入公司模板、关联栏目、跳转后台页面。
const editor = KEditor.create('#editor', {
customButtons: [
// ① 数据从接口读取
{
name: 'column',
text: '栏目',
title: '插入栏目链接',
searchable: true, // 面板内搜索
url: '/api/columns', // 内部 fetch,自动识别 items/list/data/rows/records
link: true, // 用每项自带的地址(url / href / link 字段)
insert: 'link',
position: { after: 'link' }, // 放到「链接」之后
},
// ② 数据静态配置
{
name: 'tpl',
text: '模板',
options: [
{ value: 'opening', label: '开场白', desc: '问候 + 本期导读', group: '通用' },
{ value: 'ending', label: '结束语', desc: '引导关注与转发', group: '通用' },
],
link: { url: 'https://mp.weixin.qq.com/s/tpl-{value}', text: '【{label}】', target: '_blank' },
},
],
})
editor.on('customselect', ({ name, item, link, action }) => {
console.log(name, item.label, '→', link?.url, '(插入方式:' + action + ')')
})
position 可以把它放到工具栏任意位置:'start' 最前、'tail' 右侧吸附尾栏(与「导入MD / 源码 / 全屏」同区)、数字索引、或者 { before } / { after } 锚点。锚点找不到会自动回退,不报错、不丢按钮。
运行时也能增删移位:addButton / moveButton / removeButton / setButtonOptions / getButtonValue / setButtonValue。完整字段说明见 API 参考 · 自定义工具栏按钮。
保存与回填
取内容用 getHTML(),回填用 value 或 setContent()。默认取到的是干净产物:编辑器内部属性与未完成占位块都被剔除,也没有代码高亮的 token <span>。
const editor = KEditor.create('#editor', {
value: await fetchDraft(), // 回填:初始 HTML
// 高度自适应,不用管
})
let timer = null
editor.on('change', () => {
clearTimeout(timer) // change 已内置 160ms 防抖,这里再做一次幂等
timer = setTimeout(async () => {
await fetch('/api/draft', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: editor.getHTML() }),
})
}, 1200)
})
editor.on('save', () => saveNow()) // ⌘/Ctrl + S 触发
| 方法 | 说明 |
|---|---|
getHTML() | 取干净 HTML(默认剔除内部属性与未完成占位块) |
getHTML({ clean: 'preview' }) | 保留代码高亮,用于预览渲染 |
getHTML({ clean: false }) | 原始 HTML,仅调试用 |
getText() / getJSON() | 纯文本 / {html, text, counts} |
setContent(html, { sanitize?, silent? }) | 覆盖内容 |
counts() | {chars, words, images, videos, uploading} |
默认上传适配器产生的 blob: 地址仅当前会话有效,正式环境请配置真实上传接口。入库前建议再跑一次 sanitizeHtml。
安全
所有进入编辑器的外部 HTML(value、setContent、粘贴)都会经过白名单净化:
- 标签与属性双白名单,未知标签直接丢弃
on*事件属性一律剔除javascript:/vbscript:/file:/ 非法data:协议拦截style声明逐条过滤(丢弃expression、behavior等)- Markdown 转换产物同样再过一遍白名单,
<script>会被清掉或转义
getHTML({ clean: false }) 会返回含编辑器内部属性的原始 HTML,请仅在调试时使用。